- 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작 - 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks - 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감 - 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
139 lines
5.1 KiB
Python
139 lines
5.1 KiB
Python
"""측점 평면 틀 거울 — 파이썬(`B05_Profile_Engine_Sections_Core`) ↔ TS(`_RouteEdit_Frame.ts`).
|
|
|
|
짝: `_interpolate_xy`·`_tangent_at`(측점 중심 · 좌측 축) ↔ `stationFrameAt`.
|
|
계획노선 편집 창이 노선을 끄는 동안 그리는 횡단 반폭 선이 [확인] 뒤 서버가 낸 측점 틀과
|
|
같은 자리 · 같은 방향이어야 함.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
|
from config.config_system import SECTION_LONG_SAMPLE_INTERVAL_M # noqa: E402
|
|
|
|
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
|
|
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
_TOLERANCE = 1e-9
|
|
|
|
|
|
def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list:
|
|
return [
|
|
[cx + radius * math.cos(angle), cy + radius * math.sin(angle)]
|
|
for angle in np.linspace(start, end, count)
|
|
]
|
|
|
|
|
|
# 직선 · 꺾임 · 원호 · 겹친 정점 · 되꺾임 · 시종점 · 노선 밖 누가거리
|
|
CASES = [
|
|
{"line": [[0, 0], [100, 0]], "chainages": [0, 20, 40, 100]},
|
|
{"line": [[0, 0], [40, 0], [40, 0], [40, 30], [10, 70]], "chainages": [0, 20, 40, 60, 99.9]},
|
|
{"line": _arc(500, 500, 30, 0.2, 2.9, 41), "chainages": [0, 13.7, 20, 40, 60, 80]},
|
|
{"line": [[10, 10], [60, 12], [15, 14], [70, 40]], "chainages": [20, 50.5, 51, 120, 999]},
|
|
{"line": [[0, 0], [0.4, 0.3], [1.0, 0.1]], "chainages": [0, 0.5, 1.2]},
|
|
]
|
|
|
|
_RUNNER = """
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import {
|
|
stationFrameAt,
|
|
SECTION_LONG_SAMPLE_INTERVAL_M,
|
|
} from "./B05_Profile_UI_RouteEdit_Frame.js";
|
|
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
const out = {
|
|
interval: SECTION_LONG_SAMPLE_INTERVAL_M,
|
|
frames: cases.map((item) => item.chainages.map((c) => stationFrameAt(item.line, c))),
|
|
};
|
|
writeFileSync(process.argv[3], JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def _python_frame(line: list, chainage: float) -> dict:
|
|
points, route_chainage = core._clean_polyline(np.asarray(line, dtype=np.float64))
|
|
total = float(route_chainage[-1])
|
|
target = min(float(chainage), total)
|
|
origin = core._interpolate_xy(points, route_chainage, np.array([chainage]))[0]
|
|
tangent = core._tangent_at(
|
|
points, route_chainage, target, max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)
|
|
)
|
|
return {"origin": [float(origin[0]), float(origin[1])], "left": [-tangent[1], tangent[0]]}
|
|
|
|
|
|
def _ts_frames(tmp_path: Path) -> dict:
|
|
out = tmp_path / "js"
|
|
subprocess.run( # noqa: S603 — 고정 실행 파일
|
|
[
|
|
"node",
|
|
str(TSC),
|
|
str(TS_FILE),
|
|
"--outDir",
|
|
str(out),
|
|
"--module",
|
|
"esnext",
|
|
"--target",
|
|
"es2022",
|
|
"--moduleResolution",
|
|
"bundler",
|
|
"--ignoreConfig",
|
|
],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
for emitted in out.glob("*.js"):
|
|
emitted.write_text(
|
|
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
|
encoding="utf-8",
|
|
)
|
|
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
|
payload = tmp_path / "input.json"
|
|
result = tmp_path / "output.json"
|
|
payload.write_text(json.dumps(CASES), encoding="utf-8")
|
|
subprocess.run( # noqa: S603
|
|
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
|
cwd=str(ROOT),
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return json.loads(result.read_text(encoding="utf-8"))
|
|
|
|
|
|
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
|
def test_측점_틀이_두_쪽에서_같다(tmp_path: Path) -> None:
|
|
actual = _ts_frames(tmp_path)
|
|
assert actual["interval"] == SECTION_LONG_SAMPLE_INTERVAL_M
|
|
problems: list[str] = []
|
|
checked = 0
|
|
for index, case in enumerate(CASES):
|
|
for chainage, frame in zip(case["chainages"], actual["frames"][index], strict=True):
|
|
# 노선 밖 누가거리는 파이썬이 끝값으로 붙인다 — TS 도 같아야 함.
|
|
expected = _python_frame(case["line"], chainage)
|
|
assert frame is not None, f"사례 {index} · {chainage}m: TS 가 틀을 못 냄"
|
|
for key in ("origin", "left"):
|
|
for axis in (0, 1):
|
|
if abs(frame[key][axis] - expected[key][axis]) > _TOLERANCE:
|
|
problems.append(
|
|
f"사례 {index} · {chainage}m · {key}[{axis}]: "
|
|
f"{frame[key][axis]!r} vs {expected[key][axis]!r}"
|
|
)
|
|
checked += 1
|
|
assert checked >= 20
|
|
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
|
|
|
|
|
|
def test_짝_머리글이_서로를_가리킨다() -> None:
|
|
assert "B05_Profile_Engine_Sections_Core.py" in TS_FILE.read_text(encoding="utf-8")
|
|
assert "B05_Profile_UI_RouteEdit_Frame.ts" in (core.__doc__ or "")
|