fix(B04/B05): 3D 조작감 보정 — 커서 표면 회전축, 상하 반전, 가운데 버튼 팬

- 회전축을 커서가 가리키는 지형 표면 지점으로. 포인트클라우드는 카메라 거리의 1%를
  클릭 허용 반경으로 주고, 못 맞히면 시선 수직 평면과 만나 커서 방향 지점을 쓴다
  (화면 중앙으로 되돌아가지 않음)
- 수직 회전 부호 반전 (마우스를 내리면 내려다보는 방향)
- controls.mouseButtons.MIDDLE = PAN (전역 공통). 브라우저 자동 스크롤 차단
- B05 뷰셋 버튼 그룹을 좌상단으로 이동 (.b05-route__view-controls top 58px → 16px)
This commit is contained in:
2026-08-01 09:19:59 +09:00
parent 4501f3b19e
commit f6be8d7307
2 changed files with 34 additions and 13 deletions
+31 -12
View File
@@ -56,6 +56,8 @@ export function niceScaleDistance(roughMeters: number): number {
/** 극점을 넘어 화면이 뒤집히지 않도록 남기는 여유각(rad). */
const POLAR_EPSILON = 0.02;
/** 포인트클라우드 클릭 허용 반경 — 카메라 거리에 비례(멀수록 점이 성기게 보인다). */
const POINT_PICK_RATIO = 0.01;
export interface CursorPivotOptions {
camera: THREE.PerspectiveCamera;
@@ -71,22 +73,27 @@ export interface CursorPivotOptions {
/** 커서 기준 회전·줌을 붙이고, 해제 함수를 돌려준다. */
export function bindCursorPivotControls(options: CursorPivotOptions): () => void {
const { camera, controls, element } = options;
// 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(팬·줌은 그대로 둔다).
// 회전은 여기서 직접 처리하므로 OrbitControls 쪽 회전은 끈다(줌은 그대로 둔다).
controls.enableRotate = false;
controls.zoomToCursor = true;
// 가운데 버튼 드래그 = 화면 이동(전역 공통). 기본값(DOLLY)은 휠 줌과 겹쳐 쓸모가 없다.
controls.mouseButtons.MIDDLE = THREE.MOUSE.PAN;
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을 축으로 쓴다. */
/** 커서가 가리키는 지형 지점을 회전축으로 잡는다.
*
* 지형을 맞히면 그 점을 쓰고, 하늘·구멍이라 못 맞히면 시선에 수직이고 현재 target을
* 지나는 평면과 광선을 만나게 해 **커서 방향**의 점을 쓴다(화면 중앙으로 돌아가지 않는다). */
function pickPivot(event: PointerEvent): void {
pivot.copy(controls.target);
const targets = options.pickables();
if (targets.length === 0) return;
const rect = element.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
pointer.set(
@@ -94,11 +101,22 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
-((event.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(targets, true)[0];
if (hit) pivot.copy(hit.point);
// 포인트클라우드는 점 사이가 비어 있어 정확히 맞히기 어렵다 — 거리에 비례한 허용 반경을 준다.
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);
@@ -127,16 +145,17 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
// 수평 회전 — 화면 상하축(카메라 up) 기준.
cameraOffset.applyAxisAngle(up, -yaw);
targetOffset.applyAxisAngle(up, -yaw);
// 수직 회전 — 시선의 오른쪽 축 기준. 극점을 넘으면 상하만 버린다.
const viewDirection = cameraOffset.clone().sub(targetOffset);
const right = viewDirection.clone().cross(up);
// 수직 회전 — 시선의 오른쪽 축 기준. 마우스를 내리면 위에서 내려다보는 방향(2026-08-01
// 사용자 지시로 상하 반전). 극점을 넘으면 상하 성분만 버린다.
const eyeDirection = cameraOffset.clone().sub(targetOffset);
const right = eyeDirection.clone().cross(up);
if (right.lengthSq() > 1e-8) {
right.normalize();
const rotated = viewDirection.clone().applyAxisAngle(right, -pitch);
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);
cameraOffset.applyAxisAngle(right, pitch);
targetOffset.applyAxisAngle(right, pitch);
}
}
camera.position.copy(pivot).add(cameraOffset);
+3 -1
View File
@@ -192,10 +192,12 @@
transform: translateX(-50%);
}
/* 뷰셋·표시 토글 버튼 묶음 — 좌상단(2026-08-01 사용자 지시).
안내 문구가 우상단으로 옮겨져 좌상단이 비었다. */
.b05-route__view-controls {
position: absolute;
z-index: 2;
top: 58px;
top: var(--spacing-16);
left: var(--spacing-16);
display: flex;
align-items: center;