- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존) - 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess), 라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석 - 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
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;
|
|
/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */
|
|
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 {
|
|
camera: THREE.PerspectiveCamera;
|
|
controls: OrbitControls;
|
|
/** 포인터 이벤트를 받는 캔버스. */
|
|
element: HTMLElement;
|
|
/** 커서 아래에서 찾을 대상(지형 메시·포인트클라우드). 없으면 기존 축을 유지한다. */
|
|
pickables: () => THREE.Object3D[];
|
|
/** 마커 드래그 등 다른 조작이 잡고 있으면 회전을 넘긴다. */
|
|
blocked?: () => boolean;
|
|
/** 회전 중심 구슬을 띄울 장면. 주지 않으면 구슬을 만들지 않는다. */
|
|
scene?: THREE.Scene;
|
|
}
|
|
|
|
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
|
|
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
|
|
const { camera, controls, element } = options;
|
|
// 회전·줌 모두 여기서 직접 처리한다(OrbitControls에는 휠 방향을 뒤집는 설정이 없다).
|
|
controls.enableRotate = false;
|
|
controls.enableZoom = false;
|
|
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
|
|
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
|
|
|
|
// 회전 중심 조준점 — 돌리는 동안에만 보인다. 항상 카메라를 향하는 스프라이트라
|
|
// 어느 각도에서도 또렷하고, 화면상 크기는 거리와 무관하게 일정하다.
|
|
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 {
|
|
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;
|
|
|
|
/** 커서가 가리키는 지형 위 지점을 회전축으로 잡는다.
|
|
*
|
|
* 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을
|
|
* 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */
|
|
function pickPivot(event: { clientX: number; clientY: number }): void {
|
|
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 (event.button !== 0 || pointerId !== null) return;
|
|
if (options.blocked?.() || !controls.enabled) return;
|
|
pickPivot(event);
|
|
pointerId = event.pointerId;
|
|
lastX = event.clientX;
|
|
lastY = event.clientY;
|
|
if (pivotMarker) {
|
|
pivotMarker.visible = true;
|
|
syncPivotMarker();
|
|
}
|
|
}
|
|
|
|
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
|
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
|
function onWheel(event: WheelEvent): void {
|
|
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);
|
|
camera.lookAt(controls.target);
|
|
controls.update();
|
|
syncPivotMarker();
|
|
}
|
|
|
|
function onPointerMove(event: PointerEvent): void {
|
|
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);
|
|
const right = eyeDirection.clone().cross(up);
|
|
if (right.lengthSq() > 1e-8) {
|
|
right.normalize();
|
|
const rotated = eyeDirection.clone().applyAxisAngle(right, pitch);
|
|
const polar = rotated.angleTo(up);
|
|
if (polar > POLAR_EPSILON && polar < Math.PI - POLAR_EPSILON) {
|
|
cameraOffset.applyAxisAngle(right, pitch);
|
|
targetOffset.applyAxisAngle(right, pitch);
|
|
}
|
|
}
|
|
camera.position.copy(pivot).add(cameraOffset);
|
|
controls.target.copy(pivot).add(targetOffset);
|
|
camera.lookAt(controls.target);
|
|
controls.update();
|
|
syncPivotMarker();
|
|
}
|
|
|
|
function stop(): void {
|
|
pointerId = null;
|
|
if (pivotMarker) pivotMarker.visible = false;
|
|
}
|
|
|
|
function onPointerEnd(event: PointerEvent): void {
|
|
if (pointerId === event.pointerId) stop();
|
|
}
|
|
|
|
element.addEventListener("pointerdown", onPointerDown);
|
|
element.addEventListener("pointermove", onPointerMove);
|
|
element.addEventListener("pointerup", onPointerEnd);
|
|
element.addEventListener("pointercancel", onPointerEnd);
|
|
element.addEventListener("pointerleave", onPointerEnd);
|
|
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", onPointerEnd);
|
|
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);
|
|
};
|
|
}
|