From 98e7d4aee2673aa80e83b4a8d3672fef3eefc3bd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:26:22 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(454=20B05=20=EB=B0=A9=ED=96=A5?= =?UTF-8?q?=ED=82=A4=20=EC=B8=A1=EC=A0=90=20=EA=B1=B8=EC=9D=8C):=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EB=A7=9E=EC=B6=A4=20=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=91=98=20=C2=B7=20=EB=92=A4=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=95=9E=EB=B3=B4=EA=B8=B0=20(433-21=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 곡선마다 내림 · 올림 측점 후보 · 뒤 곡선이 측점 위거나 맞춰지는 쪽 고름 - 무작위 노선 30 개 20 m 걸음 측점 밖 막힘 141 → 25 · 놓은 뒤 측점 밖 43 → 0 - 시험 test_454_arrow_station_step 추가 Co-Authored-By: Claude Opus 5.5 --- .../logic/B05_Profile_RouteEdit_StationFit.ts | 76 +++++++++++---- .../tester/helper_454_arrow_station_step.cjs | 96 +++++++++++++++++++ .../tester/test_454_arrow_station_step.py | 35 +++++++ 3 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 resources/tester/helper_454_arrow_station_step.cjs create mode 100644 resources/tester/test_454_arrow_station_step.py diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index f54583bba..5351b86cd 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -4,7 +4,7 @@ * * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서 - * 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠. + * 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454). * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. * · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`). @@ -139,7 +139,9 @@ function solve( return null; } -/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */ +/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). + * 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②) + * — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */ export function fitCurveStations( cols: Columns & Pick, limits: FitLimits, @@ -150,28 +152,64 @@ export function fitCurveStations( if (!(interval > 0) || planned.length < 3) return planned; let built = build(cols, planned, limits); const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM); + const tol = mcTolM(interval); + type Fit = { planned: Vertex[]; built: EditedPolyline }; + /** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */ + const near = (mc: number, last: number): number[] => { + const lo = Math.floor(mc / interval); + return [lo, lo + 1] + .filter((n) => n > last) + .sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc)); + }; + const onAt = (mc: number, last: number) => + near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol); + /** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ + const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => { + const fitted = solve(cols, limits, at.planned, k, n * interval, mc); + if ( + !fitted || + shortfallCrossed( + floor, + curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM), + ) + ) + return null; + if (previous < 0) return fitted; + const moved = Math.abs( + (mcOf(fitted.built, previous) ?? Infinity) - (mcOf(at.built, previous) ?? 0), + ); + return moved <= tol / 5 ? fitted : null; + }; + /** `k` 뒤 첫 곡선이 측점 위이거나 맞춰지나(앞보기 한 걸음). */ + const nextSettles = (at: Fit, k: number, last: number): boolean => { + for (let j = k + 1; j < at.planned.length - 1; j += 1) { + const mc = mcOf(at.built, j); + if (mc === null) continue; + if (onAt(mc, last) !== undefined) return true; + return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null); + } + return true; + }; let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점) let previous = -1; // 앞 곡선 꺾임점 for (let k = 1; k < planned.length - 1; k += 1) { const mc = mcOf(built, k); if (mc === null) continue; - const near = Math.max(lastStation + 1, Math.round(mc / interval)); - const onStation = Math.abs(mc - near * interval) <= mcTolM(interval); - const fitted = - onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2 - ? null - : solve(cols, limits, planned, k, near * interval, mc); - const keep = - fitted && - !shortfallCrossed( - floor, - curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM), - ) && - (previous < 0 || - Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <= - mcTolM(interval) / 5); - if (fitted && keep) ({ planned, built } = fitted); - if (onStation || keep) lastStation = near; + const on = onAt(mc, lastStation); + if (on !== undefined) lastStation = on; + else if (k >= from && !cols.apexLock[k]) { + let pick: { fit: Fit; n: number } | null = null; + for (const n of near(mc, lastStation)) { + const fit = attempt({ planned, built }, k, mc, n, previous); + if (!fit) continue; + pick ??= { fit, n }; + if (nextSettles(fit, k, n)) { + pick = { fit, n }; + break; + } + } + if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n)); + } previous = k; } return planned; diff --git a/resources/tester/helper_454_arrow_station_step.cjs b/resources/tester/helper_454_arrow_station_step.cjs new file mode 100644 index 000000000..fd8ac2627 --- /dev/null +++ b/resources/tester/helper_454_arrow_station_step.cjs @@ -0,0 +1,96 @@ +/* 454(433-21 ②) 방향키 측점 No. 걸음 — 무작위 짧은 다리 노선(14 IP · 고정 R) 교점마다 네 방향 한 걸음 + * 누르고 측점 밖 막힘 수를 JSON 한 줄로(`test_454_arrow_station_step.py` 가 판정). + * 누름 판정은 편집기 방향키 길과 같음 — 하한 깸 = 막힘 · 미리보기(`fitDrag`) 측점 밖이 늘면 막힘. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts")); +const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts")); + +const INTERVAL = 20; +const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL }; +const columns = (p) => ({ + planned: p, + curveOn: p.map(() => true), + curveRadius: p.map(() => null), + curveLock: p.map(() => null), + curveArc: p.map(() => null), + apexLock: p.map((_, i) => i === 0 || i === p.length - 1), +}); +const copy = (c) => ({ + ...c, + planned: c.planned.map(([x, y]) => [x, y]), + curveRadius: [...c.curveRadius], +}); +const off = (b) => fit.offStationCount(b.vertices, b.curves, INTERVAL); +const floor = (b) => edits.curveShortfalls(b.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM); + +function route(seed) { + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const line = [[0, 0]]; + let a = 0; + for (let i = 0; i < 14; i += 1) { + a += (rnd() - 0.5) * 1.6; + const length = 35 + rnd() * 60; + const p = line[line.length - 1]; + line.push([p[0] + Math.cos(a) * length, p[1] + Math.sin(a) * length]); + } + return line; +} + +const out = { press: 0, floorBlock: 0, offBlock: 0, offStart: 0, offAfterRelease: 0 }; +for (let seed = 1; seed <= 10; seed += 1) { + const hist = columns(fit.fitCurveStations(columns(route(seed)), LIMITS, 1)); + hist.curveRadius = edits + .rebuildLine(copy(hist), LIMITS) + .nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100)); + out.offStart += off(edits.rebuildLine(copy(hist), LIMITS)); + for (let k = 1; k < hist.planned.length - 1; k += 1) + for (const [dx, dy] of [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ]) { + const st = copy(hist); + const b0 = edits.rebuildLine(st, LIMITS); + const node = st.planned[k]; + st.planned[k] = [node[0] + dx * INTERVAL, node[1] + dy * INTERVAL]; + out.press += 1; + if (edits.shortfallCrossed(floor(b0), floor(edits.rebuildLine(st, LIMITS)))) { + out.floorBlock += 1; + continue; + } + const pv = { ...st, planned: fit.fitDrag(st, hist.planned, k, LIMITS) }; + if (off(edits.rebuildLine(copy(pv), LIMITS)) > off(b0)) { + out.offBlock += 1; + continue; + } + const rel = { ...pv, planned: fit.fitCurveStations(pv, LIMITS, fit.fitStart(hist, pv)) }; + if (off(edits.rebuildLine(copy(rel), LIMITS))) out.offAfterRelease += 1; + } +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_454_arrow_station_step.py b/resources/tester/test_454_arrow_station_step.py new file mode 100644 index 000000000..8dfd47aa3 --- /dev/null +++ b/resources/tester/test_454_arrow_station_step.py @@ -0,0 +1,35 @@ +"""454(433-21 ②) 방향키 측점 No. 20 m 걸음 막힘 — 곡선 맞춤이 후보 측점 둘(내림 · 올림) + 뒤 곡선 +앞보기로 고름. 무작위 노선 열 개 · 교점마다 네 방향 = 520 누름(`helper_454_arrow_station_step.cjs`). +고치기 전 측점 밖 막힘 48 · 처음 맞춤 측점 밖 2 · 놓은 뒤 측점 밖 43 → 6 · 0 · 0. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_454_arrow_station_step.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).with_name("helper_454_arrow_station_step.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=120 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_누름_수(got) -> None: + assert got["press"] == 520 + + +def test_측점_밖_막힘_줄어듦(got) -> None: + assert got["offBlock"] <= 10 + + +def test_처음_맞춤_놓은_뒤_측점_밖_없음(got) -> None: + assert got["offStart"] == 0 and got["offAfterRelease"] == 0 From 19bca9287bf70367ed1efb007d0055a9173607eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:28:08 +0900 Subject: [PATCH 02/11] =?UTF-8?q?fix(455=20433-5=20=EA=B4=80=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80=20=EC=9D=B4=EC=9B=83):=20=EC=83=88=20=EA=BC=AD?= =?UTF-8?q?=EC=A7=93=EC=A0=90=20=EC=9D=B4=EC=9B=83=EC=9D=B4=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=20=EC=A0=84=20=EA=B3=84=ED=9A=8D=EA=B3=A0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=9C=A8=EB=A9=B4=20=EB=A7=9E=EC=B6=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=94=EB=A1=9C=20=EC=95=9E=EB=92=A4=EB=B6=80?= =?UTF-8?q?=ED=84=B0=201mm=20=EB=84=98=EB=8A=94=20=EB=A7=8C=ED=81=BC=20?= =?UTF-8?q?=C2=B7=20=EB=86=92=EC=9D=B4=20=EA=B3=A0=EC=B9=9C=20=EA=BC=AD?= =?UTF-8?q?=EC=A7=93=EC=A0=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20TS?= =?UTF-8?q?=20=E2=86=94=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20=EC=A7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_profile_base.ts | 2 +- B05_Profile/B05_Profile_Router_Confirm.py | 55 +++++++++++++---- B05_Profile/logic/B05_Profile_Grade_Seat.ts | 53 +++++++++++----- common_util/common_util_structure_station.ts | 12 +++- resources/tester/helper_427_1_follow_seat.cjs | 2 +- resources/tester/test_427_1_follow_seat.py | 60 +++++++++++++++++++ 6 files changed, 151 insertions(+), 33 deletions(-) diff --git a/A00_Common/design/design_profile_base.ts b/A00_Common/design/design_profile_base.ts index e7410f0c6..ddad9ab42 100644 --- a/A00_Common/design/design_profile_base.ts +++ b/A00_Common/design/design_profile_base.ts @@ -44,7 +44,7 @@ export function listedBase( ); return { ...storedBase, - basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat), + basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held), stations: list.map((station) => ({ station_id: station.station_id, chainage_m: station.chainage_m, diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index f03524cf5..d9a1c43cc 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -213,14 +213,21 @@ async def sync_uphill_overrides_into_designs( _SAME_STATION_M = 0.006 #: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438). _SEAT_GROUND_M = 0.01 +#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). +_SEAT_KEEP_M = 0.001 def _seat_born_nodes( - stored: dict[str, Any], base: list[dict[str, Any]], born: list[float] + stored: dict[str, Any], + base: list[dict[str, Any]], + born: list[float], + held: list[float] = (), ) -> list[dict[str, Any]]: """새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427). - 다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 + 이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · + 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그 + 밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 = `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = @@ -258,20 +265,41 @@ def _seat_born_nodes( key=lambda row: row[0], ) base_s = np.array([row[0] for row in rows], dtype=np.float64) - target = np.array([row[1] for row in rows], dtype=np.float64) - free = np.array([row[2] is None for row in rows]) - base_z = target.copy() - for _ in range(20): + born_rows = np.array([row[2] is None for row in rows]) + # 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것 + target = np.where( + born_rows, + np.array([row[1] for row in rows], dtype=np.float64), + evaluate(pre_s, pre_z, pre_curves, base_s), + ) + can = np.array( + [ + row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held) + for row in rows + ] + ) + free = born_rows.copy() + base_z = np.array([row[1] for row in rows], dtype=np.float64) + for _ in range(len(rows)): + for _ in range(20): + curves, _warnings = build_curves(base_s, base_z, policy) + error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0) + if float(np.max(np.abs(error))) < 1e-6: + break + base_z = base_z + error + # 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼) curves, _warnings = build_curves(base_s, base_z, policy) - error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0) - if float(np.max(np.abs(error))) < 1e-6: + drift = ( + can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M) + ) + if not drift.any(): break - base_z = base_z + error + free |= drift return [ node - if node is not None - else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} - for (s, _target, node), z in zip(rows, base_z.tolist()) + if node is not None and not keep + else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} + for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist()) ] @@ -367,7 +395,8 @@ def _follow_moved_stations( changed = True if new_key is not None and new_key not in reseat: base.append({**node, "chainage_m": float(new_key)}) - seated = _seat_born_nodes(stored, base, seat) + held = [float(key) for key in next_edits["station_offsets"]] + seated = _seat_born_nodes(stored, base, seat, held) changed = changed or len(seated) != len(base) base = seated # 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) · diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index 7d42b50c2..d9fcd7330 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Seat.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Seat.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Seat.ts - * 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로. + * 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 이웃 꼭짓점은 곡선 모양이 + * 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로. * 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). * * 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes` @@ -16,6 +17,8 @@ import { /** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */ const SEAT_GROUND_M = 0.01; +/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */ +const SEAT_KEEP_M = 0.001; /** `np.median` 짝. */ function median(values: number[]): number { @@ -24,11 +27,13 @@ function median(values: number[]): number { return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } -/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */ +/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀 · + * `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(`followMovedNodes` 의 `held`). */ export function seatBornNodes( base: Pick, nodes: AlignmentNode[], born: ReadonlyArray, + held: ReadonlyArray = [], ): AlignmentNode[] { if (!nodes.length || base.chainage.length < 2) return nodes; const first = nodes[0].chainage_m; @@ -59,22 +64,40 @@ export function seatBornNodes( ...fresh.map((s) => ({ s, z: seatZ(s), node: null })), ].sort((a, b) => a.s - b.s); const baseS = rows.map((row) => row.s); - const target = rows.map((row) => row.z); - let baseZ = [...target]; - for (let round = 0; round < 20; round += 1) { + // 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것 + const target = rows.map((row) => (row.node ? evaluateAt(preS, preZ, preCurves, row.s) : row.z)); + const can = rows.map( + (row) => row.node !== null && held.every((value) => Math.abs(row.s - value) >= 0.006), + ); + const free = rows.map((row) => row.node === null); + let baseZ = rows.map((row) => row.z); + const errorOf = (): number[] => { const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] })); const curves = buildCurves(pvi, base.policy, {}, []); - const error = rows.map((row, index) => - row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]), + return baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s)); + }; + for (let grow = 0; grow < rows.length; grow += 1) { + for (let round = 0; round < 20; round += 1) { + const error = errorOf().map((value, index) => (free[index] ? value : 0)); + if (Math.max(...error.map(Math.abs)) < 1e-6) break; + baseZ = baseZ.map((z, index) => z + error[index]); + } + // 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼) + const drift = errorOf().map( + (value, index) => can[index] && !free[index] && Math.abs(value) > SEAT_KEEP_M, ); - if (Math.max(...error.map(Math.abs)) < 1e-6) break; - baseZ = baseZ.map((z, index) => z + error[index]); + if (!drift.includes(true)) break; + drift.forEach((hit, index) => { + if (hit) free[index] = true; + }); } - return rows.map( - (row, index) => - row.node ?? { - chainage_m: Number(row.s.toFixed(6)), - elevation_m: Number(baseZ[index].toFixed(6)), - }, + return rows.map((row, index) => + row.node && !free[index] + ? row.node + : { + ...row.node, + chainage_m: Number(row.s.toFixed(6)), + elevation_m: Number(baseZ[index].toFixed(6)), + }, ); } diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index b6eda5730..09b644cbc 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -344,14 +344,15 @@ export function oneStationList( nodes: ReadonlyArray, gone: ReadonlyArray, born: ReadonlyArray, edited: ReadonlyArray = [], -): { nodes: N[]; seat: number[] } { +): { nodes: N[]; seat: number[]; held: number[] } { const target = (old: number): number | null => { const near = born.find((value) => Math.abs(value - old) < 0.006); if (near !== undefined) return near; @@ -379,5 +380,10 @@ export function followMovedNodes( const to = moves.get(old) ?? null; return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }]; }); - return { nodes: kept, seat }; + const held = edited.flatMap((key) => { + const old = gone.find((value) => Math.abs(value - Number(key)) < 0.006); + const to = old === undefined ? Number(key) : (moves.get(old) ?? null); + return to === null ? [] : [to]; + }); + return { nodes: kept, seat, held }; } diff --git a/resources/tester/helper_427_1_follow_seat.cjs b/resources/tester/helper_427_1_follow_seat.cjs index 0d915ff69..9cedb64f9 100644 --- a/resources/tester/helper_427_1_follow_seat.cjs +++ b/resources/tester/helper_427_1_follow_seat.cjs @@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts"); const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts"); const out = cases.map((item) => { const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited); - return seat.seatBornNodes(item.base, moved.nodes, moved.seat); + return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held); }); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_427_1_follow_seat.py b/resources/tester/test_427_1_follow_seat.py index 6b5408ca1..23a316edd 100644 --- a/resources/tester/test_427_1_follow_seat.py +++ b/resources/tester/test_427_1_follow_seat.py @@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None: assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]] for mine, theirs in zip(nodes, server["base_pvi"]): assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) + + +def _balanced() -> dict: + """지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴).""" + data = _longitudinal() + for node in data["profile_alignment"]["base_pvi"]: + node["elevation_m"] -= 1.0 + alignment, profile = rebuild_alignment_profile(data, {}) + data["profile_alignment"], data["design_profiles"] = alignment, [profile] + return data + + +@pytest.mark.parametrize("make", [_longitudinal, _balanced]) +def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None: + """관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고.""" + first = make() + before = _rows(first, 0, 400) + _write(tmp_path, first) + after = _rows(_move(tmp_path, 356.0), 0, 400) + for at, value in before.items(): + if at != PIPE: + assert abs(after[at] - value) < 0.01, at + if make is _longitudinal: + assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4) + # 화면 짝 같은 꼭짓점 + stored = first["profile_alignment"] + base = { + "basePvi": stored["base_pvi"], + "chainage": [s["chainage_m"] for s in stored["samples"]], + "ground": [s["ground_elevation_m"] for s in stored["samples"]], + "policy": stored["policy"], + } + server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"] + [nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}]) + assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]] + for mine, theirs in zip(nodes, server["base_pvi"], strict=True): + assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) + + +def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None: + """340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝.""" + first = _longitudinal({"340.000": 0.3}) + stored = copy.deepcopy(first["profile_alignment"]) + _write(tmp_path, first) + server = _move(tmp_path, 356.0)["profile_alignment"] + old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]} + new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]} + assert new[340.0] == old[340.0] + assert new[360.0] != old[360.0] + base = { + "basePvi": stored["base_pvi"], + "chainage": [s["chainage_m"] for s in stored["samples"]], + "ground": [s["ground_elevation_m"] for s in stored["samples"]], + "policy": stored["policy"], + } + [nodes] = _ts( + tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}] + ) + for mine, theirs in zip(nodes, server["base_pvi"], strict=True): + assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6) From 33201b14ef754c387644faf8e3166d7097db45d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:29:23 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(449=20B05=20=EC=A0=80=EC=9E=A5):=20?= =?UTF-8?q?=EA=B4=80=20=EC=A0=95=EB=B3=B8=20PUT=20=EC=9D=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=B4=88=EC=95=88=20=ED=95=9C=20=EB=B2=88=20=C2=B7?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A9=B4=20=EC=B4=88=EC=95=88=EC=9D=84=20?= =?UTF-8?q?=EC=83=88=20=EB=85=B8=EC=84=A0=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-7 배수판 메모리 다시 PUT 걷음(판 안 선 [저장] 빈 목록 PUT 막음) · [확정] 초안 없음 = 칸 없이 다시 셈 · 판은 저장분 다시 받기만 - 433-12 노선 저장 뒤 다른 차례 실패 = 0 칸 계획고 → 새 노선 칸 · 횡단 초안 넘김(저장 없이 · 두 번 안 넘김) · 새 노선으로 다시 세움 - 433-6 관 PUT 끝 측점 맞춤 실패 = 응답 stations_failed · 알림 - 시험 test_449_save_pipes Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_save.ts | 34 +++- A00_Common/design/design_shared.ts | 11 +- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 12 +- .../B04_PreProcess_Router_Basins.py | 10 +- B05_Profile/B05_Profile_Api_Pipes_Draft.ts | 9 +- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 13 +- .../B05_Profile_UI_Drainage_Panel_Types.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 7 + B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 3 + resources/tester/helper_449_save_pipes.cjs | 152 ++++++++++++++++++ resources/tester/test_449_save_pipes.py | 105 ++++++++++++ 11 files changed, 328 insertions(+), 36 deletions(-) create mode 100644 resources/tester/helper_449_save_pipes.cjs create mode 100644 resources/tester/test_449_save_pipes.py diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 2edfd617b..7fa077b5e 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -9,6 +9,7 @@ * 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 → * 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움. * 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄). + * 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12). * 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄). * 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④). * @@ -38,6 +39,7 @@ import { import { markAlignmentSaved, pendingAlignment, + writeAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; import { clearState, stateKey } from "../b_page_state"; @@ -70,7 +72,7 @@ export interface DesignSaveContext { prepare?: () => Promise; /** * 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다. - * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤). + * `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7). */ steps?: Partial, () => Promise>>; /** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */ @@ -140,16 +142,35 @@ async function flushAlignmentDraft( if (moved) markAlignmentSaved(DRAFT_ROUTE_ID); } +/** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새 + * 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */ +async function keepDraftsOnNewRoute( + projectId: string, + fromRouteId: number, + routeId: number, + carried: boolean, +): Promise { + const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId); + if (draft) { + writeAlignmentDraft(routeId, draft); + markAlignmentSaved(DRAFT_ROUTE_ID); + markAlignmentSaved(fromRouteId); + } + if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false }); + await rebuildAfterRouteSave(projectId); +} + /** 차례마다 기본 창구. */ function defaultStep( step: Exclude, projectId: string, routeId: number, fromRouteId: number = routeId, + confirm = false, ): () => Promise { switch (step) { case "pipes": - return () => flushPendingPipes(projectId); + return () => flushPendingPipes(projectId, confirm); case "structures": return () => flushPendingStructures(projectId); case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2) @@ -197,6 +218,8 @@ export async function saveDesign( let routeId = fromRouteId; /** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */ let routeSaved: Awaited> | null = null; + /** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */ + let carried = false; showLoadingOverlay(); saving.add(ctx.projectId); try { @@ -215,6 +238,7 @@ export async function saveDesign( if (step === "cross") { if (!routeSaved) await ctx.cross(confirm); else { + carried = true; const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId); showToast(routeSaveSummary(routeSaved, outside), "info"); } @@ -224,7 +248,7 @@ export async function saveDesign( if (step === "uphill" && routeSaved) continue; // 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로. const own = routeSaved ? undefined : ctx.steps?.[step]; - const base = defaultStep(step, ctx.projectId, routeId, fromRouteId); + const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm); const run = step === "pipes" ? async () => { @@ -250,6 +274,10 @@ export async function saveDesign( await ctx.done?.(confirm); return true; } catch (error) { + if (routeSaved) + await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch( + () => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄) + ); ctx.failed(error, confirm); return false; } finally { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 66069900c..de9a99620 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -393,14 +393,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea routeId: () => routeIdOf(shared), prepare: crossSave?.prepare, steps: { - // 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신). - ...(side - ? { - pipes: async () => { - await side.savePipes(confirm); - }, - } - : {}), + // 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판 + // 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음). + ...(side ? { pipes: async () => side.reload(confirm) } : {}), // 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다. ...(profile ? { alignment: () => profile.save() } : {}), ...profileSave?.steps, diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 89744f458..6106be9bc 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -588,6 +588,8 @@ export interface DetailBasinResponse { upstream_lonlat: Array>; /** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */ snapshot_failed?: string[]; + /** 관 PUT 끝 측점 맞춤 실패(433-6 · 관 저장은 그대로). */ + stations_failed?: boolean; /** 지문(342-3) — 지금 노선 · 관 자리. */ route_signature?: string; pipe_signature?: string; @@ -645,15 +647,21 @@ export async function resetDetailPipePoints(projectId: string): Promise { // [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림. + // points null = 칸 없이 — 서버 저장분 그대로(433-7). const saved = await requestJson( `/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`, - { method: "PUT", body: JSON.stringify({ points }) }, + { method: "PUT", body: JSON.stringify(points ? { points } : {}) }, recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS, ); noteSnapshotFailed(saved.snapshot_failed); + if (saved.stations_failed) + showToast( + "관은 저장했지만 관 자리 측점을 못 맞췄습니다 — 횡단 「측점 없는 관」 알림에서 [측점 만들기]를 눌러 주세요.", + "error", + ); return saved; } diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 034f0e7da..6360da2e2 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -571,11 +571,12 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse: return _payload(project_id, context, detail, points, False, stale) -async def _stand_pipe_stations(project_id: UUID) -> None: +async def _stand_pipe_stations(project_id: UUID) -> bool: """관 PUT 끝 — 측점을 지금 관 자리로 다시 세움(`put_plus_stations` 처럼 · 부르는 길 모두 · 428). 노선 [저장] 뒤 관 차례가 화면 누가거리로 관을 덮으면 서버가 노선 저장 때 세운 측점과 어긋남 — - 여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로. + 여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로 · + 실패 = 거짓(응답 `stations_failed` 로 알림 · 433-6). """ from B06_Section.B06_Section_Router_Stations import ensure_structure_stations @@ -583,6 +584,8 @@ async def _stand_pipe_stations(project_id: UUID) -> None: await ensure_structure_stations(project_id) except Exception: logger.exception("관 저장: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id) + return False + return True @router.put("/{project_id}/drainage/pipe-points", response_model=None) @@ -627,8 +630,9 @@ async def put_pipe_points( failed = await asyncio.to_thread( safe_sync, resolve_stored_project_path(context.stored_path), importer(session) ) - await _stand_pipe_stations(project_id) + stood = await _stand_pipe_stations(project_id) result = _payload(project_id, context, detail, points, True, stale) result["saved_count"] = saved result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림 + result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림 return result diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts index acc0eb4d2..f4836f25d 100644 --- a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -27,10 +27,11 @@ export function writePendingPipes(projectId: string, next: PendingPipes | null): useDesignStore(projectId, null).set("pipes", next); } -/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */ -export async function flushPendingPipes(projectId: string): Promise { +/** 초안이 있으면 정본에 쓰고 비운다 — 관 정본 PUT 은 이 한 번뿐(433-7 · 배수판 메모리는 안 보냄). 빈 목록 = + * 사용자가 다 지움. 초안이 없으면 [저장]은 아무 일도 안 함 · [확정]은 `points` 없이(서버 저장분으로 다시 셈). */ +export async function flushPendingPipes(projectId: string, confirm = false): Promise { const pending = readPendingPipes(projectId); - if (!pending) return; - await saveDetailPipePoints(projectId, pending); + if (!pending && !confirm) return; + await saveDetailPipePoints(projectId, pending, confirm); writePendingPipes(projectId, null); } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 295dc6546..de00f3749 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -5,7 +5,6 @@ import { computeDetailBasins, fetchDetailPipePoints, getVWorldMapUrl, - saveDetailPipePoints, type DetailBasin, type DetailBasinResponse, type PipeSource, @@ -566,7 +565,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra projectId = nextProjectId; void loadLayers(); }, - reload() { + reload(fresh = false) { + if (fresh) live.markFresh(); // [확정] 뒤 — 낡음 걷음(응답이 아직 낡았으면 다시 닮) if (projectId && meta) void run(loadSaved); }, pipeChainages: () => pipeEditor.chainages(), @@ -667,15 +667,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(notify); scheduleDraw(); }, - async savePipes(confirm = false) { - if (!projectId) return 0; - // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). - const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm); - if (confirm && !response.stale) live.markFresh(); - apply(response); - scheduleDraw(); - return response.pipe_count; - }, setRoute(points) { routePoints = points; routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index f80f31a15..f42956a42 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -19,8 +19,9 @@ export interface DrainagePanel { root: HTMLElement; /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ load: (projectId: string) => void; - /** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */ - reload: () => void; + /** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b) · [저장] 뒤 + * 저장분으로(초안 비움 뒤 · 433-7). `fresh` = [확정] 뒤 낡음 걷음. */ + reload: (fresh?: boolean) => void; /** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */ setRoute: (points: ReadonlyArray) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ @@ -42,9 +43,6 @@ export interface DrainagePanel { attributes: FacilityAttributes, ) => void; removePipe: (chainageM: number) => void; - /** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */ - /** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */ - savePipes: (confirm?: boolean) => Promise; /** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */ pipelineStage: StageFn<"drainage">; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 4bbb56270..a686aa18c 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -147,6 +147,13 @@ export function pendingAlignment(routeId: number): AlignmentEdits | null { return readAlignmentDraft(routeId); } +/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */ +export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void { + draftOf(routeId).set("alignmentDraft", edits); + const state = shared.get(routeId); + if (state) Object.assign(state, { current: edits, unsaved: true }); +} + /** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ export function markAlignmentSaved(routeId: number): void { const state = shared.get(routeId); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index 11ef38788..e08a53272 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -160,11 +160,13 @@ const savedPipes = (projectId: string): Promise => /** * 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 — * 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로). + * `save` 거짓 = 넘기기만(뒤 차례 실패 · 433-12 — 다음 [저장]이 새 노선 초안을 보냄). */ export async function carryAfterRouteSave( projectId: string, fromRouteId: number, toRouteId: number, + { save = true }: { save?: boolean } = {}, ): Promise { invalidateSectionDetail(projectId, toRouteId); const detail = await loadSectionDetail(projectId, toRouteId); @@ -192,6 +194,7 @@ export async function carryAfterRouteSave( const uphillDropped = uphillBefore - uphillCount(); if (uphillDropped > 0) showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info"); + if (!save) return outside; await flushUphillOverrides(projectId); // 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417) withDraftAdjusts(detail, projectId, toRouteId); diff --git a/resources/tester/helper_449_save_pipes.cjs b/resources/tester/helper_449_save_pipes.cjs new file mode 100644 index 000000000..23380951b --- /dev/null +++ b/resources/tester/helper_449_save_pipes.cjs @@ -0,0 +1,152 @@ +/* PLAN 449 헬퍼 — [저장] 관 차례 · 노선 저장 뒤 실패(433-7 · 433-12). 실제 `design_save.ts` · 관 초안 창구 · 가게를 + * Node 로 · 서버 창구 · 계획고 초안 · 노선 편집 창구만 가짜. 짝 = `test_449_save_pipes.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +let calls = []; +let failAt = null; // 이 차례에서 throw +let routeBody = null; +const alignment = new Map(); // 노선 칸 → 계획고 초안 +STUBS["ui_template_elements"] = { + showLoadingOverlay: () => undefined, + hideLoadingOverlay: () => undefined, + showToast: () => undefined, +}; +const failing = (name) => { + if (failAt === name) throw new Error(`${name} 409`); +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => (calls.push(["GET"]), { pipe_points: [] }), + saveDetailPipePoints: async (_projectId, points, recompute) => { + calls.push(["PUT", points ?? null, !!recompute]); + return {}; + }, +}; +STUBS["B05_Profile_Api_Structures"] = { + flushPendingStructures: async () => failing("structures"), +}; +STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined }; +STUBS["B05_Profile_Api_Fetch"] = { + flushUphillOverrides: async () => undefined, + saveProfileAlignment: async (_projectId, routeId, draft) => { + calls.push(["ALIGN", routeId, draft]); + return {}; + }, +}; +STUBS["B05_Profile_UI_Profile_Edit"] = { + pendingAlignment: (routeId) => alignment.get(routeId) ?? null, + markAlignmentSaved: (routeId) => alignment.delete(routeId), + writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits), +}; +STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined }; +STUBS["B05_Profile_UI_RouteEdit_Apply"] = { + pendingRouteSave: async () => routeBody, + saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }), + carryAfterRouteSave: async (_projectId, from, to, options) => { + calls.push(["CARRY", from, to, options?.save ?? true]); + failing("carry"); + return 0; + }, + routeSaveSummary: () => "", + rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]), +}; +const save = load("A00_Common", "design", "design_save.ts"); +const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts"); + +const P = "proj-449"; +async function once({ route = false, draft = null, confirm = false, fail = null, routeId = 7 }) { + calls = []; + failAt = fail; + routeBody = route ? { draft: {} } : null; + drafts.writePendingPipes(P, draft); + let failure = null; + const ok = await save.saveDesign( + { + projectId: P, + routeId: () => routeId, + // 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음 + steps: { pipes: async () => void calls.push(["RELOAD"]) }, + cross: async () => void calls.push(["CROSS"]), + failed: (error) => (failure = String(error && error.message ? error.message : error)), + }, + { confirm }, + ); + return { ok, failure, calls, left: drafts.readPendingPipes(P) }; +} + +async function run() { + window.localStorage.setItem("frd_current_project_id", P); + const fifteen = [{ chainage_m: 15, source: "user", id: "p1" }]; + const out = {}; + out.draft = await once({ draft: fifteen }); // B06 고침 직후 [저장] = 초안 값 + out.noDraft = await once({}); // 판 안 섬 · 초안 없음 = PUT 없음 + out.confirmNoDraft = await once({ confirm: true }); // [확정] = 칸 없이(서버 저장분) + out.emptyDraft = await once({ draft: [] }); // 사용자가 다 지움 = 빈 목록 + // 433-12 — 노선 저장 뒤 구조물 실패 → 0 칸 계획고가 새 노선 칸에 · 다음 [저장]이 보냄 + alignment.clear(); + alignment.set(0, { pvi: { 120: 1.5 } }); + out.routeFail = await once({ route: true, fail: "structures" }); + out.alignAfterFail = Object.fromEntries(alignment); + out.nextSave = await once({ routeId: 8 }); + // 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김 + alignment.clear(); + out.carryFail = await once({ route: true, fail: "carry" }); + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_449_save_pipes.py b/resources/tester/test_449_save_pipes.py new file mode 100644 index 000000000..302f0a58a --- /dev/null +++ b/resources/tester/test_449_save_pipes.py @@ -0,0 +1,105 @@ +"""PLAN 449 — B05 [저장] 관 목록 · 노선 저장 뒤 실패(433-7 · 433-12 · 433-6). + +· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄 +· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움 +· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 → + 다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김 +· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed` +짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(scope="module") +def out() -> dict: + if not (ROOT / "config" / "node_modules" / "typescript").is_dir(): + pytest.skip("프론트엔드 의존성 미설치") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_449_save_pipes.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _puts(case: dict) -> list: + return [call for call in case["calls"] if call[0] == "PUT"] + + +def test_관_PUT_은_초안_한_번(out): + case = out["draft"] + assert case["ok"] and _puts(case) == [ + ["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False] + ] + assert case["calls"].index(["RELOAD"]) > case["calls"].index( + _puts(case)[0] + ) # 화면은 뒤에 받기만 + assert case["left"] is None + + +def test_초안_없으면_PUT_없음(out): + assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == [] + + +def test_확정은_칸_없이_다시_셈(out): + assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]] + + +def test_빈_초안은_다_지움(out): + assert _puts(out["emptyDraft"]) == [["PUT", [], False]] + + +def test_노선_저장_뒤_실패_초안_새_노선(out): + case = out["routeFail"] + assert not case["ok"] and "구조물" in case["failure"] + assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]] + assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸 + align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"] + assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]] + + +def test_넘김_실패면_두_번_안_넘김(out): + case = out["carryFail"] + assert not case["ok"] + assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]] + assert case["calls"][-1] == ["REBUILD"] + + +def test_공용_저장이_판_메모리를_안_보냄(): + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + panel = (ROOT / "B05_Profile/B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8") + assert "savePipes" not in shared and "saveDetailPipePoints" not in panel + + +def test_측점_맞춤_실패는_응답에(monkeypatch): + from B04_PreProcess import B04_PreProcess_Router_Basins as basins + from B06_Section import B06_Section_Router_Stations as stations + + async def boom(_project_id): + raise RuntimeError("샘플링 없음") + + async def fine(_project_id): + return 1 + + monkeypatch.setattr(stations, "ensure_structure_stations", boom) + assert asyncio.run(basins._stand_pipe_stations(uuid4())) is False + monkeypatch.setattr(stations, "ensure_structure_stations", fine) + assert asyncio.run(basins._stand_pipe_stations(uuid4())) is True From 5478eb1f882e27d9a31f1c2edb5626657eba4226 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:32:29 +0900 Subject: [PATCH 04/11] =?UTF-8?q?fix(452=20=EC=84=9C=EB=B2=84):=20?= =?UTF-8?q?=EA=B5=AC=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EA=B4=80?= =?UTF-8?q?=EC=9D=80=20=EC=A0=80=EC=9E=A5=20=EC=A2=85=EB=8B=A8=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20PUT=20=EB=92=A4=20=EC=98=9B=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=ED=8C=8C=EC=9D=BC=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=EC=A2=85=EC=A0=90=20=EC=97=B4=EC=87=A0=20=ED=95=9C=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-17① 이관 원천 = 저장 종단 파일 비정규 측점(서버가 직접 읽음) · 화면은 노선 id 만 보냄 — 초안 관 자리가 「기타」로 안 섞임 - 433-10 구조물 측점 다시 뜸(관 PUT · 측점 만들기) 끝에 옛 자리 cross_*.json 정리 - 433-18 서버 station_keys = 맨 앞 · 맨 끝 end:BP/EP 먼저 · 구간형 :start · :end · B06 stationKeysOf 도 맨 앞 · 맨 끝 end — 거울 시험 test_452 Co-Authored-By: Claude Opus 5.5 --- .../design/design_view_profile_sections.ts | 16 +- B05_Profile/B05_Profile_Api_Structures.ts | 7 +- B05_Profile/B05_Profile_Route_Save_Parts.py | 31 +- B05_Profile/B05_Profile_Structures_Router.py | 44 ++- B06_Section/B06_Section_Router_Stations.py | 3 + B06_Section/B06_Section_Station_Keys.ts | 9 +- .../tester/helper_411_b_pipe_id_pairing.mjs | 18 +- resources/tester/helper_452_entry.ts | 2 + resources/tester/helper_452_keys.mjs | 36 +++ resources/tester/test_346_3_route_save.py | 6 +- .../test_452_server_keys_prune_migrate.py | 291 ++++++++++++++++++ .../tester/test_b05_structures_router.py | 12 +- 12 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 resources/tester/helper_452_entry.ts create mode 100644 resources/tester/helper_452_keys.mjs create mode 100644 resources/tester/test_452_server_keys_prune_migrate.py diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index f83236d94..c7a7208d2 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -126,17 +126,15 @@ export function createProfileSections(deps: ProfileSectionsDeps) { try { renderSections(detail, routeId); // 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만 - // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). - const legacy = detail.longitudinal.stations - .filter((station) => station.kind === "irregular") - .map((station) => ({ - chainage_m: station.chainage_m, - structure: station.structure ?? "", - })); + // 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). 측점은 서버가 저장 + // 종단 파일에서 읽음 — 이 상세는 초안이 섞일 수 있어 보내지 않음(452). + const hasLegacy = detail.longitudinal.stations.some( + (station) => station.kind === "irregular", + ); const migrateKey = `${projectId}:${routeId}`; - if (legacy.length && !migratedRoutes.has(migrateKey)) { + if (hasLegacy && !migratedRoutes.has(migrateKey)) { migratedRoutes.add(migrateKey); - void migrateLegacyStations(projectId, legacy) + void migrateLegacyStations(projectId, routeId) .then((result) => { if (result.migrated > 0) { showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`); diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 3a1deaa1c..2c7f147dd 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -313,14 +313,15 @@ export async function saveStructures( } /** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점 - * 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */ + * 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. + * 측점은 서버가 저장 종단 파일에서 읽음 — 화면 상세(초안 섞임)를 안 보냄(452). */ export async function migrateLegacyStations( projectId: string, - stations: Array<{ chainage_m: number; structure: string }>, + routeId: number, ): Promise<{ status: string; migrated: number; revision: number }> { return requestJson(`/projects/${projectId}/route/structures/migrate`, { method: "POST", - body: JSON.stringify({ stations }), + body: JSON.stringify({ route_id: routeId }), }); } diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index 46533d795..e26c9918e 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -93,19 +93,25 @@ def add_crossing_pipes( def _facility_owners( pipes: Sequence[PipePoint], structures: Sequence[Any] -) -> dict[float, tuple[str, str | None]]: - """측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙).""" - owners: dict[float, tuple[str, str | None]] = {} +) -> dict[float, tuple[str, str | None, str]]: + """측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저 + (`plan_stations` 이름 규칙).""" + owners: dict[float, tuple[str, str | None, str]] = {} for pipe in pipes: for mark in plan_stations([pipe], []): owners.setdefault( - round(float(mark["chainage_m"]), 2), ("pipe", getattr(pipe, "id", None)) + round(float(mark["chainage_m"]), 2), + ("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")), ) for item in structures: for mark in plan_stations([], [item]): owners.setdefault( round(float(mark["chainage_m"]), 2), - ("structure", getattr(item, "structure_id", None)), + ( + "structure", + getattr(item, "structure_id", None), + str(mark.get("role") or "anchor"), + ), ) return owners @@ -118,8 +124,9 @@ def station_keys( ) -> dict[str, float]: """측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리. - 관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) · - id 없는 관 · 구조물은 `at:` · + 시종점(맨 앞 · 맨 끝 = 관 · 구조물이 얹혀도 `end:BP/EP` · 화면 `buildStations` · B06 + `stationKeysOf` 와 같음 · 452) > 관 · 구조물 자리(구간형 시작 · 끝 `:start` · `:end`) > + 곡선 측점(`curve:` · 252-2) > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` · 같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음). """ owners = _facility_owners(pipes, structures) @@ -128,14 +135,14 @@ def station_keys( out: dict[str, float] = {} for index, chainage in enumerate(values): owner = owners.get(round(chainage, 2)) - if owner is not None: - key = station_key(owner[0], chainage, interval_m, owner[1]) - elif rows[index].get("kind") == "curve": - key = station_key("curve", chainage, interval_m) - elif index == 0: + if index == 0: key = station_key("bp", chainage, interval_m) elif index == len(values) - 1: key = station_key("ep", chainage, interval_m) + elif owner is not None: + key = station_key(owner[0], chainage, interval_m, owner[1], role=owner[2]) + elif rows[index].get("kind") == "curve": + key = station_key("curve", chainage, interval_m) elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M: key = station_key("regular", chainage, interval_m) else: diff --git a/B05_Profile/B05_Profile_Structures_Router.py b/B05_Profile/B05_Profile_Structures_Router.py index d425b0240..3efa4ca10 100644 --- a/B05_Profile/B05_Profile_Structures_Router.py +++ b/B05_Profile/B05_Profile_Structures_Router.py @@ -9,6 +9,7 @@ """ import asyncio +import json import logging from pathlib import Path from typing import Any @@ -16,7 +17,7 @@ from uuid import UUID from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Repository import get_latest_route @@ -239,9 +240,43 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo class StructureMigrateRequest(BaseModel): - """구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로.""" + """구 비정규 측점 이관 요청 — 노선 id 만(없으면 최신 노선). - stations: list[dict[str, Any]] = Field(default_factory=list) + 측점은 서버가 저장 종단 파일에서 읽음(452).""" + + route_id: int | None = None + + +async def _saved_irregular_stations( + project_id: UUID, root: str, route_id: int | None +) -> list[dict[str, Any]]: + """저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안 + (옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음).""" + from B06_Section.B06_Section_Repository import get_longitudinal_section + from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path + + pool = get_db_pool() + async with pool.acquire() as connection: + if route_id is None: + latest = await get_latest_route(connection, project_id) + route_id = latest["id"] if latest else None + row = ( + await get_longitudinal_section(connection, project_id, route_id) + if route_id is not None + else None + ) + if not row or not row.get("longitudinal_file_path"): + return [] + try: + path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"])) + stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or [] + except (OSError, ValueError, AttributeError): + return [] + return [ + {"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""} + for station in stations + if station.get("kind") == "irregular" and station.get("chainage_m") is not None + ] @router.post("/{project_id}/route/structures/migrate", response_model=None) @@ -267,7 +302,8 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest) plus_chainages(root), ) ] - plan = migrate_irregular_stations(payload.stations, planted) + legacy = await _saved_irregular_stations(project_id, root, payload.route_id) + plan = migrate_irregular_stations(legacy, planted) # 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고, # 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다 # (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본). diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index dd0ebd5e2..1a039a781 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -247,6 +247,7 @@ async def _regenerate_structure_stations( _section_options_from_stored, drop_rows_off_stations, ) + from B06_Section.B06_Section_Router_Confirm import _prune_cross_files stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot) @@ -269,6 +270,8 @@ async def _regenerate_structure_stations( summary = _merge_irregular_into_longitudinal( project_root, str(longitudinal["longitudinal_file_path"]), stations ) + # 측점을 고친 그 자리에서 옛 자리 cross_*.json 도 지움(관 PUT · 452) + _prune_cross_files(project_root, str(longitudinal["longitudinal_file_path"])) return len(stations), summary made, summary = await asyncio.to_thread(_regenerate) diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index 5556e71d8..46f82a157 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -148,7 +148,8 @@ export function useStructureIds( const looksLikePipe = (section: CrossSection): boolean => Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); -/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). */ +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이 + * 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */ export function stationKeysOf( projectId: string | null, sections: ReadonlyArray, @@ -156,10 +157,12 @@ export function stationKeysOf( ): Keyed[] { const known = projectId ? (owners.get(projectId) ?? []) : []; const planned = known.some((owner) => owner.kind === "pipe"); - const keys = sections.map((section) => { + const keys = sections.map((section, index) => { const at = section.chainage_m; let key: string; - if (section.kind === "irregular") { + if (index === 0) key = stationKey("bp", at, intervalM); + else if (index === sections.length - 1) key = stationKey("ep", at, intervalM); + else if (section.kind === "irregular") { const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); else if (looksLikePipe(section)) { diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs index c41f8f15d..bb30a556a 100644 --- a/resources/tester/helper_411_b_pipe_id_pairing.mjs +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -146,7 +146,15 @@ m.followEditorPipes(fresh2, m.createFacilityStore(), [ ]); out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m])); -/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */ +/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) · 맨 앞 · 맨 끝 = 시종점(452)이라 BP · EP 사이에 둠 */ +const keysOf = (project, list) => + m + .stationKeysOf( + project, + [{ chainage_m: 0, kind: "bp" }, ...list, { chainage_m: 400, kind: "ep" }], + 20, + ) + .slice(1, -1); const planned = [ { chainage_m: 80.17, owners: ["p8017"], role: "anchor" }, { chainage_m: 52.5, owners: ["st-1"], role: "anchor" }, @@ -156,9 +164,9 @@ const sections = [ { chainage_m: 80.17, kind: "irregular" }, ]; m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]); -out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key); +out.keys = keysOf(P, sections).map((k) => k.key); m.useStationOwners(P, planned); -out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key); +out.keysNoPipes = keysOf(P, sections).map((k) => k.key); out.pipelineKey = m.pipelineStationKey("pipe", "p8017"); /* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */ @@ -171,10 +179,10 @@ const staleSections = [ { chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} }, { chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리 ]; -out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key); +out.staleKeys = keysOf(E, staleSections).map((k) => k.key); out.staleBook = bookSize(); m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음 -out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key; +out.legacyKey = keysOf(E, [staleSections[1]])[0].key; out.legacyBook = bookSize(); m.prunePipeIds([80], E); // 지금 관 자리만 남김 out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 }; diff --git a/resources/tester/helper_452_entry.ts b/resources/tester/helper_452_entry.ts new file mode 100644 index 000000000..ad4578246 --- /dev/null +++ b/resources/tester/helper_452_entry.ts @@ -0,0 +1,2 @@ +/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */ +export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys"; diff --git a/resources/tester/helper_452_keys.mjs b/resources/tester/helper_452_keys.mjs new file mode 100644 index 000000000..ff69c27c6 --- /dev/null +++ b/resources/tester/helper_452_keys.mjs @@ -0,0 +1,36 @@ +/* 452 헬퍼 — B06 `stationKeysOf` 열쇠를 JSON 으로. + * node helper_452_keys.mjs <묶음.mjs> <입력.json> + * 입력 = { sections: [{chainage_m, kind}], structures: [{structure_id, chainage_m, start_m, end_m}], interval }. + */ +import { readFileSync } from "node:fs"; +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; + +const [bundlePath, inputPath] = process.argv.slice(2); +const m = await import(pathToFileURL(bundlePath).href); +const input = JSON.parse(readFileSync(inputPath, "utf8")); +m.useStructureIds("p452", input.structures); +const sections = input.sections.map((s) => ({ station_id: `s${s.chainage_m}`, samples: [], ...s })); +process.stdout.write(JSON.stringify(m.stationKeysOf("p452", sections, input.interval))); diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 4fdae39b3..5960dd7c2 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -123,9 +123,9 @@ def test_측점_열쇠_규칙(): "at:33.30": 33.3, # id 없는 관 "grid:2": 40.0, "pipe:p-7": 50.0, - "structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐 - "at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at: - "at:80.00": 80.0, + "structure:w1:start": 60.0, # 구간형 시작 · 끝 = :start · :end(452 · 화면과 한 규칙) + "structure:w1": 77.0, + "structure:w1:end": 80.0, "end:EP": 95.5, } assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음 diff --git a/resources/tester/test_452_server_keys_prune_migrate.py b/resources/tester/test_452_server_keys_prune_migrate.py new file mode 100644 index 000000000..5dfe73c67 --- /dev/null +++ b/resources/tester/test_452_server_keys_prune_migrate.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +"""452 서버 — 구 구조물 이관 · 옛 횡단 파일 · 시종점 열쇠 (433-17① · 433-10 · 433-18). + +못박는 것 + - 이관 원천 = 저장 종단 파일의 비정규 측점 — 화면 본문(초안 관 자리 섞임)은 안 믿음 · + 저장 관 자리면 structures.json · pipe_points.json 그대로. + - 관 PUT 끝 측점 다시 뜸(`_regenerate_structure_stations`) = 옛 자리 cross_*.json 도 지움. + - 시종점 열쇠 한 규칙 — 맨 앞 · 맨 끝은 구조물이 얹혀도(병합이 종류를 irregular 로 갈음해도) + `end:BP/EP` · 구간형 시작 · 끝 = `:start` · `:end` · 서버 `station_keys` = B06 `stationKeysOf`. +""" + +import asyncio +import json +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +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 router_module # noqa: E402 +import B06_Section.B06_Section_Repository as b06_repo # noqa: E402 +from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import station_keys # noqa: E402 +from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402 + +PROJECT_ID = "45245245-2452-4524-5245-245245245245" +LONG_REL = "B06_Section/longitudinal/longitudinal.json" + + +def _write_longitudinal(project: Path, stations): + path = project / LONG_REL + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"stations": stations}), encoding="utf-8") + return path + + +# ── 433-17① 이관 = 저장 종단 파일 ───────────────────────────────────────── + + +@pytest.fixture() +def migrate_client(tmp_path, monkeypatch): + project = tmp_path / "project" + + async def fake_root(project_id): + return str(project) + + async def fake_length(project_id): + return 400.0 + + async def fake_latest(connection, project_id): + return {"id": 7} + + async def fake_longitudinal(connection, project_id, route_id): + assert route_id == 7 + return {"longitudinal_file_path": LONG_REL} + + class _Acquire: + async def __aenter__(self): + return object() + + async def __aexit__(self, *exc): + return False + + monkeypatch.setattr(router_module, "_project_root", fake_root) + monkeypatch.setattr(router_module, "_route_length", fake_length) + monkeypatch.setattr(router_module, "get_latest_route", fake_latest) + monkeypatch.setattr(router_module, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire)) + monkeypatch.setattr(b06_repo, "get_longitudinal_section", fake_longitudinal) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app), project + + +def test_초안_측점_본문은_버리고_저장_관_자리는_이관_안_함(migrate_client): + client, project = migrate_client + pipes = pipe_points_path_in(project) + pipes.parent.mkdir(parents=True, exist_ok=True) + pipes.write_text( + json.dumps({"route_signature": "s", "points": [{"chainage_m": 351.0, "id": "p1"}]}), + encoding="utf-8", + ) + _write_longitudinal( + project, + [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 400.0, "kind": "ep"}, + ], + ) + before = pipes.read_bytes() + # 화면이 초안(관 351 → 364) 상세를 들고 와도 — 옛 본문 꼴을 보내 봄 + body = {"route_id": 7, "stations": [{"chainage_m": 364.0, "structure": "낙석방지책"}]} + response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=body) + assert response.status_code == 200, response.text + assert response.json()["migrated"] == 0 + assert not (project / "B05_Profile" / "route" / "structures.json").exists() + assert pipes.read_bytes() == before + + +def test_저장_종단의_옛_자유글_측점은_이관(migrate_client): + client, project = migrate_client + _write_longitudinal( + project, + [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 260.0, "kind": "irregular", "structure": "낙석방지책"}, + {"chainage_m": 400.0, "kind": "ep"}, + ], + ) + response = client.post( + f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"route_id": None} + ) + assert response.status_code == 200, response.text + assert response.json()["migrated"] == 1 + stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"] + assert [(item["type_id"], item["chainage_m"]) for item in stored] == [("etc", 260.0)] + + +def test_저장_종단이_없으면_아무것도_안_함(migrate_client): + client, project = migrate_client + response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={}) + assert response.status_code == 200 and response.json()["migrated"] == 0 + assert not (project / "B05_Profile" / "route" / "structures.json").exists() + + +# ── 433-10 관 PUT 측점 다시 뜸 = 옛 cross 파일 지움 ───────────────────────── + + +def test_관_옮김_측점_다시_뜸이_옛_자리_횡단_파일을_지움(tmp_path, monkeypatch): + import B05_Profile.B05_Profile_Engine_Sections as engine + import B06_Section.B06_Section_Router_Stations as stations_module + + _write_longitudinal( + tmp_path, + [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"}, + {"chainage_m": 400.0, "kind": "ep"}, + ], + ) + cross_dir = tmp_path / "B06_Section" / "cross_sections" + cross_dir.mkdir(parents=True) + for at in (0.0, 351.0, 400.0): + (cross_dir / cross_filename(at)).write_text("{}", encoding="utf-8") + + def fake_generate(*args, **kwargs): + (cross_dir / cross_filename(361.0)).write_text("{}", encoding="utf-8") + return [{"chainage_m": 361.0, "kind": "irregular", "structure": "파형강관 D800"}] + + async def fake_inputs(project_id, route, snapshot): + return None, None, {"longitudinal_file_path": LONG_REL} + + async def fake_run(fn, *args): + return None # DB 행 맞춤 · 등급 요약은 이 시험 밖 + + monkeypatch.setattr(engine, "_load_route_polyline", lambda *a: [[0.0, 0.0], [400.0, 0.0]]) + monkeypatch.setattr(engine, "_load_pipe_points", lambda *a: []) + monkeypatch.setattr(engine, "resolve_extra_stations", lambda *a: []) + monkeypatch.setattr(engine, "generate_irregular_sections", fake_generate) + monkeypatch.setattr(stations_module, "_generation_inputs", fake_inputs) + monkeypatch.setattr(stations_module, "run_with_connection", fake_run) + + made = asyncio.run( + stations_module._regenerate_structure_stations( + PROJECT_ID, + tmp_path, + {"id": 7, "route_data_path": "route.json"}, + {"filter_key": "f", "method": "m"}, + ) + ) + assert made == 1 + assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted( + cross_filename(at) for at in (0.0, 361.0, 400.0) + ) + + +# ── 433-18 시종점 열쇠 한 규칙 ───────────────────────────────────────────── + + +STRUCTURES = [ + {"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 80.0, "start_m": 60.0}, +] +# 구간형 옹벽 끝 = 노선 끝(EP) — 병합이 EP 행 종류를 irregular 로 갈음한 저장분 꼴 +SECTIONS = [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 40.0, "kind": "regular"}, + {"chainage_m": 60.0, "kind": "irregular", "structure": "옹벽"}, + {"chainage_m": 80.0, "kind": "irregular", "structure": "옹벽"}, + {"chainage_m": 100.0, "kind": "irregular", "structure": "옹벽"}, +] + + +def _structures_ns(): + return [SimpleNamespace(**item, end_m=100.0, options={}) for item in STRUCTURES] + + +def test_서버_시종점_열쇠가_구조물보다_먼저(): + keys = station_keys(SECTIONS, [], _structures_ns(), 20.0) + assert keys == { + "end:BP": 0.0, + "grid:1": 20.0, + "grid:2": 40.0, + "structure:w1:start": 60.0, + "structure:w1": 80.0, + "end:EP": 100.0, + } + + +def test_B06_열쇠가_서버와_같음(tmp_path): + work = tmp_path / "b452" + build = subprocess.run( + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_452_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_452_entry.js", bundle) + source = work / "input.json" + source.write_text( + json.dumps( + { + "sections": SECTIONS, + "structures": [{**item, "end_m": 100.0} for item in STRUCTURES], + "interval": 20.0, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( + ["node", str(HERE / "helper_452_keys.mjs"), str(bundle), str(source)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + ts = {entry["key"]: entry["chainageM"] for entry in json.loads(proc.stdout)} + assert ts == station_keys(SECTIONS, [], _structures_ns(), 20.0) + + +def test_노선_저장_이월_EP_고침이_새_EP_에_남음(): + from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs + + old_keys = station_keys(SECTIONS, [], _structures_ns(), 20.0) + # 노선이 늘어 옹벽이 따라감 — 끝(EP) 110 · 시작 70 · 기준 90 + moved = [ + SimpleNamespace( + structure_id="w1", + type_id="retaining_wall", + chainage_m=90.0, + start_m=70.0, + end_m=110.0, + options={}, + ) + ] + new_sections = [ + {"chainage_m": at, "kind": "regular"} for at in (0.0, 20.0, 40.0, 60.0, 70.0, 80.0, 90.0) + ] + [{"chainage_m": 100.0, "kind": "regular"}, {"chainage_m": 110.0, "kind": "irregular"}] + new_keys = station_keys(new_sections, [], moved, 20.0) + designs = {100.0: {"ground_type": "rock"}, 60.0: {"ground_type": "weathered"}} + inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, ("ground_type",), ()) + assert inputs[110.0] == {"ground_type": "rock"} # EP 고침 = 새 EP + assert inputs[70.0] == {"ground_type": "weathered"} # 구간 시작 고침 = 따라간 시작 + assert dropped == 0 diff --git a/resources/tester/test_b05_structures_router.py b/resources/tester/test_b05_structures_router.py index 74eb5d41c..40f5bbdce 100644 --- a/resources/tester/test_b05_structures_router.py +++ b/resources/tester/test_b05_structures_router.py @@ -30,7 +30,13 @@ def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, rou monkeypatch.setattr(router_module, "_project_root", fake_project_root) monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate) + + async def fake_saved_irregular(project_id, root, route_id): + # 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌 + return _migrate_payload()["stations"] + monkeypatch.setattr(router_module, "_route_length", fake_route_length) + monkeypatch.setattr(router_module, "_saved_irregular_stations", fake_saved_irregular) app = FastAPI() app.include_router(router_module.router) return TestClient(app) @@ -298,7 +304,11 @@ def test_migrate_keeps_existing_structures(client, tmp_path): assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로) -def test_migrate_empty_list_is_noop(client): +def test_migrate_empty_list_is_noop(client, monkeypatch): + async def none_saved(project_id, root, route_id): + return [] + + monkeypatch.setattr(router_module, "_saved_irregular_stations", none_saved) response = client.post( f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []} ) From 9c76d32c1eeb30a338bfcacccabad244d7f50591 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:32:43 +0900 Subject: [PATCH 05/11] =?UTF-8?q?fix(455=20433-13=E2=91=A0=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=E2=96=B2=E2=96=BC=20=EC=9E=90=EB=8F=99=20R):=20?= =?UTF-8?q?=EB=88=84=EB=A5=B8=20=EA=BC=AD=EC=A7=93=EC=A0=90=20R=20?= =?UTF-8?q?=EC=9D=B4=20=EC=A0=80=EC=9E=A5=20=EC=9E=90=EB=8F=99=20R=20?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9C=BC=EB=A9=B4=20=EC=A7=80=EC=9B=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20L=20=C2=B7=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20R=20=EC=9D=80=20=EB=91=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_profile.ts | 1 + B05_Profile/B05_Profile_UI_Profile_Edit.ts | 3 + .../B05_Profile_UI_Profile_Panel_Tools.ts | 4 +- B05_Profile/B05_Profile_UI_Profile_Render.ts | 6 +- .../logic/B05_Profile_UI_Profile_Alignment.ts | 3 + resources/tester/helper_219_1_arrow_owner.cjs | 1 + .../tester/helper_455_arrow_auto_radius.cjs | 77 +++++++++++++++++++ resources/tester/test_193_1_line_axis.py | 4 +- resources/tester/test_355_1_pivot_tilt.py | 5 +- .../tester/test_455_arrow_auto_radius.py | 52 +++++++++++++ 10 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_455_arrow_auto_radius.cjs create mode 100644 resources/tester/test_455_arrow_auto_radius.py diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8cbb093ff..e31cf0acb 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -280,6 +280,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { base: () => base, alignment: () => alignment, edits: () => store.edits(), + savedRadii: () => store.savedRadii(), applyEdits: (next) => applyEdits(next), selectedStationId: () => selectedStationId, irregularStations: () => irregularStations, diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 4bbb56270..c0923b005 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): { export interface ProfileEditStore { edits(): AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */ + savedRadii(): Readonly>; replace(next: AlignmentEdits): void; /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. * 이때의 값이 [초기화]의 기준점이 된다. */ @@ -202,6 +204,7 @@ function handleOf( return { edits: () => state.current, + savedRadii: () => state.saved.curve_radii ?? {}, replace: commit, markSaved() { state.unsaved = false; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 132e87ed9..54e24ff6d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -47,6 +47,8 @@ export interface PanelToolsContext { base: () => AlignmentBase | null; alignment: () => ProfileAlignment | null; edits: () => AlignmentEdits; + /** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */ + savedRadii: () => Readonly>; /** false = 막힘(기울기 · 흙덮기). */ applyEdits: (next: AlignmentEdits) => boolean; /** 고른 측점 id(없으면 null). */ @@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (tools.mode() === "shift") return tools.nudge(delta); const chainage = selectedChainage(); if (chainage === null) return false; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())); return true; } // 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1) diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 36095813d..08d7b87d1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -79,6 +79,7 @@ export interface ProfileRenderContext { }; store: { edits: () => AlignmentEdits; + savedRadii: () => Readonly>; resetStation: (chainageM: number) => void; }; detail: () => SectionDetailResponse | null; @@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => - base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)), + base && + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())), }) : null; @@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())); }, }), ); diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index a68c8b485..c26217af1 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. * 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1). + * 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠. */ export function adjustStation( base: AlignmentBase, edits: AlignmentEdits, chainageM: number, delta: number, + autoRadii: Readonly> = {}, ): AlignmentEdits { const key = chainageKey(chainageM); const current = buildAlignment(base, edits); @@ -580,6 +582,7 @@ export function adjustStation( const withOffset = (value: number): AlignmentEdits => { const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; const radii = { ...edits.curve_radii }; + if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key]; axes?.inner.forEach((chainage) => { const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; const z = diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a4a3c0054..fc88620ae 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -89,6 +89,7 @@ tools.createPanelTools({ base: () => ({}), alignment: () => null, edits: () => ({}), + savedRadii: () => ({}), applyEdits: () => undefined, selectedStationId: () => "irregular:p1", irregularStations: () => [pipe], diff --git a/resources/tester/helper_455_arrow_auto_radius.cjs b/resources/tester/helper_455_arrow_auto_radius.cjs new file mode 100644 index 000000000..1210f49fe --- /dev/null +++ b/resources/tester/helper_455_arrow_auto_radius.cjs @@ -0,0 +1,77 @@ +/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림. + * ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로 + * ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const align = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"), +); + +// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %) +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: false, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; +const KEY = "100.000"; +const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A) + +function press(radius, autoRadii) { + const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } }; + const next = + autoRadii === undefined + ? align.adjustStation(base, edits, 100, 0.1) + : align.adjustStation(base, edits, 100, 0.1, autoRadii); + const curve = align + .buildAlignment(base, next) + .curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6); + return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null }; +} + +process.stdout.write( + JSON.stringify({ + auto: press(AUTO, { [KEY]: AUTO }), + user: press(777, { [KEY]: AUTO }), + legacy: press(AUTO), + }), +); diff --git a/resources/tester/test_193_1_line_axis.py b/resources/tester/test_193_1_line_axis.py index ba53cb1fa..9a6e59951 100644 --- a/resources/tester/test_193_1_line_axis.py +++ b/resources/tester/test_193_1_line_axis.py @@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None: encoding="utf-8" ) assert "if (event.defaultPrevented) return;" in src - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src + assert ( + "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src + ) def _ground(level: float) -> list[dict]: diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py index 84fb63679..2c600b80a 100644 --- a/resources/tester/test_355_1_pivot_tilt.py +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case): def test_부르는_곳_셋이_같은_함수(): - assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + # 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김 + assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Render.ts" ) - assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read( "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" ) assert "pivotAxes(current, chainageM)" in _read( diff --git a/resources/tester/test_455_arrow_auto_radius.py b/resources/tester/test_455_arrow_auto_radius.py new file mode 100644 index 000000000..0d4142297 --- /dev/null +++ b/resources/tester/test_455_arrow_auto_radius.py @@ -0,0 +1,52 @@ +"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L. + +까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로 +짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠. +계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_455_arrow_auto_radius.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_자동_R_은_지워_기본_L(out) -> None: + assert out["auto"]["radius"] is None + assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6) + # 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거 + assert out["legacy"]["radius"] == pytest.approx(3000.0) + assert out["legacy"]["length"] < 14.0 + + +def test_사용자_R_은_그대로(out) -> None: + assert out["user"]["radius"] == 777 + + +def test_부르는_곳_셋이_저장_R_을_넘김() -> None: + def read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts") + assert render.count("chainage, delta, store.savedRadii())") == 2 + tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools + assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts") From 1458010944a30c606f467eeeb09a74181d706af0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:33:18 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor(454=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=A4=84=20=EC=9C=A0=ED=86=A0=20=EB=8B=A8=EA=B3=84):=20?= =?UTF-8?q?=EC=95=88=20=EC=93=B0=EB=8A=94=20masshaul=20=C2=B7=20haul=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EA=B1=B7=EC=9D=8C=20(433-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계산 줄 masshaul(유토곡선 계열) · haul(흙 배분 POST) 단계 지움 — 읽는 화면 없음 · 조작마다 헛 셈 · 헛 POST - 유토곡선 판 계산 · createHaulPlanPrefetch · postHaulPlan 남김 · 표준단면 도착 때 cross 다시 먹이기 남김 - 탐침 시험: 끝 누가토량은 공용 계산 헬퍼(helper_454_final_cumulative) · 뒤 단계 늦은 응답 = corridor · 묶음 흙 배분 POST 0 단언 Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_pipeline_page.ts | 24 +++------ A00_Common/design/design_profile.ts | 2 +- A00_Common/design/logic/design_pipeline.ts | 7 +-- .../design/logic/design_pipeline_types.ts | 8 +-- B05_Profile/B05_Profile_Api_HaulPlan.ts | 22 +------- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 2 +- .../logic/B05_Profile_MassHaul_Stage.ts | 53 ------------------- resources/tester/helper_344_3_entry.ts | 4 +- .../tester/helper_344_3_station_keys.mjs | 35 ------------ .../tester/helper_344_4_design_pipeline.cjs | 24 +++++---- .../helper_346_2_pipeline_cross_page.cjs | 10 ++-- .../tester/helper_349_0g_draft_b06_loop.cjs | 3 +- .../tester/helper_349_0j_draft_vs_saved.cjs | 3 +- resources/tester/helper_349_pipeline_once.cjs | 5 +- .../tester/helper_454_final_cumulative.cjs | 23 ++++++++ resources/tester/test_344_3_station_keys.py | 9 +--- .../tester/test_344_4_design_pipeline.py | 13 ++--- .../tester/test_346_2_pipeline_cross_page.py | 9 ++-- .../tester/test_354_2_masshaul_cross_only.py | 5 +- 19 files changed, 74 insertions(+), 187 deletions(-) delete mode 100644 B05_Profile/logic/B05_Profile_MassHaul_Stage.ts create mode 100644 resources/tester/helper_454_final_cumulative.cjs diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 04d89ca2d..3f0ae51e6 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -6,9 +6,8 @@ * route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8) * follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts` * stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6) - * cross · masshaul · haul `B06_Section_Cross_Stage` · `logic/B05_Profile_MassHaul_Stage` · - * `B05_Profile_Api_HaulPlan`(sub2) · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」. - * haul · corridor 는 노선 초안 동안만(저장 노선은 유토곡선 판 · 횡단 다시 셈이 맡음). + * cross `B06_Section_Cross_Stage` · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」. + * corridor 는 노선 초안 동안만 · 유토곡선 · 흙 배분은 유토곡선 판이 셈(454). * * 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집 * (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점 @@ -65,7 +64,6 @@ import { fetchRoutePlan, type RoutePlanResponse, } from "../../B05_Profile/B05_Profile_Api_Replan"; -import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures"; import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data"; import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types"; @@ -98,10 +96,6 @@ import { SECTION_STATION_INTERVAL_M, } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; -import { - createMassHaulStage, - type MassHaulStageContext, -} from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage"; import type { PlanPipe, PlanStructure, @@ -122,7 +116,7 @@ interface PagePipeline { bridge: RouteDraftBridge; grade(edits: AlignmentEdits): void; restart(): void; - earthwork(context: MassHaulStageContext | null): void; + earthwork(): void; cross(): void; reconcile(): Promise; ownsCross: boolean; @@ -154,9 +148,8 @@ export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void => /** 저장 정본이 바뀜(새로 받은 상세 · 새 노선) — 처음부터 다시. 갈아끼운 상세 · 같은 상세 다시 그림은 안 셈. */ export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart(); -/** 유토곡선 설정(토량환산계수 · 자연방토 경사) — masshaul 단계 재료 · 표준단면이 막 왔으면 횡단 입력도. */ -export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void => - pages.get(projectId)?.earthwork(context); +/** 유토곡선 설정(표준단면 포함)이 막 옴 — 횡단 입력을 다시 먹임. */ +export const pipelineEarthwork = (projectId: string): void => pages.get(projectId)?.earthwork(); /** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */ export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross(); @@ -195,7 +188,6 @@ function createPagePipeline( let enabled: PlanType[] = []; let savedStructures: PlanStructure[] = []; let savedPipes: PlanPipe[] = []; - let earthwork: MassHaulStageContext | null = null; /** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */ let savedStations: PipelineStation[] = []; let crossSource: PipelineCrossSource | null = null; @@ -280,7 +272,6 @@ function createPagePipeline( }; const groundDeps = groundStageDeps(projectId); const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j) - const haulStage = createHaulStage(projectId, () => savedSource().routeId ?? undefined); const followStage = createFollowStage({ savedLine, saved: () => { @@ -379,8 +370,6 @@ function createPagePipeline( }); }, footprint: footprintStage, - masshaul: createMassHaulStage(() => earthwork), - haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)), drainage: link.side?.pipelineStage, corridor: (ctx) => { if (ctx.inputs.route) options.onCorridorStale?.(); @@ -678,8 +667,7 @@ function createPagePipeline( // 갈아끼움 · 같은 상세 다시 그림은 안 셈 · 보기가 심은 측점은 묶음이 입력으로 셈(386-3) if (canonChanged()) void loadPlan().then(start); }, - earthwork(context) { - earthwork = context; + earthwork() { const cross = crossNow()?.input ?? null; if (started && cross) void feed("cross", cross); }, diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8cbb093ff..ce8acc6f7 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -522,7 +522,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { */ setEarthworkContext(next: RouteMassHaulContext | null) { massHaul.setContext(next); - pagePipeline.pipelineEarthwork(projectId, next); + pagePipeline.pipelineEarthwork(projectId); drawSoon(); }, setSelectedStation(stationId: string | null) { diff --git a/A00_Common/design/logic/design_pipeline.ts b/A00_Common/design/logic/design_pipeline.ts index 970dc5384..ce23b5d06 100644 --- a/A00_Common/design/logic/design_pipeline.ts +++ b/A00_Common/design/logic/design_pipeline.ts @@ -39,10 +39,9 @@ export const BROWSER_STAGES: StageName[] = [ "profile", "cross", "footprint", - "masshaul", ]; /** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */ -export const AFTER_STAGES: StageName[] = ["haul", "drainage", "corridor"]; +export const AFTER_STAGES: StageName[] = ["drainage", "corridor"]; type Dep = StageName | `input.${InputKey}`; @@ -56,15 +55,13 @@ export const STAGE_DEPS: Record = { profile: ["planline", "input.alignment"], cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"], footprint: ["cross", "ground"], - masshaul: ["cross"], - haul: ["masshaul"], drainage: ["route", "input.pipes"], corridor: ["profile", "cross"], }; /** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */ const FOLLOW_KEYS: ReadonlyArray = ["pipes", "structures", "alignment"]; -/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */ +/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */ const YIELD_AFTER = new Set(["profile"]); export const RUN_LOG_SIZE = 20; diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index 217735186..003289de6 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -9,9 +9,9 @@ * plusStations(추가 측점 · 429 ⑨) * ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림 * 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄) - * route → follow → stations → ground → planline → profile → cross → footprint → masshaul + * route → follow → stations → ground → planline → profile → cross → footprint * 뒤(기다리지 않음 · 새 조작이 오면 AbortSignal 로 끊김 · 옛 번호 결과 버림) - * haul(흙 배분 · 서버 Node) · drainage(배수유역 「낡음」 표시) · corridor(3D 「낡음」 표시) + * drainage(배수유역 「낡음」 표시) · corridor(3D 「낡음」 표시) * * 규칙: * · 단계는 자기 출력만 돌려줌 — 입력 · 다른 단계 출력을 고치지 않음(ctx 의 값은 읽기만). @@ -31,8 +31,6 @@ * | profile | sub1 | planline · input.alignment | ProfileAlignment | * | cross | sub2 | profile · ground · input.cross · pipes · structures | CrossOutput | * | footprint | sub6 | cross · ground | PlanFootprint | - * | masshaul | sub2 | cross | unknown(sub2 모양) | - * | haul | sub2 | masshaul | unknown(서버 응답) | * | drainage | sub3 | route · input.pipes | 「낡음」 표시(출력 안 씀) | * | corridor | sub5 | profile · cross | 「낡음」 표시(출력 안 씀) | * @@ -238,8 +236,6 @@ export interface PipelineOutputs { profile: ProfileAlignment; cross: CrossOutput; footprint: PlanFootprint; - masshaul: unknown; - haul: unknown; drainage: null; corridor: null; } diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index acfa2e943..78fe2810d 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -12,8 +12,6 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; -import type { StageContext } from "../A00_Common/design/logic/design_pipeline_types"; -import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage"; /** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다. * `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */ @@ -26,7 +24,7 @@ const TIMEOUT_MS = 20000; /** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */ const KEPT_PLANS = 8; -/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */ +/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입이 씀. */ const kept = new Map(); /** @@ -86,24 +84,6 @@ async function postHaulPlan( } } -/** - * 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버 - * Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림). - * 못 받으면 앞 배분(`ctx.prev`) 그대로. - */ -export function createHaulStage(projectId: string, routeId: () => number | undefined) { - return async (ctx: StageContext<"haul">): Promise => { - const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput; - const route = routeId(); - if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null; - const key = keyOf(result); - const at = keptKey(projectId, route, key); - if (kept.has(at)) return kept.get(at) ?? null; - const plan = await postHaulPlan(projectId, route, result, key, ctx.signal); - return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan; - }; -} - export interface HaulPlanPrefetch { /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ schedule: (result: unknown) => void; diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 06b7a969c..3adf9a94d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -17,7 +17,7 @@ import { useDesignStore } from "../A00_Common/design/design_store"; import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline"; /** cross 를 지났다고 볼 단계(cross 가 같은 입력이라 건너뛰어도 뒤 단계가 알림). */ -const AFTER_CROSS = new Set(["cross", "footprint", "masshaul"]); +const AFTER_CROSS = new Set(["cross", "footprint"]); /** 묶음 차례가 cross 를 못 낸 채 끝나도(단계 실패) 「다시 계산 중」을 푸는 때(ms). */ const DRAFT_WAIT_MS = 3000; diff --git a/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts b/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts deleted file mode 100644 index 68f01ce28..000000000 --- a/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* ============================================================================= - * B05_Profile_MassHaul_Stage.ts - * 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) · - * profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산). - * - * 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음). - * 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null. - * ========================================================================== */ - -import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul"; -import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types"; -import type { StageContext } from "../../A00_Common/design/logic/design_pipeline_types"; - -export interface MassHaulStageContext { - conversion: EarthworkConversion; - naturalSpoilMinSlope?: number; -} - -export interface MassHaulStageOutput { - series: MassHaulSeries[]; - /** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */ - haulInput: MassHaulSeries["result"] | null; -} - -/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */ -export function createMassHaulStage(context: () => MassHaulStageContext | null) { - return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => { - const cross = ctx.out.cross; - const settings = context(); - if (!cross || !settings) return null; - const sections: MassHaulSection[] = Object.values(cross.byKey) - .sort((a, b) => a.chainageM - b.chainageM) - .map((entry) => ({ - station_id: entry.key, - chainage_m: entry.chainageM, - design: entry.detail as MassHaulSection["design"], - })); - const samples = ctx.out.profile?.samples ?? []; - const longitudinal = { - length_m: - samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0, - design_profiles: samples.length ? [{ samples }] : [], - }; - const series = computeMassHaulSeries( - longitudinal, - sections, - settings.conversion, - settings.naturalSpoilMinSlope, - ); - const picked = series.find((entry) => entry.basis === "cross") ?? series[0]; - return { series, haulInput: picked?.result ?? null }; - }; -} diff --git a/resources/tester/helper_344_3_entry.ts b/resources/tester/helper_344_3_entry.ts index 6368371f3..684e63d3d 100644 --- a/resources/tester/helper_344_3_entry.ts +++ b/resources/tester/helper_344_3_entry.ts @@ -1,5 +1,5 @@ /* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. - * 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다. + * 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross 단계를 화면 · 서버 없이 잰다. */ export { carryStationDrafts, @@ -20,8 +20,6 @@ export { readRockBoundarySession, } from "../../B06_Section/B06_Section_UI_Page_Persist"; export { createCrossStage, groundSamples } from "../../B06_Section/logic/B06_Section_Cross_Stage"; -export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage"; -export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan"; export { buildAlignment, emptyEdits, diff --git a/resources/tester/helper_344_3_station_keys.mjs b/resources/tester/helper_344_3_station_keys.mjs index 1a5aebfb3..99ff8dd34 100644 --- a/resources/tester/helper_344_3_station_keys.mjs +++ b/resources/tester/helper_344_3_station_keys.mjs @@ -202,39 +202,4 @@ const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curv stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited)); out.crossGrade = counts(); -/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */ -const conversion = { - soil: { loose: 1.25, compacted: 0.9 }, - ripping_rock: { loose: 1.5, compacted: 1.2 }, - blasting_rock: { loose: 1.65, compacted: 1.35 }, -}; -const massStage = m.createMassHaulStage(() => ({ conversion })); -const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } }); -out.massSeries = mass.series.map((s) => s.basis); -out.massHaulInput = Array.isArray(mass.haulInput?.points); - -/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */ -const posts = []; -globalThis.fetch = async (_url, init) => { - if (init.signal?.aborted) throw new Error("aborted"); - posts.push(1); - return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) }; -}; -const haul = m.createHaulStage(P, () => R); -const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({ - ...ctxOf(crossInput, null, prev), - signal, - out: { masshaul }, -}); -const plan1 = await haul(haulCtx(mass, null)); -const plan2 = await haul(haulCtx(mass, plan1)); -const other = massStage({ - ...ctxOf(crossInput, null, null), - out: { profile: edited, cross: third }, -}); -const aborted = new AbortController(); -aborted.abort(); -const plan3 = await haul(haulCtx(other, plan1, aborted.signal)); -out.haul = { posts: posts.length, plan1, plan2, plan3 }; - process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_344_4_design_pipeline.cjs b/resources/tester/helper_344_4_design_pipeline.cjs index 1550ac8b2..19e39415b 100644 --- a/resources/tester/helper_344_4_design_pipeline.cjs +++ b/resources/tester/helper_344_4_design_pipeline.cjs @@ -133,7 +133,7 @@ async function run() { }; } - // ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(haul 늦음 · 끊김) + // ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(corridor 늦음 · 끊김) { const log = []; let releaseGround = null; @@ -161,25 +161,27 @@ async function run() { commits, }; - // 뒤 단계 — haul 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림 - let releaseHaul = []; - const hold2 = { haul: () => new Promise((resolve) => releaseHaul.push(resolve)) }; + // 뒤 단계 — corridor 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림 + let releaseAfter = []; + const hold2 = { corridor: () => new Promise((resolve) => releaseAfter.push(resolve)) }; const log2 = []; const pipe2 = pipeMod.createDesignPipeline({ stages: fakeStages(log2, hold2) }); const commits2 = []; - pipe2.subscribe((e) => e.kind === "stage" && e.stage === "haul" && commits2.push(e.generation)); + pipe2.subscribe( + (e) => e.kind === "stage" && e.stage === "corridor" && commits2.push(e.generation), + ); await pipe2.start(BASE); - const haul1 = log2.find((e) => e.name === "haul"); + const after1 = log2.find((e) => e.name === "corridor"); await pipe2.update("cross", { x: 1 }, { fromM: 0, toM: 0 }); - const haul1Aborted = haul1.aborted(); - releaseHaul[0](); + const after1Aborted = after1.aborted(); + releaseAfter[0](); await tick(1); - releaseHaul[1](); + releaseAfter[1](); await pipe2.settled(); out.lateAfter = { - haul1Aborted, + after1Aborted, commits: commits2, - haulGen: pipe2.output("haul").gen, + afterGen: pipe2.output("corridor").gen, generation: pipe2.generation, }; } diff --git a/resources/tester/helper_346_2_pipeline_cross_page.cjs b/resources/tester/helper_346_2_pipeline_cross_page.cjs index 9699139ba..86360a208 100644 --- a/resources/tester/helper_346_2_pipeline_cross_page.cjs +++ b/resources/tester/helper_346_2_pipeline_cross_page.cjs @@ -1,4 +1,4 @@ -/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · masshaul · haul · drainage · corridor 를 +/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · drainage · corridor 를 * 붙인 것. 진짜 TS(`design_pipeline_page` · `_cross` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로 * 돌림. 서버 창구(노선 · 구조물 · 관 목록 · 지형 격자 · 표준단면 · 흙 배분 fetch)만 가짜. * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_346_2_pipeline_cross_page.py`. */ @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const CATALOG = require("./helper_322_catalog_module.cjs"); +const finalCumulative = require("./helper_454_final_cumulative.cjs"); const ROOT = path.join(__dirname, "..", ".."); const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); @@ -29,7 +30,7 @@ globalThis.window = { clearTimeout, }; -// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 · 묶음은 안 부름(454 masshaul · haul 단계 걷음) const haul = { calls: 0, aborted: 0, answered: 0 }; globalThis.fetch = (url, init) => new Promise((resolve, reject) => { @@ -205,7 +206,7 @@ async function run() { stationIntervalM: job.interval, onCorridorStale: () => (corridor += 1), }); - pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + pageMod.pipelineEarthwork(P); const pipe = pageMod.pagePipeline(P); const commits = []; pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`)); @@ -242,7 +243,6 @@ async function run() { const ground = pipe.output("ground"); const profile = pipe.output("profile"); const shown = pageMod.pipelineDetail(P); - const mass = pipe.output("masshaul"); const moved = { run: { outcome: runB.outcome, @@ -264,7 +264,7 @@ async function run() { cut: pipe.output("cross").byKey[s.key]?.cutAreaM2 ?? null, fill: pipe.output("cross").byKey[s.key]?.fillAreaM2 ?? null, })), - finalCumulative: mass?.haulInput?.final_cumulative_m3 ?? null, + finalCumulative: finalCumulative(load, pipe, job.conversion), footprint: pipe.output("footprint")?.records.length ?? 0, detail: shown && { sections: shown.cross_sections.length, diff --git a/resources/tester/helper_349_0g_draft_b06_loop.cjs b/resources/tester/helper_349_0g_draft_b06_loop.cjs index c5f3a20be..9276b3628 100644 --- a/resources/tester/helper_349_0g_draft_b06_loop.cjs +++ b/resources/tester/helper_349_0g_draft_b06_loop.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const finalCumulative = require("./helper_454_final_cumulative.cjs"); const CATALOG = require("./helper_322_catalog_module.cjs"); const ROOT = path.join(__dirname, "..", ".."); @@ -167,7 +168,7 @@ async function run() { drawn = next; void Promise.resolve().then(renderB06); }); - const final = () => pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null; + const final = () => finalCumulative(load, pipe, job.conversion); const cut = () => Object.values(pipe.output("cross")?.byKey ?? {}).reduce((s, c) => s + (c.cutAreaM2 ?? 0), 0); diff --git a/resources/tester/helper_349_0j_draft_vs_saved.cjs b/resources/tester/helper_349_0j_draft_vs_saved.cjs index 7880ccf0f..cea4134d1 100644 --- a/resources/tester/helper_349_0j_draft_vs_saved.cjs +++ b/resources/tester/helper_349_0j_draft_vs_saved.cjs @@ -5,6 +5,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const finalCumulative = require("./helper_454_final_cumulative.cjs"); const CATALOG = require("./helper_322_catalog_module.cjs"); const ROOT = path.join(__dirname, "..", ".."); @@ -249,7 +250,7 @@ async function draftRun(P, grid) { totalMs: run.totalMs, line: pipe.output("route").line, curves: pipe.output("route").curves, - final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null, + final: finalCumulative(load, pipe, job.conversion), stations, copied, }; diff --git a/resources/tester/helper_349_pipeline_once.cjs b/resources/tester/helper_349_pipeline_once.cjs index 868ab78be..8d7cf79ff 100644 --- a/resources/tester/helper_349_pipeline_once.cjs +++ b/resources/tester/helper_349_pipeline_once.cjs @@ -6,6 +6,7 @@ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const finalCumulative = require("./helper_454_final_cumulative.cjs"); const CATALOG = require("./helper_322_catalog_module.cjs"); const ROOT = path.join(__dirname, "..", ".."); @@ -198,7 +199,7 @@ async function run() { stations: chainagesOf(stations), saved: job.detail.cross_sections.map((s) => s.chainage_m), pipeChainages: (pipe.input("pipes") ?? []).map((p) => p.chainage_m), - final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null, + final: finalCumulative(load, pipe, job.conversion), footprint: pipe.output("footprint")?.records.length ?? 0, }; @@ -239,7 +240,7 @@ async function run() { const again = await openOnce("proj-349b"); const reopen = { runs: runs().slice(again.before).length, - final: again.pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null, + final: finalCumulative(load, again.pipe, job.conversion), stations: chainagesOf(again.pipe.output("ground")?.stations), }; again.stop(); diff --git a/resources/tester/helper_454_final_cumulative.cjs b/resources/tester/helper_454_final_cumulative.cjs new file mode 100644 index 000000000..b17efe05d --- /dev/null +++ b/resources/tester/helper_454_final_cumulative.cjs @@ -0,0 +1,23 @@ +/* 454 — 옛 masshaul 단계 탐침 자리: 묶음 cross · profile 출력으로 누가토량 끝값(m3). + * 유토곡선 판(`B05_Profile_UI_Profile_MassHaul`)과 같은 공용 계산(`computeMassHaulSeries`) · 횡단 기준. + * `load` = 부르는 헬퍼의 TS 로더(`load("common_util", "…ts")`). */ +module.exports = function finalCumulative(load, pipe, conversion) { + const cross = pipe.output("cross"); + const samples = pipe.output("profile")?.samples ?? []; + if (!cross) return null; + const { computeMassHaulSeries } = load("common_util", "common_util_mass_haul.ts"); + const sections = Object.values(cross.byKey) + .sort((a, b) => a.chainageM - b.chainageM) + .map((e) => ({ station_id: e.key, chainage_m: e.chainageM, design: e.detail })); + const series = computeMassHaulSeries( + { + length_m: + samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0, + design_profiles: samples.length ? [{ samples }] : [], + }, + sections, + conversion, + ); + const picked = series.find((s) => s.basis === "cross") ?? series[0]; + return picked?.result?.final_cumulative_m3 ?? null; +}; diff --git a/resources/tester/test_344_3_station_keys.py b/resources/tester/test_344_3_station_keys.py index 06adb6ef7..37430c880 100644 --- a/resources/tester/test_344_3_station_keys.py +++ b/resources/tester/test_344_3_station_keys.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계. +"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross 단계(masshaul · haul 단계는 454 에서 걷음). 못박는 것 - 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김. @@ -8,7 +8,6 @@ - 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장(417) · 단면 유형 · 측구 방향 · 포장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」. - cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용. - - masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로. Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을 부름. @@ -116,9 +115,3 @@ def test_cross_단계_국부_연산(out): assert out["crossOne"] == {"computed": 1, "reused": 5} assert out["crossOneChanged"] == "soil" assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3 - - -def test_masshaul_haul_단계(out): - assert out["massSeries"] == ["cross", "longitudinal"] - assert out["massHaulInput"] is True - assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}} diff --git a/resources/tester/test_344_4_design_pipeline.py b/resources/tester/test_344_4_design_pipeline.py index 3010614c3..a39490257 100644 --- a/resources/tester/test_344_4_design_pipeline.py +++ b/resources/tester/test_344_4_design_pipeline.py @@ -31,9 +31,8 @@ BROWSER = [ "profile", "cross", "footprint", - "masshaul", ] -AFTER = ["haul", "drainage", "corridor"] +AFTER = ["drainage", "corridor"] @pytest.fixture(scope="module") @@ -65,7 +64,7 @@ def test_같은_입력_다시_안_셈(r): b = r["basic"] assert b["same"] == {"result": None, "generation": 1} # 같은 값 = 차례 번호도 안 오름 # 횡단만 바뀌면 노선 ~ 종단 · 배수유역은 건너뜀 - assert b["crossRan"] == ["cross", "footprint", "masshaul", "haul", "corridor"] + assert b["crossRan"] == ["cross", "footprint", "corridor"] assert b["crossSkipped"] == [ "route", "follow", @@ -76,7 +75,7 @@ def test_같은_입력_다시_안_셈(r): "drainage", ] # 계획고만 바뀌면 측점 · 지반 · 첫 계획선 · 배수유역 건너뜀 - assert b["gradeRan"] == ["profile", "cross", "footprint", "masshaul", "haul", "corridor"] + assert b["gradeRan"] == ["profile", "cross", "footprint", "corridor"] # 노선이 바뀌면 전부 assert b["routeRan"] == BROWSER + AFTER @@ -101,8 +100,8 @@ def test_늦은_응답_버림_브라우저_단계(r): def test_늦은_응답_버림_뒤_단계_끊김(r): late = r["lateAfter"] - assert late["haul1Aborted"] is True # 새 조작 = 옛 요청 끊음 - assert late["commits"] == [2] and late["haulGen"] == 2 and late["generation"] == 2 + assert late["after1Aborted"] is True # 새 조작 = 옛 요청 끊음 + assert late["commits"] == [2] and late["afterGen"] == 2 and late["generation"] == 2 def test_못_끝낸_단계는_범위를_합쳐_셈(r): @@ -133,8 +132,6 @@ def test_단계_시간_기록(r): "profile", "cross", "footprint", - "masshaul", - "haul", "corridor", ] assert t["last"]["totalIsNumber"] is True diff --git a/resources/tester/test_346_2_pipeline_cross_page.py b/resources/tester/test_346_2_pipeline_cross_page.py index b6645a1e6..fb66966d3 100644 --- a/resources/tester/test_346_2_pipeline_cross_page.py +++ b/resources/tester/test_346_2_pipeline_cross_page.py @@ -201,7 +201,7 @@ def r(request) -> dict: def test_열_때_한_번_발자국_저장_횡단_입력(r): o = r["open"] assert o["outcome"] == "done" - assert {"cross", "footprint", "masshaul"} <= set(o["stages"]) + assert {"cross", "footprint"} <= set(o["stages"]) assert o["crossCount"] == o["stations"] and o["footprint"] > 2 assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로 assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None @@ -214,7 +214,7 @@ def test_노선_옮김_한_번에_유토곡선까지_시간(r): assert run["outcome"] == "done" and run["sources"] == ["route"] for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"): assert stage in run["stages"], stage - assert "masshaul" in run["stages"] + assert "masshaul" not in run["stages"] and "haul" not in run["stages"] # 454 걷음 assert ( sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor")) < BUDGET_MS @@ -271,9 +271,8 @@ def test_누가토량_서버_엔진과_같은_자리수(r): def test_늦은_응답_버림(r): late = r["late"] assert late["genB"] > late["genA"] - assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김 - assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴 - assert late["haulCommits"][-1] == f"haul@{late['genB']}" + # 454 — 묶음은 흙 배분을 안 물음(유토곡선 판 선반입만) · 늦은 응답 버림 = 344-4 corridor + assert late["haul"]["calls"] == 0 and late["haulCommits"] == [] def test_저장_관_목록이_좌표로_따라감(r): diff --git a/resources/tester/test_354_2_masshaul_cross_only.py b/resources/tester/test_354_2_masshaul_cross_only.py index b6ad7f172..3f178e535 100644 --- a/resources/tester/test_354_2_masshaul_cross_only.py +++ b/resources/tester/test_354_2_masshaul_cross_only.py @@ -3,7 +3,7 @@ - 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬. - 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로. -- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김. +- 공용 계산 코드는 두 기준 그대로 남김(계산 묶음 masshaul 단계는 454 에서 걷음). `helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림. """ @@ -52,8 +52,7 @@ def test_그리기는_횡단만_부름(): def test_묶음_단계와_공용_코드는_그대로(): - stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8") - assert '["cross"]' not in stage + assert not (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").exists() # 454 걷음 util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8") assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util assert "longitudinalAreaSamples(longitudinal, sections)" in util From e51dd2a801a16135c0648c61b7cfdc92c9964d21 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:34:51 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix(450=20B06=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EA=B0=88=EC=95=84=EB=81=BC=EC=9B=80):=20MC=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=97=B4=EC=87=A0=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EA=B0=99=EC=9D=80=20=EB=8B=A8=EB=A9=B4=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=ED=91=9C=EB=B3=B8=20=EC=9C=A0=EC=A7=80=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EA=B0=84=EA=B0=92=20=EC=A3=BD=EC=9D=80=20?= =?UTF-8?q?=EA=B8=B8=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20=ED=91=9C=20=EC=9C=84?= =?UTF-8?q?=20CAD=20=ED=95=9C=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 433-4 갈아끼움이 curveMoves 거꾸로 맵으로 옛 MC 횡단을 이어 받음 — 설계 · 측점 id · moves 짝 남음 - 433-11 자리 · 축 같은 앞 단면은 지반 표본 · 상단측 · 중심고 그대로 — 덧 측점 넣기 · 빼기 뒤 누가토량 = 서버 - 433-19 Span_Control.update(culvertopt 큐) 걷음 — 구간 줄은 다단 벽만 - 433-20 표 위 CAD 요소 2000 넘음 = 초안에 안 씀 · 알림(서버 max_length 짝) - 시험 test_433_4 · test_433_11 · test_433_19_20 새로 · 346_2 단언 · 440 헬퍼 로더 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../B06_Section_UI_Cross_Culvert_Wire.ts | 1 - .../B06_Section_UI_Cross_Detail_Cad.ts | 13 ++ .../B06_Section_UI_Cross_View_Structure.ts | 8 +- .../B06_Section_UI_Page_Span_Control.ts | 42 +--- .../B06_Section_UI_Page_Station_Controls.ts | 1 - B06_Section/logic/B06_Section_Station_Swap.ts | 45 +++- .../tester/helper_346_2_station_swap.mjs | 1 + resources/tester/helper_433_swap.cjs | 215 ++++++++++++++++++ .../tester/helper_440_draft_card_ids.cjs | 3 +- resources/tester/test_346_2_station_swap.py | 4 +- .../test_433_11_plus_station_masshaul.py | 75 ++++++ resources/tester/test_433_19_20_span_cad.py | 45 ++++ resources/tester/test_433_4_mc_swap.py | 64 ++++++ 13 files changed, 460 insertions(+), 57 deletions(-) create mode 100644 resources/tester/helper_433_swap.cjs create mode 100644 resources/tester/test_433_11_plus_station_masshaul.py create mode 100644 resources/tester/test_433_19_20_span_cad.py create mode 100644 resources/tester/test_433_4_mc_swap.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index c00cf4b20..8d21faa62 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -73,7 +73,6 @@ export interface StructureSpanControl { /** 이 카드가 구간값을 만질 대상 측점 — 링크면 소유 측점, 아니면 자기 자신. */ ownerOf: (section: CrossSection) => CrossSection | null; valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null; - update: (section: CrossSection, role: SpanRole, patch: Partial) => void; /** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자). * 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */ tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index b9682361d..c62aecddc 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -15,6 +15,7 @@ * ========================================================================== */ import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { showToast } from "@ui/ui_template_elements"; import { readState, writeState } from "../A00_Common/b_page_state"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -45,7 +46,19 @@ export function readStructureNoteCads( return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad])); } +/** 표 위 CAD 요소 한도 — 서버 `CrossNoteCad.entities max_length` 짝(넘으면 횡단 patch 통째 422 · 433-20). */ +export const NOTE_CAD_MAX_ENTITIES = 2000; + function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void { + // 한도 넘음 = 초안에 안 씀(앞 초안 그대로) · 알림 + if (cad.entities.length > NOTE_CAD_MAX_ENTITIES) { + showToast( + `표 위 CAD 요소 ${cad.entities.length}개 — 한도 ${NOTE_CAD_MAX_ENTITIES}개를 넘어 저장하지 않습니다. 요소를 줄이세요.`, + "warning", + 8000, + ); + return; + } // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 if (section.design) section.design.structure_note_cad = cad; const all = readState>( diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index ff68ab31c..7eab881c9 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -194,13 +194,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe const role = spanRoleOf(key, ctx.inletIsBasin()); return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null; }, + // 구간 줄 = 다단 벽만(기준벽 · 집수정은 폼 칸 = 관 초안 · 433-19) setSpan: (key, patch) => { - if (isTierKey(key)) { - ctx.structureSpan?.updateTier(ctx.section, key, patch); - return; - } - const role = spanRoleOf(key, ctx.inletIsBasin()); - if (role) ctx.structureSpan?.update(ctx.section, role, patch); + if (isTierKey(key)) ctx.structureSpan?.updateTier(ctx.section, key, patch); }, // 벽을 **그리지 않는 카드에는 [연동]을 내지 않는다**(2026-09-06). 링크 카드는 소유 // 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이 diff --git a/B06_Section/B06_Section_UI_Page_Span_Control.ts b/B06_Section/B06_Section_UI_Page_Span_Control.ts index 83f35a97c..757789a8f 100644 --- a/B06_Section/B06_Section_UI_Page_Span_Control.ts +++ b/B06_Section/B06_Section_UI_Page_Span_Control.ts @@ -3,8 +3,8 @@ * 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값. * * `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02). - * 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는 - * 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다. + * 세션 맵(`extraSpans`)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다. + * 기준벽 · 집수정 구간값은 폼 칸(관 초안)이 정본 — 여기는 읽기만(433-19 · 444). * ========================================================================== */ import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch"; @@ -25,8 +25,6 @@ export interface SpanControlDeps { extraSpans: Map; persistExtraSpans: () => void; patchCachedDesign: (chainageM: number, patch: Partial) => void; - /** 정본 반영 큐(관 옵션). */ - culvertOptions: { queue: (chainageM: number, values: Record) => void }; } /** 구간값 제어기와 단별 구간값 조회를 만든다. */ @@ -36,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): { tierSpansOf: (owner: CrossSection) => Record; spanKeyOf: (chainageM: number, wall: string) => string; } { - const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps; + const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf } = deps; const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`; const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => { @@ -91,40 +89,6 @@ export function createSpanControl(deps: SpanControlDeps): { const owner = ownerOf(section); return owner ? CulvertConst.spanValuesOf(owner, role) : null; }, - update: (section, role, patch) => { - const owner = ownerOf(section); - if (!owner?.culvert) return; - const current = CulvertConst.spanValuesOf(owner, role); - if (!current) return; - // 길이↔전/후 산식은 다단과 공용(`applySpanPatch`). - const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch); - const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet; - const keys = CulvertConst.SPAN_OPTION_KEYS[role]; - if (role === "basin") { - spec.basin_length_m = lengthM; - spec.basin_before_m = beforeM; - spec.basin_after_m = afterM; - } else { - spec.revet_length_m = lengthM; - spec.revet_before_m = beforeM; - spec.revet_after_m = afterM; - } - culvertOptions.queue(owner.chainage_m, { - [keys.length]: lengthM, - [keys.before]: beforeM, - [keys.after]: afterM, - }); - // 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과 - // 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번 - // 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러 - // 2번만 반영). - const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM); - for (const other of deps.detail()?.cross_sections ?? []) { - if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) { - deps.refreshCard(other.chainage_m); - } - } - }, }; return { control, tierSpansOf, spanKeyOf }; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 845147f36..86de72a45 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -505,7 +505,6 @@ export function createStationControls(deps: StationControlDeps): StationControls extraSpans, persistExtraSpans, patchCachedDesign, - culvertOptions, }); const structureSpanControl = spanControl.control; const spanKeyOf = spanControl.spanKeyOf; diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index 8dd6b34e8..aa7278010 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -2,10 +2,12 @@ * B06_Section_Station_Swap.ts * 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체. * - * · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 받음 — 설계 · 구조물 제원 · 이름 그대로 · - * 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것. + * · 같은 측점(열쇠 · MC 가 움직여 바뀐 곡선 열쇠 `curveMoves` · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 + * 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4). + * · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지 + * 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면). * · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움). - * · 상단측 = 새 지반 표본으로 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). + * · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428). * · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가 * 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) · * patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2). @@ -20,6 +22,7 @@ import type { PipelineStation } from "../../A00_Common/design/logic/design_pipeline_types"; import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch"; import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; +import { curveMoves } from "../../common_util/common_util_station_key"; interface Sample { offset_m: number; @@ -141,9 +144,21 @@ export interface StationSwap { moved: boolean; } +/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */ +function samePlace(prior: CrossSection, station: PipelineStation): boolean { + const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN]; + return ( + Math.abs(prior.chainage_m - station.chainageM) < 0.01 && + Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 && + Math.abs(lx - station.left[0]) < 1e-4 && + Math.abs(ly - station.left[1]) < 1e-4 + ); +} + /** * 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) · - * 열쇠가 안 맞으면 같은 자리(0.01 m) · 같은 종류의 앞 횡단을 씀. + * 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) · + * 같은 종류의 앞 횡단을 씀. */ export function swapCrossSections( detail: SectionDetailResponse, @@ -157,8 +172,18 @@ export function swapCrossSections( const added: string[] = []; const moves = new Map(); const text = (chainageM: number) => chainageM.toFixed(2); + // 새 곡선 열쇠 → 옛 곡선 열쇠(R 바꿔 MC 가 움직임 · 433-4) + const curveFrom = new Map( + [ + ...curveMoves( + detail.cross_sections.map((section, i) => [oldKeys[i], section.chainage_m] as const), + stations.map((station) => [station.key, station.chainageM] as const), + ), + ].map(([from, to]) => [to, from]), + ); const priorOf = (station: PipelineStation): CrossSection | undefined => { - const hit = byKey.get(station.key); + const from = curveFrom.get(station.key); + const hit = byKey.get(station.key) ?? (from === undefined ? undefined : byKey.get(from)); if (hit && !used.has(hit)) return hit; return detail.cross_sections.find( (section) => @@ -182,6 +207,8 @@ export function swapCrossSections( moved = true; if (prior && text(prior.chainage_m) !== text(station.chainageM)) moves.set(text(prior.chainage_m), text(station.chainageM)); + // 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면) + const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station)); const kind = kindOf(station); const onGrid = kind === "irregular" && @@ -193,7 +220,9 @@ export function swapCrossSections( label: formatStation(station.chainageM), kind, ...(onGrid ? { grid: true } : {}), - center_z: station.ground?.centerZ ?? prior?.center_z ?? null, + center_z: fresh + ? (station.ground?.centerZ ?? prior?.center_z ?? null) + : (prior?.center_z ?? null), azimuth_deg: station.azimuthDeg, plan_radius_m: station.planRadiusM, curve_outer_side: station.curveOuterSide, @@ -203,8 +232,8 @@ export function swapCrossSections( frame: { left_xy: [station.left[0], station.left[1]] as [number, number] }, }; longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) }); - const samples = station.ground ? groundSamples(station) : (prior?.samples ?? []); - const uphill = station.ground ? uphillSide(station) : (prior?.uphill_side ?? null); + const samples = fresh ? groundSamples(station) : (prior?.samples ?? []); + const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null); if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) }; const reseed = (station.kind === "pipe" || station.kind === "structure") && diff --git a/resources/tester/helper_346_2_station_swap.mjs b/resources/tester/helper_346_2_station_swap.mjs index 08fc7ef39..52373e024 100644 --- a/resources/tester/helper_346_2_station_swap.mjs +++ b/resources/tester/helper_346_2_station_swap.mjs @@ -113,6 +113,7 @@ const NEW = (dy) => [ kinds: swap.detail.cross_sections.map((s) => s.kind), labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label], samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m), + movedSamples: swap.detail.cross_sections[2].samples.map((s) => s.offset_m), oldUntouched: before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054, length: swap.detail.longitudinal.length_m, diff --git a/resources/tester/helper_433_swap.cjs b/resources/tester/helper_433_swap.cjs new file mode 100644 index 000000000..fd6cccf3c --- /dev/null +++ b/resources/tester/helper_433_swap.cjs @@ -0,0 +1,215 @@ +// 433-4 · 433-11 — 측점 갈아끼움(`swapCrossSections` · 진짜) · 횡단 엔진(`computeCrossDesign` · 진짜) · +// 유토(`computeMassHaul` · 진짜). 인자 = 표준단면 json · 토량환산계수 json. +const fs = require("fs"); +const path = require("path"); +const ROOT = path.resolve(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`; +const STUBS = { + B05_Profile_Pipeline_Grade: { stationIdOf: idOf }, + B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) }, +}; +const cache = new Map(); +function load(full) { + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box); + const req = (r) => { + const name = r.split("/").pop(); + if (STUBS[name]) return STUBS[name]; + if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`)); + if (r.startsWith(".")) return load(path.resolve(path.dirname(full), `${r}.ts`)); + throw new Error(`stub 없음: ${r}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} +const swap = load(path.join(ROOT, "B06_Section/logic/B06_Section_Station_Swap.ts")); +const { computeCrossDesign } = load(path.join(ROOT, "common_util/common_util_cross_design.ts")); +const { computeMassHaul } = load(path.join(ROOT, "common_util/common_util_mass_haul.ts")); +const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const conversion = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); + +const st = (key, m, kind, ground = null) => ({ + key, + chainageM: m, + kind, + x: m, + y: 0, + left: [0, 1], + azimuthDeg: 90, + planRadiusM: null, + curveOuterSide: null, + curveWideningM: 0, + ground, +}); + +/* ── 433-4: R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10) ── */ +const mcSec = (m, kind) => ({ + station_id: idOf(m), + chainage_m: m, + kind, + center_x: m, + center_y: 0, + frame: { left_xy: [0, 1] }, + samples: [], + uphill_side: "left", + design: { memo: `MY-EDIT@${m}` }, +}); +const mcDetail = { + longitudinal: { stations: [], length_m: 100 }, + cross_sections: [ + mcSec(0, "bp"), + mcSec(20, "regular"), + mcSec(47.35, "curve"), + mcSec(60, "regular"), + mcSec(100, "ep"), + ], +}; +const mcOut = swap.swapCrossSections( + mcDetail, + [ + st("end:BP", 0, "end"), + st("grid:1", 20, "grid"), + st("curve:49.10", 49.1, "curve"), + st("grid:3", 60, "grid"), + st("end:EP", 101, "end"), + ], + ["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"], + 20, +); +// 멀리(> CURVE_NEAR_M) 간 MC = 못 이음(새 측점 · 노선 밖) +const farOut = swap.swapCrossSections( + mcDetail, + [ + st("end:BP", 0, "end"), + st("grid:1", 20, "grid"), + st("curve:62.00", 62, "curve"), + st("grid:3", 60, "grid"), + st("end:EP", 100, "end"), + ], + ["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"], + 20, +); + +/* ── 433-11: 덧 측점 넣기 · 빼기 — 서버 표본(A) 대 브라우저 지형(B) ── */ +const OFFSETS = [-12, -9, -6, -4, -2, 0, 2, 4, 6, 9, 12]; +const groundA = (m, o) => 100 + 0.02 * m + 0.35 * o; // 서버 샘플링(저장 상세) +const groundB = (m, o) => groundA(m, o) + 0.6 * Math.sin(m / 7 + o / 3); // 브라우저 설계 지형 +const planZ = (m) => 100.2 + 0.02 * m; +const samplesOf = (fn, m) => + OFFSETS.map((o) => ({ offset_m: o, elevation_m: fn(m, o), valid: true })); +const groundOf = (fn, m) => ({ + centerZ: fn(m, 0), + left: OFFSETS.filter((o) => o > 0).map((o) => ({ offsetM: o, z: fn(m, o) })), + right: OFFSETS.filter((o) => o < 0).map((o) => ({ offsetM: -o, z: fn(m, o) })), +}); +const designOf = (samples, m) => ({ + ...computeCrossDesign(samples, planZ(m), { + standard, + groundType: "soil", + sectionMode: "left_cut", + }), + design_elevation_m: planZ(m), +}); +const baseKeys = [ + ["end:BP", 0, "end"], + ["grid:1", 20, "grid"], + ["grid:2", 40, "grid"], + ["grid:3", 60, "grid"], + ["end:EP", 80, "end"], +]; +const kindOf = (kind, key) => + kind === "end" ? (key.endsWith("EP") ? "ep" : "bp") : kind === "grid" ? "regular" : "irregular"; +const savedSec = ([key, m, kind]) => { + const samples = samplesOf(groundA, m); + return { + station_id: idOf(m), + chainage_m: m, + kind: kindOf(kind, key), + center_x: m, + center_y: 0, + center_z: groundA(m, 0), + frame: { left_xy: [0, 1] }, + uphill_side: "left", + samples, + design: designOf(samples, m), + }; +}; +const saved = { + longitudinal: { stations: [], length_m: 80 }, + cross_sections: baseKeys.map(savedSec), +}; +const savedKeys = baseKeys.map(([key]) => key); +// 브라우저 다시 셈 = 상세 표본으로 면적(Cross_Refresh 꼴) +const recross = (detail) => + detail.cross_sections.map((s) => ({ ...s, design: designOf(s.samples, s.chainage_m) })); +const cumulative = (sections) => + computeMassHaul(sections, conversion).points.map((p) => [ + p.chainage_m, + Math.round(p.cumulative_volume_m3 * 1000) / 1000, + ]); + +const plusList = [...baseKeys.slice(0, 2), ["pipe:p30", 30, "pipe"], ...baseKeys.slice(2)]; +const plusStations = plusList.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m))); +const added = swap.swapCrossSections(saved, plusStations, savedKeys, 20); +const addedSections = recross(added.detail); +// 서버 기준 = 저장 상세(표본 A) + 덧 측점(브라우저 표본 B · 새 측점은 표본이 그것뿐) +const serverPlus = [...saved.cross_sections]; +serverPlus.splice(2, 0, addedSections[2]); +const backStations = baseKeys.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m))); +const removed = swap.swapCrossSections( + added.detail, + backStations, + plusList.map(([key]) => key), + 20, +); +// 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본 +const shifted = swap.swapCrossSections( + saved, + baseKeys.map(([key, m, kind]) => ({ ...st(key, m, kind, groundOf(groundB, m)), y: 1.5 })), + savedKeys, + 20, +); + +console.log( + JSON.stringify({ + mc: { + added: mcOut.added, + removed: mcOut.removed, + moves: [...mcOut.moves], + memo: mcOut.detail.cross_sections[2].design.memo ?? null, + stationId: mcOut.detail.cross_sections[2].station_id, + far: { added: farOut.added, removed: farOut.removed }, + }, + plus: { + keptSamples: added.detail.cross_sections + .filter((_, i) => i !== 2) + .every( + (s, i) => JSON.stringify(s.samples) === JSON.stringify(saved.cross_sections[i].samples), + ), + keptCenterZ: added.detail.cross_sections.filter((_, i) => i !== 2).map((s) => s.center_z), + savedCenterZ: saved.cross_sections.map((s) => s.center_z), + newSamples: + JSON.stringify(added.detail.cross_sections[2].samples) === + JSON.stringify(samplesOf(groundB, 30)), + browser: cumulative(addedSections), + server: cumulative(serverPlus), + back: cumulative(recross(removed.detail)), + saved: cumulative(saved.cross_sections), + // 고치기 전 꼴(모든 측점 브라우저 표본) — 차가 큼을 보임 + allBrowser: cumulative( + plusList.map(([, m], i) => ({ + ...addedSections[i], + design: designOf(samplesOf(groundB, m), m), + })), + ), + shiftedFresh: shifted.detail.cross_sections.every( + (s, i) => JSON.stringify(s.samples) === JSON.stringify(samplesOf(groundB, baseKeys[i][1])), + ), + }, + }), +); diff --git a/resources/tester/helper_440_draft_card_ids.cjs b/resources/tester/helper_440_draft_card_ids.cjs index 30a3c9fc6..1dfe816e7 100644 --- a/resources/tester/helper_440_draft_card_ids.cjs +++ b/resources/tester/helper_440_draft_card_ids.cjs @@ -5,7 +5,8 @@ const path = require("path"); const ROOT = path.resolve(__dirname, "..", ".."); const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`; -const REAL = /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross)$/; +const REAL = + /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/; const STUBS = { B05_Profile_Pipeline_Grade: { stationIdOf: idOf }, common_util_station: { stationLabel: (m) => String(m) }, diff --git a/resources/tester/test_346_2_station_swap.py b/resources/tester/test_346_2_station_swap.py index d03def1d7..6c74a76d4 100644 --- a/resources/tester/test_346_2_station_swap.py +++ b/resources/tester/test_346_2_station_swap.py @@ -96,7 +96,9 @@ def test_셋_늘고_둘_빠진_목록으로_갈아끼움(out): "ep", ] assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"] - assert pure["samples"] == [-0.5, 0, 0.5] + # 자리 같은 앞 단면 = 앞 표본(서버 정본 · 433-11) · 옮긴 측점 = 새 지반 표본 + assert pure["samples"] == [0] + assert pure["movedSamples"] == [-0.5, 0, 0.5] assert pure["oldUntouched"] is True and pure["length"] == 90 assert pure["same"] is False and pure["sameSelf"] is True diff --git a/resources/tester/test_433_11_plus_station_masshaul.py b/resources/tester/test_433_11_plus_station_masshaul.py new file mode 100644 index 000000000..988b6b040 --- /dev/null +++ b/resources/tester/test_433_11_plus_station_masshaul.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""433-11 — 덧 측점 넣기 · 빼기 뒤 브라우저 누가토량 = 서버(0.1 ㎥ 안). + +뿌리: 갈아끼움이 안 바뀐 측점까지 지반 표본 · 상단측 · 중심고를 브라우저 지형 값으로 갈음. +못박는 것 + - 자리 · 축이 같은 앞 단면 = 서버 표본 · 중심고 그대로 · 새 측점만 브라우저 표본. + - 덧 측점 넣은 뒤 누가토량 = 서버 기준(저장 표본 + 새 측점) · 빼면 저장 값 그대로. + - 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본. + - 고치기 전 꼴(모든 측점 브라우저 표본)은 1 ㎥ 넘게 어긋남(시험이 차를 잼). + +Node 헬퍼 `helper_433_swap.cjs` — 진짜 갈아끼움 · 횡단 엔진 · 유토. +""" + +import json +import subprocess +import sys +from pathlib import Path + +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 config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402 + +TOL_M3 = 0.1 + + +@pytest.fixture(scope="module") +def plus(tmp_path_factory): + work = tmp_path_factory.mktemp("b433_11") + standard = work / "standard.json" + conversion = work / "conversion.json" + standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8") + conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["plus"] + + +def _max_gap(a, b): + assert [p[0] for p in a] == [p[0] for p in b] + return max(abs(x[1] - y[1]) for x, y in zip(a, b)) + + +def test_안_바뀐_측점은_서버_표본_중심고(plus): + assert plus["keptSamples"] + assert plus["keptCenterZ"] == plus["savedCenterZ"] + assert plus["newSamples"] + + +def test_덧_측점_넣은_뒤_누가토량_서버와_같음(plus): + assert _max_gap(plus["browser"], plus["server"]) < TOL_M3 + + +def test_덧_측점_뺀_뒤_저장_값_그대로(plus): + assert _max_gap(plus["back"], plus["saved"]) < TOL_M3 + + +def test_고치기_전_꼴은_어긋남(plus): + assert _max_gap(plus["allBrowser"], plus["server"]) > 1.0 + + +def test_노선이_옮긴_측점은_새_지형(plus): + assert plus["shiftedFresh"] diff --git a/resources/tester/test_433_19_20_span_cad.py b/resources/tester/test_433_19_20_span_cad.py new file mode 100644 index 000000000..a03c49530 --- /dev/null +++ b/resources/tester/test_433_19_20_span_cad.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""433-19 · 433-20 — 구간값 죽은 길 걷음 · 표 위 CAD 요소 한도(서버 2000 짝). + +못박는 것 + - 구간값 제어에 기준벽 `update`(culvertopt 큐 쓰기) 없음 · 구간 줄은 다단 벽만(444 관 초안 한 벌). + - 표 위 CAD 한도 = 서버 `CrossNoteCad.entities max_length` · 넘으면 초안에 안 쓰고 알림. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B06 = ROOT / "B06_Section" + + +def _read(name: str) -> str: + return (B06 / name).read_text(encoding="utf-8") + + +def test_구간값_제어에_culvertopt_큐_쓰기_없음(): + span = _read("B06_Section_UI_Page_Span_Control.ts") + assert "culvertOptions" not in span + assert "update: (section, role" not in span + wire = _read("B06_Section_UI_Cross_Culvert_Wire.ts") + assert not re.search(r"^\s*update: \(section: CrossSection, role", wire, re.M) + view = _read("B06_Section_UI_Cross_View_Structure.ts") + assert "structureSpan?.update(" not in view + + +def test_CAD_한도_서버와_같음(): + schema = _read("B06_Section_Schema.py") + server = re.search( + r"class CrossNoteCad\(BaseModel\):.*?entities:.*?max_length=(\d+)", schema, re.S + ) + cad = _read("B06_Section_UI_Cross_Detail_Cad.ts") + screen = re.search(r"NOTE_CAD_MAX_ENTITIES = (\d+)", cad) + assert server and screen and server.group(1) == screen.group(1) + + +def test_CAD_한도_넘으면_초안에_안_씀(): + cad = _read("B06_Section_UI_Cross_Detail_Cad.ts") + body = cad[cad.index("function writeNoteCad(") :] + guard = body.index("NOTE_CAD_MAX_ENTITIES") + assert guard < body.index("structure_note_cad = cad") + assert "showToast(" in body[guard : body.index("structure_note_cad = cad")] diff --git a/resources/tester/test_433_4_mc_swap.py b/resources/tester/test_433_4_mc_swap.py new file mode 100644 index 000000000..49d8bead7 --- /dev/null +++ b/resources/tester/test_433_4_mc_swap.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""433-4 — 곡선 R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10). + +상세 갈아끼움이 옛 MC 횡단을 이어 받음. + +못박는 것 + - MC 설계(사용자 칸) 남음 · 측점 id 그대로 · added · removed 빔. + - moves 에 MC 짝(누가거리 열쇠 칸이 따라감). + - CURVE_NEAR_M 밖으로 간 MC = 못 이음(새 측점 · 옛 열쇠 removed). + +Node 헬퍼 `helper_433_swap.cjs` 가 진짜 모듈(`B06_Section_Station_Swap.ts`)을 부름. +""" + +import json +import subprocess +import sys +from pathlib import Path + +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 config.config_system import STANDARD_CROSS_SECTION # noqa: E402 +from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402 + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b433") + standard = work / "standard.json" + conversion = work / "conversion.json" + standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8") + conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8") + proc = subprocess.run( + ["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)], + 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_MC_설계가_새_곡선_열쇠로_남음(out): + mc = out["mc"] + assert mc["memo"] == "MY-EDIT@47.35" + assert mc["stationId"] == "station_000000047350" + assert mc["added"] == [] + assert mc["removed"] == [] + + +def test_moves_에_MC_짝(out): + assert ["47.35", "49.10"] in out["mc"]["moves"] + + +def test_멀리_간_MC_는_못_이음(out): + far = out["mc"]["far"] + assert far["added"] == ["curve:62.00"] + assert far["removed"] == ["curve:47.35"] From 83968bf6ed3f9c5da00db2f9813c8c16b79f1703 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:37:13 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(455=20433-13=E2=91=A1=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=20=EC=B2=AB=20=EC=A2=85=EB=8B=A8):=20=EA=B4=80=20?= =?UTF-8?q?=EC=A0=95=EC=B0=A9=20=C2=B7=20=EC=A7=81=EC=84=A0=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20=EC=84=A0=ED=98=95=EB=8F=84=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=EA=B0=92=20=EC=A7=81=EC=84=A0=ED=99=94=20=E2=80=94=20R=20500?= =?UTF-8?q?=20=EB=84=98=EB=8A=94=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=A7=81=EC=84=A0=20=EC=9C=84=20=C2=B7=20=EC=97=AC?= =?UTF-8?q?=EC=9C=A0=EB=A1=9C=20=EB=93=A4=EB=A6=B0=20=EA=B4=80=20=EA=BC=AD?= =?UTF-8?q?=EC=A7=93=EC=A0=90=EC=9D=80=20=EA=B2=BD=EA=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_Grade_Profile.py | 50 +++++-- .../tester/test_455_fallback_straight.py | 122 ++++++++++++++++++ 2 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_455_fallback_straight.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 3ce1036ec..11aeab2cc 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -367,11 +367,13 @@ def design_pipe_anchored_profile( # 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다. # 시·종점만 오프셋을 얹는다. target = np.interp(base_s, chainage, ground) + lifted = np.zeros(len(base_s), dtype=bool) if pipe_clearances: for index, value in enumerate(base_s): clearance = _clearance_at(pipe_clearances, float(value)) if clearance > 0: target[index] += clearance + lifted[index] = True target[0] = fixed[0] target[-1] = fixed[1] @@ -380,12 +382,15 @@ def design_pipe_anchored_profile( edits = dict(edits or {}) user_radii = dict(edits.get("curve_radii") or {}) - def radii_for(nodes_z: np.ndarray) -> dict[str, float]: - """현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다).""" + def radii_for(nodes_z: np.ndarray, held: np.ndarray) -> dict[str, float]: + """현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다) · + 직선 위로 옮긴(`held`) 꼭짓점은 R 없음.""" spans = base_s[1:] - base_s[:-1] grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0) resolved: dict[str, float] = {} for index in range(1, len(base_s) - 1): + if held[index]: + continue delta = abs(float(grades[index] - grades[index - 1])) radius = target_length / delta if delta > 1e-9 else min_radius resolved[chainage_key(base_s[index])] = max(radius, min_radius) @@ -394,19 +399,28 @@ def design_pipe_anchored_profile( # 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록 # 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다). # 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다. - base_z = target.copy() - radii = radii_for(base_z) - for _ in range(12): - radii = radii_for(base_z) - curves, _curve_warnings = build_curves(base_s, base_z, policy, radii) - plan_at = evaluate(base_s, base_z, curves, base_s) - error = target - plan_at - error[0] = 0.0 - error[-1] = 0.0 - if float(np.max(np.abs(error))) < 1e-4: + # 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 관 꼭짓점 묶음은 앞뒤 꺾임을 잇는 + # 직선 위(높이 한도 없음 · 요구 269-2) · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시 · + # 시설 여유로 들린 관 꼭짓점은 그대로(직선화 경계). + held = np.zeros(len(base_s), dtype=bool) + for _ in range(len(base_s)): + base_z = straighten_held(base_s, target, held) + for _ in range(12): + radii = radii_for(base_z, held) + curves, _curve_warnings = build_curves(base_s, base_z, policy, radii) + plan_at = evaluate(base_s, base_z, curves, base_s) + error = target - plan_at + error[0] = 0.0 + error[-1] = 0.0 + error[held] = 0.0 + if float(np.max(np.abs(error))) < 1e-4: + break + base_z = straighten_held(base_s, base_z + error, held) + flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held & ~lifted + if not flat.any(): break - base_z = base_z + error - edits["curve_radii"] = radii + held = held | flat + edits["curve_radii"] = radii_for(base_z, held) alignment = build_alignment( base_s=base_s, @@ -498,6 +512,14 @@ def design_alignment_profile( down_limit, balance_tolerance_percent=policy.balance_tolerance_percent, ) + # 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위 + held = np.zeros(len(base_s), dtype=bool) + flat = flat_bends(base_s, base_z, policy.default_curve_length_m) + while flat.any(): + held = held | flat + straight = straighten_held(base_s, base_z, held) + flat = flat_bends(base_s, straight, policy.default_curve_length_m) & ~held + base_z = straighten_held(base_s, base_z, held) alignment = build_alignment( base_s=base_s, base_z=base_z, diff --git a/resources/tester/test_455_fallback_straight.py b/resources/tester/test_455_fallback_straight.py new file mode 100644 index 000000000..8b2306feb --- /dev/null +++ b/resources/tester/test_455_fallback_straight.py @@ -0,0 +1,122 @@ +"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음. + +까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 · +직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음. +""" + +from __future__ import annotations + +import math +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 import GradeDesignOptions # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + AlignmentPolicy, + build_curves, + is_straight_bend, +) +from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_alignment_profile, + design_pipe_anchored_profile, +) + +PIPES = [60.0, 125.0, 190.0, 260.0, 330.0] + + +def _ground(s: float) -> float: + return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0) + + +def _longitudinal() -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True} + for s in range(0, 401) + ] + stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)] + return {"samples": samples, "stations": stations} + + +OPTIONS = GradeDesignOptions( + max_grade_pct=14.0, + max_reverse_grade_pct=5.0, + min_vertical_radius_m=100.0, + min_curve_length_m=20.0, + min_tangent_length_m=20.0, + vertical_curve_skip_delta_pct=0.5, + design_speed_kph=20, +) + + +def _flat(alignment: dict) -> tuple[list[float], int]: + """(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정.""" + s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64) + z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64) + policy = AlignmentPolicy.from_dict(alignment["policy"]) + grades = np.diff(z) / np.diff(s) + straight = np.zeros(len(s), dtype=bool) + for i in range(1, len(s) - 1): + straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i]) + flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight + assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"]) + return s[flat].tolist(), int(straight.sum()) + + +def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None: + alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES) + flat, straight = _flat(alignment) + assert flat == [] and 0 < straight < len(PIPES) + # 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남 + radii = alignment["edits"]["curve_radii"] + kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]} + assert set(map(float, radii)) <= kinks + s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64) + z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64) + curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii) + assert all(float(c["chainage_m"]) in kinks for c in curves) + + +def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None: + alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS) + assert _flat(alignment)[0] == [] + # 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로 + import B05_Profile.B05_Profile_Engine_Grade_Profile as engine + + s = np.array([0.0, 100.0, 200.0, 400.0]) + z = np.array([100.0, 103.0, 106.5, 124.5]) + monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s) + monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True)) + alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS) + flat, straight = _flat(alignment) + assert flat == [] and straight == 1 + nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]} + assert nodes[100.0] == pytest.approx(103.25) + assert nodes[200.0] == pytest.approx(106.5) + + +def test_부르는_곳() -> None: + src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8") + assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘 + + +def test_여유로_들린_관_꼭짓점은_그대로() -> None: + """관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위.""" + clearances = {125.0: 1.5} + alignment, _profile = design_pipe_anchored_profile( + _longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances + ) + flat, straight = _flat(alignment) + assert straight > 0 + assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음 + rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]} + plan = np.interp(125.0, list(rows), list(rows.values())) + assert plan > _ground(125.0) + 1.0 From e5dcb695e65d840ce17302596b41f64e90711bb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:37:54 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(448=20B05=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95):=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=C2=B7=20[=ED=99=95=EC=A0=95]=20=EC=98=81=EA=B5=AC=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20=EC=84=B8=EC=85=98=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=C2=B7=20settings=20=EC=B0=A8=EB=A1=80=20=C2=B7=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EA=B8=B0=EC=A4=80=20=EA=B0=88=EC=95=84=20=EB=81=BC?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세션 칸 pagesettings(설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점) · 열 때 최신 응답 위에 얹음 - 저장 차례 settings = 노선 뒤 · 관 앞(기본 창구 · 노선 바꾼 [저장]에도 돎) - PUT /route/page-settings — stage 2 params 에 사용자 값만 병합(상태 · STALE 그대로) · 종단 정책 · criteria 갱신 뒤 위반 · 요약 다시 셈(기하 그대로) - 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(끝점 ▲▼ 와 같은 길) · 샘플 간격 두 칸 읽기 전용 - E1 재설계 요청이 설계속도 · 최소고 강제 · 시작 측점 옮김 · E2 B06 재생성 지형 기본 특수 · E3 초기값 스냅샷 stage 2 params 뜸 · 되돌림 - 노선 추천 · 평면 R 설정 읽기가 params.options 를 봄(옛 맨 위 칸 폴백) - 시험 test_442_2_page_settings · test_442_2_grade_limit_mirror · design_store_save 차례 갱신 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 + A00_Common/design/design_profile.ts | 10 + A00_Common/design/design_save.ts | 7 +- B03_FileInput/B03_FileInput_Service_Chain.py | 4 + B05_Profile/B05_Profile_Api_Fetch.ts | 24 ++ B05_Profile/B05_Profile_Api_PageSettings.ts | 67 +++ B05_Profile/B05_Profile_PageSettings.py | 127 ++++++ B05_Profile/B05_Profile_Router.py | 1 + B05_Profile/B05_Profile_Router_Lifecycle.py | 88 +++- B05_Profile/B05_Profile_Router_Replan.py | 2 + B05_Profile/B05_Profile_Schema.py | 46 ++ B05_Profile/B05_Profile_UI_Page_Latest.ts | 9 +- B05_Profile/B05_Profile_UI_Panel.ts | 76 +++- B05_Profile/B05_Profile_UI_Panel_Fields.ts | 2 + B05_Profile/B05_Profile_UI_Panel_Settings.ts | 37 ++ B05_Profile/logic/B05_Profile_EndOffsets.ts | 44 ++ .../B06_Section_Router_Regenerate_Options.py | 4 +- common_util/common_util_initial_snapshot.py | 33 ++ common_util/common_util_workflow_state.py | 31 ++ .../tester/helper_442_2_page_settings.cjs | 175 ++++++++ resources/tester/helper_design_store_save.cjs | 2 + .../tester/test_442_2_grade_limit_mirror.py | 80 ++++ resources/tester/test_442_2_page_settings.py | 396 ++++++++++++++++++ resources/tester/test_design_store_save.py | 1 + 24 files changed, 1237 insertions(+), 31 deletions(-) create mode 100644 B05_Profile/B05_Profile_Api_PageSettings.ts create mode 100644 B05_Profile/B05_Profile_PageSettings.py create mode 100644 B05_Profile/logic/B05_Profile_EndOffsets.ts create mode 100644 resources/tester/helper_442_2_page_settings.cjs create mode 100644 resources/tester/test_442_2_grade_limit_mirror.py create mode 100644 resources/tester/test_442_2_page_settings.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index c8f068986..60cd2b720 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -209,6 +209,8 @@ export const STATE_REGISTRY = { /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */ "alignment-draft": { bucket: "draft", scope: "route" }, + /** B05 페이지 설정(448) — 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점. [저장]의 `settings` 차례가 내보냄. */ + pagesettings: { bucket: "draft", scope: "project" }, /** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`). * 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */ "route-draft": { bucket: "draft", scope: "project" }, diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 8cbb093ff..460d23b82 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -46,6 +46,11 @@ import { toAlignmentBase, } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { + endOffsetsOf, + withEndOffset, + type ProfileEnd, +} from "../../B05_Profile/logic/B05_Profile_EndOffsets"; import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, @@ -607,6 +612,11 @@ export function createDesignProfile(ctx: DesignProfileContext) { if (key !== undefined) store.resetStation(Number(key), moveTo); }, isDirty: () => store.dirty(), + /** 시 · 종점 계획고 조정(448) — 첫 · 끝 측점 계획고 편집(m) · 기준이 없으면 null. */ + endOffsets: () => (base ? endOffsetsOf(base, store.edits()) : null), + /** 시 · 종점 계획고 조정 칸 → 그 측점 편집(끝점 ▲▼ 와 같은 길 · 가드 거침). false = 막힘. */ + setEndOffset: (end: ProfileEnd, value: number): boolean => + !!base && applyEdits(withEndOffset(base, store.edits(), end, value)), /** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */ pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }), /** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에 diff --git a/A00_Common/design/design_save.ts b/A00_Common/design/design_save.ts index 2edfd617b..8d0b9d249 100644 --- a/A00_Common/design/design_save.ts +++ b/A00_Common/design/design_save.ts @@ -2,7 +2,7 @@ * A00_Common/design/design_save.ts * B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례. * - * 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 + * 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단 * * 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan` * (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고 @@ -40,6 +40,7 @@ import { pendingAlignment, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options"; +import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings"; import { clearState, stateKey } from "../b_page_state"; import { carryAfterRouteSave, @@ -52,6 +53,7 @@ import { /** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */ export const DESIGN_SAVE_ORDER = [ "route", + "settings", "pipes", "structures", "plusStations", @@ -92,6 +94,7 @@ export function savingDesign(projectId: string | null): boolean { * 초안은 세션에 남아 다음 [저장]에서 다시 나감). */ const STEP_LABEL: Record = { route: "노선", + settings: "페이지 설정", pipes: "배수관", structures: "구조물", plusStations: "추가 측점", @@ -148,6 +151,8 @@ function defaultStep( fromRouteId: number = routeId, ): () => Promise { switch (step) { + case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448) + return () => flushPageSettings(projectId); case "pipes": return () => flushPendingPipes(projectId); case "structures": diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index afdcc4f3f..778377ddf 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -416,6 +416,10 @@ def redesign_solve_request( **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, paved=bool(options.get("paved", False)), terrain_type=str(options.get("terrain_type") or "special"), + # 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1) + design_speed_kph=options.get("design_speed_kph"), + enforce_pipe_clearance=params.get("enforce_pipe_clearance"), + station_display=params.get("station_display"), main_direction=str(options.get("main_direction") or "auto"), min_curve_radius_m=options.get("min_curve_radius_m"), max_uphill_grade=options.get("max_uphill_grade"), diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 7f2f0a568..cabaf6440 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -147,6 +147,8 @@ export interface RouteLatestResponse { start_elevation_offset_m?: number | null; end_elevation_offset_m?: number | null; enforce_pipe_clearance?: boolean | null; + /** 시작 측점 · 누가거리 표시(448) — B05 라벨만. */ + station_display?: { station: number; cumulative: number } | null; } | null; } @@ -244,6 +246,28 @@ export async function saveProfileAlignment( ); } +/** B05 페이지 설정(448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값). */ +export interface PageSettingsBody { + design_speed_kph: 20 | 30 | 40; + terrain_type: "normal" | "special"; + max_grade_pct: number | null; + min_vertical_radius_m: number | null; + min_tangent_length_m: number | null; + enforce_pipe_clearance: boolean; + station_display: { station: number; cumulative: number }; +} + +/** 페이지 설정을 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). */ +export async function savePageSettings( + projectId: string, + body: PageSettingsBody, +): Promise<{ warnings: string[]; profile_updated: boolean }> { + return requestJson(`/projects/${projectId}/route/page-settings`, { + method: "PUT", + body: JSON.stringify(body), + }); +} + /** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */ export interface RouteConfirmRequest { filter_key?: string; diff --git a/B05_Profile/B05_Profile_Api_PageSettings.ts b/B05_Profile/B05_Profile_Api_PageSettings.ts new file mode 100644 index 000000000..561554ba7 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_PageSettings.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * B05_Profile_Api_PageSettings.ts + * B05 「페이지 설정」 초안(448) — 세션 칸 `pagesettings` · [저장]의 `settings` 차례(노선 뒤 · 계획고 앞). + * + * 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점을 조작 즉시 세션에 쌓고 [저장] · [확정]에서만 + * 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). + * 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀. + * 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본. + * ========================================================================== */ + +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { + readRouteLatestCache, + savePageSettings, + writeRouteLatestCache, + type PageSettingsBody, +} from "./B05_Profile_Api_Fetch"; +import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields"; + +export type PageSettingsDraft = PageSettingsBody; + +/** 좌측 폼 값 → 초안(사용자가 넣은 값 그대로 · 빈 칸 = null). */ +export const pageSettingsOf = (values: RoutePanelValues): PageSettingsDraft => ({ + design_speed_kph: values.designSpeed, + terrain_type: values.terrainType, + max_grade_pct: values.maxGradePct, + min_vertical_radius_m: values.minVerticalRadius, + min_tangent_length_m: values.minTangentLength, + enforce_pipe_clearance: values.enforcePipeClearance, + station_display: values.stationDisplay, +}); + +/** 초안 → 좌측 폼 되살리기 값(최신 응답 위에 얹음). */ +export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial => ({ + designSpeed: draft.design_speed_kph, + terrainType: draft.terrain_type, + maxGradePct: draft.max_grade_pct, + minVerticalRadius: draft.min_vertical_radius_m, + minTangentLength: draft.min_tangent_length_m, + enforcePipeClearance: draft.enforce_pipe_clearance, + stationDisplay: draft.station_display, +}); + +export const readPageSettings = (projectId: string): PageSettingsDraft | null => + readState("pagesettings", projectId); + +export const writePageSettings = (projectId: string, value: PageSettingsDraft): void => + writeState("pagesettings", value, projectId); + +/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움 + * (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */ +export async function flushPageSettings(projectId: string): Promise { + const draft = readPageSettings(projectId); + if (!draft) return; + await savePageSettings(projectId, draft); + const latest = readRouteLatestCache(projectId); + if (latest?.route_params) { + const { design_speed_kph, terrain_type, ...top } = draft; + latest.route_params = { + ...latest.route_params, + ...top, + options: { ...latest.route_params.options, design_speed_kph, terrain_type }, + }; + writeRouteLatestCache(projectId, latest); + } + clearState("pagesettings", projectId); +} diff --git a/B05_Profile/B05_Profile_PageSettings.py b/B05_Profile/B05_Profile_PageSettings.py new file mode 100644 index 000000000..428eb0d87 --- /dev/null +++ b/B05_Profile/B05_Profile_PageSettings.py @@ -0,0 +1,127 @@ +"""B05 페이지 설정 저장(448) — stage 2 params 에 얹을 값 · 종단 기준 갈아 끼우기 · 옛 스냅샷 걷기. + +설계속도 · 지형 · 기준값은 **경고 기준만** 바꾼다 — 기하(base_pvi · 편집)는 그대로 · 종횡단 다시 +만들기 없음. params 에는 사용자가 넣은 원값만 둔다(해석값 저장 금지 — `resolve_grade_options`). +시작 측점은 B05 표시 전용 — 저장만 하고 측점 열쇠 · 다른 페이지 라벨에 쓰지 않는다. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Grade import ( + GradeDesignOptions, + legal_plan_radius_min_m, + resolve_grade_options, +) +from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile +from common_util.common_util_json import atomic_write_json + +#: params["options"] 안의 페이지 설정 칸 +PAGE_OPTION_KEYS = ("design_speed_kph", "terrain_type") +#: params 맨 위의 페이지 설정 칸(빈 값 = 법정값) +PAGE_TOP_KEYS = ( + "max_grade_pct", + "min_vertical_radius_m", + "min_tangent_length_m", + "enforce_pipe_clearance", + "station_display", +) +_STORED_KEYS = ("max_grade_pct", "min_vertical_radius_m", "min_tangent_length_m") + + +def params_patch(request: Any) -> dict[str, Any]: + """요청 → stage 2 params 에 얹을 값(원값 그대로).""" + return { + "options": {key: getattr(request, key) for key in PAGE_OPTION_KEYS}, + **{key: getattr(request, key) for key in PAGE_TOP_KEYS}, + } + + +def strip_page_settings(params: dict[str, Any] | None) -> dict[str, Any] | None: + """페이지 설정 칸만 걷어 기본값으로 — stage 2 params 를 안 뜬 옛 스냅샷의 [초기화].""" + if not isinstance(params, dict): + return params + stripped = {key: value for key, value in params.items() if key not in PAGE_TOP_KEYS} + options = params.get("options") + if isinstance(options, dict): + stripped["options"] = {k: v for k, v in options.items() if k not in PAGE_OPTION_KEYS} + return stripped + + +def grade_class_of(road_type: str | None, params: dict[str, Any]) -> str: + """기준 등급 — 노선 풀기가 남긴 값 · 없으면 B02 임도 종류(main → trunk · 그 밖 = work).""" + stored = (params.get("options") or {}).get("grade_class") + if stored: + return str(stored) + if road_type in ("main", "trunk"): + return "trunk" + return "fire" if road_type == "fire" else "work" + + +def grade_options_of(road_type: str | None, params: dict[str, Any]) -> GradeDesignOptions: + """params(사용자 원값) → 해석된 기준(작업임도 30 → 20 · 빈 칸 = 법정값).""" + options = params.get("options") or {} + speed = options.get("design_speed_kph") + return resolve_grade_options( + grade_class_of(road_type, params), + terrain_type=str(options.get("terrain_type") or "special"), + paved=bool(options.get("paved", False)), + main_direction=str(options.get("main_direction") or "auto"), + design_speed_kph=int(speed) if speed is not None else None, + stored={key: params[key] for key in _STORED_KEYS if params.get(key) is not None} + | {"enforce_pipe_clearance": params.get("enforce_pipe_clearance")}, + ) + + +def retarget_longitudinal( + longitudinal: dict[str, Any], options: GradeDesignOptions +) -> tuple[dict[str, Any], dict[str, Any]]: + """종단 정본의 정책 · 기준을 새 기준으로 — 저장 편집으로 다시 얹어 위반 · 요약만 새로. + + 정책 칸 = 최대 종단기울기 · 종단곡선 최소 R · 평면 최소 R(+ 있던 최소 L). 기하 칸은 그대로. + 반환 = (선형, 요약 `{id, ...}`) · `longitudinal` 은 제자리에서 바뀐다. + """ + stored = longitudinal.get("profile_alignment") + if not isinstance(stored, dict) or not stored.get("base_pvi"): + raise ValueError("저장된 계획선 선형이 없어 기준을 바꿀 수 없습니다.") + policy = dict(stored.get("policy") or {}) + policy["max_grade_pct"] = options.max_grade_pct + policy["min_vertical_radius_m"] = options.min_vertical_radius_m + policy["min_plan_radius_m"] = legal_plan_radius_min_m( + options.design_speed_kph, options.terrain_type + ) + if policy.get("min_curve_length_m"): + policy["min_curve_length_m"] = options.min_curve_length_m + stored["policy"] = policy + profiles = longitudinal.get("design_profiles") + previous = profiles[0] if isinstance(profiles, list) and profiles else {} + direction = (previous.get("criteria") or {}).get("resolved_main_direction", "none") + previous["criteria"] = {**options.as_dict(), "resolved_main_direction": direction} + longitudinal["design_profiles"] = [previous, *(profiles or [])[1:]] + alignment, profile = rebuild_alignment_profile(longitudinal, stored.get("edits")) + # `AlignmentPolicy.from_dict` 가 평면 최소 R 을 안 읽음 — 다시 셈한 정책에 되얹음 + alignment["policy"]["min_plan_radius_m"] = policy["min_plan_radius_m"] + longitudinal["profile_alignment"] = alignment + longitudinal["design_profiles"][0] = profile + return alignment, {"id": profile["id"], **profile["summary"]} + + +def retarget_file( + project_root: Path, longitudinal_file_path: str, options: GradeDesignOptions +) -> dict[str, Any] | None: + """종단 정본 파일에 새 기준 — 요약을 돌려줌 · 계획선 선형이 없으면 None(파일 그대로).""" + root = Path(project_root).resolve() + path = (root / longitudinal_file_path).resolve() + if root not in path.parents: + raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.") + if not path.is_file(): + return None + longitudinal = json.loads(path.read_text(encoding="utf-8")) + if not (longitudinal.get("profile_alignment") or {}).get("base_pvi"): + return None + _, summary = retarget_longitudinal(longitudinal, options) + atomic_write_json(path, longitudinal) + return summary diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 589a5c862..88a34f7b5 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -157,6 +157,7 @@ async def solve_route_record( "cross_sample_interval_m": request.cross_sample_interval_m, "long_sample_interval_m": request.long_sample_interval_m, **request.grade_options(), + "station_display": request.station_display, # B05 표시 전용(448) } log_b05_debug( logger, diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index d49503b57..79abc5471 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -11,22 +11,34 @@ from pathlib import Path from typing import Any from uuid import UUID +import aiomysql from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Debug import log_b05_debug -from B05_Profile.B05_Profile_Repository import confirm_route, get_latest_route +from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m +from B05_Profile.B05_Profile_PageSettings import grade_options_of, params_patch, retarget_file +from B05_Profile.B05_Profile_Repository import ( + confirm_route, + get_latest_route, + update_longitudinal_grade_summary, +) from B05_Profile.B05_Profile_Router_Confirm import ( _append_irregular_cross_sections, _merge_uphill_overrides_into_longitudinal, sync_uphill_overrides_into_designs, ) -from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse +from B05_Profile.B05_Profile_Schema import ( + PageSettingsRequest, + PageSettingsResponse, + RouteConfirmRequest, + RouteConfirmResponse, +) from B06_Section.B06_Section_Repository import get_longitudinal_section from common_util.common_util_auth import get_optional_session from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_workflow_state import complete_stage, start_stage +from common_util.common_util_workflow_state import complete_stage, merge_stage_params, start_stage from config.config_db import get_db_pool logger = logging.getLogger(__name__) @@ -149,6 +161,76 @@ async def confirm_latest_route( ) +@router.put("/{project_id}/route/page-settings", response_model=PageSettingsResponse) +async def save_page_settings( + project_id: UUID, request: PageSettingsRequest +) -> PageSettingsResponse | JSONResponse: + """B05 페이지 설정 [저장](448) — stage 2 params 에 사용자 값만 얹고(상태 · STALE 그대로) + 최신 종단 정본의 경고 기준을 갈아 끼워 위반 · 요약을 다시 셈. 기하 · 종횡단은 그대로.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + await connection.begin() + try: + async with connection.cursor(aiomysql.DictCursor) as cursor: + params = await merge_stage_params( + cursor, str(project_id), 2, params_patch(request) + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + options = grade_options_of(row[0] if row else None, params) + latest = await get_latest_route(connection, project_id) + longitudinal = ( + await get_longitudinal_section(connection, project_id, latest["id"]) + if latest + else None + ) + summary = None + if longitudinal: + stored_path = await get_project_storage_relative_path(connection, project_id) + summary = await asyncio.to_thread( + retarget_file, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + options, + ) + if summary is not None: + await connection.begin() + try: + await update_longitudinal_grade_summary( + connection, route_id=latest["id"], grade_summary=summary + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return PageSettingsResponse( + project_id=str(project_id), + design_speed_kph=options.design_speed_kph, + terrain_type=options.terrain_type, + max_grade_pct=options.max_grade_pct, + min_vertical_radius_m=options.min_vertical_radius_m, + min_plan_radius_m=legal_plan_radius_min_m( + options.design_speed_kph, options.terrain_type + ), + warnings=list(options.warnings), + profile_updated=summary is not None, + ) + except Exception: + logger.exception("B05 페이지 설정 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "페이지 설정 저장 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/route/reset") async def reset_route_design( project_id: UUID, diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 5383aee86..75a018f5f 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -187,6 +187,8 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]: (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None ) params = (stage2 or {}).get("params") or {} + # 노선 풀기 · 페이지 설정 저장(448)은 `options` 안에 둠 — 옛 맨 위 칸은 폴백 + params = {**params, **{k: v for k, v in (params.get("options") or {}).items() if v}} speed = params.get("design_speed_kph") if isinstance(speed, (int, float)): design_speed = int(speed) diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index c943f5628..7ab3241d1 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -117,6 +117,8 @@ class RouteSolveRequest(BaseModel): enforce_pipe_clearance: bool | None = Field( default=None, description="횡단배수 최소 계획고 강제 (기본 해제)" ) + # 시작 측점 · 누가거리 표시(448) — B05 라벨만 · 다시 풀어도 params 에 그대로 옮김. + station_display: dict[str, float] | None = None @model_validator(mode="after") def validate_choices(self) -> "RouteSolveRequest": @@ -217,6 +219,50 @@ class ProfileAlignmentSaveRequest(BaseModel): return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} +class PageSettingsRequest(BaseModel): + """B05 페이지 설정 [저장](448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값).""" + + model_config = ConfigDict(extra="forbid") + + design_speed_kph: int = 20 + terrain_type: str = "special" + max_grade_pct: float | None = Field(default=None, gt=0) + min_vertical_radius_m: float | None = Field(default=None, gt=0) + min_tangent_length_m: float | None = Field(default=None, gt=0) + enforce_pipe_clearance: bool = False + #: 시작 측점 · 누가거리 — B05 표시 전용(측점 열쇠 · 다른 페이지 라벨에 안 씀) + station_display: dict[str, float] | None = None + + @model_validator(mode="after") + def validate_choices(self) -> "PageSettingsRequest": + if self.design_speed_kph not in (20, 30, 40): + raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.") + if self.terrain_type not in GRADE_TERRAIN_TYPES: + raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") + if self.station_display is not None: + station = self.station_display.get("station", 0) + self.station_display = { + "station": float(max(0, round(station))), + "cumulative": float(self.station_display.get("cumulative", 0)), + } + return self + + +class PageSettingsResponse(BaseModel): + """페이지 설정 저장 결과 — 해석된 기준(작업임도 30 → 20 등) · 경고.""" + + status: str = "success" + project_id: str + design_speed_kph: int + terrain_type: str + max_grade_pct: float + min_vertical_radius_m: float + min_plan_radius_m: float + warnings: list[str] = Field(default_factory=list) + #: 종단 정본의 기준을 갈아 끼우고 위반 · 요약을 다시 셈했나(종단이 없으면 false) + profile_updated: bool = False + + class ProfileAlignmentSaveResponse(BaseModel): """계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다).""" diff --git a/B05_Profile/B05_Profile_UI_Page_Latest.ts b/B05_Profile/B05_Profile_UI_Page_Latest.ts index 186b828c0..6a5b0ebec 100644 --- a/B05_Profile/B05_Profile_UI_Page_Latest.ts +++ b/B05_Profile/B05_Profile_UI_Page_Latest.ts @@ -10,6 +10,7 @@ import { writeRouteLatestCache, type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; +import { panelValuesOfDraft, readPageSettings } from "./B05_Profile_Api_PageSettings"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; /** @@ -63,9 +64,11 @@ export function createLatestSource(projectId: string) { return { read, write, load, verify }; } -/** 저장된 노선 설정값 → 좌측 폼 되살리기 값. */ +/** 저장된 노선 설정값 → 좌측 폼 되살리기 값 — 세션 페이지 설정 초안이 있으면 그 위에(448). + * 시 · 종점 계획고 조정은 종단 편집이 비추므로 여기서 안 채움(옛 params 값은 자동 선형에 이미 듦). */ export function panelValuesOf(next: RouteLatestResponse): Partial { const options = next.route_params?.options ?? {}; + const draft = readPageSettings(next.project_id); return { contourInterval: next.surface_params.contour_interval_m, gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined, @@ -77,9 +80,9 @@ export function panelValuesOf(next: RouteLatestResponse): Partial { + callbacks.onStationDisplayChange(offset); + rememberSettings(); + }, + // 시 · 종점 계획고 조정 = 종단도 끝 측점 편집(448) — 종단도는 한 벌(`shared.profile`) + endOffsets: () => shared.profile?.endOffsets() ?? null, + onEndOffsetChange: (end, value) => void shared.profile?.setEndOffset(end, value), + }); const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4) const { contourInterval, @@ -118,6 +128,37 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh enforcePipeClearance, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); + // 페이지 설정 초안(448) — 고친 즉시 세션에 · [저장]이 내보냄(시 · 종점 조정은 계획고 편집이라 뺌) + [ + designSpeed, + terrainType, + maxGradePct, + minVerticalRadius, + minTangentLength, + enforcePipeClearance, + ].forEach((input) => input.addEventListener("change", rememberSettings)); + function rememberSettings(): void { + writePageSettings(shared.projectId, pageSettingsOf(values())); + } + + function values(): RoutePanelValues { + return { + contourInterval: Number(contourInterval.value) || 1, + gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], + stationInterval: STATION_INTERVAL_M, + crossSampleInterval: parseOptional(crossSampleInterval), + longSampleInterval: parseOptional(longSampleInterval), + designSpeed: settings.currentSpeed(), + terrainType: terrainType.value as RoutePanelValues["terrainType"], + maxGradePct: parseOptional(maxGradePct), + minVerticalRadius: parseOptional(minVerticalRadius), + minTangentLength: parseOptional(minTangentLength), + startElevationOffset: parseOptional(startElevationOffset), + endElevationOffset: parseOptional(endElevationOffset), + enforcePipeClearance: enforcePipeClearance.checked, + stationDisplay: settings.stationDisplayOffset(), + }; + } // 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 · // [확정]은 한 벌이 두 보기 몫을 모아 낸다. @@ -136,23 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh gradeLimitPct: settings.gradeLimitPct, /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ setCorridorPending, - values(): RoutePanelValues { - return { - contourInterval: Number(contourInterval.value) || 1, - gradeClass: gradeClass.value as RoutePanelValues["gradeClass"], - stationInterval: STATION_INTERVAL_M, - crossSampleInterval: parseOptional(crossSampleInterval), - longSampleInterval: parseOptional(longSampleInterval), - designSpeed: settings.currentSpeed(), - terrainType: terrainType.value as RoutePanelValues["terrainType"], - maxGradePct: parseOptional(maxGradePct), - minVerticalRadius: parseOptional(minVerticalRadius), - minTangentLength: parseOptional(minTangentLength), - startElevationOffset: parseOptional(startElevationOffset), - endElevationOffset: parseOptional(endElevationOffset), - enforcePipeClearance: enforcePipeClearance.checked, - }; - }, + values, restore(values: Partial) { if (values.contourInterval != null) contourInterval.value = String(values.contourInterval); if (values.gradeClass) gradeClass.value = values.gradeClass; @@ -167,17 +192,24 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh } if (values.designSpeed) designSpeed.value = String(values.designSpeed); if (values.terrainType) terrainType.value = values.terrainType; - if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); - if (values.minVerticalRadius != null) - minVerticalRadius.value = String(values.minVerticalRadius); - if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength); + // 기준값 — null = 사용자가 비운 칸(초안 · 448) · undefined = 그대로 + const typed: Array<[HTMLInputElement, number | null | undefined]> = [ + [maxGradePct, values.maxGradePct], + [minVerticalRadius, values.minVerticalRadius], + [minTangentLength, values.minTangentLength], + ]; + typed.forEach(([input, value]) => { + if (value !== undefined) input.value = value === null ? "" : String(value); + }); if (values.startElevationOffset != null) startElevationOffset.value = String(values.startElevationOffset); if (values.endElevationOffset != null) endElevationOffset.value = String(values.endElevationOffset); if (values.enforcePipeClearance != null) enforcePipeClearance.checked = values.enforcePipeClearance; + if (values.stationDisplay) settings.setStationDisplay(values.stationDisplay); settings.syncCriteria(); + settings.showEndOffsets(); }, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Fields.ts b/B05_Profile/B05_Profile_UI_Panel_Fields.ts index 9b1fc2097..550298ce1 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Fields.ts @@ -28,6 +28,8 @@ export interface RoutePanelValues { /** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시). * 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */ enforcePipeClearance: boolean; + /** 시작 측점 · 누가거리 표시(448 저장) — B05 라벨만 · 측점 열쇠 · 다른 페이지에 안 씀. */ + stationDisplay: { station: number; cumulative: number }; } /** diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index eb61e2853..2c1506eb3 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -25,6 +25,10 @@ export interface SettingsPartCallbacks { onContourApply: (interval: number) => void | Promise; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; + /** 시 · 종점 계획고 조정(448) = 첫 · 끝 측점 계획고 편집 — 지금 값(기준 없으면 null). */ + endOffsets?: () => { start: number; end: number } | null; + /** 칸을 바꿈 → 그 끝 측점 편집으로(빈 칸 = 0). */ + onEndOffsetChange?: (end: "start" | "end", value: number) => void; } export function createSettingsPart(callbacks: SettingsPartCallbacks) { @@ -73,6 +77,11 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); + // 읽기 전용(448) — 바꾸면 종횡단을 다시 표본해야 해서 여기서 고치지 않음. + [crossSampleInterval, longSampleInterval].forEach((input) => { + input.disabled = true; + input.title = "종횡단 표본 간격 — 바꾸려면 종횡단을 다시 만들어야 합니다(읽기 전용)."; + }); // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). const sampleRow = document.createElement("div"); sampleRow.className = "b05-route__field-row"; @@ -102,11 +111,33 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const minTangentLength = numberField("최소 직선 길이 (m)"); const startElevationOffset = numberField("시점 계획고 조정 (m)"); const endElevationOffset = numberField("종점 계획고 조정 (m)"); + // 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(448) — 칸은 종단 편집을 비추고 바꾸면 그 편집으로. + const endFields = { start: startElevationOffset, end: endElevationOffset }; + function showEndOffsets(): void { + const offsets = callbacks.endOffsets?.(); + (["start", "end"] as const).forEach((end) => { + endFields[end].disabled = !offsets; + if (offsets && document.activeElement !== endFields[end]) + endFields[end].value = offsets[end] ? String(Number(offsets[end].toFixed(3))) : ""; + }); + } + (["start", "end"] as const).forEach((end) => { + endFields[end].placeholder = "0"; + endFields[end].title = + "자동 계획선 대비 이 끝 측점의 계획고 편집(m) — 종단도 끝점 ▲▼ 와 같은 값."; + endFields[end].addEventListener("change", () => { + callbacks.onEndOffsetChange?.(end, parseOptional(endFields[end]) ?? 0); + showEndOffsets(); // 막힌 편집(최소고 가드)이면 옛 값으로 돌아옴 + }); + }); const criteriaNote = document.createElement("p"); criteriaNote.className = "b05-route__note"; const gradeAdvanced = document.createElement("details"); const gradeSummary = document.createElement("summary"); gradeSummary.textContent = "기준값 직접 지정"; + // 칸을 펼 때 · 칸 위로 올 때 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤) + gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets()); + gradeAdvanced.addEventListener("pointerenter", showEndOffsets); gradeAdvanced.append( gradeSummary, maxGradePct.wrapper, @@ -224,6 +255,12 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { enforcePipeClearance, }, stationDisplayOffset, + /** 저장된 시작 측점 · 누가거리를 칸에 되살림(알림 없음). */ + setStationDisplay(next: { station: number; cumulative: number }) { + startStation.value = String(next.station); + startCumulative.value = String(next.cumulative); + }, + showEndOffsets, gradeLimitPct, currentSpeed, syncSpeedChoices, diff --git a/B05_Profile/logic/B05_Profile_EndOffsets.ts b/B05_Profile/logic/B05_Profile_EndOffsets.ts new file mode 100644 index 000000000..d32d8b47f --- /dev/null +++ b/B05_Profile/logic/B05_Profile_EndOffsets.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * logic/B05_Profile_EndOffsets.ts + * 「페이지 설정」 시 · 종점 계획고 조정(448) = 첫 · 끝 측점의 계획고 편집(`station_offsets`) 한 칸. + * 칸 값 = 자동 선형 대비 그 측점 편집(m) · 바꾸면 끝점 ▲▼ 와 같은 길(`adjustStation`)로 얹음 — + * 계산 줄 즉시 · [저장] `alignment` 차례가 서버 정본 · [초기화]는 종단 파일과 함께 0. + * ========================================================================== */ + +import { + adjustStation, + chainageKey, + type AlignmentBase, + type AlignmentEdits, +} from "./B05_Profile_UI_Profile_Alignment"; + +export type ProfileEnd = "start" | "end"; + +function endChainage(base: AlignmentBase, end: ProfileEnd): number | undefined { + const nodes = base.basePvi; + return (end === "start" ? nodes[0] : nodes[nodes.length - 1])?.chainage_m; +} + +/** 첫 · 끝 측점 계획고 편집(m) — 편집이 없으면 0. */ +export function endOffsetsOf( + base: AlignmentBase, + edits: AlignmentEdits, +): Record { + const at = (end: ProfileEnd): number => { + const chainage = endChainage(base, end); + return chainage === undefined ? 0 : (edits.station_offsets[chainageKey(chainage)] ?? 0); + }; + return { start: at("start"), end: at("end") }; +} + +/** 그 끝 측점 계획고 편집을 `value`(m)로 — 기준이 없으면 편집 그대로. */ +export function withEndOffset( + base: AlignmentBase, + edits: AlignmentEdits, + end: ProfileEnd, + value: number, +): AlignmentEdits { + const chainage = endChainage(base, end); + if (chainage === undefined || !Number.isFinite(value)) return edits; + return adjustStation(base, edits, chainage, value - endOffsetsOf(base, edits)[end]); +} diff --git a/B06_Section/B06_Section_Router_Regenerate_Options.py b/B06_Section/B06_Section_Router_Regenerate_Options.py index f0627caba..2ddc30ca9 100644 --- a/B06_Section/B06_Section_Router_Regenerate_Options.py +++ b/B06_Section/B06_Section_Router_Regenerate_Options.py @@ -39,11 +39,11 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes ) if stage_params.get(key) is not None } - # 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20). + # 설계속도 · 지형은 B05가 저장해 둔 값 — 없으면 종류별 기본(20) · B05 기본 특수(448 E2). speed = options.get("design_speed_kph") return resolve_grade_options( str(grade_class), - terrain_type=str(options.get("terrain_type") or "normal"), + terrain_type=str(options.get("terrain_type") or "special"), paved=bool(options.get("paved", False)), design_speed_kph=int(speed) if speed is not None else None, stored=stored, diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index d970396df..fcaa00f55 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -218,6 +218,8 @@ async def save_initial_snapshot( dump: dict[str, Any] = {"routes": _json_safe([dict(route)])} for table in _CHILD_TABLES: dump[table] = _json_safe(await _dump_rows(connection, table, route_id)) + # stage 2 params(제어점 · 페이지 설정)도 — [초기화]가 함께 되돌림(448 E3) + dump["stage2_params"] = await _stage2_params(connection, str(route["project_id"])) (target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") @@ -322,9 +324,40 @@ async def restore_initial_snapshot( if "project_id" in child: child["project_id"] = project_id await _insert_row(connection, table, child) + await _restore_stage2_params(connection, project_id, dump) return new_id +async def _stage2_params(connection: aiomysql.Connection, project_id: str) -> Any: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT params FROM project_workflow_stages WHERE project_id = %s AND stage_no = 2", + (project_id,), + ) + row = await cursor.fetchone() + raw = row[0] if row else None + return json.loads(raw) if isinstance(raw, str) else raw + + +async def _restore_stage2_params( + connection: aiomysql.Connection, project_id: str, dump: dict[str, Any] +) -> None: + """뜬 stage 2 params 로 되씀 — 안 뜬 옛 스냅샷은 페이지 설정 칸만 걷어 기본값(448 E3).""" + from B05_Profile.B05_Profile_PageSettings import strip_page_settings + + if "stage2_params" in dump: + params = dump["stage2_params"] + else: + params = strip_page_settings(await _stage2_params(connection, project_id)) + if params is None: + return + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = 2", + (json.dumps(params, ensure_ascii=False), project_id), + ) + + async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int: columns = list(row.keys()) placeholders = ", ".join(["%s"] * len(columns)) diff --git a/common_util/common_util_workflow_state.py b/common_util/common_util_workflow_state.py index aaac39d96..c5c85c27b 100644 --- a/common_util/common_util_workflow_state.py +++ b/common_util/common_util_workflow_state.py @@ -88,6 +88,37 @@ async def start_stage( ) +def merged_params(params: Dict[str, Any] | None, patch: Dict[str, Any]) -> Dict[str, Any]: + """params 에 patch 를 얹는다 — `options` 는 칸끼리 합침 · 나머지는 갈음(448).""" + merged = dict(params or {}) + for key, value in patch.items(): + if key == "options" and isinstance(value, dict): + merged["options"] = {**(merged.get("options") or {}), **value} + else: + merged[key] = value + return merged + + +async def merge_stage_params( + cursor: aiomysql.DictCursor, project_id: str, stage_no: int, patch: Dict[str, Any] +) -> Dict[str, Any]: + """단계 params 에 patch 만 얹는다 — 상태 · 이후 단계(STALE)는 그대로(448 페이지 설정).""" + await cursor.execute( + "SELECT params FROM project_workflow_stages " + "WHERE project_id = %s AND stage_no = %s FOR UPDATE", + (project_id, stage_no), + ) + row = await cursor.fetchone() + raw = (row["params"] if isinstance(row, dict) else row[0]) if row else None + params = json.loads(raw) if isinstance(raw, str) else raw + merged = merged_params(params if isinstance(params, dict) else None, patch) + await cursor.execute( + "UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = %s", + (json.dumps(merged, ensure_ascii=False), project_id, stage_no), + ) + return merged + + async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None: """단계를 완료 상태로 전환한다.""" now = datetime.utcnow() diff --git a/resources/tester/helper_442_2_page_settings.cjs b/resources/tester/helper_442_2_page_settings.cjs new file mode 100644 index 000000000..18e064ea8 --- /dev/null +++ b/resources/tester/helper_442_2_page_settings.cjs @@ -0,0 +1,175 @@ +/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료. + * ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎 + * ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로 + * ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets + * ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조 + * TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +globalThis.window = { setTimeout, clearTimeout }; + +const anything = () => + new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) }); + +function load(file, stubs) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r] ?? anything()); + return box.exports; +} + +/** 상대 경로 import 를 실제 파일로 따라 읽는 로더(순수 logic 묶음용) · 그 밖은 가짜. */ +const cache = new Map(); +function loadReal(file) { + if (cache.has(file)) return cache.get(file); + const box = { exports: {} }; + cache.set(file, box.exports); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const req = (r) => { + if (!r.startsWith(".")) return anything(); + const target = path.resolve(path.dirname(file), r); + return loadReal(fs.existsSync(`${target}.ts`) ? `${target}.ts` : target); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(file, box.exports); + return box.exports; +} + +async function run() { + const out = {}; + + // ── ① 저장 차례 ── + const calls = []; + const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), { + "../../B05_Profile/B05_Profile_Api_PageSettings": { + flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`), + }, + "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": { + pendingRouteSave: async () => ({ draft: 1 }), + saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }), + carryAfterRouteSave: async () => [], + routeSaveSummary: () => "", + rebuildAfterRouteSave: async () => undefined, + }, + }); + out.order = [...save.DESIGN_SAVE_ORDER]; + const done = await save.saveDesign({ + projectId: "p1", + routeId: () => 3, + steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀 + cross: async () => calls.push("cross"), + failed: (error) => calls.push(`failed:${error.message}`), + }); + out.routeSaveDone = done; + out.routeSaveCalls = calls; + + // ── ② 초안 내보내기 ── + const session = new Map(); + const puts = []; + let mode = "ok"; + let latest = { + route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 }, + }; + const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), { + "../A00_Common/b_page_state": { + readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null), + writeState: (name, value, p) => session.set(`${name}:${p}`, value), + clearState: (name, p) => session.delete(`${name}:${p}`), + }, + "./B05_Profile_Api_Fetch": { + savePageSettings: async (projectId, body) => { + puts.push(body); + if (mode === "boom") throw new Error("서버 500"); + return { warnings: [], profile_updated: true }; + }, + readRouteLatestCache: () => latest, + writeRouteLatestCache: (_p, value) => (latest = value), + }, + }); + await api.flushPageSettings("p1"); + out.putsWithoutDraft = puts.length; + const draft = api.pageSettingsOf({ + designSpeed: 30, + terrainType: "normal", + maxGradePct: null, + minVerticalRadius: 300, + minTangentLength: null, + enforcePipeClearance: true, + stationDisplay: { station: 12, cumulative: 240 }, + }); + out.draft = draft; + api.writePageSettings("p1", draft); + out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1")); + mode = "boom"; + out.boom = await api.flushPageSettings("p1").then( + () => "ok", + (error) => error.message, + ); + out.draftAfterBoom = api.readPageSettings("p1"); + mode = "ok"; + await api.flushPageSettings("p1"); + out.draftAfterOk = api.readPageSettings("p1"); + out.latestAfterOk = latest; + out.puts = puts.length; + + // ── ③ 끝 측점 계획고 편집 ── + const logic = path.join(ROOT, "B05_Profile", "logic"); + const alignment = loadReal(path.join(logic, "B05_Profile_UI_Profile_Alignment.ts")); + const ends = loadReal(path.join(logic, "B05_Profile_EndOffsets.ts")); + const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 5, + default_curve_length_m: 20, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: true, + balance_tolerance_percent: 5, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 5, + paved: false, + }, + onlyExplicitCurves: false, + basePvi: [ + { chainage_m: 0, elevation_m: 100 }, + { chainage_m: 100, elevation_m: 105 }, + { chainage_m: 200, elevation_m: 108 }, + ], + chainage: [0, 100, 200], + ground: [100, 105, 108], + stations: [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200].map((s) => ({ + station_id: `s${s}`, + chainage_m: s, + })), + }; + const empty = { station_offsets: {}, curve_radii: {} }; + out.endsEmpty = ends.endOffsetsOf(base, empty); + const raised = ends.withEndOffset(base, empty, "start", 1.25); + out.endsRaised = ends.endOffsetsOf(base, raised); + const both = ends.withEndOffset(base, raised, "end", -0.5); + out.endsBoth = ends.endOffsetsOf(base, both); + const plan = alignment.buildAlignment(base, both); + out.planStart = plan.samples[0].elevation_m; + out.planEnd = plan.samples[plan.samples.length - 1].elevation_m; + out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0)); + + // ── ④ 기준표 ── + const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {}); + out.criteria = fields.PROFILE_CRITERIA; + out.speedChoices = fields.SPEED_CHOICES; + + process.stdout.write(JSON.stringify(out)); +} + +run().catch((error) => { + process.stderr.write(String(error.stack || error)); + process.exit(1); +}); diff --git a/resources/tester/helper_design_store_save.cjs b/resources/tester/helper_design_store_save.cjs index 31520794f..b2e350a0b 100644 --- a/resources/tester/helper_design_store_save.cjs +++ b/resources/tester/helper_design_store_save.cjs @@ -74,6 +74,8 @@ const STUBS = { "../../B06_Section/B06_Section_Api_Culvert_Options": { flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`), }, + // 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings) + "../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined }, }; const cache = new Map(); diff --git a/resources/tester/test_442_2_grade_limit_mirror.py b/resources/tester/test_442_2_grade_limit_mirror.py new file mode 100644 index 000000000..9dce7a895 --- /dev/null +++ b/resources/tester/test_442_2_grade_limit_mirror.py @@ -0,0 +1,80 @@ +"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수. + +TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙 +(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬 +`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 × +지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄. + +./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_grade_limit_mirror.py -q +""" + +from __future__ import annotations + +import itertools +import json +import subprocess +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options +from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA as PY + +HERE = Path(__file__).parent + + +@pytest.fixture(scope="module") +def ts_tables(): + proc = subprocess.run( + ["node", str(HERE / "helper_442_2_page_settings.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + return out["criteria"], out["speedChoices"] + + +def test_속도별_기울기_반경_표가_같음(ts_tables): + criteria, _ = ts_tables + assert sorted(int(speed) for speed in criteria) == sorted(PY["design_speed"]) + for speed, legal in PY["design_speed"].items(): + ts = criteria[str(speed)] + assert ts["grade"] == legal["max_grade_pct"] + assert ts["radius"] == legal["min_vertical_radius_m"] + + +def test_종류별_속도_선택지가_같음(ts_tables): + _, choices = ts_tables + for road, allowed in choices.items(): + assert tuple(allowed) == tuple(PY["selectable_design_speeds"].get(road, (20,))) + + +def _ts_limit(criteria, choices, road, speed, terrain, typed): + allowed = choices.get(road, [20]) + speed = speed if speed in allowed else allowed[0] # syncSpeedChoices + if typed is not None and typed > 0: + return float(typed) + return float(criteria[str(speed)]["grade"][terrain]) + + +def test_기울기_상한_전수(ts_tables): + criteria, choices = ts_tables + cases = itertools.product( + ["trunk", "fire", "work", "branch"], [20, 30, 40], ["normal", "special"], [None, 11.5] + ) + for road, speed, terrain, typed in cases: + py = resolve_grade_options( + road, + terrain_type=terrain, + design_speed_kph=speed, + stored={} if typed is None else {"max_grade_pct": typed}, + ) + assert _ts_limit(criteria, choices, road, speed, terrain, typed) == py.max_grade_pct, ( + road, + speed, + terrain, + typed, + ) diff --git a/resources/tester/test_442_2_page_settings.py b/resources/tester/test_442_2_page_settings.py new file mode 100644 index 000000000..a5fb9d28e --- /dev/null +++ b/resources/tester/test_442_2_page_settings.py @@ -0,0 +1,396 @@ +"""PLAN 448(442-2 · 요구 269-1) — B05 페이지 설정 [저장] · [확정] 영구저장. + +- 병합 — stage 2 params 에 사용자 값만 얹음(`options` 칸끼리 · 상태 · STALE 안 건드림) +- 정책 갱신 — 종단 경고 기준(최대 기울기 · 종단곡선 R · 평면 R)만 · 기하(base_pvi · 편집) 그대로 +- 작업임도 30 → 20 해석 · params 엔 원값 +- E1 — 노선 바꾼 저장 · B04 재확정 요청이 설계속도 · 최소고 강제 · 시작 측점을 옮김 +- E2 — B06 재생성 지형 기본 = 특수(B05 기본) +- E3 — 초기값 스냅샷이 stage 2 params 를 뜨고 [초기화]가 되돌림 · 옛 스냅샷은 설정 칸만 걷음 +- 저장 차례 · 초안 내보내기 · 끝 측점 편집 — Node 헬퍼 `helper_442_2_page_settings.cjs` + +./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_page_settings.py -q +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +from B03_FileInput.B03_FileInput_Service_Chain import redesign_solve_request +from B05_Profile import B05_Profile_PageSettings as page +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options +from B05_Profile.B05_Profile_Engine_Grade_Profile import design_alignment_profile +from B05_Profile.B05_Profile_Schema import PageSettingsRequest +from B06_Section.B06_Section_Router_Regenerate_Options import _regeneration_grade_options +from common_util import common_util_initial_snapshot as snapshot +from common_util.common_util_workflow_state import merge_stage_params, merged_params + +HERE = Path(__file__).parent + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_442_2_page_settings.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +PARAMS = { + "points": {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}}, + "options": {"grade_class": "trunk", "terrain_type": "special", "paved": False}, + "station_interval_m": 20, + "max_grade_pct": 13.0, + "start_elevation_offset_m": 0.4, +} + + +# ── 병합 ───────────────────────────────────────────────────────────────────── + + +def test_병합_options_칸끼리_나머지_갈음(): + request = PageSettingsRequest( + design_speed_kph=30, + terrain_type="normal", + max_grade_pct=None, + min_vertical_radius_m=300, + station_display={"station": 12.4, "cumulative": 240}, + ) + merged = merged_params(PARAMS, page.params_patch(request)) + assert merged["options"] == { + "grade_class": "trunk", + "terrain_type": "normal", + "paved": False, + "design_speed_kph": 30, + } + assert merged["max_grade_pct"] is None # 비운 칸 = 법정값 + assert merged["min_vertical_radius_m"] == 300 + assert merged["station_display"] == {"station": 12.0, "cumulative": 240.0} + assert merged["points"] == PARAMS["points"] and merged["start_elevation_offset_m"] == 0.4 + assert PARAMS["options"]["terrain_type"] == "special" # 원본 안 바뀜 + + +class _Cursor: + def __init__(self, params): + self.params = params + self.sql: list[str] = [] + + async def execute(self, sql, args=None): + self.sql.append(" ".join(sql.split())) + if sql.strip().upper().startswith("UPDATE"): + self.params = args[0] + + async def fetchone(self): + return {"params": self.params} + + +def test_merge_stage_params_상태_안_건드림(): + cursor = _Cursor(json.dumps(PARAMS)) + merged = asyncio.run(merge_stage_params(cursor, "p1", 2, {"options": {"design_speed_kph": 40}})) + assert merged["options"]["design_speed_kph"] == 40 + assert json.loads(cursor.params)["options"]["grade_class"] == "trunk" + assert "FOR UPDATE" in cursor.sql[0] + assert len(cursor.sql) == 2 and cursor.sql[1].startswith("UPDATE project_workflow_stages SET") + assert all("state" not in sql.lower() and "projects" not in sql for sql in cursor.sql[1:]) + + +def test_요청_검증(): + with pytest.raises(ValueError): + PageSettingsRequest(design_speed_kph=50) + with pytest.raises(ValueError): + PageSettingsRequest(terrain_type="flat") + with pytest.raises(ValueError): + PageSettingsRequest(max_grade_pct=0) + with pytest.raises(ValueError): + PageSettingsRequest(extra=1) + + +def test_작업임도_30은_20으로_params_는_원값(): + request = PageSettingsRequest(design_speed_kph=30, terrain_type="normal") + params = merged_params({"options": {}}, page.params_patch(request)) + assert params["options"]["design_speed_kph"] == 30 # 원값 저장(해석값 금지) + work = page.grade_options_of("work", params) + assert (work.design_speed_kph, work.max_grade_pct, work.min_vertical_radius_m) == (20, 9, 100) + trunk = page.grade_options_of("main", params) # B02 main = 간선 + assert (trunk.design_speed_kph, trunk.max_grade_pct, trunk.min_vertical_radius_m) == ( + 30, + 8, + 250, + ) + typed = page.grade_options_of("main", {**params, "max_grade_pct": 11.0}) + assert typed.max_grade_pct == 11.0 and typed.warnings # 법정 8 초과 = 경고(막지 않음) + + +# ── 정책 갱신 ──────────────────────────────────────────────────────────────── + + +def _longitudinal() -> dict: + """앞 200 m 11 % 오르막 · 뒤 200 m 2 % — 특수 20(14 %)엔 위반 없음 · 일반 30(8 %)엔 위반.""" + chainage = np.arange(0.0, 400.5, 1.0) + ground = np.where(chainage <= 200, 100 + 0.11 * chainage, 122 + 0.02 * (chainage - 200)) + longitudinal = { + "samples": [ + {"chainage_m": float(s), "elevation_m": float(z)} for s, z in zip(chainage, ground) + ], + "stations": [ + {"station_id": f"s{i}", "chainage_m": float(s)} + for i, s in enumerate(np.arange(0, 401, 20)) + ], + } + options = resolve_grade_options("trunk", terrain_type="special", design_speed_kph=20) + alignment, profile = design_alignment_profile(longitudinal, options) + edits = {"station_offsets": {"100.000": 0.8}, "curve_radii": {}} + longitudinal["profile_alignment"] = alignment + longitudinal["design_profiles"] = [profile] + from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile + + alignment, profile = rebuild_alignment_profile(longitudinal, edits) + longitudinal["profile_alignment"] = alignment + longitudinal["design_profiles"] = [profile] + return longitudinal + + +def test_정책_갱신_기하_그대로(): + before = _longitudinal() + after = copy.deepcopy(before) + strict = resolve_grade_options("trunk", terrain_type="normal", design_speed_kph=30) + alignment, summary = page.retarget_longitudinal(after, strict) + policy = alignment["policy"] + assert (policy["max_grade_pct"], policy["min_vertical_radius_m"]) == (8, 250) + assert policy["min_plan_radius_m"] == 30 # 30 km/h 일반지형 + criteria = after["design_profiles"][0]["criteria"] + assert (criteria["design_speed_kph"], criteria["terrain_type"]) == (30, "normal") + assert not before["profile_alignment"]["violations"] + assert after["profile_alignment"]["violations"] # 같은 선 · 엄한 기준 = 위반 뜸 + assert summary["id"] == after["design_profiles"][0]["id"] + # 기하 그대로 — 자동 선형 · 편집 · 계획고 표본 + assert alignment["base_pvi"] == before["profile_alignment"]["base_pvi"] + assert alignment["edits"] == before["profile_alignment"]["edits"] + z0 = [s["elevation_m"] for s in before["profile_alignment"]["samples"]] + z1 = [s["elevation_m"] for s in alignment["samples"]] + assert np.allclose(z0, z1) + + +def test_retarget_file_선형_없으면_그대로(tmp_path): + options = resolve_grade_options("trunk", terrain_type="normal") + rel = "B06_Section/longitudinal/longitudinal.json" + path = tmp_path / rel + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"samples": []}), encoding="utf-8") + assert page.retarget_file(tmp_path, rel, options) is None + assert json.loads(path.read_text(encoding="utf-8")) == {"samples": []} + assert page.retarget_file(tmp_path, "nope.json", options) is None + with pytest.raises(ValueError): + page.retarget_file(tmp_path, "../outside.json", options) + path.write_text(json.dumps(_longitudinal()), encoding="utf-8") + summary = page.retarget_file(tmp_path, rel, options) + stored = json.loads(path.read_text(encoding="utf-8")) + assert summary is not None and stored["profile_alignment"]["policy"]["max_grade_pct"] == 9 + + +# ── E1 · E2 ────────────────────────────────────────────────────────────────── + + +def test_E1_재설계_요청이_설정을_옮김(): + params = { + **PARAMS, + "options": {**PARAMS["options"], "design_speed_kph": 30}, + "enforce_pipe_clearance": True, + "station_display": {"station": 12, "cumulative": 240}, + } + request = redesign_solve_request(params, {"source_filter": "csf"}, 5) + assert request.design_speed_kph == 30 + assert request.enforce_pipe_clearance is True + assert request.station_display == {"station": 12, "cumulative": 240} + assert request.terrain_type == "special" + + +def test_E2_B06_재생성_지형_기본_특수(): + no_terrain = _regeneration_grade_options({"options": {"grade_class": "trunk"}}) + assert no_terrain.terrain_type == "special" and no_terrain.max_grade_pct == 14 + saved = _regeneration_grade_options( + {"options": {"grade_class": "trunk", "terrain_type": "normal", "design_speed_kph": 30}} + ) + assert (saved.terrain_type, saved.max_grade_pct) == ("normal", 8) + + +def test_노선_추천_설정은_options_에서_읽음(monkeypatch): + from B05_Profile import B05_Profile_Router_Replan as replan + + stage = { + "stage_no": 2, + "params": {"options": {"design_speed_kph": 30, "terrain_type": "normal"}}, + } + + class _Cur: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, *_): + return None + + async def fetchone(self): + return ("main",) + + class _Conn: + def cursor(self, *_): + return _Cur() + + class _Acquire: + async def __aenter__(self): + return _Conn() + + async def __aexit__(self, *_): + return False + + monkeypatch.setattr(replan, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire)) + + async def fake_state(*_): + return {"stages": [stage]} + + import common_util.common_util_workflow_state as workflow + + monkeypatch.setattr(workflow, "get_workflow_state", fake_state) + assert asyncio.run(replan._road_settings("p1")) == ("main", 30, "normal") + + +# ── E3 스냅샷 ──────────────────────────────────────────────────────────────── + + +class _SnapCursor: + def __init__(self, state): + self.state = state + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, args=None): + self.state["sql"].append(sql) + if sql.strip().upper().startswith("UPDATE"): + self.state["params"] = args[0] + + async def fetchone(self): + return (self.state["params"],) + + +class _SnapConn: + def __init__(self, state): + self.state = state + + def cursor(self, *_): + return _SnapCursor(self.state) + + +def test_E3_스냅샷_stage2_왕복(): + initial = {**PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 20}} + state = {"params": json.dumps(initial), "sql": []} + dumped = asyncio.run(snapshot._stage2_params(_SnapConn(state), "p1")) + assert dumped == initial + state["params"] = json.dumps( + {**initial, "max_grade_pct": 7, "options": {"design_speed_kph": 40}} + ) + asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": dumped})) + assert json.loads(state["params"]) == initial + + +def test_E3_옛_스냅샷은_설정_칸만_걷음(): + edited = { + **PARAMS, + "options": {**PARAMS["options"], "design_speed_kph": 40, "terrain_type": "normal"}, + "min_vertical_radius_m": 500, + "enforce_pipe_clearance": True, + "station_display": {"station": 3, "cumulative": 60}, + } + state = {"params": json.dumps(edited), "sql": []} + asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"routes": []})) + restored = json.loads(state["params"]) + assert restored["options"] == {"grade_class": "trunk", "paved": False} + for key in page.PAGE_TOP_KEYS: + assert key not in restored + assert restored["points"] == PARAMS["points"] and restored["start_elevation_offset_m"] == 0.4 + + +def test_E3_뜰_params_없으면_안_씀(): + state = {"params": None, "sql": []} + asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": None})) + assert not any(sql.strip().upper().startswith("UPDATE") for sql in state["sql"]) + + +# ── 화면(Node) ─────────────────────────────────────────────────────────────── + + +def test_저장_차례_settings_는_노선_뒤_관_앞(out): + order = out["order"] + assert order.index("route") + 1 == order.index("settings") < order.index("alignment") + + +def test_노선_바꾼_저장에도_settings_기본_창구(out): + assert out["routeSaveDone"] is True + assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구 + + +def test_초안_내보내기(out): + assert out["putsWithoutDraft"] == 0 + assert out["boom"] == "서버 500" and out["draftAfterBoom"] == out["draft"] + assert out["draftAfterOk"] is None and out["puts"] == 2 + params = out["latestAfterOk"]["route_params"] + assert params["options"] == { + "grade_class": "trunk", + "design_speed_kph": 30, + "terrain_type": "normal", + } + assert params["max_grade_pct"] is None and params["min_vertical_radius_m"] == 300 + assert params["station_display"] == {"station": 12, "cumulative": 240} + + +def test_초안_되살림_값(out): + restore = out["restoreFromDraft"] + assert restore["designSpeed"] == 30 and restore["maxGradePct"] is None + assert restore["stationDisplay"] == {"station": 12, "cumulative": 240} + + +def test_시종점_조정은_끝_측점_계획고_편집(out): + assert out["endsEmpty"] == {"start": 0, "end": 0} + assert out["endsRaised"] == {"start": 1.25, "end": 0} + assert out["endsBoth"] == {"start": 1.25, "end": -0.5} + assert out["planStart"] == pytest.approx(101.25) and out["planEnd"] == pytest.approx(107.5) + assert out["backToZero"]["start"] == 0 + + +def test_시작_측점은_열쇠_다른_페이지에_안_섞임(): + root = HERE.parents[1] + hits = subprocess.run( + ["git", "grep", "-l", "station_display", "--", "*.py", "*.ts"], + capture_output=True, + text=True, + cwd=root, + encoding="utf-8", + ).stdout.split() + allowed = { + "B03_FileInput/B03_FileInput_Service_Chain.py", # E1 옮김 + "B05_Profile/B05_Profile_Api_Fetch.ts", + "B05_Profile/B05_Profile_Api_PageSettings.ts", + "B05_Profile/B05_Profile_PageSettings.py", + "B05_Profile/B05_Profile_Router.py", + "B05_Profile/B05_Profile_Schema.py", + "B05_Profile/B05_Profile_UI_Page_Latest.ts", + } + assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed diff --git a/resources/tester/test_design_store_save.py b/resources/tester/test_design_store_save.py index 900248884..25e0daace 100644 --- a/resources/tester/test_design_store_save.py +++ b/resources/tester/test_design_store_save.py @@ -50,6 +50,7 @@ def test_계획고_가게_한_벌(out): def test_저장_차례(out): assert out["order"] == [ "route", + "settings", # 448 페이지 설정 — 노선 뒤 "pipes", "structures", "plusStations", # 368-2 추가 측점 — 계획고 앞 From 6836bcef1f183e4b3f9d39e70c87727efb06edfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 21:43:08 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(455=20433-13=E2=91=A2=20=EC=98=9B=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B2=BD=EA=B3=A0):=20=EC=98=9B=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EB=B9=88=20=EB=B2=95=EC=A0=95=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20R=20=C2=B7=20L=20=3D=20=EC=B2=AB=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=A0=20=EC=84=A4=EA=B3=84=20=EA=B8=B0=EC=A4=80(cr?= =?UTF-8?q?iteria)=20=E2=80=94=20=ED=99=94=EB=A9=B4=20readAlignment=20?= =?UTF-8?q?=E2=86=94=20=EC=84=9C=EB=B2=84=20rebuild=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=C2=B7=20=EC=B4=88=EC=95=88=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=3D=20=EC=A0=80=EC=9E=A5=20=EB=92=A4=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_Engine_Grade_Profile.py | 12 +- .../logic/B05_Profile_UI_Profile_Data.ts | 10 ++ B06_Section/B06_Section_Api_Types.ts | 2 + resources/tester/helper_455_old_policy.cjs | 38 +++++++ .../tester/test_455_old_policy_warning.py | 105 ++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 resources/tester/helper_455_old_policy.cjs create mode 100644 resources/tester/test_455_old_policy_warning.py diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 11aeab2cc..0c3ff91fe 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -28,6 +28,7 @@ from collections import Counter from collections.abc import Callable +from dataclasses import replace from typing import Any import numpy as np @@ -553,6 +554,16 @@ def rebuild_alignment_profile( policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) previous = (longitudinal.get("design_profiles") or [{}])[0] + criteria = previous.get("criteria") or {} + # 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 + # 채움(433-13 · TS `readAlignment` 짝) + policy = replace( + policy, + min_vertical_radius_m=policy.min_vertical_radius_m + or float(criteria.get("min_vertical_radius_m") or 0.0), + min_curve_length_m=policy.min_curve_length_m + or float(criteria.get("min_curve_length_m") or 0.0), + ) alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -562,7 +573,6 @@ def rebuild_alignment_profile( policy=policy, edits=edits, ) - criteria = previous.get("criteria") or {} options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0), diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts index 2968725fe..e76df599f 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Data.ts @@ -17,6 +17,16 @@ export function readAlignment(data: LongitudinalSection): ProfileAlignment | nul station_offsets: candidate.edits?.station_offsets ?? {}, curve_radii: candidate.edits?.curve_radii ?? {}, }; + // 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 채움 + // (433-13 · 서버 `rebuild_alignment_profile` 짝) + const criteria = data.design_profiles?.[0]?.criteria; + const policy = candidate.policy; + if (criteria && (!policy.min_vertical_radius_m || !policy.min_curve_length_m)) + candidate.policy = { + ...policy, + min_vertical_radius_m: policy.min_vertical_radius_m || criteria.min_vertical_radius_m || 0, + min_curve_length_m: policy.min_curve_length_m || criteria.min_curve_length_m || 0, + }; return candidate; } diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index b310af6c1..092918d85 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -180,6 +180,8 @@ export interface DesignProfile { samples: DesignProfileSample[]; balance_segments: DesignProfileSegment[]; summary: DesignProfileSummary; + /** 첫 계획선 설계 기준(서버 `GradeDesignOptions.as_dict`) — 옛 정책 빈 법정 최소 R · L 채움(433-13). */ + criteria?: { min_vertical_radius_m?: number | null; min_curve_length_m?: number | null }; } export interface LongitudinalSection { diff --git a/resources/tester/helper_455_old_policy.cjs b/resources/tester/helper_455_old_policy.cjs new file mode 100644 index 000000000..b4b2519b2 --- /dev/null +++ b/resources/tester/helper_455_old_policy.cjs @@ -0,0 +1,38 @@ +/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment + + * `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림. + * 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts")); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const stored = data.readAlignment(longitudinal); +const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits()); +process.stdout.write( + JSON.stringify({ + warnings: built.warnings.filter((text) => text.includes("법정 최소")), + policy: stored.policy, + }), +); diff --git a/resources/tester/test_455_old_policy_warning.py b/resources/tester/test_455_old_policy_warning.py new file mode 100644 index 000000000..ec3e59e8c --- /dev/null +++ b/resources/tester/test_455_old_policy_warning.py @@ -0,0 +1,105 @@ +"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음. + +까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장 +정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria = +설계속도 법정값) +— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`). +""" + +from __future__ import annotations + +import copy +import json +import math +import subprocess +import sys +from pathlib import Path + +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 import GradeDesignOptions # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, + rebuild_alignment_profile, +) + +# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달 +OPTIONS = GradeDesignOptions( + max_grade_pct=14.0, + max_reverse_grade_pct=5.0, + min_vertical_radius_m=450.0, + min_curve_length_m=40.0, + min_tangent_length_m=20.0, + vertical_curve_skip_delta_pct=0.5, + design_speed_kph=40, +) + + +def _ground(s: float) -> float: + return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0) + + +def _fresh() -> dict: + samples = [ + {"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True} + for s in range(0, 401) + ] + stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)] + data = {"length_m": 400.0, "samples": samples, "stations": stations} + alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0) + data["profile_alignment"], data["design_profiles"] = alignment, [profile] + return data + + +def _old() -> dict: + data = _fresh() + for key in ("min_vertical_radius_m", "min_curve_length_m"): + data["profile_alignment"]["policy"].pop(key) + return data + + +def _law(warnings: list[str]) -> list[str]: + return [text for text in warnings if "법정 최소" in text] + + +def _ts(tmp_path: Path, data: dict) -> dict: + source = tmp_path / "in455.json" + source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_455_old_policy.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 test_새_정책_경고_기준(tmp_path: Path) -> None: + fresh = _fresh() + law = _law(fresh["profile_alignment"]["warnings"]) + assert law + assert _ts(tmp_path, fresh)["warnings"] == law + + +def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None: + expected = _law(_fresh()["profile_alignment"]["warnings"]) + old = _old() + ts = _ts(tmp_path, copy.deepcopy(old)) + assert ts["policy"]["min_vertical_radius_m"] == 450.0 + assert ts["policy"]["min_curve_length_m"] == 40.0 + assert ts["warnings"] == expected + alignment, _profile = rebuild_alignment_profile(old, {}) + assert _law(alignment["warnings"]) == expected + assert alignment["policy"]["min_curve_length_m"] == 40.0 + + +def test_criteria_없는_판은_그대로(tmp_path: Path) -> None: + old = _old() + old["design_profiles"][0].pop("criteria") + assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == [] From 4f2c7b7a0b466ba968431dc3427770d1d3c05534 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:01:27 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(448=20B05=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=84=A4=EC=A0=95):=20=EA=B3=84=ED=9A=8D=EA=B3=A0?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=ED=8F=89=EB=A9=B4=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20R=20=EC=A7=80=EC=9A=B0=EB=8D=98=20=ED=9D=A0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=A2=85=EC=A0=90=20=EC=B9=B8=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=B9=84=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlignmentPolicy.from_dict 가 min_plan_radius_m · hairpin_min_radius_m 을 읽음 — 계획고 저장마다 0 으로 굳던 것(브레인 지시) - 「기준값 직접 지정」을 편 동안 누름 · 키 뒤마다 시 · 종점 계획고 조정 칸을 종단 편집으로 다시 비춤(되돌리기 · 끝점 ▲▼) - 시험 test_442_2_page_settings 평면 R 유지 한 건 Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_Engine_Grade_Alignment.py | 6 ++++++ B05_Profile/B05_Profile_PageSettings.py | 2 -- B05_Profile/B05_Profile_UI_Panel_Settings.ts | 9 +++++++-- resources/tester/test_442_2_page_settings.py | 8 ++++++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 863e075b7..5bd1d5a71 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -142,6 +142,12 @@ class AlignmentPolicy: max_grade_pct=float(payload.get("max_grade_pct") or 9.0), curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0), paved=bool(payload.get("paved", False)), + # 평면 R 경고 기준도 물려받음 — 빠지면 계획고 저장마다 0(판정 안 함)으로(448) + min_plan_radius_m=float(payload.get("min_plan_radius_m") or 0.0), + hairpin_min_radius_m=float( + payload.get("hairpin_min_radius_m") + or FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"] + ), min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0), min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0), ) diff --git a/B05_Profile/B05_Profile_PageSettings.py b/B05_Profile/B05_Profile_PageSettings.py index 428eb0d87..c58e610d6 100644 --- a/B05_Profile/B05_Profile_PageSettings.py +++ b/B05_Profile/B05_Profile_PageSettings.py @@ -102,8 +102,6 @@ def retarget_longitudinal( previous["criteria"] = {**options.as_dict(), "resolved_main_direction": direction} longitudinal["design_profiles"] = [previous, *(profiles or [])[1:]] alignment, profile = rebuild_alignment_profile(longitudinal, stored.get("edits")) - # `AlignmentPolicy.from_dict` 가 평면 최소 R 을 안 읽음 — 다시 셈한 정책에 되얹음 - alignment["policy"]["min_plan_radius_m"] = policy["min_plan_radius_m"] longitudinal["profile_alignment"] = alignment longitudinal["design_profiles"][0] = profile return alignment, {"id": profile["id"], **profile["summary"]} diff --git a/B05_Profile/B05_Profile_UI_Panel_Settings.ts b/B05_Profile/B05_Profile_UI_Panel_Settings.ts index 2c1506eb3..017784cfb 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Settings.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Settings.ts @@ -135,9 +135,14 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) { const gradeAdvanced = document.createElement("details"); const gradeSummary = document.createElement("summary"); gradeSummary.textContent = "기준값 직접 지정"; - // 칸을 펼 때 · 칸 위로 올 때 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤) + // 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤) + // ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것 gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets()); - gradeAdvanced.addEventListener("pointerenter", showEndOffsets); + const refreshSoon = (): void => { + if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets); + }; + document.addEventListener("pointerup", refreshSoon); + document.addEventListener("keyup", refreshSoon); gradeAdvanced.append( gradeSummary, maxGradePct.wrapper, diff --git a/resources/tester/test_442_2_page_settings.py b/resources/tester/test_442_2_page_settings.py index a5fb9d28e..494919197 100644 --- a/resources/tester/test_442_2_page_settings.py +++ b/resources/tester/test_442_2_page_settings.py @@ -394,3 +394,11 @@ def test_시작_측점은_열쇠_다른_페이지에_안_섞임(): "B05_Profile/B05_Profile_UI_Page_Latest.ts", } assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed + + +def test_저장_정책을_다시_읽어도_평면_R_유지(): + from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy + + policy = AlignmentPolicy.from_dict({"min_plan_radius_m": 30, "max_grade_pct": 8}) + assert policy.min_plan_radius_m == 30 and policy.hairpin_min_radius_m == 10 + assert AlignmentPolicy.from_dict({}).min_plan_radius_m == 0 # 옛 정책 = 판정 안 함 그대로