fix(B04,B05): 3D 상하 드래그 시 화면이 튀던 문제 — 극점 처리와 드래그 이탈

세로로 많이 끌면 화면이 멈췄다가 한 번에 움직이는 증상. 원인 두 가지였다.

1) 극점을 넘길 회전을 통째로 버렸다. 끄는 동안 화면이 굳어 있다가 각도가 다시 범위
   안으로 들어오는 순간 밀린 만큼 한꺼번에 움직였다. 이제 극점 직전까지만 잘라서
   적용한다 — right축이 up과 직교하므로 돌린 각이 곧 극각 변화량이고, 부호만 실제로
   재서 클램프한다. 극점에 닿으면 더 안 가고 멈출 뿐, 반대로는 자유롭게 빠져나온다.
2) 드래그가 캔버스 밖(브라우저 위·아래 끝)으로 나가면 pointerleave로 회전이 끊겼다.
   다시 누를 때 커서 아래로 회전축을 새로 집어 시점이 튀었다. 포인터를 캡처해 밖으로
   나가도 회전을 이어 가고, 놓을 때만 끝낸다.

B04 지형·포인트클라우드 뷰어와 B05 노선 뷰어가 같은 모듈을 쓰므로 양쪽 모두 적용된다.
typecheck·prettier 통과, 정적 번들 재빌드.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:26:26 +09:00
co-authored by Claude Opus 5
parent d9f6fbdff1
commit c3f643b8b0
+32 -7
View File
@@ -198,6 +198,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
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) {
@@ -250,11 +254,23 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
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);
// 극점을 넘길 회전은 **버리지 않고 극점 직전까지만 잘라서** 적용한다. 통째로 버리면
// 끄는 동안 화면이 멈춰 있다가 각도가 범위 안으로 돌아오는 순간 한꺼번에 움직여
// 튀어 보인다(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);
@@ -265,6 +281,9 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
}
function stop(): void {
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
element.releasePointerCapture(pointerId);
}
pointerId = null;
if (pivotMarker) pivotMarker.visible = false;
}
@@ -273,11 +292,17 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => 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", onPointerEnd);
element.addEventListener("pointerleave", onPointerLeave);
element.addEventListener("wheel", onWheel, { passive: false });
return () => {
@@ -285,7 +310,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
element.removeEventListener("pointermove", onPointerMove);
element.removeEventListener("pointerup", onPointerEnd);
element.removeEventListener("pointercancel", onPointerEnd);
element.removeEventListener("pointerleave", onPointerEnd);
element.removeEventListener("pointerleave", onPointerLeave);
element.removeEventListener("wheel", onWheel);
if (pivotMarker) {
pivotMarker.removeFromParent();