fix(B04/B05): 3D 조작감 보정 — 커서 표면 회전축, 상하 반전, 가운데 버튼 팬
- 회전축을 커서가 가리키는 지형 표면 지점으로. 포인트클라우드는 카메라 거리의 1%를 클릭 허용 반경으로 주고, 못 맞히면 시선 수직 평면과 만나 커서 방향 지점을 쓴다 (화면 중앙으로 되돌아가지 않음) - 수직 회전 부호 반전 (마우스를 내리면 내려다보는 방향) - controls.mouseButtons.MIDDLE = PAN (전역 공통). 브라우저 자동 스크롤 차단 - B05 뷰셋 버튼 그룹을 좌상단으로 이동 (.b05-route__view-controls top 58px → 16px)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user