Add rotation mode controls

This commit is contained in:
Nettika 2026-01-29 16:45:50 -08:00
parent 698286ec86
commit 39530cc4d7
No known key found for this signature in database
3 changed files with 527 additions and 35 deletions

10
TODO.md
View file

@ -120,11 +120,11 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
- [x] Hide gizmo when nothing selected - [x] Hide gizmo when nothing selected
### Rotation Mode (Ctrl Modifier) ### Rotation Mode (Ctrl Modifier)
- [ ] Create custom rotation gizmo with flat ring handles per axis - [x] Create custom rotation gizmo with flat ring handles per axis
- [ ] Show rotation rings when Ctrl held with selection - [x] Show rotation rings when Ctrl held with selection
- [ ] Hover effect: ring thickens and brightens - [x] Hover effect: ring thickens and brightens
- [ ] Left-drag ring: free (by-pixel) rotation - [x] Left-drag ring: free (by-pixel) rotation
- [ ] Right-drag ring: constrained rotation (15°, 30°, 45° based on mouse distance from control) - [x] Right-drag ring: constrained rotation (15°, 30°, 45° based on mouse distance from control)
- [ ] Display angle wheel during constrained rotation (world-oriented) - [ ] Display angle wheel during constrained rotation (world-oriented)
- [ ] Track object rotation state for world-oriented snapping - [ ] Track object rotation state for world-oriented snapping

View file

@ -24,6 +24,14 @@ import {
type TranslateGizmo, type TranslateGizmo,
type DragState, type DragState,
} from '../composables/useTranslateGizmo' } from '../composables/useTranslateGizmo'
import {
createRotateGizmo,
createRotateDragState,
getRotationAngle,
snapAngleToConstraint,
type RotateGizmo,
type RotateDragState,
} from '../composables/useRotateGizmo'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -62,8 +70,11 @@ let selectionOutlinePass: OutlinePass
let hoverOutlinePass: OutlinePass let hoverOutlinePass: OutlinePass
let hoveredObject: SceneObject | null = null let hoveredObject: SceneObject | null = null
let translateGizmo: TranslateGizmo | null = null let translateGizmo: TranslateGizmo | null = null
let rotateGizmo: RotateGizmo | null = null
let dragState: DragState = createDragState() let dragState: DragState = createDragState()
let rotateDragState: RotateDragState = createRotateDragState()
let hoveredAxis: 'x' | 'y' | 'z' | null = null let hoveredAxis: 'x' | 'y' | 'z' | null = null
let isCtrlPressed = false
function initScene() { function initScene() {
if (!containerRef.value) return if (!containerRef.value) return
@ -119,15 +130,19 @@ function initScene() {
addTestCube() addTestCube()
setupPostProcessing() setupPostProcessing()
setupTranslateGizmo() setupGizmos()
setupMouseHandlers() setupMouseHandlers()
setupKeyboardHandlers()
setupResizeObserver() setupResizeObserver()
animate() animate()
} }
function setupTranslateGizmo() { function setupGizmos() {
translateGizmo = createTranslateGizmo() translateGizmo = createTranslateGizmo()
scene.add(translateGizmo.group) scene.add(translateGizmo.group)
rotateGizmo = createRotateGizmo()
scene.add(rotateGizmo.group)
} }
function setupPostProcessing() { function setupPostProcessing() {
@ -233,6 +248,50 @@ function cleanupMouseHandlers() {
renderer.domElement.removeEventListener('wheel', onCanvasWheel) renderer.domElement.removeEventListener('wheel', onCanvasWheel)
} }
function setupKeyboardHandlers() {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
}
function cleanupKeyboardHandlers() {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Control' && !isCtrlPressed) {
isCtrlPressed = true
updateGizmoVisibility()
}
}
function onKeyUp(event: KeyboardEvent) {
if (event.key === 'Control' && isCtrlPressed) {
isCtrlPressed = false
updateGizmoVisibility()
}
}
function updateGizmoVisibility() {
const hasSelection = sceneStore.selectedObjects.length > 0
if (!hasSelection) {
translateGizmo?.hide()
rotateGizmo?.hide()
return
}
if (isCtrlPressed) {
translateGizmo?.hide()
rotateGizmo?.show()
updateRotateGizmoPosition()
} else {
rotateGizmo?.hide()
translateGizmo?.show()
updateGizmoPosition()
}
}
function updateMouseCoordinates(event: MouseEvent) { function updateMouseCoordinates(event: MouseEvent) {
if (!containerRef.value) return if (!containerRef.value) return
@ -272,7 +331,7 @@ function onCanvasClick(event: MouseEvent) {
function onCanvasMouseMove(event: MouseEvent) { function onCanvasMouseMove(event: MouseEvent) {
updateMouseCoordinates(event) updateMouseCoordinates(event)
// Handle gizmo dragging // Handle translate gizmo dragging
if (dragState.isDragging && dragState.axis && translateGizmo) { if (dragState.isDragging && dragState.axis && translateGizmo) {
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
if (selectedMeshes.length === 0) return if (selectedMeshes.length === 0) return
@ -312,9 +371,52 @@ function onCanvasMouseMove(event: MouseEvent) {
return return
} }
// Handle rotate gizmo dragging
if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) {
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
if (selectedMeshes.length === 0) return
// Get current rotation angle
const center = rotateGizmo.group.position
const currentAngle = getRotationAngle(mouse, activeCamera, center, rotateDragState.axis)
rotateDragState.currentAngle = currentAngle
// Calculate angle delta from start
let angleDelta = currentAngle - rotateDragState.startAngle
// Apply constraint snapping for right-drag
if (rotateDragState.isRightDrag) {
angleDelta = snapAngleToConstraint(angleDelta, rotateDragState.constraintAngles)
}
// Apply rotation to all selected objects
const rotationAxis = new THREE.Vector3()
if (rotateDragState.axis === 'x') rotationAxis.set(1, 0, 0)
else if (rotateDragState.axis === 'y') rotationAxis.set(0, 1, 0)
else rotationAxis.set(0, 0, 1)
for (const mesh of selectedMeshes) {
const startRotation = rotateDragState.startObjectRotations.get(mesh.uuid)
if (startRotation) {
// Reset to start rotation then apply delta
mesh.rotation.copy(startRotation)
mesh.rotateOnWorldAxis(rotationAxis, angleDelta)
}
}
return
}
// Handle gizmo hover highlight // Handle gizmo hover highlight
if (translateGizmo) {
raycaster.setFromCamera(mouse, activeCamera) raycaster.setFromCamera(mouse, activeCamera)
if (isCtrlPressed && rotateGizmo?.group.visible) {
const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster)
if (newHoveredAxis !== hoveredAxis) {
hoveredAxis = newHoveredAxis
rotateGizmo.setHighlightedAxis(hoveredAxis)
}
} else if (translateGizmo?.group.visible) {
const newHoveredAxis = translateGizmo.getHoveredAxis(raycaster) const newHoveredAxis = translateGizmo.getHoveredAxis(raycaster)
if (newHoveredAxis !== hoveredAxis) { if (newHoveredAxis !== hoveredAxis) {
hoveredAxis = newHoveredAxis hoveredAxis = newHoveredAxis
@ -337,17 +439,45 @@ function onCanvasContextMenu(event: MouseEvent) {
} }
function onCanvasMouseDown(event: MouseEvent) { function onCanvasMouseDown(event: MouseEvent) {
if (!translateGizmo) return
updateMouseCoordinates(event) updateMouseCoordinates(event)
raycaster.setFromCamera(mouse, activeCamera) raycaster.setFromCamera(mouse, activeCamera)
// Check for rotation gizmo first (when Ctrl is held)
if (isCtrlPressed && rotateGizmo?.group.visible) {
const axis = rotateGizmo.getHoveredAxis(raycaster)
if (axis) {
event.preventDefault()
event.stopPropagation()
controls.enabled = false
rotateDragState.isDragging = true
rotateDragState.axis = axis
rotateDragState.isRightDrag = event.button === 2
// Store starting rotations for all selected objects
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
rotateDragState.startObjectRotations.clear()
for (const mesh of selectedMeshes) {
rotateDragState.startObjectRotations.set(mesh.uuid, mesh.rotation.clone())
}
// Calculate starting angle
const center = rotateGizmo.group.position
rotateDragState.startAngle = getRotationAngle(mouse, activeCamera, center, axis)
rotateDragState.currentAngle = rotateDragState.startAngle
return
}
}
// Check for translate gizmo
if (translateGizmo?.group.visible) {
const axis = translateGizmo.getHoveredAxis(raycaster) const axis = translateGizmo.getHoveredAxis(raycaster)
if (axis) { if (axis) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
// Disable orbit controls during gizmo drag
controls.enabled = false controls.enabled = false
dragState.isDragging = true dragState.isDragging = true
@ -371,6 +501,7 @@ function onCanvasMouseDown(event: MouseEvent) {
} }
} }
} }
}
function onCanvasMouseUp(_event: MouseEvent) { function onCanvasMouseUp(_event: MouseEvent) {
if (dragState.isDragging) { if (dragState.isDragging) {
@ -383,6 +514,13 @@ function onCanvasMouseUp(_event: MouseEvent) {
dragState.axis = null dragState.axis = null
controls.enabled = true controls.enabled = true
} }
if (rotateDragState.isDragging) {
rotateDragState.isDragging = false
rotateDragState.axis = null
rotateDragState.startObjectRotations.clear()
controls.enabled = true
}
} }
function onCanvasWheel(event: WheelEvent) { function onCanvasWheel(event: WheelEvent) {
@ -435,10 +573,13 @@ function animate() {
animationFrameId = requestAnimationFrame(animate) animationFrameId = requestAnimationFrame(animate)
controls.update() controls.update()
// Keep gizmo at consistent screen size regardless of zoom // Keep gizmos at consistent screen size regardless of zoom
if (translateGizmo) { if (translateGizmo) {
translateGizmo.updateScale(activeCamera) translateGizmo.updateScale(activeCamera)
} }
if (rotateGizmo) {
rotateGizmo.updateScale(activeCamera)
}
composer.render() composer.render()
} }
@ -475,15 +616,40 @@ function updateGizmoPosition() {
const center = box.getCenter(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3())
translateGizmo.setPositionAndBounds(center, box) translateGizmo.setPositionAndBounds(center, box)
if (!isCtrlPressed) {
translateGizmo.show() translateGizmo.show()
} }
}
function updateRotateGizmoPosition() {
if (!rotateGizmo) return
const selected = sceneStore.selectedObjects
if (selected.length === 0) {
rotateGizmo.hide()
return
}
// Calculate bounding box of all selected objects
const box = new THREE.Box3()
for (const obj of selected) {
box.expandByObject(obj.mesh)
}
const center = box.getCenter(new THREE.Vector3())
rotateGizmo.setPositionAndBounds(center, box)
if (isCtrlPressed) {
rotateGizmo.show()
}
}
watch( watch(
() => sceneStore.selectedObjects, () => sceneStore.selectedObjects,
() => { () => {
updateSelectionOutline() updateSelectionOutline()
updateHoverOutline() updateHoverOutline()
updateGizmoPosition() updateGizmoVisibility()
}, },
{ deep: true } { deep: true }
) )
@ -558,11 +724,16 @@ function cleanup() {
} }
cleanupMouseHandlers() cleanupMouseHandlers()
cleanupKeyboardHandlers()
if (translateGizmo) { if (translateGizmo) {
translateGizmo.dispose() translateGizmo.dispose()
} }
if (rotateGizmo) {
rotateGizmo.dispose()
}
if (controls) { if (controls) {
controls.dispose() controls.dispose()
} }

View file

@ -0,0 +1,321 @@
import * as THREE from 'three'
export interface RotateGizmoOptions {
tubeRadius?: number
highlightTubeRadius?: number
segments?: number
screenSize?: number
}
export interface RotateGizmo {
group: THREE.Group
show: () => void
hide: () => void
setPositionAndBounds: (position: THREE.Vector3, box: THREE.Box3) => void
getHoveredAxis: (raycaster: THREE.Raycaster) => 'x' | 'y' | 'z' | null
setHighlightedAxis: (axis: 'x' | 'y' | 'z' | null) => void
updateScale: (camera: THREE.Camera) => void
dispose: () => void
}
const AXIS_COLORS = {
x: 0xe74c3c, // Red
y: 0x2ecc71, // Green
z: 0x3498db, // Blue
}
const HIGHLIGHT_COLORS = {
x: 0xff6b6b,
y: 0x5dff7f,
z: 0x5dafff,
}
export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo {
const {
tubeRadius = 0.02,
highlightTubeRadius = 0.04,
segments = 64,
screenSize = 150, // Target screen size for tube thickness
} = options
const group = new THREE.Group()
group.name = '__rotateGizmo__'
group.visible = false
// Store ring meshes and materials
const rings: Record<string, THREE.Mesh> = {}
const materials: Record<string, THREE.MeshBasicMaterial> = {}
// Track current geometry parameters for regeneration
let currentRingRadius = 1.0
let currentHighlightedAxis: 'x' | 'y' | 'z' | null = null
// Track the bounds-based radius (how big the rings need to be for the bounding box)
let boundsRadius = 1.0
const BOUNDS_PADDING = 1.1 // 10% padding outside the bounding box
function createRingGeometry(ringRadius: number, tubeRad: number): THREE.TorusGeometry {
return new THREE.TorusGeometry(ringRadius, tubeRad, 16, segments)
}
function createRing(axis: 'x' | 'y' | 'z'): THREE.Mesh {
const color = AXIS_COLORS[axis]
const geometry = createRingGeometry(currentRingRadius, tubeRadius)
const material = new THREE.MeshBasicMaterial({
color,
side: THREE.DoubleSide,
})
materials[axis] = material
const ring = new THREE.Mesh(geometry, material)
ring.name = `__gizmo_rotate_${axis}__`
ring.userData = { axis, isGizmoHandle: true, type: 'rotate' }
// Rotate ring to correct orientation
// Torus is created in XY plane by default
if (axis === 'x') {
// Rotate to YZ plane (rotate around Y by 90°)
ring.rotation.y = Math.PI / 2
} else if (axis === 'y') {
// Rotate to XZ plane (rotate around X by 90°)
ring.rotation.x = Math.PI / 2
}
// Z axis: default XY plane, no rotation needed
rings[axis] = ring
return ring
}
function updateRingGeometries(): void {
for (const axis of ['x', 'y', 'z'] as const) {
const ring = rings[axis]
if (!ring) continue
// Dispose old geometry
ring.geometry.dispose()
// Create new geometry with updated ring radius
const isHighlighted = axis === currentHighlightedAxis
const tubeRad = isHighlighted ? highlightTubeRadius : tubeRadius
ring.geometry = createRingGeometry(currentRingRadius, tubeRad)
}
}
group.add(createRing('x'))
group.add(createRing('y'))
group.add(createRing('z'))
function show(): void {
group.visible = true
}
function hide(): void {
group.visible = false
}
function setPositionAndBounds(position: THREE.Vector3, box: THREE.Box3): void {
group.position.copy(position)
// Calculate radius needed to fully encompass the bounding box
// Use the 3D diagonal from center to corner so rings clear all corners
const size = box.getSize(new THREE.Vector3())
const diagonal = Math.sqrt(size.x * size.x + size.y * size.y + size.z * size.z)
boundsRadius = Math.max((diagonal / 2) * BOUNDS_PADDING, 0.5)
}
function getHoveredAxis(raycaster: THREE.Raycaster): 'x' | 'y' | 'z' | null {
if (!group.visible) return null
const intersects = raycaster.intersectObject(group, true)
if (intersects.length > 0) {
const hit = intersects[0]!.object
if (hit.userData?.axis && hit.userData?.type === 'rotate') {
return hit.userData.axis as 'x' | 'y' | 'z'
}
}
return null
}
function setHighlightedAxis(axis: 'x' | 'y' | 'z' | null): void {
if (axis === currentHighlightedAxis) return
currentHighlightedAxis = axis
for (const ax of ['x', 'y', 'z'] as const) {
const ring = rings[ax]
const material = materials[ax]
if (!ring || !material) continue
const isHighlighted = ax === axis
material.color.setHex(isHighlighted ? HIGHLIGHT_COLORS[ax] : AXIS_COLORS[ax])
// Regenerate geometry for thickness change
ring.geometry.dispose()
const tubeRad = isHighlighted ? highlightTubeRadius : tubeRadius
ring.geometry = createRingGeometry(currentRingRadius, tubeRad)
}
}
function updateScale(camera: THREE.Camera): void {
if (!group.visible) return
// Calculate scale factor for screen-invariant tube thickness
const distance = camera.position.distanceTo(group.position)
let scale: number
if (camera instanceof THREE.PerspectiveCamera) {
const vFov = (camera.fov * Math.PI) / 180
const worldHeight = 2 * Math.tan(vFov / 2) * distance
scale = (worldHeight / 800) * screenSize * 0.5
} else if (camera instanceof THREE.OrthographicCamera) {
const viewHeight = camera.top - camera.bottom
scale = (viewHeight / 800) * screenSize * 0.5
} else {
scale = 1
}
// Apply scale to group - this makes tube thickness screen-invariant
group.scale.setScalar(scale)
// To keep ring radius at boundsRadius despite scaling, set geometry ring radius
// so that: geometryRingRadius * scale = boundsRadius
const targetRingRadius = boundsRadius / scale
// Only regenerate geometry if ring radius changed significantly
if (Math.abs(targetRingRadius - currentRingRadius) > 0.01) {
currentRingRadius = targetRingRadius
updateRingGeometries()
}
}
function dispose(): void {
// Dispose all ring geometries and materials
for (const axis of ['x', 'y', 'z'] as const) {
const ring = rings[axis]
if (ring) {
ring.geometry.dispose()
}
const material = materials[axis]
if (material) {
material.dispose()
}
}
}
return {
group,
show,
hide,
setPositionAndBounds,
getHoveredAxis,
setHighlightedAxis,
updateScale,
dispose,
}
}
export interface RotateDragState {
isDragging: boolean
axis: 'x' | 'y' | 'z' | null
isRightDrag: boolean
startAngle: number
currentAngle: number
startObjectRotations: Map<string, THREE.Euler>
constraintAngles: number[] // Available snap angles for right-drag
}
export function createRotateDragState(): RotateDragState {
return {
isDragging: false,
axis: null,
isRightDrag: false,
startAngle: 0,
currentAngle: 0,
startObjectRotations: new Map(),
constraintAngles: [
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285,
300, 315, 330, 345,
],
}
}
export function getRotationAngle(
mouse: THREE.Vector2,
camera: THREE.Camera,
center: THREE.Vector3,
axis: 'x' | 'y' | 'z'
): number {
// Create a ray from camera through mouse position
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, camera)
// Create plane perpendicular to the rotation axis passing through center
const axisDirection = new THREE.Vector3()
if (axis === 'x') axisDirection.set(1, 0, 0)
else if (axis === 'y') axisDirection.set(0, 1, 0)
else axisDirection.set(0, 0, 1)
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(axisDirection, center)
// Find intersection of ray with plane
const intersection = new THREE.Vector3()
const intersected = raycaster.ray.intersectPlane(plane, intersection)
if (!intersected) {
return 0
}
// Calculate angle from center to intersection point
const toIntersection = intersection.clone().sub(center)
// Get the two axes perpendicular to the rotation axis
let refAxis1: THREE.Vector3
let refAxis2: THREE.Vector3
if (axis === 'x') {
refAxis1 = new THREE.Vector3(0, 1, 0)
refAxis2 = new THREE.Vector3(0, 0, 1)
} else if (axis === 'y') {
refAxis1 = new THREE.Vector3(1, 0, 0)
refAxis2 = new THREE.Vector3(0, 0, 1)
} else {
refAxis1 = new THREE.Vector3(1, 0, 0)
refAxis2 = new THREE.Vector3(0, 1, 0)
}
const x = toIntersection.dot(refAxis1)
const y = toIntersection.dot(refAxis2)
return Math.atan2(y, x)
}
export function snapAngleToConstraint(angleDelta: number, constraintAngles: number[]): number {
// Convert to degrees
const degrees = (angleDelta * 180) / Math.PI
// Normalize to 0-360 range
let normalized = degrees % 360
if (normalized < 0) normalized += 360
// Find closest constraint angle
let closest = constraintAngles[0]!
let minDiff = Math.abs(normalized - closest)
for (const angle of constraintAngles) {
const diff = Math.abs(normalized - angle)
// Also check wrap-around
const wrapDiff = Math.abs(normalized - (angle + 360))
const wrapDiff2 = Math.abs(normalized - (angle - 360))
const effectiveDiff = Math.min(diff, wrapDiff, wrapDiff2)
if (effectiveDiff < minDiff) {
minDiff = effectiveDiff
closest = angle
}
}
// Convert back to radians, preserving sign and full rotations
const fullRotations = Math.floor(degrees / 360)
return ((fullRotations * 360 + closest) * Math.PI) / 180
}