feat(344-2): 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 계획고 옮김 짝

- 첫 계획선(계획고 = 지반고) base_pvi TS 짝 = B05_Profile/logic/B05_Profile_Grade_Ground.ts(1순위만 · 못 서는 땅은 null = 서버로 다시 셈)
- 관 자리 투영 TS 짝 · 구조물 따라감 py · ts 짝 = common_util_route_follow(정수 m · 구간 끝 밂 · 0 ~ 연장 자름 · 20 m 넘으면 멀어짐 표시 · 상수 STRUCTURE_ROUTE_DRIFT_MAX_M)
- 구조물 x · y 저장(저장 때 노선 위 자리 · 자리가 그대로면 옛 좌표 지킴 · 옛 자료는 읽을 때 채움) · 노선 [확인] 때 구조물 정본이 좌표를 따라감
- 노선 바뀜 때 계획고 옮김 = 바뀐 구간만 새 지반 첫 계획선 · 앞 그대로 · 뒤 길이 차만큼 밂(B05_Profile_Engine_Grade_Carry.py ↔ logic/B05_Profile_Grade_Carry.ts)
- 거울 시험 test_344_2_route_pairs(+ helper cjs) · buildCurves · evaluateAt 내보냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-16 11:19:54 +09:00
co-authored by Claude Opus 5.5
parent 73a251db26
commit bb63b30aae
14 changed files with 1137 additions and 3 deletions
@@ -0,0 +1,54 @@
/* 344-2 노선 짝 거울 — 진짜 TS(`logic/B05_Profile_Grade_Ground` · `logic/B05_Profile_Grade_Carry` ·
* `common_util_route_follow`)를 Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const alignment = loadTs("B05_Profile/B05_Profile_UI_Profile_Alignment.ts");
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
"../B05_Profile_UI_Profile_Alignment": alignment,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"../B05_Profile_UI_Profile_Alignment": alignment,
});
const catalog = require("./helper_322_catalog_module.cjs");
const station = loadTs("common_util/common_util_structure_station.ts", {
"./common_util_structure_catalog": catalog,
});
const follow = loadTs("common_util/common_util_route_follow.ts", {
"./common_util_structure_station": station,
});
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = {
ground: input.ground.map((item) => ground.groundFollowingBasePvi(item)),
carry: input.carry.map((item) => {
const span = carry.changedSpan(item.old_xy, item.new_xy);
return {
span,
result: span
? carry.carryProfileEdits(item.base_pvi, item.edits, span, item.ground_pvi)
: null,
};
}),
pipes: input.pipes.map((item) => follow.projectPipePoints(item.points, item.line)),
structures: input.structures.map((item) => follow.followStructures(item.items, item.line)),
driftMax: follow.STRUCTURE_ROUTE_DRIFT_MAX_M,
};
process.stdout.write(JSON.stringify(out));
+402
View File
@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
"""344-2 실시간 바탕 짝 — 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 노선 바뀜 때 계획고 옮김.
거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값):
① 첫 계획선 base_pvi — `design_ground_following_profile` ↔ `logic/B05_Profile_Grade_Ground.ts`
(1순위만 · 못 서는 땅이면 TS 는 null = 「서버로 다시 셈」).
② 관 자리 투영 — `common_util_drainage_pipes.project_pipe_points` ↔
`common_util_route_follow.ts` `projectPipePoints`.
③ 구조물 따라감 — `common_util_route_follow.follow_structures` ↔ `followStructures`(20 m 상수 같음).
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`.
서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감.
"""
from __future__ import annotations
import json
import math
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
carry_profile_edits,
changed_span,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
)
from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402
from common_util.common_util_route_follow import ( # noqa: E402
fill_structure_coordinates,
follow_structures,
)
from common_util.common_util_route_geometry import RouteVertex # noqa: E402
from config.config_system import STRUCTURE_ROUTE_DRIFT_MAX_M # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
# ── 입력 만들기 ───────────────────────────────────────────────────────────────
def _longitudinal(total: float, *, wave: float, holes: tuple[int, ...] = ()) -> dict:
samples = []
for index in range(int(total) + 1):
s = float(min(index, total))
z = 100.0 + 0.06 * s + 4.0 * math.sin(s / wave) + 1.5 * math.cos(s / 7.3)
samples.append({"chainage_m": s, "elevation_m": z, "valid": index not in holes})
if samples[-1]["chainage_m"] < total:
samples.append({"chainage_m": total, "elevation_m": 100.0 + 0.06 * total, "valid": True})
stations = [float(s) for s in range(0, int(total) + 1, 20)] + [47.3, 133.0, total]
return {"samples": samples, "stations": [{"chainage_m": s} for s in sorted(set(stations))]}
def _ground_case(total: float, wave: float, stored: dict | None = None, paved=False) -> dict:
longitudinal = _longitudinal(total, wave=wave, holes=(3, 4, 90))
options = resolve_grade_options("trunk", terrain_type="normal", paved=paved, stored=stored)
alignment, _ = design_ground_following_profile(longitudinal, options)
return {
"py": alignment["base_pvi"],
"ts_input": {
"samples": longitudinal["samples"],
"stations": [row["chainage_m"] for row in longitudinal["stations"]],
"policy": alignment["policy"],
"startOffsetM": options.start_elevation_offset_m,
"endOffsetM": options.end_elevation_offset_m,
},
}
GROUND_CASES = [
_ground_case(200.0, 11.0),
_ground_case(279.8, 23.0, {"start_elevation_offset_m": 0.4, "end_elevation_offset_m": -0.3}),
_ground_case(160.4, 5.0, paved=True),
]
GROUND_FAIL = {
"samples": [{"chainage_m": 0.0, "elevation_m": 100.0}, {"chainage_m": 50.0, "valid": False}],
"stations": [0.0, 50.0],
"policy": GROUND_CASES[0]["ts_input"]["policy"],
}
OLD_ROUTE = [(0.0, 0.0), (40.0, 5.0), (80.0, 0.0), (120.0, 10.0), (160.0, 5.0), (200.0, 0.0)]
L_OLD = round(sum(math.dist(a, b) for a, b in zip(OLD_ROUTE, OLD_ROUTE[1:])), 3)
def _carry_case(new_route: list[tuple[float, float]]) -> dict:
total = sum(math.dist(a, b) for a, b in zip(new_route, new_route[1:]))
longitudinal = _longitudinal(total, wave=13.0)
options = resolve_grade_options("trunk", terrain_type="normal")
alignment, _ = design_ground_following_profile(longitudinal, options)
base_pvi = [
{"chainage_m": s, "elevation_m": 200.0 + s / 10.0}
for s in (0.0, 20.0, 40.0, 60.0, 90.5, 120.0, 150.0, 180.0, L_OLD)
]
edits = {
"station_offsets": {"20.000": 0.5, "90.500": -0.3, "180.000": 0.2, f"{L_OLD:.3f}": 0.1},
"curve_radii": {"40.000": 300.0, "150.000": 250.0},
}
return {
"old_xy": [{"x": x, "y": y} for x, y in OLD_ROUTE],
"new_xy": [{"x": x, "y": y} for x, y in new_route],
"base_pvi": base_pvi,
"edits": edits,
"ground_pvi": alignment["base_pvi"],
}
CARRY_CASES = [
# 가운데 노드(80, 0) 옮김 — 앞(0 ~ 40) 그대로 · 뒤(120 ~) 밂
_carry_case(
[(0.0, 0.0), (40.0, 5.0), (85.0, -12.0), (120.0, 10.0), (160.0, 5.0), (200.0, 0.0)]
),
# 끝 노드 옮김(꼬리 없음)
_carry_case([(0.0, 0.0), (40.0, 5.0), (80.0, 0.0), (120.0, 10.0), (160.0, 5.0), (215.0, 9.0)]),
# 앞 노드 옮김(앞머리 없음) · 점 하나 더함
_carry_case(
[
(-6.0, 4.0),
(20.0, 6.0),
(40.0, 5.0),
(80.0, 0.0),
(120.0, 10.0),
(160.0, 5.0),
(200.0, 0.0),
]
),
# 같음
_carry_case(OLD_ROUTE),
]
NEW_LINE = [(0.0, 0.0), (50.0, 0.0), (50.0, 50.0), (120.0, 60.0)]
PIPES = [
{"chainage_m": 10.0, "x": 12.3, "y": 1.1, "start_m": 8.0, "end_m": 14.0},
{"chainage_m": 70.0, "x": 48.0, "y": 25.0},
{"chainage_m": 5.0}, # 좌표 없음 — 누가거리 그대로
{"chainage_m": 90.0, "x": 55.0, "y": -5.0, "start_m": 88.0}, # 모서리 둘 사이
{"chainage_m": 150.0, "x": 119.5, "y": 61.0, "end_m": 152.0}, # 끝 밖
]
STRUCTURES = [
{"chainage_m": 20.0, "x": 30.0, "y": 2.0},
{
"chainage_m": 3.0,
"start_m": 0.0,
"end_m": 12.0,
"x": 1.0,
"y": 0.5,
}, # 앞으로 밀면 0 에서 자름
{"chainage_m": 100.0, "x": 80.0, "y": 90.0}, # 노선에서 멀어짐(>20 m)
{"chainage_m": 40.0}, # 좌표 없음
]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s344") / "in.json"
source.write_text(
json.dumps(
{
"ground": [case["ts_input"] for case in GROUND_CASES] + [GROUND_FAIL],
"carry": CARRY_CASES,
"pipes": [{"points": PIPES, "line": [{"x": x, "y": y} for x, y in NEW_LINE]}],
"structures": [
{"items": STRUCTURES, "line": [{"x": x, "y": y} for x, y in NEW_LINE]}
],
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_344_2_route_pairs.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _close(a: float, b: float, tol: float = 1e-6) -> bool:
return abs(float(a) - float(b)) <= tol
# ── ① 첫 계획선 ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("index", range(len(GROUND_CASES)))
def test_첫_계획선_거울(ts, index):
py, got = GROUND_CASES[index]["py"], ts["ground"][index]
assert len(got) == len(py)
for a, b in zip(py, got):
assert _close(a["chainage_m"], b["chainage_m"]) and _close(
a["elevation_m"], b["elevation_m"]
)
def test_첫_계획선은_측점에서_지반고(ts):
case = GROUND_CASES[0]
assert len(case["py"]) >= 13 # 모든 측점이 꼭짓점(호 보정으로 꼭짓점 표고는 지반에서 뜸)
def test_못_서는_땅은_서버로(ts):
assert ts["ground"][-1] is None
longitudinal = {"samples": GROUND_FAIL["samples"], "stations": []}
with pytest.raises(ValueError):
design_ground_following_profile(longitudinal, resolve_grade_options("trunk"))
# ── ② 관 자리 투영 ────────────────────────────────────────────────────────────
def _vertices(line):
out, walked = [], 0.0
for index, (x, y) in enumerate(line):
if index:
walked += math.dist(line[index - 1], (x, y))
out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked))
return out
def test_관_자리_투영_거울(ts):
points = [
PipePoint(
chainage_m=p["chainage_m"],
x=p.get("x"),
y=p.get("y"),
start_m=p.get("start_m"),
end_m=p.get("end_m"),
)
for p in PIPES
]
py = project_pipe_points(points, _vertices(NEW_LINE))
got = ts["pipes"][0]
assert [p.chainage_m for p in py] == [g["chainage_m"] for g in got]
for p, g in zip(py, got):
assert p.start_m == g.get("start_m") and p.end_m == g.get("end_m")
assert (p.x, p.y) == (g.get("x"), g.get("y"))
# (55, -5) 은 두 선분에서 같은 거리 — 앞 선분 끝(50) · 끝 밖은 연장 안 정수(170)
assert [g["chainage_m"] for g in got] == [5.0, 12.0, 50.0, 75.0, 170.0]
# ── ③ 구조물 따라감 ───────────────────────────────────────────────────────────
def test_구조물_따라감_거울(ts):
items = [SimpleNamespace(**{"start_m": None, "end_m": None, **s}) for s in STRUCTURES]
for item in items:
item.x, item.y = getattr(item, "x", None), getattr(item, "y", None)
py = follow_structures(items, NEW_LINE)
got = ts["structures"][0]
for item, (drift, far), entry in zip(items, py, got):
moved = entry["item"]
assert item.chainage_m == moved["chainage_m"]
assert item.start_m == moved.get("start_m") and item.end_m == moved.get("end_m")
assert (drift is None) == (entry["drift_m"] is None)
if drift is not None:
assert _close(drift, entry["drift_m"], 1e-9)
assert far == entry["far"]
assert [entry["far"] for entry in got] == [False, False, True, False]
assert got[1]["item"]["start_m"] == 0.0 # 앞으로 밀려도 0 에서 자름
assert got[3]["item"]["chainage_m"] == 40.0 # 좌표 없는 옛 자료 그대로
def test_멀어짐_거리_상수_한_벌(ts):
assert ts["driftMax"] == STRUCTURE_ROUTE_DRIFT_MAX_M == 20.0
def test_저장_때_좌표_채움_자리가_그대로면_옛_좌표():
line = [(0.0, 0.0), (100.0, 0.0)]
kept = SimpleNamespace(structure_id="a", chainage_m=30.0, start_m=None, end_m=None, x=31, y=9)
moved = SimpleNamespace(structure_id="b", chainage_m=50.0, start_m=None, end_m=None, x=1, y=1)
fresh = SimpleNamespace(
structure_id=None, chainage_m=70.0, start_m=None, end_m=None, x=None, y=None
)
previous = {
"a": SimpleNamespace(chainage_m=30.0, start_m=None, end_m=None, x=31, y=9),
"b": SimpleNamespace(chainage_m=40.0, start_m=None, end_m=None, x=1, y=1),
}
fill_structure_coordinates([kept, moved, fresh], line, previous=previous)
assert (kept.x, kept.y) == (31, 9) # 따라간 땅 자리(노선 밖) 그대로
assert (moved.x, moved.y) == (50.0, 0.0) # 사용자가 옮김 → 노선 위 새 자리
assert (fresh.x, fresh.y) == (70.0, 0.0)
def test_읽을_때는_없는_것만():
line = [(0.0, 0.0), (100.0, 0.0)]
old = SimpleNamespace(chainage_m=30.0, x=None, y=None)
has = SimpleNamespace(chainage_m=60.0, x=5.0, y=5.0)
assert fill_structure_coordinates([old, has], line, only_missing=True) == 1
assert (old.x, old.y) == (30.0, 0.0) and (has.x, has.y) == (5.0, 5.0)
def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch):
route = tmp_path / "route.geojson"
route.write_text(
json.dumps({"geometry": {"coordinates": [[0, 0, 0], [100, 0, 0]]}}), encoding="utf-8"
)
items = [
SimpleNamespace(chainage_m=30.0, start_m=None, end_m=None, x=None, y=None), # 옛 자료
SimpleNamespace(chainage_m=60.0, start_m=55.0, end_m=65.0, x=60.0, y=0.0),
]
saved: dict = {}
monkeypatch.setattr(structures_router, "load_structures", lambda root: (4, items))
monkeypatch.setattr(
structures_router,
"save_structures",
lambda root, values, *, base_revision: saved.update(values=values, revision=base_revision),
)
# 새 노선 = 앞쪽 10 m 늘림(같은 땅 자리는 누가거리 +10)
new_xy = [{"x": -10.0, "y": 0.0}, {"x": 100.0, "y": 0.0}]
result = structures_router.follow_structures_to_route(tmp_path, "route.geojson", new_xy)
assert result == {"moved": 2, "far": 0}
assert saved["revision"] == 4
assert [(i.chainage_m, i.start_m, i.end_m) for i in items] == [
(40.0, None, None),
(70.0, 65.0, 75.0),
]
assert (items[0].x, items[0].y) == (30.0, 0.0) # 옛 노선으로 채운 땅 자리
# ── ④ 계획고 옮김 ─────────────────────────────────────────────────────────────
def _py_carry(case):
span = changed_span(
[(p["x"], p["y"]) for p in case["old_xy"]], [(p["x"], p["y"]) for p in case["new_xy"]]
)
result = (
carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"])
if span
else None
)
return span, result
@pytest.mark.parametrize("index", range(len(CARRY_CASES)))
def test_계획고_옮김_거울(ts, index):
span, result = _py_carry(CARRY_CASES[index])
got = ts["carry"][index]
if span is None:
assert got["span"] is None and got["result"] is None
return
for key, value in span.items():
assert (
(value == got["span"][key])
if isinstance(value, bool)
else _close(value, got["span"][key], 1e-9)
)
base_pvi, edits = result
ts_result = got["result"]
assert len(base_pvi) == len(ts_result["basePvi"])
for a, b in zip(base_pvi, ts_result["basePvi"]):
assert _close(a["chainage_m"], b["chainage_m"]) and _close(
a["elevation_m"], b["elevation_m"]
)
assert edits == ts_result["edits"]
def test_가운데를_고치면_앞은_그대로_뒤는_밂():
span, (base_pvi, edits) = _py_carry(CARRY_CASES[0])
assert span["start_fixed"] and span["end_fixed"]
delta = span["delta_m"]
assert span["start_m"] == pytest.approx(math.dist(OLD_ROUTE[0], OLD_ROUTE[1]))
# 앞 편집 그대로 · 안(90.5) 버림 · 뒤(180 · 207.6) 길이 차만큼
assert edits["station_offsets"]["20.000"] == 0.5
assert "90.500" not in edits["station_offsets"]
assert edits["station_offsets"][f"{180.0 + delta:.3f}"] == 0.2
assert edits["curve_radii"] == {"40.000": 300.0, f"{150.0 + delta:.3f}": 250.0}
old = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["base_pvi"]}
got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi}
assert got[20.0] == old[20.0] and got[round(180.0 + delta, 3)] == old[180.0]
# 바뀐 구간 안은 새 지반 첫 계획선 꼭짓점
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["ground_pvi"]}
inside = [s for s in got if span["start_m"] + 1e-6 < s < span["new_end_m"] - 1e-6]
assert inside and all(got[s] == ground[s] for s in inside)
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + delta, abs=1e-3)
def test_끝을_고치면_끝_꼭짓점은_새_지반():
span, (base_pvi, edits) = _py_carry(CARRY_CASES[1])
assert not span["end_fixed"]
assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림
assert edits["station_offsets"]["90.500"] == -0.3 # 앞(바뀐 구간 앞) 편집 그대로
ground_end = CARRY_CASES[1]["ground_pvi"][-1]
assert base_pvi[-1] == {
"chainage_m": round(ground_end["chainage_m"], 6),
"elevation_m": ground_end["elevation_m"],
}