Fix XZ-constrained corner scaling
This commit is contained in:
parent
17242f5a69
commit
e93b63bb9d
1 changed files with 99 additions and 81 deletions
|
|
@ -370,9 +370,16 @@ export function getScaleFactor(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate scale factor for face handles by finding the closest point
|
* Calculate scale factor for face handles by intersecting with a constraint plane.
|
||||||
* on the axis line to the mouse ray. This makes the handle track the
|
* The constraint plane must CONTAIN the scale axis, and should be as perpendicular
|
||||||
* mouse as closely as possible while staying constrained to its axis.
|
* 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(
|
export function getFaceScaleFactor(
|
||||||
mouse: THREE.Vector2,
|
mouse: THREE.Vector2,
|
||||||
|
|
@ -381,23 +388,62 @@ export function getFaceScaleFactor(
|
||||||
handleWorldPosition: THREE.Vector3,
|
handleWorldPosition: THREE.Vector3,
|
||||||
axis: 'x' | 'y' | 'z'
|
axis: 'x' | 'y' | 'z'
|
||||||
): THREE.Vector3 {
|
): THREE.Vector3 {
|
||||||
// Get axis direction
|
// Get axis direction for the face being scaled
|
||||||
const axisDir = new THREE.Vector3()
|
const axisDir = new THREE.Vector3()
|
||||||
if (axis === 'x') axisDir.set(1, 0, 0)
|
if (axis === 'x') axisDir.set(1, 0, 0)
|
||||||
else if (axis === 'y') axisDir.set(0, 1, 0)
|
else if (axis === 'y') axisDir.set(0, 1, 0)
|
||||||
else axisDir.set(0, 0, 1)
|
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
|
// Cast a ray from camera through mouse position
|
||||||
const raycaster = new THREE.Raycaster()
|
const raycaster = new THREE.Raycaster()
|
||||||
raycaster.setFromCamera(mouse, camera)
|
raycaster.setFromCamera(mouse, camera)
|
||||||
|
|
||||||
// Find closest point on axis line to the mouse ray
|
// Intersect with constraint plane
|
||||||
// The axis line passes through anchor in direction axisDir
|
const intersection = new THREE.Vector3()
|
||||||
const closestPoint = closestPointOnLineToRay(anchor, axisDir, raycaster.ray)
|
const hit = raycaster.ray.intersectPlane(plane, intersection)
|
||||||
|
|
||||||
// Calculate the signed distance from anchor to closest point along axis
|
if (!hit) {
|
||||||
const anchorToClosest = closestPoint.clone().sub(anchor)
|
return new THREE.Vector3(1, 1, 1)
|
||||||
const closestDist = anchorToClosest.dot(axisDir)
|
}
|
||||||
|
|
||||||
|
// 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
|
// Calculate original distance from anchor to handle along axis
|
||||||
const anchorToHandle = handleWorldPosition.clone().sub(anchor)
|
const anchorToHandle = handleWorldPosition.clone().sub(anchor)
|
||||||
|
|
@ -406,7 +452,7 @@ export function getFaceScaleFactor(
|
||||||
// Scale factor is the ratio
|
// Scale factor is the ratio
|
||||||
let scaleFactor = 1.0
|
let scaleFactor = 1.0
|
||||||
if (Math.abs(handleDist) > 0.001) {
|
if (Math.abs(handleDist) > 0.001) {
|
||||||
scaleFactor = closestDist / handleDist
|
scaleFactor = targetDist / handleDist
|
||||||
}
|
}
|
||||||
|
|
||||||
scaleFactor = clampScaleFactor(scaleFactor)
|
scaleFactor = clampScaleFactor(scaleFactor)
|
||||||
|
|
@ -420,48 +466,15 @@ export function getFaceScaleFactor(
|
||||||
return result
|
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).
|
* Calculate scale factor for free-form corner scaling (left-drag).
|
||||||
* Constrains scaling to a plane aligned to major world axes.
|
* Constrains scaling to a plane aligned to major world axes.
|
||||||
* The plane is chosen based on camera angle:
|
* The plane is determined by which direction the camera is looking:
|
||||||
* - Vertical axis (Y) is always included
|
* - Looking along X (from sides): scale Y and Z (YZ plane)
|
||||||
* - Horizontal axis (X or Z) is whichever is most perpendicular to camera view
|
* - Looking along Y (from above/below): scale X and Z (XZ plane)
|
||||||
* The corner handle follows the mouse position exactly on this 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(
|
export function getFreeformScaleFactor(
|
||||||
mouse: THREE.Vector2,
|
mouse: THREE.Vector2,
|
||||||
|
|
@ -470,28 +483,38 @@ export function getFreeformScaleFactor(
|
||||||
handleWorldPosition: THREE.Vector3
|
handleWorldPosition: THREE.Vector3
|
||||||
): THREE.Vector3 {
|
): THREE.Vector3 {
|
||||||
// Get camera's forward direction (where it's looking)
|
// Get camera's forward direction (where it's looking)
|
||||||
const cameraForward = new THREE.Vector3()
|
const viewDir = new THREE.Vector3()
|
||||||
camera.getWorldDirection(cameraForward)
|
camera.getWorldDirection(viewDir)
|
||||||
|
|
||||||
// Project onto XZ plane to get horizontal look direction
|
// Determine which axis the camera is most aligned with
|
||||||
const horizontalForward = new THREE.Vector2(cameraForward.x, cameraForward.z)
|
const dotX = Math.abs(viewDir.x)
|
||||||
if (horizontalForward.length() > 0.001) {
|
const dotY = Math.abs(viewDir.y)
|
||||||
horizontalForward.normalize()
|
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
|
// Create the constraint plane passing through the handle
|
||||||
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()
|
const plane = new THREE.Plane()
|
||||||
plane.setFromNormalAndCoplanarPoint(planeNormal, handleWorldPosition)
|
plane.setFromNormalAndCoplanarPoint(planeNormal, handleWorldPosition)
|
||||||
|
|
||||||
|
|
@ -515,20 +538,15 @@ export function getFreeformScaleFactor(
|
||||||
const result = new THREE.Vector3(1, 1, 1)
|
const result = new THREE.Vector3(1, 1, 1)
|
||||||
const minComponent = 0.001
|
const minComponent = 0.001
|
||||||
|
|
||||||
// Y axis always scales
|
// Scale only the axes in the constraint plane
|
||||||
if (Math.abs(anchorToHandle.y) > minComponent) {
|
if (scaleX && Math.abs(anchorToHandle.x) > 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)
|
result.x = clampScaleFactor(anchorToMouse.x / anchorToHandle.x)
|
||||||
}
|
}
|
||||||
} else {
|
if (scaleY && Math.abs(anchorToHandle.y) > minComponent) {
|
||||||
if (Math.abs(anchorToHandle.z) > minComponent) {
|
result.y = clampScaleFactor(anchorToMouse.y / anchorToHandle.y)
|
||||||
result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z)
|
|
||||||
}
|
}
|
||||||
|
if (scaleZ && Math.abs(anchorToHandle.z) > minComponent) {
|
||||||
|
result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue