세로로 많이 끌면 화면이 멈췄다가 한 번에 움직이는 증상. 원인 두 가지였다. 1) 극점을 넘길 회전을 통째로 버렸다. 끄는 동안 화면이 굳어 있다가 각도가 다시 범위 안으로 들어오는 순간 밀린 만큼 한꺼번에 움직였다. 이제 극점 직전까지만 잘라서 적용한다 — right축이 up과 직교하므로 돌린 각이 곧 극각 변화량이고, 부호만 실제로 재서 클램프한다. 극점에 닿으면 더 안 가고 멈출 뿐, 반대로는 자유롭게 빠져나온다. 2) 드래그가 캔버스 밖(브라우저 위·아래 끝)으로 나가면 pointerleave로 회전이 끊겼다. 다시 누를 때 커서 아래로 회전축을 새로 집어 시점이 튀었다. 포인터를 캡처해 밖으로 나가도 회전을 이어 가고, 놓을 때만 끝낸다. B04 지형·포인트클라우드 뷰어와 B05 노선 뷰어가 같은 모듈을 쓰므로 양쪽 모두 적용된다. typecheck·prettier 통과, 정적 번들 재빌드. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
355 lines
15 KiB
TypeScript
355 lines
15 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;
|
|
// 드래그가 캔버스 밖(브라우저 위·아래 끝)으로 나가도 회전이 끊기지 않게 포인터를 잡아 둔다.
|
|
// 안 잡으면 밖으로 나가는 순간 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 {
|
|
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();
|
|
// 극점을 넘길 회전은 **버리지 않고 극점 직전까지만 잘라서** 적용한다. 통째로 버리면
|
|
// 끄는 동안 화면이 멈춰 있다가 각도가 범위 안으로 돌아오는 순간 한꺼번에 움직여
|
|
// 튀어 보인다(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;
|
|
const clamped = THREE.MathUtils.clamp(
|
|
polar + direction * pitch,
|
|
POLAR_EPSILON,
|
|
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();
|
|
}
|
|
|
|
/** 캔버스를 벗어나도 잡고 있는 동안에는 회전을 이어 간다 — 놓을 때만 끝낸다. */
|
|
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);
|
|
};
|
|
}
|