- 측점 오프셋 = 측점 값 그대로 지나는 단조 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
200 lines
7.4 KiB
Python
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
|