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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 # 옛 기본 접힘 그대로
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user