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 { createOrthoCameraRig } from "./B05_Profile_UI_Viewer_Camera"; 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"; import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; 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; 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 cameraRig = createOrthoCameraRig(); const camera = cameraRig.camera; 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; /** 지형 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; 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; } // 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물 // 솔리드가 덮고 있을 수 있다). 씬·THREE·모델→씬 변환을 내보내 위에서 레이캐스트로 // 확인한다(`__corridorBuild`·`__corridorClip`과 같은 용도). (window as unknown as { __corridorScene?: unknown }).__corridorScene = { scene, THREE, toScene: (x: number, y: number, z: number) => bounds ? modelToScene({ x, y, z }, bounds) : null, topZ: () => (bounds ? bounds.z[1] + 100 : 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); cameraRig.setAspect(width / height); } 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; // 원근 45°(반각 tan ≈ 0.414)와 비슷한 화면 배율 — 뷰 전환 시 크기감이 유지된다. cameraRig.setHalfHeight(distance * 0.42); controls.update(); } 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) 좌표 아래 지형의 모델 좌표 — 우클릭 구조물 배치 등 외부 픽에도 쓴다. */ 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에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다. // 기본 흑백 지형(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; // 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 // 수치로 확인한다(`__corridorClip`과 같은 용도). (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build ? { ribbons: build.ribbons.map((ribbon) => ({ kind: ribbon.kind, side: ribbon.side, rows: ribbon.chainages.length, cols: ribbon.colCount, first: ribbon.chainages[0], last: ribbon.chainages[ribbon.chainages.length - 1], })), outlineRows: build.outline.chainages.length, outline: build.outline, // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). structures: (build.structures ?? []).map((solid) => ({ kind: solid.kind, at: solid.chainage_m, rings: solid.rings?.length ?? 0, points: solid.polygon?.length ?? 0, })), // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. raw: { ribbons: build.ribbons, planCurves: build.planCurves ?? [], cutWalls: build.cutWalls ?? [], }, planCurves: (build.planCurves ?? []).map((curve) => ({ source: curve.source, role: curve.role, side: curve.side, at: curve.setChainageM, loops: curve.loops.length, points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), z0: curve.loops[0]?.[0]?.[2] ?? null, planZ: curve.planZ, })), } : null; if (build && bounds) { snapCorridorEdges(build); attachPatchSkirts(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; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. 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, 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(); bandSplit?.dispose(); controls.dispose(); renderer.dispose(); }, }; }