diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 232e1817d..9044fe2c1 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -1,6 +1,6 @@ /* ============================================================================= * design_view_profile.ts - * 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 · + * 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 · * 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다. * * 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫. @@ -284,9 +284,6 @@ export async function mountProfileView( }), onContourApply: (interval) => view3d.applyContours(interval), ...view3d.viewCallbacks, - onMovePoint: viewer.beginMoveSelected, - onDeletePoint: viewer.markers.deleteSelected, - onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }), onInputChange: markStale, onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset), onStructuresChange: (next) => bridge.applyStructures(next), @@ -336,7 +333,6 @@ export async function mountProfileView( } viewer.markers.onChange(markStale); - viewer.markers.onSelectionChange(panel.setSelected); viewer.markers.onStationSelectionChange((stationId) => { profilePanel.setSelectedStation(stationId); syncIrregularSelection(stationId); @@ -393,7 +389,13 @@ export async function mountProfileView( // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b). bindProfileViewLife(host, { projectId: activeProjectId, - viewer, + // 3D 는 모달 — 숨으면 닫음(그리기 멈춤) · 보여도 모달을 열 때만 그림 · 떠나면 버림(345-2). + viewer: { + setActive: (active) => { + if (!active) view3d.modal.close(); + }, + dispose: view3d.modal.dispose, + }, ready: () => !state.restoring, // 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다. reload: async () => { @@ -420,22 +422,24 @@ export async function mountProfileView( /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). - * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트 - * 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ + * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때 + * 올린다(345-2). 서클은 가운데 위 칸 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */ const LOAD_STEP_COUNT = 3; + const mainContent = document.createElement("div"); + mainContent.className = "b05-route__main"; + const planTools = document.createElement("div"); + planTools.className = "b05-route__plan-tools"; + planTools.append(view3d.modal.button); + mainContent.append(planTools, view3d.modal.park); + center.append(mainContent); const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true }); - viewer.root.append(progress.root); + mainContent.append(progress.root); let loadedSteps = 0; function advanceLoading(label: string): void { loadedSteps += 1; progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label); } - const mainContent = document.createElement("div"); - mainContent.className = "b05-route__main"; - mainContent.append(viewer.root); - center.append(mainContent); - try { // ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 // (2026-09-06). 캐시가 없을 때만 DB를 기다린다. @@ -509,7 +513,4 @@ export async function mountProfileView( snapOld(); restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더. } - - // ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. - void view3d.loadTerrain(); } diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 16a7dda32..96f999fe7 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -1,6 +1,7 @@ /* ============================================================================= * design_view_profile_3d.ts - * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기. + * 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기 · + * 큰 모달(`design_view_profile_3d_modal` · 345-2 — 지형은 처음 열 때 올림). * `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`. * ========================================================================== */ @@ -14,6 +15,7 @@ import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_P import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer"; import type { DesignPageHost } from "./design_page"; import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections"; +import { createViewer3dModal } from "./design_view_profile_3d_modal"; export interface Profile3dDeps { projectId: string; @@ -70,8 +72,14 @@ export function createProfile3d(deps: Profile3dDeps) { async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { - await viewer.reloadContours(interval); + await viewer.reloadContours(interval); // 지형 전(모달 안 엶)이면 그냥 지나감 await updateContourInterval(projectId, interval); + // 처음 열 때 올릴 지형도 새 간격으로(지형은 모달을 열 때 올림 · 345-2). + if (state.latest) + state.latest = { + ...state.latest, + surface_params: { ...state.latest.surface_params, contour_interval_m: interval }, + }; // 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다. const cached = deps.latestSource.read(); if (cached) { @@ -85,7 +93,7 @@ export function createProfile3d(deps: Profile3dDeps) { } } - /** ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. */ + /** ④ 3D 지형 — 모달을 처음 열 때 올린다(345-2). 실패해도 나머지는 그대로 쓴다. */ async function loadTerrain(): Promise { const surface = state.confirmedSurface; const current = state.latest; @@ -110,5 +118,11 @@ export function createProfile3d(deps: Profile3dDeps) { } } - return { viewCallbacks, applyContours, loadTerrain }; + const modal = createViewer3dModal({ + viewer, + loadTerrain, + visible: () => deps.host.visible(), + }); + + return { viewCallbacks, applyContours, modal }; } diff --git a/A00_Common/design/design_view_profile_3d_modal.ts b/A00_Common/design/design_view_profile_3d_modal.ts new file mode 100644 index 000000000..69af1a74d --- /dev/null +++ b/A00_Common/design/design_view_profile_3d_modal.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * design_view_profile_3d_modal.ts + * 종단 보기 3D 를 **큰 모달**로(345-2). 3D 화면은 숨긴 자리(`park`)에 살고 [3D] 를 누르면 공용 큰 + * 모달(`openModal({ wide })`) 몸으로 옮겨 붙는다 — 같은 노드라 보기 조작 줄 · [3D 업데이트] · + * 3D 안 고름(측점 · 구조물 · 우클릭 더함)이 그대로 따라간다. + * + * 처음 열 때 3D 지형을 올린다(아직이면 로딩 원). 닫으면 숨긴 자리로 되돌려 `setActive(false)` — + * 그리기 루프 · 포인터 듣기만 멈추고 WebGL 문맥 · 지형은 살린다. 버림은 페이지를 떠날 때만. + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +export interface Viewer3dModalDeps { + viewer: { root: HTMLElement; setActive: (active: boolean) => void; dispose: () => void }; + /** 처음 열 때 한 번 — 3D 지형 올리기(실패해도 모달은 열림). */ + loadTerrain: () => Promise; + /** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */ + visible: () => boolean; +} + +export function createViewer3dModal(deps: Viewer3dModalDeps) { + const { viewer } = deps; + const park = document.createElement("div"); + park.className = "b05-route__3d-park"; + park.hidden = true; + park.append(viewer.root); + viewer.setActive(false); // 닫힌 동안 그리기 0 + let modal: { close: () => void } | null = null; + let terrain: Promise | null = null; + + const putBack = (): void => { + modal = null; + park.append(viewer.root); + viewer.setActive(false); + }; + + function open(): void { + if (modal || !deps.visible()) return; + modal = openModal({ + title: "3D 보기", + closeLabel: "닫기", + wide: true, + dialogClass: "b05-route__3d-modal", + mount: (body) => { + body.classList.add("b05-route__3d-body"); + body.append(viewer.root); + }, + // 닫기 단추 · Esc · 바깥 누름 — 묻지 않고 숨긴 자리로 되돌린다. + beforeClose: () => { + putBack(); + return true; + }, + }); + viewer.setActive(true); + if (terrain) return; + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + terrain = deps.loadTerrain().finally(() => progress.remove()); + } + + /** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */ + function close(): void { + if (!modal) return; + const shown = modal; + putBack(); + shown.close(); + } + + const button = createButton({ label: "3D", variant: "ghost", onClick: open }); + button.classList.add("b05-route__3d-open"); + button.title = "3D 화면을 큰 창으로 엽니다 — 닫으면 그리기를 멈춥니다"; + + return { + park, + button, + open, + close, + isOpen: () => modal !== null, + dispose(): void { + close(); + viewer.dispose(); + park.remove(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 8f748552f..b8a70d0a0 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -8,7 +8,6 @@ * 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다. * ========================================================================== */ -import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel"; @@ -51,9 +50,6 @@ interface PanelCallbacks { /** [3D 업데이트] — 계획선 편집을 3D 예상형상·측점선에 한 번에 반영(2026-09-01 사용자 * 지시). 편집마다 따라오던 자동 갱신을 없애고 이 버튼으로만 돌린다. */ onCorridorRefresh: () => void | Promise; - onMovePoint: () => void; - onDeletePoint: () => void; - onRadiusChange: (radius: number) => void; onInputChange: () => void; /** 구조물 목록(구조물군 B~G)이 바뀔 때 — 정본 저장·그래프 반영은 Page가 맡는다. */ onStructuresChange: (structures: StructureInstance[]) => void; @@ -183,9 +179,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh enforcePipeClearance.checked = values.enforcePipeClearance; settings.syncCriteria(); }, - setSelected(point: PlacedRoutePoint | null) { - route.setSelected(point); - }, }; } diff --git a/B05_Profile/B05_Profile_UI_Panel_Route.ts b/B05_Profile/B05_Profile_UI_Panel_Route.ts index a21185ec8..95482457e 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Route.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Route.ts @@ -1,19 +1,16 @@ /* ============================================================================= * B05_Profile_UI_Panel_Route.ts - * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 선택 포인트 상세. 칸 틀은 + * 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추. 칸 틀은 * `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). + * 「선택 포인트 상세」(3D 노선 점 옮김 · 지움)는 지움(345-2). * `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다. * ========================================================================== */ -import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers"; -import { button, numberField } from "./B05_Profile_UI_Panel_Fields"; +import { button } from "./B05_Profile_UI_Panel_Fields"; export interface RoutePartCallbacks { /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ onEditPlannedRoute: () => void; - onMovePoint: () => void; - onDeletePoint: () => void; - onRadiusChange: (radius: number) => void; } export function createRoutePart(callbacks: RoutePartCallbacks) { @@ -23,34 +20,7 @@ export function createRoutePart(callbacks: RoutePartCallbacks) { plannedRouteBtn.classList.add("b05-route__btn"); plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; - const selectedName = document.createElement("strong"); - const radius = numberField("회피/금지 반경 (m)", "25"); - radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1)); - const selectedActions = document.createElement("div"); - selectedActions.className = "b05-route__actions"; - selectedActions.append( - button("위치 이동", callbacks.onMovePoint), - button("삭제", callbacks.onDeletePoint, "danger"), - ); - // 칸 뿌리는 등록부가 만든 뒤 알려 준다 — 평소 숨김(포인트를 고를 때만 보인다). - let selectedRoot: HTMLElement | null = null; - - return { - plannedRouteButton: plannedRouteBtn, - /** 「선택 포인트 상세 설정」 본문 — 화면 순서대로. */ - selectedNodes: [selectedName, radius.wrapper, selectedActions] as HTMLElement[], - bindSelectedRoot(root: HTMLElement): void { - selectedRoot = root; - root.hidden = true; - }, - setSelected(point: PlacedRoutePoint | null): void { - if (selectedRoot) selectedRoot.hidden = !point; - if (!point) return; - selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`; - radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp"; - radius.value = String(point.radius_m ?? 25); - }, - }; + return { plannedRouteButton: plannedRouteBtn }; } export type RoutePart = ReturnType; diff --git a/B05_Profile/B05_Profile_UI_Panel_Sections.ts b/B05_Profile/B05_Profile_UI_Panel_Sections.ts index 2fc33bfd4..417e064da 100644 --- a/B05_Profile/B05_Profile_UI_Panel_Sections.ts +++ b/B05_Profile/B05_Profile_UI_Panel_Sections.ts @@ -1,10 +1,10 @@ /* ============================================================================= * B05_Profile_UI_Panel_Sections.ts - * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 · - * 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. + * 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선. + * 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다. * - * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 - * > 선택 포인트(평소 숨김) > 아래 고정 영역. 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 + * 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역. + * 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어 * `ctx.parts` 로 건넨다. * ========================================================================== */ @@ -36,17 +36,3 @@ registerPanelSection({ return frame.root; }, }); - -registerPanelSection({ - id: "selected-point", - title: "선택 포인트 상세 설정", - views: ["profile"], - order: 50, - build: (ctx, view) => { - const frame = createSectionFrame(view, "선택 포인트 상세 설정"); - const route = part(ctx, "route"); - frame.body.append(...route.selectedNodes); - route.bindSelectedRoot(frame.root); - return frame.root; - }, -}); diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 97fa3b1c1..3c974f2b8 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -628,3 +628,22 @@ html[data-size="compact"] .b05-route__view-controls { html[data-size="compact"] .b05-route__view-group { flex-wrap: wrap; } + +/* 3D 큰 모달(345-2) — 3D 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음(캔버스 크기는 ResizeObserver 가 따라감). */ +.ui-modal--wide > .ui-modal__body.b05-route__3d-body { + overflow: hidden; +} + +.b05-route__3d-body > .b05-route__viewport { + flex: 1 1 auto; + min-height: 0; + border-radius: var(--radius-md); +} + +/* 가운데 위 [3D] 단추 자리 — 노선 편집 본문이 서기 전 임시 칸. */ +.b05-route__plan-tools { + display: flex; + flex: none; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index bd13562a0..78f276413 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -87,7 +87,6 @@ export interface RouteViewer { setView: (view: "iso" | "top" | "front" | "side") => void; /** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */ setProjection: (kind: ProjectionKind) => void; - beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; /** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */ @@ -512,10 +511,6 @@ export function createRouteViewer(): RouteViewer { renderStationLines: markers.renderStationLines, setView: fit, setProjection: (kind) => cameraRig.setKind(kind, controls), - beginMoveSelected() { - markerInput.beginMoveSelected(); - status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; - }, modelPointAt: markerInput.terrainPointAt, setActive(active) { structurePick.setListening(active); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index 7ab032e7e..4ee4c6d51 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -20,9 +20,7 @@ import { export interface MarkerPointerControls { /** 화면 좌표 아래 지형 지점(모델 좌표). 지형·bounds가 없으면 null. */ terrainPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; - /** 다음 클릭으로 선택 포인트를 옮기는 모드로 들어간다. */ - beginMoveSelected: () => void; - /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ + /** 마커를 잡고 있는가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ blocked: () => boolean; /** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */ pressedMarker: () => boolean; @@ -41,7 +39,6 @@ export function bindMarkerPointerControls(options: { }): MarkerPointerControls { const { canvas, camera, controls, markers, getTerrain, getBounds } = options; - let movingSelected = false; let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; @@ -136,13 +133,7 @@ export function bindMarkerPointerControls(options: { event.stopPropagation(); return; } - if (movingSelected) { - const point = terrainPoint(event); - if (point) markers.moveSelected(point); - movingSelected = false; - } else { - markers.selectObject(undefined); - } + markers.selectObject(undefined); } function handlePointerMove(event: PointerEvent): void { @@ -191,10 +182,7 @@ export function bindMarkerPointerControls(options: { return { terrainPointAt, - beginMoveSelected: () => { - movingSelected = true; - }, - blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + blocked: () => dragCandidate !== null || draggingMarker, pressedMarker: () => downOnMarker, dispose: () => { canvas.removeEventListener("dragover", handleDragOver); diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs index 4b997e9bb..51d2252bc 100644 --- a/resources/tester/helper_311_panel_folded.cjs +++ b/resources/tester/helper_311_panel_folded.cjs @@ -178,8 +178,6 @@ const panel = design.createDesignPanel( settings: { nodes: [text("a")] }, route: { plannedRouteButton: text("plan"), - selectedNodes: [text("sel")], - bindSelectedRoot: () => undefined, }, crossView: { row: text("cross-row") }, standardSlot: text("std"), diff --git a/resources/tester/helper_345_2_3d_modal.cjs b/resources/tester/helper_345_2_3d_modal.cjs new file mode 100644 index 000000000..83466b52a --- /dev/null +++ b/resources/tester/helper_345_2_3d_modal.cjs @@ -0,0 +1,161 @@ +/* 345-2 3D 큰 모달 헬퍼 — `design_view_profile_3d_modal.ts` 를 트랜스파일해 가짜 DOM · 가짜 뷰어로 + * 닫힌 동안 그리기 0 · 열면 그림 · 닫으면 숨긴 자리로 되돌림 · 지형은 처음 열 때 한 번 · 버림은 떠날 때만을 본다. + * 결과는 JSON 한 벌(표준출력). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag; + this.children = []; + this.parent = null; + this.hidden = false; + this.title = ""; + this.listeners = {}; + const classes = new Set(); + this.classList = { + add: (...n) => n.forEach((x) => classes.add(x)), + contains: (n) => classes.has(n), + }; + this._classes = classes; + } + set className(v) { + this._classes.clear(); + String(v) + .split(/\s+/) + .filter(Boolean) + .forEach((x) => this._classes.add(x)); + } + append(...kids) { + kids.forEach((kid) => { + if (kid.parent) kid.parent.children = kid.parent.children.filter((c) => c !== kid); + kid.parent = this; + this.children.push(kid); + }); + } + remove() { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + click() { + (this.listeners.click ?? []).forEach((fn) => fn({})); + } +} +global.document = { createElement: (tag) => new FakeNode(tag) }; + +const modals = []; +const STUBS = { + "@ui/ui_template_elements": { + createButton: ({ label, onClick }) => { + const button = new FakeNode("button"); + button.label = label; + if (onClick) button.addEventListener("click", onClick); + return button; + }, + }, + "@ui/ui_template_modal": { + openModal: (opts) => { + const body = new FakeNode("div"); + const record = { opts, body, open: true }; + opts.mount(body, () => (record.open = false), new FakeNode("div")); + record.close = () => (record.open = false); + modals.push(record); + return { close: record.close }; + }, + }, + "@ui/ui_template_progress": { + createProgressCircle: () => { + const root = new FakeNode("div"); + return { root, set: () => undefined, remove: () => root.remove() }; + }, + }, +}; + +function loadTs(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => STUBS[request] ?? require(request); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const { createViewer3dModal } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_view_profile_3d_modal.ts"), +); + +// 가짜 뷰어 — 만들면 그리기 루프가 돎(진짜와 같음) · setActive 가 루프를 켜고 끔. +const viewer = { + root: new FakeNode("div"), + running: true, + draws: 0, + disposed: false, + setActive(active) { + this.running = active; + }, + dispose() { + this.disposed = true; + }, +}; +const frames = (n) => { + for (let i = 0; i < n; i += 1) if (viewer.running) viewer.draws += 1; +}; + +let terrainLoads = 0; +let resolveTerrain; +const view3d = createViewer3dModal({ + viewer, + loadTerrain: () => { + terrainLoads += 1; + return new Promise((resolve) => (resolveTerrain = resolve)); + }, + visible: () => true, +}); + +(async () => { + const out = {}; + frames(10); + out.closedDraws = viewer.draws; + out.inPark = viewer.root.parent === view3d.park && view3d.park.hidden; + out.terrainBeforeOpen = terrainLoads; + + view3d.button.click(); + const first = modals[0]; + out.wide = first.opts.wide === true; + out.inModal = viewer.root.parent === first.body; + out.spinnerWhileLoading = viewer.root.children.length === 1; + frames(3); + out.openDraws = viewer.draws; + resolveTerrain(); + await new Promise((r) => setImmediate(r)); + out.spinnerGone = viewer.root.children.length === 0; + + // 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙) + out.beforeCloseOk = (await first.opts.beforeClose()) === true; + first.close(); + frames(10); + out.afterCloseDraws = viewer.draws; + out.backInPark = viewer.root.parent === view3d.park; + out.disposedOnClose = viewer.disposed; + + view3d.open(); + view3d.open(); // 열린 동안 또 눌러도 하나 + out.modalCount = modals.length; + out.terrainLoads = terrainLoads; + frames(2); + out.reopenDraws = viewer.draws; + + view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림 + out.closedOnDispose = modals[1].open === false; + out.disposed = viewer.disposed; + out.runningAfterDispose = viewer.running; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/helper_design_view_parity.cjs b/resources/tester/helper_design_view_parity.cjs index e94b6161e..e08b68822 100644 --- a/resources/tester/helper_design_view_parity.cjs +++ b/resources/tester/helper_design_view_parity.cjs @@ -199,10 +199,6 @@ const built = panel.createDesignPanel( settings: { nodes: [text("a"), text("b")] }, route: { plannedRouteButton: text("plan"), - selectedNodes: [text("sel")], - bindSelectedRoot: (root) => { - root.hidden = true; - }, }, crossView: { row: text("cross-row") }, standardSlot: text("std"), diff --git a/resources/tester/test_311_panel_folded.py b/resources/tester/test_311_panel_folded.py index 66aeb65fd..60880ce05 100644 --- a/resources/tester/test_311_panel_folded.py +++ b/resources/tester/test_311_panel_folded.py @@ -43,7 +43,6 @@ def test_B05_B06_첫_그림_접힘(out): assert out["profileFirst"] == { "페이지 설정": True, "계획노선": True, - "선택 포인트 상세 설정": False, } assert out["crossFirst"]["B06_View_Title"] is True assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로 diff --git a/resources/tester/test_345_2_3d_modal.py b/resources/tester/test_345_2_3d_modal.py new file mode 100644 index 000000000..2ce035a62 --- /dev/null +++ b/resources/tester/test_345_2_3d_modal.py @@ -0,0 +1,69 @@ +"""345-2 3D → 큰 모달 — 닫힌 동안 그리기 0 · 처음 열 때 지형 · 닫으면 숨긴 자리 · 버림은 떠날 때만. + +Node 헬퍼 `helper_345_2_3d_modal.cjs` 가 모달 조각을 트랜스파일해 가짜 뷰어로 돌림 · 배선은 글로 봄. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_345_2_3d_modal.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(run.stdout) + + +def test_닫힌_동안_그리기_0(out): + assert out["closedDraws"] == 0 + assert out["inPark"] is True + assert out["afterCloseDraws"] == out["openDraws"] == 3 + assert out["backInPark"] is True + assert out["disposedOnClose"] is False # 닫기는 멈춤만 · 문맥 살림 + + +def test_처음_열_때_지형_한_번_로딩_원(out): + assert out["terrainBeforeOpen"] == 0 + assert out["wide"] is True and out["inModal"] is True + assert out["spinnerWhileLoading"] is True and out["spinnerGone"] is True + assert out["terrainLoads"] == 1 # 다시 열어도 다시 안 올림 + assert out["modalCount"] == 2 # 열린 동안 또 눌러도 하나 + assert out["reopenDraws"] == 5 + + +def test_떠날_때만_버림(out): + assert out["beforeCloseOk"] is True + assert out["closedOnDispose"] is True + assert out["disposed"] is True + assert out["runningAfterDispose"] is False + + +def test_배선_3D_상주_없음_죽은_길_지움(): + view = _read("A00_Common/design/design_view_profile.ts") + assert "mainContent.append(viewer.root)" not in view + assert "view3d.modal.park" in view and "view3d.modal.button" in view + assert "dispose: view3d.modal.dispose" in view + assert "loadTerrain" not in view # 지형은 모달을 열 때 + for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"): + assert gone not in view + assert "selected-point" not in _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts") + assert "selectedNodes" not in _read("B05_Profile/B05_Profile_UI_Panel_Route.ts") + assert "beginMoveSelected" not in _read("B05_Profile/B05_Profile_UI_Viewer.ts") + assert len(view.splitlines()) <= 700 diff --git a/resources/tester/test_design_view_parity.py b/resources/tester/test_design_view_parity.py index 98906a2bb..cc7e3cb83 100644 --- a/resources/tester/test_design_view_parity.py +++ b/resources/tester/test_design_view_parity.py @@ -59,7 +59,9 @@ def results(): def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results): assert results["sameNodes"] == {"cross": True, "back": True} # 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만. - assert results["childCount"] == 1 + 4 + 2 # 아래 고정 영역 + 종단 칸 넷 + 횡단 칸 둘 + assert ( + results["childCount"] == 1 + 3 + 2 + ) # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 지움) def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results): @@ -80,7 +82,6 @@ def test_보기마다_제_칸만_보인다(results): "구조물 배치", "페이지 설정", "계획노선", - "선택 포인트 상세 설정", ] assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"] assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]