feat(device): 손가락 → 마우스 번역 — 길게 누름 = 우클릭 · 두 번 톡 = 더블클릭 · 벌림 = Ctrl+휠(touch_bridge) · 3D 두 손가락 확대 · 이동 · 공용 SVG 확대 두 손가락 · 당기면 확대 지도 벌림 방향 · M02 그림판 손가락 끌기 · 시트 ⋯ 칸 메뉴 · 그림 면 안내 (기기대응 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -225,10 +225,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
if (options.blocked?.() || !controls.enabled) return;
|
||||
if (event.button === 1 && panPointerId === null) {
|
||||
// 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다.
|
||||
pickPivot(event);
|
||||
panAnchor.copy(pivot);
|
||||
getCamera().getWorldDirection(viewDirection);
|
||||
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
||||
startGrabPan(event);
|
||||
panPointerId = event.pointerId;
|
||||
element.setPointerCapture?.(event.pointerId);
|
||||
return;
|
||||
@@ -251,11 +248,15 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
/** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다.
|
||||
* 휠을 위로 올리면 멀어진다(사용자 지시). */
|
||||
function onWheel(event: WheelEvent): void {
|
||||
const camera = getCamera();
|
||||
if (!controls.enabled || options.blocked?.()) return;
|
||||
event.preventDefault();
|
||||
pickPivot(event);
|
||||
const factor = event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
|
||||
zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP);
|
||||
}
|
||||
|
||||
/** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */
|
||||
function zoomAround(at: { clientX: number; clientY: number }, factor: number): void {
|
||||
const camera = getCamera();
|
||||
pickPivot(at);
|
||||
const cameraOffset = camera.position.clone().sub(pivot).multiplyScalar(factor);
|
||||
const targetOffset = controls.target.clone().sub(pivot).multiplyScalar(factor);
|
||||
// 축에 너무 가까워지면 시점이 뒤집히므로 최소 거리를 남긴다.
|
||||
@@ -276,19 +277,7 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
function onPointerMove(event: PointerEvent): void {
|
||||
const camera = getCamera();
|
||||
if (panPointerId === event.pointerId) {
|
||||
// 그랩 팬 — 잡은 점이 커서 아래에 계속 오도록 카메라·target을 평행 이동한다.
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
pointer.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
||||
const delta = panAnchor.clone().sub(panPoint);
|
||||
camera.position.add(delta);
|
||||
controls.target.add(delta);
|
||||
controls.update();
|
||||
grabPanTo(event);
|
||||
return;
|
||||
}
|
||||
if (pointerId !== event.pointerId) return;
|
||||
@@ -351,6 +340,67 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
syncPivotMarker();
|
||||
}
|
||||
|
||||
/** 그랩 팬 — 잡은 점이 커서(손가락 가운데) 아래에 계속 오도록 카메라·target을 평행 이동한다. */
|
||||
function grabPanTo(at: { clientX: number; clientY: number }): void {
|
||||
const camera = getCamera();
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0 || rect.height <= 0) return;
|
||||
pointer.set(
|
||||
((at.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((at.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
if (!raycaster.ray.intersectPlane(panPlane, panPoint)) return;
|
||||
const delta = panAnchor.clone().sub(panPoint);
|
||||
camera.position.add(delta);
|
||||
controls.target.add(delta);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function startGrabPan(at: { clientX: number; clientY: number }): void {
|
||||
pickPivot(at);
|
||||
panAnchor.copy(pivot);
|
||||
getCamera().getWorldDirection(viewDirection);
|
||||
panPlane.setFromNormalAndCoplanarPoint(viewDirection, panAnchor);
|
||||
}
|
||||
|
||||
// 손가락(기기대응 3-1) — 하나 = 회전(왼쪽 단추와 같음) · 둘 = 벌려 확대 · 같이 끌어 이동.
|
||||
// 3D 는 자체 처리 — 손가락 번역(`touch_bridge`)은 이 요소를 건너뜀.
|
||||
element.dataset.touchOwn = "3d";
|
||||
element.style.touchAction = "none";
|
||||
const touches = new Map<number, { clientX: number; clientY: number }>();
|
||||
let pinchDist = 0;
|
||||
const touchMid = (): { clientX: number; clientY: number; dist: number } => {
|
||||
const [a, b] = [...touches.values()];
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2,
|
||||
dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY),
|
||||
};
|
||||
};
|
||||
function onTouchDown(event: PointerEvent): void {
|
||||
if (event.pointerType !== "touch" || options.blocked?.() || !controls.enabled) return;
|
||||
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||
if (touches.size !== 2) return;
|
||||
stop(); // 한 손가락 회전을 멈추고 두 손가락으로
|
||||
const m = touchMid();
|
||||
pinchDist = m.dist;
|
||||
startGrabPan(m);
|
||||
}
|
||||
function onTouchMove(event: PointerEvent): void {
|
||||
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
|
||||
touches.set(event.pointerId, { clientX: event.clientX, clientY: event.clientY });
|
||||
if (touches.size !== 2) return;
|
||||
const m = touchMid();
|
||||
grabPanTo(m);
|
||||
if (pinchDist > 0 && m.dist > 0) zoomAround(m, pinchDist / m.dist);
|
||||
pinchDist = m.dist;
|
||||
startGrabPan(m); // 확대 뒤 다시 잡음 — 다음 이동이 새 배율 기준
|
||||
}
|
||||
function onTouchUp(event: PointerEvent): void {
|
||||
touches.delete(event.pointerId);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (pointerId !== null && element.hasPointerCapture?.(pointerId)) {
|
||||
element.releasePointerCapture(pointerId);
|
||||
@@ -381,6 +431,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
element.addEventListener("pointercancel", onPointerEnd);
|
||||
element.addEventListener("pointerleave", onPointerLeave);
|
||||
element.addEventListener("wheel", onWheel, { passive: false });
|
||||
element.addEventListener("pointerdown", onTouchDown);
|
||||
element.addEventListener("pointermove", onTouchMove);
|
||||
element.addEventListener("pointerup", onTouchUp);
|
||||
element.addEventListener("pointercancel", onTouchUp);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("pointerdown", onPointerDown);
|
||||
@@ -389,6 +443,10 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void
|
||||
element.removeEventListener("pointercancel", onPointerEnd);
|
||||
element.removeEventListener("pointerleave", onPointerLeave);
|
||||
element.removeEventListener("wheel", onWheel);
|
||||
element.removeEventListener("pointerdown", onTouchDown);
|
||||
element.removeEventListener("pointermove", onTouchMove);
|
||||
element.removeEventListener("pointerup", onTouchUp);
|
||||
element.removeEventListener("pointercancel", onTouchUp);
|
||||
if (pivotMarker) {
|
||||
pivotMarker.removeFromParent();
|
||||
pivotMarker.material.map?.dispose();
|
||||
|
||||
Reference in New Issue
Block a user