diff --git a/TODO.md b/TODO.md index f84657410..79f0153bc 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index f476f58fe..3bce5d790 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -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() diff --git a/src/composables/useRotateGizmo.ts b/src/composables/useRotateGizmo.ts index 80aa0c3cf..05d780963 100644 --- a/src/composables/useRotateGizmo.ts +++ b/src/composables/useRotateGizmo.ts @@ -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 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 +}