diff --git a/TODO.md b/TODO.md index 79f0153bc..b6fd95288 100644 --- a/TODO.md +++ b/TODO.md @@ -129,15 +129,15 @@ A step-by-step checklist for porting MatterControl's design features to a Vue + - [x] Track object rotation state for world-oriented snapping ### Scale Mode (Ctrl+Shift Modifier) -- [ ] Create bounding box visualization for selection -- [ ] Show scale handles when Ctrl+Shift held with selection -- [ ] Corner handles: white cubes for uniform scaling - - [ ] Left-drag: free scale - - [ ] Right-drag: symmetrical proportional scale from center -- [ ] Face center handles: colored cubes (by axis) for stretch - - [ ] Left-drag: one-directional stretch along axis - - [ ] Right-drag: symmetrical stretch from center along axis -- [ ] Hover effect: handles grow subtly and brighten +- [x] Create bounding box visualization for selection +- [x] Show scale handles when Ctrl+Shift held with selection +- [x] Corner handles: white cubes for uniform scaling + - [x] Left-drag: free scale + - [x] Right-drag: symmetrical proportional scale from center +- [x] Face center handles: colored cubes (by axis) for stretch + - [x] Left-drag: one-directional stretch along axis + - [x] Right-drag: symmetrical stretch from center along axis +- [x] Hover effect: handles grow subtly and brighten ### Camera Mode (Alt Modifier) - [ ] Left-drag: orbit camera diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index 3bce5d790..c30304ee9 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -35,6 +35,15 @@ import { type RotateGizmo, type RotateDragState, } from '../composables/useRotateGizmo' +import { + createScaleGizmo, + createScaleDragState, + getScaleFactor, + getFreeformScaleFactor, + type ScaleGizmo, + type ScaleDragState, + type ScaleHandleInfo, +} from '../composables/useScaleGizmo' const props = withDefaults( defineProps<{ @@ -74,10 +83,14 @@ let hoverOutlinePass: OutlinePass let hoveredObject: SceneObject | null = null let translateGizmo: TranslateGizmo | null = null let rotateGizmo: RotateGizmo | null = null +let scaleGizmo: ScaleGizmo | null = null let dragState: DragState = createDragState() let rotateDragState: RotateDragState = createRotateDragState() +let scaleDragState: ScaleDragState = createScaleDragState() let hoveredAxis: 'x' | 'y' | 'z' | null = null +let hoveredScaleHandle: ScaleHandleInfo | null = null let isCtrlPressed = false +let isShiftPressed = false function initScene() { if (!containerRef.value) return @@ -146,6 +159,9 @@ function setupGizmos() { rotateGizmo = createRotateGizmo() scene.add(rotateGizmo.group) + + scaleGizmo = createScaleGizmo() + scene.add(scaleGizmo.group) } function setupPostProcessing() { @@ -262,15 +278,31 @@ function cleanupKeyboardHandlers() { } function onKeyDown(event: KeyboardEvent) { + let needsUpdate = false if (event.key === 'Control' && !isCtrlPressed) { isCtrlPressed = true + needsUpdate = true + } + if (event.key === 'Shift' && !isShiftPressed) { + isShiftPressed = true + needsUpdate = true + } + if (needsUpdate) { updateGizmoVisibility() } } function onKeyUp(event: KeyboardEvent) { + let needsUpdate = false if (event.key === 'Control' && isCtrlPressed) { isCtrlPressed = false + needsUpdate = true + } + if (event.key === 'Shift' && isShiftPressed) { + isShiftPressed = false + needsUpdate = true + } + if (needsUpdate) { updateGizmoVisibility() } } @@ -281,15 +313,26 @@ function updateGizmoVisibility() { if (!hasSelection) { translateGizmo?.hide() rotateGizmo?.hide() + scaleGizmo?.hide() return } - if (isCtrlPressed) { + if (isCtrlPressed && isShiftPressed) { + // Scale mode: Ctrl+Shift translateGizmo?.hide() + rotateGizmo?.hide() + scaleGizmo?.show() + updateScaleGizmoPosition() + } else if (isCtrlPressed) { + // Rotate mode: Ctrl only + translateGizmo?.hide() + scaleGizmo?.hide() rotateGizmo?.show() updateRotateGizmoPosition() } else { + // Translate mode: default rotateGizmo?.hide() + scaleGizmo?.hide() translateGizmo?.show() updateGizmoPosition() } @@ -374,6 +417,112 @@ function onCanvasMouseMove(event: MouseEvent) { return } + // Handle scale gizmo dragging + if (scaleDragState.isDragging && scaleDragState.handleInfo && scaleGizmo) { + const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) + if (selectedMeshes.length === 0) return + + const handleInfo = scaleDragState.handleInfo + const startBounds = scaleDragState.startBounds + const boundsCenter = startBounds.getCenter(new THREE.Vector3()) + const boundsSize = startBounds.getSize(new THREE.Vector3()) + + // Calculate scale factor based on mouse movement + const scaleFactor = getScaleFactor( + mouse, + scaleDragState.startMousePosition, + activeCamera, + boundsCenter, + handleInfo, + scaleDragState.handleWorldPosition + ) + + // For corner handles with left-drag, use free-form scaling where corner follows mouse + let finalScaleFactor = scaleFactor.clone() + if (handleInfo.type === 'corner' && !scaleDragState.isRightDrag) { + // Calculate anchor point (opposite corner) + const corner = handleInfo.corner! + const halfSize = boundsSize.clone().multiplyScalar(0.5) + const anchor = new THREE.Vector3( + boundsCenter.x - corner.x * halfSize.x, + boundsCenter.y - corner.y * halfSize.y, + boundsCenter.z - corner.z * halfSize.z + ) + + // Use free-form scaling that makes handle follow mouse position + finalScaleFactor = getFreeformScaleFactor( + mouse, + activeCamera, + anchor, + scaleDragState.handleWorldPosition + ) + } + + // Apply scale to all selected objects + for (const mesh of selectedMeshes) { + const startScale = scaleDragState.startMeshScales.get(mesh.uuid) + const startPos = scaleDragState.startMeshPositions.get(mesh.uuid) + if (!startScale || !startPos) continue + + // Apply new scale + mesh.scale.set( + startScale.x * finalScaleFactor.x, + startScale.y * finalScaleFactor.y, + startScale.z * finalScaleFactor.z + ) + + if (handleInfo.type === 'corner') { + // For corner handles, use the opposite corner as anchor + const corner = handleInfo.corner! + const halfSize = boundsSize.clone().multiplyScalar(0.5) + // Opposite corner is at -corner direction + const anchor = new THREE.Vector3( + boundsCenter.x - corner.x * halfSize.x, + boundsCenter.y - corner.y * halfSize.y, + boundsCenter.z - corner.z * halfSize.z + ) + + // Scale position relative to anchor + const offsetFromAnchor = startPos.clone().sub(anchor) + const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor) + mesh.position.copy(anchor).add(newOffset) + } else if (handleInfo.type === 'face') { + if (scaleDragState.isRightDrag) { + // Symmetrical scaling from center for right-drag + const offsetFromCenter = startPos.clone().sub(boundsCenter) + const newOffset = offsetFromCenter.clone().multiply(finalScaleFactor) + mesh.position.copy(boundsCenter).add(newOffset) + } else { + // One-directional stretch from opposite face for left-drag + const faceDir = new THREE.Vector3() + if (handleInfo.face!.startsWith('x')) faceDir.x = handleInfo.face!.endsWith('+') ? 1 : -1 + else if (handleInfo.face!.startsWith('y')) + faceDir.y = handleInfo.face!.endsWith('+') ? 1 : -1 + else if (handleInfo.face!.startsWith('z')) + faceDir.z = handleInfo.face!.endsWith('+') ? 1 : -1 + + // Anchor is at the opposite face + const halfSize = boundsSize.clone().multiplyScalar(0.5) + const anchor = new THREE.Vector3( + boundsCenter.x - faceDir.x * halfSize.x, + boundsCenter.y - faceDir.y * halfSize.y, + boundsCenter.z - faceDir.z * halfSize.z + ) + + // Scale position relative to anchor + const offsetFromAnchor = startPos.clone().sub(anchor) + const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor) + mesh.position.copy(anchor).add(newOffset) + } + } + } + + // Update scale gizmo bounds + updateScaleGizmoPosition() + + return + } + // Handle rotate gizmo dragging if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) { const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) @@ -462,7 +611,21 @@ function onCanvasMouseMove(event: MouseEvent) { // Handle gizmo hover highlight raycaster.setFromCamera(mouse, activeCamera) - if (isCtrlPressed && rotateGizmo?.group.visible) { + if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) { + const newHoveredHandle = scaleGizmo.getHoveredHandle(raycaster) + // Compare handle info (can't use simple !== due to object comparison) + const handleChanged = + !newHoveredHandle !== !hoveredScaleHandle || + (newHoveredHandle && + hoveredScaleHandle && + (newHoveredHandle.type !== hoveredScaleHandle.type || + newHoveredHandle.face !== hoveredScaleHandle.face || + !newHoveredHandle.corner?.equals(hoveredScaleHandle.corner ?? new THREE.Vector3()))) + if (handleChanged) { + hoveredScaleHandle = newHoveredHandle + scaleGizmo.setHighlightedHandle(hoveredScaleHandle) + } + } else if (isCtrlPressed && rotateGizmo?.group.visible) { const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster) if (newHoveredAxis !== hoveredAxis) { hoveredAxis = newHoveredAxis @@ -486,6 +649,13 @@ function onCanvasMouseMove(event: MouseEvent) { function onCanvasContextMenu(event: MouseEvent) { event.preventDefault() + event.stopPropagation() + + // Don't show context menu when in scale mode (Ctrl+Shift held) + if (isCtrlPressed && isShiftPressed) { + return + } + const pickedObject = pickObject(event) emit('contextmenu', pickedObject, event) } @@ -494,8 +664,65 @@ function onCanvasMouseDown(event: MouseEvent) { updateMouseCoordinates(event) raycaster.setFromCamera(mouse, activeCamera) - // Check for rotation gizmo first (when Ctrl is held) - if (isCtrlPressed && rotateGizmo?.group.visible) { + // Check for scale gizmo first (when Ctrl+Shift is held) + if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) { + const handleInfo = scaleGizmo.getHoveredHandle(raycaster) + if (handleInfo) { + event.preventDefault() + event.stopPropagation() + + controls.enabled = false + + scaleDragState.isDragging = true + scaleDragState.handleInfo = handleInfo + scaleDragState.isRightDrag = event.button === 2 + scaleDragState.startMousePosition.copy(mouse) + + // Store starting scales and positions for all selected objects + const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) + scaleDragState.startMeshScales.clear() + scaleDragState.startMeshPositions.clear() + for (const mesh of selectedMeshes) { + scaleDragState.startMeshScales.set(mesh.uuid, mesh.scale.clone()) + scaleDragState.startMeshPositions.set(mesh.uuid, mesh.position.clone()) + } + + // Store the starting bounding box + const box = new THREE.Box3() + for (const obj of sceneStore.selectedObjects) { + box.expandByObject(obj.mesh) + } + scaleDragState.startBounds.copy(box) + + // Calculate and store the handle world position + const boundsCenter = box.getCenter(new THREE.Vector3()) + const boundsSize = box.getSize(new THREE.Vector3()) + const halfSize = boundsSize.clone().multiplyScalar(0.5) + + if (handleInfo.type === 'corner' && handleInfo.corner) { + scaleDragState.handleWorldPosition.set( + boundsCenter.x + handleInfo.corner.x * halfSize.x, + boundsCenter.y + handleInfo.corner.y * halfSize.y, + boundsCenter.z + handleInfo.corner.z * halfSize.z + ) + } else if (handleInfo.face) { + const faceDir = new THREE.Vector3() + if (handleInfo.face.startsWith('x')) faceDir.x = handleInfo.face.endsWith('+') ? 1 : -1 + else if (handleInfo.face.startsWith('y')) faceDir.y = handleInfo.face.endsWith('+') ? 1 : -1 + else if (handleInfo.face.startsWith('z')) faceDir.z = handleInfo.face.endsWith('+') ? 1 : -1 + scaleDragState.handleWorldPosition.set( + boundsCenter.x + faceDir.x * halfSize.x, + boundsCenter.y + faceDir.y * halfSize.y, + boundsCenter.z + faceDir.z * halfSize.z + ) + } + + return + } + } + + // Check for rotation gizmo (when Ctrl is held but not Shift) + if (isCtrlPressed && !isShiftPressed && rotateGizmo?.group.visible) { const axis = rotateGizmo.getHoveredAxis(raycaster) if (axis) { event.preventDefault() @@ -581,6 +808,14 @@ function onCanvasMouseUp(_event: MouseEvent) { rotateDragState.startObjectRotations.clear() controls.enabled = true } + + if (scaleDragState.isDragging) { + scaleDragState.isDragging = false + scaleDragState.handleInfo = null + scaleDragState.startMeshScales.clear() + scaleDragState.startMeshPositions.clear() + controls.enabled = true + } } function onCanvasWheel(event: WheelEvent) { @@ -640,6 +875,9 @@ function animate() { if (rotateGizmo) { rotateGizmo.updateScale(activeCamera) } + if (scaleGizmo) { + scaleGizmo.updateScale(activeCamera) + } composer.render() } @@ -699,11 +937,33 @@ function updateRotateGizmoPosition() { const center = box.getCenter(new THREE.Vector3()) rotateGizmo.setPositionAndBounds(center, box) - if (isCtrlPressed) { + if (isCtrlPressed && !isShiftPressed) { rotateGizmo.show() } } +function updateScaleGizmoPosition() { + if (!scaleGizmo) return + + const selected = sceneStore.selectedObjects + if (selected.length === 0) { + scaleGizmo.hide() + return + } + + // Calculate bounding box of all selected objects + const box = new THREE.Box3() + for (const obj of selected) { + box.expandByObject(obj.mesh) + } + + scaleGizmo.setBounds(box) + + if (isCtrlPressed && isShiftPressed) { + scaleGizmo.show() + } +} + watch( () => sceneStore.selectedObjects, () => { diff --git a/src/composables/useScaleGizmo.ts b/src/composables/useScaleGizmo.ts new file mode 100644 index 000000000..3d6396887 --- /dev/null +++ b/src/composables/useScaleGizmo.ts @@ -0,0 +1,467 @@ +import * as THREE from 'three' + +export interface ScaleGizmoOptions { + handleSize?: number + highlightScale?: number + lineColor?: number +} + +export interface ScaleGizmo { + group: THREE.Group + show: () => void + hide: () => void + setBounds: (box: THREE.Box3) => void + getHoveredHandle: (raycaster: THREE.Raycaster) => ScaleHandleInfo | null + setHighlightedHandle: (handle: ScaleHandleInfo | null) => void + updateScale: (camera: THREE.Camera) => void + dispose: () => void +} + +export type ScaleHandleType = 'corner' | 'face' +export type ScaleAxis = 'x' | 'y' | 'z' | 'xyz' + +export interface ScaleHandleInfo { + type: ScaleHandleType + axis: ScaleAxis + corner?: THREE.Vector3 // For corner handles, which corner (-1 or 1 for each axis) + face?: 'x+' | 'x-' | 'y+' | 'y-' | 'z+' | 'z-' // For face handles +} + +const AXIS_COLORS = { + x: 0xe74c3c, // Red + y: 0x2ecc71, // Green + z: 0x3498db, // Blue + xyz: 0xffffff, // White for uniform +} + +const HIGHLIGHT_COLORS = { + x: 0xff6b6b, + y: 0x5dff7f, + z: 0x5dafff, + xyz: 0xffffaa, +} + +export function createScaleGizmo(options: ScaleGizmoOptions = {}): ScaleGizmo { + const { handleSize = 0.04, highlightScale = 1.3, lineColor = 0x888888 } = options + + const group = new THREE.Group() + group.name = '__scaleGizmo__' + group.visible = false + + // Store current bounds + const currentBox = new THREE.Box3() + const boxCenter = new THREE.Vector3() + const boxSize = new THREE.Vector3() + + // Bounding box wireframe + const boxGeometry = new THREE.BoxGeometry(1, 1, 1) + const boxEdges = new THREE.EdgesGeometry(boxGeometry) + const boxLineMaterial = new THREE.LineBasicMaterial({ color: lineColor }) + const boxLine = new THREE.LineSegments(boxEdges, boxLineMaterial) + boxLine.name = '__scaleGizmo_box__' + group.add(boxLine) + + // Store handles + const cornerHandles: THREE.Mesh[] = [] + const faceHandles: THREE.Mesh[] = [] + const handleMaterials: Map = new Map() + + // Track highlighted handle + let highlightedHandle: THREE.Mesh | null = null + + // Create corner handles (8 corners) + const corners = [ + new THREE.Vector3(-1, -1, -1), + new THREE.Vector3(-1, -1, 1), + new THREE.Vector3(-1, 1, -1), + new THREE.Vector3(-1, 1, 1), + new THREE.Vector3(1, -1, -1), + new THREE.Vector3(1, -1, 1), + new THREE.Vector3(1, 1, -1), + new THREE.Vector3(1, 1, 1), + ] + + // Create geometry at unit size - scaling happens in updateScale + const cornerGeometry = new THREE.BoxGeometry(1, 1, 1) + + for (const corner of corners) { + const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS.xyz }) + const handle = new THREE.Mesh(cornerGeometry, material) + handle.name = `__scaleGizmo_corner_${corner.x}_${corner.y}_${corner.z}__` + handle.userData = { + isGizmoHandle: true, + type: 'scale', + handleType: 'corner' as ScaleHandleType, + axis: 'xyz' as ScaleAxis, + corner: corner.clone(), + } + handleMaterials.set(handle, material) + cornerHandles.push(handle) + group.add(handle) + } + + // Create face center handles (6 faces) + const faces: { position: THREE.Vector3; axis: 'x' | 'y' | 'z'; face: string }[] = [ + { position: new THREE.Vector3(1, 0, 0), axis: 'x', face: 'x+' }, + { position: new THREE.Vector3(-1, 0, 0), axis: 'x', face: 'x-' }, + { position: new THREE.Vector3(0, 1, 0), axis: 'y', face: 'y+' }, + { position: new THREE.Vector3(0, -1, 0), axis: 'y', face: 'y-' }, + { position: new THREE.Vector3(0, 0, 1), axis: 'z', face: 'z+' }, + { position: new THREE.Vector3(0, 0, -1), axis: 'z', face: 'z-' }, + ] + + // Create geometry at unit size - scaling happens in updateScale + const faceGeometry = new THREE.BoxGeometry(1, 1, 1) + + for (const faceInfo of faces) { + const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS[faceInfo.axis] }) + const handle = new THREE.Mesh(faceGeometry, material) + handle.name = `__scaleGizmo_face_${faceInfo.face}__` + handle.userData = { + isGizmoHandle: true, + type: 'scale', + handleType: 'face' as ScaleHandleType, + axis: faceInfo.axis as ScaleAxis, + face: faceInfo.face, + faceDirection: faceInfo.position.clone(), + } + handleMaterials.set(handle, material) + faceHandles.push(handle) + group.add(handle) + } + + function updateHandlePositions(): void { + const halfSize = boxSize.clone().multiplyScalar(0.5) + + // Update corner handle positions + for (const handle of cornerHandles) { + const corner = handle.userData.corner as THREE.Vector3 + handle.position.set( + boxCenter.x + corner.x * halfSize.x, + boxCenter.y + corner.y * halfSize.y, + boxCenter.z + corner.z * halfSize.z + ) + } + + // Update face handle positions + for (const handle of faceHandles) { + const dir = handle.userData.faceDirection as THREE.Vector3 + handle.position.set( + boxCenter.x + dir.x * halfSize.x, + boxCenter.y + dir.y * halfSize.y, + boxCenter.z + dir.z * halfSize.z + ) + } + + // Update bounding box wireframe + boxLine.position.copy(boxCenter) + boxLine.scale.copy(boxSize) + } + + function show(): void { + group.visible = true + } + + function hide(): void { + group.visible = false + } + + function setBounds(box: THREE.Box3): void { + currentBox.copy(box) + box.getCenter(boxCenter) + box.getSize(boxSize) + + // Ensure minimum size + if (boxSize.x < 0.1) boxSize.x = 0.1 + if (boxSize.y < 0.1) boxSize.y = 0.1 + if (boxSize.z < 0.1) boxSize.z = 0.1 + + updateHandlePositions() + } + + function getHoveredHandle(raycaster: THREE.Raycaster): ScaleHandleInfo | null { + if (!group.visible) return null + + const allHandles = [...cornerHandles, ...faceHandles] + const intersects = raycaster.intersectObjects(allHandles, false) + + if (intersects.length > 0) { + const hit = intersects[0]!.object + if (hit.userData?.isGizmoHandle && hit.userData?.type === 'scale') { + return { + type: hit.userData.handleType, + axis: hit.userData.axis, + corner: hit.userData.corner?.clone(), + face: hit.userData.face, + } + } + } + return null + } + + function setHighlightedHandle(handleInfo: ScaleHandleInfo | null): void { + // Reset previous highlight + if (highlightedHandle) { + const material = handleMaterials.get(highlightedHandle) + if (material) { + const axis = highlightedHandle.userData.axis as ScaleAxis + material.color.setHex(AXIS_COLORS[axis]) + } + highlightedHandle.scale.setScalar(1) + highlightedHandle = null + } + + if (!handleInfo) return + + // Find and highlight the matching handle + const allHandles = [...cornerHandles, ...faceHandles] + for (const handle of allHandles) { + const userData = handle.userData + if (userData.handleType === handleInfo.type) { + let matches = false + if (handleInfo.type === 'corner' && handleInfo.corner) { + const corner = userData.corner as THREE.Vector3 + matches = corner.equals(handleInfo.corner) + } else if (handleInfo.type === 'face' && handleInfo.face) { + matches = userData.face === handleInfo.face + } + + if (matches) { + highlightedHandle = handle + const material = handleMaterials.get(handle) + if (material) { + const axis = userData.axis as ScaleAxis + material.color.setHex(HIGHLIGHT_COLORS[axis]) + } + handle.scale.setScalar(highlightScale) + break + } + } + } + } + + function updateScale(camera: THREE.Camera): void { + if (!group.visible) return + + // Make handles screen-size invariant + // handleSize determines the target screen size (as a fraction of view) + const distance = camera.position.distanceTo(boxCenter) + + let scale: number + if (camera instanceof THREE.PerspectiveCamera) { + const vFov = (camera.fov * Math.PI) / 180 + const worldHeight = 2 * Math.tan(vFov / 2) * distance + // handleSize controls how big handles appear on screen + scale = (worldHeight * handleSize) / 2 + } else if (camera instanceof THREE.OrthographicCamera) { + const viewHeight = camera.top - camera.bottom + scale = (viewHeight * handleSize) / 2 + } else { + scale = handleSize + } + + // Scale all handles uniformly for screen-invariant size + for (const handle of [...cornerHandles, ...faceHandles]) { + const isHighlighted = handle === highlightedHandle + const baseScale = isHighlighted ? highlightScale : 1 + handle.scale.setScalar(baseScale * scale) + } + } + + function dispose(): void { + boxGeometry.dispose() + boxEdges.dispose() + boxLineMaterial.dispose() + cornerGeometry.dispose() + faceGeometry.dispose() + + for (const material of handleMaterials.values()) { + material.dispose() + } + } + + return { + group, + show, + hide, + setBounds, + getHoveredHandle, + setHighlightedHandle, + updateScale, + dispose, + } +} + +export interface ScaleDragState { + isDragging: boolean + handleInfo: ScaleHandleInfo | null + isRightDrag: boolean + startMousePosition: THREE.Vector2 + startBounds: THREE.Box3 + startMeshScales: Map + startMeshPositions: Map + handleWorldPosition: THREE.Vector3 // World position of the handle being dragged +} + +export function createScaleDragState(): ScaleDragState { + return { + isDragging: false, + handleInfo: null, + isRightDrag: false, + startMousePosition: new THREE.Vector2(), + startBounds: new THREE.Box3(), + startMeshScales: new Map(), + startMeshPositions: new Map(), + handleWorldPosition: new THREE.Vector3(), + } +} + +/** + * Clamp scale factor to avoid degenerate cases (zero scale). + * Allows negative values for object flipping/inversion. + */ +function clampScaleFactor(factor: number): number { + const minAbsScale = 0.01 + if (Math.abs(factor) < minAbsScale) { + // If near zero, push to the minimum on the side we were approaching from + return factor >= 0 ? minAbsScale : -minAbsScale + } + return factor +} + +/** + * Calculate scale factor based on mouse movement. + * For face handles with right-drag (symmetrical), uses axis-projected movement. + * For corner handles with right-drag (uniform), uses diagonal movement. + */ +export function getScaleFactor( + mouse: THREE.Vector2, + startMouse: THREE.Vector2, + camera: THREE.Camera, + center: THREE.Vector3, + handleInfo: ScaleHandleInfo, + handleWorldPosition: THREE.Vector3 +): THREE.Vector3 { + // Calculate mouse delta in screen space + const mouseDelta = mouse.clone().sub(startMouse) + + // Scale sensitivity + const sensitivity = 2.0 + + let baseFactor: number + + if (handleInfo.type === 'face' && handleInfo.face) { + // For face handles, project mouse movement onto the direction from center to handle + // First, get the screen-space direction from center to handle + const centerScreen = center.clone().project(camera) + const handleScreen = handleWorldPosition.clone().project(camera) + const handleDir = new THREE.Vector2( + handleScreen.x - centerScreen.x, + handleScreen.y - centerScreen.y + ).normalize() + + // Project mouse delta onto this direction + const projectedDelta = mouseDelta.dot(handleDir) * sensitivity + baseFactor = 1 + projectedDelta + } else { + // For corner handles (right-drag uniform scaling), use diagonal mouse movement + const deltaLength = mouseDelta.length() * sensitivity + const direction = mouseDelta.x + mouseDelta.y > 0 ? 1 : -1 + baseFactor = 1 + direction * deltaLength + } + + // Clamp to prevent zero scale, but allow negative for inversion + const clampedFactor = clampScaleFactor(baseFactor) + + // Apply scale based on handle type + if (handleInfo.type === 'corner') { + // Uniform scaling for corners + return new THREE.Vector3(clampedFactor, clampedFactor, clampedFactor) + } else { + // Axis-specific scaling for faces + const scale = new THREE.Vector3(1, 1, 1) + if (handleInfo.axis === 'x') scale.x = clampedFactor + else if (handleInfo.axis === 'y') scale.y = clampedFactor + else if (handleInfo.axis === 'z') scale.z = clampedFactor + return scale + } +} + +/** + * Calculate scale factor for free-form corner scaling (left-drag). + * Constrains scaling to a plane aligned to major world axes. + * The plane is chosen based on camera angle: + * - Vertical axis (Y) is always included + * - Horizontal axis (X or Z) is whichever is most perpendicular to camera view + * The corner handle follows the mouse position exactly on this plane. + */ +export function getFreeformScaleFactor( + mouse: THREE.Vector2, + camera: THREE.Camera, + anchor: THREE.Vector3, + handleWorldPosition: THREE.Vector3 +): THREE.Vector3 { + // Get camera's forward direction (where it's looking) + const cameraForward = new THREE.Vector3() + camera.getWorldDirection(cameraForward) + + // Project onto XZ plane to get horizontal look direction + const horizontalForward = new THREE.Vector2(cameraForward.x, cameraForward.z) + if (horizontalForward.length() > 0.001) { + horizontalForward.normalize() + } + + // Determine which horizontal axis (X or Z) is most perpendicular to camera + const dotX = Math.abs(horizontalForward.x) // How much we're looking along X + const dotZ = Math.abs(horizontalForward.y) // How much we're looking along Z + + // If looking more along X, Z is perpendicular. If looking more along Z, X is perpendicular. + const useXAxis = dotZ > dotX + + // Create a plane passing through the handle, with normal pointing toward camera + // The plane is defined by the axis we're NOT scaling (X or Z) + const planeNormal = useXAxis + ? new THREE.Vector3(0, 0, 1) // XY plane (scaling X and Y, Z fixed) + : new THREE.Vector3(1, 0, 0) // YZ plane (scaling Y and Z, X fixed) + + const plane = new THREE.Plane() + plane.setFromNormalAndCoplanarPoint(planeNormal, handleWorldPosition) + + // Cast a ray from camera through mouse position + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(mouse, camera) + + // Find intersection with the scaling plane + const intersection = new THREE.Vector3() + const hit = raycaster.ray.intersectPlane(plane, intersection) + + if (!hit) { + // Fallback if ray doesn't hit plane (e.g., parallel) + return new THREE.Vector3(1, 1, 1) + } + + // Calculate scale factors based on world-space positions + const anchorToHandle = handleWorldPosition.clone().sub(anchor) + const anchorToMouse = intersection.clone().sub(anchor) + + const result = new THREE.Vector3(1, 1, 1) + const minComponent = 0.001 + + // Y axis always scales + if (Math.abs(anchorToHandle.y) > minComponent) { + result.y = clampScaleFactor(anchorToMouse.y / anchorToHandle.y) + } + + // Scale X or Z based on which plane we're using + if (useXAxis) { + if (Math.abs(anchorToHandle.x) > minComponent) { + result.x = clampScaleFactor(anchorToMouse.x / anchorToHandle.x) + } + } else { + if (Math.abs(anchorToHandle.z) > minComponent) { + result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z) + } + } + + return result +}