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 { 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 { 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"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; /** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ 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; loadSurface: ( projectId: string, modelId: number, method: string, smooth: boolean, interval: number, bounds: ModelBounds, ) => Promise; reloadContours: (interval: number) => Promise; 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; 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). let corridorBuild: CorridorBuildResult | null = null; let corridorGroup: THREE.Group | null = null; let clippedTerrain: THREE.Object3D | null = null; /** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */ let heightIndex: TerrainHeightIndex | null = null; /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ let bandSplit: TerrainBandSplit | 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; /** * 모델 좌표 (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; } // 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물 // 솔리드가 덮고 있을 수 있다). 씬·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: () => corridorGroup, 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 { 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) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */ let frame = 0; function animate(): void { frame = requestAnimationFrame(animate); 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()); } animate(); // 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라 // (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다. // 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다. let surfaceGrayscale = true; 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); // 분할본이 들고 있는 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 = 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) => { // 측구는 제외 — 바깥 상단은 노견과 같은 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, structurePick, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { bounds = nextBounds; current = { projectId, modelId, smooth, interval }; if (terrain) { scene.remove(terrain); disposeObject(terrain); } heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. bandSplit?.dispose(); bandSplit = null; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). const buffer = await fetchCachedBytes(projectId, url); terrain = await new Promise((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, setProjection: (kind) => cameraRig.setKind(kind, controls), beginMoveSelected() { markerInput.beginMoveSelected(); status.textContent = "선택한 포인트를 이동할 지형 위치를 클릭하세요."; }, modelPointAt: markerInput.terrainPointAt, dispose() { markerInput.dispose(); cancelAnimationFrame(frame); resizeObserver.disconnect(); themeObserver.disconnect(); systemDarkTheme.removeEventListener("change", updateSceneBackground); releaseCursorPivot(); markers.dispose(); clearContours(); disposeObject(terrain); structurePick.dispose(); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); bandSplit?.dispose(); controls.dispose(); renderer.dispose(); }, }; }