Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
389 lines
15 KiB
Python
389 lines
15 KiB
Python
"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅].
|
|
|
|
짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` ·
|
|
`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` ·
|
|
`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운
|
|
하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로.
|
|
"""
|
|
|
|
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
|
|
|
|
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
|
|
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
|
|
|
GRID = [float(value) for value in range(0, 1001, 20)]
|
|
|
|
|
|
def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]:
|
|
return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)]
|
|
|
|
|
|
# ── 파이썬 규칙 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_곡선_없음_그대로() -> None:
|
|
assert _snap([]) == GRID
|
|
|
|
|
|
def test_측점_없는_호는_안_심음() -> None:
|
|
assert _snap([(621.0, 639.0)]) == GRID
|
|
|
|
|
|
def test_호_안_하나를_정수_MC_로() -> None:
|
|
out = _snap([(612.0, 634.0)]) # MC 623
|
|
assert 623.0 in out and 620.0 not in out and len(out) == len(GRID)
|
|
|
|
|
|
def test_호_안_둘이면_MC_에_가까운_하나만() -> None:
|
|
out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움
|
|
assert 631.0 in out and 640.0 not in out and 620.0 in out
|
|
|
|
|
|
def test_BC_위_측점은_호_밖() -> None:
|
|
assert _snap([(620.0, 630.0)]) == GRID
|
|
assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖
|
|
|
|
|
|
def test_반_올림() -> None:
|
|
out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖
|
|
assert 626.0 in out and 620.0 not in out and 640.0 in out
|
|
|
|
|
|
def test_같은_m_는_하나로() -> None:
|
|
# 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음
|
|
assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0]
|
|
|
|
|
|
# ── 파이썬 ↔ TS 거울 ─────────────────────────────────────────────────────────
|
|
|
|
|
|
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)
|
|
]
|
|
|
|
|
|
def _bend_route() -> tuple[list, list]:
|
|
"""직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선."""
|
|
arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60)
|
|
line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]]
|
|
curves = [
|
|
{"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0},
|
|
{"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0},
|
|
{"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0},
|
|
{"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None},
|
|
]
|
|
return line, curves
|
|
|
|
|
|
CASES = [
|
|
{"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []},
|
|
{
|
|
"line": [[0, 0], [1000.3, 0]],
|
|
"interval": 20,
|
|
"end": True,
|
|
"curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}],
|
|
},
|
|
{
|
|
"line": [[0, 0], [500, 0], [500, 0], [700, 10]],
|
|
"interval": 20,
|
|
"end": False,
|
|
"curves": [
|
|
{"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25},
|
|
{"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25},
|
|
{"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25},
|
|
],
|
|
},
|
|
{"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]},
|
|
{"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]},
|
|
]
|
|
|
|
_RUNNER = """
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js";
|
|
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
const out = {
|
|
tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M,
|
|
edge: frame.CURVE_STATION_EDGE_M,
|
|
rows: cases.map((item) => {
|
|
const grid = frame.stationsAlong(item.line, item.interval, item.end);
|
|
const spans = frame.curveSpans(item.line, item.curves);
|
|
return { grid, spans, snapped: frame.snapCurveStations(grid, spans) };
|
|
}),
|
|
};
|
|
writeFileSync(process.argv[3], JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def _python_row(case: dict) -> dict:
|
|
points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64))
|
|
grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"])
|
|
spans = core._design_curve_spans(points, route_chainage, case["curves"])
|
|
return {
|
|
"grid": [float(v) for v in grid],
|
|
"spans": [[span[0], span[1]] for span in spans],
|
|
"snapped": [float(v) for v in core.snap_curve_stations(grid, spans)],
|
|
}
|
|
|
|
|
|
def _ts_rows(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_rows(tmp_path)
|
|
assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M
|
|
assert actual["edge"] == core.CURVE_STATION_EDGE_M
|
|
moved = 0
|
|
for index, case in enumerate(CASES):
|
|
expected = _python_row(case)
|
|
row = actual["rows"][index]
|
|
assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자"
|
|
assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수"
|
|
for got, want in zip(row["spans"], expected["spans"], strict=True):
|
|
assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선"
|
|
assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅"
|
|
moved += row["snapped"] != row["grid"]
|
|
assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌
|
|
|
|
|
|
def test_짝_머리글이_서로를_가리킨다() -> None:
|
|
text = TS_FILE.read_text(encoding="utf-8")
|
|
assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text
|
|
assert "snapCurveStations" in (core.__doc__ or "")
|
|
|
|
|
|
# ── generate_sections ────────────────────────────────────────────────────────
|
|
|
|
|
|
class _Flat:
|
|
def sample_xy(self, xy):
|
|
count = len(np.asarray(xy, dtype=np.float64))
|
|
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
|
|
|
|
|
_LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]]
|
|
_CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}]
|
|
|
|
|
|
def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]:
|
|
kwargs = {} if snap is None else {"station_snap": snap}
|
|
result = core.generate_sections(
|
|
_LINE,
|
|
_Flat(),
|
|
core.SectionGenerationOptions(extra_stations=extras),
|
|
design_curves=_CURVES,
|
|
**kwargs,
|
|
)
|
|
return result["longitudinal"]["stations"]
|
|
|
|
|
|
def test_끔은_지금과_같음() -> None:
|
|
assert _stations(False) == _stations(None)
|
|
assert 640.0 in [s["chainage_m"] for s in _stations(False)]
|
|
|
|
|
|
def test_켬은_640_없고_631_있음() -> None:
|
|
by_chainage = {s["chainage_m"]: s for s in _stations(True)}
|
|
assert 640.0 not in by_chainage and 631.0 in by_chainage
|
|
assert by_chainage[631.0]["kind"] == "regular"
|
|
assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경
|
|
assert len(by_chainage) == len(_stations(False))
|
|
|
|
|
|
def test_631_에_관이면_grid() -> None:
|
|
by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))}
|
|
assert by_chainage[631.0]["kind"] == "irregular"
|
|
assert by_chainage[631.0]["grid"] is True
|
|
# 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점
|
|
off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))}
|
|
assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0]
|
|
|
|
|
|
# ── 서버 저장 · 정본 (326-2) ─────────────────────────────────────────────────
|
|
|
|
_NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)]
|
|
|
|
|
|
def _working(tmp_path: Path) -> Path:
|
|
from common_util.common_util_route_geometry import planned_route_working_path
|
|
|
|
path = planned_route_working_path(tmp_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None:
|
|
from B05_Profile import B05_Profile_Router_Replan as replan
|
|
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
|
|
|
path = _working(tmp_path)
|
|
assert load_station_snap(tmp_path) is False # 파일 없음
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
|
assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8"))
|
|
assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트)
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
|
|
assert load_station_snap(tmp_path) is True
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False)
|
|
assert load_station_snap(tmp_path) is False
|
|
|
|
|
|
def _patch_replan(monkeypatch, tmp_path: Path):
|
|
from B05_Profile import B05_Profile_Router_Replan as replan
|
|
|
|
async def paths(_project_id):
|
|
return tmp_path, "stored"
|
|
|
|
async def criteria(_project_id):
|
|
return 30.0, 0.0, 0.0
|
|
|
|
monkeypatch.setattr(replan, "_project_paths", paths)
|
|
monkeypatch.setattr(replan, "_plan_criteria", criteria)
|
|
return replan
|
|
|
|
|
|
def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None:
|
|
import asyncio
|
|
from uuid import uuid4
|
|
|
|
replan = _patch_replan(monkeypatch, tmp_path)
|
|
|
|
async def broken(*_args, **_kwargs):
|
|
return {"error": "체인 끊김"}
|
|
|
|
monkeypatch.setattr(replan, "_recompute", broken)
|
|
path = _working(tmp_path)
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
|
files = (path, replan._curves_path(path), replan._nodes_path(path))
|
|
before = {file: file.read_bytes() for file in files}
|
|
request = replan.RouteReplanRequest(
|
|
vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}],
|
|
station_snap=True,
|
|
)
|
|
response = asyncio.run(replan.replan_route(uuid4(), request))
|
|
assert response.status_code == 409
|
|
assert {file: file.read_bytes() for file in files} == before
|
|
|
|
|
|
def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None:
|
|
import asyncio
|
|
from uuid import uuid4
|
|
|
|
replan = _patch_replan(monkeypatch, tmp_path)
|
|
path = _working(tmp_path)
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
|
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True)
|
|
assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True
|
|
|
|
|
|
def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None:
|
|
from B05_Profile import B05_Profile_Engine_Sections as sections
|
|
from B05_Profile import B05_Profile_Router_Replan as replan
|
|
|
|
path = _working(tmp_path)
|
|
replan._write_planned_polyline(path, _NODES, 30.0, simplify=False)
|
|
replan._curves_path(path).write_text(
|
|
json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8"
|
|
)
|
|
monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE)
|
|
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat())
|
|
# [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리
|
|
stations, _ = sections.sample_structure_sections(
|
|
tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),)
|
|
)
|
|
assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True
|
|
|
|
seen: dict = {}
|
|
|
|
def stop(*_args, **kwargs):
|
|
seen.update(kwargs)
|
|
raise RuntimeError("여기까지")
|
|
|
|
monkeypatch.setattr(sections, "generate_sections", stop)
|
|
monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: [])
|
|
monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ())
|
|
with pytest.raises(RuntimeError):
|
|
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
|
|
assert seen["station_snap"] is True
|
|
|
|
|
|
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
|
|
from B05_Profile import B05_Profile_Router_Terrain as terrain
|
|
from common_util.common_util_route_polyline import build_planned_polyline
|
|
|
|
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
|
|
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
|
|
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
|
|
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
|
|
assert len(spans) == 1
|
|
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
|
|
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
|
|
|
|
def ask(snap: bool) -> float:
|
|
request = terrain.CrossPreviewRequest(
|
|
vertices=[{"x": x, "y": y} for x, y in _NODES],
|
|
chainage_m=target,
|
|
min_radius_m=40.0,
|
|
station_interval_m=20.0,
|
|
station_snap=snap,
|
|
)
|
|
params = {"source_filter": "f", "method": "m", "smooth": False}
|
|
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
|
|
|
|
assert ask(True) == target
|
|
assert ask(False) % 20 == 0
|