Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-09-29 21:55:36 +09:00
17 changed files with 845 additions and 304 deletions
+11
View File
@@ -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;
}
+212
View File
@@ -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;
},
};
}
+16 -2
View File
@@ -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();
+3
View File
@@ -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
View File
@@ -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);
+18 -2
View File
@@ -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");
+48
View File
@@ -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 };
}
+57 -289
View File
@@ -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);
+16 -1
View File
@@ -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