Add camera mode

This commit is contained in:
Nettika 2026-01-29 23:54:34 -08:00
parent 54137379c7
commit 737f4dbbb3
No known key found for this signature in database
3 changed files with 409 additions and 13 deletions

View file

@ -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)

View file

@ -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

View 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,
}
}