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 } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. import { buildTerrainViewerChrome } from "./B04_PreProcess_UI_TerrainViewer_Chrome"; import { loadContourLinesInto } from "./B04_PreProcess_UI_TerrainViewer_Contours"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { bindCursorPivotControls, bindSurfaceViewerTheme, getTopFitDistance, TOP_VIEW_TILT, niceScaleDistance, SURFACE_CAMERA_FOV, targetPlaneMetersPerPixel, type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export interface SurfaceTerrainViewer { root: HTMLElement; /** 표시 토글(축·서피스·등고선·간격) + 상태 줄. 페이지가 "모델 표시 옵션"에 넣는다. */ optionsContent: HTMLElement; /** 스무딩 드롭다운 한 줄. 페이지가 "지표면 분석"에 넣는다. */ smoothingField: HTMLElement; render: (projectId: string, models: readonly SurfaceModelSummary[]) => void; setReferenceBounds: (bounds: SurfaceBounds) => void; /** 계획노선(사업지 좌표계 m)을 3D 최고 표고 평면에 그린다. 빈 목록이면 걷어낸다. */ setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void; setSelection: (sourceFilter: string, method: string) => void; /** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */ showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; /** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */ setSmoothing: (enabled: boolean) => void; getContourInterval: () => number; /** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */ setContourInterval: (interval: number) => void; resetOptions: () => void; dispose: () => void; } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // DOM 뼈대는 700줄 제한으로 `_TerrainViewer_Chrome` 로 옮겼다 — 이름·순서는 그대로다. const { root, statusSpan, axesCheck, surfCheck, smoothLabel, smoothSelect, contourCheck, intervalForm, intervalInput, intervalSubmit, optionsContent, viewerArea, canvas, scaleBar, scaleLabel, compass, legendBar, maxValSpan, minValSpan, progress, } = buildTerrainViewerChrome(); let activeFilter = "csf"; let activeMethod = "dtm"; /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { progress.root.hidden = label === null; if (label !== null) progress.set(ratio, label); } // Three.js context variables let currentProjectId = ""; let currentModelsList: readonly SurfaceModelSummary[] = []; let referenceBounds: SurfaceBounds | null = null; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let axesVisibilityListener: ((visible: boolean) => void) | null = null; let suppressCameraEvent = false; let smoothPreferred = true; let currentModelId: number | null = null; let currentModelSmooth = false; // 선택 변경 후 늦게 도착한 이전 로더 콜백이 장면을 오염시키지 않도록 세대를 추적한다. let loadGeneration = 0; const scene = new THREE.Scene(); const releaseTheme = bindSurfaceViewerTheme((color) => { scene.background = new THREE.Color(color); }); const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.screenSpacePanning = true; scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8)); const directional = new THREE.DirectionalLight(0xffffff, 1.5); directional.position.set(100, 180, 120); scene.add(directional); const axes = new THREE.AxesHelper(25); axes.visible = axesCheck.checked; scene.add(axes); const contourGroup = new THREE.Group(); contourGroup.visible = contourCheck.checked; scene.add(contourGroup); // 계획노선 — 지표면에 드리우지 않고 **데이터 최고 표고 평면**에 수평으로 얹는다 // (2026-09-01 사용자 확정). 노선과 측량 범위가 평면상 어디서 어긋나는지 보려는 것이라 // 지형을 따라 오르내리면 오히려 판단이 어렵다. const routeGroup = new THREE.Group(); scene.add(routeGroup); let routePoints: ReadonlyArray<{ x: number; y: number }> = []; let terrainMesh: THREE.Object3D | null = null; const labelElements: HTMLDivElement[] = []; // 라벨 목록이 바뀌거나 표시 옵션을 껐다 켰을 때는 카메라가 그대로여도 다시 배치해야 한다. let labelsDirty = true; // 회전·줌 중심을 커서 아래 지형 지점으로 (포인트클라우드 뷰어·B05와 공용 유틸). const releaseCursorPivot = bindCursorPivotControls({ camera, controls, element: renderer.domElement, pickables: () => (terrainMesh ? [terrainMesh] : []), scene, }); function disposeObject(obj: THREE.Object3D) { obj.traverse((child) => { const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments; renderable.geometry?.dispose(); const material = renderable.material; if (Array.isArray(material)) material.forEach((item) => item.dispose()); else material?.dispose(); }); } function clearMesh() { if (terrainMesh) { scene.remove(terrainMesh); disposeObject(terrainMesh); terrainMesh = null; } } // ── 겹쳐 보기 메시 ───────────────────────────────────────────────────────── // 도엽등고 서피스 위에 라이다 지표면을 겹쳐 두 지형을 눈으로 대조한다 // (2026-08-30 사용자 지시). 본 메시와 카메라·좌표계를 공유하므로 같은 자리에 겹친다. let overlayMesh: THREE.Object3D | null = null; let overlayGeneration = 0; function clearOverlay() { if (overlayMesh) { scene.remove(overlayMesh); disposeObject(overlayMesh); overlayMesh = null; } } async function loadOverlay( projectId: string, models: readonly SurfaceModelSummary[], sourceFilter: string, method: string, smooth: boolean, ): Promise { const generation = ++overlayGeneration; clearOverlay(); const match = models.find((model) => { const configured = model.generation_params?.source_filter; return ( model.model_type.toLowerCase() === method.toLowerCase() && typeof configured === "string" && configured.toLowerCase() === sourceFilter.toLowerCase() ); }); if (!match) return false; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${match.id}/preview?smooth=${smooth}`; try { const buffer = await fetchCachedBytes(projectId, url); if (generation !== overlayGeneration) return false; return await new Promise((resolve) => { new GLTFLoader().parse( buffer, "", (gltf) => { if (generation !== overlayGeneration) { disposeObject(gltf.scene); resolve(false); return; } // 겹친 두 면을 구분하려고 반투명 단색으로 덮어씌운다. gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material = new THREE.MeshStandardMaterial({ color: 0x60a5fa, transparent: true, opacity: 0.45, side: THREE.DoubleSide, flatShading: false, }); } }); overlayMesh = gltf.scene; scene.add(gltf.scene); resolve(true); }, () => resolve(false), ); }); } catch { return false; } } function clearContours() { while (contourGroup.children.length > 0) { const child = contourGroup.children[0]; contourGroup.remove(child); if (child instanceof THREE.LineSegments) { child.geometry.dispose(); (child.material as THREE.Material).dispose(); } } labelElements.forEach((el) => el.remove()); labelElements.length = 0; labelsDirty = true; legendBar.style.display = "none"; } function drawRoute(): void { while (routeGroup.children.length > 0) { const child = routeGroup.children[0]; routeGroup.remove(child); if (child instanceof THREE.Line) { child.geometry.dispose(); (child.material as THREE.Material).dispose(); } } if (routePoints.length < 2 || !referenceBounds) return; // 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2; const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2; const cz = (referenceBounds.z_min + referenceBounds.z_max) / 2; const planeY = referenceBounds.z_max - cz; const vertices = routePoints.map( (point) => new THREE.Vector3(point.x - cx, planeY, -(point.y - cy)), ); const material = new THREE.LineBasicMaterial({ color: new THREE.Color(routeLineColor()), }); routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material)); // 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다. if (terrainMesh) fitCamera(terrainMesh); } const getFitParams = (object: THREE.Object3D) => { const box = new THREE.Box3().setFromObject(object); // 계획노선은 최고 표고 평면에 있어 지형 상자 위·밖으로 걸친다. 지형만 보고 맞추면 // 노선이 화면 밖으로 밀려 보이지 않는다 — 프레임에 같이 넣는다. if (routeGroup.children.length > 0) box.expandByObject(routeGroup); const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); const span = Math.max(size.x, size.y, size.z, 1); return { center, span }; }; /** 화면맞춤 기준 범위 — 지표면 범위에 계획노선까지 담는다. * * 카메라 타깃은 늘 지표면 중심(0,0,0)이라 범위를 한쪽으로만 늘려서는 소용이 없다. * 중심에서 가장 먼 노선 정점까지를 반폭으로 잡아 **대칭으로** 넓힌다. 그러지 않으면 * 라이다가 노선의 일부만 덮을 때 나머지가 화면 밖으로 잘린다(2026-09-02 용화 실측: * 노선 2,136m 중 라이다는 1,400m만 덮어 오른쪽이 캔버스 밖으로 나갔다). */ const fitBounds = (): SurfaceBounds | null => { if (!referenceBounds || routePoints.length < 2) return referenceBounds; const cx = (referenceBounds.x_min + referenceBounds.x_max) / 2; const cy = (referenceBounds.y_min + referenceBounds.y_max) / 2; let halfX = (referenceBounds.x_max - referenceBounds.x_min) / 2; let halfY = (referenceBounds.y_max - referenceBounds.y_min) / 2; for (const point of routePoints) { halfX = Math.max(halfX, Math.abs(point.x - cx)); halfY = Math.max(halfY, Math.abs(point.y - cy)); } return { ...referenceBounds, x_min: cx - halfX, x_max: cx + halfX, y_min: cy - halfY, y_max: cy + halfY, }; }; const fitCamera = (object: THREE.Object3D) => { const { span } = getFitParams(object); const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); const bounds = fitBounds(); const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2; controls.target.set(0, 0, 0); // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. camera.position.set(0, distance, distance * TOP_VIEW_TILT); camera.near = Math.max(distance / 10000, 0.01); camera.far = distance * 100; camera.updateProjectionMatrix(); controls.update(); }; function emitCameraState(): void { if (suppressCameraEvent || !cameraListener) return; const offset = camera.position.clone().sub(controls.target); const distance = Math.max(offset.length(), 0.001); offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], distanceMeters: distance, targetMeters: [controls.target.x, controls.target.y, controls.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; controls.target.set(...state.targetMeters); camera.position .set(...state.direction) .multiplyScalar(Math.max(state.distanceMeters, 0.001)) .add(controls.target); camera.lookAt(controls.target); controls.update(); suppressCameraEvent = false; } function syncSmoothingSupport(): void { const supported = activeMethod === "tin" || activeMethod === "dtm"; smoothSelect.disabled = !supported; smoothSelect.value = supported && smoothPreferred ? "on" : "off"; smoothLabel.classList.toggle("is-disabled", !supported); smoothLabel.title = supported ? "" : L("B04_Surface_Smoothing_Unsupported"); } /** 지금 스무딩을 적용하는 상태인가. */ function smoothingOn(): boolean { return !smoothSelect.disabled && smoothSelect.value === "on"; } // Load mesh and contours async function updateSelectedModel() { if (!currentProjectId || currentModelsList.length === 0) return; clearMesh(); clearContours(); currentModelId = null; scaleBar.hidden = true; statusSpan.textContent = "모델 조회 중..."; showProgress(null, "모델 조회 중…"); // 1. Find matching model in list // model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod) // model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z) const match = currentModelsList.find((m) => { const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase(); const configuredFilter = m.generation_params?.source_filter; const filterMatches = (typeof configuredFilter === "string" && configuredFilter.toLowerCase() === activeFilter.toLowerCase()) || Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase())); return typeMatches && filterMatches; }); if (!match) { statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다."; showProgress(null, null); return; } const modelId = match.id; const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn(); currentModelId = modelId; currentModelSmooth = isSmooth; const generation = ++loadGeneration; statusSpan.textContent = "3D 메쉬 파일 다운로드 중..."; showProgress(0, "3D 메쉬 내려받는 중…"); const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`; try { // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). const buffer = await fetchCachedBytes(currentProjectId, previewUrl, { onProgress: (ratio) => { if (generation !== loadGeneration) return; showProgress(ratio, "3D 메쉬 내려받는 중…"); }, }); if (generation !== loadGeneration) return; if (activeMethod === "meshfree") { const geometry = new PLYLoader().parse(buffer); geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ size: 0.35, vertexColors: geometry.hasAttribute("color"), sizeAttenuation: true, }); const points = new THREE.Points(geometry, material); points.visible = surfCheck.checked; terrainMesh = points; scene.add(points); fitCamera(points); showProgress(1, "등고선을 그리는 중…"); await loadSelectedContours(modelId, isSmooth); showProgress(null, null); } else { new GLTFLoader().parse( buffer, "", async (gltf) => { if (generation !== loadGeneration) { disposeObject(gltf.scene); return; } gltf.scene.traverse((child) => { if (child instanceof THREE.Mesh) { child.material.side = THREE.DoubleSide; child.material.vertexColors = child.geometry.hasAttribute("color"); } }); gltf.scene.visible = surfCheck.checked; terrainMesh = gltf.scene; scene.add(gltf.scene); fitCamera(gltf.scene); showProgress(1, "등고선을 그리는 중…"); await loadSelectedContours(modelId, isSmooth); showProgress(null, null); }, () => { if (generation !== loadGeneration) return; statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다."; showProgress(null, null); }, ); } } catch (e) { statusSpan.textContent = "3D 파일 로드에 실패했습니다."; showProgress(null, null); } } /** 등고선 적재는 700줄 제한으로 `_TerrainViewer_Contours` 로 옮겼다 — 호출부는 그대로다. */ const loadContourLines = ( modelId: number, isSmooth: boolean, recalculate = false, ): Promise => loadContourLinesInto( { viewerArea, legendBar, maxValSpan, minValSpan, intervalInput, camera, contourGroup, contourCheck, projectId: () => currentProjectId, currentModelId: () => currentModelId, currentModelSmooth: () => currentModelSmooth, labelElements, setLabelsDirty: (dirty) => { labelsDirty = dirty; }, clearContours, }, modelId, isSmooth, recalculate, ); async function loadSelectedContours( modelId: number, isSmooth: boolean, recalculate = false, ): Promise { const projectId = currentProjectId; const interval = parseFloat(intervalInput.value) || 1.0; statusSpan.textContent = `등고선 ${interval}m 계산 중...`; const loaded = await loadContourLines(modelId, isSmooth, recalculate); if ( currentProjectId === projectId && currentModelId === modelId && currentModelSmooth === isSmooth && (parseFloat(intervalInput.value) || 1.0) === interval ) { statusSpan.textContent = loaded ? `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} · 등고선 ${interval}m` : "등고선 계산 또는 조회에 실패했습니다."; } } // Animation render loop let animationFrameId = 0; let hasConnected = false; // 라벨 재계산 여부 판단용 — 직전 프레임의 카메라 자세. const cameraMatrixSnapshot = new THREE.Matrix4(); function animate() { if (!root.isConnected) { if (!hasConnected) { animationFrameId = requestAnimationFrame(animate); } else { cancelAnimationFrame(animationFrameId); clearMesh(); clearContours(); releaseTheme(); releaseCursorPivot(); controls.dispose(); renderer.dispose(); } return; } hasConnected = true; controls.update(); // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { scaleBar.hidden = false; compass.setVisible(true); compass.update( camera.position.x - controls.target.x, camera.position.y - controls.target.y, camera.position.z - controls.target.z, ); const dist = camera.position.distanceTo(controls.target); const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; const prettyMeters = niceScaleDistance(roughMeters); scaleBar.style.width = `${prettyMeters / metersPerPixel}px`; scaleLabel.textContent = prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.hidden = true; compass.setVisible(false); } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) { labelsDirty = false; cameraMatrixSnapshot.copy(camera.matrixWorldInverse); labelElements.forEach((label) => { if (typeof (label as any).__updateLabelPos === "function") { (label as any).__updateLabelPos(); } }); } renderer.render(scene, camera); animationFrameId = requestAnimationFrame(animate); } // Event Listeners axesCheck.addEventListener("change", () => { axes.visible = axesCheck.checked; axesVisibilityListener?.(axesCheck.checked); }); surfCheck.addEventListener("change", () => { if (terrainMesh) { terrainMesh.visible = surfCheck.checked; } }); smoothSelect.addEventListener("change", () => { smoothPreferred = smoothSelect.value === "on"; updateSelectedModel(); }); contourCheck.addEventListener("change", () => { contourGroup.visible = contourCheck.checked; labelElements.forEach((el) => { el.style.display = contourCheck.checked ? "block" : "none"; }); labelsDirty = true; }); intervalForm.addEventListener("submit", async (e) => { e.preventDefault(); const interval = Number(intervalInput.value); if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return; intervalSubmit.disabled = true; await loadSelectedContours(currentModelId, currentModelSmooth, true); intervalSubmit.disabled = false; }); controls.addEventListener("change", emitCameraState); // Resize handler const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const width = entry.contentRect.width || 800; const height = entry.contentRect.height || 520; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); } }); resizeObserver.observe(viewerArea); animationFrameId = requestAnimationFrame(animate); return { root, optionsContent, smoothingField: smoothLabel, render(projectId, models) { currentProjectId = projectId; currentModelsList = models; updateSelectedModel(); }, setReferenceBounds(bounds) { referenceBounds = bounds; drawRoute(); }, setRoute(points) { routePoints = points; drawRoute(); }, setSelection(sourceFilter, method) { activeFilter = sourceFilter; activeMethod = method; syncSmoothingSupport(); }, showOverlay(sourceFilter, method, smooth) { if (!sourceFilter) { clearOverlay(); return Promise.resolve(false); } return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth); }, applyCameraState, onCameraChange(listener) { cameraListener = listener; }, onAxesVisibilityChange(listener) { axesVisibilityListener = listener; }, isSmoothingEnabled() { return smoothingOn(); }, setSmoothing(enabled) { smoothPreferred = enabled; syncSmoothingSupport(); }, setContourInterval(interval) { if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); }, getContourInterval() { return Number.parseFloat(intervalInput.value); }, resetOptions() { axesCheck.checked = false; axes.visible = false; axesVisibilityListener?.(false); surfCheck.checked = true; smoothPreferred = true; syncSmoothingSupport(); contourCheck.checked = true; contourGroup.visible = true; intervalInput.value = "1.0"; if (terrainMesh) terrainMesh.visible = true; void updateSelectedModel(); }, dispose() { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); releaseTheme(); releaseCursorPivot(); clearMesh(); clearContours(); controls.dispose(); renderer.dispose(); }, }; }