Add camera mode
This commit is contained in:
parent
54137379c7
commit
737f4dbbb3
3 changed files with 409 additions and 13 deletions
6
TODO.md
6
TODO.md
|
|
@ -140,9 +140,9 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
||||||
- [x] Hover effect: handles grow subtly and brighten
|
- [x] Hover effect: handles grow subtly and brighten
|
||||||
|
|
||||||
### Camera Mode (Alt Modifier)
|
### Camera Mode (Alt Modifier)
|
||||||
- [ ] Left-drag: orbit camera
|
- [x] Left-drag: orbit camera
|
||||||
- [ ] Right-drag: pan camera
|
- [x] Right-drag: pan camera
|
||||||
- [ ] Left-click object: center/zoom camera on object
|
- [x] Left-click object: center/zoom camera on object
|
||||||
|
|
||||||
### Shift Modifier Behaviors
|
### Shift Modifier Behaviors
|
||||||
- [ ] Click with Shift: add to selection (already in Phase 4)
|
- [ ] Click with Shift: add to selection (already in Phase 4)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
import { createCameraControls, type CameraControls } from '../composables/useCameraControls'
|
||||||
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
|
||||||
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
||||||
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
|
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
|
||||||
|
|
@ -75,7 +75,7 @@ let animationFrameId: number
|
||||||
let resizeObserver: ResizeObserver
|
let resizeObserver: ResizeObserver
|
||||||
let gridHelper: THREE.GridHelper
|
let gridHelper: THREE.GridHelper
|
||||||
let axisHelper: THREE.AxesHelper
|
let axisHelper: THREE.AxesHelper
|
||||||
let controls: OrbitControls
|
let controls: CameraControls
|
||||||
let raycaster: THREE.Raycaster
|
let raycaster: THREE.Raycaster
|
||||||
const mouse = new THREE.Vector2()
|
const mouse = new THREE.Vector2()
|
||||||
let composer: EffectComposer
|
let composer: EffectComposer
|
||||||
|
|
@ -92,6 +92,63 @@ let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
||||||
let hoveredScaleHandle: ScaleHandleInfo | null = null
|
let hoveredScaleHandle: ScaleHandleInfo | null = null
|
||||||
let isCtrlPressed = false
|
let isCtrlPressed = false
|
||||||
let isShiftPressed = false
|
let isShiftPressed = false
|
||||||
|
let isAltPressed = false
|
||||||
|
let isSpacePressed = false
|
||||||
|
let isMiddleMouseDown = false
|
||||||
|
let cameraClickStartPosition: THREE.Vector2 | null = null
|
||||||
|
|
||||||
|
// Fly-to animation state
|
||||||
|
let flyToAnimation: {
|
||||||
|
active: boolean
|
||||||
|
startTime: number
|
||||||
|
duration: number
|
||||||
|
startPosition: THREE.Vector3
|
||||||
|
startTarget: THREE.Vector3
|
||||||
|
endPosition: THREE.Vector3
|
||||||
|
endTarget: THREE.Vector3
|
||||||
|
} | null = null
|
||||||
|
|
||||||
|
// Zoom state for smooth wheel zoom
|
||||||
|
let zoomVelocity = 0
|
||||||
|
const ZOOM_DAMPING = 0.1
|
||||||
|
const ZOOM_SPEED = 0.0002
|
||||||
|
|
||||||
|
function isCameraModeActive(): boolean {
|
||||||
|
return isAltPressed || isSpacePressed || isMiddleMouseDown
|
||||||
|
}
|
||||||
|
|
||||||
|
function flyToObject(object: THREE.Object3D) {
|
||||||
|
// Calculate bounding box and center
|
||||||
|
const box = new THREE.Box3().expandByObject(object)
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
|
// Calculate distance for object to fill ~50% of screen
|
||||||
|
const fov = perspectiveCamera.fov * (Math.PI / 180)
|
||||||
|
const screenFillFactor = 0.5 // 50% of screen
|
||||||
|
const distance = maxDim / (2 * Math.tan(fov / 2) * screenFillFactor)
|
||||||
|
|
||||||
|
// Calculate end camera position: maintain current view direction, adjust distance
|
||||||
|
const currentDirection = new THREE.Vector3()
|
||||||
|
.subVectors(activeCamera.position, controls.target)
|
||||||
|
.normalize()
|
||||||
|
const endPosition = new THREE.Vector3().addVectors(
|
||||||
|
center,
|
||||||
|
currentDirection.multiplyScalar(distance)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start the animation
|
||||||
|
flyToAnimation = {
|
||||||
|
active: true,
|
||||||
|
startTime: performance.now(),
|
||||||
|
duration: 500, // 500ms animation
|
||||||
|
startPosition: activeCamera.position.clone(),
|
||||||
|
startTarget: controls.target.clone(),
|
||||||
|
endPosition: endPosition,
|
||||||
|
endTarget: center,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
@ -132,10 +189,11 @@ function initScene() {
|
||||||
renderer.domElement.draggable = false
|
renderer.domElement.draggable = false
|
||||||
renderer.domElement.addEventListener('dragstart', (e) => e.preventDefault())
|
renderer.domElement.addEventListener('dragstart', (e) => e.preventDefault())
|
||||||
|
|
||||||
// Setup orbit controls
|
// Setup camera controls (disabled by default, enabled only with camera mode modifiers)
|
||||||
controls = new OrbitControls(activeCamera, renderer.domElement)
|
// Left-drag = orbit, right/middle-drag = pan
|
||||||
controls.enableDamping = true
|
controls = createCameraControls(activeCamera, renderer.domElement)
|
||||||
controls.dampingFactor = 0.05
|
controls.dampingFactor = 0.05
|
||||||
|
controls.enabled = false
|
||||||
|
|
||||||
addLighting()
|
addLighting()
|
||||||
addGridHelper()
|
addGridHelper()
|
||||||
|
|
@ -288,6 +346,22 @@ function onKeyDown(event: KeyboardEvent) {
|
||||||
isShiftPressed = true
|
isShiftPressed = true
|
||||||
needsUpdate = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
if (event.key === 'Alt' && !isAltPressed) {
|
||||||
|
// Prevent window manager from intercepting Alt for window dragging
|
||||||
|
event.preventDefault()
|
||||||
|
isAltPressed = true
|
||||||
|
needsUpdate = true
|
||||||
|
// Enable camera controls in camera mode
|
||||||
|
controls.enabled = true
|
||||||
|
}
|
||||||
|
if (event.key === ' ' && !isSpacePressed) {
|
||||||
|
// Prevent page scroll on Space
|
||||||
|
event.preventDefault()
|
||||||
|
isSpacePressed = true
|
||||||
|
needsUpdate = true
|
||||||
|
// Enable camera controls in camera mode
|
||||||
|
controls.enabled = true
|
||||||
|
}
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
updateGizmoVisibility()
|
updateGizmoVisibility()
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +377,22 @@ function onKeyUp(event: KeyboardEvent) {
|
||||||
isShiftPressed = false
|
isShiftPressed = false
|
||||||
needsUpdate = true
|
needsUpdate = true
|
||||||
}
|
}
|
||||||
|
if (event.key === 'Alt' && isAltPressed) {
|
||||||
|
isAltPressed = false
|
||||||
|
needsUpdate = true
|
||||||
|
// Disable camera controls when leaving camera mode (if Space not held)
|
||||||
|
if (!isSpacePressed) {
|
||||||
|
controls.enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.key === ' ' && isSpacePressed) {
|
||||||
|
isSpacePressed = false
|
||||||
|
needsUpdate = true
|
||||||
|
// Disable camera controls when leaving camera mode (if Alt not held)
|
||||||
|
if (!isAltPressed) {
|
||||||
|
controls.enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
updateGizmoVisibility()
|
updateGizmoVisibility()
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +401,8 @@ function onKeyUp(event: KeyboardEvent) {
|
||||||
function updateGizmoVisibility() {
|
function updateGizmoVisibility() {
|
||||||
const hasSelection = sceneStore.selectedObjects.length > 0
|
const hasSelection = sceneStore.selectedObjects.length > 0
|
||||||
|
|
||||||
if (!hasSelection) {
|
if (!hasSelection || isCameraModeActive()) {
|
||||||
|
// Hide all gizmos when nothing selected or in Camera Mode (Alt or Space held)
|
||||||
translateGizmo?.hide()
|
translateGizmo?.hide()
|
||||||
rotateGizmo?.hide()
|
rotateGizmo?.hide()
|
||||||
scaleGizmo?.hide()
|
scaleGizmo?.hide()
|
||||||
|
|
@ -371,11 +462,35 @@ function pickObject(event: MouseEvent): SceneObject | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasClick(event: MouseEvent) {
|
function onCanvasClick(event: MouseEvent) {
|
||||||
|
// Camera Mode: if we started a click in camera mode, handle it here
|
||||||
|
// (cameraClickStartPosition is set in onCanvasMouseDown when in camera mode)
|
||||||
|
if (cameraClickStartPosition) {
|
||||||
|
updateMouseCoordinates(event)
|
||||||
|
// Check if it was a click (not a drag) by comparing positions
|
||||||
|
const clickThreshold = 0.01 // Small threshold in NDC space
|
||||||
|
const distance = mouse.distanceTo(cameraClickStartPosition)
|
||||||
|
cameraClickStartPosition = null
|
||||||
|
|
||||||
|
if (distance < clickThreshold) {
|
||||||
|
const pickedObject = pickObject(event)
|
||||||
|
if (pickedObject) {
|
||||||
|
// Fly camera to the clicked object
|
||||||
|
flyToObject(pickedObject.mesh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pickedObject = pickObject(event)
|
const pickedObject = pickObject(event)
|
||||||
emit('pick', pickedObject, event)
|
emit('pick', pickedObject, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseMove(event: MouseEvent) {
|
function onCanvasMouseMove(event: MouseEvent) {
|
||||||
|
// Prevent window manager from grabbing window on Alt+drag
|
||||||
|
if (isCameraModeActive()) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
|
|
||||||
// Handle translate gizmo dragging
|
// Handle translate gizmo dragging
|
||||||
|
|
@ -692,6 +807,25 @@ function onCanvasMouseDown(event: MouseEvent) {
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
raycaster.setFromCamera(mouse, activeCamera)
|
raycaster.setFromCamera(mouse, activeCamera)
|
||||||
|
|
||||||
|
// Middle mouse button activates camera mode
|
||||||
|
if (event.button === 1) {
|
||||||
|
event.preventDefault()
|
||||||
|
isMiddleMouseDown = true
|
||||||
|
controls.enabled = true
|
||||||
|
updateGizmoVisibility()
|
||||||
|
cameraClickStartPosition = mouse.clone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera Mode (Alt or Space held): track click position for center-on-click
|
||||||
|
if (isCameraModeActive()) {
|
||||||
|
// Prevent browser/window manager from grabbing the window
|
||||||
|
event.preventDefault()
|
||||||
|
cameraClickStartPosition = mouse.clone()
|
||||||
|
// Let OrbitControls handle orbit/pan naturally
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check for scale gizmo first (when Ctrl+Shift is held)
|
// Check for scale gizmo first (when Ctrl+Shift is held)
|
||||||
if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
|
if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
|
||||||
const handleInfo = scaleGizmo.getHoveredHandle(raycaster)
|
const handleInfo = scaleGizmo.getHoveredHandle(raycaster)
|
||||||
|
|
@ -815,7 +949,17 @@ function onCanvasMouseDown(event: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseUp(_event: MouseEvent) {
|
function onCanvasMouseUp(event: MouseEvent) {
|
||||||
|
// Middle mouse button release exits camera mode
|
||||||
|
if (event.button === 1 && isMiddleMouseDown) {
|
||||||
|
isMiddleMouseDown = false
|
||||||
|
// Disable camera controls if no other camera mode modifier is held
|
||||||
|
if (!isAltPressed && !isSpacePressed) {
|
||||||
|
controls.enabled = false
|
||||||
|
}
|
||||||
|
updateGizmoVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
if (dragState.isDragging) {
|
if (dragState.isDragging) {
|
||||||
// Clear stored original positions
|
// Clear stored original positions
|
||||||
for (const obj of sceneStore.selectedObjects) {
|
for (const obj of sceneStore.selectedObjects) {
|
||||||
|
|
@ -824,7 +968,6 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
|
|
||||||
dragState.isDragging = false
|
dragState.isDragging = false
|
||||||
dragState.axis = null
|
dragState.axis = null
|
||||||
controls.enabled = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rotateDragState.isDragging) {
|
if (rotateDragState.isDragging) {
|
||||||
|
|
@ -834,7 +977,6 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
rotateDragState.isDragging = false
|
rotateDragState.isDragging = false
|
||||||
rotateDragState.axis = null
|
rotateDragState.axis = null
|
||||||
rotateDragState.startObjectRotations.clear()
|
rotateDragState.startObjectRotations.clear()
|
||||||
controls.enabled = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scaleDragState.isDragging) {
|
if (scaleDragState.isDragging) {
|
||||||
|
|
@ -842,11 +984,18 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
scaleDragState.handleInfo = null
|
scaleDragState.handleInfo = null
|
||||||
scaleDragState.startMeshScales.clear()
|
scaleDragState.startMeshScales.clear()
|
||||||
scaleDragState.startMeshPositions.clear()
|
scaleDragState.startMeshPositions.clear()
|
||||||
controls.enabled = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasWheel(event: WheelEvent) {
|
function onCanvasWheel(event: WheelEvent) {
|
||||||
|
// Camera mode: zoom in/out
|
||||||
|
if (isCameraModeActive()) {
|
||||||
|
event.preventDefault()
|
||||||
|
// Add to zoom velocity (negative deltaY = zoom in)
|
||||||
|
zoomVelocity += event.deltaY * ZOOM_SPEED
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!translateGizmo) return
|
if (!translateGizmo) return
|
||||||
|
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
|
|
@ -894,6 +1043,45 @@ function setupResizeObserver() {
|
||||||
|
|
||||||
function animate() {
|
function animate() {
|
||||||
animationFrameId = requestAnimationFrame(animate)
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
// Handle fly-to animation
|
||||||
|
if (flyToAnimation?.active) {
|
||||||
|
const elapsed = performance.now() - flyToAnimation.startTime
|
||||||
|
const t = Math.min(elapsed / flyToAnimation.duration, 1)
|
||||||
|
|
||||||
|
// Ease-out cubic for smooth deceleration
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3)
|
||||||
|
|
||||||
|
// Interpolate position and target
|
||||||
|
activeCamera.position.lerpVectors(
|
||||||
|
flyToAnimation.startPosition,
|
||||||
|
flyToAnimation.endPosition,
|
||||||
|
eased
|
||||||
|
)
|
||||||
|
controls.target.lerpVectors(flyToAnimation.startTarget, flyToAnimation.endTarget, eased)
|
||||||
|
activeCamera.lookAt(controls.target)
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
flyToAnimation.active = false
|
||||||
|
flyToAnimation = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply smooth zoom
|
||||||
|
if (Math.abs(zoomVelocity) > 0.0001) {
|
||||||
|
const direction = new THREE.Vector3()
|
||||||
|
.subVectors(activeCamera.position, controls.target)
|
||||||
|
.normalize()
|
||||||
|
const currentDistance = activeCamera.position.distanceTo(controls.target)
|
||||||
|
const zoomAmount = zoomVelocity * currentDistance // Scale by distance for consistent feel
|
||||||
|
const newDistance = Math.max(0.1, currentDistance + zoomAmount) // Prevent going through target
|
||||||
|
|
||||||
|
activeCamera.position.copy(controls.target).addScaledVector(direction, newDistance)
|
||||||
|
|
||||||
|
// Apply damping
|
||||||
|
zoomVelocity *= 1 - ZOOM_DAMPING
|
||||||
|
}
|
||||||
|
|
||||||
controls.update()
|
controls.update()
|
||||||
|
|
||||||
// Keep gizmos at consistent screen size regardless of zoom
|
// Keep gizmos at consistent screen size regardless of zoom
|
||||||
|
|
|
||||||
208
src/composables/useCameraControls.ts
Normal file
208
src/composables/useCameraControls.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
export interface CameraControls {
|
||||||
|
enabled: boolean
|
||||||
|
target: THREE.Vector3
|
||||||
|
dampingFactor: number
|
||||||
|
object: THREE.Camera
|
||||||
|
|
||||||
|
update(): void
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCameraControls(
|
||||||
|
camera: THREE.Camera,
|
||||||
|
domElement: HTMLElement
|
||||||
|
): CameraControls {
|
||||||
|
// State
|
||||||
|
let enabled = false
|
||||||
|
const target = new THREE.Vector3()
|
||||||
|
let dampingFactor = 0.05
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
let isDragging = false
|
||||||
|
let dragButton = -1
|
||||||
|
const pointerStart = new THREE.Vector2()
|
||||||
|
const pointerCurrent = new THREE.Vector2()
|
||||||
|
|
||||||
|
// Spherical coordinates for orbit
|
||||||
|
const spherical = new THREE.Spherical()
|
||||||
|
const sphericalDelta = new THREE.Spherical()
|
||||||
|
|
||||||
|
// Pan offset
|
||||||
|
const panOffset = new THREE.Vector3()
|
||||||
|
|
||||||
|
// Damping velocities
|
||||||
|
const orbitVelocity = new THREE.Spherical()
|
||||||
|
const panVelocity = new THREE.Vector3()
|
||||||
|
|
||||||
|
// Temp vectors
|
||||||
|
const tempVec = new THREE.Vector3()
|
||||||
|
|
||||||
|
// Sensitivity
|
||||||
|
const rotateSpeed = 0.005
|
||||||
|
const panSpeed = 0.002
|
||||||
|
|
||||||
|
function getSphericalFromCamera() {
|
||||||
|
tempVec.copy(camera.position).sub(target)
|
||||||
|
spherical.setFromVector3(tempVec)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySpericalToCamera() {
|
||||||
|
tempVec.setFromSpherical(spherical)
|
||||||
|
camera.position.copy(target).add(tempVec)
|
||||||
|
camera.lookAt(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
isDragging = true
|
||||||
|
dragButton = event.button
|
||||||
|
pointerStart.set(event.clientX, event.clientY)
|
||||||
|
pointerCurrent.copy(pointerStart)
|
||||||
|
|
||||||
|
// Capture pointer for tracking outside element
|
||||||
|
domElement.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent) {
|
||||||
|
if (!enabled || !isDragging) return
|
||||||
|
|
||||||
|
pointerCurrent.set(event.clientX, event.clientY)
|
||||||
|
const deltaX = pointerCurrent.x - pointerStart.x
|
||||||
|
const deltaY = pointerCurrent.y - pointerStart.y
|
||||||
|
|
||||||
|
if (dragButton === 0) {
|
||||||
|
// Left button: orbit
|
||||||
|
sphericalDelta.theta = -deltaX * rotateSpeed
|
||||||
|
sphericalDelta.phi = -deltaY * rotateSpeed
|
||||||
|
|
||||||
|
// Store velocity for damping
|
||||||
|
orbitVelocity.theta = sphericalDelta.theta
|
||||||
|
orbitVelocity.phi = sphericalDelta.phi
|
||||||
|
} else {
|
||||||
|
// Right/middle button: pan
|
||||||
|
panInScreenSpace(deltaX, deltaY)
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerStart.copy(pointerCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event: PointerEvent) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
isDragging = false
|
||||||
|
dragButton = -1
|
||||||
|
|
||||||
|
// Release pointer capture
|
||||||
|
domElement.releasePointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panInScreenSpace(deltaX: number, deltaY: number) {
|
||||||
|
// Get camera's local axes
|
||||||
|
const right = new THREE.Vector3()
|
||||||
|
const up = new THREE.Vector3()
|
||||||
|
|
||||||
|
// Extract right and up vectors from camera's world matrix
|
||||||
|
camera.matrixWorld.extractBasis(right, up, tempVec)
|
||||||
|
|
||||||
|
// Calculate distance to target for consistent pan speed
|
||||||
|
const distance = camera.position.distanceTo(target)
|
||||||
|
const panFactor = distance * panSpeed
|
||||||
|
|
||||||
|
// Pan in screen space
|
||||||
|
panOffset.set(0, 0, 0)
|
||||||
|
panOffset.addScaledVector(right, -deltaX * panFactor)
|
||||||
|
panOffset.addScaledVector(up, deltaY * panFactor)
|
||||||
|
|
||||||
|
// Store velocity for damping
|
||||||
|
panVelocity.copy(panOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
// Get current spherical from camera position
|
||||||
|
getSphericalFromCamera()
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
// Apply orbit delta
|
||||||
|
spherical.theta += sphericalDelta.theta
|
||||||
|
spherical.phi += sphericalDelta.phi
|
||||||
|
|
||||||
|
// Clamp phi to avoid flipping
|
||||||
|
spherical.phi = Math.max(0.01, Math.min(Math.PI - 0.01, spherical.phi))
|
||||||
|
|
||||||
|
// Apply pan
|
||||||
|
target.add(panOffset)
|
||||||
|
camera.position.add(panOffset)
|
||||||
|
|
||||||
|
// Reset deltas
|
||||||
|
sphericalDelta.set(0, 0, 0)
|
||||||
|
panOffset.set(0, 0, 0)
|
||||||
|
} else {
|
||||||
|
// Apply damping when not dragging
|
||||||
|
if (Math.abs(orbitVelocity.theta) > 0.0001 || Math.abs(orbitVelocity.phi) > 0.0001) {
|
||||||
|
spherical.theta += orbitVelocity.theta
|
||||||
|
spherical.phi += orbitVelocity.phi
|
||||||
|
spherical.phi = Math.max(0.01, Math.min(Math.PI - 0.01, spherical.phi))
|
||||||
|
|
||||||
|
orbitVelocity.theta *= 1 - dampingFactor
|
||||||
|
orbitVelocity.phi *= 1 - dampingFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panVelocity.lengthSq() > 0.0000001) {
|
||||||
|
target.add(panVelocity)
|
||||||
|
camera.position.add(panVelocity)
|
||||||
|
|
||||||
|
panVelocity.multiplyScalar(1 - dampingFactor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply spherical to camera
|
||||||
|
applySpericalToCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
domElement.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
domElement.removeEventListener('pointermove', onPointerMove)
|
||||||
|
domElement.removeEventListener('pointerup', onPointerUp)
|
||||||
|
domElement.removeEventListener('pointercancel', onPointerUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
domElement.addEventListener('pointerdown', onPointerDown)
|
||||||
|
domElement.addEventListener('pointermove', onPointerMove)
|
||||||
|
domElement.addEventListener('pointerup', onPointerUp)
|
||||||
|
domElement.addEventListener('pointercancel', onPointerUp)
|
||||||
|
|
||||||
|
// Return controls object with getters/setters
|
||||||
|
return {
|
||||||
|
get enabled() {
|
||||||
|
return enabled
|
||||||
|
},
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
enabled = value
|
||||||
|
if (!value) {
|
||||||
|
// Stop any ongoing drag
|
||||||
|
isDragging = false
|
||||||
|
dragButton = -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
target,
|
||||||
|
get dampingFactor() {
|
||||||
|
return dampingFactor
|
||||||
|
},
|
||||||
|
set dampingFactor(value: number) {
|
||||||
|
dampingFactor = value
|
||||||
|
},
|
||||||
|
get object() {
|
||||||
|
return camera
|
||||||
|
},
|
||||||
|
set object(value: THREE.Camera) {
|
||||||
|
// Update camera reference (for ortho/perspective switching)
|
||||||
|
;(camera as THREE.Camera) = value
|
||||||
|
},
|
||||||
|
update,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue