From a2b3217d80b153c1dd62bd32ef91ae443ec4acb7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:03:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(design):=203D=20=C2=B7=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=B9=B4=EB=93=9C=20=C2=B7=20[=EC=83=81=EC=84=B8]?= =?UTF-8?q?=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B3=A0=EB=A6=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=B9=B8=EC=97=90=EC=84=9C=20=ED=8C=8C=EC=83=9D=20(233-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 보기 구독이 3D 측점 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이 칠하기만) - B06_Section_UI_Page_Pick_Sync — 횡단 보기 구독 · 다른 곳이 바꾼 값은 넘김 적용 길로 다시 세움 - 풀기 한 길 — 카드 · 폼 관 · 범위 테두리 · 포장층 · 형상 모두 끔(카드 다시 누름도 이 길) - [상세] 선 조각 — 다시 그려도 같은 자리 조각 고름 유지 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_profile.ts | 46 ++- A00_Common/design/design_view_cross.ts | 32 +- A00_Common/design/design_view_profile.ts | 5 +- B05_Profile/B05_Profile_UI_Markers.ts | 5 +- .../B06_Section_UI_Cross_Detail_Select.ts | 10 +- B06_Section/B06_Section_UI_Page_Pick_Sync.ts | 80 +++++ .../tester/helper_233_1_profile_pick.cjs | 2 +- resources/tester/helper_233_2_view_pick.cjs | 316 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 2 +- resources/tester/test_233_2_view_pick.py | 103 ++++++ 10 files changed, 570 insertions(+), 31 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Page_Pick_Sync.ts create mode 100644 resources/tester/helper_233_2_view_pick.cjs create mode 100644 resources/tester/test_233_2_view_pick.py diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index 48c2bb1bb..d8c3a4d07 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -1,6 +1,7 @@ /* ============================================================================= * A00_Common/design/design_pick_profile.ts - * 종단도 고름 강조(측점 세로선 · 알약 · 구간 띠) = 고름 한 칸에서 파생 (233-1 · 조사 AF (나) ②). + * 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) · + * 3D(측점 띠 · 핀 · 부재 · 233-2) (조사 AF (나) ② ③). * * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 @@ -70,18 +71,53 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): else target.setSelectedStation(pick.stationId); } +export interface ViewerPickTarget { + markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; + structurePick: { + select: (pick: { chainageM: number; kind: string; key?: string } | null) => void; + }; +} + +/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) · + * 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */ +export function viewerPickOf( + selection: (DesignSelection & { partKey?: string }) | null, + source: ProfilePickSource, +): { stationId: string | null; member: { chainageM: number; kind: string; key?: string } | null } { + if (!selection || selection.chainageM === null) return { stationId: null, member: null }; + const at = selection.chainageM; + const pick = profilePickOf(selection, source); + const own = selection.structureId + ? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m) + : undefined; + const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null; + return { stationId, member: { chainageM: at, kind: "", key: selection.partKey } }; +} + +export function paintViewerPick( + target: ViewerPickTarget, + pick: ReturnType, +): void { + target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게 + target.structurePick.select(pick.member); +} + /** - * 종단도를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 같은 값으로 다시 얹음. - * 보기가 숨어 있으면(다른 보기가 종단도를 쥠) 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + * 종단 보기(종단도 · 3D)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 · + * 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) 안 그림 — 그 보기가 + * 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. */ export function bindProfilePick( projectId: string | null, - target: ProfilePickTarget, + target: { profile: ProfilePickTarget; viewer?: ViewerPickTarget }, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, source: ProfilePickSource, ): { paint: () => void } { const paint = (): void => { - if (host.visible()) paintProfilePick(target, profilePickOf(readSelection(projectId), source)); + if (!host.visible()) return; + const selection = readSelection(projectId); + paintProfilePick(target.profile, profilePickOf(selection, source)); + if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source)); }; host.onDispose(subscribeSelection(projectId, paint)); return { paint }; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 985c16f40..77aba5392 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -39,6 +39,7 @@ import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pi import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; +import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { @@ -387,7 +388,7 @@ export async function mountCrossView( : null; if (!target) { structuresPanel.showAtChainage(null); - selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다. + releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2) return; } const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null; @@ -494,23 +495,20 @@ export async function mountCrossView( if (applied) appliedPick = signature; } - /** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */ + // 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2) + const crossPick = bindCrossPick(projectId, host, { + sections: () => sectionDetail?.cross_sections ?? [], + controls: stationControls, + releasePipe: () => structuresPanel.releasePipe(), + view: sectionView, + reapply: () => { + appliedPick = null; + restoreStructurePick(); + }, + }); + /** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */ function releaseCross(): void { - const { revetOffset, ford, box } = stationControls; - const lit = (sectionDetail?.cross_sections ?? []).filter( - (s) => - revetOffset.highlightFor(s) || - ford.selectedFor(s.chainage_m) || - box.selectedFor(s.chainage_m), - ); - for (const s of lit) { - if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); - if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); - if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); - } - structuresPanel.releasePipe(); - sectionView.focusStation(null); - sectionView.refreshCards(lit.map((s) => s.chainage_m)); + crossPick.release(); appliedPick = null; selectDesign(projectId, NO_SELECTION, { from: "b06" }); } diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 05821f65d..1adab405a 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -411,8 +411,8 @@ export async function mountProfileView( }, isRestoring: () => restoring, }); - // 종단도 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1) - const profilePick = bindProfilePick(activeProjectId, profilePanel, host, { + // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) + const profilePick = bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host, { irregular: () => bridge.irregularStations(), pipes: () => lastPipes ?? [], sections: () => currentSectionDetail?.cross_sections ?? [], @@ -573,7 +573,6 @@ export async function mountProfileView( ); return; } - viewer.markers.selectStation(null); profilePick.paint(); }, // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index e0709d413..a32733979 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -494,7 +494,8 @@ export function createRouteMarkers( if (center) selectionPin.position.copy(center); } - function selectStation(stationId: string | null): void { + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(알림 없음 · 233-2). */ + function selectStation(stationId: string | null, notify = true): void { selectedStationId = stationId; stationGroup.children.forEach((object) => { // 측점 띠(stationId 보유 Mesh)만 색을 바꾼다. @@ -503,7 +504,7 @@ export function createRouteMarkers( (object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15); }); syncSelectionPin(); - stationSelectionListener?.(selectedStationId); + if (notify) stationSelectionListener?.(selectedStationId); } return { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index b3e8540ec..2a5aca824 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -143,7 +143,7 @@ export function selectionSource( /* ── 모달 배선 ─────────────────────────────────────────────────────────── */ export interface DetailSelect { - /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(고름 지움). */ + /** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */ redraw: () => void; /** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */ setMode: (kind: "area" | "length" | null) => void; @@ -152,6 +152,10 @@ export interface DetailSelect { detach: () => void; } +/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ +const pieceKey = (piece: SplitPiece | undefined): string => + piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; + export function attachDetailSelect(input: { cad: HTMLElement; design: () => CrossDesign | undefined; @@ -209,10 +213,12 @@ export function attachDetailSelect(input: { return { redraw() { - selected = []; + // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2) + const keep = new Set(selected.map((i) => pieceKey(pieces[i]))); const axes = input.axes(); const layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; + selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); const toPx = axes ? mToPx(axes) : null; hits = toPx ? pieces.map((piece) => { diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts new file mode 100644 index 000000000..48d3cad72 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * B06_Section_UI_Page_Pick_Sync.ts + * 횡단 보기 강조를 고름 한 칸에 묶음 (233-2 · 조사 AF (나) ④). + * + * 고름 한 칸(`structure-pick`)이 유일 원천이다. 횡단 보기가 보일 때 다른 곳(풀기 Esc 등)이 값을 + * 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로 + * 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음. + * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. + * 풀기(카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 관 · 범위 테두리 · + * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). + * ========================================================================== */ + +import { subscribeSelection } from "../A00_Common/design/design_selection"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; + +export interface CrossPickDeps { + sections: () => ReadonlyArray; + controls: Pick; + releasePipe: () => void; + view: { + focusStation: (stationId: string | null) => void; + refreshCards: (chainages: ReadonlyArray) => void; + selectMark: (structureId: string | null, span?: boolean) => void; + }; + /** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */ + reapply: () => void; +} + +let releasing = false; + +/** 횡단 보기 강조를 모두 끔 — 카드 · 벽 · 세월교 · BOX · 폼 관 · 범위 테두리 · 포장층(값은 안 건드림). + * 카드를 풀면 카드 리스너가 다시 풀기를 부름 — 되돌아온 부름은 건너뜀. */ +export function releaseCrossHighlights(deps: CrossPickDeps): void { + if (releasing) return; + releasing = true; + try { + releaseAll(deps); + } finally { + releasing = false; + } +} + +function releaseAll(deps: CrossPickDeps): void { + const { revetOffset, ford, box } = deps.controls; + const lit = deps + .sections() + .filter( + (s) => + revetOffset.highlightFor(s) || + ford.selectedFor(s.chainage_m) || + box.selectedFor(s.chainage_m), + ); + for (const s of lit) { + if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null); + if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); + if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); + } + deps.releasePipe(); + deps.view.focusStation(null); + deps.view.selectMark(null); + deps.view.refreshCards(lit.map((s) => s.chainage_m)); +} + +/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음. + * `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */ +export function bindCrossPick( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, + deps: CrossPickDeps, +): { release: () => void } { + host.onDispose( + subscribeSelection(projectId, (selection, from) => { + if (!host.visible() || from === "b06") return; + if (selection.chainageM === null) releaseCrossHighlights(deps); + else deps.reapply(); + }), + ); + return { release: () => releaseCrossHighlights(deps) }; +} diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index a328856e2..2d690aab7 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -67,7 +67,7 @@ let visible = true; const disposers = []; const pick = PP.bindProfilePick( PID, - target, + { profile: target }, { visible: () => visible, onDispose: (fn) => disposers.push(fn) }, { irregular: () => irregular, pipes: () => pipes, sections: () => sections }, ); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs new file mode 100644 index 000000000..95c32d464 --- /dev/null +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -0,0 +1,316 @@ +/* 233-2 3D · 횡단 카드 · [상세] 강조 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림. + * ① 3D: `bindProfilePick`(종단 보기 구독) + 진짜 마커(MK3) · 3D 부재 고름(SPK) · three. + * ② 횡단: `bindCrossPick` · `releaseCrossHighlights`(B06_Section_UI_Page_Pick_Sync) + 카드 · 형상 흉내. + * ③ [상세]: `attachDetailSelect` 다시 그림(창 크기 바뀜) 뒤 고른 선 조각 유지. + * 표준출력 JSON 한 벌 · 짝 = test_233_2_view_pick.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 가짜 DOM(라벨 캔버스 · [상세] 값 칸 · 조각) ── */ +const node = (tag) => { + const self = { + tag, + hidden: false, + textContent: "", + attrs: {}, + classes: new Set(), + parentElement: null, + setAttribute: (k, v) => { + self.attrs[k] = String(v); + if (k === "class") + String(v) + .split(" ") + .forEach((c) => self.classes.add(c)); + }, + getAttribute: (k) => self.attrs[k] ?? null, + addEventListener() {}, + classList: { + toggle: (c, on) => (on ? self.classes.add(c) : self.classes.delete(c)), + contains: (c) => self.classes.has(c), + }, + closest: (sel) => (self.classes.has(sel.slice(1)) ? self : null), + getContext: () => null, + }; + return self; +}; +globalThis.document = { + createElement: node, + createElementNS: (_ns, tag) => node(tag), + addEventListener() {}, + removeEventListener() {}, +}; +globalThis.window = { addEventListener() {}, removeEventListener() {} }; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { inferStationInterval: () => 20, stationLabel: (c) => `0+${c}` }, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, +}); +const CROSS_PAD = Function( + `return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`, +)(); +// [상세] 조각 모듈은 helper_209_1 과 같은 꼴로 딸린 모듈까지 진짜로 읽음(카드 공용만 흉내) +const DEEP_STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolveTs = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadDeep(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (DEEP_STUBS[request]) return DEEP_STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadDeep(resolveTs(path.join(ROOT, "common_util", request.slice(6)))); + return loadDeep(resolveTs(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const DTS = loadDeep(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Select.ts")); + +const out = {}; +const PID = "p233b"; +const sel = (chainageM, structureId = null, options = {}) => + DSel.selectDesign( + PID, + chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, + options, + ); + +/* ── ① 3D ── */ +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [ + ...[100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at, center_x: at })), + { station_id: "irregular:sign", chainage_m: 127.86, center_x: 127.86 }, +].map((s) => ({ ...s, center_y: 100, center_z: 5, frame: { left_xy: [0, 1] }, kind: "regular" })); +markers.renderStationLines(STATIONS, 3); +let notified = 0; +markers.onStationSelectionChange(() => (notified += 1)); +const corridor = new THREE.Group(); +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +const wall = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +wall.userData = { chainageM: 120, kind: "wall", key: "own" }; +corridor.add(member, wall); +const structurePick = SPK.bindStructurePick({ + canvas: { addEventListener() {}, removeEventListener() {} }, + camera: () => null, + group: () => corridor, + blocked: () => false, +}); +const profile = { station: null, structure: null }; +let b05Visible = true; +const disposers = []; +PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + }, + viewer: { markers, structurePick }, + }, + { visible: () => b05Visible, onDispose: (fn) => disposers.push(fn) }, + { + irregular: () => [{ id: "sign", chainage_m: 127.86, origin: "user" }], + pipes: () => [], + sections: () => STATIONS.filter((s) => s.station_id.startsWith("s")), + }, +); +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const lit = (mesh) => mesh.material.emissive.getHex() !== 0; +const view3d = () => ({ + red: STATIONS.map((s) => s.station_id).filter( + (id) => bar(id).material.color.getHexString() === "ef4444", + ), + pin: pin().visible, + member: lit(member), + wall: lit(wall), + profile: { ...profile }, +}); +sel(120, null, { from: "b06" }); // 횡단 카드 120 고름 → 종단 보기도 값을 받음 +out.card120 = view3d(); +sel(160, null, { partKey: "inlet", from: "b06" }); +out.part160 = view3d(); +sel(127.86, "sign", { from: "b05" }); // 목록 표지 — 그 자리 구조물 측점 띠 +out.sign = view3d(); +sel(null); +out.released3d = view3d(); +out.notified = notified; // 칠하기만 — 마커 알림으로 사슬이 다시 돌지 않음 +disposers.forEach((fn) => fn()); + +/* ── ② 횡단 카드 · 범위 · 형상 ── */ +const calls = []; +const sections = [100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })); +const revetLit = new Set([120]); +let cardSelected = "s120"; +let crossVisible = true; +let releaseCross; +const deps = { + sections: () => sections, + controls: { + revetOffset: { + highlightFor: (s) => revetLit.has(s.chainage_m), + selectedFor: (s) => revetLit.has(s.chainage_m), + select: (at) => revetLit.delete(at), + }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releasePipe: () => calls.push("releasePipe"), + view: { + focusStation: (id) => { + calls.push(`focus:${id}`); + if (id === null && cardSelected !== null) { + cardSelected = null; + releaseCross(); // 카드 리스너(null) → 풀기 한 길 다시 부름 — 되돌아온 부름은 건너뜀 + } + }, + refreshCards: (list) => calls.push(`refresh:${list.join(",")}`), + selectMark: (id) => calls.push(`mark:${id}`), + }, + reapply: () => calls.push("reapply"), +}; +const crossDisposers = []; +const crossPick = SYNC.bindCrossPick( + PID, + { visible: () => crossVisible, onDispose: (fn) => crossDisposers.push(fn) }, + deps, +); +releaseCross = () => { + crossPick.release(); + sel(null, null, { from: "b06" }); +}; +sel(120, null, { from: "b06" }); +out.ownPick = calls.splice(0); +sel(140, null, { from: "esc" }); +out.otherPick = calls.splice(0); +releaseCross(); // 카드 다시 누름 · 목록 다시 누름 · 보기 오감 +out.release = calls.splice(0); +out.revetAfter = [...revetLit]; +revetLit.add(100); +cardSelected = "s100"; +sel(null, null, { from: "esc" }); // 다른 곳 풀기(Esc) +out.otherRelease = calls.splice(0); +crossVisible = false; +sel(100, null, { from: "esc" }); +out.hidden = calls.splice(0); +crossDisposers.forEach((fn) => fn()); + +/* ── ③ [상세] 선 조각 — 다시 그린 뒤 고름 유지 ── */ +let axes = { + maxOffset: 10, + pixelsPerMeter: 10, + displayMax: 110, + elevationMid: 100, + exaggeration: 1, +}; +const groundM = [ + [ + [-5, 100], + [0, 101], + ], + [ + [0, 101], + [5, 99], + ], +]; +const toPx = ([o, e]) => + [ + CROSS_PAD.left + (axes.maxOffset - o) * axes.pixelsPerMeter, + CROSS_PAD.top + (axes.displayMax - e) * axes.pixelsPerMeter, + ].join(","); +const layerChildren = []; +const layer = { + querySelectorAll: () => [...layerChildren], + append: (hit) => layerChildren.push(hit), +}; +const drawGround = () => { + layerChildren.length = 0; + for (const pts of groundM) { + const el = node("polyline"); + el.tagName = "polyline"; + el.setAttribute("class", "b06-chart__cross-profile"); + el.setAttribute("points", pts.map(toPx).join(" ")); + el.hasAttribute = () => false; + layerChildren.push(el); + } +}; +let click = null; +const cad = { + addEventListener: (_type, fn) => (click = fn), + querySelector: (s) => (s === "g.b06-chart__plot-layer" ? layer : null), +}; +const select = DTS.attachDetailSelect({ + cad, + design: () => undefined, + axes: () => axes, + picked() {}, + commit() {}, +}); +drawGround(); +select.redraw(); +const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS)); +click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} }); +out.detailPicked = select.source("length")?.pieces.length ?? 0; +axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 +drawGround(); +select.redraw(); +out.detailAfterRedraw = select.source("length")?.pieces.length ?? 0; +groundM.splice(0, 1, [ + [-6, 100], + [0, 102], +]); // 그 조각 자리가 바뀌면 고름도 빠짐 +drawGround(); +select.redraw(); +out.detailAfterChange = select.source("length")?.pieces.length ?? 0; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py index 3801a57e6..6e48e9181 100644 --- a/resources/tester/test_233_1_profile_pick.py +++ b/resources/tester/test_233_1_profile_pick.py @@ -86,7 +86,7 @@ def test_풂과_구독_끊음(out) -> None: def test_종단_보기_배선() -> None: view = _read("A00_Common", "design", "design_view_profile.ts") - assert "bindProfilePick(activeProjectId, profilePanel, host," in view + assert "bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host," in view # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view assert view.count("profilePick.paint()") >= 3 diff --git a/resources/tester/test_233_2_view_pick.py b/resources/tester/test_233_2_view_pick.py new file mode 100644 index 000000000..134687edd --- /dev/null +++ b/resources/tester/test_233_2_view_pick.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""233-2 3D(띠 · 핀 · 부재) · 횡단 카드(고름 · --span · 형상 · 포장층) · [상세] 선 조각 = 고름 한 칸에서 파생. + +까닭: 3D 는 화면끼리 손으로 부르는 사슬로만 칠해져 길마다 빠짐(구조물 고름 뒤 옛 띠 남음 · 횡단 카드 +고름은 부재를 안 켬) · 횡단 카드를 풀어도 `--span` · 포장층이 남음(a4) · 풀기 길마다 끄는 범위가 다름 · +[상세] 선 조각은 다시 그릴 때(창 크기 · 카드 다시 세움) 고름을 지워 바로 꺼짐. +고침: 종단 보기 구독이 3D 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이) · 횡단 보기 구독(`bindCrossPick`)은 +다른 곳이 바꾼 값을 넘김 적용 길로 다시 세우고 · 풀기는 한 길(`releaseCrossHighlights` — 카드 · 폼 관 · +범위 테두리 · 포장층 · 형상) · [상세] 다시 그림은 같은 자리 조각 고름 유지. +진짜 TS 를 `helper_233_2_view_pick.cjs` 가 Node(three 포함)로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +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() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_2_view_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_3D_횡단_카드_고름은_띠_핀_그_측점_부재(out) -> None: + card = out["card120"] + assert (card["red"], card["pin"], card["wall"], card["member"]) == (["s120"], True, True, False) + assert card["profile"]["station"] == "s120" + + +def test_3D_부재키_고름은_그_부재만_띠는_없음(out) -> None: + part = out["part160"] + assert (part["red"], part["pin"], part["member"], part["wall"]) == ([], False, True, False) + + +def test_3D_구조물_고름은_그_자리_구조물_측점_띠(out) -> None: + assert out["sign"]["red"] == ["irregular:sign"] + assert out["sign"]["profile"]["structure"] == "sign" + + +def test_3D_풂과_알림_없음(out) -> None: + released = out["released3d"] + assert (released["red"], released["pin"], released["member"], released["wall"]) == ( + [], + False, + False, + False, + ) + assert out["notified"] == 0 # 칠하기만 — 마커 알림이 사슬을 다시 돌리지 않음 + + +def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(out) -> None: + assert out["ownPick"] == [] + assert out["otherPick"] == ["reapply"] + assert out["hidden"] == [] # 숨은 보기는 안 받음 + + +def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None: + for key in ("release", "otherRelease"): + assert out[key] == ["releasePipe", "focus:null", "mark:null", out[key][-1]] + assert out["release"][-1] == "refresh:120" + assert out["revetAfter"] == [] + + +def test_상세_다시_그려도_같은_자리_조각_고름_유지(out) -> None: + assert out["detailPicked"] == 1 + assert out["detailAfterRedraw"] == 1 + assert out["detailAfterChange"] == 0 + + +def test_배선() -> None: + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "bindCrossPick(projectId, host," in cross + assert "crossPick.release()" in cross + # 카드 다시 누름 = 풀기 한 길 + assert "releaseCross(); // 카드 다시 누름" in cross + assert len(cross.splitlines()) <= 685 + profile = _read("A00_Common", "design", "design_view_profile.ts") + assert "{ profile: profilePanel, viewer }" in profile + assert len(profile.splitlines()) <= 700 From b3c17622293ab3edcbfedc26a75888fc8c29a85a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 19:13:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(design):=20=EB=AA=A9=EB=A1=9D=20=C2=B7?= =?UTF-8?q?=20=ED=8F=BC=20=C2=B7=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?=EB=8F=84=20=EA=B0=95=EC=A1=B0=EB=A5=BC=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B8=EC=97=90=EC=84=9C=20=C2=B7=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=3D=20Esc=20=ED=95=9C=20=EA=B8=B8=20(233-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 보기 구독이 배수유역도 관 마커 · 유역 · 마름모까지 알림 없이 칠함 · 재계산 뒤 다시 얹음 - 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 - 목록 관 줄 폭 PIPE_MATCH_M · 조금 옮겨진 관도 고름 따라감 · 저장 뒤 목록 주입이 관 폼 안 닫음 - design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/design/design_pick_escape.ts | 31 ++++ A00_Common/design/design_pick_profile.ts | 40 +++- A00_Common/design/design_view_cross.ts | 2 +- A00_Common/design/design_view_profile.ts | 68 +++---- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 10 +- .../B05_Profile_UI_Drainage_Panel_Types.ts | 3 +- B05_Profile/B05_Profile_UI_Panel.ts | 3 + B05_Profile/B05_Profile_UI_Structures_List.ts | 4 +- .../B05_Profile_UI_Structures_Panel.ts | 18 +- B06_Section/B06_Section_UI_Page_Pick_Sync.ts | 9 +- .../tester/helper_233_1_profile_pick.cjs | 3 + resources/tester/helper_233_2_view_pick.cjs | 5 +- resources/tester/helper_233_3_release.cjs | 171 ++++++++++++++++++ resources/tester/test_233_1_profile_pick.py | 4 +- resources/tester/test_233_2_view_pick.py | 4 +- resources/tester/test_233_3_release.py | 108 +++++++++++ 16 files changed, 410 insertions(+), 73 deletions(-) create mode 100644 A00_Common/design/design_pick_escape.ts create mode 100644 resources/tester/helper_233_3_release.cjs create mode 100644 resources/tester/test_233_3_release.py diff --git a/A00_Common/design/design_pick_escape.ts b/A00_Common/design/design_pick_escape.ts new file mode 100644 index 000000000..691c328e5 --- /dev/null +++ b/A00_Common/design/design_pick_escape.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * A00_Common/design/design_pick_escape.ts + * 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)). + * + * 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 · + * 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저 + * 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음. + * ========================================================================== */ + +import { NO_SELECTION, selectDesign } from "./design_selection"; + +/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */ +export const ESCAPE_FROM = "esc"; + +const OWN_ESCAPE = + "input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop"; + +/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */ +export function bindEscapeRelease( + projectId: string | null, + host: { visible: () => boolean; onDispose: (fn: () => void) => void }, +): void { + const onKey = (event: KeyboardEvent): void => { + if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return; + const target = event.target as HTMLElement | null; + if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return; + selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM }); + }; + window.addEventListener("keydown", onKey); + host.onDispose(() => window.removeEventListener("keydown", onKey)); +} diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index d8c3a4d07..13c71b83e 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -1,7 +1,7 @@ /* ============================================================================= * A00_Common/design/design_pick_profile.ts * 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) · - * 3D(측점 띠 · 핀 · 부재 · 233-2) (조사 AF (나) ② ③). + * 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤). * * 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고, * 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이 @@ -15,6 +15,7 @@ import { subscribeSelection, type DesignSelection, } from "./design_selection"; +import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape"; import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -33,6 +34,11 @@ export interface ProfilePickSource { export interface ProfilePickTarget { setSelectedStation: (stationId: string | null) => void; setSelectedStructure: (structureId: string | null) => void; + /** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */ + drainage?: { + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; + markStation: (chainageM: number | null) => void; + } | null; } /** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */ @@ -71,6 +77,16 @@ export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): else target.setSelectedStation(pick.stationId); } +/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */ +export function paintMapPick( + map: NonNullable, + selection: DesignSelection | null, +): void { + const at = selection?.chainageM ?? null; + map.selectPipeAtChainage(at, false); + map.markStation(at); +} + export interface ViewerPickTarget { markers: { selectStation: (stationId: string | null, notify?: boolean) => void }; structurePick: { @@ -103,13 +119,17 @@ export function paintViewerPick( } /** - * 종단 보기(종단도 · 3D)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 바뀐 뒤 · - * 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) 안 그림 — 그 보기가 - * 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. + * 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이 + * 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠) + * 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음. */ export function bindProfilePick( projectId: string | null, - target: { profile: ProfilePickTarget; viewer?: ViewerPickTarget }, + target: { + profile: ProfilePickTarget; + viewer?: ViewerPickTarget; + form?: { hasSelection: () => boolean; selectById: (id: null) => void }; + }, host: { visible: () => boolean; onDispose: (fn: () => void) => void }, source: ProfilePickSource, ): { paint: () => void } { @@ -118,7 +138,15 @@ export function bindProfilePick( const selection = readSelection(projectId); paintProfilePick(target.profile, profilePickOf(selection, source)); if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source)); + if (target.profile.drainage) paintMapPick(target.profile.drainage, selection); }; - host.onDispose(subscribeSelection(projectId, paint)); + host.onDispose( + subscribeSelection(projectId, (_selection, from) => { + paint(); + if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection()) + target.form.selectById(null); + }), + ); + bindEscapeRelease(projectId, host); return { paint }; } diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 77aba5392..1f4239ca2 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -499,7 +499,7 @@ export async function mountCrossView( const crossPick = bindCrossPick(projectId, host, { sections: () => sectionDetail?.cross_sections ?? [], controls: stationControls, - releasePipe: () => structuresPanel.releasePipe(), + releaseForm: () => structuresPanel.showAtChainage(null), view: sectionView, reapply: () => { appliedPick = null; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 1adab405a..2c2113c78 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,13 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { - coversAtStation, - NO_SELECTION, - nearestWithin, - readSelection, - selectDesign, -} from "./design_selection"; +import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindProfilePick } from "./design_pick_profile"; import { @@ -174,8 +168,8 @@ export async function mountProfileView( panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다. - onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), - onPipeSelected: (chainageM) => selectStationOfPipe(chainageM), + onBasinSelected: (chainageM) => pickFromMap(chainageM), + onPipeSelected: (chainageM) => pickFromMap(chainageM), // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { @@ -221,7 +215,7 @@ export async function mountProfileView( * 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */ const uphillOverrides = loadUphillOverrides(activeProjectId); - const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({ + const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({ stations: () => bridge.irregularStations(), isSyncing: () => selectionSyncing, setSyncing: (value) => { @@ -245,6 +239,12 @@ export async function mountProfileView( remember: (chainageM) => rememberPick(chainageM, null), }); + /** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */ + const pickFromMap = (chainageM: number | null): void => { + if (!selectionSyncing) rememberPick(chainageM, null); + selectStationOfPipe(chainageM); + }; + /** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다 * (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */ function restorePick(): void { @@ -320,39 +320,18 @@ export async function mountProfileView( forwardPipeOptions(shared, from, attributes.options as Record); }, onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage), + // 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3) onPipeFacilitySelect: (chainage) => { - rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다. - if (selectionSyncing) return; - selectionSyncing = true; - try { - // null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18). - const station = - chainage === null - ? undefined - : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); - const id = station ? irregularStationId(station.id) : null; - viewer.markers.selectStation(id); - selectGraphAt(id, chainage); - syncBasinHighlight(id); - // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). - profilePanel.drainage.selectPipeAtChainage(chainage); - viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) - } finally { - selectionSyncing = false; - } + rememberPick(chainage, null); + if (!selectionSyncing) profilePick.paint(); }, + onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3) onStructureSelect: (structure) => { // 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을 // 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문). if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null); - if (selectionSyncing) return; - selectionSyncing = true; - try { - profilePick.paint(); // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단은 고름 한 칸대로(233-1) - viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); - } finally { - selectionSyncing = false; - } + // 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2) + if (!selectionSyncing) profilePick.paint(); }, }, shared, @@ -412,11 +391,16 @@ export async function mountProfileView( isRestoring: () => restoring, }); // 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2) - const profilePick = bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host, { - irregular: () => bridge.irregularStations(), - pipes: () => lastPipes ?? [], - sections: () => currentSectionDetail?.cross_sections ?? [], - }); + const profilePick = bindProfilePick( + activeProjectId, + { profile: profilePanel, viewer, form: panel.structures }, + host, + { + irregular: () => bridge.irregularStations(), + pipes: () => lastPipes ?? [], + sections: () => currentSectionDetail?.cross_sections ?? [], + }, + ); // 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19). bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1deeea3c5..251c0233f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -287,18 +287,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); /** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */ - function syncPipeSelection(): void { + function syncPipeSelection(notify = true): void { const index = pipeEditor.selected(); deleteButton.disabled = index === null; const picked = index === null ? null : pipeEditor.pipes()[index]; - selectBasin(basinIndexOfPipe(basins, picked ?? null)); + selectBasin(basinIndexOfPipe(basins, picked ?? null), notify); // 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은 // Page의 selectionSyncing 가드가 끊는다). 값이 안 바뀌면 조용히 — 재계산(apply) // 뒤의 재호출이 다른 화면 선택을 풀어 버리지 않게. const pickedChainage = picked ? picked.chainage_m : null; if (pickedChainage !== lastNotifiedPipeChainage) { lastNotifiedPipeChainage = pickedChainage; - callbacks.onPipeSelected?.(pickedChainage); + if (notify) callbacks.onPipeSelected?.(pickedChainage); } } @@ -639,7 +639,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.select(index); pipeEditor.deleteSelected(); }, - selectPipeAtChainage(chainageM) { + selectPipeAtChainage(chainageM, notify = true) { const index = chainageM === null ? null @@ -648,7 +648,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (pipeEditor.selected() === next) return; // select()는 onChange를 울리지 않는다 — 폼·유역·재그리기를 직접 맞춘다. pipeEditor.select(next); - syncPipeSelection(); + syncPipeSelection(notify); scheduleDraw(); }, async savePipes() { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 140f8d9e8..7ed1fe8b7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -45,7 +45,8 @@ export interface DrainagePanel { selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. * 이미 같으면 무시(2026-08-17 전역 선택 동기화). */ - selectPipeAtChainage: (chainageM: number | null) => void; + /** notify 거짓 = 고름 한 칸에서 파생해 칠하기만(밖으로 안 알림 · 재계산 뒤 다시 얹음 · 233-3). */ + selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void; /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ markStation: (chainageM: number | null) => void; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index ca7b2e808..8f748552f 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -69,6 +69,8 @@ interface PanelCallbacks { onPipeFacilityRemove: (chainageM: number) => void; /** null = 사이드 목록 재클릭 해제 — 전 화면 선택도 함께 푼다(2026-08-18). */ onPipeFacilitySelect: (chainageM: number | null) => void; + /** 목록 다시 누름 · 새로 놓기 = 풀기 한 길(233-3). */ + onListRelease?: () => void; /** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */ onStationDisplayChange: (offset: { station: number; cumulative: number }) => void; } @@ -104,6 +106,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh onPipeUpdate: callbacks.onPipeFacilityUpdate, onPipeRemove: callbacks.onPipeFacilityRemove, onPipeSelect: callbacks.onPipeFacilitySelect, + onListRelease: callbacks.onListRelease, }, }); diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index f50e8df9a..5151d8e16 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -23,6 +23,7 @@ import { type SummaryItem, } from "../common_util/common_util_summary_item"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import { containerTitle, @@ -229,7 +230,8 @@ export function renderStructureList(params: StructureListParams): void { } else { const { pipe } = row; const picked = - selectedPipeChainage !== null && Math.abs(selectedPipeChainage - pipe.chainage_m) < 0.05; + selectedPipeChainage !== null && + Math.abs(selectedPipeChainage - pipe.chainage_m) < PIPE_MATCH_M; // 고름 찾는 거리 한 값(233-3) item.classList.toggle("is-selected", picked); station.textContent = formatStation(pipe.chainage_m, intervalM); item.addEventListener("click", () => params.onSelectPipe(pipe)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 526d27113..29cf68046 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -560,10 +560,10 @@ export function createStructuresSection( // 이 배열을 참조로 받아 두므로, 새 배열로 바꾸면 그쪽이 옛 배열을 고쳐 // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); - // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + // 실시간 반영 목록이면 폼 안 닫음(고치던 칸) · [저장] 뒤 같은 id · 관 폼은 그대로(53-2 · 233-3) const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; - if (!applyingLive) loadForm(editingId === null ? null : kept); + if (!applyingLive && selectedPipeChainage === null) + loadForm(editingId === null ? null : kept); renderList(); }, getStructures: () => [...structures], @@ -587,12 +587,12 @@ export function createStructuresSection( syncButtons(); } } - if ( - selectedPipeChainage !== null && - !pipeFacilities.some( - (pipe) => Math.abs(pipe.chainage_m - selectedPipeChainage!) < PIPE_MATCH_M, - ) - ) { + 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); diff --git a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts index 48d3cad72..239649672 100644 --- a/B06_Section/B06_Section_UI_Page_Pick_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Pick_Sync.ts @@ -6,18 +6,20 @@ * 바꾸면 구독으로 받아 카드 · 범위 테두리(`--span`) · 포장층 · 벽 · 세월교 · BOX 형상 강조를 그 값으로 * 다시 세운다 — 값이 비면 모두 풂, 값이 있으면 넘김 적용(`applyStructurePick`)과 같은 길로 다시 얹음. * 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음. - * 풀기(카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 관 · 범위 테두리 · + * 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 · * 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4). * ========================================================================== */ import { subscribeSelection } from "../A00_Common/design/design_selection"; +import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; export interface CrossPickDeps { sections: () => ReadonlyArray; controls: Pick; - releasePipe: () => void; + /** 좌측 폼(구조물 · 관) 닫기. */ + releaseForm: () => void; view: { focusStation: (stationId: string | null) => void; refreshCards: (chainages: ReadonlyArray) => void; @@ -56,7 +58,7 @@ function releaseAll(deps: CrossPickDeps): void { if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null); if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null); } - deps.releasePipe(); + deps.releaseForm(); deps.view.focusStation(null); deps.view.selectMark(null); deps.view.refreshCards(lit.map((s) => s.chainage_m)); @@ -76,5 +78,6 @@ export function bindCrossPick( else deps.reapply(); }), ); + bindEscapeRelease(projectId, host); // 풀기 한 길(233-3) return { release: () => releaseCrossHighlights(deps) }; } diff --git a/resources/tester/helper_233_1_profile_pick.cjs b/resources/tester/helper_233_1_profile_pick.cjs index 2d690aab7..22811c127 100644 --- a/resources/tester/helper_233_1_profile_pick.cjs +++ b/resources/tester/helper_233_1_profile_pick.cjs @@ -27,6 +27,8 @@ const DSel = loadTs("A00_Common/design/design_selection.ts", { writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), }, }); +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: (s) => s.origin === "pipe", @@ -37,6 +39,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, }); diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index 95c32d464..0158756a5 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -67,6 +67,7 @@ const DSel = loadTs("A00_Common/design/design_selection.ts", { writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), }, }); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: (s) => s.origin === "pipe", @@ -77,6 +78,7 @@ const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { }); const PP = loadTs("A00_Common/design/design_pick_profile.ts", { "./design_selection": DSel, + "./design_pick_escape": ESC, "../../B05_Profile/B05_Profile_UI_Selection": SY, "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, }); @@ -86,6 +88,7 @@ const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, }); const CROSS_PAD = Function( `return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(fs.readFileSync(path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"), "utf8"))[1]}`, @@ -205,7 +208,7 @@ const deps = { ford: { selectedFor: () => false, select() {} }, box: { selectedFor: () => false, select() {} }, }, - releasePipe: () => calls.push("releasePipe"), + releaseForm: () => calls.push("releaseForm"), view: { focusStation: (id) => { calls.push(`focus:${id}`); diff --git a/resources/tester/helper_233_3_release.cjs b/resources/tester/helper_233_3_release.cjs new file mode 100644 index 000000000..ff6114e70 --- /dev/null +++ b/resources/tester/helper_233_3_release.cjs @@ -0,0 +1,171 @@ +/* 233-3 풀기 한 길(Esc) · 배수유역도 다시 얹음 — 진짜 `design_pick_escape` · `design_pick_profile` · + * `B06_Section_UI_Page_Pick_Sync` · `design_selection` 을 Node 로 돌림. 창 키 · 폼 · 지도 · 카드는 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_233_3_release.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* 창 키 듣기 · 창(모달) 흉내 */ +const keyListeners = new Set(); +globalThis.window = { + addEventListener: (type, fn) => type === "keydown" && keyListeners.add(fn), + removeEventListener: (type, fn) => keyListeners.delete(fn), +}; +let modalOpen = false; +globalThis.document = { querySelector: () => (modalOpen ? {} : null) }; +const press = (key, { inInput = false, prevented = false } = {}) => { + const event = { + key, + defaultPrevented: prevented, + target: { closest: () => (inInput ? {} : null) }, + }; + for (const fn of [...keyListeners]) fn(event); +}; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +const IRR = { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", +}; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "./design_pick_escape": ESC, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); +const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", { + "../A00_Common/design/design_selection": DSel, + "../A00_Common/design/design_pick_escape": ESC, +}); + +const PID = "p233c"; +const sel = (chainageM, structureId = null, from = "b05") => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, { + from, + }); + +/* ── 종단 보기(B05) — 종단도 · 배수유역도 · 좌측 폼 흉내 ── */ +const map = { pipe: null, mark: null, notifies: 0 }; +const profile = { station: null, structure: null }; +const form = { open: "wall", resets: 0 }; +let b05Visible = true; +const b05 = PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: (id) => Object.assign(profile, { station: id, structure: null }), + setSelectedStructure: (id) => Object.assign(profile, { station: null, structure: id }), + drainage: { + // 진짜 판 꼴 — 0.51 m 안 관을 고름 · notify 거짓이면 밖으로 안 알림 + selectPipeAtChainage: (at, notify = true) => { + map.pipe = at === null ? null : Math.abs(at - 80.17) < 0.51 ? 80.17 : null; + if (notify) map.notifies += 1; + }, + markStation: (at) => (map.mark = at), + }, + }, + form: { + hasSelection: () => form.open !== null, + selectById: () => { + form.open = null; + form.resets += 1; + }, + }, + }, + { visible: () => b05Visible, onDispose() {} }, + { + irregular: () => [{ id: "p80", chainage_m: 80.17, origin: "pipe" }], + pipes: () => [{ chainage_m: 80.17 }], + sections: () => [80, 100, 120].map((at) => ({ station_id: `s${at}`, chainage_m: at })), + }, +); + +/* ── 횡단 보기(B06) — 카드 · 형상 · 폼 흉내 ── */ +const cross = []; +let b06Visible = false; +SYNC.bindCrossPick( + PID, + { visible: () => b06Visible, onDispose() {} }, + { + sections: () => [], + controls: { + revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} }, + ford: { selectedFor: () => false, select() {} }, + box: { selectedFor: () => false, select() {} }, + }, + releaseForm: () => cross.push("releaseForm"), + view: { + focusStation: (id) => cross.push(`focus:${id}`), + refreshCards: () => undefined, + selectMark: (id) => cross.push(`mark:${id}`), + }, + reapply: () => cross.push("reapply"), + }, +); + +const snap = () => ({ + session: DSel.readSelection(PID), + profile: { ...profile }, + map: { ...map }, + form: { ...form }, +}); +const out = {}; +sel(80.17); +out.pipePick = snap(); +// 배수유역도 재계산이 관 · 유역 고름을 지움 → 같은 값으로 다시 얹음(알림 없이) +map.pipe = null; +map.mark = null; +b05.paint(); +out.afterRecompute = snap(); +press("Escape", { inInput: true }); +out.escInInput = snap(); +modalOpen = true; +press("Escape"); +modalOpen = false; +out.escModal = snap(); +press("Escape", { prevented: true }); +out.escPrevented = snap(); +press("Escape"); +out.esc = snap(); +out.crossWhileHidden = cross.splice(0); +// 목록 다시 누름 같은 B05 풀기(값 비움)는 폼을 그 길이 이미 닫음 — 구독은 폼을 또 닫지 않음 +form.open = "sign"; +sel(100, "sign"); +sel(null); +out.listRelease = snap(); +// 횡단 보기가 보일 때 Esc = 카드 · 폼 · 범위 · 형상 모두 풂 +b05Visible = false; +b06Visible = true; +sel(120, null, "b06"); +press("Escape"); +out.crossEsc = cross.splice(0); +out.crossSession = DSel.readSelection(PID); +out.listeners = keyListeners.size; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_1_profile_pick.py b/resources/tester/test_233_1_profile_pick.py index 6e48e9181..7ce7a537e 100644 --- a/resources/tester/test_233_1_profile_pick.py +++ b/resources/tester/test_233_1_profile_pick.py @@ -80,13 +80,13 @@ def test_숨은_보기는_안_그리고_보이면_다시_얹음(out) -> None: def test_풂과_구독_끊음(out) -> None: assert out["released"] == line(None) - assert out["disposers"] == 1 + assert out["disposers"] == 2 # 구독 + Esc 듣기(233-3) assert out["paintsAfterDispose"] == 0 def test_종단_보기_배선() -> None: view = _read("A00_Common", "design", "design_view_profile.ts") - assert "bindProfilePick(activeProjectId, { profile: profilePanel, viewer }, host," in view + assert "bindProfilePick(" in view and "{ profile: profilePanel, viewer," in view # 폼이 닫히는 길(저장 뒤 · 관 폼으로)은 종단을 끄지 않고 값대로 다시 얹음 assert "profilePanel.setSelectedStructure(structure?.structure_id ?? null)" not in view assert view.count("profilePick.paint()") >= 3 diff --git a/resources/tester/test_233_2_view_pick.py b/resources/tester/test_233_2_view_pick.py index 134687edd..241f5a561 100644 --- a/resources/tester/test_233_2_view_pick.py +++ b/resources/tester/test_233_2_view_pick.py @@ -80,7 +80,7 @@ def test_횡단_제_누름은_되받지_않고_다른_곳_값은_다시_세움(o def test_횡단_풀기_한_길은_범위_테두리_형상까지(out) -> None: for key in ("release", "otherRelease"): - assert out[key] == ["releasePipe", "focus:null", "mark:null", out[key][-1]] + assert out[key] == ["releaseForm", "focus:null", "mark:null", out[key][-1]] assert out["release"][-1] == "refresh:120" assert out["revetAfter"] == [] @@ -99,5 +99,5 @@ def test_배선() -> None: assert "releaseCross(); // 카드 다시 누름" in cross assert len(cross.splitlines()) <= 685 profile = _read("A00_Common", "design", "design_view_profile.ts") - assert "{ profile: profilePanel, viewer }" in profile + assert "{ profile: profilePanel, viewer," in profile assert len(profile.splitlines()) <= 700 diff --git a/resources/tester/test_233_3_release.py b/resources/tester/test_233_3_release.py new file mode 100644 index 000000000..246703daf --- /dev/null +++ b/resources/tester/test_233_3_release.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""233-3 좌측 목록 · 폼 · 배수유역도 · 남은 바로 꺼짐 자리 · 풀기 = Esc 한 길(조사 AF (나) ⑤ · a3 · a5 · a6). + +까닭: 배수유역도 재계산이 관 마커 · 유역 고름을 지움(지도만 꺼짐) · 지도 · 유역 고름은 고름 한 칸에 안 적혀 +마름모가 안 옮겨짐 · 목록 관 줄은 폭 0.05 · 관이 조금 옮겨지면 줄 강조 빠짐 · 목록 관 고름은 화면마다 손으로 +불러 길마다 빠짐 · 풀기가 화면마다 다름(어디까지 푸는지 제각각). +고침: 종단 보기 구독이 배수유역도(관 마커 · 유역 · 마름모)까지 값으로 칠함(알림 없이 · 재계산 뒤 다시 얹음) · +지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫 · 목록 줄 폭 = `PIPE_MATCH_M` · +옮겨진 관도 고름 따라감 · Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂 +(입력 칸 · 창 · 먼저 받은 Esc 는 그쪽 몫). +진짜 TS 를 `helper_233_3_release.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +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() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_3_release.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_관_고름은_지도_관_마커_유역_마름모까지_알림_없이(out) -> None: + assert out["pipePick"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + assert out["pipePick"]["profile"]["station"] == "irregular:p80" + + +def test_재계산이_지운_지도_고름을_같은_값으로_다시_얹음(out) -> None: + assert out["afterRecompute"]["map"] == {"pipe": 80.17, "mark": 80.17, "notifies": 0} + + +def test_입력칸_창_먼저_받은_Esc는_안_풂(out) -> None: + for key in ("escInInput", "escModal", "escPrevented"): + assert out[key]["session"] == {"chainageM": 80.17, "structureId": None}, key + assert out[key]["form"]["resets"] == 0, key + + +def test_Esc는_값_종단_지도_폼_모두_풂(out) -> None: + esc = out["esc"] + assert esc["session"] is None + assert esc["profile"] == {"station": None, "structure": None} + assert (esc["map"]["pipe"], esc["map"]["mark"]) == (None, None) + assert esc["form"] == {"open": None, "resets": 1} + assert out["crossWhileHidden"] == [] # 숨은 횡단 보기는 안 받음 + + +def test_B05_목록_풀기는_그_길이_폼을_닫고_구독은_폼을_또_안_닫음(out) -> None: + release = out["listRelease"] + assert release["session"] is None + assert release["profile"] == {"station": None, "structure": None} + assert release["form"]["resets"] == 1 # Esc 때 한 번뿐 + + +def test_횡단_보기_Esc는_카드_폼_범위_형상_모두(out) -> None: + assert out["crossEsc"] == ["releaseForm", "focus:null", "mark:null"] + assert out["crossSession"] is None + assert out["listeners"] == 2 # 보기마다 하나 — 보이는 보기만 받음 + + +def test_배선() -> None: + view = _read("A00_Common", "design", "design_view_profile.ts") + assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view + assert "if (!selectionSyncing) rememberPick(chainageM, null);" in view + assert "onListRelease: () => rememberPick(null, null)" in view + assert "form: panel.structures" in view + assert len(view.splitlines()) <= 700 + panel = _read("B05_Profile", "B05_Profile_UI_Panel.ts") + assert "onListRelease: callbacks.onListRelease" in panel + drainage = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts") + assert "if (notify) callbacks.onPipeSelected?.(pickedChainage);" in drainage + assert "selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);" in drainage + 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 + # 저장 · 새로 읽기 뒤 목록 주입이 관 폼을 닫지 않음(닫히며 종단 선까지 꺼졌음) + assert "if (!applyingLive && selectedPipeChainage === null)" in structures + assert len(structures.splitlines()) <= 700 + cross = _read("A00_Common", "design", "design_view_cross.ts") + assert "releaseForm: () => structuresPanel.showAtChainage(null)" in cross + assert len(cross.splitlines()) <= 685