Add rotation mode angle visualizer
This commit is contained in:
parent
39530cc4d7
commit
3c139fe58d
3 changed files with 354 additions and 20 deletions
4
TODO.md
4
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] Hover effect: ring thickens and brightens
|
||||||
- [x] Left-drag ring: free (by-pixel) rotation
|
- [x] Left-drag ring: free (by-pixel) rotation
|
||||||
- [x] 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)
|
- [x] Display angle wheel during constrained rotation (world-oriented)
|
||||||
- [ ] Track object rotation state for world-oriented snapping
|
- [x] Track object rotation state for world-oriented snapping
|
||||||
|
|
||||||
### Scale Mode (Ctrl+Shift Modifier)
|
### Scale Mode (Ctrl+Shift Modifier)
|
||||||
- [ ] Create bounding box visualization for selection
|
- [ ] Create bounding box visualization for selection
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ import {
|
||||||
createRotateGizmo,
|
createRotateGizmo,
|
||||||
createRotateDragState,
|
createRotateDragState,
|
||||||
getRotationAngle,
|
getRotationAngle,
|
||||||
snapAngleToConstraint,
|
getWorldRotationAroundAxis,
|
||||||
|
snapToWorldAlignedAngle,
|
||||||
|
generateConstraintAngles,
|
||||||
|
getGranularityFromDistance,
|
||||||
type RotateGizmo,
|
type RotateGizmo,
|
||||||
type RotateDragState,
|
type RotateDragState,
|
||||||
} from '../composables/useRotateGizmo'
|
} from '../composables/useRotateGizmo'
|
||||||
|
|
@ -376,7 +379,7 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
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
|
||||||
|
|
||||||
// Get current rotation angle
|
// Get current rotation angle from mouse position
|
||||||
const center = rotateGizmo.group.position
|
const center = rotateGizmo.group.position
|
||||||
const currentAngle = getRotationAngle(mouse, activeCamera, center, rotateDragState.axis)
|
const currentAngle = getRotationAngle(mouse, activeCamera, center, rotateDragState.axis)
|
||||||
rotateDragState.currentAngle = currentAngle
|
rotateDragState.currentAngle = currentAngle
|
||||||
|
|
@ -384,21 +387,70 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
// Calculate angle delta from start
|
// Calculate angle delta from start
|
||||||
let angleDelta = currentAngle - rotateDragState.startAngle
|
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
|
// Apply rotation to all selected objects
|
||||||
const rotationAxis = new THREE.Vector3()
|
const rotationAxis = new THREE.Vector3()
|
||||||
if (rotateDragState.axis === 'x') rotationAxis.set(1, 0, 0)
|
if (rotateDragState.axis === 'x') rotationAxis.set(1, 0, 0)
|
||||||
else if (rotateDragState.axis === 'y') rotationAxis.set(0, 1, 0)
|
else if (rotateDragState.axis === 'y') rotationAxis.set(0, 1, 0)
|
||||||
else rotationAxis.set(0, 0, 1)
|
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) {
|
for (const mesh of selectedMeshes) {
|
||||||
const startRotation = rotateDragState.startObjectRotations.get(mesh.uuid)
|
const startRotation = rotateDragState.startObjectRotations.get(mesh.uuid)
|
||||||
if (startRotation) {
|
if (startRotation) {
|
||||||
// Reset to start rotation then apply delta
|
|
||||||
mesh.rotation.copy(startRotation)
|
mesh.rotation.copy(startRotation)
|
||||||
mesh.rotateOnWorldAxis(rotationAxis, angleDelta)
|
mesh.rotateOnWorldAxis(rotationAxis, angleDelta)
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +519,11 @@ function onCanvasMouseDown(event: MouseEvent) {
|
||||||
rotateDragState.startAngle = getRotationAngle(mouse, activeCamera, center, axis)
|
rotateDragState.startAngle = getRotationAngle(mouse, activeCamera, center, axis)
|
||||||
rotateDragState.currentAngle = rotateDragState.startAngle
|
rotateDragState.currentAngle = rotateDragState.startAngle
|
||||||
|
|
||||||
|
// Show angle wheel for constrained (right-drag) rotation
|
||||||
|
if (rotateDragState.isRightDrag) {
|
||||||
|
rotateGizmo.showAngleWheel(axis, rotateDragState.constraintAngles)
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -516,6 +573,9 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rotateDragState.isDragging) {
|
if (rotateDragState.isDragging) {
|
||||||
|
// Hide angle wheel if it was shown
|
||||||
|
rotateGizmo?.hideAngleWheel()
|
||||||
|
|
||||||
rotateDragState.isDragging = false
|
rotateDragState.isDragging = false
|
||||||
rotateDragState.axis = null
|
rotateDragState.axis = null
|
||||||
rotateDragState.startObjectRotations.clear()
|
rotateDragState.startObjectRotations.clear()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ export interface RotateGizmo {
|
||||||
getHoveredAxis: (raycaster: THREE.Raycaster) => 'x' | 'y' | 'z' | null
|
getHoveredAxis: (raycaster: THREE.Raycaster) => 'x' | 'y' | 'z' | null
|
||||||
setHighlightedAxis: (axis: 'x' | 'y' | 'z' | null) => void
|
setHighlightedAxis: (axis: 'x' | 'y' | 'z' | null) => void
|
||||||
updateScale: (camera: THREE.Camera) => void
|
updateScale: (camera: THREE.Camera) => void
|
||||||
|
showAngleWheel: (axis: 'x' | 'y' | 'z', constraintAngles: number[]) => void
|
||||||
|
hideAngleWheel: () => void
|
||||||
|
updateAngleWheel: (snappedAngleDegrees: number) => void
|
||||||
dispose: () => void
|
dispose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,6 +110,142 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
|
||||||
group.add(createRing('y'))
|
group.add(createRing('y'))
|
||||||
group.add(createRing('z'))
|
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 {
|
function show(): void {
|
||||||
group.visible = true
|
group.visible = true
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +340,20 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
|
||||||
material.dispose()
|
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 {
|
return {
|
||||||
|
|
@ -211,6 +364,9 @@ export function createRotateGizmo(options: RotateGizmoOptions = {}): RotateGizmo
|
||||||
getHoveredAxis,
|
getHoveredAxis,
|
||||||
setHighlightedAxis,
|
setHighlightedAxis,
|
||||||
updateScale,
|
updateScale,
|
||||||
|
showAngleWheel,
|
||||||
|
hideAngleWheel,
|
||||||
|
updateAngleWheel,
|
||||||
dispose,
|
dispose,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +379,7 @@ export interface RotateDragState {
|
||||||
currentAngle: number
|
currentAngle: number
|
||||||
startObjectRotations: Map<string, THREE.Euler>
|
startObjectRotations: Map<string, THREE.Euler>
|
||||||
constraintAngles: number[] // Available snap angles for right-drag
|
constraintAngles: number[] // Available snap angles for right-drag
|
||||||
|
currentGranularity: number // Current snap granularity in degrees (15, 30, or 45)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRotateDragState(): RotateDragState {
|
export function createRotateDragState(): RotateDragState {
|
||||||
|
|
@ -233,10 +390,34 @@ export function createRotateDragState(): RotateDragState {
|
||||||
startAngle: 0,
|
startAngle: 0,
|
||||||
currentAngle: 0,
|
currentAngle: 0,
|
||||||
startObjectRotations: new Map(),
|
startObjectRotations: new Map(),
|
||||||
constraintAngles: [
|
constraintAngles: generateConstraintAngles(15),
|
||||||
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285,
|
currentGranularity: 15,
|
||||||
300, 315, 330, 345,
|
}
|
||||||
],
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
const toIntersection = intersection.clone().sub(center)
|
||||||
|
|
||||||
// Get the two axes perpendicular to the rotation axis
|
// 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 refAxis1: THREE.Vector3
|
||||||
let refAxis2: THREE.Vector3
|
let refAxis2: THREE.Vector3
|
||||||
|
|
||||||
if (axis === 'x') {
|
if (axis === 'x') {
|
||||||
refAxis1 = new THREE.Vector3(0, 1, 0)
|
// YZ plane: 0° at +Y, positive rotation goes from +Y toward +Z (right-hand rule around +X)
|
||||||
refAxis2 = new THREE.Vector3(0, 0, 1)
|
refAxis1 = new THREE.Vector3(0, 1, 0) // 0° reference
|
||||||
|
refAxis2 = new THREE.Vector3(0, 0, 1) // 90° direction
|
||||||
} else if (axis === 'y') {
|
} else if (axis === 'y') {
|
||||||
refAxis1 = new THREE.Vector3(1, 0, 0)
|
// XZ plane: 0° at +Z, positive rotation goes from +Z toward +X (right-hand rule around +Y)
|
||||||
refAxis2 = new THREE.Vector3(0, 0, 1)
|
refAxis1 = new THREE.Vector3(0, 0, 1) // 0° reference
|
||||||
|
refAxis2 = new THREE.Vector3(1, 0, 0) // 90° direction
|
||||||
} else {
|
} else {
|
||||||
refAxis1 = new THREE.Vector3(1, 0, 0)
|
// XY plane: 0° at +Y, positive rotation goes from +Y toward -X (right-hand rule around +Z)
|
||||||
refAxis2 = new THREE.Vector3(0, 1, 0)
|
refAxis1 = new THREE.Vector3(0, 1, 0) // 0° reference
|
||||||
|
refAxis2 = new THREE.Vector3(-1, 0, 0) // 90° direction
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = toIntersection.dot(refAxis1)
|
const x = toIntersection.dot(refAxis1)
|
||||||
|
|
@ -319,3 +504,92 @@ export function snapAngleToConstraint(angleDelta: number, constraintAngles: numb
|
||||||
const fullRotations = Math.floor(degrees / 360)
|
const fullRotations = Math.floor(degrees / 360)
|
||||||
return ((fullRotations * 360 + closest) * Math.PI) / 180
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue