import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; const DARK_VIEWER_BACKGROUND = 0x251f38; export interface SurfaceCameraState { direction: [number, number, number]; distanceMeters: number; targetMeters: [number, number, number]; } export function getReferenceCenter(bounds: SurfaceBounds): [number, number, number] { return [ (bounds.x_min + bounds.x_max) / 2, (bounds.y_min + bounds.y_max) / 2, (bounds.z_min + bounds.z_max) / 2, ]; } export function getTopFitDistance(bounds: SurfaceBounds, aspect: number): number { const width = Math.max(bounds.x_max - bounds.x_min, 1); const depth = Math.max(bounds.y_max - bounds.y_min, 1); const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * Math.max(aspect, 0.1)); const verticalDistance = depth / (2 * Math.tan(verticalFov / 2)); const horizontalDistance = width / (2 * Math.tan(horizontalFov / 2)); return Math.max(verticalDistance, horizontalDistance, 1) * 1.12; } export function targetPlaneMetersPerPixel(distanceMeters: number, viewportHeight: number): number { const verticalFov = (SURFACE_CAMERA_FOV * Math.PI) / 180; return ( (2 * Math.tan(verticalFov / 2) * Math.max(distanceMeters, 0.001)) / Math.max(viewportHeight, 1) ); } export function niceScaleDistance(roughMeters: number): number { const exponent = Math.floor(Math.log10(Math.max(roughMeters, 0.001))); const base = 10 ** exponent; const normalized = roughMeters / base; const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; return step * base; } /* ----------------------------------------------------------------------------- * 커서 기준 회전·줌 (B04 포인트클라우드/지형, B05 노선 뷰어 공용) * * OrbitControls 기본값은 화면 중앙(target)을 축으로 돌아서, 커서로 보고 있는 봉우리가 * 화면 밖으로 밀려난다. 여기서는 커서 아래 지형 지점을 축으로 삼아 카메라와 target을 * 함께 돌린다 — 축이 화면에 고정되므로 시점이 튀지 않는다. * 줌은 OrbitControls의 zoomToCursor로 같은 기준을 쓴다. * -------------------------------------------------------------------------- */ /** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */ const POLAR_EPSILON = 0.02; /** * 탑뷰를 극점에서 살짝 띄우는 기울기(수평/수직 비). * * 카메라를 정확히 수직으로 세우면 `lookAt`이 화면의 가로·세로 방향을 정할 수 없다(시선과 * up이 평행). 그 자리에서 조금만 끌어도 화면 축이 한 번에 180° 돌아간다 — 헤드리스 크롬으로 * 실측했다: 2px 드래그 한 걸음에 화면축 반전, 그 뒤 같은 방향 드래그는 먹통(2026-08-08). * * 2°는 화면에서 위에서 내려다보는 그림과 사실상 구분되지 않으면서, 여유각(`POLAR_EPSILON` * ≈ 1.15°)보다 바깥이라 첫 드래그부터 회전이 자연스럽게 이어진다. */ export const TOP_VIEW_TILT = Math.tan((2 * Math.PI) / 180); /** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */ const POINT_PICK_RATIO = 0.01; /** 휠 한 칸당 배율. 휠을 위로 올리면 이 값의 역수만큼 멀어진다(2026-08-01 사용자 지시). */ const ZOOM_STEP = 0.9; /** 회전 중심 조준점의 화면상 크기 비율(카메라 거리 대비). 멀어져도 같은 크기로 보인다. * 예전 불투명 구슬(지름 = 거리×0.024)이 크고 투박하다는 보고(2026-08-06)로 얇은 링 * 조준점으로 교체. 0.012(구슬의 절반 폭)는 링이 뭉개져 안 보였다 — 0.02면 화면에서 * 약 15px: 구슬보다 지름은 약간 작고, 속이 빈 링이라 시각적 무게는 훨씬 가볍다. */ const PIVOT_MARKER_RATIO = 0.02; const PIVOT_MARKER_COLOR = "rgba(245, 158, 11, 0.95)"; /** 링+십자+중심점 조준점 텍스처 — 구슬 대신 축 위치만 가볍게 표시한다(2026-08-06). */ function pivotReticleTexture(): THREE.CanvasTexture { const size = 96; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const context = canvas.getContext("2d"); if (context) { const center = size / 2; context.strokeStyle = PIVOT_MARKER_COLOR; context.fillStyle = PIVOT_MARKER_COLOR; context.lineWidth = 6; context.beginPath(); context.arc(center, center, size * 0.3, 0, Math.PI * 2); context.stroke(); // 링 안팎으로 걸치는 십자 4획 — 조준점임을 또렷하게. const inner = size * 0.16; const outer = size * 0.47; ( [ [1, 0], [-1, 0], [0, 1], [0, -1], ] as const ).forEach(([dx, dy]) => { context.beginPath(); context.moveTo(center + dx * inner, center + dy * inner); context.lineTo(center + dx * outer, center + dy * outer); context.stroke(); }); context.beginPath(); context.arc(center, center, 5, 0, Math.PI * 2); context.fill(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; return texture; } export interface CursorPivotOptions { /** 카메라. **바뀔 수 있으면 함수로** 준다 — B05는 직교/원근을 갈아 끼운다(2026-09-04). */ camera: | THREE.PerspectiveCamera | THREE.OrthographicCamera | (() => THREE.PerspectiveCamera | THREE.OrthographicCamera); controls: OrbitControls; /** 포인터 이벤트를 받는 캔버스. */ element: HTMLElement; /** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */ pickables: () => THREE.Object3D[]; /** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */ blocked?: () => boolean; /** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */ scene?: THREE.Scene; } /** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */ export function bindCursorPivotControls(options: CursorPivotOptions): () => void { const { controls, element } = options; const getCamera = (): THREE.PerspectiveCamera | THREE.OrthographicCamera => typeof options.camera === "function" ? options.camera() : options.camera; // 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다). controls.enableRotate = false; controls.enableZoom = false; // 가운데 버튼 드래그 = 그랩 팬(커서 아래 지형점이 커서를 1:1로 따라온다) — 아래에서 // 직접 처리한다. OrbitControls 기본 PAN은 카메라↔target 거리에 비례하는데, 커서 피벗 // 회전·줌이 target을 지형과 무관한 곳으로 옮겨 두면 실제 지형 거리보다 훨씬 느려진다 // (2026-08-23 사용자 보고). 겹치지 않게 내장 팬은 끈다. controls.enablePan = false; // 회전 중심 조준점 — 돌리는 동안에만 보인다. 항상 카메라를 향하는 스프라이트라 // 어느 각도에서도 또렷하고, 화면상 크기는 거리와 무관하게 일정하다. const pivotMarker = options.scene ? new THREE.Sprite( new THREE.SpriteMaterial({ map: pivotReticleTexture(), // 지형에 묻혀 안 보이면 축을 확인할 수 없으므로 항상 위에 그린다. depthTest: false, transparent: true, opacity: 0.9, }), ) : null; if (pivotMarker && options.scene) { pivotMarker.visible = false; pivotMarker.renderOrder = 999; options.scene.add(pivotMarker); } /** 조준점을 현재 축 위치·크기로 맞춘다. 스프라이트 scale = 쿼드의 월드 폭. */ function syncPivotMarker(): void { const camera = getCamera(); if (!pivotMarker || !pivotMarker.visible) return; pivotMarker.position.copy(pivot); const distance = camera.position.distanceTo(pivot); pivotMarker.scale.setScalar(Math.max(distance * PIVOT_MARKER_RATIO, 0.01)); } const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const pivot = new THREE.Vector3(); const viewDirection = new THREE.Vector3(); const fallbackPlane = new THREE.Plane(); let pointerId: number | null = null; let lastX = 0; let lastY = 0; // 그랩 팬 상태 — 잡은 지형점(월드)과 그 점을 지나는 시선 수직 평면. let panPointerId: number | null = null; const panAnchor = new THREE.Vector3(); const panPlane = new THREE.Plane(); const panPoint = new THREE.Vector3(); /** 커서가 가리키는 지형 위 지점을 회전축으로 잡는다. * * 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을 * 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */ function pickPivot(event: { clientX: number; clientY: number }): void { const camera = getCamera(); pivot.copy(controls.target); const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; pointer.set( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1, ); raycaster.setFromCamera(pointer, camera); // 포인트클라우드는 점 사이가 비어 있어 정확히 맞히기 어렵다 — 거리에 비례한 허용 반경을 준다. raycaster.params.Points.threshold = camera.position.distanceTo(controls.target) * POINT_PICK_RATIO; const hit = raycaster.intersectObjects(options.pickables(), true)[0]; if (hit) { pivot.copy(hit.point); return; } camera.getWorldDirection(viewDirection); fallbackPlane.setFromNormalAndCoplanarPoint(viewDirection, controls.target); raycaster.ray.intersectPlane(fallbackPlane, pivot); } function onPointerDown(event: PointerEvent): void { // 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 팬과 겹치므로 막는다. if (event.button === 1) event.preventDefault(); if (options.blocked?.() || !controls.enabled) return; if (event.button === 1 && panPointerId === null) { // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. pickPivot(event); panAnchor.copy(pivot); getCamera().getWorldDirection(viewDirection); panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor); panPointerId = event.pointerId; element.setPointerCapture?.(event.pointerId); return; } if (event.button !== 0 || pointerId !== null) return; pickPivot(event); pointerId = event.pointerId; // 드래그가 캔버스 밖(브라우저 위·아래 끝)으로 나가도 회전이 끊기지 않게 포인터를 잡아 둔다. // 안 잡으면 밖으로 나가는 순간 pointerleave로 회전이 멈췄다가, 다시 누를 때 회전축을 // 새로 집어 시점이 튄다(2026-08-08 사용자 보고). element.setPointerCapture?.(event.pointerId); lastX = event.clientX; lastY = event.clientY; if (pivotMarker) { pivotMarker.visible = true; syncPivotMarker(); } } /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. * 휠을 위로 올리면 멀어진다(사용자 지시). */ function onWheel(event: WheelEvent): void { const camera = getCamera(); if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); pickPivot(event); const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor); const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor); // 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다. if (cameraOffset.length() < 0.5 && factor < 1) return; camera.position.copy(pivot).add(cameraOffset); controls.target.copy(pivot).add(targetOffset); // 직교 카메라는 거리로 화면 배율이 안 변한다 — zoom을 같은 비율로 함께 조인다. // 커서 아래 지점(pivot)의 화면 위치는 옆 이동(offset 스케일)이 이미 지켜 준다. if ((camera as THREE.OrthographicCamera).isOrthographicCamera) { camera.zoom /= factor; camera.updateProjectionMatrix(); } camera.lookAt(controls.target); controls.update(); syncPivotMarker(); } function onPointerMove(event: PointerEvent): void { const camera = getCamera(); if (panPointerId === event.pointerId) { // 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다. const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; pointer.set( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1, ); raycaster.setFromCamera(pointer, camera); if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return; const delta = panAnchor.clone().sub(panPoint); camera.position.add(delta); controls.target.add(delta); controls.update(); return; } if (pointerId !== event.pointerId) return; if (options.blocked?.()) { stop(); return; } const height = Math.max(element.clientHeight, 1); // OrbitControls와 같은 감도: 화면 높이만큼 끌면 한 바퀴. const yaw = (2 * Math.PI * (event.clientX - lastX)) / height; const pitch = (2 * Math.PI * (event.clientY - lastY)) / height; lastX = event.clientX; lastY = event.clientY; if (yaw === 0 && pitch === 0) return; const up = camera.up.clone().normalize(); const cameraOffset = camera.position.clone().sub(pivot); const targetOffset = controls.target.clone().sub(pivot); // 수평 회전 — 화면 상하축(카메라 up) 기준. cameraOffset.applyAxisAngle(up, -yaw); targetOffset.applyAxisAngle(up, -yaw); // 수직 회전 — 시선의 오른쪽 축 기준. 마우스를 내리면 위에서 내려다보는 방향(2026-08-01 // 사용자 지시로 상하 반전). 극점을 넘으면 상하 성분만 버린다. const eyeDirection = cameraOffset.clone().sub(targetOffset); // 회전축은 화면의 가로축이다. 보통은 시선×up으로 얻지만, 탑뷰처럼 시선이 up과 거의 // 나란하면 이 외적이 0에 수렴해 축 방향이 잡음으로 정해진다 — 살짝만 끌어도 엉뚱한 // 축으로 돌아 화면이 뒤집혔다(2026-08-08 사용자 보고). 그럴 때는 카메라 자신의 // 가로축을 쓴다(시선×up과 같은 방향이라 조작감이 달라지지 않는다). const right = eyeDirection.clone().cross(up); if (right.lengthSq() < 1e-6 * Math.max(eyeDirection.lengthSq(), 1e-12)) { right.setFromMatrixColumn(camera.matrix, 0).negate(); } if (right.lengthSq() > 1e-12) { right.normalize(); // 극점을 넘길 회전은 **버리지 않고 극점 직전까지만 잘라서** 적용한다. 통째로 버리면 // 끄는 동안 화면이 멈춰 있다가 각도가 범위 안으로 돌아오는 순간 한꺼번에 움직여 // 튀어 보인다(2026-08-08 사용자 보고). const polar = eyeDirection.angleTo(up); // right는 up과 직교하므로 이 축으로 a만큼 돌리면 극각도 딱 a만큼 변한다. 부호만 // 실제로 재 본다 — 카메라가 극점 어느 쪽에 있느냐에 따라 뒤집힌다. const probe = eyeDirection.clone().applyAxisAngle(right, 1e-3).angleTo(up); const direction = probe >= polar ? 1 : -1; // 기본 탑뷰는 극각이 이미 여유각 안쪽(거의 0)이다. 경계로 끌어올리면 살짝만 끌어도 // 여유각만큼 튄다 — 현재 각도를 경계로 삼아, 극점에서 **멀어지는 쪽만** 열어 준다. const clamped = THREE.MathUtils.clamp( polar + direction * pitch, Math.min(polar, POLAR_EPSILON), Math.max(polar, Math.PI - POLAR_EPSILON), ); const applied = direction * (clamped - polar); if (Math.abs(applied) > 1e-9) { cameraOffset.applyAxisAngle(right, applied); targetOffset.applyAxisAngle(right, applied); } } camera.position.copy(pivot).add(cameraOffset); controls.target.copy(pivot).add(targetOffset); camera.lookAt(controls.target); controls.update(); syncPivotMarker(); } function stop(): void { if (pointerId !== null && element.hasPointerCapture?.(pointerId)) { element.releasePointerCapture(pointerId); } pointerId = null; if (pivotMarker) pivotMarker.visible = false; } function onPointerEnd(event: PointerEvent): void { if (pointerId === event.pointerId) stop(); if (panPointerId === event.pointerId) { if (element.hasPointerCapture?.(event.pointerId)) { element.releasePointerCapture(event.pointerId); } panPointerId = null; } } /** 캔버스를 벗어나도 잡고 있는 동안에는 회전을 이어 간다 — 놓을 때만 끝낸다. */ function onPointerLeave(event: PointerEvent): void { if (element.hasPointerCapture?.(event.pointerId)) return; onPointerEnd(event); } element.addEventListener("pointerdown", onPointerDown); element.addEventListener("pointermove", onPointerMove); element.addEventListener("pointerup", onPointerEnd); element.addEventListener("pointercancel", onPointerEnd); element.addEventListener("pointerleave", onPointerLeave); element.addEventListener("wheel", onWheel, { passive: false }); return () => { element.removeEventListener("pointerdown", onPointerDown); element.removeEventListener("pointermove", onPointerMove); element.removeEventListener("pointerup", onPointerEnd); element.removeEventListener("pointercancel", onPointerEnd); element.removeEventListener("pointerleave", onPointerLeave); element.removeEventListener("wheel", onWheel); if (pivotMarker) { pivotMarker.removeFromParent(); pivotMarker.material.map?.dispose(); pivotMarker.material.dispose(); } }; } export function bindSurfaceViewerTheme( applyBackground: (color: string | number) => void, ): () => void { const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); const update = (): void => { const theme = document.documentElement.getAttribute("data-theme"); const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches); if (!dark) { applyBackground(LIGHT_VIEWER_BACKGROUND); return; } const surfaceRaised = getComputedStyle(document.documentElement) .getPropertyValue("--color-surface-raised") .trim(); applyBackground( surfaceRaised && CSS.supports("color", surfaceRaised) ? surfaceRaised : DARK_VIEWER_BACKGROUND, ); }; const observer = new MutationObserver(update); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); systemDarkTheme.addEventListener("change", update); update(); return () => { observer.disconnect(); systemDarkTheme.removeEventListener("change", update); }; }