Files
Aislo/B05_Profile/B05_Profile_UI_Viewer.ts
T
eomsangdonandClaude Opus 5.5 90a2cb9a11 refactor(B05·B06): 한 페이지 뼈대 — 주소 둘이 같은 페이지(design_page) · 같은 페이지 안 이동은 보기만 바꿈 · 떠날 때 3D · 종단도 · 횡단 정리 · 숨긴 3D 그리기 멈춤 · 창 키 듣기 뗌 · 뷰어 나눔(771→541)
- 라우터: 여러 주소 페이지(MountedPage) 길 · 같은 페이지면 셸도 다시 안 그림 · 떠나면 dispose · 늦게 끝난 옛 렌더 버림
- design_page: 보기 약속 DesignPageView(mount 중간 단계 · mountCenter 끝 모습 + shared 한 벌 자리) · host.onShow · onHide · onDispose
- 3D 뷰어: setActive(루프 멈춤 · 창 포인터 뗌) · dispose 가 WebGL 문맥까지 놓음 · 보관 지형은 남김 · 코리도 조각을 Viewer_Corridor 로
- 종단도: setActive(창 키 듣기 뗌 · 보일 때 초안 · 판 다시 읽음)
- 겹친 알약: 같은 측점이면 누른 알약을 그대로(24-6b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 21:39:31 +09:00

540 lines
25 KiB
TypeScript

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { bindCursorPivotControls, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera";
import {
createRouteMarkers,
modelToScene,
sceneToModel,
type ModelBounds,
type RouteMarkers,
type SectionStationMarker,
} from "./B05_Profile_UI_Markers";
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 { 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";
type ViewKind = "iso" | "top" | "front" | "side";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38;
/**
* 파싱해 둔 지표면 한 벌 — **뷰어보다 오래 산다**.
*
* B05 는 해시가 바뀔 때마다 `renderB05Route` 로 통째로 다시 조립되므로 뷰어 안의 상태는
* 매번 비워진다. 그러면 8MB 짜리 지표면을 화면에 들어올 때마다 다시 읽고 다시 파싱하는데,
* 실측에서 그 값이 **단일 동기 블록 14.4초**였다(2026-09-06 공용 브라우저 3왕복).
* 같은 모델이면 이 자리에 둔 것을 새 장면에 그대로 붙인다.
*
* 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게).
* 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다.
*/
const cachedTerrain: {
key: string | null;
object: THREE.Object3D | null;
/** 높이 격자 색인 — 만드는 값이 O(삼각형)이라 지형과 한 벌로 쥔다. */
heightIndex: TerrainHeightIndex | null;
} = { key: null, object: null, heightIndex: null };
declare global {
interface Window {
/** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */
__surfaceTiming?: Array<{ step: string; ms: number }>;
}
}
export interface RouteViewer {
root: HTMLElement;
markers: RouteMarkers;
loadSurface: (
projectId: string,
modelId: number,
method: string,
smooth: boolean,
interval: number,
bounds: ModelBounds,
) => Promise<void>;
reloadContours: (interval: number) => Promise<void>;
setSurfaceVisible: (visible: boolean) => void;
/** 지표면 흑백 표시 — 무지개 고도색이 헷갈릴 때 명도만 남긴다(정점색 1회 변환, 성능 영향 없음). */
setSurfaceGrayscale: (grayscale: boolean) => void;
setContoursVisible: (visible: boolean) => void;
setAxesVisible: (visible: boolean) => void;
setStationLinesVisible: (visible: boolean) => void;
/** 구조물(비정규) 측점의 번호·이름 라벨 표시 토글. */
setStationLabelsVisible: (visible: boolean) => void;
/**
* 계획노선 코리도(예상형상) 서피스 반영. build의 비탈 최외곽 정점 z는 지형
* 메쉬에 투영(심 보정)되므로 **전달 객체가 제자리 수정**된다 — 저장 직렬화는
* 이 호출 뒤에 할 것. null이면 코리도 제거.
*/
setCorridor: (build: CorridorBuildResult | null) => void;
/** [예상형상] 토글 — ON: 클리핑 지형+코리도(공사 후), OFF: 원본 지형 완전체. */
setCorridorVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
setView: (view: "iso" | "top" | "front" | "side") => void;
/** 직교/원근 전환(2026-09-04) — 보이는 크기를 유지한 채 카메라만 갈아 끼운다. */
setProjection: (kind: ProjectionKind) => void;
beginMoveSelected: () => void;
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
/** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */
structurePick: StructurePickControls;
/** 보일 때 true — 그리기 루프 · 창 포인터 듣기를 잇고, false 면 멈추고 뗀다. */
setActive: (active: boolean) => void;
/** 떠날 때 — WebGL 문맥까지 놓는다(보관 지형은 다음 뷰어가 다시 쓰므로 남긴다). */
dispose: () => void;
}
export function createRouteViewer(): RouteViewer {
const root = document.createElement("div");
root.className = "b05-route__viewport";
const status = document.createElement("div");
status.className = "b05-route__viewer-status";
status.textContent = "확정 지표면을 불러오는 중입니다.";
const canvas = document.createElement("canvas");
// 방위 나침반 — ISO 버튼 아래(2026-09-03 사용자 지시). 지면에 누운 링이라 사시도에서도
// 화면 북쪽과 어긋나지 않는다. 위젯은 B04 지표면 뷰어와 **같은 공용 모듈**을 쓴다.
const compass = createTerrainCompass({ sizePx: 104, className: "b05-route__compass" });
root.append(canvas, status, compass.root);
const scene = new THREE.Scene();
const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)");
function updateSceneBackground(): void {
const theme = document.documentElement.getAttribute("data-theme");
const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches);
if (!dark) {
scene.background = new THREE.Color(LIGHT_VIEWER_BACKGROUND);
return;
}
const surfaceRaised = getComputedStyle(document.documentElement)
.getPropertyValue("--color-surface-raised")
.trim();
scene.background = new THREE.Color(
surfaceRaised && CSS.supports("color", surfaceRaised)
? surfaceRaised
: DARK_VIEWER_BACKGROUND,
);
}
const themeObserver = new MutationObserver(updateSceneBackground);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
systemDarkTheme.addEventListener("change", updateSceneBackground);
updateSceneBackground();
// 카메라는 원근(기본)·직교 두 벌을 두고 갈아 끼운다 — 갈아 끼우면 **객체가 바뀌므로**
// 붙잡아 두지 말고 `cameraRig.camera()`로 그때그때 읽는다(2026-09-04 사용자 지시).
const cameraRig = createCameraRig();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const controls = new OrbitControls(cameraRig.camera(), canvas);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2));
const directional = new THREE.DirectionalLight(0xffffff, 2.2);
directional.position.set(100, 200, 100);
scene.add(directional);
const axes = new THREE.AxesHelper(30);
axes.visible = false;
scene.add(axes);
let terrain: THREE.Object3D | null = null;
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
//
// 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시
// 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다).
// 코리도 · 클리핑 지형 · 밴드 분할본은 `_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;
/** 지형 높이 색인 확보 — 지형 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;
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
null;
/**
* 모델 좌표 (x, y) 자리의 지형 표고. 지형 위에서 수직으로 광선을 쏴 맞은 높이를 돌려준다.
* 계획노선 CSV로 만든 점처럼 표고가 없는 자리를 지형에 얹을 때 쓴다.
*/
function terrainElevation(x: number, y: number): number | null {
if (!terrain || !bounds) return null;
const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds);
// 격자 색인으로 찾는다 — Raycaster 는 한 번 쏠 때마다 삼각형을 전부 훑는다.
// 마커·측점선이 점마다 부르는 자리라, B05 진입에서 **13.5초 중 11.6초**가 여기였다
// (2026-09-06 CPU 프로파일: getVertexPosition·intersectTriangle·checkGeometryIntersection).
// 같은 교훈을 2026-08-23 비탈 투영에서 이미 한 번 겪어 색인을 만들어 뒀다.
const index = ensureHeightIndex();
if (index) {
const height = index.heightAt(origin.x, origin.z);
return height === null
? null
: sceneToModel(new THREE.Vector3(origin.x, height, origin.z), bounds).z;
}
const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0));
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds).z : null;
}
// 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물
// 솔리드가 덮고 있을 수 있다). 씬·THREE·모델→씬 변환을 내보내 위에서 레이캐스트로
// 확인한다(`__corridorBuild`·`__corridorClip`과 같은 용도).
(window as unknown as { __corridorScene?: unknown }).__corridorScene = {
scene,
THREE,
// 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지
// 확인해야 3D 클릭 검증을 수치로 할 수 있다.
get camera() {
return cameraRig.camera();
},
toScene: (x: number, y: number, z: number) =>
bounds ? modelToScene({ x, y, z }, bounds) : null,
topZ: () => (bounds ? bounds.z[1] + 100 : null),
// 카메라를 모델 좌표 한 점으로 바로 보낸다 — 측점 확인을 마우스 휠·드래그로 하면
// 한 번에 20~30초가 걸리고 우클릭이 구조물 메뉴를 연다(2026-09-02). 화면 검증 전용.
lookAt: (x: number, y: number, z: number, distance: number, view: ViewKind = "top") => {
if (!bounds) return false;
placeCamera(modelToScene({ x, y, z }, bounds), distance, view);
return true;
},
// 모델 좌표 한 점의 **화면(client) 좌표**(2026-09-04) — 3D 물체를 실제 마우스로
// 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다.
project: (x: number, y: number, z: number) => {
if (!bounds) return null;
const point = modelToScene({ x, y, z }, bounds).project(cameraRig.camera());
const rect = canvas.getBoundingClientRect();
return {
x: rect.left + ((point.x + 1) / 2) * rect.width,
y: rect.top + ((1 - point.y) / 2) * rect.height,
};
},
};
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
// 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한).
const markerInput = bindMarkerPointerControls({
canvas,
camera: cameraRig.camera,
controls,
markers,
getTerrain: () => terrain,
getBounds: () => bounds,
});
// 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다.
const structurePick = bindStructurePick({
canvas,
camera: cameraRig.camera,
group: () => corridor.group(),
blocked: () => markerInput.blocked(),
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
const releaseCursorPivot = bindCursorPivotControls({
camera: cameraRig.camera,
controls,
element: canvas,
pickables: () => (terrain ? [terrain] : []),
blocked: () => markerInput.blocked(),
scene,
});
function clearContours(): void {
disposeObject(contours);
contours.clear();
}
function resize(): void {
const width = Math.max(1, root.clientWidth);
const height = Math.max(1, root.clientHeight);
renderer.setSize(width, height, false);
cameraRig.setAspect(width / height);
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
function placeCamera(target: THREE.Vector3, distance: number, view: ViewKind): void {
controls.target.copy(target);
const positions = {
iso: [distance, distance, distance],
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
top: [0, distance, distance * TOP_VIEW_TILT],
front: [0, distance * 0.25, distance],
side: [distance, distance * 0.25, 0],
} as const;
const [x, y, z] = positions[view];
const camera = cameraRig.camera();
camera.position.set(target.x + x, target.y + y, target.z + z);
// 근평면 상한 0.4m — 휠 확대는 커서 아래 지점 0.5m 앞에서 멈춘다(커서 피벗 유틸).
// 거리에만 비례시키면 긴 노선(맞춤 거리 1km 이상)에서 근평면이 그 0.5m를 넘어
// 최대 확대 시 지형이 잘린다(2026-09-04 원근 복귀 실측: 400m 노선 여유 13mm).
camera.near = Math.max(0.1, Math.min(0.4, distance / 1000));
camera.far = distance * 10;
camera.updateProjectionMatrix();
cameraRig.setFit(distance);
controls.update();
}
function fit(view: ViewKind = "top"): void {
if (!bounds) return;
const width = bounds.x[1] - bounds.x[0];
const depth = bounds.y[1] - bounds.y[0];
placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view);
}
async function reloadContours(interval: number): Promise<void> {
if (!current || !bounds) return;
current.interval = interval;
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
const data = await fetchCachedJson<{
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
}>(
current.projectId,
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
);
clearContours();
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
const majorPoints: THREE.Vector3[] = [];
const minorPoints: THREE.Vector3[] = [];
const cx = (bounds.x[0] + bounds.x[1]) / 2;
const cy = (bounds.y[0] + bounds.y[1]) / 2;
const cz = (bounds.z[0] + bounds.z[1]) / 2;
data.contours.forEach((contour) => {
const points = contour.coordinates.map(
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
);
if (points.length < 2) return;
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
for (let index = 0; index < points.length - 1; index += 1) {
bucket.push(points[index], points[index + 1]);
}
});
[
{ points: minorPoints, color: 0xf59e0b },
{ points: majorPoints, color: 0xd97706 },
].forEach(({ points, color }) => {
if (points.length === 0) return;
contours.add(
new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
),
);
});
}
/** 그리기 루프 — 0 이면 멈춤. 숨긴 보기(횡단 보기)에서는 멈춘다(계획서 24-3a). */
let frame = 0;
function animate(): void {
frame = requestAnimationFrame(animate);
drawCount += 1;
controls.update();
// 나침반은 시선 방향이 실제로 바뀔 때만 다시 그린다(모듈 안에서 걸러낸다).
if (terrain) {
compass.setVisible(true);
compass.update(
cameraRig.camera().position.x - controls.target.x,
cameraRig.camera().position.y - controls.target.y,
cameraRig.camera().position.z - controls.target.z,
);
} else {
compass.setVisible(false);
}
// 측점 라벨 솎기 — 가까울수록 촘촘히 보인다(단계가 안 바뀌면 모듈 안에서 걸러낸다).
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, corridor.clipped()].forEach((target) => applyGrayscaleTo(target));
}
function applyGrayscaleTo(target: THREE.Object3D | null): void {
target?.traverse((child) => {
const geometry = (child as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
const color = geometry?.getAttribute("color") as THREE.BufferAttribute | undefined;
if (!geometry || !color) return;
const target = color.array as Float32Array;
if (!child.userData.originalColors) child.userData.originalColors = target.slice();
const original = child.userData.originalColors as Float32Array;
const stride = color.itemSize;
if (surfaceGrayscale) {
for (let i = 0; i + 2 < original.length; i += stride) {
const gray = 0.299 * original[i] + 0.587 * original[i + 1] + 0.114 * original[i + 2];
target[i] = target[i + 1] = target[i + 2] = gray;
}
} else {
target.set(original);
}
color.needsUpdate = true;
});
}
return {
root,
markers,
structurePick,
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
const step = (label: string, from: number): void => {
timing.push({ step: label, ms: Math.round(performance.now() - from) });
};
const timing: Array<{ step: string; ms: number }> = [];
const started = performance.now();
bounds = nextBounds;
current = { projectId, modelId, smooth, interval };
if (terrain) {
scene.remove(terrain);
if (terrain !== cachedTerrain.object) disposeObject(terrain);
}
heightIndex = 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) {
// 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06).
terrain = cachedTerrain.object;
step("reuse", started);
} else {
const fetched = performance.now();
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
step("fetch", fetched);
const parsed = performance.now();
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") {
const geometry = new PLYLoader().parse(buffer);
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
} else {
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
}
});
terrain.traverse((child) => {
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
});
step("parse", parsed);
// 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다.
if (cachedTerrain.object && cachedTerrain.object !== terrain) {
disposeObject(cachedTerrain.object);
}
cachedTerrain.key = key;
cachedTerrain.object = terrain;
cachedTerrain.heightIndex = null; // 새 지형이면 색인도 새로.
}
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
applySurfaceGrayscale();
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
corridor.rebuild();
const fitted = performance.now();
fit("top");
markers.renderMarkers();
step("fit+markers", fitted);
const contoured = performance.now();
await reloadContours(interval);
step("contours", contoured);
step("total", started);
window.__surfaceTiming = timing;
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
status.textContent = "";
},
reloadContours,
setSurfaceVisible(visible) {
corridor.setSurfaceVisible(visible);
},
setCorridor: corridor.set,
setCorridorVisible: corridor.setVisible,
setSurfaceGrayscale(grayscale) {
if (surfaceGrayscale === grayscale) return;
surfaceGrayscale = grayscale;
applySurfaceGrayscale();
},
setContoursVisible(visible) {
contours.visible = visible;
},
setAxesVisible(visible) {
axes.visible = visible;
},
setStationLinesVisible: markers.setStationLinesVisible,
setStationLabelsVisible: markers.setStationLabelsVisible,
renderStationLines: markers.renderStationLines,
setView: fit,
setProjection: (kind) => cameraRig.setKind(kind, controls),
beginMoveSelected() {
markerInput.beginMoveSelected();
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();
if (terrain) scene.remove(terrain);
// 보관 지형(`cachedTerrain`)은 다음 뷰어가 그대로 쓴다 — 버리면 다시 들어올 때 14초 파싱.
if (terrain !== cachedTerrain.object) disposeObject(terrain);
structurePick.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;
},
};
}