From 8a9456579a316ef8dce3051e45c7c1e4c1eb1d99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 21:02:55 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05=C2=B7B06):=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20selection=20=ED=95=9C=20=EA=B3=B3=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EA=B4=80=20=EC=B0=BE=EB=8A=94=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=20PIPE=5FMATCH=5FM=20=ED=95=9C=20=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=EB=88=84=EB=A6=84?= =?UTF-8?q?=EC=9D=B4=20=EC=98=B9=EB=B2=BD=20=C2=B7=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=C2=B7=20BOX=20=C2=B7=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=ED=8F=BC=EC=9D=84=20=EC=97=B6=20(24-2d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 · 종단 알약 · 3D · 횡단 카드 · 벽 어디서 골라도 selectDesign 한 길로 세션에 적음(구조물 id 포함) — B05 목록 구조물 고름도 B06 으로 이어짐 - 폼이 열 항목 풀이 resolveSelection 한 곳(관 먼저 · 구조물 다음 · 가장 가까운 것) — 공용 폼의 0.05 없앰 - B06 벽 · 구체 누름은 showAtChainage 하나를 탐(옛 관 전용 showPipeAt 없앰) · 벽 누름 중 폼이 카드를 먼저 고르는 이중 토글 막음 - B06 진입 때 구조물 id 로 카드를 찾고, 못 찾으면 다시 부르게 함 · B05 진입 때 구조물 id 가 있을 때만 구조물로 연다 - 시험 test_b05_b06_selection_single 21 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- A00_Common/design/design_selection.ts | 25 ++- B05_Profile/B05_Profile_UI_Page.ts | 24 ++- B05_Profile/B05_Profile_UI_Selection.ts | 6 +- .../B05_Profile_UI_Structure_Pick_Session.ts | 79 +++---- .../B05_Profile_UI_Structures_Panel.ts | 23 ++- .../B05_Profile_UI_Structures_Panel_Types.ts | 6 + B06_Section/B06_Section_UI_Page.ts | 51 +++-- .../B06_Section_UI_Page_Structure_Pick.ts | 55 ++++- .../B06_Section_UI_Page_Structures_Panel.ts | 96 +++++---- resources/tester/helper_b05_b06_selection.cjs | 178 ++++++++++++++++ .../tester/test_b05_b06_selection_single.py | 195 ++++++++++++++++++ 11 files changed, 597 insertions(+), 141 deletions(-) create mode 100644 resources/tester/helper_b05_b06_selection.cjs create mode 100644 resources/tester/test_b05_b06_selection_single.py diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 43f23a77..4e7884a1 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -28,9 +28,28 @@ export function isNear(a: number, b: number): boolean { return Math.abs(a - b) < PIPE_MATCH_M; } +/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도 + * 자리가 맞는 쪽을 고른다. */ +export function nearestWithin( + items: ReadonlyArray, + chainageM: number, + chainageOf: (item: T) => number, +): T | undefined { + let best: T | undefined; + let bestGap = PIPE_MATCH_M; + for (const item of items) { + const gap = Math.abs(chainageOf(item) - chainageM); + if (gap < bestGap) { + best = item; + bestGap = gap; + } + } + return best; +} + /** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */ export interface SelectableStructure { - structure_id: string; + structure_id?: string | null; chainage_m?: number | null; start_m?: number | null; end_m?: number | null; @@ -66,11 +85,11 @@ export function resolveSelection( ): ResolvedSelection { if (selection.structureId) { const byId = structures.find((entry) => entry.structure_id === selection.structureId); - if (byId) return { pipeChainageM: null, structureId: byId.structure_id }; + if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null }; } const chainageM = selection.chainageM; if (chainageM === null) return { pipeChainageM: null, structureId: null }; - const pipe = pipes.find((entry) => isNear(entry.chainage_m, chainageM)); + const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m); if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null }; const hit = structures.find((entry) => coversChainage(entry, chainageM)); return { pipeChainageM: null, structureId: hit?.structure_id ?? null }; diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index de0d8b71..183b51bd 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -28,11 +28,9 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; -import { - restoreStructurePick, - wireStructurePick, - writeStructurePick, -} from "./B05_Profile_UI_Structure_Pick_Session"; +import { restoreStructurePick, wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session"; +import { NO_SELECTION, nearestWithin, selectDesign } from "../A00_Common/design/design_selection"; +import { structureAnchorM } from "./B05_Profile_Api_Structures"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; @@ -98,6 +96,11 @@ export async function renderB05Route(root: HTMLElement): Promise { 대피로: "refuge", 기타: "etc", }; + /** 고른 것을 selection 한 곳에 적는다 — B06 으로 이어지는 값이다(chainageM null = 해제). */ + const rememberPick = (chainageM: number | null, structureId: string | null): void => + selectDesign(activeProjectId, chainageM === null ? NO_SELECTION : { chainageM, structureId }, { + from: "b05", + }); const profilePanel = createRouteProfilePanel( activeProjectId, (stationId) => { @@ -144,8 +147,8 @@ export async function renderB05Route(root: HTMLElement): Promise { onStructureSelect: (structureId) => { // 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만 // 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06). - writeStructurePick(activeProjectId, bridge.markChainage(structureId)); const chainage = bridge.pipeMarkChainage(structureId); + rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null); if (chainage !== null) { panel.structures.selectPipeByChainage(chainage); selectStationOfPipe(chainage); @@ -342,7 +345,7 @@ export async function renderB05Route(root: HTMLElement): Promise { profilePanel.drainage.updatePipeFacility(from, to, attributes), onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), onPipeFacilitySelect: (chainage) => { - writeStructurePick(activeProjectId, chainage); // 목록 선택도 B06으로 이어진다. + rememberPick(chainage, null); // 목록 선택도 B06으로 이어진다. if (selectionSyncing) return; selectionSyncing = true; try { @@ -350,9 +353,7 @@ export async function renderB05Route(root: HTMLElement): Promise { const station = chainage === null ? undefined - : bridge - .irregularStations() - .find((entry) => Math.abs(entry.chainage_m - chainage) < 0.05); + : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); profilePanel.setSelectedStation(id); @@ -367,6 +368,9 @@ export async function renderB05Route(root: HTMLElement): Promise { // 3D·종단·유역도에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). onStructureReveal: () => layout.setOptionsOpen(true), onStructureSelect: (structure) => { + // 목록에서 고른 구조물도 B06 으로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 + // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). + if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); if (selectionSyncing) return; selectionSyncing = true; try { diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index baf289fd..c5fd228e 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -9,6 +9,7 @@ * 구조물(`origin: "pipe"`)일 때만 붙는다 — 다른 구조물에는 대응하는 유역이 없다. * ========================================================================== */ +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { irregularStationId, isPipeStation, @@ -48,9 +49,6 @@ export interface SelectionSync { selectStationOfPipe: (chainageM: number | null) => void; } -/** 같은 누가거리로 볼 여유(m). 관 지점과 구조물 측점은 소수점 둘째 자리까지 같은 값을 쓴다. */ -const SAME_CHAINAGE_M = 0.51; - export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { const prefix = irregularStationId(""); @@ -94,7 +92,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { .stations() .find( (entry) => - isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < SAME_CHAINAGE_M, + isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, ); const id = matched ? irregularStationId(matched.id) : null; ports.setSyncing(true); diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index a0214bf3..d4de1c8c 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -9,62 +9,35 @@ * 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +import { + NO_SELECTION, + readSelection, + selectDesign, + type DesignSelection, +} from "../A00_Common/design/design_selection"; import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; -import type { StructureInstance } from "./B05_Profile_Api_Structures"; -/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍과 같은 모양이다. */ +/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍에 구조물 id 가 붙는다. */ export interface StructurePickHandoff { at: number; key?: string; + structureId?: string; } -/* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다 - (2026-09-06 캐시·세션 일원화). */ - -/** 세션에 남긴다(선택 해제면 지운다). */ -export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void { - if (!projectId) return; - if (!pick) return writeState("structure-pick", null, projectId); - const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key }; - writeState("structure-pick", handoff, projectId); -} - -/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */ -export function writeStructurePick( - projectId: string | null, - at: number | null, - key?: string, -): void { - if (!projectId) return; - if (at === null) return writeState("structure-pick", null, projectId); - // 부재키 없이 **측점만** 오는 길(사이드 목록·선택 동기화)이 3D 로 고른 부재키를 지웠다 - // (2026-09-05 진단). 같은 측점이면 이미 적힌 부재키를 지킨다 — 3D 로 고른 직후 선택 - // 동기화가 이 길을 타도 조정창이 그대로 열린다. - const kept = key ?? readStructurePick(projectId)?.key; - writeState("structure-pick", { at, key: kept }, projectId); -} +/* 넘김값은 `design_selection` 의 selection 한 곳이다 — 3D · 알약 · 목록 · 횡단 카드 어디서 + 골라도 그 함수 하나로 적고(`selectDesign`), 여기서는 읽기만 한다. 값은 ② 설계 초안이라 + 등록표(`b_page_state`)를 쓴다(2026-09-06 캐시·세션 일원화). */ /** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */ export function readStructurePick(projectId: string | null): StructurePickHandoff | null { - if (!projectId) return null; - try { - const value = readState>("structure-pick", projectId); - if (!value) return null; - if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null; - return { at: value.at, key: typeof value.key === "string" ? value.key : undefined }; - } catch { - return null; - } -} - -/** 구간형 구조물이 그 누가거리를 덮는가(기준점형은 ±0.51m 안). */ -function covers(entry: StructureInstance, chainageM: number): boolean { - const start = entry.start_m ?? entry.chainage_m; - const end = entry.end_m ?? entry.chainage_m; - if (start == null || end == null) return false; - return chainageM >= Math.min(start, end) - 0.51 && chainageM <= Math.max(start, end) + 0.51; + const value = readSelection(projectId); + if (!value || value.chainageM === null) return null; + return { + at: value.chainageM, + key: value.partKey, + structureId: value.structureId ?? undefined, + }; } /** @@ -72,7 +45,7 @@ function covers(entry: StructureInstance, chainageM: number): boolean { * * 배수관 세트(기슭막이·집수정·배관)는 `selectStation`(=기존 선택 동기화)이 좌측 폼·종단 * 그래프·3D 마커·유역도를 한 줄로 맞춘다. 그 줄에서 아무것도 안 잡히면 옛 정본 구조물로 - * 보고 구조물 id로 연다. + * 보고 폼이 구조물 id 로 연다 — 관·구조물 찾는 거리와 순서는 `resolveSelection` 한 곳이다. */ export function wireStructurePick( controls: StructurePickControls, @@ -82,15 +55,16 @@ export function wireStructurePick( selectStation: (chainageM: number | null) => void, ): void { controls.onPick = (pick: StructurePick | null): void => { - rememberStructurePick(projectId, pick); if (!pick) { + selectDesign(projectId, NO_SELECTION, { from: "b05" }); selectStation(null); return; } + const selection: DesignSelection = { chainageM: pick.chainageM, structureId: null }; + const { structureId } = structures.resolveAt(selection); + selectDesign(projectId, { ...selection, structureId }, { partKey: pick.key, from: "b05" }); selectStation(pick.chainageM); - if (structures.hasSelection()) return; - const hit = structures.getStructures().find((entry) => covers(entry, pick.chainageM)); - structures.selectById(hit?.structure_id ?? null); + if (!structures.hasSelection()) structures.selectAt({ ...selection, structureId }); }; } @@ -112,5 +86,10 @@ export function restoreStructurePick( if (!handoff) return true; // 되살릴 것이 없으면 끝난 것으로 본다. controls.select({ chainageM: handoff.at, kind: "", key: handoff.key }); selectStation(handoff.at); + // 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을 + // 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다). + if (!structures.hasSelection() && handoff.structureId) { + structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId }); + } return structures.hasSelection(); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 10be07e9..ec2db490 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -40,6 +40,7 @@ import { import { renderStructureList } from "./B05_Profile_UI_Structures_List"; import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { stationLabel as formatStation } from "../common_util/common_util_station"; +import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -408,7 +409,8 @@ export function createStructuresSection( }, onSelectPipe: (pipe) => { const isSame = - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + selectedPipeChainage !== null && + Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; if (isSame) { resetForm(); } else { @@ -540,9 +542,9 @@ export function createStructuresSection( setPipeFacilities(pipes) { pipeFacilities = pipes.map((pipe) => ({ ...pipe })); if (tempPipeChainage !== null) { - // 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다(0.51m 기준). + // 스냅·재계산으로 좌표가 조금 옮겨진 임시 관을 계속 따라간다. const hit = pipeFacilities.find( - (pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < 0.51, + (pipe) => Math.abs(pipe.chainage_m - tempPipeChainage!) < PIPE_MATCH_M, ); if (hit) { tempPipeChainage = hit.chainage_m; @@ -559,7 +561,9 @@ export function createStructuresSection( } if ( selectedPipeChainage !== null && - !pipeFacilities.some((pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < 0.05) + !pipeFacilities.some( + (pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M, + ) ) { // 고르고 있던 관이 사라졌다(삭제·재계산 이동) — 폼도 함께 닫는다. selectedPipeChainage = null; @@ -578,10 +582,19 @@ export function createStructuresSection( } // 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든 // 같은 편집 화면이 열린다(2026-08-17 전역 선택 동기화). - const pipe = pipeFacilities.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.05); + const pipe = pipeFacilities.find( + (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, + ); if (!pipe) return; loadPipeForm(pipe); }, + resolveAt: (selection) => resolveSelection(selection, pipeFacilities, structures), + selectAt(selection) { + const hit = resolveSelection(selection, pipeFacilities, structures); + if (hit.pipeChainageM !== null) this.selectPipeByChainage(hit.pipeChainageM); + else this.selectById(hit.structureId); + return hit; + }, selectById(structureId) { if (structureId !== null) callbacks.onReveal?.(); if (structureId === null) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 9c7cabc5..03a7afb7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -18,6 +18,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; +import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; import type { SummaryItem } from "../common_util/common_util_summary_item"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. @@ -93,6 +94,11 @@ export interface StructuresSection { hasSelection: () => boolean; /** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */ selectById: (structureId: string | null) => void; + /** 측점 · 구조물 고름 한 값을 폼이 열 항목으로 푼다 — 열지는 않는다(관 계열이 먼저). */ + resolveAt: (selection: DesignSelection) => ResolvedSelection; + /** 고름 한 값으로 폼을 연다(관 → 구조물 순) — 어느 화면에서 골라도 같은 거리 · 같은 순서다. + * 고를 항목이 없으면 폼을 해제한다. 푼 결과를 돌려준다. */ + selectAt: (selection: DesignSelection) => ResolvedSelection; /** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */ addAt: (chainageM: number, typeId: string) => void; /** 종단 그래프에서 마크를 끌어 옮긴다. 옮겼으면 true. */ diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index b4f5f165..cca5802a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -32,10 +32,8 @@ import { type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; -import { - readStructurePick, - writeStructurePick, -} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { NO_SELECTION, selectDesign } from "../A00_Common/design/design_selection"; import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick"; import { createSectionView } from "./B06_Section_UI_Section_View"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -196,7 +194,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" }; }, stationInterval: () => stationInterval ?? 20, - focusChainage: focusStationAt, + // 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저 + // 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다. + focusChainage: (chainageM) => { + if (!isWallSelecting()) focusStationAt(chainageM); + }, queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), @@ -377,7 +379,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { () => sectionDetail, structuresPanel, // 횡단도에서 고른 부재도 같은 세션 칸에 남긴다(2026-09-04 — 두 화면이 한 페이지처럼). - (chainageM, key) => writeStructurePick(projectId, chainageM, key), + (chainageM, key) => + selectDesign(projectId, structuresPanel.selectionAt(chainageM), { + partKey: key, + from: "b06", + }), ); // 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자). // 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다. @@ -387,13 +393,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { : null; if (!target) { structuresPanel.showAtChainage(null); - writeStructurePick(projectId, null); // 카드 해제 — 세션 선택도 비운다. + selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 세션 선택도 비운다. return; } const owner = stationControls.structureSpan.ownerOf(target); - structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); + const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. - if (!isWallSelecting()) writeStructurePick(projectId, owner?.chainage_m ?? target.chainage_m); + if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -462,16 +468,25 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { function restoreStructurePick(): void { if (!sectionDetail) return; const handoff = readStructurePick(projectId); - const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null; + const signature = handoff + ? `${handoff.at}|${handoff.key ?? ""}|${handoff.structureId ?? ""}` + : null; if (signature === null || signature === appliedPick) return; - appliedPick = signature; - applyStructurePick(handoff, sectionDetail, { - focusStation: (stationId) => sectionView.focusStation(stationId), - refreshCard: (chainageM) => sectionView.refreshCard(chainageM), - revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), - fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), - boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role), - }); + // 카드를 못 찾았으면(구조물 목록이 아직 안 실림) 적용한 것으로 치지 않는다 — 목록이 실린 뒤 + // `load().then(restoreStructurePick)` 가 다시 부른다. + const applied = applyStructurePick( + handoff, + sectionDetail, + { + focusStation: (stationId) => sectionView.focusStation(stationId), + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), + fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), + boxSelect: (chainageM, role) => stationControls.box.select(chainageM, role), + }, + structuresPanel.getStructures(), + ); + if (applied) appliedPick = signature; } function renderSectionDetail(): void { diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 45f45bcb..bbeef09b 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -16,6 +16,11 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import { + coversChainage, + isNear, + type SelectableStructure, +} from "../A00_Common/design/design_selection"; /** 기슭막이 조정창이 받는 부재키 — 집수정·배관·본체는 여기서 빠진다. */ const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+)$/; @@ -30,20 +35,30 @@ interface PickTargets { /** * 넘김값을 화면에 적용한다. 값이 없거나 그 측점이 이 노선에 없으면 아무것도 안 한다. - * 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. + * 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. 카드를 찾아 적용했으면 true — + * 못 찾았으면(구조물 목록이 아직 안 실린 경우 등) false 라 부르는 쪽이 나중에 다시 부른다. */ export function applyStructurePick( handoff: StructurePickHandoff | null, detail: SectionDetailResponse | null, targets: PickTargets, -): void { - if (!handoff) return; - // 허용오차는 같은 화면의 다른 대조와 같은 0.05m 다 — 0.01m 로는 3D 가 준 누가거리가 + /** 구조물 정본 — 카드 위치가 아닌 곳(구간형 옹벽 한가운데 등)을 골랐을 때 그 카드를 찾는 데 쓴다. */ + structures: ReadonlyArray = [], +): boolean { + if (!handoff) return false; + // 허용오차는 관·구조물 찾는 거리 한 값이다 — 0.01m 로는 3D 가 준 누가거리가 // 소수점 아래에서 조금만 달라도 그 측점을 못 찾고 조용히 끝났다(2026-09-05 진단). - const target = detail?.cross_sections.find( - (section) => Math.abs(section.chainage_m - handoff.at) < 0.05, - ); - if (!target) return; + const sections = detail?.cross_sections ?? []; + // 구조물 id 가 있으면 그 구조물의 카드가 먼저다 — 같은 측점 근처에 다른 카드(관)가 있어도. + const byId = handoff.structureId + ? structures.find((entry) => entry.structure_id === handoff.structureId) + : undefined; + const covering = structures.find((entry) => coversChainage(entry, handoff.at)); + const target = + (byId && cardOfStructure(sections, byId, handoff.at)) || + sections.find((section) => isNear(section.chainage_m, handoff.at)) || + (covering && cardOfStructure(sections, covering, handoff.at)); + if (!target) return false; // 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다. const key = handoff.key?.split(":")[0]; requestAnimationFrame(() => { @@ -61,4 +76,28 @@ export function applyStructurePick( } targets.refreshCard(target.chainage_m); }); + return true; +} + +type Sections = SectionDetailResponse["cross_sections"]; + +/** 구조물이 자리한 카드 — 구간형이면 그 안의 첫 카드, 점형이면 가장 가까운 카드. 옹벽 · 기슭막이는 + * 횡단 제원이라 시작 측점 카드가 주인이다(관은 `isNear` 로 카드를 바로 찾는다). */ +function cardOfStructure( + sections: Sections, + structure: SelectableStructure, + at: number, +): Sections[number] | undefined { + if (!sections.length) return undefined; + const start = structure.start_m ?? structure.chainage_m; + const end = structure.end_m ?? structure.chainage_m; + if (start != null && end != null) { + const inside = sections.find( + (section) => section.chainage_m >= start - 0.01 && section.chainage_m <= end + 0.01, + ); + if (inside) return inside; + } + return sections.reduce((best, entry) => + Math.abs(entry.chainage_m - at) < Math.abs(best.chainage_m - at) ? entry : best, + ); } diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 5f968d6d..8c40bd6b 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -27,10 +27,14 @@ import { import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readSummaryItem } from "../common_util/common_util_summary_item"; +import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { - readStructurePick, - writeStructurePick, -} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; + NO_SELECTION, + PIPE_MATCH_M, + resolveSelection, + selectDesign, + type DesignSelection, +} from "../A00_Common/design/design_selection"; import { showToast } from "@ui/ui_template_elements"; import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { @@ -92,14 +96,20 @@ export interface B06StructuresPanel { listRoot: HTMLElement; /** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */ load: () => Promise; - /** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */ - showPipeAt: (chainageM: number | null) => void; /** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인 * 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */ overrideOptions: (chainageM: number, patch: Record) => void; /** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고, - * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */ - showAtChainage: (chainageM: number | null) => void; + * 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). 횡단도의 카드 · + * 벽 · 구체 어디를 눌러도 이 길 하나다. 폼이 연 것을 고름 값으로 돌려준다. */ + showAtChainage: (chainageM: number | null) => DesignSelection; + /** 횡단도에서 벽·구체 선택을 풀 때 — 관 계열 폼만 비운다. 구조물 폼은 그대로 둔다(카드를 + * 갈아 선택하며 옛 카드의 벽 선택이 풀리는 순간 새 카드의 폼을 지우면 안 된다). */ + releasePipe: () => void; + /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ + selectionAt: (chainageM: number | null) => DesignSelection; + /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ + getStructures: () => StructureInstance[]; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ currentChainage: () => number | null; /** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */ @@ -116,17 +126,6 @@ export interface B06StructuresPanel { }; } -/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */ -const PIPE_MATCH_M = 0.51; - -/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */ -function coversChainage(structure: StructureInstance, chainageM: number): boolean { - if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) { - return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01; - } - return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M; -} - type Merged = Record; /** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */ @@ -376,7 +375,11 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. - writeStructurePick(deps.projectId, chainageM); + selectDesign( + deps.projectId, + chainageM === null ? NO_SELECTION : { chainageM, structureId: null }, + { from: "b06" }, + ); if (chainageM !== null) deps.focusChainage(chainageM); }, }, @@ -456,8 +459,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. - const picked = currentChainageM ?? readStructurePick(deps.projectId)?.at ?? null; - if (picked !== null) section.selectPipeByChainage(picked); + const pick = readStructurePick(deps.projectId); + const picked = currentChainageM ?? pick?.at ?? null; + // 관이면 그 관, 옹벽 · 기슭막이 같은 구조물이면 저장된 구조물 id 로 폼을 세운다. + if (picked !== null) { + section.selectAt({ + chainageM: picked, + structureId: currentChainageM === null ? (pick?.structureId ?? null) : null, + }); + } } catch (error) { showToast( error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.", @@ -576,30 +586,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다. section.selectPipeByChainage(hit.chainage_m); }, - showPipeAt: (chainageM) => { - currentChainageM = chainageM; - if (chainageM !== null) refreshSpecDefaults(chainageM); - section.selectPipeByChainage(chainageM); + getStructures: () => structures, + releasePipe: () => { + currentChainageM = null; + section.selectPipeByChainage(null); + }, + selectionAt: (chainageM) => { + const selection = { chainageM, structureId: null }; + return { chainageM, structureId: section.resolveAt(selection).structureId }; }, showAtChainage: (chainageM) => { currentChainageM = null; - if (chainageM === null) { - section.selectById(null); - return; - } + const selection: DesignSelection = { chainageM, structureId: null }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 - // 그려지는 것은 이쪽이다. - const pipe = pipeFacilities.find( - (entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M, - ); - if (pipe) { - currentChainageM = pipe.chainage_m; - refreshSpecDefaults(pipe.chainage_m); - section.selectPipeByChainage(pipe.chainage_m); - return; + // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). + const hit = resolveSelection(selection, pipeFacilities, structures); + if (hit.pipeChainageM !== null) { + currentChainageM = hit.pipeChainageM; + refreshSpecDefaults(hit.pipeChainageM); } - const hit = structures.find((entry) => coversChainage(entry, chainageM)); - section.selectById(hit?.structure_id ?? null); + section.selectAt(selection); + return { chainageM, structureId: hit.structureId }; }, }; } @@ -661,7 +668,8 @@ export function wireStructureSelection( markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); - panel.showPipeAt(key ? ownerChainageOf(chainageM) : null); + if (key) panel.showAtChainage(ownerChainageOf(chainageM)); + else panel.releasePipe(); if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); @@ -672,7 +680,8 @@ export function wireStructureSelection( markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); - panel.showPipeAt(role ? chainageM : null); + if (role) panel.showAtChainage(chainageM); + else panel.releasePipe(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); @@ -682,7 +691,8 @@ export function wireStructureSelection( markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); - panel.showPipeAt(role ? chainageM : null); + if (role) panel.showAtChainage(chainageM); + else panel.releasePipe(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; diff --git a/resources/tester/helper_b05_b06_selection.cjs b/resources/tester/helper_b05_b06_selection.cjs new file mode 100644 index 00000000..8892c7e2 --- /dev/null +++ b/resources/tester/helper_b05_b06_selection.cjs @@ -0,0 +1,178 @@ +/* 측점 · 구조물 고름 한 곳 단위검증 헬퍼(PLAN 24-2d) — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소와 즉시 실행 프레임만 흉내). + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +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(); + +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; +globalThis.requestAnimationFrame = (fn) => fn(); + +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 sel = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts")); +const pickPage = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structure_Pick.ts")); +const pickSession = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), +); + +const out = { matchM: sel.PIPE_MATCH_M }; +const P = "proj-1"; + +// ── 풀기: 관 → 구조물 순 · 한 거리 ─────────────────────────────────────── +// 옹벽(구간 200~210) · 독립 기슭막이(구간 80~90) · BOX/세월교/배수관은 관 정본 +const structures = [ + { structure_id: "wall", placement: "interval", start_m: 200, end_m: 210, chainage_m: 205 }, + { structure_id: "revet", placement: "interval", start_m: 80, end_m: 90, chainage_m: 85 }, +]; +const pipes = [{ chainage_m: 173.09 }, { chainage_m: 205.2 }, { chainage_m: 300 }]; +const at = (chainageM, structureId = null) => + sel.resolveSelection({ chainageM, structureId }, pipes, structures); +out.card = { + // 횡단 카드 측점이 관 측점에서 0.05 ~ 0.51 m 어긋나도 그 관을 찾는다(옛 B05 폼은 0.05 라 놓쳤다) + pipeNear: at(173.4), + pipeFar: at(173.09 + 0.6), + wallStart: at(200), + wallMiddle: at(207), + wallEdge: at(210.4), + revet: at(85), + none: at(250), + cleared: at(null), + // 같은 측점에 관과 구조물이 겹치면 관이 먼저(횡단도에 그려지는 것) + pipeBeatsStructure: at(205.0), + // 구조물 id 가 주어지고 목록에 있으면 측점보다 id 가 이긴다 · 없는 id 는 측점으로 되돌아간다 + byId: at(300, "revet"), + staleId: at(200, "gone"), +}; + +// ── 세션 값 + 알림 ───────────────────────────────────────────────────── +const heard = []; +const off = sel.subscribeSelection(P, (s, from) => heard.push({ ...s, from })); +sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", from: "b06" }); +out.store1 = sel.readSelection(P); +sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" }); +out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음 +sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" }); +out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙) +sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" }); +out.cleared = sel.readSelection(P); +out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]); +off(); +sel.selectDesign(P, { chainageM: 1, structureId: null }); +out.afterOff = heard.length; +out.noProject = sel.readSelection(null); +// 알림 도중 다시 낸 값은 적기만 한다(되울림 방지) +let echoes = 0; +const off2 = sel.subscribeSelection(P, () => { + echoes += 1; + sel.selectDesign(P, { chainageM: 2, structureId: null }); +}); +sel.selectDesign(P, { chainageM: 3, structureId: null }); +out.echoes = echoes; +out.lastWritten = sel.readSelection(P)?.chainageM; +off2(); +sel.selectDesign(P, sel.NO_SELECTION); + +// ── 넘김값 읽기 ──────────────────────────────────────────────────────── +sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "right" }); +out.handoff = pickSession.readStructurePick(P); +sel.selectDesign(P, sel.NO_SELECTION); +out.handoffNone = pickSession.readStructurePick(P); + +// ── B06 진입 때 카드 찾기 ────────────────────────────────────────────── +const cards = [80, 173.09, 200, 205.2, 220].map((c) => ({ + chainage_m: c, + station_id: `s${c}`, + ford: null, + box: null, +})); +const detail = { cross_sections: cards }; +const run = (handoff, list) => { + const calls = []; + calls.applied = pickPage.applyStructurePick( + handoff, + detail, + { + focusStation: (id) => calls.push(["focus", id]), + refreshCard: (c) => calls.push(["refresh", c]), + revetSelect: (c, k) => calls.push(["revet", c, k]), + fordSelect: (c, k) => calls.push(["ford", c, k]), + boxSelect: (c, k) => calls.push(["box", c, k]), + }, + list, + ); + return calls; +}; +const midWall = { at: 207, structureId: "wall" }; // 구간 한가운데 — 카드 위치가 아님 +out.retry = { + // 구조물 목록이 아직 안 실렸으면 카드를 못 찾고 false → 부르는 쪽이 나중에 다시 부른다 + before: run({ at: 260 }, []).applied, + // 목록이 실린 뒤 같은 넘김값은 카드를 찾는다 + after: run(midWall, structures).applied, + notFound: run({ at: 260 }, structures).applied, + emptyHandoff: run(null, structures).applied, +}; +out.apply = { + pipeOffsetCard: run({ at: 173.4 }, structures), + wallByAnchor: run({ at: 205, structureId: "wall" }, structures), + wallNoId: run({ at: 207 }, structures), + revetCardFirst: run({ at: 85, structureId: "revet", key: "own" }, structures), + unknown: run({ at: 260 }, structures), + none: run(null, structures), +}; + +// ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ───────────── +const restoreRun = (pickValue) => { + sel.selectDesign(P, sel.NO_SELECTION); + if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key }); + const calls = []; + let opened = false; + const section = { + hasSelection: () => opened, + selectAt: (s) => { + calls.push(["selectAt", s.chainageM, s.structureId]); + opened = true; + }, + }; + const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) }; + const done = pickSession.restoreStructurePick(controls, P, section, (c) => + calls.push(["station", c]), + ); + return { calls, done }; +}; +out.restore = { + pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }), + structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }), + nothing: restoreRun(null), +}; +sel.selectDesign(P, sel.NO_SELECTION); + +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py new file mode 100644 index 00000000..71ee5b83 --- /dev/null +++ b/resources/tester/test_b05_b06_selection_single.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""측점 · 구조물 고름 한 곳 회귀검증 — PLAN 24-2d (2026-09-29). + +• 관 찾는 거리 한 값(`PIPE_MATCH_M = 0.51`) — 정의는 한 곳, 옛 0.05 는 없음 +• 횡단 카드 · 종단 알약 · 목록 · 3D 표지 어디서 골라도 같은 순서(관 → 구조물) · 같은 거리로 폼이 열 항목을 풂 +• 세션 값(`structure-pick`) 읽기 · 쓰기 · 알림 — 부재키 지킴 · 같은 값 조용 · 되울림 막음 +• B06 진입 때 옹벽 · 기슭막이 카드를 구조물 id 로 찾음 +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_b05_b06_selection.cjs`). +실제 화면 조작은 ORCA(자체 확인)로 따로 본다. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_b06_selection.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 거리 한 값 ───────────────────────────────────────────────────────────── +def test_거리_값은_0_51_하나(out): + assert out["matchM"] == 0.51 + + +def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다(): + definitions = [] + for folder in ("A00_Common", "B05_Profile", "B06_Section"): + for current, _dirs, names in os.walk(os.path.join(ROOT, folder)): + if "node_modules" in current: + continue + for name in names: + if not name.endswith(".ts"): + continue + text = _read(os.path.relpath(os.path.join(current, name), ROOT)) + if re.search(r"\b(const|let)\s+PIPE_MATCH_M\b", text): + definitions.append(name) + assert definitions == ["design_selection.ts"] + for name, folder in ( + ("B05_Profile_UI_Structures_Panel.ts", "B05_Profile"), + ("B05_Profile_UI_Page.ts", "B05_Profile"), + ("B05_Profile_UI_Selection.ts", "B05_Profile"), + ("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"), + ("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"), + ("B05_Profile_UI_Structure_Pick_Session.ts", "B05_Profile"), + ): + text = _read(folder, name) + assert not re.search(r"<\s*0\.05\b", text), name + assert not re.search(r"[-+]\s*0\.51\b", text), name + + +# ── 카드 누름 → 폼이 열 항목 ──────────────────────────────────────────── +def test_카드_측점이_관에서_0_05보다_어긋나도_관을_찾는다(out): + assert out["card"]["pipeNear"] == {"pipeChainageM": 173.09, "structureId": None} + assert out["card"]["pipeFar"]["pipeChainageM"] is None # 0.51 m 밖은 다른 자리 + + +def test_옹벽_기슭막이_카드는_구조물_폼을_연다(out): + for key in ("wallStart", "wallMiddle", "wallEdge"): + assert out["card"][key] == {"pipeChainageM": None, "structureId": "wall"}, key + assert out["card"]["revet"]["structureId"] == "revet" + + +def test_같은_측점에_관과_구조물이_겹치면_관이_먼저(out): + assert out["card"]["pipeBeatsStructure"]["pipeChainageM"] == 205.2 + + +def test_구조물_id가_있으면_id가_이기고_없는_id는_측점으로_되돌아간다(out): + assert out["card"]["byId"]["structureId"] == "revet" + assert out["card"]["staleId"]["structureId"] == "wall" + + +def test_고를_것이_없으면_해제(out): + assert out["card"]["none"] == {"pipeChainageM": None, "structureId": None} + assert out["card"]["cleared"] == {"pipeChainageM": None, "structureId": None} + + +# ── 세션 값 + 알림 ───────────────────────────────────────────────────── +def test_세션에_적고_읽는다(out): + assert out["store1"] == {"chainageM": 205, "structureId": "wall", "partKey": "own"} + assert out["handoff"] == {"at": 205, "key": "right", "structureId": "wall"} + assert out["handoffNone"] is None + assert out["noProject"] is None + + +def test_부재키를_안_주면_지키고_해제하면_비운다(out): + assert out["keyKept"] == "own" + assert out["cleared"] is None + + +def test_같은_값은_알리지_않고_출처를_함께_알린다(out): + assert out["sameQuiet"] == 1 + assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]] + assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림 + + +def test_알림_도중_다시_낸_값은_적기만_한다(out): + assert out["echoes"] == 1 + assert out["lastWritten"] == 2 + + +# ── B06 진입 때 카드 찾기 ────────────────────────────────────────────── +def test_관_카드는_거리_안에서_바로_찾는다(out): + assert out["apply"]["pipeOffsetCard"] == [["focus", "s173.09"]] + + +def test_옹벽은_구조물_id로_시작_카드를_찾는다(out): + assert out["apply"]["wallByAnchor"] == [["focus", "s200"]] + assert out["apply"]["wallNoId"] == [["focus", "s200"]] + + +def test_기슭막이_부재키는_그_카드의_조정창을_연다(out): + assert out["apply"]["revetCardFirst"] == [ + ["focus", "s80"], + ["revet", 80, "own"], + ["refresh", 80], + ] + + +def test_카드를_못_찾으면_false로_알려_나중에_다시_부르게_한다(out): + assert out["retry"] == { + "before": False, + "after": True, + "notFound": False, + "emptyHandoff": False, + } + + +def test_모르는_자리_또는_값_없음은_아무것도_안_한다(out): + assert out["apply"]["unknown"] == [] + assert out["apply"]["none"] == [] + + +# ── B05 진입 때 되살리기 ─────────────────────────────────────────────── +def test_B05_되살리기_관은_측점만으로_구조물을_짐작하지_않는다(out): + pipe = out["restore"]["pipeOnly"] + assert pipe["calls"] == [["3d", 85.05, None], ["station", 85.05]] + assert pipe["done"] is False # 관 목록이 실리면 다시 불린다 + + +def test_B05_되살리기_구조물_id가_있으면_그_구조물을_연다(out): + structure = out["restore"]["structure"] + assert structure["calls"] == [ + ["3d", 205, "own"], + ["station", 205], + ["selectAt", 205, "wall"], + ] + assert structure["done"] is True + + +def test_B05_되살리기_적힌_것이_없으면_끝난_것(out): + assert out["restore"]["nothing"] == {"calls": [], "done": True} + + +# ── 배선 ─────────────────────────────────────────────────────────────── +def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): + panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + assert "showPipeAt" not in panel # 관 전용 길을 없앰 + assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX + assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움 + assert "section.selectAt(" in panel + page = _read("B06_Section", "B06_Section_UI_Page.ts") + assert "writeStructurePick" not in page + assert "selectDesign(" in page + + +def test_고름은_selectDesign_한_길로_적는다(): + for folder, name in ( + ("B05_Profile", "B05_Profile_UI_Page.ts"), + ("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts"), + ("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts"), + ): + text = _read(folder, name) + assert "writeStructurePick" not in text, name + assert "rememberStructurePick" not in text, name + assert "selectDesign(" in _read("B05_Profile", "B05_Profile_UI_Structure_Pick_Session.ts")