diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index a59a88b7..e1db3c38 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -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();