From f6be8d73071fe4ef8a9881bc0044586818025c50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 09:19:59 +0900 Subject: [PATCH] =?UTF-8?q?fix(B04/B05):=203D=20=EC=A1=B0=EC=9E=91?= =?UTF-8?q?=EA=B0=90=20=EB=B3=B4=EC=A0=95=20=E2=80=94=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=20=ED=91=9C=EB=A9=B4=20=ED=9A=8C=EC=A0=84=EC=B6=95,=20?= =?UTF-8?q?=EC=83=81=ED=95=98=20=EB=B0=98=EC=A0=84,=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EB=B2=84=ED=8A=BC=20=ED=8C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회전축을 커서가 가리키는 지형 표면 지점으로. 포인트클라우드는 카메라 거리의 1%를 클릭 허용 반경으로 주고, 못 맞히면 시선 수직 평면과 만나 커서 방향 지점을 쓴다 (화면 중앙으로 되돌아가지 않음) - 수직 회전 부호 반전 (마우스를 내리면 내려다보는 방향) - controls.mouseButtons.MIDDLE = PAN (전역 공통). 브라우저 자동 스크롤 차단 - B05 뷰셋 버튼 그룹을 좌상단으로 이동 (.b05-route__view-controls top 58px → 16px) --- B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts | 43 ++++++++++++++------ B05_wf2_Route/B05_wf2_Route_UI_Style.css | 4 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index b7a5eb0e..72902bee 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -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); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 9c066216..e6674dca 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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;