From 93966a7f11ca34b4d5d1350d1c895831bf28b1cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 15:51:26 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06=20=EC=B8=A1=EC=A0=90=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0):=20MC=20=EA=B0=80=20=EC=9B=80=EC=A7=81=EC=97=AC=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=97=B4=EC=87=A0=EA=B0=80=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EC=96=B4=EB=8F=84=20=EA=B3=A0=EC=B9=A8=EC=9D=B4=20?= =?UTF-8?q?=EA=B0=80=EA=B9=8C=EC=9A=B4=20=EC=83=88=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90(10=20m=20=EC=95=88=20=C2=B7=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EC=94=A9)=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=96=B4?= =?UTF-8?q?=EA=B0=90=20=E2=80=94=20=EC=84=B8=EC=85=98=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=C2=B7=20=EB=88=84=EA=B0=80=EA=B1=B0=EB=A6=AC=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=B2=84=20=EB=85=B8=EC=84=A0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=84=98=EA=B9=80=20=EC=A7=9D=20(432-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Route_Save_Parts.py | 8 +- B06_Section/B06_Section_Station_Keys.ts | 16 ++- common_util/common_util_station_key.py | 28 +++++ common_util/common_util_station_key.ts | 29 +++++ resources/tester/helper_432_3_curve_key.mjs | 95 +++++++++++++++ resources/tester/helper_432_3_entry.ts | 9 ++ resources/tester/test_432_3_curve_key.py | 127 ++++++++++++++++++++ 7 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 resources/tester/helper_432_3_curve_key.mjs create mode 100644 resources/tester/helper_432_3_entry.ts create mode 100644 resources/tester/test_432_3_curve_key.py diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index abbf44b55..46533d795 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -32,7 +32,7 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_route_geometry import RouteVertex from common_util.common_util_station_key import chainage_key as station_chainage_key -from common_util.common_util_station_key import station_key +from common_util.common_util_station_key import curve_moves, station_key from common_util.common_util_structure_station import plan_stations, snap_structure_station from config.config_system import DRAINAGE_PIPE_MIN_SPACING_M @@ -156,8 +156,10 @@ def carry_cross_inputs( """같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수). `old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에 - 같은 열쇠가 없는 것(「노선 밖 고침」). + 같은 열쇠가 없는 것(「노선 밖 고침」) · MC 가 움직인 곡선 열쇠는 가까운 새 곡선 측점 + (`curve_moves`). """ + moves = curve_moves(list(old_keys.items()), list(new_keys.items())) inputs: dict[float, dict[str, Any]] = {} adjusts: dict[float, dict[str, Any]] = {} dropped = 0 @@ -165,7 +167,7 @@ def carry_cross_inputs( design = old_designs.get(round(old_at, 3)) if not isinstance(design, dict): continue - new_at = new_keys.get(key) + new_at = new_keys.get(moves.get(key, key)) if new_at is None: dropped += 1 continue diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 2de267c95..e63b1f823 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -24,6 +24,7 @@ import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; import { chainageKey, + curveMoves, dedupeStationKeys, looseChainage, stationKey, @@ -265,6 +266,13 @@ export function chainageOfKey( return near ? Number(chainageKey(near.chainageM)) : null; } +/** 두 노선 목록 사이 MC 가 움직인 곡선 열쇠(432-3 · 서버 `carry_cross_inputs` 와 같음). */ +function curveMovesBetween(projectId: string, fromRouteId: number, toRouteId: number) { + const pairs = (routeId: number) => + (lists.get(scopeOf(projectId, routeId)) ?? []).map((e) => [e.key, e.chainageM] as const); + return curveMoves(pairs(fromRouteId), pairs(toRouteId)); +} + function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { const raw = readState(name, projectId, routeId); return raw && typeof raw === "object" ? raw : {}; @@ -338,6 +346,7 @@ export function carryStationDrafts( { quiet = false }: { quiet?: boolean } = {}, ): number { const outside = new Set(); + const curves = curveMovesBetween(projectId, fromRouteId, toRouteId); for (const name of KEYED_STATES) { const raw = readRaw(name, projectId, fromRouteId); const next: Raw = { [HINT]: {} }; @@ -345,7 +354,8 @@ export function carryStationDrafts( // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 const fromAt = looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); - const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); + const was = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); + const stable = curves.get(was) ?? was; const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined); const carried = carriedValue(name, value); if (toAt === null || stable.startsWith("at:")) { @@ -420,10 +430,12 @@ export function carryChainageStates( ): number { const facilityAt = facilities.keyAt ?? (() => null); const outside = new Set(); + const curves = curveMovesBetween(projectId, fromRouteId, toRouteId); const moveAt = (state: string, text: string): string | null => { const moved = facilities.movedAt?.(Number(text)); if (moved !== null && moved !== undefined) return chainageKey(moved); - const stable = keyAt(projectId, fromRouteId, Number(text)); + const was = keyAt(projectId, fromRouteId, Number(text)); + const stable = curves.get(was) ?? was; const structural = stable.startsWith("pipe:") || stable.startsWith("structure:"); // 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점) const toAt = diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index f6c0194b9..48d8df3bd 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -5,6 +5,7 @@ 곡선 측점(MC · 252-2) = ``curve:<누가거리 0.01 m>``. 구간형 구조물 시작 · 끝 측점 = ``structure::start`` · ``:end``(기준 측점은 ``structure:``). 한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``). +MC 가 움직여 곡선 열쇠가 바뀌면 넘길 때 가까운 새 곡선 측점으로 이음(``curve_moves`` · 432-3). ⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. @@ -79,3 +80,30 @@ def loose_chainage(key: str) -> float | None: """옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None.""" text = key[3:] if key.startswith("at:") else key return float(text) if _LOOSE.match(text) else None + + +#: 옮긴 곡선 측점으로 보는 거리(m) — 옛 MC 자리에서 이 안의 새 MC. +CURVE_NEAR_M = 10.0 + + +def curve_moves(before: list[tuple[str, float]], after: list[tuple[str, float]]) -> dict[str, str]: + """MC 가 움직여 바뀐 ``curve:`` 열쇠 → 가까운 새 곡선 열쇠. + + 옛 목록에만 있는 곡선 열쇠 → 새 목록에만 있는 가장 가까운 곡선 열쇠(``CURVE_NEAR_M`` 안 · + 새 열쇠 하나에 옛 열쇠 하나 · 옛 누가거리 차례). + """ + was = {key for key, _ in before} + kept = {key for key, _ in after} + free = [(key, at) for key, at in after if key.startswith("curve:") and key not in was] + out: dict[str, str] = {} + for key, at in sorted(before, key=lambda item: item[1]): + if not key.startswith("curve:") or key in kept: + continue + best = -1 + for index, (_, to) in enumerate(free): + gap = abs(to - at) + if gap <= CURVE_NEAR_M and (best < 0 or gap < abs(free[best][1] - at)): + best = index + if best >= 0: + out[key] = free.pop(best)[0] + return out diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts index afd0e4cd1..842ba8dee 100644 --- a/common_util/common_util_station_key.ts +++ b/common_util/common_util_station_key.ts @@ -6,6 +6,7 @@ * 곡선 측점(MC · 252-2) = `curve:<누가거리 0.01 m>`. * 구간형 구조물의 시작 · 끝 측점 = `structure::start` · `:end`(기준 측점은 `structure:` 그대로). * 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>`(`dedupeStationKeys`). + * MC 가 움직여 곡선 열쇠가 바뀌면 넘길 때 가까운 새 곡선 측점으로 이음(`curveMoves` · 432-3). * 쓰는 곳 = 서버 `B05_Profile_Route_Save_Parts` · 묶음 stations 단계 · B06 세션 횡단 초안(`stationKeysOf`). * * ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도 @@ -68,3 +69,31 @@ export function looseChainage(key: string): number | null { if (!/^-?\d+(\.\d+)?$/.test(text)) return null; return Number(text); } + +/** 옮긴 곡선 측점으로 보는 거리(m) — 옛 MC 자리에서 이 안의 새 MC. */ +export const CURVE_NEAR_M = 10; + +/** + * MC 가 움직여 `curve:` 열쇠가 바뀜 — 옛 목록에만 있는 곡선 열쇠 → 새 목록에만 있는 가장 가까운 곡선 열쇠 + * (`CURVE_NEAR_M` 안 · 새 열쇠 하나에 옛 열쇠 하나 · 옛 누가거리 차례). 목록 = [열쇠, 누가거리]. + */ +export function curveMoves( + before: ReadonlyArray, + after: ReadonlyArray, +): Map { + const isCurve = (key: string) => key.startsWith("curve:"); + const was = new Set(before.map(([key]) => key)); + const kept = new Set(after.map(([key]) => key)); + const free = after.filter(([key]) => isCurve(key) && !was.has(key)); + const out = new Map(); + for (const [key, at] of [...before].sort((a, b) => a[1] - b[1])) { + if (!isCurve(key) || kept.has(key)) continue; + let best = -1; + free.forEach(([, to], i) => { + const gap = Math.abs(to - at); + if (gap <= CURVE_NEAR_M && (best < 0 || gap < Math.abs(free[best][1] - at))) best = i; + }); + if (best >= 0) out.set(key, free.splice(best, 1)[0][0]); + } + return out; +} diff --git a/resources/tester/helper_432_3_curve_key.mjs b/resources/tester/helper_432_3_curve_key.mjs new file mode 100644 index 000000000..b774d7d3f --- /dev/null +++ b/resources/tester/helper_432_3_curve_key.mjs @@ -0,0 +1,95 @@ +/* 432-3 헬퍼 — MC 가 움직여도 B06 고침이 곡선 측점을 따라가는지. + * node helper_432_3_curve_key.mjs <묶음.mjs> + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); + +const m = await import(pathToFileURL(process.argv[2]).href); +const P = "p432"; +const detailOf = (rows) => { + const sections = rows.map(([chainage_m, kind]) => ({ + station_id: `s${chainage_m}`, + chainage_m, + kind, + label: String(chainage_m), + samples: [], + })); + return { + longitudinal: { stations: sections, profile_alignment: { policy: { station_interval_m: 20 } } }, + cross_sections: sections, + }; +}; +const base = [ + [0, "bp"], + [20, "regular"], + [40, "regular"], + [60, "regular"], + [120, "ep"], +]; +const route = (curves) => detailOf([...base, ...curves].sort((a, b) => a[0] - b[0])); + +// 옛 노선 7 — MC 둘(47.35 · 90.00)에 카드 고침 · 사면 쉬프트(측구 폭 칸 crossw 는 누가거리 열쇠) +m.useStationKeys( + P, + 7, + route([ + [47.35, "curve"], + [90, "curve"], + ]), +); +const card = { ground_type: "rock", section_mode: "right_cut" }; +m.writeKeyed( + "crossdesign", + P, + 7, + new Map([ + ["47.35", card], + ["90.00", card], + ]), +); +m.writeKeyed("shoulderext", P, 7, new Map([["47.35", { left: 0.5 }]])); +m.writeState("crossw", { 47.35: 3.2 }, P, 7); + +// 새 노선 8 — 첫 MC 1.75 m 움직임(R 고침) · 둘째 MC 15 m 움직임(10 m 넘음 = 다른 곡선으로 봄) +m.useStationKeys( + P, + 8, + route([ + [49.1, "curve"], + [105, "curve"], + ]), +); +const outside = m.carryStationDrafts(P, 7, 8, { quiet: true }); +const states = m.carryChainageStates(P, 7, 8); +process.stdout.write( + JSON.stringify({ + outside, + states, + cards: Object.fromEntries(m.readKeyed("crossdesign", P, 8)), + shoulder: Object.fromEntries(m.readKeyed("shoulderext", P, 8)), + crossw: m.readState("crossw", P, 8), + }), +); diff --git a/resources/tester/helper_432_3_entry.ts b/resources/tester/helper_432_3_entry.ts new file mode 100644 index 000000000..3c8a5e487 --- /dev/null +++ b/resources/tester/helper_432_3_entry.ts @@ -0,0 +1,9 @@ +/* 432-3 시험 묶음 진입점 — MC 가 움직인 곡선 측점 고침 넘김(진짜 모듈 · vite ssr). */ +export { + carryChainageStates, + carryStationDrafts, + readKeyed, + useStationKeys, + writeKeyed, +} from "../../B06_Section/B06_Section_Station_Keys"; +export { readState, writeState } from "../../A00_Common/b_page_state"; diff --git a/resources/tester/test_432_3_curve_key.py b/resources/tester/test_432_3_curve_key.py new file mode 100644 index 000000000..62e42865f --- /dev/null +++ b/resources/tester/test_432_3_curve_key.py @@ -0,0 +1,127 @@ +"""432-3 MC 곡선 측점 열쇠 — MC 가 움직여도 B06 고침이 그 곡선 측점을 따라감. + +- 열쇠 `curve:<누가거리>` 는 MC 가 0.01 m 만 움직여도 바뀜 → 예전엔 「노선 밖 고침」으로 버림. +- 이제 옛 목록에만 있는 곡선 열쇠 → 새 목록에만 있는 가장 가까운 곡선 열쇠(10 m 안 · 하나씩). +- 짝: `common_util_station_key` `curve_moves` ↔ `curveMoves`(거울) · 서버 `carry_cross_inputs` · + B06 세션 `carryStationDrafts` · `carryChainageStates`(진짜 모듈 · vite ssr). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs +from common_util.common_util_station_key import curve_moves + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +TS = ROOT / "common_util" / "common_util_station_key.ts" +TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript" + +CASES = [ + # MC 하나 1.75 m 움직임 · 하나 그대로 · 하나 15 m(못 이음) + ( + [("end:BP", 0.0), ("curve:47.35", 47.35), ("curve:70.00", 70.0), ("curve:90.00", 90.0)], + [("end:BP", 0.0), ("curve:49.10", 49.1), ("curve:70.00", 70.0), ("curve:105.00", 105.0)], + ), + # 두 옛 MC 가 한 새 MC 를 다툼 — 앞엣것이 가장 가까운 것만 · 둘째는 다음 가까운 것 + ( + [("curve:10.00", 10.0), ("curve:14.00", 14.0)], + [("curve:12.00", 12.0), ("curve:20.00", 20.0)], + ), + # 곡선 아닌 열쇠는 안 이음 · 같은 거리 = 앞 차례 + ( + [("grid:1", 20.0), ("curve:30.00", 30.0)], + [("grid:2", 21.0), ("curve:25", 25.0), ("curve:35", 35.0)], + ), + ([], []), +] + +MIRROR = r""" +const ts = require(process.argv[1]); +const src = require("fs").readFileSync(process.argv[2], "utf8"); +const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, opts).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const cases = JSON.parse(process.argv[3]); +const moves = cases.map(([a, b]) => Object.fromEntries(box.exports.curveMoves(a, b))); +process.stdout.write(JSON.stringify(moves)); +""" + + +def test_곡선_이음_규칙(): + assert curve_moves(*CASES[0]) == {"curve:47.35": "curve:49.10"} + assert curve_moves(*CASES[1]) == {"curve:10.00": "curve:12.00", "curve:14.00": "curve:20.00"} + assert curve_moves(*CASES[2]) == {"curve:30.00": "curve:25"} + + +def test_파이썬과_TS_같음(): + proc = subprocess.run( + ["node", "-e", MIRROR, str(TYPESCRIPT), str(TS), json.dumps(CASES)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout) == [curve_moves(*case) for case in CASES] + + +def test_서버_노선_저장_넘김은_움직인_MC_로(): + old_keys = dict(CASES[0][0]) + new_keys = dict(CASES[0][1]) + designs = {47.35: {"ground_type": "rock"}, 90.0: {"ground_type": "soil"}} + inputs, _adjusts, dropped = carry_cross_inputs(old_keys, new_keys, designs, ["ground_type"], []) + assert inputs == {49.1: {"ground_type": "rock"}} + assert dropped == 1 # 15 m 움직인 MC = 다른 곡선 · 「노선 밖 고침」 + + +@pytest.fixture(scope="module") +def session(tmp_path_factory): + work = tmp_path_factory.mktemp("b432") + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_432_3_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_432_3_entry.js", bundle) + proc = subprocess.run( + ["node", str(HERE / "helper_432_3_curve_key.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_B06_세션_고침이_움직인_MC_를_따라감(session): + assert set(session["cards"]) == {"49.10"} + assert session["cards"]["49.10"]["ground_type"] == "rock" + assert session["shoulder"] == {"49.10": {"left": 0.5}} + assert session["crossw"] == {"49.10": 3.2} + assert session["outside"] == 1 # 90 m MC 카드 — 15 m 움직임 = 버림 + assert session["states"] == 0