"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장). `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. ⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math from bisect import bisect_left, bisect_right from typing import Any from config.config_system import DRAWING_SCALE_PLAN #: 비탈 눈금 간격(1/1,200 실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 · 다른 축척은 종이 간격이 #: 같게(1/6,000 = 10 m · 141-2) TICK_STEP_M = 2.0 def tick_step_m(scale: int = DRAWING_SCALE_PLAN) -> float: """비탈 눈금 간격(실거리 m) — 종이 간격(1/1,200 의 2 m) × 축척.""" return TICK_STEP_M * scale / DRAWING_SCALE_PLAN # ── 노선 따라가기 ───────────────────────────────────── 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 _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], ) #: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°) SPLIT_TURN_DEG = 2.0 #: 나눔 깊이 상한(2 m → 12.5 cm) SPLIT_DEPTH = 4 #: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값 NORMAL_PROBE_M = 1.0 def route_normal( route: list[tuple[float, float, float]], chainage: float ) -> tuple[float, float, float, float]: """노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식).""" x, y, nx, ny = route_frame(route, chainage) first, last = route[0][0], route[-1][0] before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M) if after - before <= 1e-9: before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3) x0, y0, _nx, _ny = route_frame(route, before) x1, y1, _nx, _ny = route_frame(route, after) length = math.hypot(x1 - x0, y1 - y0) if length <= 1e-9: return x, y, nx, ny return x, y, -(y1 - y0) / length, (x1 - x0) / length def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]: """단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다.""" n = len(xs) if n < 2: return [0.0] * n h = [xs[i + 1] - xs[i] for i in range(n - 1)] delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)] slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]] for k in range(1, n - 1): if delta[k - 1] * delta[k] > 0: w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1] slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]) return slopes def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float: """꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값.""" xs, ys, slopes = knots if x <= xs[0]: return ys[0] if x >= xs[-1]: return ys[-1] i = bisect_right(xs, x) - 1 h = xs[i + 1] - xs[i] t = (x - xs[i]) / h return ( (1 + 2 * t) * (1 - t) ** 2 * ys[i] + t * (1 - t) ** 2 * h * slopes[i] + t * t * (3 - 2 * t) * ys[i + 1] + t * t * (t - 1) * h * slopes[i + 1] ) def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]: return xs, ys, _pchip_slopes(xs, ys) def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]: """한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임).""" chainages = [record["chainage_m"] for record in records] edges = [record[f"{side}_edge_m"] for record in records] widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records] flags = [bool(record[f"{side}_cut"]) for record in records] switches: list[float] = [] width_x, width_y = [chainages[0]], [widths[0]] for i in range(1, len(records)): c0, c1 = chainages[i - 1], chainages[i] w0, w1 = abs(widths[i - 1]), abs(widths[i]) switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5) switches.append(switch) if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9: width_x.append(switch) width_y.append(0.0) width_x.append(c1) width_y.append(widths[i]) return { "chainages": chainages, "flags": flags, "switches": switches, "edge": _knots(chainages, edges), "width": _knots(width_x, width_y), } def _cut_at(curves: dict[str, Any], chainage: float) -> bool: """그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐).""" flags = curves["flags"] i = bisect_right(curves["chainages"], chainage) if i == 0: return flags[0] if i == len(flags): return flags[-1] if flags[i - 1] == flags[i]: return flags[i] return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i] def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]: """진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호.""" kept = [0] if points else [] for i in range(1, len(points)): last = points[kept[-1]] _x, _y, nx, ny = frames[i] if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6: kept.append(i) return kept def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1]) return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) #: 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°) ROUND_TURN_DEG = 15.0 #: 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 #: 좁으면 그대로 ROUND_M = 3.0 ROUND_MIN_M = 0.5 #: 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한 ROUND_STEP_DEG = 5.0 ROUND_PASSES = 200 def _dist(a: list[float], b: list[float]) -> float: return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) def _round_corners( points: list[list[float]], flags: list[bool], pins: list[bool] ) -> tuple[list[list[float]], list[bool], list[int]]: """가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈. 측점(pins) · 깎기↔쌓기 바뀜 점 · 양 끝은 그대로 둔다. 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). """ pts, fl, src = [list(p) for p in points], list(flags), list(range(len(points))) def pinned(i: int) -> bool: return src[i] >= 0 and pins[src[i]] def fixed(i: int) -> bool: return i == 0 or i == len(pts) - 1 or fl[i] != fl[i - 1] or pinned(i) def walk(i: int, step: int, length: float) -> tuple[int, list[float], float, list[float]]: """i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지). 돌려줌 = (남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향 — i 쪽을 향함). """ gone, j = 0.0, i while True: k = j + step seg = _dist(pts[j], pts[k]) toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]] if gone + seg >= length: ratio = (length - gone) / seg at = [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio] return k, at, length, toward if fixed(k): # 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림 return k, pts[k], (gone + seg) / 2.0 if pinned(k) else gone + seg, toward gone, j = gone + seg, k def control(a: list[float], ta: list[float], b: list[float], tb: list[float], reach: float): """두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 None.""" den = ta[0] * tb[1] - ta[1] * tb[0] if abs(den) < 1e-12: return None s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den c = [a[0] + ta[0] * s, a[1] + ta[1] * s] if s <= 0 or u <= 0 or _dist(a, c) > 2 * reach or _dist(b, c) > 2 * reach: return None return c for _ in range(ROUND_PASSES): worst, at, reach = ROUND_TURN_DEG, -1, 0.0 for i in range(1, len(pts) - 1): if fixed(i): continue turn = _turn_deg(pts[i - 1], pts[i], pts[i + 1]) if turn > worst: room = min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]) if room >= ROUND_MIN_M: worst, at, reach = turn, i, room if at < 0: break before, a, _gone, ta = walk(at, -1, reach) after, b, _gone, tb = walk(at, 1, reach) p = control(a, ta, b, tb, reach) or pts[at] count = max(2, math.ceil(worst / ROUND_STEP_DEG)) curve = [] for n in range(count + 1): t = n / count point = [ round((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4) for k in (0, 1) ] if _dist(point, pts[before]) > 1e-6 and _dist(point, pts[after]) > 1e-6: curve.append(point) pts[before + 1 : after] = curve fl[before + 1 : after] = [fl[at]] * len(curve) src[before + 1 : after] = [-1] * len(curve) return pts, fl, src def _densify( chainages: list[float], frame_of: Any, place: Any ) -> tuple[list[float], list[tuple[float, float, float, float]]]: """곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔.""" def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]: if depth >= SPLIT_DEPTH: return [] middle = (c0 + c1) / 2.0 pm = place(middle, frame_of(middle)) # 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3) q1 = place((c0 + middle) / 2.0, frame_of((c0 + middle) / 2.0)) q3 = place((middle + c1) / 2.0, frame_of((middle + c1) / 2.0)) triples = [*zip(p0, pm, p1), *zip(p0, q1, pm), *zip(pm, q3, p1)] # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in triples): return [] return ( split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) ) points = [place(chainage, frame_of(chainage)) for chainage in chainages] out = chainages[:1] for i in range(1, len(chainages)): out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0) out.append(chainages[i]) return out, [frame_of(chainage) for chainage in out] def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], start_m: float, end_m: float, step_m: float = TICK_STEP_M, ) -> 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)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). 버린 자리 · 잔 갈지자에 남은 비탈 끝 모서리는 측점을 두고 둥글린다(364-3). 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ sorted_records = sorted( ( {**record, **{f"{s}_cut": 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 sorted_records: return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} # 같은 누가거리 측점은 뒤 것 하나만 records = [] for record in sorted_records: if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9: records[-1] = record else: records.append(record) sides = ("left", "right") keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") curves = {side: _side_curves(records, side) for side in sides} by_chainage = {round(record["chainage_m"], 3): record for record in records} base = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: base.add(round(step, 3)) step += step_m switches = { at for side in sides for i, at in enumerate(curves[side]["switches"]) if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m } def frame_of(chainage: float) -> tuple[float, float, float, float]: record = by_chainage.get(round(chainage, 3)) if record and all(k in record for k in ("x", "y", "nx", "ny")): return record["x"], record["y"], record["nx"], record["ny"] return route_normal(route, chainage) def values_at(chainage: float) -> dict[str, float]: values: dict[str, float] = {} for side in sides: edge = _pchip(curves[side]["edge"], chainage) values[f"{side}_edge_m"] = edge values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage) return values def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]: x, y, nx, ny = frame values = values_at(chainage) return [[x + nx * values[key], y + ny * values[key]] for key in keys] chainages, frames = _densify(sorted(base | switches), frame_of, place) offsets = [values_at(chainage) for chainage in chainages] raw: dict[str, list[list[float]]] = {key: [] for key in keys} for (x, y, nx, ny), values in zip(frames, offsets): for key in keys: raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)]) kept = {key: _forward_only(raw[key], frames) for key in keys} lines = {key: [raw[key][i] for i in kept[key]] for key in keys} cut = { side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides } kept_sets = {key: set(kept[key]) for key in keys} for side in sides: key = f"{side}_end_m" pins = [round(chainages[i], 3) in by_chainage for i in kept[key]] lines[key], cut[side], src = _round_corners(lines[key], cut[side], pins) kept_sets[key] = {kept[key][j] for j in src if j >= 0} # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 # 선다(납품 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌. ticks: list[list[list[float]]] = [] position = {chainage: i for i, chainage in enumerate(chainages)} for index, chainage in enumerate(sorted(base)): i = position[chainage] x, y, nx, ny = frames[i] for side in sides: edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"] if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] for side in sides: 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}, )