Improve mouse tracking when stretching from face

This commit is contained in:
Nettika 2026-01-29 22:52:11 -08:00
parent bd3d60ccf1
commit 17242f5a69
No known key found for this signature in database
2 changed files with 143 additions and 47 deletions

View file

@ -40,6 +40,7 @@ import {
createScaleDragState, createScaleDragState,
getScaleFactor, getScaleFactor,
getFreeformScaleFactor, getFreeformScaleFactor,
getFaceScaleFactor,
type ScaleGizmo, type ScaleGizmo,
type ScaleDragState, type ScaleDragState,
type ScaleHandleInfo, type ScaleHandleInfo,
@ -427,8 +428,13 @@ function onCanvasMouseMove(event: MouseEvent) {
const boundsCenter = startBounds.getCenter(new THREE.Vector3()) const boundsCenter = startBounds.getCenter(new THREE.Vector3())
const boundsSize = startBounds.getSize(new THREE.Vector3()) const boundsSize = startBounds.getSize(new THREE.Vector3())
// Calculate scale factor based on mouse movement // Calculate scale factor based on handle type and drag mode
const scaleFactor = getScaleFactor( let finalScaleFactor: THREE.Vector3
if (handleInfo.type === 'corner') {
if (scaleDragState.isRightDrag) {
// Right-drag: uniform scaling from center
finalScaleFactor = getScaleFactor(
mouse, mouse,
scaleDragState.startMousePosition, scaleDragState.startMousePosition,
activeCamera, activeCamera,
@ -436,11 +442,8 @@ function onCanvasMouseMove(event: MouseEvent) {
handleInfo, handleInfo,
scaleDragState.handleWorldPosition scaleDragState.handleWorldPosition
) )
} else {
// For corner handles with left-drag, use free-form scaling where corner follows mouse // Left-drag: 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 corner = handleInfo.corner!
const halfSize = boundsSize.clone().multiplyScalar(0.5) const halfSize = boundsSize.clone().multiplyScalar(0.5)
const anchor = new THREE.Vector3( const anchor = new THREE.Vector3(
@ -448,8 +451,6 @@ function onCanvasMouseMove(event: MouseEvent) {
boundsCenter.y - corner.y * halfSize.y, boundsCenter.y - corner.y * halfSize.y,
boundsCenter.z - corner.z * halfSize.z boundsCenter.z - corner.z * halfSize.z
) )
// Use free-form scaling that makes handle follow mouse position
finalScaleFactor = getFreeformScaleFactor( finalScaleFactor = getFreeformScaleFactor(
mouse, mouse,
activeCamera, activeCamera,
@ -457,6 +458,33 @@ function onCanvasMouseMove(event: MouseEvent) {
scaleDragState.handleWorldPosition 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
const halfSize = boundsSize.clone().multiplyScalar(0.5)
// 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,
handleInfo.axis as 'x' | 'y' | 'z'
)
}
// Apply scale to all selected objects // Apply scale to all selected objects
for (const mesh of selectedMeshes) { for (const mesh of selectedMeshes) {

View file

@ -331,16 +331,15 @@ function clampScaleFactor(factor: number): number {
/** /**
* Calculate scale factor based on mouse movement. * 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. * For corner handles with right-drag (uniform), uses diagonal movement.
*/ */
export function getScaleFactor( export function getScaleFactor(
mouse: THREE.Vector2, mouse: THREE.Vector2,
startMouse: THREE.Vector2, startMouse: THREE.Vector2,
camera: THREE.Camera, _camera: THREE.Camera,
center: THREE.Vector3, _center: THREE.Vector3,
handleInfo: ScaleHandleInfo, handleInfo: ScaleHandleInfo,
handleWorldPosition: THREE.Vector3 _handleWorldPosition: THREE.Vector3
): THREE.Vector3 { ): THREE.Vector3 {
// Calculate mouse delta in screen space // Calculate mouse delta in screen space
const mouseDelta = mouse.clone().sub(startMouse) const mouseDelta = mouse.clone().sub(startMouse)
@ -348,27 +347,10 @@ export function getScaleFactor(
// Scale sensitivity // Scale sensitivity
const sensitivity = 2.0 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 // For corner handles (right-drag uniform scaling), use diagonal mouse movement
const deltaLength = mouseDelta.length() * sensitivity const deltaLength = mouseDelta.length() * sensitivity
const direction = mouseDelta.x + mouseDelta.y > 0 ? 1 : -1 const direction = mouseDelta.x + mouseDelta.y > 0 ? 1 : -1
baseFactor = 1 + direction * deltaLength const baseFactor = 1 + direction * deltaLength
}
// Clamp to prevent zero scale, but allow negative for inversion // Clamp to prevent zero scale, but allow negative for inversion
const clampedFactor = clampScaleFactor(baseFactor) 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). * 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.