diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css new file mode 100644 index 00000000..99fc27b6 --- /dev/null +++ b/A00_Common/design/design_page.css @@ -0,0 +1,11 @@ +/* 한 페이지 두 보기 뼈대(계획서 24-3a) — 감싸는 칸은 자리를 차지하지 않아(display: contents) + 보기 안의 틀이 예전처럼 아울렛에 바로 붙은 것과 같게 놓인다. 숨긴 보기만 빠진다. */ +.design-page, +.design-page__view { + display: contents; +} + +.design-page__view[hidden], +.design-page__frame[hidden] { + display: none; +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts new file mode 100644 index 00000000..ae29d393 --- /dev/null +++ b/A00_Common/design/design_page.ts @@ -0,0 +1,212 @@ +/* ============================================================================= + * design_page.ts + * B05 · B06 **한 페이지 두 보기** 뼈대(계획서 24-3a). + * + * 주소 둘(`#/b05-profile` · `#/b06-section`)이 이 페이지 하나를 연다. 같은 페이지 안에서 주소만 + * 바뀌면 라우터가 `show(route)` 만 부른다 — 보기를 다시 만들지 않고 숨김 · 보임만 바꾼다. + * 다른 페이지로 떠나면 `dispose` 로 3D · 종단도 · 횡단 · 창 듣기를 정리한다. + * + * 보기 약속 `DesignPageView` — 두 길 중 하나를 구현한다: + * mount(root, host) ← 중간 단계: 보기가 좌측 · 가운데 · 종단도를 통째로 그린다 + * (지금 B05 · B06 페이지 · 보기마다 한 벌씩이지만 다시 안 만듦) + * mountCenter(center, host, shared) ← 끝 모습(24-3b): 가운데 위 칸만. 좌측 패널 · 단추 · 종단도는 + * `shared` 한 벌(한 노드)을 두 보기가 함께 쓴다 + * + * 보기는 `host.onShow` · `onHide` · `onDispose` 로 숨김 · 보임 · 떠남 때 할 일을 건다 — + * 숨으면 3D 그리기 루프 · 창 키 · 포인터 듣기를 떼고, 보이면 가게 값으로 다시 그린다. + * ========================================================================== */ + +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { currentRoute, type MountedPage } from "../router"; +import type { DesignView } from "./design_dock"; +import type { DesignPanel } from "./design_panel"; +import type { DesignProfile } from "./design_profile"; +import "./design_page.css"; + +/** 보기 ↔ 주소. */ +export const DESIGN_VIEW_ROUTES: Record = { + profile: ROUTES.B05_PROFILE, + cross: ROUTES.B06_SECTION, +}; + +export function designViewOf(route: RoutePath): DesignView | null { + if (route === ROUTES.B05_PROFILE) return "profile"; + if (route === ROUTES.B06_SECTION) return "cross"; + return null; +} + +/** 보기가 페이지에서 받는 것 — 숨김 · 보임 · 떠남 때 할 일을 건다. */ +export interface DesignPageHost { + readonly view: DesignView; + /** 지금 보이는가(떠났으면 false). */ + visible: () => boolean; + /** 떠났는가 — 늦게 온 자료가 떠난 화면을 채우지 않게 본다. */ + disposed: () => boolean; + /** 다시 보일 때(처음 마운트는 빼고) — 다른 보기에서 바뀐 값을 가게에서 다시 읽는다. */ + onShow: (fn: () => void) => void; + /** 숨을 때 — 그리기 루프 · 창 듣기를 뗀다. */ + onHide: (fn: () => void) => void; + /** 떠날 때 — 건 차례의 거꾸로 부른다. */ + onDispose: (fn: () => void) => void; +} + +/** 끝 모습(24-3b)의 한 벌 자리 — 두 보기가 같은 객체 · 같은 노드를 쓴다. */ +export interface DesignPageShared { + /** 좌측 패널 자리(아래 단추 넷은 좌측 패널 안). */ + left: HTMLElement; + /** 가운데 아래 종단도 자리. */ + profileSlot: HTMLElement; + /** 먼저 마운트한 보기가 만들어 채운다 — 뒤 보기는 있는 것을 `setView` 로 바꿔 쓴다. */ + panel: DesignPanel | null; + profile: DesignProfile | null; +} + +export interface DesignPageView { + mount?: (root: HTMLElement, host: DesignPageHost) => void | Promise; + mountCenter?: ( + center: HTMLElement, + host: DesignPageHost, + shared: DesignPageShared, + ) => void | Promise; +} + +/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */ +const VIEWS: Record Promise> = { + profile: async () => ({ + mount: (await import("../../B05_Profile/B05_Profile_UI_Page")).renderB05Route, + }), + cross: async () => ({ + mount: (await import("../../B06_Section/B06_Section_UI_Page")).renderB06ProfileCross, + }), +}; + +interface ViewSlot { + box: HTMLElement; + centered: boolean; + show: () => void; + hide: () => void; + dispose: () => void; +} + +const run = (fn: () => void): void => { + try { + fn(); + } catch (error) { + console.error("[design_page]", error); + } +}; + +/** 한 페이지를 세운다 — 라우터가 `MountedPage` 로 받아 같은 페이지 안 이동에 쓴다. */ +export function renderDesignPage(root: HTMLElement): MountedPage { + const page = document.createElement("div"); + page.className = "design-page"; + root.replaceChildren(page); + + const slots = new Map(); + let current: DesignView | null = null; + let frame: (DesignPageShared & { root: HTMLElement; center: HTMLElement }) | null = null; + + /** 끝 모습의 틀 — mountCenter 보기가 처음 올 때만 만든다(틀 꾸밈은 24-3b). */ + const ensureFrame = (): NonNullable => { + if (frame) return frame; + const frameRoot = document.createElement("div"); + frameRoot.className = "design-page__frame"; + const left = document.createElement("div"); + left.className = "design-page__left"; + const main = document.createElement("div"); + main.className = "design-page__main"; + const center = document.createElement("div"); + center.className = "design-page__center"; + const profileSlot = document.createElement("div"); + profileSlot.className = "design-page__profile"; + main.append(center, profileSlot); + frameRoot.append(left, main); + page.append(frameRoot); + frame = { root: frameRoot, center, left, profileSlot, panel: null, profile: null }; + return frame; + }; + + const mountView = (view: DesignView): ViewSlot => { + const life = { show: [] as Array<() => void>, hide: [] as Array<() => void> }; + const cleanups: Array<() => void> = []; + let visible = true; + let disposed = false; + const box = document.createElement("div"); + box.className = "design-page__view"; + box.dataset.view = view; + page.append(box); + const host: DesignPageHost = { + view, + visible: () => visible && !disposed, + disposed: () => disposed, + onShow: (fn) => life.show.push(fn), + onHide: (fn) => life.hide.push(fn), + onDispose: (fn) => cleanups.push(fn), + }; + const slot: ViewSlot = { + box, + centered: false, + show() { + visible = true; + box.hidden = false; + if (frame) frame.root.hidden = !slot.centered; + life.show.forEach(run); + }, + hide() { + visible = false; + box.hidden = true; + life.hide.forEach(run); + }, + dispose() { + disposed = true; + cleanups.reverse().forEach(run); + }, + }; + void VIEWS[view]() + .then((def) => { + if (disposed) return; + if (def.mountCenter) { + const shared = ensureFrame(); + slot.centered = true; + shared.center.append(box); + shared.root.hidden = !visible; + return def.mountCenter(box, host, shared); + } + if (frame) frame.root.hidden = true; + return def.mount?.(box, host); + }) + .catch((error) => console.error(`[design_page] 보기 마운트 실패: ${view}`, error)); + return slot; + }; + + const show = (view: DesignView): void => { + if (current === view) return; + const started = performance.now(); + if (current) slots.get(current)?.hide(); + current = view; + const slot = slots.get(view); + if (slot) slot.show(); + else slots.set(view, mountView(view)); + // 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다. + const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } }; + probe.__designPage ??= { view, switchMs: [] }; + probe.__designPage.view = view; + probe.__designPage.switchMs.push(Math.round(performance.now() - started)); + }; + + show(designViewOf(currentRoute()) ?? "profile"); + + return { + covers: (route) => designViewOf(route) !== null, + show: (route) => { + const view = designViewOf(route); + if (view) show(view); + }, + dispose() { + slots.forEach((slot) => slot.dispose()); + slots.clear(); + current = null; + delete (window as unknown as { __designPage?: unknown }).__designPage; + }, + }; +} diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index d390d4fc..04ef5de0 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -258,8 +258,12 @@ export function createDesignProfile(ctx: DesignProfileContext) { let lastHeight = 0; /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */ + /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 + * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => - structureIdAtStationOf(stationId, irregularStations, structures); + stationId !== null && stationIdAtStructure(selectedStructureId) === stationId + ? selectedStructureId + : structureIdAtStationOf(stationId, irregularStations, structures); const stationIdAtStructure = (structureId: string | null): string | null => stationIdAtStructureOf(structureId, irregularStations, structures); @@ -315,7 +319,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : [])); /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */ - const { tools, history, handleToolPick } = createPanelTools({ + const { tools, history, handleToolPick, setListening } = createPanelTools({ root, base: () => base, alignment: () => alignment, @@ -672,7 +676,17 @@ export function createDesignProfile(ctx: DesignProfileContext) { progress.root.hidden = label === null; if (label !== null) progress.set(null, label); }, + /** 보기가 숨으면 false — 창 키 듣기를 뗀다. 다시 보이면 잇고, 다른 보기가 고친 계획고 초안 + * (같은 가게) · 관(판)을 다시 읽어 그린다(계획서 24-3a). */ + setActive(active: boolean) { + setListening(active); + if (!active) return; + if (base) alignment = buildAlignment(base, store.edits()); + draw(); + drainagePanel?.load(projectId); + }, dispose() { + setListening(false); drag.dispose(); crossPreview.dispose(); resizeObserver.disconnect(); diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 804a64dd..1b0a85ee 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -18,6 +18,7 @@ import { renderCurrentRoute, ensureInitialHash, setLeaveGuard, + showWithinPage, takeLeaveGuard, } from "./router"; @@ -54,8 +55,10 @@ function bootstrap(): void { let shown = ""; let shownHash = ""; const renderAll = (): void => { + const within = showWithinPage(currentRoute()); // B05 ↔ B06 — 보기만 바꿈(24-3a) shown = currentRoute(); shownHash = window.location.hash; + if (within) return; const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 diff --git a/A00_Common/router.ts b/A00_Common/router.ts index b856bba0..80c8151c 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -9,8 +9,18 @@ import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend"; +/** + * 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다 + * (B05 · B06 한 페이지 · 계획서 24-3a). 다른 페이지로 떠날 때 `dispose` 로 정리한다. + */ +export interface MountedPage { + covers: (route: RoutePath) => boolean; + show: (route: RoutePath) => void; + dispose: () => void; +} + /** 페이지 렌더 함수 시그니처. root(마운트 대상)를 받아 내용을 채운다. */ -export type PageRenderer = (root: HTMLElement) => void | Promise; +export type PageRenderer = (root: HTMLElement) => void | MountedPage | Promise; /** 라우트별 지연 로더 등록 테이블. * 페이지 구현이 완료되는 대로 아래에 한 줄씩 추가한다. */ @@ -41,10 +51,9 @@ const routeTable: Partial Promise>> = { (await import("../B03_FileInput/B03_FileInput_UI_Page")).renderB03FileInput, [ROUTES.B04_PREPROCESS]: async () => (await import("../B04_PreProcess/B04_PreProcess_UI_Page")).renderB04Surface, - [ROUTES.B05_PROFILE]: async () => - (await import("../B05_Profile/B05_Profile_UI_Page")).renderB05Route, - [ROUTES.B06_SECTION]: async () => - (await import("../B06_Section/B06_Section_UI_Page")).renderB06ProfileCross, + // B05 · B06 = 한 페이지 두 보기(계획서 24-3a) — 주소만 바뀌면 보기만 바꾼다. + [ROUTES.B05_PROFILE]: async () => (await import("./design/design_page")).renderDesignPage, + [ROUTES.B06_SECTION]: async () => (await import("./design/design_page")).renderDesignPage, [ROUTES.B07_DESIGN_DETAIL]: async () => (await import("../B07_DesignDetail/B07_DesignDetail_UI_Page")).renderB07DesignDetail, // B08_QUANTITY: 수량 본문은 재작성 예정(구 구현은 B08_Quantity/0_old_260726_codex.zip 백업). @@ -72,6 +81,17 @@ export const setLeaveGuard = (guard: (() => Promise) | null): void => { }; export const takeLeaveGuard = (): (() => Promise) | null => leaveGuard; +/** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */ +let mounted: MountedPage | null = null; +let renderSeq = 0; + +/** 같은 페이지 안 주소면 보기만 바꾸고 true — 셸 · 페이지를 다시 그리지 않는다. */ +export function showWithinPage(route: RoutePath): boolean { + if (!mounted?.covers(route)) return false; + mounted.show(route); + return true; +} + /** 로그인 여부 (토큰 존재 확인) */ export async function isAuthenticated(): Promise { const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch"); @@ -115,6 +135,10 @@ function renderPlaceholder(root: HTMLElement, route: RoutePath): void { */ export async function renderCurrentRoute(outlet: HTMLElement): Promise { const route = currentRoute(); + const seq = ++renderSeq; + // 다른 페이지로 떠남 — 3D · 그리기 루프 · 창 듣기를 정리한다(계획서 24-3a). + mounted?.dispose(); + mounted = null; if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { navigateTo(ROUTES.A06_LOGIN); @@ -161,8 +185,11 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { try { const renderer = await loader(); + if (seq !== renderSeq) return; // 그 사이 다른 주소로 떠났다. outlet.innerHTML = ""; - await renderer(outlet); + const page = await renderer(outlet); + if (page && seq !== renderSeq) page.dispose(); + else if (page) mounted = page; window.scrollTo(0, 0); } catch (err) { console.error(`[router] 페이지 로드 실패: ${route}`, err); diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 183b51bd..1ffc7889 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -30,6 +30,8 @@ import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { restoreStructurePick, wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, nearestWithin, selectDesign } from "../A00_Common/design/design_selection"; +import type { DesignPageHost } from "../A00_Common/design/design_page"; +import { bindProfileViewLife } from "./B05_Profile_UI_Page_Life"; import { structureAnchorM } from "./B05_Profile_Api_Structures"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; @@ -79,7 +81,7 @@ function L(key: keyof typeof ui_locales): string { /** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ const migratedRoutes = new Set(); -export async function renderB05Route(root: HTMLElement): Promise { +export async function renderB05Route(root: HTMLElement, host?: DesignPageHost): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { showToast("프로젝트를 먼저 선택하세요.", "error"); @@ -647,6 +649,19 @@ export async function renderB05Route(root: HTMLElement): Promise { restoreSections, }; + // 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a). + if (host) { + bindProfileViewLife(host, { + projectId: activeProjectId, + viewer, + profile: profilePanel, + ready: () => !restoring, + reload: () => bridge.load(), + structures: () => panel.structures, + restorePick, + }); + } + /* ── 진입 로딩 ───────────────────────────────────────────────────────── * 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시). * 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — 네 단계가 @@ -748,7 +763,7 @@ export async function renderB05Route(root: HTMLElement): Promise { // ⑤ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다. void (async () => { const [surface, current] = [confirmedSurface, latest]; - if (!surface || !current) return; + if (!surface || !current || host?.disposed()) return; try { // 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); @@ -760,6 +775,7 @@ export async function renderB05Route(root: HTMLElement): Promise { current.surface_params.contour_interval_m, toBounds(surface.bounds), ); + if (host?.disposed()) return; // 올리는 사이 페이지를 떠났다. renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. if (currentSectionDetail) renderStationLines(currentSectionDetail); showToast("3D 지형 준비 완료", "success"); diff --git a/B05_Profile/B05_Profile_UI_Page_Life.ts b/B05_Profile/B05_Profile_UI_Page_Life.ts new file mode 100644 index 00000000..05392ec6 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Page_Life.ts @@ -0,0 +1,48 @@ +/* ============================================================================= + * B05_Profile_UI_Page_Life.ts + * 종단 보기의 숨김 · 보임 · 떠남(한 페이지 두 보기 · 계획서 24-3a). + * + * 숨으면 3D 그리기 루프 · 창 키 · 포인터 듣기를 뗀다. 다시 보이면 잇고, 다른 보기(횡단)에서 + * 바뀐 구조물 · 관 · 계획고 초안 · 고른 것을 가게에서 다시 읽는다. 떠나면 3D(WebGL 문맥까지) · + * 종단도를 정리한다. 페이지 본체가 700줄을 넘어 여기로 뗐다. + * ========================================================================== */ + +import type { DesignPageHost } from "../A00_Common/design/design_page"; +import { readSelection } from "../A00_Common/design/design_selection"; + +export interface ProfileViewLifeDeps { + projectId: string; + viewer: { setActive: (active: boolean) => void; dispose: () => void }; + profile: { setActive: (active: boolean) => void; dispose: () => void }; + /** 첫 진입 로딩이 끝났는가 — 끝나기 전에는 다시 읽지 않는다. */ + ready: () => boolean; + /** 구조물 목록을 다시 읽는다(초안 우선). */ + reload: () => Promise; + structures: () => { selectById: (structureId: string | null) => void }; + /** 가게에 남은 고른 것을 되살린다(이미 고른 것이 있으면 그대로). */ + restorePick: () => void; +} + +export function bindProfileViewLife(host: DesignPageHost, deps: ProfileViewLifeDeps): void { + let pickAtHide = ""; + const pickNow = (): string => JSON.stringify(readSelection(deps.projectId)); + host.onHide(() => { + deps.viewer.setActive(false); + deps.profile.setActive(false); + pickAtHide = pickNow(); + }); + host.onShow(() => { + deps.viewer.setActive(true); + deps.profile.setActive(true); + if (!deps.ready()) return; + void deps.reload().then(() => { + // 다른 보기에서 다른 것을 골랐으면 지금 고른 것을 풀고 그것을 되살린다. + if (pickNow() !== pickAtHide) deps.structures().selectById(null); + deps.restorePick(); + }); + }); + host.onDispose(() => { + deps.viewer.dispose(); + deps.profile.dispose(); + }); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 8bdb8b2a..05a49e05 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -68,6 +68,8 @@ export interface PanelTools { history: ProfileHistory; /** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */ handleToolPick: (chainageM: number | null) => boolean; + /** 창 키 듣기를 잇고 뗀다 — 종단도가 숨거나 떠나면 뗀다(계획서 24-3a). */ + setListening: (on: boolean) => void; } export function createPanelTools(ctx: PanelToolsContext): PanelTools { @@ -275,7 +277,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에 * 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다 * (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */ - window.addEventListener("keydown", (event) => { + const onKeyDown = (event: KeyboardEvent): void => { if (!ctx.root.isConnected) return; const target = event.target as HTMLElement | null; // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. @@ -305,7 +307,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { } event.preventDefault(); startHold(key); - }); + }; + window.addEventListener("keydown", onKeyDown); - return { tools, history, handleToolPick }; + const setListening = (on: boolean): void => { + window.removeEventListener("keydown", onKeyDown); + if (on) window.addEventListener("keydown", onKeyDown); + else stopHold(); + }; + return { tools, history, handleToolPick, setListening }; } diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 65375e32..165456da 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -17,20 +17,12 @@ import { import { createCameraRig, type ProjectionKind } from "./B05_Profile_UI_Viewer_Camera"; import { bindMarkerPointerControls } from "./B05_Profile_UI_Viewer_Marker_Input"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { - createCorridorGroup, - PLAN_CURVE_FLAG, - PLAN_CURVE_GROUP, -} from "./B05_Profile_UI_Corridor_Mesh"; -import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; -import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; +import { createViewerCorridor, disposeObject } from "./B05_Profile_UI_Viewer_Corridor"; import { bindStructurePick, type StructurePickControls, } from "./B05_Profile_UI_Viewer_Structure_Pick"; import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; -import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; type ViewKind = "iso" | "top" | "front" | "side"; @@ -62,57 +54,6 @@ declare global { } } -/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ -function countTriangles(root: THREE.Object3D | null): number { - let total = 0; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return; - const index = child.geometry.getIndex(); - const position = child.geometry.getAttribute("position"); - total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3); - }); - return total; -} - -/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */ -function describeSurface(root: THREE.Object3D | null): unknown { - const out: unknown[] = []; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh) || out.length >= 2) return; - const geometry = child.geometry; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - const material = ( - Array.isArray(child.material) ? child.material[0] : child.material - ) as THREE.MeshLambertMaterial; - out.push({ - type: material?.type, - vertexColors: material?.vertexColors, - matColor: material?.color?.getHexString(), - side: material?.side, - colorSize: color?.itemSize, - color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null, - normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null, - hasNormal: Boolean(normal), - }); - }); - return out; -} - -function disposeObject(object: THREE.Object3D | null): void { - object?.traverse((child) => { - if ( - child instanceof THREE.Mesh || - child instanceof THREE.Points || - child instanceof THREE.Line - ) { - child.geometry.dispose(); - const materials = Array.isArray(child.material) ? child.material : [child.material]; - materials.forEach((material) => material.dispose()); - } - }); -} - export interface RouteViewer { root: HTMLElement; markers: RouteMarkers; @@ -150,6 +91,9 @@ export interface RouteViewer { modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; /** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */ structurePick: StructurePickControls; + /** 보일 때 true — 그리기 루프 · 창 포인터 듣기를 잇고, false 면 멈추고 뗀다. */ + setActive: (active: boolean) => void; + /** 떠날 때 — WebGL 문맥까지 놓는다(보관 지형은 다음 뷰어가 다시 쓰므로 남긴다). */ dispose: () => void; } @@ -212,15 +156,30 @@ export function createRouteViewer(): RouteViewer { // // 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시 // 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다). - let corridorBuild: CorridorBuildResult | null = null; - let corridorGroup: THREE.Group | null = null; - let clippedTerrain: THREE.Object3D | null = null; + // 코리도 · 클리핑 지형 · 밴드 분할본은 `_Viewer_Corridor` 몫(700줄 한계 · 계획서 24-3a). + const corridor = createViewerCorridor({ + scene, + terrain: () => terrain, + bounds: () => bounds, + heightIndex: () => ensureHeightIndex(), + onGroup: () => structurePick.reapply(), + applyGrayscale: (target) => applyGrayscaleTo(target), + }); /** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */ let heightIndex: TerrainHeightIndex | null = null; - /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ - let bandSplit: TerrainBandSplit | null = null; - let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. - let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). + + /** 지형 높이 색인 확보 — 지형 1회당 한 번만 만든다(빌드 비용 O(삼각형)). */ + function ensureHeightIndex(): TerrainHeightIndex | null { + if (heightIndex) return heightIndex; + if (!terrain) return null; + // 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다. + heightIndex = + cachedTerrain.object === terrain && cachedTerrain.heightIndex + ? cachedTerrain.heightIndex + : new TerrainHeightIndex(terrain); + if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex; + return heightIndex.triangleCount > 0 ? heightIndex : null; + } const contours = new THREE.Group(); scene.add(contours); let bounds: ModelBounds | null = null; @@ -296,7 +255,7 @@ export function createRouteViewer(): RouteViewer { const structurePick = bindStructurePick({ canvas, camera: cameraRig.camera, - group: () => corridorGroup, + group: () => corridor.group(), blocked: () => markerInput.blocked(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). @@ -394,11 +353,11 @@ export function createRouteViewer(): RouteViewer { }); } - /** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */ - + /** 그리기 루프 — 0 이면 멈춤. 숨긴 보기(횡단 보기)에서는 멈춘다(계획서 24-3a). */ let frame = 0; function animate(): void { frame = requestAnimationFrame(animate); + drawCount += 1; controls.update(); // 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다). if (terrain) { @@ -415,14 +374,18 @@ export function createRouteViewer(): RouteViewer { markers.updateLabelDetail(cameraRig.camera().position.distanceTo(controls.target)); renderer.render(scene, cameraRig.camera()); } + let drawCount = 0; animate(); + /** 검증용 — 살아 있는 뷰어의 그리기 횟수 · 루프 여부를 화면 밖에서 수치로 본다. */ + const probe = { draws: () => drawCount, running: () => frame !== 0 }; + (window as unknown as { __routeViewer?: typeof probe }).__routeViewer = probe; // 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라 // (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다. // 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다. let surfaceGrayscale = true; function applySurfaceGrayscale(): void { - [terrain, clippedTerrain].forEach((target) => applyGrayscaleTo(target)); + [terrain, corridor.clipped()].forEach((target) => applyGrayscaleTo(target)); } function applyGrayscaleTo(target: THREE.Object3D | null): void { target?.traverse((child) => { @@ -445,209 +408,6 @@ export function createRouteViewer(): RouteViewer { }); } - /* ── 예상형상(코리도) — 원본/클리핑 지형 스왑 + 계획 서피스 조합 ───────── */ - - /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ - function applyCorridorVisibility(): void { - const swapped = corridorOn && clippedTerrain !== null; - if (terrain) terrain.visible = surfaceOn && !swapped; - if (clippedTerrain) clippedTerrain.visible = surfaceOn && swapped; - if (corridorGroup) corridorGroup.visible = corridorOn; - } - - function disposeClippedTerrain(): void { - if (!clippedTerrain) return; - scene.remove(clippedTerrain); - // 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에 - // 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다. - clippedTerrain.traverse((child) => { - if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return; - if (child.userData.owned) child.geometry.dispose(); - }); - clippedTerrain = null; - } - - /** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */ - function corridorBand(build: CorridorBuildResult): SceneBox | null { - if (!bounds) return null; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - let minX = Infinity; - let maxX = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - [...build.outline.left, ...build.outline.right].forEach(([mx, my]) => { - const x = mx - ox; - const z = -(my - oy); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; - }); - if (!Number.isFinite(minX)) return null; - return { minX, maxX, minZ, maxZ }; - } - - /** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */ - function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null { - if (!terrain) return null; - const box = corridorBand(build); - if (!box) return null; - if (bandSplit?.covers(box)) return bandSplit; - bandSplit?.dispose(); - bandSplit = new TerrainBandSplit(terrain, { - minX: box.minX - BAND_MARGIN_M, - maxX: box.maxX + BAND_MARGIN_M, - minZ: box.minZ - BAND_MARGIN_M, - maxZ: box.maxZ + BAND_MARGIN_M, - }); - return bandSplit; - } - - function disposeCorridorGroup(): void { - if (!corridorGroup) return; - scene.remove(corridorGroup); - disposeObject(corridorGroup); - corridorGroup = null; - } - - /** 지형 높이 색인 확보 — 지형 1회당 한 번만 만든다(빌드 비용 O(삼각형)). */ - function ensureHeightIndex(): TerrainHeightIndex | null { - if (heightIndex) return heightIndex; - if (!terrain) return null; - // 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다. - heightIndex = - cachedTerrain.object === terrain && cachedTerrain.heightIndex - ? cachedTerrain.heightIndex - : new TerrainHeightIndex(terrain); - if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex; - return heightIndex.triangleCount > 0 ? heightIndex : null; - } - - /** - * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. - * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 - * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). - */ - function snapCorridorEdges(build: CorridorBuildResult): void { - if (!bounds) return; - const index = ensureHeightIndex(); - if (!index) return; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - build.ribbons.forEach((ribbon) => { - // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). - // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; - // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 - // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 - // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). - if (ribbon.patch) return; - const cols = ribbon.colCount; - const outerCol = ribbon.side === "right" ? 0 : cols - 1; - const innerCol = ribbon.side === "right" ? cols - 1 : 0; - for (let row = 0; row < ribbon.chainages.length; row += 1) { - const outer = (row * cols + outerCol) * 3; - const inner = (row * cols + innerCol) * 3; - // 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다. - const width = Math.hypot( - ribbon.positions[outer] - ribbon.positions[inner], - ribbon.positions[outer + 1] - ribbon.positions[inner + 1], - ); - if (width < 1e-4) continue; - const height = index.heightAt( - ribbon.positions[outer] - ox, - -(ribbon.positions[outer + 1] - oy), - ); - if (height !== null) ribbon.positions[outer + 2] = height + oz; - } - }); - } - - /** - * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 - * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 - * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. - */ - function attachPatchSkirts(build: CorridorBuildResult): void { - if (!bounds) return; - const index = ensureHeightIndex(); - if (!index) return; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => { - const height = index.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }); - const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); - build.cutWalls = [...kept, ...walls]; - (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; - } - - /** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */ - function scheduleTerrainClip(): void { - disposeClippedTerrain(); - if (!corridorBuild || !terrain || !bounds) { - applyCorridorVisibility(); - return; - } - const buildAtSchedule = corridorBuild; - requestAnimationFrame(() => { - // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. - if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const split = ensureBandSplit(buildAtSchedule); - if (!split) return; - const clipped = clipTerrain(split, buildAtSchedule, bounds); - disposeClippedTerrain(); - clippedTerrain = clipped; - scene.add(clipped); - // 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다. - (window as unknown as { __corridorClip?: unknown }).__corridorClip = { - source: countTriangles(terrain), - clipped: countTriangles(clipped), - detail: describeSurface(clipped), - sourceDetail: describeSurface(terrain), - }; - applyGrayscaleTo(clipped); // 흑백 토글 상태 유지. - applyCorridorVisibility(); - }); - applyCorridorVisibility(); - } - - function setCorridor(build: CorridorBuildResult | null): void { - disposeCorridorGroup(); - corridorBuild = build; - setCorridorBuildSummary(build); - if (build && bounds) { - snapCorridorEdges(build); - attachPatchSkirts(build); - corridorGroup = createCorridorGroup(build, bounds); - scene.add(corridorGroup); - structurePick.reapply(); - } - // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 - // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). - // `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아 - // 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다. - ( - window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean } - ).__corridorPlanCurves = (on?: boolean): boolean => { - const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP); - if (on !== undefined) { - try { - window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0"); - } catch { - // 저장소가 막힌 환경 — 이번 화면에만 적용한다. - } - if (sketch) sketch.visible = on; - } - return sketch?.visible ?? false; - }; - scheduleTerrainClip(); - } - return { root, markers, @@ -665,8 +425,7 @@ export function createRouteViewer(): RouteViewer { if (terrain !== cachedTerrain.object) disposeObject(terrain); } heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. - bandSplit?.dispose(); - bandSplit = null; + corridor.resetTerrain(); const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; const key = `${projectId}:${modelId}:${method}:${smooth}`; if (cachedTerrain.key === key && cachedTerrain.object) { @@ -704,7 +463,7 @@ export function createRouteViewer(): RouteViewer { applySurfaceGrayscale(); // 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단 // 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다. - if (corridorBuild) setCorridor(corridorBuild); + corridor.rebuild(); const fitted = performance.now(); fit("top"); markers.renderMarkers(); @@ -720,14 +479,10 @@ export function createRouteViewer(): RouteViewer { }, reloadContours, setSurfaceVisible(visible) { - surfaceOn = visible; - applyCorridorVisibility(); - }, - setCorridor, - setCorridorVisible(visible) { - corridorOn = visible; - applyCorridorVisibility(); + corridor.setSurfaceVisible(visible); }, + setCorridor: corridor.set, + setCorridorVisible: corridor.setVisible, setSurfaceGrayscale(grayscale) { if (surfaceGrayscale === grayscale) return; surfaceGrayscale = grayscale; @@ -749,23 +504,36 @@ export function createRouteViewer(): RouteViewer { status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; }, modelPointAt: markerInput.terrainPointAt, + setActive(active) { + structurePick.setListening(active); + if (!active) { + cancelAnimationFrame(frame); + frame = 0; + } else if (frame === 0) { + animate(); + } + }, dispose() { markerInput.dispose(); cancelAnimationFrame(frame); + frame = 0; resizeObserver.disconnect(); themeObserver.disconnect(); systemDarkTheme.removeEventListener("change", updateSceneBackground); releaseCursorPivot(); markers.dispose(); clearContours(); - disposeObject(terrain); + if (terrain) scene.remove(terrain); + // 보관 지형(`cachedTerrain`)은 다음 뷰어가 그대로 쓴다 — 버리면 다시 들어올 때 14초 파싱. + if (terrain !== cachedTerrain.object) disposeObject(terrain); structurePick.dispose(); - corridorBuild = null; // 예약된 클립 콜백 무효화. - disposeCorridorGroup(); - disposeClippedTerrain(); - bandSplit?.dispose(); + corridor.dispose(); controls.dispose(); renderer.dispose(); + // dispose 만으로는 WebGL 문맥이 남는다 — 브라우저 문맥 수 한도(16)에 쌓이지 않게 놓는다. + renderer.forceContextLoss(); + if (probe === (window as unknown as { __routeViewer?: unknown }).__routeViewer) + delete (window as unknown as { __routeViewer?: unknown }).__routeViewer; }, }; } diff --git a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts new file mode 100644 index 00000000..d7492a9f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts @@ -0,0 +1,331 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Corridor.ts + * 3D 뷰어의 예상형상(코리도) 조각 — 원본/클리핑 지형 스왑 + 계획 서피스 조합. + * + * 뷰어 본체(`B05_Profile_UI_Viewer.ts`)가 700줄을 넘어 이 부분만 뗐다(계획서 24-3a). + * 지형 · 범위 · 높이 색인은 뷰어가 쥐고, 여기서는 받아 읽기만 한다. + * ========================================================================== */ + +import * as THREE from "three"; +import type { ModelBounds } from "./B05_Profile_UI_Markers"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { + createCorridorGroup, + PLAN_CURVE_FLAG, + PLAN_CURVE_GROUP, +} from "./B05_Profile_UI_Corridor_Mesh"; +import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; +import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; +import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; +import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; +import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; + +/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ +function countTriangles(root: THREE.Object3D | null): number { + let total = 0; + root?.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const index = child.geometry.getIndex(); + const position = child.geometry.getAttribute("position"); + total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3); + }); + return total; +} + +/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */ +function describeSurface(root: THREE.Object3D | null): unknown { + const out: unknown[] = []; + root?.traverse((child) => { + if (!(child instanceof THREE.Mesh) || out.length >= 2) return; + const geometry = child.geometry; + const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; + const material = ( + Array.isArray(child.material) ? child.material[0] : child.material + ) as THREE.MeshLambertMaterial; + out.push({ + type: material?.type, + vertexColors: material?.vertexColors, + matColor: material?.color?.getHexString(), + side: material?.side, + colorSize: color?.itemSize, + color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null, + normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null, + hasNormal: Boolean(normal), + }); + }); + return out; +} + +export function disposeObject(object: THREE.Object3D | null): void { + object?.traverse((child) => { + if ( + child instanceof THREE.Mesh || + child instanceof THREE.Points || + child instanceof THREE.Line + ) { + child.geometry.dispose(); + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((material) => material.dispose()); + } + }); +} + +export interface ViewerCorridorDeps { + scene: THREE.Scene; + terrain: () => THREE.Object3D | null; + bounds: () => ModelBounds | null; + heightIndex: () => TerrainHeightIndex | null; + /** 코리도 그룹을 새로 만들었을 때 — 구조물 강조를 다시 입힌다. */ + onGroup: () => void; + /** 클리핑본에도 흑백 토글을 입힌다. */ + applyGrayscale: (target: THREE.Object3D | null) => void; +} + +export interface ViewerCorridor { + set: (build: CorridorBuildResult | null) => void; + setVisible: (visible: boolean) => void; + setSurfaceVisible: (visible: boolean) => void; + /** 지형이 바뀌기 전 — 밴드 분할본을 버린다. */ + resetTerrain: () => void; + /** 지형 · 범위가 준비된 뒤 — 미뤄 둔 코리도를 다시 조립한다. */ + rebuild: () => void; + group: () => THREE.Group | null; + clipped: () => THREE.Object3D | null; + dispose: () => void; +} + +export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { + const { scene } = deps; + let corridorBuild: CorridorBuildResult | null = null; + let corridorGroup: THREE.Group | null = null; + let clippedTerrain: THREE.Object3D | null = null; + /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ + let bandSplit: TerrainBandSplit | null = null; + let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. + let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). + + /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ + function applyCorridorVisibility(): void { + const terrain = deps.terrain(); + const swapped = corridorOn && clippedTerrain !== null; + if (terrain) terrain.visible = surfaceOn && !swapped; + if (clippedTerrain) clippedTerrain.visible = surfaceOn && swapped; + if (corridorGroup) corridorGroup.visible = corridorOn; + } + + function disposeClippedTerrain(): void { + if (!clippedTerrain) return; + scene.remove(clippedTerrain); + // 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에 + // 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다. + clippedTerrain.traverse((child) => { + if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return; + if (child.userData.owned) child.geometry.dispose(); + }); + clippedTerrain = null; + } + + /** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */ + function corridorBand(build: CorridorBuildResult): SceneBox | null { + const bounds = deps.bounds(); + if (!bounds) return null; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + [...build.outline.left, ...build.outline.right].forEach(([mx, my]) => { + const x = mx - ox; + const z = -(my - oy); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + }); + if (!Number.isFinite(minX)) return null; + return { minX, maxX, minZ, maxZ }; + } + + /** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */ + function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null { + const terrain = deps.terrain(); + if (!terrain) return null; + const box = corridorBand(build); + if (!box) return null; + if (bandSplit?.covers(box)) return bandSplit; + bandSplit?.dispose(); + bandSplit = new TerrainBandSplit(terrain, { + minX: box.minX - BAND_MARGIN_M, + maxX: box.maxX + BAND_MARGIN_M, + minZ: box.minZ - BAND_MARGIN_M, + maxZ: box.maxZ + BAND_MARGIN_M, + }); + return bandSplit; + } + + function disposeCorridorGroup(): void { + if (!corridorGroup) return; + scene.remove(corridorGroup); + disposeObject(corridorGroup); + corridorGroup = null; + } + + /** + * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. + * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 + * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). + */ + function snapCorridorEdges(build: CorridorBuildResult): void { + const bounds = deps.bounds(); + if (!bounds) return; + const index = deps.heightIndex(); + if (!index) return; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + build.ribbons.forEach((ribbon) => { + // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). + // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. + if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; + // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 + // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 + // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). + if (ribbon.patch) return; + const cols = ribbon.colCount; + const outerCol = ribbon.side === "right" ? 0 : cols - 1; + const innerCol = ribbon.side === "right" ? cols - 1 : 0; + for (let row = 0; row < ribbon.chainages.length; row += 1) { + const outer = (row * cols + outerCol) * 3; + const inner = (row * cols + innerCol) * 3; + // 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다. + const width = Math.hypot( + ribbon.positions[outer] - ribbon.positions[inner], + ribbon.positions[outer + 1] - ribbon.positions[inner + 1], + ); + if (width < 1e-4) continue; + const height = index.heightAt( + ribbon.positions[outer] - ox, + -(ribbon.positions[outer + 1] - oy), + ); + if (height !== null) ribbon.positions[outer + 2] = height + oz; + } + }); + } + + /** + * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 + * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 + * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. + */ + function attachPatchSkirts(build: CorridorBuildResult): void { + const bounds = deps.bounds(); + if (!bounds) return; + const index = deps.heightIndex(); + if (!index) return; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; + const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => { + const height = index.heightAt(x - ox, -(y - oy)); + return height === null ? null : height + oz; + }); + const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); + build.cutWalls = [...kept, ...walls]; + (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; + } + + /** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */ + function scheduleTerrainClip(): void { + disposeClippedTerrain(); + if (!corridorBuild || !deps.terrain() || !deps.bounds()) { + applyCorridorVisibility(); + return; + } + const buildAtSchedule = corridorBuild; + requestAnimationFrame(() => { + const terrain = deps.terrain(); + const bounds = deps.bounds(); + // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. + if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; + const split = ensureBandSplit(buildAtSchedule); + if (!split) return; + const clipped = clipTerrain(split, buildAtSchedule, bounds); + disposeClippedTerrain(); + clippedTerrain = clipped; + scene.add(clipped); + // 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다. + (window as unknown as { __corridorClip?: unknown }).__corridorClip = { + source: countTriangles(terrain), + clipped: countTriangles(clipped), + detail: describeSurface(clipped), + sourceDetail: describeSurface(terrain), + }; + deps.applyGrayscale(clipped); // 흑백 토글 상태 유지. + applyCorridorVisibility(); + }); + applyCorridorVisibility(); + } + + function set(build: CorridorBuildResult | null): void { + disposeCorridorGroup(); + corridorBuild = build; + setCorridorBuildSummary(build); + const bounds = deps.bounds(); + if (build && bounds) { + snapCorridorEdges(build); + attachPatchSkirts(build); + corridorGroup = createCorridorGroup(build, bounds); + scene.add(corridorGroup); + deps.onGroup(); + } + // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 + // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). + // `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아 + // 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다. + ( + window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean } + ).__corridorPlanCurves = (on?: boolean): boolean => { + const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP); + if (on !== undefined) { + try { + window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0"); + } catch { + // 저장소가 막힌 환경 — 이번 화면에만 적용한다. + } + if (sketch) sketch.visible = on; + } + return sketch?.visible ?? false; + }; + scheduleTerrainClip(); + } + + return { + set, + setVisible(visible) { + corridorOn = visible; + applyCorridorVisibility(); + }, + setSurfaceVisible(visible) { + surfaceOn = visible; + applyCorridorVisibility(); + }, + resetTerrain() { + bandSplit?.dispose(); + bandSplit = null; + }, + rebuild() { + if (corridorBuild) set(corridorBuild); + }, + group: () => corridorGroup, + clipped: () => clippedTerrain, + dispose() { + corridorBuild = null; // 예약된 클립 콜백 무효화. + disposeCorridorGroup(); + disposeClippedTerrain(); + bandSplit?.dispose(); + bandSplit = null; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 4235b2e4..1fab0d9d 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -28,6 +28,8 @@ export interface StructurePickControls { select: (pick: StructurePick | null) => void; /** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */ reapply: () => void; + /** 창(window)에 건 듣기를 잇고 뗀다 — 뷰어가 숨으면 뗀다(계획서 24-3a). */ + setListening: (on: boolean) => void; dispose: () => void; } @@ -160,6 +162,10 @@ export function bindStructurePick(options: { applyHighlight(); }, reapply: applyHighlight, + setListening(on) { + window.removeEventListener("pointerdown", handleWindowDown, true); + if (on) window.addEventListener("pointerdown", handleWindowDown, true); + }, dispose() { window.removeEventListener("pointerdown", handleWindowDown, true); canvas.removeEventListener("pointerdown", handleDown, true); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index dea24b1c..d1204544 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -60,9 +60,13 @@ import { createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import { createCrossViewPart, createStandardSlot } from "./B06_Section_UI_Page_Panel"; import { createDesignPanel } from "../A00_Common/design/design_panel"; import { resetDesign } from "../A00_Common/design/design_reset"; +import type { DesignPageHost } from "../A00_Common/design/design_page"; import "@util/common_util_mass_haul.css"; -export async function renderB06ProfileCross(root: HTMLElement): Promise { +export async function renderB06ProfileCross( + root: HTMLElement, + host?: DesignPageHost, +): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; @@ -494,6 +498,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }), }; + // 한 페이지 두 보기(계획서 24-3a) — 숨으면 종단도 창 듣기를 떼고, 다시 보이면 종단 보기에서 + // 바뀐 구조물 · 관 · 계획고를 가게에서 다시 읽어 카드 · 종단도를 그린다. 떠나면 정리한다. + host?.onHide(() => sectionView.setActive(false)); + host?.onShow(() => { + sectionView.setActive(true); + if (currentRouteId === null) return; + void structuresPanel.load().then(restoreStructurePick); + void refreshDetailForStructures().then(() => reconcileStaleDesigns()); + }); + host?.onDispose(() => sectionView.dispose()); + let workflowState: WorkflowState | undefined; let context: SectionContextResponse | null = null; if (projectId) { diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index a09925d1..652a33d8 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -465,6 +465,7 @@ export function createSectionView( selectedStationId = null; cards.replaceChildren(); }, + setActive: (active) => profile.setActive(active), dispose() { window.clearTimeout(resizeTimer); resizeObserver.disconnect(); diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index a7218998..6d1f532e 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -70,6 +70,7 @@ export interface SectionProfile { redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ showCard: (section: CrossSection | null) => void; + setActive: (active: boolean) => void; dispose: () => void; } @@ -254,6 +255,7 @@ export function createSectionProfile(host: SectionProfileHost): SectionProfile { ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); }, + setActive: (active) => profile?.setActive(active), dispose() { profile?.dispose(); profile = null; diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 2c5b05c8..bc434451 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -52,5 +52,7 @@ export interface SectionViewController { ) => void; /** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */ setStructureEdit: (edit: SectionStructureEdit | null) => void; + /** 보기가 숨으면 false — 종단도의 창 키 듣기를 떼고, 보이면 잇는다(계획서 24-3a). */ + setActive: (active: boolean) => void; dispose: () => void; } diff --git a/resources/tester/test_b05_corridor_envelope_flags.py b/resources/tester/test_b05_corridor_envelope_flags.py index 8b276104..3bb2c3e2 100644 --- a/resources/tester/test_b05_corridor_envelope_flags.py +++ b/resources/tester/test_b05_corridor_envelope_flags.py @@ -21,7 +21,8 @@ if str(PROJECT_ROOT) not in sys.path: # 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리). CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts" -VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts" +# 지형 스냅 가드는 뷰어에서 코리도 조각으로 옮겼다(계획서 24-3a · 700줄 나눔). +VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer_Corridor.ts" CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts" CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts" diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py new file mode 100644 index 00000000..4fdfb920 --- /dev/null +++ b/resources/tester/test_design_page_single.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""한 페이지 뼈대(계획서 24-3a) · 겹친 알약(24-6b) 회귀검증. + +주소 둘(`#/b05-profile` · `#/b06-section`)이 같은 페이지(`A00_Common/design/design_page.ts`)를 +열고, 같은 페이지 안 이동은 다시 만들지 않고 보기만 바꾸는지, 떠날 때 3D(WebGL 문맥) · +종단도 · 횡단을 정리하는지, 숨긴 3D 는 그리기 루프를 멈추는지, 창에 건 키 듣기를 떼는지 본다. +화면 수치(문맥 수 · 루프 · 같은 노드 · 전환 시간)는 ORCA 로 잰다 — 여기서는 길이 끊기지 않게 잠근다. +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +def test_주소_둘이_같은_페이지(): + router = _read("A00_Common/router.ts") + assert router.count('(await import("./design/design_page")).renderDesignPage') == 2 + assert "export function showWithinPage(" in router + # 다른 페이지로 떠나면 정리 · 늦게 끝난 옛 렌더는 버림 + assert "mounted?.dispose();" in router + assert "if (page && seq !== renderSeq) page.dispose();" in router + + +def test_같은_페이지면_셸도_다시_안_그림(): + main = _read("A00_Common/main.ts") + within = main.index("showWithinPage(currentRoute())") + assert within < main.index("renderShell(appRoot") + assert "if (within) return;" in main + + +def test_보기_약속_두_길(): + page = _read("A00_Common/design/design_page.ts") + assert "export interface DesignPageView" in page + assert "mount?: (root: HTMLElement, host: DesignPageHost)" in page + assert "mountCenter?: (" in page and "shared: DesignPageShared" in page + for hook in ("onShow:", "onHide:", "onDispose:"): + assert hook in page + # 보기는 처음 보일 때 한 번만 만든다 — 있으면 보이기만. + assert "if (slot) slot.show();" in page + + +def test_페이지가_숨김_떠남을_건다(): + life = _read("B05_Profile/B05_Profile_UI_Page_Life.ts") + assert "deps.viewer.setActive(false)" in life and "deps.viewer.dispose()" in life + assert "bindProfileViewLife(host," in _read("B05_Profile/B05_Profile_UI_Page.ts") + b06 = _read("B06_Section/B06_Section_UI_Page.ts") + assert "host?.onHide(() => sectionView.setActive(false));" in b06 + assert "host?.onDispose(() => sectionView.dispose());" in b06 + + +def test_3D_멈춤과_문맥_놓기(): + viewer = _read("B05_Profile/B05_Profile_UI_Viewer.ts") + assert "setActive(active)" in viewer + assert "renderer.forceContextLoss();" in viewer + # 보관 지형은 다음 뷰어가 쓴다 — 떠날 때 버리지 않는다. + assert "if (terrain !== cachedTerrain.object) disposeObject(terrain);" in viewer + assert len(viewer.splitlines()) <= 700 + + +def test_창_키_듣기를_뗌(): + tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") + assert 'window.removeEventListener("keydown", onKeyDown);' in tools + assert 'window.addEventListener("keydown", (event)' not in tools + profile = _read("A00_Common/design/design_profile.ts") + assert "setListening(false);" in profile # dispose + pick = _read("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts") + assert "setListening(on)" in pick + + +def test_겹친_알약은_누른_것을_그대로(): + profile = _read("A00_Common/design/design_profile.ts") + assert "stationIdAtStructure(selectedStructureId) === stationId" in profile