Fix XZ-constrained corner scaling

This commit is contained in:
Nettika 2026-01-29 23:03:40 -08:00
parent 17242f5a69
commit e93b63bb9d
No known key found for this signature in database

View file

@ -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