From cca08daa11df007e812131c68c20170ab6b243aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:33:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(472-4=20=E2=91=A0=20=EA=BC=AD?= =?UTF-8?q?=EC=A7=93=EC=A0=90=20=EC=84=B8=EC=9B=80):=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=EC=84=B8=EC=9B=80=EB=8F=84=20?= =?UTF-8?q?TS=20seatBornNodes=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20Node?= =?UTF-8?q?=20=EC=9E=85=EA=B5=AC=20profile.seat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 design_calc_node_follow.ts(따라감 몫 runner) · 입구는 두 줄만(import · 펼침) · 등록부 runner node:profile.seat · 실행기 NODE_IDS - Router_Confirm._seat_born_nodes = run_calc 겉(세울 자리 없으면 Node 안 부름) · 파이썬 셈 · 상수 걷음 - 시험 test_472_4_follow_node(옛 코드 빨강 2) · 427_1 · 459 거울 통과 · 472-3 NODE_IDS 에 profile.seat(cross.design 빨강은 ② 몫) Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/logic/design_calc_node.ts | 4 +- .../design/logic/design_calc_node_follow.ts | 43 ++++++++ .../design/logic/design_calc_registry.ts | 9 +- B05_Profile/B05_Profile_Router_Confirm.py | 100 ++++-------------- B05_Profile/logic/B05_Profile_Grade_Seat.ts | 6 +- common_util/common_util_calc_runner.py | 1 + resources/tester/test_472_3_calc_registry.py | 1 + resources/tester/test_472_4_follow_node.py | 59 +++++++++++ 8 files changed, 137 insertions(+), 86 deletions(-) create mode 100644 A00_Common/design/logic/design_calc_node_follow.ts create mode 100644 resources/tester/test_472_4_follow_node.py diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 130e0546d..ea875446e 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -35,10 +35,11 @@ import { extraWallRows, structureAreaRows, } from "../../../B06_Section/B06_Section_Structure_Layouts"; +import { followRunners, type FollowInputs } from "./design_calc_node_follow"; type HaulOptions = NonNullable[2]>; -interface CalcInputs { +interface CalcInputs extends FollowInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -130,6 +131,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { let massHaul: MassHaulResult | null = null; const RUNNERS: Record unknown> = { + ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts new file mode 100644 index 000000000..ebecc1f64 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_follow.ts + * 계산 Node 입구(`design_calc_node.ts`)의 따라감 몫(PLAN 472-4 ①) — 측점 열쇠 · 시설 따라감 · 추가 측점 · + * 꼭짓점 세움. 브라우저 묶음이 쓰는 같은 TS 를 부름 · 파이썬은 `common_util_calc_runner.run_calc` 로. + * + * profile.seat 입력 seat { ground_s · ground_z(저장 선형 표본) · policy(파이썬 `AlignmentPolicy.as_dict`) · + * base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`) + * ========================================================================== */ + +import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat"; +import type { + AlignmentNode, + AlignmentPolicy, +} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; + +export interface FollowInputs { + seat?: { + ground_s: number[]; + ground_z: number[]; + policy: AlignmentPolicy; + base_pvi: AlignmentNode[]; + base: AlignmentNode[]; + born: number[]; + held?: number[]; + }; +} + +/** id → 셈(입구 `RUNNERS` 에 얹음). */ +export function followRunners(inputs: FollowInputs): Record unknown> { + return { + "profile.seat": () => { + const seat = inputs.seat; + if (!seat) return null; + const base = { + chainage: seat.ground_s, + ground: seat.ground_z, + policy: seat.policy, + basePvi: seat.base_pvi, + }; + return seatBornNodes(base, seat.base, seat.born, seat.held ?? []); + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index e69d3c7da..7bcccfe6a 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -320,11 +320,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "follow", ts: `${B05L}B05_Profile_Grade_Seat.ts#seatBornNodes`, - runner: `py:${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, - until: "472-4", - pair: `${B05}B05_Profile_Router_Confirm.py#_seat_born_nodes`, - mirror: ["test_427_1_follow_seat", "test_459_route_seat_mirror"], - note: "Route_Save `_carry_profile` 도 같은 일", + runner: "node:profile.seat", + pair: null, + mirror: ["test_427_1_follow_seat", "test_459_route_seat_mirror", "test_472_4_follow_node"], + note: "파이썬 `Router_Confirm._seat_born_nodes` = Node 부름 겉(측점 병합 · Route_Save `_carry_profile`)", }, { id: "profile.tools", diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 71b2c1131..53ab71b6c 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -12,15 +12,12 @@ from typing import Any from uuid import UUID import aiomysql -import numpy as np from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( AlignmentPolicy, - build_curves, chainage_key, - evaluate, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( @@ -42,6 +39,7 @@ from B06_Section.B06_Section_Repository import ( get_latest_section_options, get_longitudinal_section, ) +from common_util.common_util_calc_runner import run_calc from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path @@ -213,10 +211,6 @@ async def sync_uphill_overrides_into_designs( #: 반올림만 다른 같은 측점(m) — 서버가 관 측점을 0.01m 로 반올림. #: 화면 `applyIrregularStations` 0.006 과 짝. _SAME_STATION_M = 0.006 -#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438). -_SEAT_GROUND_M = 0.01 -#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). -_SEAT_KEEP_M = 0.001 def _seat_born_nodes( @@ -225,84 +219,36 @@ def _seat_born_nodes( born: list[float], held: list[float] = (), ) -> list[dict[str, Any]]: - """새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427). + """새 측점(`born`)에 자동 계획선 규칙(원지반 지남)으로 꼭짓점을 세움(427 · 433-5 · 438). - 이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · - 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그 - 밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장 - 선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점 - 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 = - `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 = - `test_427_1_follow_seat.py`. + 셈 = TS `seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) 한 벌 — 서버 Node 입구 + `profile.seat`(472-4 ①). 지반 = 저장 선형 표본(`samples`) · 정책 = 파이썬 기본값을 채운 한 벌. + 세울 자리가 없으면(양 끝 밖 · 이미 꼭짓점 0.006m 안) Node 를 안 부름. 거울 시험 = + `test_427_1_follow_seat.py` · `test_472_4_follow_node.py`. """ samples = stored.get("samples") or [] if not base or len(samples) < 2: return base first, last = float(base[0]["chainage_m"]), float(base[-1]["chainage_m"]) - fresh = [ - value - for value in born - if first < value < last + if not any( + first < value < last and all(abs(value - float(node["chainage_m"])) >= _SAME_STATION_M for node in base) - ] - if not fresh: + for value in born + ): return base - ground_s = np.array([float(row["chainage_m"]) for row in samples], dtype=np.float64) - ground_z = np.array([float(row["ground_elevation_m"]) for row in samples], dtype=np.float64) - policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) - pre_s = np.array([float(node["chainage_m"]) for node in stored["base_pvi"]], dtype=np.float64) - pre_z = np.array([float(node["elevation_m"]) for node in stored["base_pvi"]], dtype=np.float64) - pre_curves, _warnings = build_curves(pre_s, pre_z, policy) - off = evaluate(pre_s, pre_z, pre_curves, pre_s) - np.interp(pre_s, ground_s, ground_z) - on_ground = float(np.median(np.abs(off))) < _SEAT_GROUND_M - - def seat_z(value: float) -> float: - if on_ground: - return float(np.interp(value, ground_s, ground_z)) - return float(evaluate(pre_s, pre_z, pre_curves, np.array([value]))[0]) - - rows = sorted( - [(float(node["chainage_m"]), float(node["elevation_m"]), node) for node in base] - + [(value, seat_z(value), None) for value in fresh], - key=lambda row: row[0], - ) - base_s = np.array([row[0] for row in rows], dtype=np.float64) - 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) - drift = ( - can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M) - ) - if not drift.any(): - break - free |= drift - return [ - node - 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()) - ] + seat = { + "ground_s": [float(row["chainage_m"]) for row in samples], + "ground_z": [float(row["ground_elevation_m"]) for row in samples], + "policy": AlignmentPolicy.from_dict(stored.get("policy") or {}).as_dict(), + "base_pvi": stored["base_pvi"], + "base": base, + "born": [float(value) for value in born], + "held": [float(value) for value in held], + } + results = run_calc(["profile.seat"], {"seat": seat}) + if results is None: + raise RuntimeError("꼭짓점 세움 실패 — 계산 Node 입구(profile.seat)") + return results["profile.seat"] def _follow_moved_stations( diff --git a/B05_Profile/logic/B05_Profile_Grade_Seat.ts b/B05_Profile/logic/B05_Profile_Grade_Seat.ts index d9fcd7330..eadf7f89b 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Seat.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Seat.ts @@ -4,7 +4,7 @@ * 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로. * 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). * - * 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes` + * 서버도 이 셈 — Node 입구 `profile.seat`(472-4 ① · 파이썬 `Router_Confirm._seat_born_nodes` 가 부름) · 세울 측점은 공용 `followMovedNodes` * (common_util_structure_station.ts)가 고름 · 거울 시험 = resources/tester/test_427_1_follow_seat.py. * ========================================================================== */ @@ -15,9 +15,9 @@ import { type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; -/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */ +/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) (438). */ const SEAT_GROUND_M = 0.01; -/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */ +/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5). */ const SEAT_KEEP_M = 0.001; /** `np.median` 짝. */ diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index b54a08f8e..d9eab93e0 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -24,6 +24,7 @@ NODE_IDS = frozenset( "haul.plan", "excavation.trench", "corridor.mesh", + "profile.seat", } ) diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 6fa2f52a0..51dd773ef 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -40,6 +40,7 @@ NODE_IDS = { "haul.plan", "excavation.trench", "corridor.mesh", + "profile.seat", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_472_4_follow_node.py b/resources/tester/test_472_4_follow_node.py new file mode 100644 index 000000000..5e9f90751 --- /dev/null +++ b/resources/tester/test_472_4_follow_node.py @@ -0,0 +1,59 @@ +"""PLAN 472-4 ① — 측점 열쇠 · 시설 따라감 · 추가 측점 · 꼭짓점 세움을 서버도 같은 TS(Node 입구)로 셈. + +파이썬 짝을 걷고 서버 부름 자리는 `common_util_calc_runner.run_calc`(등록부 id) 한 길 — + profile.seat `Router_Confirm._seat_born_nodes`(측점 병합 · 노선 저장 `_carry_profile`) +고정 자료 = `fixtures/golden_472/detail.json`(472-1 · 읽기만). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_follow_node.py -q +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Router_Confirm as confirm # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +DETAIL = json.loads((HERE / "fixtures" / "golden_472" / "detail.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def calls(monkeypatch) -> list[list[str]]: + seen: list[list[str]] = [] + + def spy(ids, inputs): + seen.append(list(ids)) + return runner.run_calc(ids, inputs) + + monkeypatch.setattr(confirm, "run_calc", spy) + return seen + + +def test_꼭짓점_세움은_Node_입구(calls) -> None: + stored = DETAIL["longitudinal"]["profile_alignment"] + base = [node for node in stored["base_pvi"] if abs(node["chainage_m"] - 351.0) > 0.5] + got = confirm._seat_born_nodes(stored, base, [351.0]) + assert calls == [["profile.seat"]] + assert len(got) == len(base) + 1 + seated = next(node for node in got if abs(node["chainage_m"] - 351.0) < 1e-9) + old = next(node for node in stored["base_pvi"] if abs(node["chainage_m"] - 351.0) < 1e-9) + assert seated["elevation_m"] == pytest.approx(old["elevation_m"], abs=0.5) + + +def test_세울_자리_없으면_Node_안_부름(calls) -> None: + stored = DETAIL["longitudinal"]["profile_alignment"] + assert confirm._seat_born_nodes(stored, stored["base_pvi"], [351.0]) == stored["base_pvi"] + assert calls == [] From 39adebdaf50e8bb10c4344f3b8f255b5a71e40e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:37:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(472-4=20=E2=91=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=B8=A1=EC=A0=90=20=EB=94=B0=EB=9D=BC=EA=B0=90):?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=B6=94=EA=B0=80=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EA=B0=90=EB=8F=84=20TS=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=20=E2=80=94=20followPlusRows=20=C2=B7=20Node=20?= =?UTF-8?q?=EC=9E=85=EA=B5=AC=20facility.plus=5Ffollow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_route_follow.ts followPlusRows(땅 위 자리 → 새 노선 정수 m · 같은 m 하나) · followPlusStations 가 그것을 씀 - Plus_Stations.follow_plus_stations = run_calc 겉(옮긴 수 · 쓰기 그대로) · follow_structures 끌림 걷음 - 등록부 node:facility.plus_follow · NODE_IDS · 시험 test_472_4_follow_node(옛 코드 빨강) · 368_2 · 459 통과 Co-Authored-By: Claude Opus 5.5 --- .../design/logic/design_calc_node_follow.ts | 21 +++++++++ .../design/logic/design_calc_registry.ts | 11 +++-- B05_Profile/B05_Profile_Plus_Stations.py | 44 ++++++++++--------- common_util/common_util_calc_runner.py | 1 + common_util/common_util_route_follow.ts | 21 +++++++-- resources/tester/test_472_3_calc_registry.py | 1 + resources/tester/test_472_4_follow_node.py | 23 +++++++++- 7 files changed, 90 insertions(+), 32 deletions(-) diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts index ebecc1f64..679460e79 100644 --- a/A00_Common/design/logic/design_calc_node_follow.ts +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -5,15 +5,22 @@ * * profile.seat 입력 seat { ground_s · ground_z(저장 선형 표본) · policy(파이썬 `AlignmentPolicy.as_dict`) · * base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`) + * facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } → + * { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 } * ========================================================================== */ import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat"; +import { followPlusRows, followStructures } from "@util/common_util_route_follow"; import type { AlignmentNode, AlignmentPolicy, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; +type Line = Array<[number, number]>; +const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y })); + export interface FollowInputs { + plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line }; seat?: { ground_s: number[]; ground_z: number[]; @@ -39,5 +46,19 @@ export function followRunners(inputs: FollowInputs): Record unknow }; return seatBornNodes(base, seat.base, seat.born, seat.held ?? []); }, + "facility.plus_follow": () => { + const plus = inputs.plus; + if (!plus) return null; + const line = xyOf(plus.line); + const moved = followStructures(plus.rows, line).filter( + ({ item }, index) => item.chainage_m !== plus.rows[index].chainage_m, + ).length; + const rows = followPlusRows(plus.rows, line).map(({ chainage_m, x, y }) => ({ + chainage_m, + x: x ?? null, + y: y ?? null, + })); + return { rows, moved }; + }, }; } diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 7bcccfe6a..386312393 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -479,12 +479,11 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ outputs: ["추가 측점(input.plusStations 고침)"], site: "browser_now", stage: "follow", - ts: `${B05}B05_Profile_Api_PlusStations.ts#followPlusDraft`, - runner: `py:${B05}B05_Profile_Plus_Stations.py#follow_plus_stations`, - until: "472-4", - pair: `${B05}B05_Profile_Plus_Stations.py#follow_plus_stations`, - mirror: ["test_459_route_seat_mirror"], - note: "TS 가 Api 파일 안 — logic 으로(472-4 ①)", + ts: `${U}common_util_route_follow.ts#followPlusRows`, + runner: "node:facility.plus_follow", + pair: null, + mirror: ["test_459_route_seat_mirror", "test_368_2_plus_stations", "test_472_4_follow_node"], + note: "파이썬 `Plus_Stations.follow_plus_stations` = Node 부름 겉(정본 파일 읽기 · 쓰기)", }, { id: "facility.stations", diff --git a/B05_Profile/B05_Profile_Plus_Stations.py b/B05_Profile/B05_Profile_Plus_Stations.py index 7865737e2..7f21321a2 100644 --- a/B05_Profile/B05_Profile_Plus_Stations.py +++ b/B05_Profile/B05_Profile_Plus_Stations.py @@ -1,7 +1,7 @@ """추가 측점 정본(`B05_Profile/route/plus_stations.json`) — 구조물 정본 곁(368-2 · 요구 240-21). 사용자가 종단 원지반선에서 넣은 일반 측점(정수 m) · 이름 없음. 자리 정본 = 좌표(x · y) — 노선이 -바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(`follow_structures` 재사용). +바뀌면 관 · 구조물처럼 새 노선에 투영해 누가거리를 다시 매김(TS `followPlusRows` · Node 입구). 측점 계획에는 `resolve_extra_stations` → `plan_stations(plus=)` 로 들어감(TS `planStations` 짝). """ @@ -10,11 +10,10 @@ from __future__ import annotations import json import os from pathlib import Path -from types import SimpleNamespace from typing import Any, Sequence +from common_util.common_util_calc_runner import run_calc from common_util.common_util_json import atomic_write_json -from common_util.common_util_route_follow import follow_structures PLUS_STATIONS_FILE_NAME = "plus_stations.json" @@ -71,23 +70,26 @@ def save_plus_stations( def follow_plus_stations(project_root: str | Path, new_xy: Sequence[Sequence[float]]) -> int: - """노선이 바뀔 때 좌표를 새 노선에 투영해 누가거리를 다시 매김 → 옮긴 수(같은 m 는 하나).""" + """노선이 바뀔 때 좌표를 새 노선에 투영해 누가거리를 다시 매김 → 옮긴 수(같은 m 는 하나). + 셈 = TS `followPlusRows`(Node 입구 `facility.plus_follow` · 472-4 ①).""" rows = load_plus_stations(project_root) - items = [ - SimpleNamespace(chainage_m=float(r["chainage_m"]), x=r.get("x"), y=r.get("y")) for r in rows - ] - if not items: + if not rows: return 0 - before = [item.chainage_m for item in items] - follow_structures(items, new_xy) - moved = sum(a != item.chainage_m for a, item in zip(before, items)) - if moved: - seen: set[float] = set() - kept = [] - for item in sorted(items, key=lambda i: i.chainage_m): - if item.chainage_m in seen: - continue - seen.add(item.chainage_m) - kept.append({"chainage_m": item.chainage_m, "x": item.x, "y": item.y}) - atomic_write_json(str(plus_stations_path(project_root)), {"stations": kept}) - return moved + results = run_calc( + ["facility.plus_follow"], + { + "plus": { + "rows": [ + {"chainage_m": float(r["chainage_m"]), "x": r.get("x"), "y": r.get("y")} + for r in rows + ], + "line": [[float(p[0]), float(p[1])] for p in new_xy], + } + }, + ) + if results is None: + raise RuntimeError("추가 측점 따라감 실패 — 계산 Node 입구(facility.plus_follow)") + followed = results["facility.plus_follow"] + if followed["moved"]: + atomic_write_json(str(plus_stations_path(project_root)), {"stations": followed["rows"]}) + return int(followed["moved"]) diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index d9eab93e0..b32643608 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -25,6 +25,7 @@ NODE_IDS = frozenset( "excavation.trench", "corridor.mesh", "profile.seat", + "facility.plus_follow", } ) diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 9595495c9..069a94345 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -14,7 +14,8 @@ * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * - `rebaseStructureDraft` 도 TS 만 — 구조물 초안을 서버가 따라 옮긴 정본 위에(459 · 관 짝). - * - `followPlusStations` ↔ 파이썬 `B05_Profile_Plus_Stations.follow_plus_stations`(459 · 노선 초안 동안). + * - `followPlusRows` = 추가 측점 따라감 한 벌 — 서버도(Node 입구 `facility.plus_follow` · 472-4 ①) · + * `followPlusStations` = 노선 초안 동안(옛 선 위 자리에서). * - `routeOutside` ↔ 파이썬 `route_outside`(459 · 노선 밖 = 측점 계획 · 알약에서 뺌 · 좌표 그대로) — 따라감이 * `outside: true` 를 붙임(파생값 · 저장 때 뺌 · 서버는 측점 세울 때 다시 셈). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. @@ -221,8 +222,21 @@ export function rebaseStructureDraft( }); } +/** 땅 위 자리(x · y)가 있는 추가 측점이 새 선(`to`)을 따라감 — 정수 m · 누가거리 순 · 같은 m 하나(앞엣것) · + * 좌표 그대로. 서버 정본(`plus_stations.json`)도 이 셈(Node 입구 `facility.plus_follow` · 472-4 ①). */ +export function followPlusRows( + rows: ReadonlyArray, + to: ReadonlyArray, +): R[] { + const seen = new Set(); + return followStructures(rows, to) + .map(({ item }) => item) + .sort((a, b) => a.chainage_m! - b.chainage_m!) + .filter((item) => !seen.has(item.chainage_m!) && seen.add(item.chainage_m!)); +} + /** 추가 측점이 노선을 따라감(459) — 옛 선(`from`) 위 누가거리의 땅 위 자리를 새 선(`to`)에 투영한 정수 m - * (같은 m 하나 · 오름차순). 파이썬 `follow_plus_stations` 짝(`follow_structures` 같은 셈). */ + * (같은 m 하나 · 오름차순 · 노선 초안 동안 = 좌표가 아직 옛 선 위). */ export function followPlusStations( list: ReadonlyArray, from: ReadonlyArray, @@ -232,8 +246,7 @@ export function followPlusStations( list.map((chainage_m) => ({ chainage_m })), from, ); - const moved = followStructures(placed, to).map(({ item }) => item.chainage_m!); - return [...new Set(moved)].sort((a, b) => a - b); + return followPlusRows(placed, to).map((item) => item.chainage_m!); } /** 따라가기만 한 관 = 정본과 같은 땅 위 자리(m) — 서버 좌표가 mm 3자리라 그 반올림 폭. */ diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index 51dd773ef..c72649ac2 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -41,6 +41,7 @@ NODE_IDS = { "excavation.trench", "corridor.mesh", "profile.seat", + "facility.plus_follow", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_472_4_follow_node.py b/resources/tester/test_472_4_follow_node.py index 5e9f90751..223976d3a 100644 --- a/resources/tester/test_472_4_follow_node.py +++ b/resources/tester/test_472_4_follow_node.py @@ -1,7 +1,8 @@ -"""PLAN 472-4 ① — 측점 열쇠 · 시설 따라감 · 추가 측점 · 꼭짓점 세움을 서버도 같은 TS(Node 입구)로 셈. +"""PLAN 472-4 ① — 측점 열쇠 · 시설 따라감 · 추가 측점 · 꼭짓점 세움 = 서버도 같은 TS(Node 입구). 파이썬 짝을 걷고 서버 부름 자리는 `common_util_calc_runner.run_calc`(등록부 id) 한 길 — profile.seat `Router_Confirm._seat_born_nodes`(측점 병합 · 노선 저장 `_carry_profile`) + facility.plus_follow `Plus_Stations.follow_plus_stations`(노선 저장) 고정 자료 = `fixtures/golden_472/detail.json`(472-1 · 읽기만). ./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_follow_node.py -q @@ -57,3 +58,23 @@ def test_세울_자리_없으면_Node_안_부름(calls) -> None: stored = DETAIL["longitudinal"]["profile_alignment"] assert confirm._seat_born_nodes(stored, stored["base_pvi"], [351.0]) == stored["base_pvi"] assert calls == [] + + +def test_추가_측점_따라감은_Node_입구(tmp_path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Plus_Stations as plus + + seen: list[list[str]] = [] + + def spy(ids, inputs): + seen.append(list(ids)) + return runner.run_calc(ids, inputs) + + monkeypatch.setattr(plus, "run_calc", spy) + old = [(0.0, 0.0), (100.0, 0.0)] + plus.save_plus_stations(tmp_path, [30.0, 60.0], old) + new = [(0.0, 5.0), (40.0, 5.0), (100.0, 25.0)] # 땅 위 자리를 새 노선에 투영 + moved = plus.follow_plus_stations(tmp_path, new) + assert seen == [["facility.plus_follow"]] + rows = plus.load_plus_stations(tmp_path) + assert moved == 1 and [row["chainage_m"] for row in rows] == [30.0, 57.0] # 옛 파이썬 값 + assert [(row["x"], row["y"]) for row in rows] == [(30.0, 0.0), (60.0, 0.0)] # 좌표 그대로 From 162d32620e4cf9cbeab20df90eb7991b4d5ed25a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:39:54 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(472-4=20=E2=91=A0=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EB=94=B0=EB=9D=BC=EA=B0=90):=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=94=B0=EB=9D=BC=EA=B0=90?= =?UTF-8?q?=EB=8F=84=20TS=20followStructures=20=ED=95=9C=20=EB=B2=8C=20?= =?UTF-8?q?=E2=80=94=20Node=20=EC=9E=85=EA=B5=AC=20facility.follow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_route_follow.follow_structures = run_calc 겉(같은 계약 · 제자리 고침 · site 간격 = 서버 측점 간격) - 저장 때 좌표 맞춤 fill_structure_coordinates 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS placeOnLine 과 규칙 달라 옮기면 값 바뀜) · 등록부 note - 등록부 node:facility.follow · NODE_IDS · 시험 test_472_4_follow_node(옛 코드 빨강) · 344_2 · 380_3 · 410 · 459 통과 Co-Authored-By: Claude Opus 5.5 --- .../design/logic/design_calc_node_follow.ts | 24 +++++++- .../design/logic/design_calc_registry.ts | 14 +++-- common_util/common_util_calc_runner.py | 1 + common_util/common_util_route_follow.py | 55 ++++++++++--------- resources/tester/test_472_3_calc_registry.py | 1 + resources/tester/test_472_4_follow_node.py | 23 ++++++++ 6 files changed, 86 insertions(+), 32 deletions(-) diff --git a/A00_Common/design/logic/design_calc_node_follow.ts b/A00_Common/design/logic/design_calc_node_follow.ts index 679460e79..f6ce41a40 100644 --- a/A00_Common/design/logic/design_calc_node_follow.ts +++ b/A00_Common/design/logic/design_calc_node_follow.ts @@ -5,12 +5,18 @@ * * profile.seat 입력 seat { ground_s · ground_z(저장 선형 표본) · policy(파이썬 `AlignmentPolicy.as_dict`) · * base_pvi(옮기기 전) · base(옮긴 꼭짓점) · born · held } → 꼭짓점 목록(`seatBornNodes`) + * facility.follow 입력 follow { items: [{ placement · chainage_m · start_m · end_m · x · y }] · line · + * max_drift_m · interval_m(site 맞춤 간격) } → [{ item · drift_m · far }](`followStructures`) * facility.plus_follow 입력 plus { rows: [{ chainage_m · x · y }] · line: [[x, y]](새 노선) } → * { rows: [{ chainage_m · x · y }](`followPlusRows` · 정수 m · 같은 m 하나) · moved: 옮긴 수 } * ========================================================================== */ import { seatBornNodes } from "../../../B05_Profile/logic/B05_Profile_Grade_Seat"; -import { followPlusRows, followStructures } from "@util/common_util_route_follow"; +import { + followPlusRows, + followStructures, + type FollowingStructure, +} from "@util/common_util_route_follow"; import type { AlignmentNode, AlignmentPolicy, @@ -20,6 +26,12 @@ type Line = Array<[number, number]>; const xyOf = (line: Line) => line.map(([x, y]) => ({ x, y })); export interface FollowInputs { + follow?: { + items: FollowingStructure[]; + line: Line; + max_drift_m?: number; + interval_m?: number | null; + }; plus?: { rows: Array<{ chainage_m: number; x?: number | null; y?: number | null }>; line: Line }; seat?: { ground_s: number[]; @@ -46,6 +58,16 @@ export function followRunners(inputs: FollowInputs): Record unknow }; return seatBornNodes(base, seat.base, seat.born, seat.held ?? []); }, + "facility.follow": () => { + const follow = inputs.follow; + if (!follow) return null; + return followStructures( + follow.items, + xyOf(follow.line), + follow.max_drift_m, + follow.interval_m ?? null, + ); + }, "facility.plus_follow": () => { const plus = inputs.plus; if (!plus) return null; diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 386312393..9263a1d9d 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -466,10 +466,16 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [ site: "browser_now", stage: "follow", ts: `${U}common_util_route_follow.ts#followStructures`, - runner: `py:${U}common_util_route_follow.py#follow_structures`, - until: "472-4", - pair: `${U}common_util_route_follow.py#follow_structures`, - mirror: ["test_410_follow_xy", "test_344_2_route_pairs", "test_459_route_seat_mirror"], + runner: "node:facility.follow", + pair: null, + mirror: [ + "test_410_follow_xy", + "test_344_2_route_pairs", + "test_459_route_seat_mirror", + "test_380_3_site_follow", + "test_472_4_follow_node", + ], + note: "파이썬 `common_util_route_follow.follow_structures` = Node 부름 겉 · 저장 때 좌표 맞춤 `fill_structure_coordinates` 는 파이썬 남음(옛 좌표 지킴 · 0.1 mm 반올림 — TS `placeOnLine` 과 규칙 다름)", }, { id: "facility.plus_follow", diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index b32643608..f15a44e30 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -26,6 +26,7 @@ NODE_IDS = frozenset( "corridor.mesh", "profile.seat", "facility.plus_follow", + "facility.follow", } ) diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index e6a3bf8c0..cf56c50bf 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -6,7 +6,8 @@ - `fill_structure_coordinates` — 저장 때 노선 위 기준 자리로 x · y 채움(자리가 그대로면 옛 좌표 지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움. - - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리. + - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리 + (셈 = TS `followStructures` · Node 입구 `facility.follow` · 472-4 ①). - `route_outside` — 노선 밖(시점 앞 · 종점 뒤 0.51 m 넘음) · 측점 세울 때 셈(459 · 정본에 안 씀). @@ -22,15 +23,17 @@ from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point +from common_util.common_util_calc_runner import run_calc from common_util.common_util_structure_station import ( SAME_STATION_M, route_general_stations, snap_existing_station, snap_structure_station, ) -from config.config_system import STRUCTURE_ROUTE_DRIFT_MAX_M +from config.config_system import SECTION_STATION_INTERVAL_M, STRUCTURE_ROUTE_DRIFT_MAX_M _POSITION_KEYS = ("chainage_m", "start_m", "end_m") +_FOLLOW_KEYS = ("placement", *_POSITION_KEYS, "x", "y") #: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝). ROUTE_OUTSIDE_M = 0.51 @@ -125,33 +128,31 @@ def follow_structures( """좌표를 새 노선에 투영해 누가거리를 다시 매긴다(제자리) → 항목마다 (노선과 거리, 멀어짐). 기준 자리 = 투영 → 정수 m(노선 끝 안) · 구간 끝(`start_m` · `end_m`)은 같은 만큼 밀고 0 ~ 연장 - 안으로 자름. 좌표가 없는 옛 자료는 그대로(거리 None). - site 꼴(사토장 · 토취장 · 새 측점 안 심음) = 새 노선 일반 측점 중 가장 가까운 곳(380-3). + 안으로 자름. 좌표가 없는 옛 자료는 그대로(거리 None) · site 꼴(사토장 · 토취장) = 새 노선 일반 + 측점 중 가장 가까운 곳(380-3). 셈 = TS `followStructures`(Node 입구 `facility.follow` · 472-4). """ items = list(items) line = _line(xy) - if line is None: + if line is None or not items: return [(None, False)] * len(items) - length = float(line.length) - result: list[tuple[float | None, bool]] = [] - for item in items: - x, y = getattr(item, "x", None), getattr(item, "y", None) - anchor = getattr(item, "chainage_m", None) - if x is None or y is None or anchor is None: - result.append((None, False)) - continue - point = Point(float(x), float(y)) - at = float(line.project(point)) - target = ( - snap_existing_station(at, route_general_stations(length)) - if getattr(item, "placement", None) == "site" - else snap_structure_station(at, [length]) - ) - shift = target - float(anchor) + rows = [{key: getattr(item, key, None) for key in _FOLLOW_KEYS} for item in items] + results = run_calc( + ["facility.follow"], + { + "follow": { + "items": rows, + "line": [[x, y] for x, y in line.coords], + "max_drift_m": max_drift_m, + "interval_m": SECTION_STATION_INTERVAL_M, + } + }, + ) + if results is None: + raise RuntimeError("구조물 따라감 실패 — 계산 Node 입구(facility.follow)") + out: list[tuple[float | None, bool]] = [] + for item, row in zip(items, results["facility.follow"], strict=True): for key in _POSITION_KEYS: - value = getattr(item, key, None) - if value is not None: - setattr(item, key, min(max(float(value) + shift, 0.0), length)) - drift = float(line.distance(point)) - result.append((drift, drift > max_drift_m)) - return result + if getattr(item, key, None) is not None: + setattr(item, key, float(row["item"][key])) + out.append((row["drift_m"], bool(row["far"]))) + return out diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index c72649ac2..ad4bdd7d3 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -42,6 +42,7 @@ NODE_IDS = { "corridor.mesh", "profile.seat", "facility.plus_follow", + "facility.follow", } pytestmark = pytest.mark.skipif( diff --git a/resources/tester/test_472_4_follow_node.py b/resources/tester/test_472_4_follow_node.py index 223976d3a..5162bde76 100644 --- a/resources/tester/test_472_4_follow_node.py +++ b/resources/tester/test_472_4_follow_node.py @@ -78,3 +78,26 @@ def test_추가_측점_따라감은_Node_입구(tmp_path, monkeypatch) -> None: rows = plus.load_plus_stations(tmp_path) assert moved == 1 and [row["chainage_m"] for row in rows] == [30.0, 57.0] # 옛 파이썬 값 assert [(row["x"], row["y"]) for row in rows] == [(30.0, 0.0), (60.0, 0.0)] # 좌표 그대로 + + +def test_구조물_따라감은_Node_입구(monkeypatch) -> None: + from types import SimpleNamespace + + from common_util import common_util_route_follow as follow + + seen: list[list[str]] = [] + + def spy(ids, inputs): + seen.append(list(ids)) + return runner.run_calc(ids, inputs) + + monkeypatch.setattr(follow, "run_calc", spy) + items = [ + SimpleNamespace(chainage_m=60.0, start_m=55.0, end_m=65.0, x=60.0, y=0.0, placement=None), + SimpleNamespace(chainage_m=30.0, start_m=None, end_m=None, x=None, y=None, placement=None), + ] + result = follow.follow_structures(items, [(0.0, 5.0), (40.0, 5.0), (100.0, 25.0)]) + assert seen == [["facility.follow"]] + assert (items[0].chainage_m, items[0].start_m, items[0].end_m) == (57.0, 52.0, 62.0) + assert items[1].chainage_m == 30.0 and result[1] == (None, False) # 좌표 없는 옛 자료 그대로 + assert result[0][0] == pytest.approx(11.068, abs=1e-3) and result[0][1] is False # 옛 파이썬 값 From 65b4b13ed7f0f2c4104ff7821dee0bac910e4226 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 09:42:53 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(472-5=205-A=20=EB=82=98=EB=88=94):?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=EB=B0=B0=EC=88=98=20?= =?UTF-8?q?=ED=8C=90=20=EC=85=8B=20690=EC=A4=84=20=EB=84=98=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=8F=99=EC=9E=91=20=EA=B0=99=EA=B2=8C=20=EB=82=98?= =?UTF-8?q?=EB=88=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06_Section_UI_Page_Structures_Panel 700 → 타입을 _Panel_Types 로(다시 내보냄) - B05_Profile_UI_Structures_Panel 693 → 관 목록 받기(임시 · 고른 관 따라감)를 _Panel_Pipes 로 - B05_Profile_UI_Drainage_Panel 691 → 표시 토글 상태를 _Drainage_Show 로 - 시험 test_233_3 — 옮긴 줄을 새 파일에서 읽음(까닭: 같은 줄이 자리만 옮김) Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 58 ++-------- B05_Profile/B05_Profile_UI_Drainage_Show.ts | 62 +++++++++++ .../B05_Profile_UI_Structures_Panel.ts | 32 +----- .../B05_Profile_UI_Structures_Panel_Pipes.ts | 42 +++++++ .../B06_Section_UI_Page_Structures_Panel.ts | 105 ++---------------- ..._Section_UI_Page_Structures_Panel_Types.ts | 101 +++++++++++++++++ resources/tester/test_233_3_release.py | 4 +- 7 files changed, 231 insertions(+), 173 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Show.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Pipes.ts create mode 100644 B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index de00f3749..2e7cc7526 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -42,8 +42,8 @@ import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange"; +import { mountDrainageShow } from "./B05_Profile_UI_Drainage_Show"; import { - mountDrainageToggles, DRAINAGE_LAYERS, fetchDrainageLayers, createProgressReporter, @@ -159,21 +159,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // 평균 흐름 화살표 — B04가 계산해 둔 것을 그대로 받아 그린다(여기서 계산하지 않는다). let flowArrows: FlowArrow[] = []; let arrowSpacingM = 0; - let showArrows = true; // 유입 집중점 — 관 자리를 판단하는 근거. 기본 꺼짐(마커가 관 마커와 겹쳐 읽기 어렵다). let hotspots: Array<{ chainage: number; area: number }> = []; let maxHotspotArea = 0; - let showHotspots = false; /** 종단 Z 출처 — 세부유역이 갈리는 근거라 화면에서 확인 가능해야 한다. */ let zSource = ""; // 유역 안쪽 상류 세류망 — B04가 채택한 기준선을 그대로 받아 강조만 한다. let upstreamLines: Array> = []; - let showUpstream = true; // 계획선 위 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). let strengthSamples: RoutePoint[] = []; let strength: Float64Array = new Float64Array(0); let maxStrength = 0; - let showStrength = true; let scale = 1; let offsetX = 0; let offsetY = 0; @@ -187,42 +183,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let canvasHeight = 0; let canvasDpr = 0; - // 위성사진은 기본 꺼짐(2026-08-18 사용자 지시) — 어두운 사진이 유역 채움색을 묻는다. - backgroundImage.hidden = true; - mountDrainageToggles(layerButtons, { - initial: { - satellite: false, - arrows: showArrows, - strength: showStrength, - hotspots: showHotspots, - upstream: showUpstream, - }, - onSatellite: (next) => { - backgroundImage.hidden = !next; - scheduleDraw(); - }, - onSheetLayer: (layer, next) => { - if (next) activeLayers.add(layer); - else activeLayers.delete(layer); - scheduleDraw(); - }, - onArrows: (next) => { - showArrows = next; - scheduleDraw(); - }, - onStrength: (next) => { - showStrength = next; - legend.update(maxStrength, showStrength); - scheduleDraw(); - }, - onHotspots: (next) => { - showHotspots = next; - scheduleDraw(); - }, - onUpstream: (next) => { - showUpstream = next; - scheduleDraw(); - }, + // 표시 토글(위성 · 도엽 · 화살표 · 강도 · 집중점 · 상류) — 상태는 `_Drainage_Show`. + const show = mountDrainageShow(layerButtons, { + backgroundImage, + activeLayers, + onStrength: (next) => legend.update(maxStrength, next), + redraw: () => scheduleDraw(), }); function updateImageTransform(): void { @@ -260,17 +226,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra activeLayers, routeLayer, upstreamLines, - showUpstream, + showUpstream: show.upstream, strengthSamples, strength, maxStrength, - showStrength, + showStrength: show.strength, flowArrows, arrowSpacingM, - showArrows, + showArrows: show.arrows, hotspots, maxHotspotArea, - showHotspots, + showHotspots: show.hotspots, pipeEditor, pipeColor, markedChainage, @@ -352,7 +318,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); strength = built.strength; maxStrength = built.maximum; - legend.update(maxStrength, showStrength); + legend.update(maxStrength, show.strength); maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0); // 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함). pipeEditor.setPipes( diff --git a/B05_Profile/B05_Profile_UI_Drainage_Show.ts b/B05_Profile/B05_Profile_UI_Drainage_Show.ts new file mode 100644 index 000000000..68666ccaf --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Show.ts @@ -0,0 +1,62 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Show.ts + * 배수유역 판 표시 토글 상태 — 위성 · 도엽 · 화살표 · 강도 · 집중점 · 상류 세류 + * (`B05_Profile_UI_Drainage_Panel.ts` 에서 나눔 · 472-5 · 동작 같음). + * ========================================================================== */ + +import { mountDrainageToggles, type DrainageLayer } from "./B05_Profile_UI_Drainage_Parts"; + +/** 지금 켜진 표시 — 그리기가 읽음(토글이 고침). */ +export interface DrainageShow { + arrows: boolean; + /** 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). */ + strength: boolean; + /** 유입 집중점 — 기본 꺼짐(마커가 관 마커와 겹쳐 읽기 어렵다). */ + hotspots: boolean; + /** 유역 안쪽 상류 세류망 — B04 기준선 강조. */ + upstream: boolean; +} + +/** 토글 줄을 붙이고 상태를 돌려줌 — 위성사진은 기본 꺼짐(2026-08-18 사용자 지시). */ +export function mountDrainageShow( + layerButtons: HTMLElement, + deps: { + backgroundImage: HTMLImageElement; + activeLayers: Set; + onStrength: (show: boolean) => void; + redraw: () => void; + }, +): DrainageShow { + const show: DrainageShow = { arrows: true, strength: true, hotspots: false, upstream: true }; + deps.backgroundImage.hidden = true; + mountDrainageToggles(layerButtons, { + initial: { satellite: false, ...show }, + onSatellite: (next) => { + deps.backgroundImage.hidden = !next; + deps.redraw(); + }, + onSheetLayer: (layer, next) => { + if (next) deps.activeLayers.add(layer); + else deps.activeLayers.delete(layer); + deps.redraw(); + }, + onArrows: (next) => { + show.arrows = next; + deps.redraw(); + }, + onStrength: (next) => { + show.strength = next; + deps.onStrength(next); + deps.redraw(); + }, + onHotspots: (next) => { + show.hotspots = next; + deps.redraw(); + }, + onUpstream: (next) => { + show.upstream = next; + deps.redraw(); + }, + }); + return show; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 436a16adc..207781293 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -48,6 +48,7 @@ import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; import { movedStructure, snapOldStations, snapStationFor } from "./B05_Profile_UI_Structures_Snap"; +import { followPipeList } from "./B05_Profile_UI_Structures_Panel_Pipes"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -573,36 +574,7 @@ export function createStructuresSection( getStructures: () => [...structures], setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); - if (tempPipeChainage !== null) { - // 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다. - const hit = pipeFacilities.find( - (pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < PIPE_MATCH_M, - ); - if (hit) { - tempPipeChainage = hit.chainage_m; - if (selectedPipeChainage !== null) selectedPipeChainage = hit.chainage_m; - // 재계산이 끝나 도착한 담당 유역 설계유량을 입력 중인 폼에 반영한다 — - // 물넘이·세월교 월류 높이가 이 값으로 계산된다(2026-08-18 진단: 재계산 - // 후 선택 통지가 조용히 끝나 폼이 유량을 못 받던 문제). - facilityOptions.setDesignFlow(hit.design_flow_m3s ?? null); - } else { - // 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다). - tempPipeChainage = null; - syncButtons(); - } - } - const kept = pipeFacilities.find( - (pipe) => Math.abs(pipe.chainage_m - (selectedPipeChainage ?? NaN)) < PIPE_MATCH_M, - ); - if (kept) - selectedPipeChainage = kept.chainage_m; // 조금 옮겨진 관도 고름 따라감(233-3) - else if (selectedPipeChainage !== null) { - // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. - selectedPipeChainage = null; - facilityOptions.setFacility(null); - syncButtons(); - } - renderList(); + followPipeList(panelContext, pipeFacilities); }, selectPipeByChainage(chainageM) { if (chainageM !== null) callbacks.onReveal?.(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Pipes.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Pipes.ts new file mode 100644 index 000000000..046466007 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Pipes.ts @@ -0,0 +1,42 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Panel_Pipes.ts + * 「구조물 배치」 폼이 관 목록을 받는 몫 — 임시 관 · 고른 관이 조금 옮겨져도 따라감 + * (`B05_Profile_UI_Structures_Panel.ts` setPipeFacilities 에서 나눔 · 472-5 · 동작 같음). + * ========================================================================== */ + +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import type { StructuresEventContext } from "./B05_Profile_UI_Structures_Panel_Events"; +import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; + +/** 새 관 목록(`list` = 이미 갈아 낀 폼 목록) — 임시 · 고른 관 누가거리 따라감 · 사라지면 폼 닫음. */ +export function followPipeList(ctx: StructuresEventContext, list: PipeFacilityItem[]): void { + const temp = ctx.tempPipeChainage(); + if (temp !== null) { + // 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다. + const hit = list.find((pipe) => Math.abs(pipe.chainage_m - temp) < PIPE_MATCH_M); + if (hit) { + ctx.setTempPipeChainage(hit.chainage_m); + if (ctx.selectedPipeChainage() !== null) ctx.setSelectedPipeChainage(hit.chainage_m); + // 재계산이 끝나 도착한 담당 유역 설계유량을 입력 중인 폼에 반영한다 — + // 물넘이·세월교 월류 높이가 이 값으로 계산된다(2026-08-18 진단: 재계산 + // 후 선택 통지가 조용히 끝나 폼이 유량을 못 받던 문제). + ctx.facilityOptions.setDesignFlow(hit.design_flow_m3s ?? null); + } else { + // 임시 배치 관이 재계산에서 사라졌다 — 임시 상태만 정리(관은 이미 없다). + ctx.setTempPipeChainage(null); + ctx.syncButtons(); + } + } + const kept = list.find( + (pipe) => Math.abs(pipe.chainage_m - (ctx.selectedPipeChainage() ?? NaN)) < PIPE_MATCH_M, + ); + if (kept) + ctx.setSelectedPipeChainage(kept.chainage_m); // 조금 옮겨진 관도 고름 따라감(233-3) + else if (ctx.selectedPipeChainage() !== null) { + // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. + ctx.setSelectedPipeChainage(null); + ctx.facilityOptions.setFacility(null); + ctx.syncButtons(); + } + ctx.renderList(); +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 1c9a1a4cd..bde0bd1e8 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -13,11 +13,7 @@ * ========================================================================== */ import type { PipeFacilityItem } from "../A00_Common/b_structures_section"; -import { - bindDesignView, - sharedStructures, - type DesignPageShared, -} from "../A00_Common/design/design_shared"; +import { bindDesignView, sharedStructures } from "../A00_Common/design/design_shared"; import { fetchStructureTypes, staleDefinitionNotice, @@ -58,10 +54,17 @@ import { resetAdjustDock, setAdjustSideSlots, } from "./B06_Section_UI_Adjust_Dock"; -import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; import { listPick } from "../common_util/common_util_structure_catalog"; import { followMovedPipe, pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import type { + B06StructuresPanel, + B06StructuresPanelDeps, +} from "./B06_Section_UI_Page_Structures_Panel_Types"; +export type { + B06StructuresPanel, + B06StructuresPanelDeps, +} from "./B06_Section_UI_Page_Structures_Panel_Types"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다."; @@ -74,96 +77,6 @@ const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세 const PIPE_MOVE_NOTICE = "기준 측점을 옮겼습니다 — 횡단도는 바로 보이고, 세부 배수유역은 뒤에서 다시 나눕니다."; -export interface B06StructuresPanelDeps { - projectId: string | null; - /** 한 페이지 한 벌 — 「구조물 배치」 폼은 종단 보기와 같은 노드다(계획서 24-3b). */ - shared: DesignPageShared; - /** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다 - * (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */ - detail?: () => SectionDetailResponse | null; - /** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본 - * 높이도 이 값을 쓴다(조정창 표시값과 같다). */ - wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null; - /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ - wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null; - /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ - wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; - /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ - stationInterval: () => number; - /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지도 스크롤도 않음 · 측점 없는 구조물 · 307). */ - focusChainage: (chainageM: number, revealOnly?: boolean) => void; - /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ - releaseCard?: () => void; - /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ - markStructure?: (structureId: string | null, span?: boolean) => void; - /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ - /** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */ - onMarks?: ( - structures: StructureInstance[], - types: StructureType[], - pipes: ReadonlyArray, - ) => void; - /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — - * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ - applyPipeOptions?: (chainageM: number, patch: Record) => void; - /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ - onPipesWritten?: () => void; -} - -export interface B06StructuresPanel { - /** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */ - root: HTMLElement; - /** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */ - listRoot: HTMLElement; - /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ - load: () => Promise; - /** 가게(초안 · 없으면 저장분) 관 · 구조물 자리가 목록과 다른가 — 다시 실을 때(노선 초안 따라감 · 평면 마커 옮김 · 440). */ - listStale: () => boolean; - /** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인 - * 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */ - overrideOptions: (chainageM: number, patch: Record) => void; - /** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고, - * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 · - * 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */ - showAtChainage: (chainageM: number | null) => DesignSelection; - /** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를 - * 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */ - releasePipe: () => void; - /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ - selectionAt: (chainageM: number | null) => DesignSelection; - /** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */ - prefer: (structureId: string | null) => void; - /** 지금 카드 고름이 먼저 열 구조물(목록 · 알약 · 넘김) — 없으면 null(카드만 고름 · 189-1). */ - preferred: () => string | null; - /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ - selectStructure: (structureId: string) => void; - /** 측점 없는 관 하나를 폼에 올림 — 카드 안 고름 · 스크롤 없음 · 알약 · 고름 값에 알약 id(요구 178 · 307). */ - selectPipeAt: (chainageM: number) => void; - /** 넘김값(B05 알약 · 3D · 지도)의 카드 고름 — `focus` 가 그 카드를 고름 · 넘긴 구조물이 먼저(52-15) · - * 폼은 id 로 직접 엶(53-1) · 측점 없는 구조물은 카드 고름 · 스크롤 없이 폼만(요구 178 · 307). */ - focusPicked: (structureId: string | null, focus: () => void) => void; - /** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */ - adoptFordHeights: () => void; - /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ - getStructures: () => StructureInstance[]; - /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ - currentChainage: () => number | null; - /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ - addStructureAt: (chainageM: number, typeId: string) => void; - removeStructureById: (structureId: string) => boolean; - addPipeAt: (chainageM: number) => void; - removePipeAt: (chainageM: number) => void; - movePipeTo: (fromChainageM: number, toChainageM: number) => void; - moveStructureTo: (structureId: string, toChainageM: number) => boolean; - /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ - facility: { - setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; - onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void; - /** 횡단 단 수가 바뀜 — 배관 흙막이 컨테이너 수를 맞춤(54-1). */ - recount: () => void; - }; -} - type PlaceOf = { chainage_m?: number | null; start_m?: number | null; end_m?: number | null }; /** 목록 자리 지문 — 누가거리 · 구간만(옵션 고침은 안 셈 · 440). */ const placeKey = (list: ReadonlyArray): string => diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts new file mode 100644 index 000000000..1908219c9 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel_Types.ts @@ -0,0 +1,101 @@ +/* ============================================================================= + * B06_Section_UI_Page_Structures_Panel_Types.ts + * 횡단 보기 「구조물 배치」 몫의 타입 — `B06_Section_UI_Page_Structures_Panel.ts` 에서 나눔 + * (472-5 · 700줄 · 동작 같음 · 그쪽이 다시 내보냄). + * ========================================================================== */ + +import type { PipeFacilityItem } from "../A00_Common/b_structures_section"; +import type { DesignPageShared } from "../A00_Common/design/design_shared"; +import type { DesignSelection } from "../A00_Common/design/design_selection"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; + +export interface B06StructuresPanelDeps { + projectId: string | null; + /** 한 페이지 한 벌 — 「구조물 배치」 폼은 종단 보기와 같은 노드다(계획서 24-3b). */ + shared: DesignPageShared; + /** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다 + * (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */ + detail?: () => SectionDetailResponse | null; + /** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본 + * 높이도 이 값을 쓴다(조정창 표시값과 같다). */ + wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null; + /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ + wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null; + /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ + wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; + /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ + stationInterval: () => number; + /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지도 스크롤도 않음 · 측점 없는 구조물 · 307). */ + focusChainage: (chainageM: number, revealOnly?: boolean) => void; + /** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */ + releaseCard?: () => void; + /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름 · span 끔 = 범위 테두리 없이 · 137-4). */ + markStructure?: (structureId: string | null, span?: boolean) => void; + /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ + /** `pipes` = 관 지점(구간 포함) — 종단 띠 측점 계획(322-3). */ + onMarks?: ( + structures: StructureInstance[], + types: StructureType[], + pipes: ReadonlyArray, + ) => void; + /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — + * 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */ + applyPipeOptions?: (chainageM: number, patch: Record) => void; + /** 관 목록 초안을 고쳤다 — 한 벌 배수유역도가 그 초안으로 다시 선다. */ + onPipesWritten?: () => void; +} + +export interface B06StructuresPanel { + /** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */ + root: HTMLElement; + /** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */ + listRoot: HTMLElement; + /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ + load: () => Promise; + /** 가게(초안 · 없으면 저장분) 관 · 구조물 자리가 목록과 다른가 — 다시 실을 때(노선 초안 따라감 · 평면 마커 옮김 · 440). */ + listStale: () => boolean; + /** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인 + * 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */ + overrideOptions: (chainageM: number, patch: Record) => void; + /** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고, + * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 · + * 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */ + showAtChainage: (chainageM: number | null) => DesignSelection; + /** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를 + * 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */ + releasePipe: () => void; + /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ + selectionAt: (chainageM: number | null) => DesignSelection; + /** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */ + prefer: (structureId: string | null) => void; + /** 지금 카드 고름이 먼저 열 구조물(목록 · 알약 · 넘김) — 없으면 null(카드만 고름 · 189-1). */ + preferred: () => string | null; + /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ + selectStructure: (structureId: string) => void; + /** 측점 없는 관 하나를 폼에 올림 — 카드 안 고름 · 스크롤 없음 · 알약 · 고름 값에 알약 id(요구 178 · 307). */ + selectPipeAt: (chainageM: number) => void; + /** 넘김값(B05 알약 · 3D · 지도)의 카드 고름 — `focus` 가 그 카드를 고름 · 넘긴 구조물이 먼저(52-15) · + * 폼은 id 로 직접 엶(53-1) · 측점 없는 구조물은 카드 고름 · 스크롤 없이 폼만(요구 178 · 307). */ + focusPicked: (structureId: string | null, focus: () => void) => void; + /** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */ + adoptFordHeights: () => void; + /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ + getStructures: () => StructureInstance[]; + /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ + currentChainage: () => number | null; + /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ + addStructureAt: (chainageM: number, typeId: string) => void; + removeStructureById: (structureId: string) => boolean; + addPipeAt: (chainageM: number) => void; + removePipeAt: (chainageM: number) => void; + movePipeTo: (fromChainageM: number, toChainageM: number) => void; + moveStructureTo: (structureId: string, toChainageM: number) => boolean; + /** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */ + facility: { + setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void; + onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void; + /** 횡단 단 수가 바뀜 — 배관 흙막이 컨테이너 수를 맞춤(54-1). */ + recount: () => void; + }; +} diff --git a/resources/tester/test_233_3_release.py b/resources/tester/test_233_3_release.py index 4af479f86..90dde8257 100644 --- a/resources/tester/test_233_3_release.py +++ b/resources/tester/test_233_3_release.py @@ -99,7 +99,9 @@ def test_배선() -> None: listing = _read("B05_Profile", "B05_Profile_UI_Structures_List.ts") assert "< PIPE_MATCH_M" in listing and "< 0.05" not in listing structures = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") - assert "selectedPipeChainage = kept.chainage_m;" in structures + # 관 목록 받기는 472-5 에서 `_Structures_Panel_Pipes` 로 나눔(동작 같음) + pipes = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Pipes.ts") + assert "ctx.setSelectedPipeChainage(kept.chainage_m);" in pipes # 저장 · 새로 읽기 뒤 목록 주입이 관 폼을 닫지 않음(닫히며 종단 선까지 꺼졌음) assert "if (!applyingLive && selectedPipeChainage === null)" in structures assert len(structures.splitlines()) <= 700