Files
Aislo/resources/tester/test_353_1_cut_fill_line_smooth.py
T
eomsangdonandClaude Opus 5.5 e1b1c79249 feat(평면): 353-1 [발자국] → [절/성토선] · 선 매끄럽게 — 단조 3차 보간 · ±1 m 법선 · 곡선 촘촘 · 접힘 버림 · 바뀜 자리 도로 끝
- 측점 오프셋 = 측점 값 그대로 지나는 단조 3차(PCHIP) · 비탈은 폭으로 보간
- 사이 점 법선 = 측점 횡단 틀과 같은 앞뒤 ±1 m 식(route_normal · routeNormal 새 함수 · route_frame 그대로)
- 네 선 중 하나라도 가운데서 2° 넘게 꺾이는 사이를 반씩 나눔(깊이 4 · 2 m → 12.5 cm)
- 진행 거스르는 점 버림(안쪽 비탈 끝 접힘) · 그 자리 눈금 뺌 · 눈금은 측점 · 2 m 자리만
- 깎기↔쌓기 바뀜 자리 = 폭 0(비탈 끝이 도로 끝으로 모임) · 바뀜 점은 앞뒤 조각이 나눠 가짐
- TS ↔ 파이썬(B07 계획평면도) 짝 · 거울 시험 test_353_1 · 옛 계산은 꺾임 · 접힘 · 바뀜 자리 시험 셋 모두 떨어짐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 20:40:01 +09:00

200 lines
7.4 KiB
Python

"""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 ">절/성토선</button>" in chrome[at : at + 200] and ">발자국<" not in chrome