"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. """ import math from bisect import bisect_left from typing import Any TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 # ── 노선 따라가기 ───────────────────────────────────── def route_frame( route: list[tuple[float, float, float]], chainage: float ) -> tuple[float, float, float, float]: """노선 (누가거리, x, y) 위 그 누가거리 자리와 좌측 법선 (x, y, nx, ny).""" chainages = [item[0] for item in route] index = min(max(bisect_left(chainages, chainage), 1), len(route) - 1) (c0, x0, y0), (c1, x1, y1) = route[index - 1], route[index] dx, dy = x1 - x0, y1 - y0 length = math.hypot(dx, dy) or 1.0 ratio = (chainage - c0) / (c1 - c0) if c1 > c0 else 0.0 return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: chainages = [record["chainage_m"] for record in records] index = bisect_left(chainages, chainage) if index <= 0: return records[0][key] if index >= len(records): return records[-1][key] before, after = records[index - 1], records[index] span = after["chainage_m"] - before["chainage_m"] ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 return before[key] + (after[key] - before[key]) * ratio def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: return sorted( (float(item["offset_m"]), float(item["elevation_m"])) for item in samples if item.get("valid", True) and isinstance(item.get("elevation_m"), (int, float)) ) def _ground_at(ground: list[tuple[float, float]], offset: float) -> float: offsets = [x for x, _z in ground] index = min(max(bisect_left(offsets, offset), 1), len(ground) - 1) (x0, z0), (x1, z1) = ground[index - 1], ground[index] return z0 + (z1 - z0) * ((offset - x0) / (x1 - x0) if x1 > x0 else 0.0) def side_is_cut(samples: list[dict[str, Any]], edge: float, elevation: float) -> bool: """노견 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 절토다(낮으면 성토).""" ground = _ground(samples) return len(ground) >= 2 and _ground_at(ground, edge) > elevation def slope_end( design_line: list[dict[str, Any]], samples: list[dict[str, Any]], edge: float, side: str ) -> float: """노견 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 못 닿으면 설계선 끝. 설계선(B06 횡단 설계 결과)은 비탈이 지반과 만난 뒤로 지반을 따라간다 — 그 첫 자리가 끝이다. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. """ ground = _ground(samples) points = sorted((float(p["offset_m"]), float(p["elevation_m"])) for p in design_line) outward = ( [p for p in points if p[0] > edge] if side == "left" else [p for p in points[::-1] if p[0] < edge] ) if not ground: return outward[-1][0] if outward else edge end, differs = edge, False for offset, elevation in outward: if abs(elevation - _ground_at(ground, offset)) <= 1e-3: return offset if differs else end differs, end = True, offset return end def _split_runs( points: list[list[float]], flags: list[bool] ) -> tuple[list[list[list[float]]], list[list[list[float]]]]: """한 선을 참 · 거짓 구간으로 끊는다 — 바뀌는 자리 점은 앞뒤 구간이 나눠 가져 이어진다.""" runs: dict[bool, list[list[list[float]]]] = {True: [], False: []} current: list[list[float]] = [] previous: bool | None = None for point, flag in zip(points, flags): if flag != previous: if current: current.append(point) current = [point] runs[flag].append(current) previous = flag else: current.append(point) return ( [run for run in runs[True] if len(run) >= 2], [run for run in runs[False] if len(run) >= 2], ) def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], start_m: float, end_m: float, ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: """측점별 노견 끝 · 비탈 끝 오프셋에서 노견선 · 성토 외곽선 · 절토 외곽선 · 비탈 눈금을 만든다. `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ records = sorted( ( { **record, **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, } for record in records ), key=lambda record: record["chainage_m"], ) if len(route) < 2 or not records: return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") by_chainage = {round(record["chainage_m"], 3): record for record in records} samples = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: samples.add(round(step, 3)) step += TICK_STEP_M lines: dict[str, list[list[float]]] = {key: [] for key in keys} cut: dict[str, list[bool]] = {"left": [], "right": []} ticks: list[list[list[float]]] = [] for index, chainage in enumerate(sorted(samples)): record = by_chainage.get(chainage) if record and all(k in record for k in ("x", "y", "nx", "ny")): x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] else: x, y, nx, ny = route_frame(route, chainage) offsets = {key: _interp(records, chainage, key) for key in keys} points = { key: [round(x + nx * value, 4), round(y + ny * value, 4)] for key, value in offsets.items() } for key in keys: lines[key].append(points[key]) for side in cut: cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). for side in ("left", "right"): edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] if abs(end - edge) < 0.05: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] for side in cut: cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) cut_lines += cut_runs fill_lines += fill_runs return ( {"lines": [lines["left_edge_m"], lines["right_edge_m"]]}, {"lines": fill_lines}, {"lines": cut_lines}, {"ticks": ticks}, )