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 { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. import { createTerrainCompass } from "./B04_PreProcess_UI_Compass"; 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"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서 * 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */ const MAX_CONTOUR_LABELS = 40; 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 { const root = document.createElement("div"); root.className = "terrain-model-group"; const statusSpan = document.createElement("span"); statusSpan.className = "terrain-status"; statusSpan.style.fontSize = "var(--text-caption)"; statusSpan.style.color = "var(--color-text-secondary)"; statusSpan.textContent = "모델 선택 대기 중..."; let activeFilter = "csf"; let activeMethod = "dtm"; const axesCheck = document.createElement("input"); axesCheck.type = "checkbox"; axesCheck.checked = false; const rightControls = document.createElement("div"); rightControls.className = "viewer-options model-display-options"; // Surface Toggle const surfLabel = document.createElement("label"); surfLabel.className = "toggle-label toggle-button"; const surfCheck = document.createElement("input"); surfCheck.type = "checkbox"; surfCheck.checked = true; surfLabel.append(surfCheck, document.createTextNode(" 서피스")); // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. const smoothLabel = document.createElement("label"); smoothLabel.className = "b04-surface__field"; const smoothCaption = document.createElement("span"); smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); const smoothSelect = document.createElement("select"); smoothSelect.className = "b04-surface__select"; ( [ ["on", "B04_Surface_Smoothing_On"], ["off", "B04_Surface_Smoothing_Off"], ] as const ).forEach(([value, key]) => { const option = document.createElement("option"); option.value = value; option.textContent = L(key); smoothSelect.append(option); }); smoothSelect.value = "on"; smoothLabel.append(smoothCaption, smoothSelect); // Contour Toggle const contourLabel = document.createElement("label"); contourLabel.className = "toggle-label toggle-button"; const contourCheck = document.createElement("input"); contourCheck.type = "checkbox"; contourCheck.checked = true; contourLabel.append(contourCheck, document.createTextNode(" 등고선")); // Contour Interval input form const intervalForm = document.createElement("form"); intervalForm.className = "contour-interval-form"; const intervalInput = document.createElement("input"); intervalInput.type = "number"; intervalInput.value = "1.0"; intervalInput.step = "0.5"; intervalInput.min = "0.5"; intervalInput.className = "contour-interval-input"; const intervalSubmit = document.createElement("button"); intervalSubmit.type = "submit"; intervalSubmit.textContent = "적용"; intervalSubmit.className = "contour-interval-submit"; intervalForm.append( document.createTextNode("간격 "), intervalInput, document.createTextNode("m "), intervalSubmit, ); const axesLabel = document.createElement("label"); axesLabel.className = "toggle-label toggle-button"; axesLabel.append(axesCheck, document.createTextNode(" 축")); rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. const optionsContent = document.createElement("div"); optionsContent.className = "terrain-options-content"; optionsContent.append(rightControls, statusSpan); // 3D View container const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; viewerArea.style.position = "relative"; viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; viewerArea.style.overflow = "hidden"; const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; viewerArea.append(canvas); root.append(viewerArea); // Scale bar overlay const scaleBar = document.createElement("div"); scaleBar.className = "b04-surface__scale"; scaleBar.hidden = true; const scaleLabel = document.createElement("span"); scaleBar.append(scaleLabel); viewerArea.append(scaleBar); // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. const compass = createTerrainCompass(); viewerArea.append(compass.root); // Elevation bounds legend bar overlay (I-403) const legendBar = document.createElement("div"); legendBar.style.position = "absolute"; legendBar.style.top = "16px"; legendBar.style.right = "16px"; legendBar.style.background = "rgba(255, 255, 255, 0.9)"; legendBar.style.border = "1px solid #cbd5e1"; legendBar.style.borderRadius = "6px"; legendBar.style.padding = "8px"; legendBar.style.width = "50px"; legendBar.style.display = "none"; // hidden until contours are loaded legendBar.style.flexDirection = "column"; legendBar.style.alignItems = "center"; legendBar.style.zIndex = "10"; legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; legendBar.style.pointerEvents = "none"; const maxValSpan = document.createElement("span"); maxValSpan.style.fontSize = "10px"; maxValSpan.style.fontWeight = "bold"; maxValSpan.style.color = "#b91c1c"; maxValSpan.style.marginBottom = "4px"; const gradientDiv = document.createElement("div"); gradientDiv.style.width = "12px"; gradientDiv.style.height = "120px"; gradientDiv.style.background = "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; gradientDiv.style.borderRadius = "2px"; gradientDiv.style.border = "1px solid #94a3b8"; const minValSpan = document.createElement("span"); minValSpan.style.fontSize = "10px"; minValSpan.style.fontWeight = "bold"; minValSpan.style.color = "#1d4ed8"; minValSpan.style.marginTop = "4px"; legendBar.append(maxValSpan, gradientDiv, minValSpan); viewerArea.append(legendBar); // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; viewerArea.append(progress.root); /** 진행률(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); } } async function loadContourLines( modelId: number, isSmooth: boolean, recalculate = false, ): Promise { const interval = parseFloat(intervalInput.value) || 1.0; const projectId = currentProjectId; const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; try { // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. const data = await fetchCachedJson(projectId, contourUrl); if ( currentProjectId !== projectId || currentModelId !== modelId || currentModelSmooth !== isSmooth || (parseFloat(intervalInput.value) || 1.0) !== interval ) { return false; } clearContours(); const bounds = data.bounds; if (!bounds) return false; 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; const transform = (coords: [number, number, number][]) => { return coords.map(([x_model, y_model, z_model]) => { const x_scene = x_model - cx; const y_scene = z_model - cz; const z_scene = -(y_model - cy); return new THREE.Vector3(x_scene, y_scene, z_scene); }); }; let minH = Infinity; let maxH = -Infinity; // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). const majorPoints: THREE.Vector3[] = []; const minorPoints: THREE.Vector3[] = []; const labelCandidates: { level: number; position: THREE.Vector3; length: number; }[] = []; data.contours.forEach((c: any) => { if (c.level < minH) minH = c.level; if (c.level > maxH) maxH = c.level; const points = transform(c.coordinates); if (points.length < 2) return; const isMajor = c.level % (interval * 5) === 0; const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); } // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. if (isMajor && points.length > 4) { let length = 0; for (let i = 0; i < points.length - 1; i++) { length += points[i].distanceTo(points[i + 1]); } labelCandidates.push({ level: c.level, position: points[Math.floor(points.length / 2)], length, }); } }); labelCandidates.sort((a, b) => b.length - a.length); for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { const labelPos = candidate.position; const labelDiv = document.createElement("div"); labelDiv.className = "contour-label"; labelDiv.innerText = `${Math.round(candidate.level)}m`; labelDiv.style.position = "absolute"; labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; labelDiv.style.border = "1px solid #d97706"; labelDiv.style.color = "#b45309"; labelDiv.style.padding = "1px 4px"; labelDiv.style.borderRadius = "3px"; labelDiv.style.fontSize = "9px"; labelDiv.style.fontWeight = "bold"; labelDiv.style.pointerEvents = "none"; labelDiv.style.zIndex = "5"; labelDiv.style.transform = "translate(-50%, -50%)"; (labelDiv as any).__updateLabelPos = () => { if (!contourCheck.checked) { labelDiv.style.display = "none"; return; } const proj = labelPos.clone().project(camera); const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; if (proj.z > 1) { labelDiv.style.display = "none"; } else { labelDiv.style.display = "block"; labelDiv.style.left = `${x}px`; labelDiv.style.top = `${y}px`; } }; viewerArea.appendChild(labelDiv); labelElements.push(labelDiv); labelsDirty = true; } // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. [ { points: minorPoints, color: 0xf59e0b }, { points: majorPoints, color: 0xd97706 }, ].forEach(({ points, color }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, }); contourGroup.add(new THREE.LineSegments(geometry, material)); }); if (minH !== Infinity && maxH !== -Infinity) { const nearestMin10 = Math.round(minH / 10) * 10; const nearestMax10 = Math.round(maxH / 10) * 10; maxValSpan.textContent = `${nearestMax10}m`; minValSpan.textContent = `${nearestMin10}m`; legendBar.style.display = "flex"; } else { legendBar.style.display = "none"; } return true; } catch (e) { legendBar.style.display = "none"; return false; } } 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(); }, }; }