Files
Aislo/B05_Profile/B05_Profile_UI_Viewer.ts
T
eomsangdonandClaude Opus 5 7fdbb343ab feat(B05): 코리도 편집 즉시 반영 + 프리즈 제거 + 구분 품질 4종
편집 반영
- Profile_Panel 프리뷰를 full_designs로 올려 설계선까지 갱신, 갱신 후
  onCrossDesignsUpdated 콜백 → Page가 코리도 재빌드(500ms 디바운스)
- 종단 계획고를 올리면 3D 예상형상이 그 자리에서 따라 바뀐다

프리즈 제거 (프레임 갭 실측 3603ms → 100ms, 100ms 초과 0회)
- Corridor_Terrain(신규): 지형 XZ 균일격자 높이 색인. 심 보정 raycast가
  BVH 없이 전 삼각형을 훑어 3.6초 단일 블록을 만들던 것을 버킷 조회로 대체.
  지형 로드당 1회만 구축
- Corridor_Clip: 스트립 셀·경계선분 균일격자 색인, 핫루프를 정점 복사에서
  인덱스 재구성으로 전환(원본 버퍼 재사용, 잘린 조각만 덧붙임)

구분 품질 (사용자 확정 4종)
- 색 재설계: 지형 고도색(주황~녹)과 겹치던 적갈/초록을 자주(절토)·청록(성토)
  으로, 도로계는 무채색, 측구 하늘색
- 종류 경계 윤곽선, 절·성토 빗금(해칭) 텍스처 방향 구분(절 우상향/성 좌상향)
- 시·종점 마구리 봉인 — 설계선↔지반선 절단면 스트립(저장 왕복 포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:11:49 +09:00

596 lines
24 KiB
TypeScript

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
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 RoutePointKind,
type SectionStationMarker,
} from "./B05_Profile_UI_Markers";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38;
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;
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;
beginMoveSelected: () => void;
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null;
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");
root.append(canvas, status);
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();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000);
camera.position.set(100, 120, 100);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const controls = new OrbitControls(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).
let corridorBuild: CorridorBuildResult | null = null;
let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null;
/** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */
let heightIndex: TerrainHeightIndex | null = null;
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
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;
let movingSelected = false;
let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null;
let draggingMarker = false;
let lastDragPoint: { x: number; y: number; z: number } | null = null;
/**
* 모델 좌표 (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);
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;
}
const markers = createRouteMarkers(scene, () => bounds, terrainElevation);
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
const releaseCursorPivot = bindCursorPivotControls({
camera,
controls,
element: canvas,
pickables: () => (terrain ? [terrain] : []),
blocked: () => dragCandidate !== null || draggingMarker || movingSelected,
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);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(root);
function fit(view: "iso" | "top" | "front" | "side" = "top"): void {
if (!bounds) return;
const width = bounds.x[1] - bounds.x[0];
const depth = bounds.y[1] - bounds.y[0];
const distance = Math.max(width, depth, 20) * 1.35;
controls.target.set(0, 0, 0);
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];
camera.position.set(x, y, z);
camera.near = Math.max(0.1, distance / 1000);
camera.far = distance * 10;
camera.updateProjectionMatrix();
controls.update();
}
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 }),
),
);
});
}
/** 화면(client) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */
function terrainPointAt(
clientX: number,
clientY: number,
): { x: number; y: number; z: number } | null {
if (!terrain || !bounds) return null;
const rect = canvas.getBoundingClientRect();
const pointer = new THREE.Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds) : null;
}
function terrainPoint(
event: PointerEvent | DragEvent,
): { x: number; y: number; z: number } | null {
return terrainPointAt(event.clientX, event.clientY);
}
canvas.addEventListener("dragover", (event) => event.preventDefault());
canvas.addEventListener("drop", (event) => {
event.preventDefault();
const kind = event.dataTransfer?.getData("pointType") as RoutePointKind;
const point = terrainPoint(event);
if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point);
});
function markerHit(event: PointerEvent): THREE.Object3D | undefined {
const rect = canvas.getBoundingClientRect();
const pointer = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, camera);
return raycaster.intersectObject(markers.group, true)[0]?.object;
}
function finishMarkerInteraction(selectCandidate: boolean): void {
if (dragCandidate && (draggingMarker || selectCandidate)) {
markers.selectPoint(dragCandidate.id);
}
if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) {
canvas.releasePointerCapture(dragCandidate.pointerId);
}
controls.enabled = true;
dragCandidate = null;
draggingMarker = false;
lastDragPoint = null;
}
function handlePointerDown(event: PointerEvent): void {
if (event.button !== 0) return;
const hit = markerHit(event);
const pointId = markers.pointIdForObject(hit);
if (pointId) {
dragCandidate = {
id: pointId,
pointerId: event.pointerId,
x: event.clientX,
y: event.clientY,
};
draggingMarker = false;
lastDragPoint = null;
canvas.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
return;
}
if (hit) {
markers.selectObject(hit);
event.stopPropagation();
return;
}
if (movingSelected) {
const point = terrainPoint(event);
if (point) markers.moveSelected(point);
movingSelected = false;
} else {
markers.selectObject(undefined);
}
}
function handlePointerMove(event: PointerEvent): void {
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (
!draggingMarker &&
Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3
) {
draggingMarker = true;
controls.enabled = false;
}
if (!draggingMarker) return;
const point = terrainPoint(event);
if (!point) return;
lastDragPoint = point;
markers.movePoint(dragCandidate.id, point);
}
function handlePointerUp(event: PointerEvent): void {
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (draggingMarker) {
const point = terrainPoint(event) ?? lastDragPoint;
if (point) markers.movePoint(dragCandidate.id, point);
}
finishMarkerInteraction(!draggingMarker);
}
function handlePointerExit(event: PointerEvent): void {
if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return;
event.preventDefault();
event.stopPropagation();
finishMarkerInteraction(false);
}
canvas.addEventListener("pointerdown", handlePointerDown, true);
canvas.addEventListener("pointermove", handlePointerMove, true);
canvas.addEventListener("pointerup", handlePointerUp, true);
canvas.addEventListener("pointerleave", handlePointerExit, true);
canvas.addEventListener("pointercancel", handlePointerExit, true);
let frame = 0;
function animate(): void {
frame = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
let surfaceGrayscale = false;
function applySurfaceGrayscale(): void {
[terrain, clippedTerrain].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;
});
}
/* ── 예상형상(코리도) — 원본/클리핑 지형 스왑 + 계획 서피스 조합 ───────── */
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
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);
disposeObject(clippedTerrain);
clippedTerrain = null;
}
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 = new TerrainHeightIndex(terrain);
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) => {
if (ribbon.kind !== "cut" && ribbon.kind !== "fill" && ribbon.kind !== "ditch") 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;
}
});
}
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
function scheduleTerrainClip(): void {
disposeClippedTerrain();
if (!corridorBuild || !terrain || !bounds) {
applyCorridorVisibility();
return;
}
const buildAtSchedule = corridorBuild;
requestAnimationFrame(() => {
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const clipped = clipTerrain(terrain, buildAtSchedule, bounds);
disposeClippedTerrain();
clippedTerrain = clipped;
scene.add(clipped);
applyGrayscaleTo(clipped); // 흑백 토글 상태 유지.
applyCorridorVisibility();
});
applyCorridorVisibility();
}
function setCorridor(build: CorridorBuildResult | null): void {
disposeCorridorGroup();
corridorBuild = build;
if (build && bounds) {
snapCorridorEdges(build);
corridorGroup = createCorridorGroup(build, bounds);
scene.add(corridorGroup);
}
scheduleTerrainClip();
}
return {
root,
markers,
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
bounds = nextBounds;
current = { projectId, modelId, smooth, interval };
if (terrain) {
scene.remove(terrain);
disposeObject(terrain);
}
heightIndex = null; // 지형이 바뀌면 높이 색인도 새로 만든다.
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
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;
});
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
applySurfaceGrayscale();
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
if (corridorBuild) setCorridor(corridorBuild);
fit("top");
markers.renderMarkers();
await reloadContours(interval);
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
status.textContent = "";
},
reloadContours,
setSurfaceVisible(visible) {
surfaceOn = visible;
applyCorridorVisibility();
},
setCorridor,
setCorridorVisible(visible) {
corridorOn = visible;
applyCorridorVisibility();
},
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,
beginMoveSelected() {
movingSelected = true;
status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요.";
},
modelPointAt: terrainPointAt,
dispose() {
cancelAnimationFrame(frame);
resizeObserver.disconnect();
themeObserver.disconnect();
systemDarkTheme.removeEventListener("change", updateSceneBackground);
canvas.removeEventListener("pointerdown", handlePointerDown, true);
canvas.removeEventListener("pointermove", handlePointerMove, true);
canvas.removeEventListener("pointerup", handlePointerUp, true);
canvas.removeEventListener("pointerleave", handlePointerExit, true);
canvas.removeEventListener("pointercancel", handlePointerExit, true);
releaseCursorPivot();
markers.dispose();
clearContours();
disposeObject(terrain);
corridorBuild = null; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
controls.dispose();
renderer.dispose();
},
};
}