import { RENDER_OPTIONS } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_PreProcess_Api_Fetch"; import { bindCursorPivotControls, bindSurfaceViewerTheme, getReferenceCenter, getTopFitDistance, TOP_VIEW_TILT, niceScaleDistance, SURFACE_CAMERA_FOV, targetPlaneMetersPerPixel, type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; export type { SurfaceCameraState } from "./B04_PreProcess_UI_Camera"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export interface SurfacePointCloudViewer { root: HTMLElement; controlsGroup: HTMLElement; /** 점 크기·밀도 슬라이더 묶음. 페이지가 "모델 표시 옵션" 컨테이너 맨 아래에 붙인다. */ optionsContent: HTMLElement; statusSpan: HTMLElement; /** 로딩 서클 표시. 문구를 주면 켜고, null이면 끈다. `render()` 시 자동으로 꺼진다. */ setLoading: (label: string | null) => void; render: (data: SurfacePointCloudSampleResponse | null) => void; setAxesVisible: (visible: boolean) => void; applyCameraState: (state: SurfaceCameraState) => void; onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; resetOptions: () => void; dispose: () => void; } type CameraView = "iso" | "top" | "front" | "side"; function makeButton(label: string): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.textContent = label; return button; } export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { const root = document.createElement("div"); root.className = "point-viewer"; const statusSpan = document.createElement("span"); statusSpan.className = "b04-surface__status-info"; statusSpan.textContent = "포인트 데이터 로딩 중..."; const controls = document.createElement("div"); controls.className = "viewer-controls"; const viewButtons: Array<[CameraView, HTMLButtonElement]> = [ ["iso", makeButton("사시도")], ["top", makeButton("상단")], ["front", makeButton("정면")], ["side", makeButton("측면")], ]; controls.append(...viewButtons.map(([, button]) => button)); const controlsGroup = document.createElement("section"); controlsGroup.className = "b04-surface__group"; const controlsTitle = document.createElement("h3"); controlsTitle.className = "b04-surface__panel-title"; controlsTitle.textContent = L("B04_Surface_Group_ViewControls"); controlsGroup.append(controlsTitle, controls); /** 슬라이더 한 줄 — `이름 [현재값] [게이지]`. 값이 게이지 앞에 온다(2026-08-01 사용자 지시). */ function buildSlider( label: string, attributes: { min: string; max: string; step: string; value: string }, ): { root: HTMLLabelElement; input: HTMLInputElement; value: HTMLSpanElement } { const root = document.createElement("label"); root.className = "viewer-option-row"; const name = document.createElement("span"); name.className = "viewer-option-name"; name.textContent = label; const value = document.createElement("span"); value.className = "viewer-option-val"; const input = document.createElement("input"); input.type = "range"; input.min = attributes.min; input.max = attributes.max; input.step = attributes.step; input.value = attributes.value; root.append(name, value, input); return { root, input, value }; } const options = document.createElement("div"); options.className = "viewer-options"; const size = buildSlider(L("B04_Surface_Opt_PointSize"), { min: "0.1", max: "2.5", step: "0.01", value: "0.42", }); const density = buildSlider(L("B04_Surface_Opt_Density"), { min: "1", max: "10", step: "1", value: "10", }); const sizeInput = size.input; const densityInput = density.input; options.append(size.root, density.root); /** 게이지 앞 숫자를 현재값으로 맞춘다. */ function syncOptionValues(): void { size.value.textContent = Number(sizeInput.value).toFixed(2); density.value.textContent = `${Number(densityInput.value) * 10}%`; } syncOptionValues(); const viewerArea = document.createElement("div"); viewerArea.className = "three-viewer"; const canvas = document.createElement("canvas"); canvas.className = "b04-surface-viewer__canvas"; const scaleBar = document.createElement("div"); scaleBar.className = "b04-surface__scale"; const scaleText = document.createElement("span"); scaleBar.append(scaleText); viewerArea.append(canvas, scaleBar, statusSpan); root.append(viewerArea); // 뷰포트 정중앙 로딩 서클 — 지도·그래프·다른 3D 뷰어와 같은 공통 컴포넌트. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; viewerArea.append(progress.root); function setLoading(label: string | null): void { progress.root.hidden = label === null; if (label !== null) progress.set(null, label); } const renderer = new THREE.WebGLRenderer({ canvas, antialias: RENDER_OPTIONS.antialias, }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); 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.1, 22000); const orbit = new OrbitControls(camera, canvas); orbit.enableDamping = true; orbit.dampingFactor = 0.08; orbit.screenSpacePanning = true; const axes = new THREE.AxesHelper(45); axes.visible = false; scene.add(axes); let pointsObject: THREE.Points | null = null; // 회전·줌 중심을 커서 아래 지점으로 (지형 뷰어·B05와 공용 유틸). const releaseCursorPivot = bindCursorPivotControls({ camera, controls: orbit, element: canvas, pickables: () => (pointsObject ? [pointsObject] : []), scene, }); let currentData: SurfacePointCloudSampleResponse | null = null; let animationFrame = 0; let hasConnected = false; let disposed = false; let cameraListener: ((state: SurfaceCameraState) => void) | null = null; let suppressCameraEvent = false; let referenceBounds: SurfaceBounds | null = null; function resize(): void { const rect = viewerArea.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); } function clearPoints(): void { if (!pointsObject) return; pointsObject.geometry.dispose(); const material = pointsObject.material; if (Array.isArray(material)) material.forEach((item) => item.dispose()); else material.dispose(); scene.remove(pointsObject); pointsObject = null; } function setCameraView(view: CameraView): void { const directions: Record = { iso: [120, 95, 135], // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. top: [0, 1, TOP_VIEW_TILT], front: [0, 60, 240], side: [240, 60, 0], }; const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1); const distance = referenceBounds ? getTopFitDistance(referenceBounds, aspect) : 190; orbit.target.set(0, 0, 0); camera.position .set(...directions[view]) .normalize() .multiplyScalar(distance); camera.near = Math.max(distance / 10000, 0.01); camera.far = distance * 100; camera.updateProjectionMatrix(); camera.lookAt(orbit.target); orbit.update(); } function emitCameraState(): void { if (suppressCameraEvent || !cameraListener) return; const offset = camera.position.clone().sub(orbit.target); const distance = Math.max(offset.length(), 0.001); offset.normalize(); cameraListener({ direction: [offset.x, offset.y, offset.z], distanceMeters: distance, targetMeters: [orbit.target.x, orbit.target.y, orbit.target.z], }); } function applyCameraState(state: SurfaceCameraState): void { suppressCameraEvent = true; orbit.target.set(...state.targetMeters); camera.position .set(...state.direction) .multiplyScalar(Math.max(state.distanceMeters, 0.001)) .add(orbit.target); camera.lookAt(orbit.target); orbit.update(); suppressCameraEvent = false; } function renderPointCloud(data: SurfacePointCloudSampleResponse): void { clearPoints(); const bounds = data.bounds; const zMin = bounds.z_min ?? 0; const zMax = bounds.z_max ?? 0; const [xMid, yMid, zMid] = getReferenceCenter(bounds); const zSpan = Math.max(zMax - zMin, 1e-9); referenceBounds = bounds; const density = Number(densityInput.value); const step = Math.max(1, Math.ceil(10 / density)); const count = Math.ceil(data.points.length / step); const positions = new Float32Array(count * 3); const colors = new Float32Array(count * 3); const hasRgb = Boolean(data.rgb && data.rgb.length === data.points.length); let outputIndex = 0; for (let index = 0; index < data.points.length; index += step) { const [x, y, z] = data.points[index]; positions[outputIndex * 3] = x - xMid; positions[outputIndex * 3 + 1] = z - zMid; positions[outputIndex * 3 + 2] = -(y - yMid); const rgb = hasRgb ? data.rgb?.[index] : undefined; if (rgb) { const divisor = Math.max(...rgb) > 255 ? 65535 : 255; colors[outputIndex * 3] = rgb[0] / divisor; colors[outputIndex * 3 + 1] = rgb[1] / divisor; colors[outputIndex * 3 + 2] = rgb[2] / divisor; } else { const ratio = Math.max(0, Math.min(1, (z - zMin) / zSpan)); colors[outputIndex * 3] = (36 + ratio * 190) / 255; colors[outputIndex * 3 + 1] = (86 + Math.sin(ratio * Math.PI) * 95) / 255; colors[outputIndex * 3 + 2] = (128 - ratio * 80) / 255; } outputIndex += 1; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.computeBoundingSphere(); const material = new THREE.PointsMaterial({ size: Number(sizeInput.value), vertexColors: true, sizeAttenuation: true, }); pointsObject = new THREE.Points(geometry, material); scene.add(pointsObject); statusSpan.textContent = `${count.toLocaleString()}개 점 표시 중`; } function updateScaleBar(): void { if (!currentData) { scaleBar.hidden = true; return; } const distance = camera.position.distanceTo(orbit.target); const metersPerPixel = targetPlaneMetersPerPixel(distance, viewerArea.clientHeight); const meters = niceScaleDistance(100 * metersPerPixel); scaleBar.hidden = false; scaleBar.style.width = `${meters / metersPerPixel}px`; scaleText.textContent = meters >= 1000 ? `${meters / 1000} km` : `${meters} m`; } function animate(): void { if (!root.isConnected) { if (!hasConnected) animationFrame = requestAnimationFrame(animate); else disposeViewer(); return; } hasConnected = true; resize(); orbit.update(); updateScaleBar(); renderer.render(scene, camera); animationFrame = requestAnimationFrame(animate); } function disposeViewer(): void { if (disposed) return; disposed = true; cancelAnimationFrame(animationFrame); releaseTheme(); releaseCursorPivot(); clearPoints(); orbit.dispose(); renderer.dispose(); } viewButtons.forEach(([view, button]) => { button.addEventListener("click", () => setCameraView(view)); }); sizeInput.addEventListener("input", () => { syncOptionValues(); if (pointsObject) (pointsObject.material as THREE.PointsMaterial).size = Number(sizeInput.value); }); densityInput.addEventListener("input", () => { syncOptionValues(); if (currentData) renderPointCloud(currentData); }); orbit.addEventListener("change", emitCameraState); animationFrame = requestAnimationFrame(animate); return { root, controlsGroup, optionsContent: options, statusSpan, setLoading, render(data) { setLoading(null); currentData = data; clearPoints(); if (!data) { statusSpan.textContent = "데이터가 존재하지 않습니다."; return; } renderPointCloud(data); setCameraView("top"); }, applyCameraState, onCameraChange(listener) { cameraListener = listener; }, setAxesVisible(visible) { axes.visible = visible; }, resetOptions() { axes.visible = false; sizeInput.value = "0.42"; densityInput.value = "10"; syncOptionValues(); if (currentData) renderPointCloud(currentData); setCameraView("iso"); }, dispose: disposeViewer, }; }