Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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<DesignView, RoutePath> = {
|
||||
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<void>;
|
||||
mountCenter?: (
|
||||
center: HTMLElement,
|
||||
host: DesignPageHost,
|
||||
shared: DesignPageShared,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** 보기 정의 — 처음 보일 때 읽는다(번들을 보기별로 나눠 둔다). */
|
||||
const VIEWS: Record<DesignView, () => Promise<DesignPageView>> = {
|
||||
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<DesignView, ViewSlot>();
|
||||
let current: DesignView | null = null;
|
||||
let frame: (DesignPageShared & { root: HTMLElement; center: HTMLElement }) | null = null;
|
||||
|
||||
/** 끝 모습의 틀 — mountCenter 보기가 처음 올 때만 만든다(틀 꾸밈은 24-3b). */
|
||||
const ensureFrame = (): NonNullable<typeof frame> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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); // 아울렛에 페이지 렌더
|
||||
|
||||
+33
-6
@@ -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<void>;
|
||||
export type PageRenderer = (root: HTMLElement) => void | MountedPage | Promise<void | MountedPage>;
|
||||
|
||||
/** 라우트별 지연 로더 등록 테이블.
|
||||
* 페이지 구현이 완료되는 대로 아래에 한 줄씩 추가한다. */
|
||||
@@ -41,10 +51,9 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(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<boolean>) | null): void => {
|
||||
};
|
||||
export const takeLeaveGuard = (): (() => Promise<boolean>) | 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
export async function renderB05Route(root: HTMLElement, host?: DesignPageHost): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) {
|
||||
showToast("프로젝트를 먼저 선택하세요.", "error");
|
||||
@@ -647,6 +649,19 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
// ⑤ 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<void> {
|
||||
current.surface_params.contour_interval_m,
|
||||
toBounds(surface.bounds),
|
||||
);
|
||||
if (host?.disposed()) return; // 올리는 사이 페이지를 떠났다.
|
||||
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
|
||||
if (currentSectionDetail) renderStationLines(currentSectionDetail);
|
||||
showToast("3D 지형 준비 완료", "success");
|
||||
|
||||
@@ -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<void>;
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
export async function renderB06ProfileCross(
|
||||
root: HTMLElement,
|
||||
host?: DesignPageHost,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
}),
|
||||
};
|
||||
|
||||
// 한 페이지 두 보기(계획서 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) {
|
||||
|
||||
@@ -465,6 +465,7 @@ export function createSectionView(
|
||||
selectedStationId = null;
|
||||
cards.replaceChildren();
|
||||
},
|
||||
setActive: (active) => profile.setActive(active),
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeObserver.disconnect();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user