Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_1

This commit is contained in:
2026-10-17 22:07:09 +09:00
14 changed files with 1047 additions and 211 deletions
+11 -1
View File
@@ -46,7 +46,17 @@ const out = {
return {
span,
result: span
? carry.carryProfileEdits(item.base_pvi, item.edits, span, item.ground_pvi)
? {
basePvi: carry.carryBase(item.old_xy, item.new_xy, item.base_pvi, item.ground_pvi),
edits: carry.carryEdits(
item.old_xy,
item.new_xy,
item.base_pvi,
item.edits,
item.stations,
10,
),
}
: null,
};
}),
+8 -3
View File
@@ -1,5 +1,5 @@
/* 349-0v 바뀐 구간 거울 — 화면 planline 단계 차례 그대로: 새 노선 = `buildEditedPolyline`(노드 초안) ·
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryProfileEdits`. 입력 argv[2] JSON · 출력 JSON.
* 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryBase` · `carryEdits`(400). 입력 argv[2] JSON · 출력 JSON.
*/
const fs = require("fs");
const path = require("path");
@@ -37,13 +37,18 @@ const built = curve.buildEditedPolyline(
input.curveRadius,
input.minRadiusM,
);
const span = carry.changedSpan(xy(input.old_line), xy(built.vertices));
const old = xy(input.old_line);
const now = xy(built.vertices);
const span = carry.changedSpan(old, now);
process.stdout.write(
JSON.stringify({
new_line: built.vertices,
span,
result: span
? carry.carryProfileEdits(input.base_pvi, input.edits, span, input.ground_pvi)
? {
basePvi: carry.carryBase(old, now, input.base_pvi, input.ground_pvi),
edits: carry.carryEdits(old, now, input.base_pvi, input.edits, input.stations, 20),
}
: null,
}),
);
@@ -0,0 +1,123 @@
/* 400 계획고 이월 거울 — 진짜 TS(`logic/B05_Profile_Grade_Carry` · `logic/B05_Profile_Pipeline_Grade` 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 pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts");
const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", {
"./B05_Profile_UI_Profile_Pivot": pivot,
});
const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
});
const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", {
"./B05_Profile_UI_Profile_Alignment": alignment,
});
const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", {
"../../common_util/common_util_route_follow": {},
"./B05_Profile_UI_Profile_Alignment": alignment,
"./B05_Profile_Grade_Carry": carry,
"./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 },
"./B05_Profile_Grade_Ground": ground,
});
const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts");
const edits = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts", {
"./B05_Profile_UI_RouteEdit_Curve": curve,
});
const frame = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts");
const fit = loadTs("B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts", {
"./B05_Profile_UI_RouteEdit_Curve": curve,
"./B05_Profile_UI_RouteEdit_Edits": edits,
"./B05_Profile_UI_RouteEdit_Frame": frame,
});
/** 측점 스냅 켠 노선 — 저장 노선(맞춤) · 교점 하나 옮긴 뒤 맞춤(편집기와 같은 `fitCurveStations`). */
function snapFit(item) {
const n = item.nodes.length;
const cols = (planned) => ({
planned,
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
apexLock: Array(n).fill(false),
});
const line = (planned) =>
curve.buildEditedPolyline(
planned,
Array(n).fill(true),
Array(n).fill(null),
item.limits.minRadiusM,
).vertices;
const old = fit.fitCurveStations(cols(item.nodes), item.limits, 1);
const moved = old.map((p, i) => (i === item.index ? item.to : p));
const now = fit.fitCurveStations(cols(moved), item.limits, Math.max(1, item.index - 1));
return { old_nodes: old, new_nodes: now, old_line: line(old), new_line: line(now) };
}
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const routeOf = (line) => {
const chainage = [0];
for (let i = 1; i < line.length; i += 1)
chainage.push(
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
);
return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] };
};
/** follow 단계를 노선 걸음 차례대로 — 각 걸음 뒤 편집(묶음이 입력에 넣는 것과 같음). */
function walk(item) {
let stations = [];
const stage = grade.createFollowStage({ savedLine: () => null, stationsAt: () => stations });
const planline = { policy: item.policy, basePvi: item.base_pvi };
let alignmentNow = item.edits;
let before = routeOf(item.ref_line);
const out = [];
for (const step of item.steps) {
stations = step.stations;
const now = routeOf(step.line);
const patch = stage({
inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: alignmentNow },
out: { route: now },
before: { route: before, planline },
});
if (patch.alignment) alignmentNow = patch.alignment;
out.push(alignmentNow);
before = now;
}
return out;
}
const result = {
match: input.match.map((item) => {
const fn = carry.lineMatcher(item.old_xy, item.new_xy, item.tol);
return item.queries.map((t) => fn(t));
}),
base: input.base.map((item) =>
carry.carryBase(item.old_xy, item.new_xy, item.old_base, item.ground_pvi),
),
edits: input.edits.map((item) =>
carry.carryEdits(item.old_xy, item.new_xy, item.old_base, item.edits, item.stations, item.half),
),
follow: input.follow.map(walk),
snap: (input.snap ?? []).map(snapFit),
};
process.stdout.write(JSON.stringify(result));
+57 -23
View File
@@ -7,7 +7,8 @@
② 관 자리 투영 — `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`.
④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`
(400 = 같은 자리 · 새 측점 위 · 자세한 규칙은 test_400_profile_carry.py).
서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감.
"""
@@ -20,6 +21,7 @@ import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
@@ -29,9 +31,12 @@ if str(ROOT) not in sys.path:
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_Alignment import resolve_pvi # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
carry_profile_edits,
carry_base,
carry_edits,
changed_span,
line_matcher,
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
@@ -115,6 +120,7 @@ def _carry_case(new_route: list[tuple[float, float]]) -> dict:
"base_pvi": base_pvi,
"edits": edits,
"ground_pvi": alignment["base_pvi"],
"stations": [row["chainage_m"] for row in longitudinal["stations"]],
}
@@ -335,18 +341,47 @@ def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch):
# ── ④ 계획고 옮김 ─────────────────────────────────────────────────────────────
def _pts(case, key):
return [(p["x"], p["y"]) for p in case[key]]
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"]]
)
old, new = _pts(case, "old_xy"), _pts(case, "new_xy")
span = changed_span(old, new)
result = (
carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"])
(
carry_base(old, new, case["base_pvi"], case["ground_pvi"]),
carry_edits(old, new, case["base_pvi"], case["edits"], case["stations"], 10.0),
)
if span
else None
)
return span, result
def _plan_at(base, edits, s):
pvi_s, pvi_z, _ = resolve_pvi(
np.array([p["chainage_m"] for p in base]),
np.array([p["elevation_m"] for p in base]),
edits["station_offsets"],
)
return float(np.interp(s, pvi_s, pvi_z))
def _kept_plan(case, base_pvi, edits):
"""같은 자리 새 측점마다 새 계획고 = 옛 계획고 · 바뀐 자리 측점 목록."""
match = line_matcher(_pts(case, "old_xy"), _pts(case, "new_xy"))
moved = []
for t in case["stations"]:
s = match(t)
if s is None:
moved.append(t)
continue
want = _plan_at(case["base_pvi"], case["edits"], s)
assert _plan_at(base_pvi, edits, t) == pytest.approx(want, abs=1e-3), t
return moved
@pytest.mark.parametrize("index", range(len(CARRY_CASES)))
def test_계획고_옮김_거울(ts, index):
span, result = _py_carry(CARRY_CASES[index])
@@ -370,33 +405,32 @@ def test_계획고_옮김_거울(ts, index):
assert edits == ts_result["edits"]
def test_가운데를_고치면_앞은_그대로_뒤는_밂():
span, (base_pvi, edits) = _py_carry(CARRY_CASES[0])
def test_가운데를_고치면_앞은_그대로_뒤는_같은자리_계획고():
case = CARRY_CASES[0]
span, (base_pvi, edits) = _py_carry(case)
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) 길이 차만큼
# 앞 편집 그대로 · 안(90.5) 버림 · 앞 R 그대로 · 같은 자리 새 측점 계획고 = 옛 계획고
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"]}
assert edits["curve_radii"]["40.000"] == 300.0
moved = _kept_plan(case, base_pvi, edits)
# 바뀐 자리는 새 지반 첫 계획선 꼭짓점 · 편집 없음
ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in case["ground_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)
assert moved and all(got[round(t, 3)] == ground[round(t, 3)] for t in moved)
assert not {f"{t:.3f}" for t in moved} & set(edits["station_offsets"])
assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + span["delta_m"], abs=1e-3)
def test_끝을_고치면_끝_꼭짓점은_새_지반():
span, (base_pvi, edits) = _py_carry(CARRY_CASES[1])
case = CARRY_CASES[1]
span, (base_pvi, edits) = _py_carry(case)
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]
_kept_plan(case, base_pvi, edits) # 앞(90.5 편집 둘레) 계획고 그대로
ground_end = case["ground_pvi"][-1]
assert base_pvi[-1] == {
"chainage_m": round(ground_end["chainage_m"], 6),
"chainage_m": ground_end["chainage_m"],
"elevation_m": ground_end["elevation_m"],
}
+5 -3
View File
@@ -522,7 +522,7 @@ def _longitudinal(total: float) -> dict:
return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]}
def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
def test_계획고_옮김은_앞_그대로_뒤_같은자리(tmp_path, monkeypatch):
new_xy = [list(p) for p in NEW]
total = float(vertices_of(NEW)[-1].chainage_m)
old = _longitudinal(400.0)
@@ -548,10 +548,12 @@ def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
)
)
edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"]
delta = total - 400.0
assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로
assert "200.000" not in edits # 바뀐 구간 안 — 새 지반
assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂
# 뒤(옛 380 둘레) — 같은 자리 새 측점 위 편집으로 이어받음(400 · 길이 차만큼 민 글자 키 아님)
stations = {f"{row['chainage_m']:.3f}" for row in _longitudinal(total)["stations"]}
behind = [key for key in edits if float(key) > 300.0]
assert behind and set(behind) <= stations
def test_저장_확정이_옛_횡단_파일을_지움(tmp_path):
@@ -2,9 +2,10 @@
"""349-0v 손으로 고친 노선 [저장] — 미리보기 계획고 = 저장 뒤 계획고(바뀐 구간 거울).
화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드
초안) → `changedSpan` → `carryProfileEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 CSV · 새 =
`_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_profile_edits`. 두 길이 같은 구간 · 같은
계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음).
초안) → `changedSpan` → `carryBase` · `carryEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인
CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · `carry_edits`(400).
두 길이 같은 구간 · 같은 계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라
앞머리가 일찍 갈렸음).
"""
from __future__ import annotations
@@ -24,7 +25,8 @@ if str(ROOT) not in sys.path:
from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
carry_profile_edits,
carry_base,
carry_edits,
changed_span,
)
@@ -80,6 +82,7 @@ def world(tmp_path_factory):
"base_pvi": base_pvi,
"edits": edits,
"ground_pvi": ground_pvi,
"stations": [p["chainage_m"] for p in ground_pvi],
}
source = tmp / "input.json"
source.write_text(json.dumps(payload), encoding="utf-8")
@@ -119,11 +122,18 @@ def test_옮긴_계획고_화면_서버_같음(world):
span = changed_span(
[tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]]
)
base, edits = carry_profile_edits(world["base_pvi"], world["edits"], span, world["ground_pvi"])
old, new = world["old_line"], world["new_line_py"]
base = carry_base(old, new, world["base_pvi"], world["ground_pvi"])
edits = carry_edits(old, new, world["base_pvi"], world["edits"], world["stations"], 20.0)
assert span is not None
ts = world["ts"]["result"]
flat = [v for n in base for v in (n["chainage_m"], n["elevation_m"])]
assert flat == pytest.approx(
[v for n in ts["basePvi"] for v in (n["chainage_m"], n["elevation_m"])], abs=1e-3
) # 서버 새 선 = CSV 0.1 mm 반올림 · 화면 = 원값
assert edits == ts["edits"]
assert edits["station_offsets"]["150.000"] == 0.8 # 바뀐 구간 앞 사용자 계획고 그대로
for field in ("station_offsets", "curve_radii"):
assert edits[field].keys() == ts["edits"][field].keys()
for key, value in edits[field].items():
assert value == pytest.approx(ts["edits"][field][key], abs=1e-3)
# 바뀐 구간 앞 사용자 계획고(150) = 둘레 새 측점(120 · 160) 편집으로 그대로
assert {"120.000", "160.000"} <= set(edits["station_offsets"])
+366
View File
@@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
"""400 평면 고친 뒤 종단 편집 이어받기 — 같은 자리(옆 1 m · 방향 30° 안)는 옛 계획고를 새 측점 위에.
거울(파이썬 = 서버 [저장] 정본 · TS = 브라우저 즉시 · 같은 값):
`B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · carry_edits ↔
`logic/B05_Profile_Grade_Carry.ts` lineMatcher · carryBase · carryEdits.
묶음 follow(`logic/B05_Profile_Pipeline_Grade.ts`) — 걸음 수와 무관(기준 한 벌에서 한 번) ·
되돌린 노선 = 옛 편집.
"""
from __future__ import annotations
import json
import math
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402
SAME_LINE_TOL_M,
carry_base,
carry_edits,
line_matcher,
)
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
INTERVAL = 20.0
HALF = INTERVAL / 2
# ── 입력 만들기 ───────────────────────────────────────────────────────────────
def _dense(nodes: list[tuple[float, float]], step: float = 2.0) -> list[list[float]]:
"""꺾은선을 `step` m 안팎 점으로(그린 노선처럼 정점이 많게)."""
out = [list(nodes[0])]
for (x0, y0), (x1, y1) in zip(nodes, nodes[1:]):
n = max(1, int(math.ceil(math.dist((x0, y0), (x1, y1)) / step)))
out += [[x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n] for k in range(1, n + 1)]
return out
def _length(line) -> float:
return sum(math.dist(a, b) for a, b in zip(line, line[1:]))
def _stations(line) -> list[float]:
total = _length(line)
grid = [round(k * INTERVAL, 3) for k in range(int(total // INTERVAL) + 1)]
return grid + ([round(total, 3)] if total - grid[-1] > 1e-6 else [])
def _ground(s: float) -> float:
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 37.0)
def _ground_pvi(line) -> list[dict]:
"""새 노선 지반 첫 계획선 꼴(측점마다 지반 · 옛 계획고와 다르게 +3 m)."""
return [{"chainage_m": s, "elevation_m": round(_ground(s) + 3.0, 6)} for s in _stations(line)]
OLD_NODES = [(0.0, 0.0), (200.0, 0.0), (400.0, 100.0), (600.0, 100.0), (800.0, 0.0)]
OLD = _dense(OLD_NODES)
OLD_BASE = [{"chainage_m": s, "elevation_m": round(_ground(s), 6)} for s in _stations(OLD)]
def _old_edits() -> dict:
"""직선화(40 ~ 120 사이 측점) · ▲ 1 m(300) · 종단곡선 R(300) · 자유지정 꺾임(520 · 540)."""
base = {n["chainage_m"]: n["elevation_m"] for n in OLD_BASE}
z40, z120 = base[40.0], base[120.0]
offsets = {}
for s in (60.0, 80.0, 100.0):
offsets[f"{s:.3f}"] = round(z40 + (z120 - z40) * (s - 40) / 80 - base[s], 6)
offsets["300.000"] = 1.0
offsets["520.000"] = -0.8
offsets["540.000"] = -1.2
return {"station_offsets": offsets, "curve_radii": {"300.000": 400.0, "520.000": 250.0}}
OLD_EDITS = _old_edits()
def _moved(index: int, to: tuple[float, float]) -> list[list[float]]:
nodes = list(OLD_NODES)
nodes[index] = to
return _dense(nodes)
CASES = {
# 먼 교점(끝 앞) 하나 옮김 — 앞쪽 편집 그대로
"far": _moved(3, (620.0, 130.0)),
# 시점을 뒤로 15 m 늘림 — 같은 선 · 누가거리만 +15(새 측점은 옛 측점 사이)
"start": _dense([(-15.0, 0.0)] + OLD_NODES[1:]),
# 끝을 같은 방향으로 늘림 — 옛 끝까지 그대로 · 넘는 자리만 지반
"end": _dense(OLD_NODES[:-1] + [(840.0, -20.0)]),
# 측점 스냅처럼 뒤 교점이 옆으로 0.6 m(같은 자리) · 1.6 m(바뀐 자리) 밀림
"snap": _dense(OLD_NODES[:2] + [(400.0, 100.6), (600.0, 101.6), (800.0, 1.6)]),
"same": OLD,
}
def _xy(line):
return [{"x": x, "y": y} for x, y in line]
def _plan(base: list[dict], edits: dict) -> tuple[np.ndarray, np.ndarray]:
s = np.array([n["chainage_m"] for n in base], dtype=np.float64)
z = np.array([n["elevation_m"] for n in base], dtype=np.float64)
pvi_s, pvi_z, _ = resolve_pvi(s, z, edits["station_offsets"])
return pvi_s, pvi_z
def _py(name: str) -> tuple[list[dict], dict]:
new = CASES[name]
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
edits = carry_edits(OLD, new, OLD_BASE, OLD_EDITS, _stations(new), HALF)
return base, edits
# ── TS 한 번 ─────────────────────────────────────────────────────────────────
FOLLOW_STEPS = [
# 기준 → 먼 교점 옮김 → 더 옮김 · 한 번에 옮김과 같아야
[("far1", _moved(3, (610.0, 115.0))), ("far2", CASES["far"])],
[("far2", CASES["far"])],
# 바뀐 자리를 거쳐 되돌림 — 옛 편집 그대로
[("mid", _moved(4, (830.0, 30.0))), ("back", OLD)],
]
# 측점 스냅 켠 노선 — 앞 교점 하나 옮기면 뒤 교점이 모두 MC 맞춤으로 밀림(StationFit)
SNAP = {
"nodes": [[0, 0], [150, 30], [300, 0], [450, 40], [600, 10], [750, 50], [900, 20], [1050, 40]],
"index": 1,
"to": [150.0, 37.0],
"limits": {"minRadiusM": 60.0, "limitRadiusM": 0.0, "limitArcM": 0.0, "intervalM": INTERVAL},
}
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
queries = [0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6]
payload = {
"match": [
{"old_xy": _xy(OLD), "new_xy": _xy(line), "tol": SAME_LINE_TOL_M, "queries": queries}
for line in CASES.values()
],
"base": [
{
"old_xy": _xy(OLD),
"new_xy": _xy(line),
"old_base": OLD_BASE,
"ground_pvi": _ground_pvi(line),
}
for line in CASES.values()
],
"edits": [
{
"old_xy": _xy(OLD),
"new_xy": _xy(line),
"old_base": OLD_BASE,
"edits": OLD_EDITS,
"stations": _stations(line),
"half": HALF,
}
for line in CASES.values()
],
"follow": [
{
"ref_line": OLD,
"policy": {"station_interval_m": INTERVAL},
"base_pvi": OLD_BASE,
"edits": OLD_EDITS,
"steps": [{"line": line, "stations": _stations(line)} for _, line in steps],
}
for steps in FOLLOW_STEPS
],
"snap": [SNAP],
}
source = tmp_path_factory.mktemp("s400") / "in.json"
source.write_text(json.dumps(payload), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_400_profile_carry.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
# ── 거울 ─────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("index", range(len(CASES)))
def test_거울_같은자리_꼭짓점_편집(ts, index):
name, line = list(CASES.items())[index]
match = line_matcher(OLD, line)
for t, got in zip([0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6], ts["match"][index]):
want = match(t)
assert (want is None) == (got is None), (name, t)
if want is not None:
assert want == pytest.approx(got, abs=1e-9)
base, edits = _py(name)
assert len(base) == len(ts["base"][index])
for a, b in zip(base, ts["base"][index]):
assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-9)
assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6)
got = ts["edits"][index]
for field in ("station_offsets", "curve_radii"):
assert edits[field].keys() == got[field].keys(), (name, field)
for key, value in edits[field].items():
assert value == pytest.approx(got[field][key], abs=1e-6)
# ── 규칙 ─────────────────────────────────────────────────────────────────────
def _same_place_plan(name: str):
"""같은 자리 새 측점마다 (새 계획고, 옛 계획고) · 바뀐 자리 측점."""
new = CASES[name]
base, edits = _py(name)
match = line_matcher(OLD, new)
old_s, old_z = _plan(OLD_BASE, OLD_EDITS)
new_s, new_z = _plan(base, edits)
kept, moved = [], []
for t in _stations(new):
s = match(t)
if s is None:
moved.append(t)
else:
kept.append((t, float(np.interp(t, new_s, new_z)), float(np.interp(s, old_s, old_z))))
return kept, moved, base, edits
@pytest.mark.parametrize("name", ["far", "start", "end", "snap", "same"])
def test_같은자리_계획고_그대로(name):
kept, _, _, _ = _same_place_plan(name)
assert kept
for t, new_z, old_z in kept:
# 옛 자동 꼭짓점 0.006 m 안 측점은 그 꼭짓점 위로 봄 — 기울기 차 × 0.006 m 안
assert new_z == pytest.approx(old_z, abs=1e-3), (name, t)
def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리만_지반():
kept, moved, base, edits = _same_place_plan("far")
# 앞쪽(같은 선 · 같은 측점) 편집 글자 그대로
for key in ("60.000", "80.000", "100.000", "300.000"):
assert edits["station_offsets"][key] == pytest.approx(OLD_EDITS["station_offsets"][key])
assert edits["curve_radii"]["300.000"] == 400.0
# 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 지반 첫 계획선 · 편집 없음(옛 520 · 540 자유지정 버림)
assert moved and min(moved) > 423.0
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(CASES["far"])}
by = {n["chainage_m"]: n["elevation_m"] for n in base}
assert all(by[t] == ground[t] for t in moved)
assert not any(float(k) in moved for k in edits["station_offsets"])
def test_시점_늘림_누가거리_투영_새측점_위():
kept, moved, _, edits = _same_place_plan("start")
assert not moved or max(moved) < 1.0
stations = set(_stations(CASES["start"]))
assert edits["station_offsets"] and all(float(k) in stations for k in edits["station_offsets"])
assert all(float(k) in stations for k in edits["curve_radii"])
# ▲(옛 300) 의 R 은 옛 300 = 새 315 에서 가장 가까운 새 측점(320 · 옛 305)
assert edits["curve_radii"].get("320.000") == 400.0
def test_끝_늘림_옛끝까지_이어받고_넘는자리만_지반():
kept, moved, base, _ = _same_place_plan("end")
old_len = _length(OLD)
assert moved and min(moved) > old_len
assert max(t for t, _, _ in kept) > old_len - INTERVAL
def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시():
match = line_matcher(OLD, CASES["snap"])
assert match(410.0) is not None # 0.6 m 쪽
assert match(650.0) is None # 1.6 m 쪽
_, _, _, edits = _same_place_plan("snap")
assert "300.000" in edits["station_offsets"]
def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_지반():
empty = {"station_offsets": {}, "curve_radii": {}}
new = CASES["far"]
assert carry_edits(OLD, new, OLD_BASE, empty, _stations(new), HALF) == empty
base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new))
old_s = [n["chainage_m"] for n in OLD_BASE]
old_z = [n["elevation_m"] for n in OLD_BASE]
ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(new)}
match = line_matcher(OLD, new)
for node in base:
t = node["chainage_m"]
s = match(t)
want = ground[t] if s is None else round(float(np.interp(s, old_s, old_z)), 6)
assert node["elevation_m"] == pytest.approx(want, abs=1e-9)
def test_엇갈려_지나는_선은_같은자리_아님():
crossing = _dense([(100.0, -50.0), (100.0, 50.0)])
assert line_matcher(OLD, crossing)(50.0) is None
reverse = OLD[::-1]
assert line_matcher(OLD, reverse)(100.0) is None
# ── 묶음 follow ──────────────────────────────────────────────────────────────
def test_follow_걸음수와_무관_서버와_같은값(ts):
stepwise, direct, back = ts["follow"]
assert stepwise[-1] == direct[-1]
_, edits = _py("far")
got = direct[-1]
assert edits["station_offsets"].keys() == got["station_offsets"].keys()
for key, value in edits["station_offsets"].items():
assert value == pytest.approx(got["station_offsets"][key], abs=1e-6)
assert edits["curve_radii"] == got["curve_radii"]
def test_follow_바뀐자리_거쳐_되돌리면_옛편집(ts):
_, _, back = ts["follow"]
assert "520.000" in back[0]["station_offsets"] # 끝 교점 옮김 — 앞쪽(먼) 편집은 남음
assert back[-1] == OLD_EDITS
def test_측점스냅_켠_노선_뒤_교점_밀려도_같은자리_편집_이어받음(ts):
got = ts["snap"][0]
old_line, new_line = got["old_line"], got["new_line"]
shifts = [math.dist(a, b) for a, b in zip(got["old_nodes"][2:], got["new_nodes"][2:])]
assert max(shifts) > 0.5 # 뒤 교점이 정말 밀림 — 1 mm 정점 비교로는 뒤쪽 전부 「바뀐 구간」
stations = _stations(old_line)
base = [{"chainage_m": t, "elevation_m": round(_ground(t), 6)} for t in stations]
far = [t for t in stations if 600.0 <= t <= 900.0]
offsets = {f"{t:.3f}": round(0.5 + 0.001 * t, 6) for t in far} # 뒤쪽 직선화 · ▲ 몫
edits = {"station_offsets": offsets, "curve_radii": {}}
new_stations = _stations(new_line)
new_base = carry_base(old_line, new_line, base, _ground_pvi(new_line))
new_edits = carry_edits(old_line, new_line, base, edits, new_stations, HALF)
match = line_matcher(old_line, new_line)
old_s, old_z = _plan(base, edits)
new_s, new_z = _plan(new_base, new_edits)
carried = [t for t in new_stations if f"{t:.3f}" in new_edits["station_offsets"]]
assert (
len(carried) >= len(far) // 2
) # 뒤쪽 편집 절반 넘게 이어받음(나머지 = 1 m 넘게 밀린 곡선 둘레)
for t in carried:
s = match(t)
assert float(np.interp(t, new_s, new_z)) == pytest.approx(
float(np.interp(s, old_s, old_z)), abs=1e-3
)