Add rotation mode controls
This commit is contained in:
parent
698286ec86
commit
39530cc4d7
3 changed files with 527 additions and 35 deletions
10
TODO.md
10
TODO.md
|
|
@ -120,11 +120,11 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
|
|||
- [x] Hide gizmo when nothing selected
|
||||
|
||||
### Rotation Mode (Ctrl Modifier)
|
||||
- [ ] Create custom rotation gizmo with flat ring handles per axis
|
||||
- [ ] Show rotation rings when Ctrl held with selection
|
||||
- [ ] Hover effect: ring thickens and brightens
|
||||
- [ ] Left-drag ring: free (by-pixel) rotation
|
||||
- [ ] Right-drag ring: constrained rotation (15°, 30°, 45° based on mouse distance from control)
|
||||
- [x] Create custom rotation gizmo with flat ring handles per axis
|
||||
- [x] Show rotation rings when Ctrl held with selection
|
||||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ import {
|
|||
type TranslateGizmo,
|
||||
type DragState,
|
||||
} from '../composables/useTranslateGizmo'
|
||||
import {
|
||||
createRotateGizmo,
|
||||
createRotateDragState,
|
||||
getRotationAngle,
|
||||
snapAngleToConstraint,
|
||||
type RotateGizmo,
|
||||
type RotateDragState,
|
||||
} from '../composables/useRotateGizmo'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -62,8 +70,11 @@ let selectionOutlinePass: OutlinePass
|
|||
let hoverOutlinePass: OutlinePass
|
||||
let hoveredObject: SceneObject | null = null
|
||||
let translateGizmo: TranslateGizmo | null = null
|
||||
let rotateGizmo: RotateGizmo | null = null
|
||||
let dragState: DragState = createDragState()
|
||||
let rotateDragState: RotateDragState = createRotateDragState()
|
||||
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
||||
let isCtrlPressed = false
|
||||
|
||||
function initScene() {
|
||||
if (!containerRef.value) return
|
||||
|
|
@ -119,15 +130,19 @@ function initScene() {
|
|||
addTestCube()
|
||||
|
||||
setupPostProcessing()
|
||||
setupTranslateGizmo()
|
||||
setupGizmos()
|
||||
setupMouseHandlers()
|
||||
setupKeyboardHandlers()
|
||||
setupResizeObserver()
|
||||
animate()
|
||||
}
|
||||
|
||||
function setupTranslateGizmo() {
|
||||
function setupGizmos() {
|
||||
translateGizmo = createTranslateGizmo()
|
||||
scene.add(translateGizmo.group)
|
||||
|
||||
rotateGizmo = createRotateGizmo()
|
||||
scene.add(rotateGizmo.group)
|
||||
}
|
||||
|
||||
function setupPostProcessing() {
|
||||
|
|
@ -233,6 +248,50 @@ function cleanupMouseHandlers() {
|
|||
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) {
|
||||
if (!containerRef.value) return
|
||||
|
||||
|
|
@ -272,7 +331,7 @@ function onCanvasClick(event: MouseEvent) {
|
|||
function onCanvasMouseMove(event: MouseEvent) {
|
||||
updateMouseCoordinates(event)
|
||||
|
||||
// Handle gizmo dragging
|
||||
// Handle translate gizmo dragging
|
||||
if (dragState.isDragging && dragState.axis && translateGizmo) {
|
||||
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
|
||||
if (selectedMeshes.length === 0) return
|
||||
|
|
@ -312,9 +371,52 @@ function onCanvasMouseMove(event: MouseEvent) {
|
|||
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
|
||||
if (translateGizmo) {
|
||||
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)
|
||||
if (newHoveredAxis !== hoveredAxis) {
|
||||
hoveredAxis = newHoveredAxis
|
||||
|
|
@ -337,17 +439,45 @@ function onCanvasContextMenu(event: MouseEvent) {
|
|||
}
|
||||
|
||||
function onCanvasMouseDown(event: MouseEvent) {
|
||||
if (!translateGizmo) return
|
||||
|
||||
updateMouseCoordinates(event)
|
||||
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)
|
||||
if (axis) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Disable orbit controls during gizmo drag
|
||||
controls.enabled = false
|
||||
|
||||
dragState.isDragging = true
|
||||
|
|
@ -371,6 +501,7 @@ function onCanvasMouseDown(event: MouseEvent) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasMouseUp(_event: MouseEvent) {
|
||||
if (dragState.isDragging) {
|
||||
|
|
@ -383,6 +514,13 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
|||
dragState.axis = null
|
||||
controls.enabled = true
|
||||
}
|
||||
|
||||
if (rotateDragState.isDragging) {
|
||||
rotateDragState.isDragging = false
|
||||
rotateDragState.axis = null
|
||||
rotateDragState.startObjectRotations.clear()
|
||||
controls.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasWheel(event: WheelEvent) {
|
||||
|
|
@ -435,10 +573,13 @@ function animate() {
|
|||
animationFrameId = requestAnimationFrame(animate)
|
||||
controls.update()
|
||||
|
||||
// Keep gizmo at consistent screen size regardless of zoom
|
||||
// Keep gizmos at consistent screen size regardless of zoom
|
||||
if (translateGizmo) {
|
||||
translateGizmo.updateScale(activeCamera)
|
||||
}
|
||||
if (rotateGizmo) {
|
||||
rotateGizmo.updateScale(activeCamera)
|
||||
}
|
||||
|
||||
composer.render()
|
||||
}
|
||||
|
|
@ -475,15 +616,40 @@ function updateGizmoPosition() {
|
|||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
translateGizmo.setPositionAndBounds(center, box)
|
||||
if (!isCtrlPressed) {
|
||||
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(
|
||||
() => sceneStore.selectedObjects,
|
||||
() => {
|
||||
updateSelectionOutline()
|
||||
updateHoverOutline()
|
||||
updateGizmoPosition()
|
||||
updateGizmoVisibility()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
|
@ -558,11 +724,16 @@ function cleanup() {
|
|||
}
|
||||
|
||||
cleanupMouseHandlers()
|
||||
cleanupKeyboardHandlers()
|
||||
|
||||
if (translateGizmo) {
|
||||
translateGizmo.dispose()
|
||||
}
|
||||
|
||||
if (rotateGizmo) {
|
||||
rotateGizmo.dispose()
|
||||
}
|
||||
|
||||
if (controls) {
|
||||
controls.dispose()
|
||||
}
|
||||
|
|
|
|||
321
src/composables/useRotateGizmo.ts
Normal file
321
src/composables/useRotateGizmo.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue