From e93b63bb9db23c7ceb598697008133499b7d9b00 Mon Sep 17 00:00:00 2001 From: Nettika Date: Thu, 29 Jan 2026 23:03:40 -0800 Subject: [PATCH] Fix XZ-constrained corner scaling --- src/composables/useScaleGizmo.ts | 180 +++++++++++++++++-------------- 1 file changed, 99 insertions(+), 81 deletions(-) diff --git a/src/composables/useScaleGizmo.ts b/src/composables/useScaleGizmo.ts index 9c61dfbf9..5e4248114 100644 --- a/src/composables/useScaleGizmo.ts +++ b/src/composables/useScaleGizmo.ts @@ -370,9 +370,16 @@ 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. + * Calculate scale factor for face handles by intersecting with a constraint plane. + * The constraint plane must CONTAIN the scale axis, and should be as perpendicular + * to the camera view as possible for stable mouse tracking. + * + * For each scale axis, there are two planes containing it: + * - X axis: XY plane (normal=Z) or XZ plane (normal=Y) + * - Y axis: XY plane (normal=Z) or YZ plane (normal=X) + * - Z axis: XZ plane (normal=Y) or YZ plane (normal=X) + * + * We pick the plane whose normal is more aligned with the camera view. */ export function getFaceScaleFactor( mouse: THREE.Vector2, @@ -381,23 +388,62 @@ export function getFaceScaleFactor( handleWorldPosition: THREE.Vector3, axis: 'x' | 'y' | 'z' ): THREE.Vector3 { - // Get axis direction + // Get axis direction for the face being scaled 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) + // Get camera view direction + const viewDir = new THREE.Vector3() + camera.getWorldDirection(viewDir) + + // Choose constraint plane that contains the scale axis and is most perpendicular to camera + let planeNormal: THREE.Vector3 + + if (axis === 'x') { + // Planes containing X: XY (normal=Z) and XZ (normal=Y) + // Pick the one whose normal is more aligned with view direction + if (Math.abs(viewDir.y) > Math.abs(viewDir.z)) { + planeNormal = new THREE.Vector3(0, 1, 0) // XZ plane + } else { + planeNormal = new THREE.Vector3(0, 0, 1) // XY plane + } + } else if (axis === 'y') { + // Planes containing Y: XY (normal=Z) and YZ (normal=X) + if (Math.abs(viewDir.x) > Math.abs(viewDir.z)) { + planeNormal = new THREE.Vector3(1, 0, 0) // YZ plane + } else { + planeNormal = new THREE.Vector3(0, 0, 1) // XY plane + } + } else { + // Planes containing Z: XZ (normal=Y) and YZ (normal=X) + if (Math.abs(viewDir.x) > Math.abs(viewDir.y)) { + planeNormal = new THREE.Vector3(1, 0, 0) // YZ plane + } else { + planeNormal = new THREE.Vector3(0, 1, 0) // XZ plane + } + } + + // Create the constraint plane passing through the handle + 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 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) + // Intersect with constraint plane + const intersection = new THREE.Vector3() + const hit = raycaster.ray.intersectPlane(plane, intersection) - // Calculate the signed distance from anchor to closest point along axis - const anchorToClosest = closestPoint.clone().sub(anchor) - const closestDist = anchorToClosest.dot(axisDir) + if (!hit) { + return new THREE.Vector3(1, 1, 1) + } + + // Project the intersection onto the scale axis + const anchorToIntersection = intersection.clone().sub(anchor) + const targetDist = anchorToIntersection.dot(axisDir) // Calculate original distance from anchor to handle along axis const anchorToHandle = handleWorldPosition.clone().sub(anchor) @@ -406,7 +452,7 @@ export function getFaceScaleFactor( // Scale factor is the ratio let scaleFactor = 1.0 if (Math.abs(handleDist) > 0.001) { - scaleFactor = closestDist / handleDist + scaleFactor = targetDist / handleDist } scaleFactor = clampScaleFactor(scaleFactor) @@ -420,48 +466,15 @@ export function getFaceScaleFactor( 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. - * 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. + * The plane is determined by which direction the camera is looking: + * - Looking along X (from sides): scale Y and Z (YZ plane) + * - Looking along Y (from above/below): scale X and Z (XZ plane) + * - Looking along Z (from front/back): scale X and Y (XY plane) + * + * This creates 6 pyramid-shaped zones extending from the object. */ export function getFreeformScaleFactor( mouse: THREE.Vector2, @@ -470,28 +483,38 @@ export function getFreeformScaleFactor( handleWorldPosition: THREE.Vector3 ): THREE.Vector3 { // Get camera's forward direction (where it's looking) - const cameraForward = new THREE.Vector3() - camera.getWorldDirection(cameraForward) + const viewDir = new THREE.Vector3() + camera.getWorldDirection(viewDir) - // 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 axis the camera is most aligned with + const dotX = Math.abs(viewDir.x) + const dotY = Math.abs(viewDir.y) + const dotZ = Math.abs(viewDir.z) + + // Choose the constraint plane perpendicular to the dominant view axis + let planeNormal: THREE.Vector3 + let scaleX = false + let scaleY = false + let scaleZ = false + + if (dotX >= dotY && dotX >= dotZ) { + // Looking mostly along X - use YZ plane (scale Y and Z) + planeNormal = new THREE.Vector3(1, 0, 0) + scaleY = true + scaleZ = true + } else if (dotY >= dotX && dotY >= dotZ) { + // Looking mostly along Y - use XZ plane (scale X and Z) + planeNormal = new THREE.Vector3(0, 1, 0) + scaleX = true + scaleZ = true + } else { + // Looking mostly along Z - use XY plane (scale X and Y) + planeNormal = new THREE.Vector3(0, 0, 1) + scaleX = true + scaleY = true } - // 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) - + // Create the constraint plane passing through the handle const plane = new THREE.Plane() plane.setFromNormalAndCoplanarPoint(planeNormal, handleWorldPosition) @@ -515,20 +538,15 @@ export function getFreeformScaleFactor( const result = new THREE.Vector3(1, 1, 1) const minComponent = 0.001 - // Y axis always scales - if (Math.abs(anchorToHandle.y) > minComponent) { + // Scale only the axes in the constraint plane + if (scaleX && Math.abs(anchorToHandle.x) > minComponent) { + result.x = clampScaleFactor(anchorToMouse.x / anchorToHandle.x) + } + if (scaleY && 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) - } + if (scaleZ && Math.abs(anchorToHandle.z) > minComponent) { + result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z) } return result