Add rotation mode angle visualizer

This commit is contained in:
Nettika 2026-01-29 17:26:34 -08:00
parent 39530cc4d7
commit 3c139fe58d
No known key found for this signature in database
3 changed files with 354 additions and 20 deletions

View file

@ -125,8 +125,8 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
- [x] Hover effect: ring thickens and brightens
- [x] Left-drag ring: free (by-pixel) rotation
- [x] Right-drag ring: constrained rotation (15°, 30°, 45° based on mouse distance from control)
- [ ] Display angle wheel during constrained rotation (world-oriented)
- [ ] Track object rotation state for world-oriented snapping
- [x] Display angle wheel during constrained rotation (world-oriented)
- [x] Track object rotation state for world-oriented snapping
### Scale Mode (Ctrl+Shift Modifier)
- [ ] Create bounding box visualization for selection

View file

@ -28,7 +28,10 @@ import {
createRotateGizmo,
createRotateDragState,
getRotationAngle,
snapAngleToConstraint,
getWorldRotationAroundAxis,
snapToWorldAlignedAngle,
generateConstraintAngles,
getGranularityFromDistance,
type RotateGizmo,
type RotateDragState,
} from '../composables/useRotateGizmo'
@ -376,7 +379,7 @@ function onCanvasMouseMove(event: MouseEvent) {
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
if (selectedMeshes.length === 0) return
// Get current rotation angle
// Get current rotation angle from mouse position
const center = rotateGizmo.group.position
const currentAngle = getRotationAngle(mouse, activeCamera, center, rotateDragState.axis)
rotateDragState.currentAngle = currentAngle
@ -384,21 +387,70 @@ function onCanvasMouseMove(event: MouseEvent) {
// 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 constrained (right-drag) rotation, snap to world-aligned angles
if (rotateDragState.isRightDrag && selectedMeshes.length > 0) {
// Calculate mouse distance from ring center to determine granularity
// Project center to NDC and compare with mouse position
const centerNDC = center.clone().project(activeCamera)
const mouseDistNDC = Math.sqrt(
Math.pow(mouse.x - centerNDC.x, 2) + Math.pow(mouse.y - centerNDC.y, 2)
)
// Convert to approximate screen pixels (NDC ranges from -1 to 1)
const screenDist = mouseDistNDC * Math.min(window.innerWidth, window.innerHeight) * 0.5
// Get granularity based on distance (using approximate ring screen size as reference)
const ringScreenSize = 150 // Approximate screen size of the ring
const newGranularity = getGranularityFromDistance(screenDist, ringScreenSize)
// Update angle wheel if granularity changed
if (newGranularity !== rotateDragState.currentGranularity) {
rotateDragState.currentGranularity = newGranularity
rotateDragState.constraintAngles = generateConstraintAngles(newGranularity)
rotateGizmo.showAngleWheel(rotateDragState.axis, rotateDragState.constraintAngles)
}
// Use the first mesh as the reference for world-oriented snapping
const referenceMesh = selectedMeshes[0]!
const startRotation = rotateDragState.startObjectRotations.get(referenceMesh.uuid)
if (startRotation) {
// Temporarily apply the free rotation to calculate proposed world angle
referenceMesh.rotation.copy(startRotation)
referenceMesh.rotateOnWorldAxis(rotationAxis, angleDelta)
// Get the proposed world rotation
const proposedWorldAngle = getWorldRotationAroundAxis(referenceMesh, rotateDragState.axis)
// Snap to nearest constraint angle
const snappedWorldAngle = snapToWorldAlignedAngle(
proposedWorldAngle,
rotateDragState.constraintAngles
)
// Reset and get the starting world angle
referenceMesh.rotation.copy(startRotation)
const startWorldAngle = getWorldRotationAroundAxis(referenceMesh, rotateDragState.axis)
// Calculate the delta needed to achieve the snapped world angle
angleDelta = snappedWorldAngle - startWorldAngle
// Update angle wheel to show the snapped world angle
let snappedDegrees = (snappedWorldAngle * 180) / Math.PI
if (snappedDegrees < 0) snappedDegrees += 360
rotateGizmo.updateAngleWheel(snappedDegrees)
}
}
// Apply the final rotation delta to all selected objects
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)
}
@ -467,6 +519,11 @@ function onCanvasMouseDown(event: MouseEvent) {
rotateDragState.startAngle = getRotationAngle(mouse, activeCamera, center, axis)
rotateDragState.currentAngle = rotateDragState.startAngle
// Show angle wheel for constrained (right-drag) rotation
if (rotateDragState.isRightDrag) {
rotateGizmo.showAngleWheel(axis, rotateDragState.constraintAngles)
}
return
}
}
@ -516,6 +573,9 @@ function onCanvasMouseUp(_event: MouseEvent) {
}
if (rotateDragState.isDragging) {
// Hide angle wheel if it was shown
rotateGizmo?.hideAngleWheel()
rotateDragState.isDragging = false
rotateDragState.axis = null
rotateDragState.startObjectRotations.clear()

View file

@ -15,6 +15,9 @@ export interface RotateGizmo {
getHoveredAxis: (raycaster: THREE.Raycaster) => 'x' | 'y' | 'z' | null
setHighlightedAxis: (axis: 'x' | 'y' | 'z' | null) => void
updateScale: (camera: THREE.Camera) => void
showAngleWheel: (axis: 'x' | 'y' | 'z', constraintAngles: number[]) => void
hideAngleWheel: () => void
updateAngleWheel: (snappedAngleDegrees: number) => void
dispose: () => void
}
@ -107,6 +110,142 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
group.add(createRing('y'))
group.add(createRing('z'))
// Angle wheel visualization for constrained rotation
const angleWheelGroup = new THREE.Group()
angleWheelGroup.name = '__angleWheel__'
angleWheelGroup.visible = false
group.add(angleWheelGroup)
let angleWheelAxis: 'x' | 'y' | 'z' | null = null
let angleWheelTickMeshes: THREE.Mesh[] = []
let angleWheelIndicator: THREE.Mesh | null = null
const TICK_COLOR = 0xffffff
const MAJOR_TICK_COLOR = 0xffff00
const INDICATOR_COLOR = 0x00ff00
function createAngleWheelTicks(constraintAngles: number[]): void {
// Clear existing ticks
for (const mesh of angleWheelTickMeshes) {
mesh.geometry.dispose()
if (mesh.material instanceof THREE.Material) {
mesh.material.dispose()
}
angleWheelGroup.remove(mesh)
}
angleWheelTickMeshes = []
// Create tick marks at each constraint angle
// Ticks are rays extending outward from the ring
for (const angleDeg of constraintAngles) {
const angleRad = (angleDeg * Math.PI) / 180
const isMajor = angleDeg % 45 === 0
// Tick dimensions (in local space, will be scaled with group)
const tickLength = isMajor ? 0.15 : 0.08
const tickWidth = isMajor ? 0.015 : 0.008
const geometry = new THREE.PlaneGeometry(tickWidth, tickLength)
const material = new THREE.MeshBasicMaterial({
color: isMajor ? MAJOR_TICK_COLOR : TICK_COLOR,
side: THREE.DoubleSide,
transparent: true,
opacity: isMajor ? 1.0 : 0.6,
})
const tick = new THREE.Mesh(geometry, material)
// Position tick so inner edge starts at ring radius (ray extending outward)
const tickCenterRadius = currentRingRadius + tickLength / 2
tick.position.x = Math.cos(angleRad) * tickCenterRadius
tick.position.y = Math.sin(angleRad) * tickCenterRadius
// Rotate tick so its length (Y axis) points radially outward
// PlaneGeometry's Y axis (height) points at angle (rotation.z + 90°) from +X
// To make Y point at angleRad, we need rotation.z = angleRad - 90°
tick.rotation.z = angleRad - Math.PI / 2
angleWheelTickMeshes.push(tick)
angleWheelGroup.add(tick)
}
// Create the current angle indicator (a brighter, larger tick)
if (angleWheelIndicator) {
angleWheelIndicator.geometry.dispose()
if (angleWheelIndicator.material instanceof THREE.Material) {
angleWheelIndicator.material.dispose()
}
angleWheelGroup.remove(angleWheelIndicator)
}
const indicatorLength = 0.2
const indicatorGeometry = new THREE.PlaneGeometry(0.025, indicatorLength)
const indicatorMaterial = new THREE.MeshBasicMaterial({
color: INDICATOR_COLOR,
side: THREE.DoubleSide,
})
angleWheelIndicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial)
angleWheelIndicator.visible = false
angleWheelGroup.add(angleWheelIndicator)
}
function updateAngleWheelOrientation(): void {
// Orient the angle wheel group to match the rotation axis
// Ticks are created in XY plane with 0° at local +X
// We want 0° to point toward +Y (green/up axis) for X and Z rotations
// For Y rotation, 0° should point toward +Z
angleWheelGroup.rotation.set(0, 0, 0)
if (angleWheelAxis === 'x') {
// Rotate to YZ plane with 0° at +Y
// Positive X rotation (right-hand rule) goes from +Y toward +Z
// Need: first Ry(90°) to put plane in YZ, then Rx(90°) to rotate 0° from -Z to +Y
// Use quaternion to control rotation order (Euler XYZ applies X first, which is wrong)
const qy = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2)
const qx = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2)
angleWheelGroup.quaternion.copy(qx).multiply(qy) // qx * qy = first Ry, then Rx
} else if (angleWheelAxis === 'y') {
// Rotate to XZ plane with 0° at +Z
// Positive Y rotation (right-hand rule) goes from +Z toward +X
angleWheelGroup.rotation.x = -Math.PI / 2
angleWheelGroup.rotation.z = -Math.PI / 2
} else {
// Z axis: XY plane with 0° at +Y
// Positive Z rotation (right-hand rule) goes from +Y toward -X
angleWheelGroup.rotation.z = Math.PI / 2
}
}
function showAngleWheel(axis: 'x' | 'y' | 'z', constraintAngles: number[]): void {
angleWheelAxis = axis
createAngleWheelTicks(constraintAngles)
updateAngleWheelOrientation()
angleWheelGroup.visible = true
}
function hideAngleWheel(): void {
angleWheelGroup.visible = false
angleWheelAxis = null
if (angleWheelIndicator) {
angleWheelIndicator.visible = false
}
}
function updateAngleWheel(snappedAngleDegrees: number): void {
if (!angleWheelIndicator || !angleWheelGroup.visible) return
// Position and show the indicator at the snapped angle
// Indicator is a ray extending outward from the ring
const angleRad = (snappedAngleDegrees * Math.PI) / 180
const indicatorLength = 0.2
const indicatorCenterRadius = currentRingRadius + indicatorLength / 2
angleWheelIndicator.position.x = Math.cos(angleRad) * indicatorCenterRadius
angleWheelIndicator.position.y = Math.sin(angleRad) * indicatorCenterRadius
// Rotate so indicator's length (Y axis) points radially outward
angleWheelIndicator.rotation.z = angleRad - Math.PI / 2
angleWheelIndicator.visible = true
}
function show(): void {
group.visible = true
}
@ -201,6 +340,20 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
material.dispose()
}
}
// Dispose angle wheel resources
for (const mesh of angleWheelTickMeshes) {
mesh.geometry.dispose()
if (mesh.material instanceof THREE.Material) {
mesh.material.dispose()
}
}
if (angleWheelIndicator) {
angleWheelIndicator.geometry.dispose()
if (angleWheelIndicator.material instanceof THREE.Material) {
angleWheelIndicator.material.dispose()
}
}
}
return {
@ -211,6 +364,9 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
getHoveredAxis,
setHighlightedAxis,
updateScale,
showAngleWheel,
hideAngleWheel,
updateAngleWheel,
dispose,
}
}
@ -223,6 +379,7 @@ export interface RotateDragState {
currentAngle: number
startObjectRotations: Map<string, THREE.Euler>
constraintAngles: number[] // Available snap angles for right-drag
currentGranularity: number // Current snap granularity in degrees (15, 30, or 45)
}
export function createRotateDragState(): RotateDragState {
@ -233,10 +390,34 @@ export function createRotateDragState(): RotateDragState {
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,
],
constraintAngles: generateConstraintAngles(15),
currentGranularity: 15,
}
}
/**
* Generate constraint angles for a given granularity (e.g., 15, 30, or 45 degrees).
*/
export function generateConstraintAngles(granularity: number): number[] {
const angles: number[] = []
for (let angle = 0; angle < 360; angle += granularity) {
angles.push(angle)
}
return angles
}
/**
* Determine the snap granularity based on mouse distance from the rotation ring.
* Closer = finer control (15°), farther = coarser control (45°).
*/
export function getGranularityFromDistance(distance: number, ringRadius: number): number {
const ratio = distance / ringRadius
if (ratio < 1.5) {
return 15 // Close to ring: fine control
} else if (ratio < 2.5) {
return 30 // Medium distance: medium control
} else {
return 45 // Far from ring: coarse control
}
}
@ -270,18 +451,22 @@ export function getRotationAngle(
const toIntersection = intersection.clone().sub(center)
// Get the two axes perpendicular to the rotation axis
// 0° should point toward +Y (up) for X and Z rotations, +Z for Y rotation
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)
// YZ plane: 0° at +Y, positive rotation goes from +Y toward +Z (right-hand rule around +X)
refAxis1 = new THREE.Vector3(0, 1, 0) // 0° reference
refAxis2 = new THREE.Vector3(0, 0, 1) // 90° direction
} else if (axis === 'y') {
refAxis1 = new THREE.Vector3(1, 0, 0)
refAxis2 = new THREE.Vector3(0, 0, 1)
// XZ plane: 0° at +Z, positive rotation goes from +Z toward +X (right-hand rule around +Y)
refAxis1 = new THREE.Vector3(0, 0, 1) // 0° reference
refAxis2 = new THREE.Vector3(1, 0, 0) // 90° direction
} else {
refAxis1 = new THREE.Vector3(1, 0, 0)
refAxis2 = new THREE.Vector3(0, 1, 0)
// XY plane: 0° at +Y, positive rotation goes from +Y toward -X (right-hand rule around +Z)
refAxis1 = new THREE.Vector3(0, 1, 0) // 0° reference
refAxis2 = new THREE.Vector3(-1, 0, 0) // 90° direction
}
const x = toIntersection.dot(refAxis1)
@ -319,3 +504,92 @@ export function snapAngleToConstraint(angleDelta: number, constraintAngles: numb
const fullRotations = Math.floor(degrees / 360)
return ((fullRotations * 360 + closest) * Math.PI) / 180
}
/**
* Get the current rotation of a mesh around a world axis.
* Returns the angle in radians.
*/
export function getWorldRotationAroundAxis(mesh: THREE.Mesh, axis: 'x' | 'y' | 'z'): number {
// Get the world quaternion of the mesh
const worldQuat = new THREE.Quaternion()
mesh.getWorldQuaternion(worldQuat)
// Extract the rotation around the specified axis
// We do this by looking at how a reference vector is transformed
const axisVec = new THREE.Vector3()
const refVec = new THREE.Vector3()
if (axis === 'x') {
axisVec.set(1, 0, 0)
refVec.set(0, 1, 0) // Reference: +Y is 0°
} else if (axis === 'y') {
axisVec.set(0, 1, 0)
refVec.set(0, 0, 1) // Reference: +Z is 0°
} else {
axisVec.set(0, 0, 1)
refVec.set(0, 1, 0) // Reference: +Y is 0°
}
// Transform the reference vector by the mesh's rotation
const transformedRef = refVec.clone().applyQuaternion(worldQuat)
// Project onto the plane perpendicular to the axis
const projected = transformedRef.clone()
projected.sub(axisVec.clone().multiplyScalar(transformedRef.dot(axisVec)))
projected.normalize()
// Calculate angle using the same convention as getRotationAngle
let refAxis1: THREE.Vector3
let refAxis2: THREE.Vector3
if (axis === 'x') {
// 0° at +Y, 90° at +Z
refAxis1 = new THREE.Vector3(0, 1, 0)
refAxis2 = new THREE.Vector3(0, 0, 1)
} else if (axis === 'y') {
// 0° at +Z, 90° at +X
refAxis1 = new THREE.Vector3(0, 0, 1)
refAxis2 = new THREE.Vector3(1, 0, 0)
} else {
// 0° at +Y, 90° at -X
refAxis1 = new THREE.Vector3(0, 1, 0)
refAxis2 = new THREE.Vector3(-1, 0, 0)
}
const x = projected.dot(refAxis1)
const y = projected.dot(refAxis2)
return Math.atan2(y, x)
}
/**
* Calculate the rotation delta needed to snap an object to a world-aligned constraint angle.
*/
export function snapToWorldAlignedAngle(
currentWorldAngle: number,
constraintAngles: number[]
): number {
// Convert current angle to degrees and normalize
let currentDegrees = (currentWorldAngle * 180) / Math.PI
currentDegrees = currentDegrees % 360
if (currentDegrees < 0) currentDegrees += 360
// Find closest constraint angle
let closest = constraintAngles[0]!
let minDiff = Math.abs(currentDegrees - closest)
for (const angle of constraintAngles) {
const diff = Math.abs(currentDegrees - angle)
const wrapDiff = Math.abs(currentDegrees - (angle + 360))
const wrapDiff2 = Math.abs(currentDegrees - (angle - 360))
const effectiveDiff = Math.min(diff, wrapDiff, wrapDiff2)
if (effectiveDiff < minDiff) {
minDiff = effectiveDiff
closest = angle
}
}
// Return the snapped world angle in radians
return (closest * Math.PI) / 180
}