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
|
- [x] Hide gizmo when nothing selected
|
||||||
|
|
||||||
### Rotation Mode (Ctrl Modifier)
|
### Rotation Mode (Ctrl Modifier)
|
||||||
- [ ] Create custom rotation gizmo with flat ring handles per axis
|
- [x] Create custom rotation gizmo with flat ring handles per axis
|
||||||
- [ ] Show rotation rings when Ctrl held with selection
|
- [x] Show rotation rings when Ctrl held with selection
|
||||||
- [ ] Hover effect: ring thickens and brightens
|
- [x] Hover effect: ring thickens and brightens
|
||||||
- [ ] Left-drag ring: free (by-pixel) rotation
|
- [x] Left-drag ring: free (by-pixel) rotation
|
||||||
- [ ] 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)
|
- [ ] Display angle wheel during constrained rotation (world-oriented)
|
||||||
- [ ] Track object rotation state for world-oriented snapping
|
- [ ] Track object rotation state for world-oriented snapping
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ import {
|
||||||
type TranslateGizmo,
|
type TranslateGizmo,
|
||||||
type DragState,
|
type DragState,
|
||||||
} from '../composables/useTranslateGizmo'
|
} from '../composables/useTranslateGizmo'
|
||||||
|
import {
|
||||||
|
createRotateGizmo,
|
||||||
|
createRotateDragState,
|
||||||
|
getRotationAngle,
|
||||||
|
snapAngleToConstraint,
|
||||||
|
type RotateGizmo,
|
||||||
|
type RotateDragState,
|
||||||
|
} from '../composables/useRotateGizmo'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -62,8 +70,11 @@ let selectionOutlinePass: OutlinePass
|
||||||
let hoverOutlinePass: OutlinePass
|
let hoverOutlinePass: OutlinePass
|
||||||
let hoveredObject: SceneObject | null = null
|
let hoveredObject: SceneObject | null = null
|
||||||
let translateGizmo: TranslateGizmo | null = null
|
let translateGizmo: TranslateGizmo | null = null
|
||||||
|
let rotateGizmo: RotateGizmo | null = null
|
||||||
let dragState: DragState = createDragState()
|
let dragState: DragState = createDragState()
|
||||||
|
let rotateDragState: RotateDragState = createRotateDragState()
|
||||||
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
let hoveredAxis: 'x' | 'y' | 'z' | null = null
|
||||||
|
let isCtrlPressed = false
|
||||||
|
|
||||||
function initScene() {
|
function initScene() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
@ -119,15 +130,19 @@ function initScene() {
|
||||||
addTestCube()
|
addTestCube()
|
||||||
|
|
||||||
setupPostProcessing()
|
setupPostProcessing()
|
||||||
setupTranslateGizmo()
|
setupGizmos()
|
||||||
setupMouseHandlers()
|
setupMouseHandlers()
|
||||||
|
setupKeyboardHandlers()
|
||||||
setupResizeObserver()
|
setupResizeObserver()
|
||||||
animate()
|
animate()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTranslateGizmo() {
|
function setupGizmos() {
|
||||||
translateGizmo = createTranslateGizmo()
|
translateGizmo = createTranslateGizmo()
|
||||||
scene.add(translateGizmo.group)
|
scene.add(translateGizmo.group)
|
||||||
|
|
||||||
|
rotateGizmo = createRotateGizmo()
|
||||||
|
scene.add(rotateGizmo.group)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPostProcessing() {
|
function setupPostProcessing() {
|
||||||
|
|
@ -233,6 +248,50 @@ function cleanupMouseHandlers() {
|
||||||
renderer.domElement.removeEventListener('wheel', onCanvasWheel)
|
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) {
|
function updateMouseCoordinates(event: MouseEvent) {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
||||||
|
|
@ -272,7 +331,7 @@ function onCanvasClick(event: MouseEvent) {
|
||||||
function onCanvasMouseMove(event: MouseEvent) {
|
function onCanvasMouseMove(event: MouseEvent) {
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
|
|
||||||
// Handle gizmo dragging
|
// Handle translate gizmo dragging
|
||||||
if (dragState.isDragging && dragState.axis && translateGizmo) {
|
if (dragState.isDragging && dragState.axis && translateGizmo) {
|
||||||
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
|
||||||
|
|
@ -312,9 +371,52 @@ function onCanvasMouseMove(event: MouseEvent) {
|
||||||
return
|
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
|
// Handle gizmo hover highlight
|
||||||
if (translateGizmo) {
|
|
||||||
raycaster.setFromCamera(mouse, activeCamera)
|
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)
|
const newHoveredAxis = translateGizmo.getHoveredAxis(raycaster)
|
||||||
if (newHoveredAxis !== hoveredAxis) {
|
if (newHoveredAxis !== hoveredAxis) {
|
||||||
hoveredAxis = newHoveredAxis
|
hoveredAxis = newHoveredAxis
|
||||||
|
|
@ -337,17 +439,45 @@ function onCanvasContextMenu(event: MouseEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseDown(event: MouseEvent) {
|
function onCanvasMouseDown(event: MouseEvent) {
|
||||||
if (!translateGizmo) return
|
|
||||||
|
|
||||||
updateMouseCoordinates(event)
|
updateMouseCoordinates(event)
|
||||||
raycaster.setFromCamera(mouse, activeCamera)
|
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)
|
const axis = translateGizmo.getHoveredAxis(raycaster)
|
||||||
if (axis) {
|
if (axis) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
// Disable orbit controls during gizmo drag
|
|
||||||
controls.enabled = false
|
controls.enabled = false
|
||||||
|
|
||||||
dragState.isDragging = true
|
dragState.isDragging = true
|
||||||
|
|
@ -371,6 +501,7 @@ function onCanvasMouseDown(event: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCanvasMouseUp(_event: MouseEvent) {
|
function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
if (dragState.isDragging) {
|
if (dragState.isDragging) {
|
||||||
|
|
@ -383,6 +514,13 @@ function onCanvasMouseUp(_event: MouseEvent) {
|
||||||
dragState.axis = null
|
dragState.axis = null
|
||||||
controls.enabled = true
|
controls.enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rotateDragState.isDragging) {
|
||||||
|
rotateDragState.isDragging = false
|
||||||
|
rotateDragState.axis = null
|
||||||
|
rotateDragState.startObjectRotations.clear()
|
||||||
|
controls.enabled = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasWheel(event: WheelEvent) {
|
function onCanvasWheel(event: WheelEvent) {
|
||||||
|
|
@ -435,10 +573,13 @@ function animate() {
|
||||||
animationFrameId = requestAnimationFrame(animate)
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
controls.update()
|
controls.update()
|
||||||
|
|
||||||
// Keep gizmo at consistent screen size regardless of zoom
|
// Keep gizmos at consistent screen size regardless of zoom
|
||||||
if (translateGizmo) {
|
if (translateGizmo) {
|
||||||
translateGizmo.updateScale(activeCamera)
|
translateGizmo.updateScale(activeCamera)
|
||||||
}
|
}
|
||||||
|
if (rotateGizmo) {
|
||||||
|
rotateGizmo.updateScale(activeCamera)
|
||||||
|
}
|
||||||
|
|
||||||
composer.render()
|
composer.render()
|
||||||
}
|
}
|
||||||
|
|
@ -475,15 +616,40 @@ function updateGizmoPosition() {
|
||||||
const center = box.getCenter(new THREE.Vector3())
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
|
||||||
translateGizmo.setPositionAndBounds(center, box)
|
translateGizmo.setPositionAndBounds(center, box)
|
||||||
|
if (!isCtrlPressed) {
|
||||||
translateGizmo.show()
|
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(
|
watch(
|
||||||
() => sceneStore.selectedObjects,
|
() => sceneStore.selectedObjects,
|
||||||
() => {
|
() => {
|
||||||
updateSelectionOutline()
|
updateSelectionOutline()
|
||||||
updateHoverOutline()
|
updateHoverOutline()
|
||||||
updateGizmoPosition()
|
updateGizmoVisibility()
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
@ -558,11 +724,16 @@ function cleanup() {
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupMouseHandlers()
|
cleanupMouseHandlers()
|
||||||
|
cleanupKeyboardHandlers()
|
||||||
|
|
||||||
if (translateGizmo) {
|
if (translateGizmo) {
|
||||||
translateGizmo.dispose()
|
translateGizmo.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rotateGizmo) {
|
||||||
|
rotateGizmo.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
if (controls) {
|
if (controls) {
|
||||||
controls.dispose()
|
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