feat(종단보기): 345-2 3D → 큰 모달 — [3D] 단추 · 처음 열 때 지형 · 닫으면 숨긴 자리로 · 그리기 멈춤 · 떠날 때만 버림 · 3D 노선 점 옮김 · 지움 + 「선택 포인트 상세」 칸 지움

- 새 design_view_profile_3d_modal.ts(공용 openModal wide · 보기 조작 줄 · [3D 업데이트] · 3D 고름이 같은 노드로 따라감)
- 지형 올리기는 페이지 열기에서 빠짐 · 모달 처음 열 때(로딩 원) · 등고선 간격 바꿈도 그 값으로
- 시험: test_345_2_3d_modal 4 통과(닫힌 동안 그리기 0) · 칸 시험(parity · 311) 고쳐 씀 · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-16 11:11:10 +09:00
co-authored by Claude Opus 5.5
parent 8eb5ba5450
commit f8fcb812d3
15 changed files with 387 additions and 109 deletions
+18 -17
View File
@@ -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();
}
+18 -4
View File
@@ -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<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(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<void> {
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 };
}
@@ -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<void>;
/** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */
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<void> | 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();
},
};
}
-7
View File
@@ -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<void>;
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);
},
};
}
+4 -34
View File
@@ -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<typeof createRoutePart>;
+4 -18
View File
@@ -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<RoutePart>(ctx, "route");
frame.body.append(...route.selectedNodes);
route.bindSelectedRoot(frame.root);
return frame.root;
},
});
+19
View File
@@ -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);
}
-5
View File
@@ -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);
@@ -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);
@@ -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"),
+161
View File
@@ -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));
})();
@@ -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"),
@@ -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 # 옛 기본 접힘 그대로
+69
View File
@@ -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
+3 -2
View File
@@ -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"]