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"; 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; 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; 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; 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 { 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?.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, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { bounds = nextBounds; current = { projectId, modelId, smooth, interval }; if (terrain) { scene.remove(terrain); disposeObject(terrain); } 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(); fit("top"); markers.renderMarkers(); await reloadContours(interval); // 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가 // 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다. status.textContent = ""; }, reloadContours, setSurfaceVisible(visible) { if (terrain) terrain.visible = visible; }, 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); controls.dispose(); renderer.dispose(); }, }; }