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
|
||||
|
||||
### Camera Mode (Alt Modifier)
|
||||
- [ ] Left-drag: orbit camera
|
||||
- [ ] Right-drag: pan camera
|
||||
- [ ] Left-click object: center/zoom camera on object
|
||||
- [x] Left-drag: orbit camera
|
||||
- [x] Right-drag: pan camera
|
||||
- [x] Left-click object: center/zoom camera on object
|
||||
|
||||
### Shift Modifier Behaviors
|
||||
- [ ] Click with Shift: add to selection (already in Phase 4)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
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 { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
||||
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
|
||||
|
|
@ -75,7 +75,7 @@ let animationFrameId: number
|
|||
let resizeObserver: ResizeObserver
|
||||
let gridHelper: THREE.GridHelper
|
||||
let axisHelper: THREE.AxesHelper
|
||||
let controls: OrbitControls
|
||||
let controls: CameraControls
|
||||
let raycaster: THREE.Raycaster
|
||||
const mouse = new THREE.Vector2()
|
||||
let composer: EffectComposer
|
||||
|
|
@ -92,6 +92,63 @@ let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
|||
let hoveredScaleHandle: ScaleHandleInfo | null = null
|
||||
let isCtrlPressed = 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() {
|
||||
if (!containerRef.value) return
|
||||
|
|
@ -132,10 +189,11 @@ function initScene() {
|
|||
renderer.domElement.draggable = false
|
||||
renderer.domElement.addEventListener('dragstart', (e) => e.preventDefault())
|
||||
|
||||
// Setup orbit controls
|
||||
controls = new OrbitControls(activeCamera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
// Setup camera controls (disabled by default, enabled only with camera mode modifiers)
|
||||
// Left-drag = orbit, right/middle-drag = pan
|
||||
controls = createCameraControls(activeCamera, renderer.domElement)
|
||||
controls.dampingFactor = 0.05
|
||||
controls.enabled = false
|
||||
|
||||
addLighting()
|
||||
addGridHelper()
|
||||
|
|
@ -288,6 +346,22 @@ function onKeyDown(event: KeyboardEvent) {
|
|||
isShiftPressed = 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) {
|
||||
updateGizmoVisibility()
|
||||
}
|
||||
|
|
@ -303,6 +377,22 @@ function onKeyUp(event: KeyboardEvent) {
|
|||
isShiftPressed = false
|
||||
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) {
|
||||
updateGizmoVisibility()
|
||||
}
|
||||
|
|
@ -311,7 +401,8 @@ function onKeyUp(event: KeyboardEvent) {
|
|||
function updateGizmoVisibility() {
|
||||
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()
|
||||
rotateGizmo?.hide()
|
||||
scaleGizmo?.hide()
|
||||
|
|
@ -371,11 +462,35 @@ function pickObject(event: MouseEvent): SceneObject | null {
|
|||
}
|
||||
|
||||
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)
|
||||
emit('pick', pickedObject, event)
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(event: MouseEvent) {
|
||||
// Prevent window manager from grabbing window on Alt+drag
|
||||
if (isCameraModeActive()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
updateMouseCoordinates(event)
|
||||
|
||||
// Handle translate gizmo dragging
|
||||
|
|
@ -692,6 +807,25 @@ function onCanvasMouseDown(event: MouseEvent) {
|
|||
updateMouseCoordinates(event)
|
||||
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)
|
||||
if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
|
||||
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) {
|
||||
// Clear stored original positions
|
||||
for (const obj of sceneStore.selectedObjects) {
|
||||
|
|
@ -824,7 +968,6 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
|||
|
||||
dragState.isDragging = false
|
||||
dragState.axis = null
|
||||
controls.enabled = true
|
||||
}
|
||||
|
||||
if (rotateDragState.isDragging) {
|
||||
|
|
@ -834,7 +977,6 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
|||
rotateDragState.isDragging = false
|
||||
rotateDragState.axis = null
|
||||
rotateDragState.startObjectRotations.clear()
|
||||
controls.enabled = true
|
||||
}
|
||||
|
||||
if (scaleDragState.isDragging) {
|
||||
|
|
@ -842,11 +984,18 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
|||
scaleDragState.handleInfo = null
|
||||
scaleDragState.startMeshScales.clear()
|
||||
scaleDragState.startMeshPositions.clear()
|
||||
controls.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
updateMouseCoordinates(event)
|
||||
|
|
@ -894,6 +1043,45 @@ function setupResizeObserver() {
|
|||
|
||||
function 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()
|
||||
|
||||
// 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