diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index c30304ee9..fe079680b 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -40,6 +40,7 @@ import { createScaleDragState, getScaleFactor, getFreeformScaleFactor, + getFaceScaleFactor, type ScaleGizmo, type ScaleDragState, type ScaleHandleInfo, @@ -427,34 +428,61 @@ function onCanvasMouseMove(event: MouseEvent) { 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 - ) + // Calculate scale factor based on handle type and drag mode + let finalScaleFactor: THREE.Vector3 + + if (handleInfo.type === 'corner') { + if (scaleDragState.isRightDrag) { + // Right-drag: uniform scaling from center + finalScaleFactor = getScaleFactor( + mouse, + scaleDragState.startMousePosition, + activeCamera, + boundsCenter, + handleInfo, + scaleDragState.handleWorldPosition + ) + } else { + // Left-drag: free-form scaling where corner follows mouse + 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 + ) + finalScaleFactor = getFreeformScaleFactor( + mouse, + activeCamera, + anchor, + scaleDragState.handleWorldPosition + ) + } + } else { + // Face handle - use axis-constrained scaling that tracks mouse closely + 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 - // 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( + // Anchor point depends on drag mode + const anchor = scaleDragState.isRightDrag + ? boundsCenter.clone() // Symmetrical from center + : new THREE.Vector3( + // Opposite face + boundsCenter.x - faceDir.x * halfSize.x, + boundsCenter.y - faceDir.y * halfSize.y, + boundsCenter.z - faceDir.z * halfSize.z + ) + + finalScaleFactor = getFaceScaleFactor( mouse, activeCamera, anchor, - scaleDragState.handleWorldPosition + scaleDragState.handleWorldPosition, + handleInfo.axis as 'x' | 'y' | 'z' ) } diff --git a/src/composables/useScaleGizmo.ts b/src/composables/useScaleGizmo.ts index 3d6396887..9c61dfbf9 100644 --- a/src/composables/useScaleGizmo.ts +++ b/src/composables/useScaleGizmo.ts @@ -331,16 +331,15 @@ function clampScaleFactor(factor: number): number { /** * 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, + _camera: THREE.Camera, + _center: THREE.Vector3, handleInfo: ScaleHandleInfo, - handleWorldPosition: THREE.Vector3 + _handleWorldPosition: THREE.Vector3 ): THREE.Vector3 { // Calculate mouse delta in screen space const mouseDelta = mouse.clone().sub(startMouse) @@ -348,27 +347,10 @@ export function getScaleFactor( // 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 - } + // 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 + const baseFactor = 1 + direction * deltaLength // Clamp to prevent zero scale, but allow negative for inversion const clampedFactor = clampScaleFactor(baseFactor) @@ -387,6 +369,92 @@ export function getScaleFactor( } } +/** + * Calculate scale factor for face handles by finding the closest point + * on the axis line to the mouse ray. This makes the handle track the + * mouse as closely as possible while staying constrained to its axis. + */ +export function getFaceScaleFactor( + mouse: THREE.Vector2, + camera: THREE.Camera, + anchor: THREE.Vector3, + handleWorldPosition: THREE.Vector3, + axis: 'x' | 'y' | 'z' +): THREE.Vector3 { + // Get axis direction + const axisDir = new THREE.Vector3() + if (axis === 'x') axisDir.set(1, 0, 0) + else if (axis === 'y') axisDir.set(0, 1, 0) + else axisDir.set(0, 0, 1) + + // Cast a ray from camera through mouse position + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(mouse, camera) + + // Find closest point on axis line to the mouse ray + // The axis line passes through anchor in direction axisDir + const closestPoint = closestPointOnLineToRay(anchor, axisDir, raycaster.ray) + + // Calculate the signed distance from anchor to closest point along axis + const anchorToClosest = closestPoint.clone().sub(anchor) + const closestDist = anchorToClosest.dot(axisDir) + + // Calculate original distance from anchor to handle along axis + const anchorToHandle = handleWorldPosition.clone().sub(anchor) + const handleDist = anchorToHandle.dot(axisDir) + + // Scale factor is the ratio + let scaleFactor = 1.0 + if (Math.abs(handleDist) > 0.001) { + scaleFactor = closestDist / handleDist + } + + scaleFactor = clampScaleFactor(scaleFactor) + + // Apply to the correct axis + const result = new THREE.Vector3(1, 1, 1) + if (axis === 'x') result.x = scaleFactor + else if (axis === 'y') result.y = scaleFactor + else result.z = scaleFactor + + return result +} + +/** + * Find the closest point on a line to a ray. + * Line is defined by a point and direction. + * Returns the closest point on the line. + */ +function closestPointOnLineToRay( + linePoint: THREE.Vector3, + lineDir: THREE.Vector3, + ray: THREE.Ray +): THREE.Vector3 { + // Using the formula for closest points between two lines + // Line 1: P = linePoint + t * lineDir + // Line 2 (ray): Q = ray.origin + s * ray.direction + + const w0 = linePoint.clone().sub(ray.origin) + const a = lineDir.dot(lineDir) // Always 1 if normalized + const b = lineDir.dot(ray.direction) + const c = ray.direction.dot(ray.direction) // Always 1 if normalized + const d = lineDir.dot(w0) + const e = ray.direction.dot(w0) + + const denom = a * c - b * b + + let t: number + if (Math.abs(denom) < 0.0001) { + // Lines are parallel, just project linePoint onto ray and back + t = 0 + } else { + t = (b * e - c * d) / denom + } + + // Return point on line at parameter t + return linePoint.clone().add(lineDir.clone().multiplyScalar(t)) +} + /** * Calculate scale factor for free-form corner scaling (left-drag). * Constrains scaling to a plane aligned to major world axes.