"""353-1 절/성토선 매끄럽게 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 (`B07_DesignDetail_Engine_Cad_PlanSlope.py`) 거울 + 측점 폭 = 횡단 값 · 꺾임 각 상한 · 접힘 버림 · 깎기↔쌓기 바뀜 자리 = 도로 끝. ./venv/Scripts/python.exe -m pytest resources/tester/test_353_1_cut_fill_line_smooth.py -q """ from __future__ import annotations import json import math import subprocess import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 route_normal, slope_pieces, ) TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" TOL = 2e-4 RADIUS = 20.0 #: 꺾임 각 상한(°) — 이웃 선분 사이 방향 차 MAX_TURN_DEG = 10.0 def _route() -> list[tuple[float, float, float]]: """+x 직선 50 m → 좌회전 90° 원호(R 20 · 5° 편) → +y 직선 40 m.""" points = [(0.0, 0.0)] points += [ (50.0 + RADIUS * math.sin(math.radians(a)), RADIUS - RADIUS * math.cos(math.radians(a))) for a in range(0, 91, 5) ] points.append((70.0, 60.0)) route, chain = [], 0.0 for index, (x, y) in enumerate(points): if index: step = math.hypot(x - points[index - 1][0], y - points[index - 1][1]) if step < 1e-9: continue chain += step route.append((chain, x, y)) return route def _records() -> list[dict]: """20 m 측점 — 좌(곡선 안쪽) 비탈 폭이 곡선에서 R 보다 커져 접힘 · 우는 깎기↔쌓기 번갈아.""" route = _route() total = route[-1][0] records = [] for i in range(int(total // 10) + 1): chainage = 10.0 * i x, y, nx, ny = route_normal(route, chainage) inside_curve = 50.0 <= chainage <= 50.0 + RADIUS * math.pi / 2 records.append( { "chainage_m": chainage, "left_edge_m": 2.5, "left_end_m": 2.5 + (22.0 if inside_curve else 3.0 + 2.0 * math.sin(chainage / 7)), "right_edge_m": -2.5 - 0.3 * (i % 2), "right_end_m": -6.0 - 2.0 * math.cos(chainage / 11), "left_cut": True, "right_cut": (i // 2) % 2 == 0, "x": x, "y": y, "nx": nx, "ny": ny, } ) return records DRIVER = """ import { readFileSync, writeFileSync } from "node:fs"; import { slopePieces } from "./B05_Profile_PlanFootprint.js"; const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, c.start, c.end))); """ @pytest.fixture(scope="module") def pieces() -> dict: route = _route() edge, fill, cut, ticks = slope_pieces(route, _records(), 0.0, route[-1][0]) return { "edge": edge["lines"], "fill": fill["lines"], "cut": cut["lines"], "ticks": ticks["ticks"], } @pytest.fixture(scope="module") def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: out = tmp_path_factory.mktemp("ts_353_1") subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", "--noResolve"], cwd=str(ROOT), check=True, capture_output=True, ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") route = _route() payload = {"route": route, "records": _records(), "start": 0.0, "end": route[-1][0]} (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") run = subprocess.run( # noqa: S603 ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], capture_output=True, text=True, cwd=str(out), ) # fmt: skip assert run.returncode == 0, run.stderr return json.loads((out / "out.json").read_text(encoding="utf-8")) def test_두_쪽이_같다(pieces, ts_pieces) -> None: for name in ("edge", "fill", "cut", "ticks"): want, got = pieces[name], ts_pieces[name] assert len(want) == len(got), name for line_w, line_g in zip(want, got): assert len(line_w) == len(line_g), name for pw, pg in zip(line_w, line_g): assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, (name, pw, pg) def _nearest(lines: list, point: tuple[float, float]) -> float: return min(math.hypot(p[0] - point[0], p[1] - point[1]) for line in lines for p in line) def test_측점_폭은_횡단_값_그대로(pieces) -> None: left_edge, right_edge = pieces["edge"] ends = pieces["fill"] + pieces["cut"] for record in _records(): x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] for key, lines in (("left_edge_m", [left_edge]), ("right_edge_m", [right_edge]), ("right_end_m", ends)): # fmt: skip value = record[key] assert _nearest(lines, (x + nx * value, y + ny * value)) < 1e-3, (record, key) if not 50.0 <= record["chainage_m"] <= 50.0 + RADIUS * math.pi / 2: # 접히지 않는 자리 value = record["left_end_m"] assert _nearest(ends, (x + nx * value, y + ny * value)) < 1e-3, record def _turns(line: list) -> list[float]: turns = [] for a, b, c in zip(line, line[1:], line[2:]): u = (b[0] - a[0], b[1] - a[1]) v = (c[0] - b[0], c[1] - b[1]) turns.append( abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) ) return turns def _right_runs(pieces: dict) -> list: """우측 비탈 끝 조각 — 좌측은 늘 깎기라 깎기 첫 조각이 좌측 한 줄.""" return pieces["fill"] + pieces["cut"][1:] def test_꺾임_각_상한(pieces) -> None: # 도로 끝 · 곡선 바깥(우) 비탈 끝 — 원호 5° 편 위에서도 톱니 없이 고름 for line in [*pieces["edge"], *_right_runs(pieces)]: assert max(_turns(line), default=0.0) < MAX_TURN_DEG def _crosses(p1, p2, p3, p4) -> bool: def side(a, b, c) -> float: return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) return side(p1, p2, p3) * side(p1, p2, p4) < 0 and side(p3, p4, p1) * side(p3, p4, p2) < 0 def test_안쪽_접힘은_버림(pieces) -> None: left_end = pieces["cut"][0] segments = list(zip(left_end, left_end[1:])) for i, (a, b) in enumerate(segments): # 스스로 꼬이지 않음 for c, d in segments[i + 2 :]: assert not _crosses(a, b, c, d), (a, b, c, d) def test_깎기_쌓기_바뀜_자리는_도로_끝(pieces) -> None: right_edge = pieces["edge"][1] runs = _right_runs(pieces) assert len(runs) >= 4 ends = sorted((p for line in runs for p in (line[0], line[-1])), key=lambda p: p[0] + p[1]) for point in ends[1:-1]: # 시점 · 종점 빼고 조각 끝 = 바뀜 자리 assert _nearest([right_edge], point) < 0.05, point def test_단추_이름은_절성토선() -> None: chrome = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8") at = chrome.index('data-act="footprint"') assert ">절/성토선" in chrome[at : at + 200] and ">발자국<" not in chrome