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] 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue