Add scale mode

This commit is contained in:
Nettika 2026-01-29 22:34:59 -08:00
parent 3c139fe58d
commit 56295c8c9d
No known key found for this signature in database
3 changed files with 741 additions and 14 deletions

18
TODO.md
View file

@ -129,15 +129,15 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
- [x] Track object rotation state for world-oriented snapping - [x] Track object rotation state for world-oriented snapping
### Scale Mode (Ctrl+Shift Modifier) ### Scale Mode (Ctrl+Shift Modifier)
- [ ] Create bounding box visualization for selection - [x] Create bounding box visualization for selection
- [ ] Show scale handles when Ctrl+Shift held with selection - [x] Show scale handles when Ctrl+Shift held with selection
- [ ] Corner handles: white cubes for uniform scaling - [x] Corner handles: white cubes for uniform scaling
- [ ] Left-drag: free scale - [x] Left-drag: free scale
- [ ] Right-drag: symmetrical proportional scale from center - [x] Right-drag: symmetrical proportional scale from center
- [ ] Face center handles: colored cubes (by axis) for stretch - [x] Face center handles: colored cubes (by axis) for stretch
- [ ] Left-drag: one-directional stretch along axis - [x] Left-drag: one-directional stretch along axis
- [ ] Right-drag: symmetrical stretch from center along axis - [x] Right-drag: symmetrical stretch from center along axis
- [ ] Hover effect: handles grow subtly and brighten - [x] Hover effect: handles grow subtly and brighten
### Camera Mode (Alt Modifier) ### Camera Mode (Alt Modifier)
- [ ] Left-drag: orbit camera - [ ] Left-drag: orbit camera

View file

@ -35,6 +35,15 @@ import {
type RotateGizmo, type RotateGizmo,
type RotateDragState, type RotateDragState,
} from '../composables/useRotateGizmo' } from '../composables/useRotateGizmo'
import {
createScaleGizmo,
createScaleDragState,
getScaleFactor,
getFreeformScaleFactor,
type ScaleGizmo,
type ScaleDragState,
type ScaleHandleInfo,
} from '../composables/useScaleGizmo'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -74,10 +83,14 @@ let hoverOutlinePass: OutlinePass
let hoveredObject: SceneObject | null = null let hoveredObject: SceneObject | null = null
let translateGizmo: TranslateGizmo | null = null let translateGizmo: TranslateGizmo | null = null
let rotateGizmo: RotateGizmo | null = null let rotateGizmo: RotateGizmo | null = null
let scaleGizmo: ScaleGizmo | null = null
let dragState: DragState = createDragState() let dragState: DragState = createDragState()
let rotateDragState: RotateDragState = createRotateDragState() let rotateDragState: RotateDragState = createRotateDragState()
let scaleDragState: ScaleDragState = createScaleDragState()
let hoveredAxis: 'x' | 'y' | 'z' | null = null let hoveredAxis: 'x' | 'y' | 'z' | null = null
let hoveredScaleHandle: ScaleHandleInfo | null = null
let isCtrlPressed = false let isCtrlPressed = false
let isShiftPressed = false
function initScene() { function initScene() {
if (!containerRef.value) return if (!containerRef.value) return
@ -146,6 +159,9 @@ function setupGizmos() {
rotateGizmo = createRotateGizmo() rotateGizmo = createRotateGizmo()
scene.add(rotateGizmo.group) scene.add(rotateGizmo.group)
scaleGizmo = createScaleGizmo()
scene.add(scaleGizmo.group)
} }
function setupPostProcessing() { function setupPostProcessing() {
@ -262,15 +278,31 @@ function cleanupKeyboardHandlers() {
} }
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
let needsUpdate = false
if (event.key === 'Control' && !isCtrlPressed) { if (event.key === 'Control' && !isCtrlPressed) {
isCtrlPressed = true isCtrlPressed = true
needsUpdate = true
}
if (event.key === 'Shift' && !isShiftPressed) {
isShiftPressed = true
needsUpdate = true
}
if (needsUpdate) {
updateGizmoVisibility() updateGizmoVisibility()
} }
} }
function onKeyUp(event: KeyboardEvent) { function onKeyUp(event: KeyboardEvent) {
let needsUpdate = false
if (event.key === 'Control' && isCtrlPressed) { if (event.key === 'Control' && isCtrlPressed) {
isCtrlPressed = false isCtrlPressed = false
needsUpdate = true
}
if (event.key === 'Shift' && isShiftPressed) {
isShiftPressed = false
needsUpdate = true
}
if (needsUpdate) {
updateGizmoVisibility() updateGizmoVisibility()
} }
} }
@ -281,15 +313,26 @@ function updateGizmoVisibility() {
if (!hasSelection) { if (!hasSelection) {
translateGizmo?.hide() translateGizmo?.hide()
rotateGizmo?.hide() rotateGizmo?.hide()
scaleGizmo?.hide()
return return
} }
if (isCtrlPressed) { if (isCtrlPressed && isShiftPressed) {
// Scale mode: Ctrl+Shift
translateGizmo?.hide() translateGizmo?.hide()
rotateGizmo?.hide()
scaleGizmo?.show()
updateScaleGizmoPosition()
} else if (isCtrlPressed) {
// Rotate mode: Ctrl only
translateGizmo?.hide()
scaleGizmo?.hide()
rotateGizmo?.show() rotateGizmo?.show()
updateRotateGizmoPosition() updateRotateGizmoPosition()
} else { } else {
// Translate mode: default
rotateGizmo?.hide() rotateGizmo?.hide()
scaleGizmo?.hide()
translateGizmo?.show() translateGizmo?.show()
updateGizmoPosition() updateGizmoPosition()
} }
@ -374,6 +417,112 @@ function onCanvasMouseMove(event: MouseEvent) {
return return
} }
// Handle scale gizmo dragging
if (scaleDragState.isDragging && scaleDragState.handleInfo && scaleGizmo) {
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
if (selectedMeshes.length === 0) return
const handleInfo = scaleDragState.handleInfo
const startBounds = scaleDragState.startBounds
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
)
// 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(
mouse,
activeCamera,
anchor,
scaleDragState.handleWorldPosition
)
}
// Apply scale to all selected objects
for (const mesh of selectedMeshes) {
const startScale = scaleDragState.startMeshScales.get(mesh.uuid)
const startPos = scaleDragState.startMeshPositions.get(mesh.uuid)
if (!startScale || !startPos) continue
// Apply new scale
mesh.scale.set(
startScale.x * finalScaleFactor.x,
startScale.y * finalScaleFactor.y,
startScale.z * finalScaleFactor.z
)
if (handleInfo.type === 'corner') {
// For corner handles, use the opposite corner as anchor
const corner = handleInfo.corner!
const halfSize = boundsSize.clone().multiplyScalar(0.5)
// Opposite corner is at -corner direction
const anchor = new THREE.Vector3(
boundsCenter.x - corner.x * halfSize.x,
boundsCenter.y - corner.y * halfSize.y,
boundsCenter.z - corner.z * halfSize.z
)
// Scale position relative to anchor
const offsetFromAnchor = startPos.clone().sub(anchor)
const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor)
mesh.position.copy(anchor).add(newOffset)
} else if (handleInfo.type === 'face') {
if (scaleDragState.isRightDrag) {
// Symmetrical scaling from center for right-drag
const offsetFromCenter = startPos.clone().sub(boundsCenter)
const newOffset = offsetFromCenter.clone().multiply(finalScaleFactor)
mesh.position.copy(boundsCenter).add(newOffset)
} else {
// One-directional stretch from opposite face for left-drag
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
// Anchor is at the opposite face
const halfSize = boundsSize.clone().multiplyScalar(0.5)
const anchor = new THREE.Vector3(
boundsCenter.x - faceDir.x * halfSize.x,
boundsCenter.y - faceDir.y * halfSize.y,
boundsCenter.z - faceDir.z * halfSize.z
)
// Scale position relative to anchor
const offsetFromAnchor = startPos.clone().sub(anchor)
const newOffset = offsetFromAnchor.clone().multiply(finalScaleFactor)
mesh.position.copy(anchor).add(newOffset)
}
}
}
// Update scale gizmo bounds
updateScaleGizmoPosition()
return
}
// Handle rotate gizmo dragging // Handle rotate gizmo dragging
if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) { if (rotateDragState.isDragging && rotateDragState.axis && rotateGizmo) {
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh) const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
@ -462,7 +611,21 @@ function onCanvasMouseMove(event: MouseEvent) {
// Handle gizmo hover highlight // Handle gizmo hover highlight
raycaster.setFromCamera(mouse, activeCamera) raycaster.setFromCamera(mouse, activeCamera)
if (isCtrlPressed && rotateGizmo?.group.visible) { if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
const newHoveredHandle = scaleGizmo.getHoveredHandle(raycaster)
// Compare handle info (can't use simple !== due to object comparison)
const handleChanged =
!newHoveredHandle !== !hoveredScaleHandle ||
(newHoveredHandle &&
hoveredScaleHandle &&
(newHoveredHandle.type !== hoveredScaleHandle.type ||
newHoveredHandle.face !== hoveredScaleHandle.face ||
!newHoveredHandle.corner?.equals(hoveredScaleHandle.corner ?? new THREE.Vector3())))
if (handleChanged) {
hoveredScaleHandle = newHoveredHandle
scaleGizmo.setHighlightedHandle(hoveredScaleHandle)
}
} else if (isCtrlPressed && rotateGizmo?.group.visible) {
const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster) const newHoveredAxis = rotateGizmo.getHoveredAxis(raycaster)
if (newHoveredAxis !== hoveredAxis) { if (newHoveredAxis !== hoveredAxis) {
hoveredAxis = newHoveredAxis hoveredAxis = newHoveredAxis
@ -486,6 +649,13 @@ function onCanvasMouseMove(event: MouseEvent) {
function onCanvasContextMenu(event: MouseEvent) { function onCanvasContextMenu(event: MouseEvent) {
event.preventDefault() event.preventDefault()
event.stopPropagation()
// Don't show context menu when in scale mode (Ctrl+Shift held)
if (isCtrlPressed && isShiftPressed) {
return
}
const pickedObject = pickObject(event) const pickedObject = pickObject(event)
emit('contextmenu', pickedObject, event) emit('contextmenu', pickedObject, event)
} }
@ -494,8 +664,65 @@ function onCanvasMouseDown(event: MouseEvent) {
updateMouseCoordinates(event) updateMouseCoordinates(event)
raycaster.setFromCamera(mouse, activeCamera) raycaster.setFromCamera(mouse, activeCamera)
// Check for rotation gizmo first (when Ctrl is held) // Check for scale gizmo first (when Ctrl+Shift is held)
if (isCtrlPressed && rotateGizmo?.group.visible) { if (isCtrlPressed && isShiftPressed && scaleGizmo?.group.visible) {
const handleInfo = scaleGizmo.getHoveredHandle(raycaster)
if (handleInfo) {
event.preventDefault()
event.stopPropagation()
controls.enabled = false
scaleDragState.isDragging = true
scaleDragState.handleInfo = handleInfo
scaleDragState.isRightDrag = event.button === 2
scaleDragState.startMousePosition.copy(mouse)
// Store starting scales and positions for all selected objects
const selectedMeshes = sceneStore.selectedObjects.map((o) => o.mesh)
scaleDragState.startMeshScales.clear()
scaleDragState.startMeshPositions.clear()
for (const mesh of selectedMeshes) {
scaleDragState.startMeshScales.set(mesh.uuid, mesh.scale.clone())
scaleDragState.startMeshPositions.set(mesh.uuid, mesh.position.clone())
}
// Store the starting bounding box
const box = new THREE.Box3()
for (const obj of sceneStore.selectedObjects) {
box.expandByObject(obj.mesh)
}
scaleDragState.startBounds.copy(box)
// Calculate and store the handle world position
const boundsCenter = box.getCenter(new THREE.Vector3())
const boundsSize = box.getSize(new THREE.Vector3())
const halfSize = boundsSize.clone().multiplyScalar(0.5)
if (handleInfo.type === 'corner' && handleInfo.corner) {
scaleDragState.handleWorldPosition.set(
boundsCenter.x + handleInfo.corner.x * halfSize.x,
boundsCenter.y + handleInfo.corner.y * halfSize.y,
boundsCenter.z + handleInfo.corner.z * halfSize.z
)
} else if (handleInfo.face) {
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
scaleDragState.handleWorldPosition.set(
boundsCenter.x + faceDir.x * halfSize.x,
boundsCenter.y + faceDir.y * halfSize.y,
boundsCenter.z + faceDir.z * halfSize.z
)
}
return
}
}
// Check for rotation gizmo (when Ctrl is held but not Shift)
if (isCtrlPressed && !isShiftPressed && rotateGizmo?.group.visible) {
const axis = rotateGizmo.getHoveredAxis(raycaster) const axis = rotateGizmo.getHoveredAxis(raycaster)
if (axis) { if (axis) {
event.preventDefault() event.preventDefault()
@ -581,6 +808,14 @@ function onCanvasMouseUp(_event: MouseEvent) {
rotateDragState.startObjectRotations.clear() rotateDragState.startObjectRotations.clear()
controls.enabled = true controls.enabled = true
} }
if (scaleDragState.isDragging) {
scaleDragState.isDragging = false
scaleDragState.handleInfo = null
scaleDragState.startMeshScales.clear()
scaleDragState.startMeshPositions.clear()
controls.enabled = true
}
} }
function onCanvasWheel(event: WheelEvent) { function onCanvasWheel(event: WheelEvent) {
@ -640,6 +875,9 @@ function animate() {
if (rotateGizmo) { if (rotateGizmo) {
rotateGizmo.updateScale(activeCamera) rotateGizmo.updateScale(activeCamera)
} }
if (scaleGizmo) {
scaleGizmo.updateScale(activeCamera)
}
composer.render() composer.render()
} }
@ -699,11 +937,33 @@ function updateRotateGizmoPosition() {
const center = box.getCenter(new THREE.Vector3()) const center = box.getCenter(new THREE.Vector3())
rotateGizmo.setPositionAndBounds(center, box) rotateGizmo.setPositionAndBounds(center, box)
if (isCtrlPressed) { if (isCtrlPressed && !isShiftPressed) {
rotateGizmo.show() rotateGizmo.show()
} }
} }
function updateScaleGizmoPosition() {
if (!scaleGizmo) return
const selected = sceneStore.selectedObjects
if (selected.length === 0) {
scaleGizmo.hide()
return
}
// Calculate bounding box of all selected objects
const box = new THREE.Box3()
for (const obj of selected) {
box.expandByObject(obj.mesh)
}
scaleGizmo.setBounds(box)
if (isCtrlPressed && isShiftPressed) {
scaleGizmo.show()
}
}
watch( watch(
() => sceneStore.selectedObjects, () => sceneStore.selectedObjects,
() => { () => {

View file

@ -0,0 +1,467 @@
import * as THREE from 'three'
export interface ScaleGizmoOptions {
handleSize?: number
highlightScale?: number
lineColor?: number
}
export interface ScaleGizmo {
group: THREE.Group
show: () => void
hide: () => void
setBounds: (box: THREE.Box3) => void
getHoveredHandle: (raycaster: THREE.Raycaster) => ScaleHandleInfo | null
setHighlightedHandle: (handle: ScaleHandleInfo | null) => void
updateScale: (camera: THREE.Camera) => void
dispose: () => void
}
export type ScaleHandleType = 'corner' | 'face'
export type ScaleAxis = 'x' | 'y' | 'z' | 'xyz'
export interface ScaleHandleInfo {
type: ScaleHandleType
axis: ScaleAxis
corner?: THREE.Vector3 // For corner handles, which corner (-1 or 1 for each axis)
face?: 'x+' | 'x-' | 'y+' | 'y-' | 'z+' | 'z-' // For face handles
}
const AXIS_COLORS = {
x: 0xe74c3c, // Red
y: 0x2ecc71, // Green
z: 0x3498db, // Blue
xyz: 0xffffff, // White for uniform
}
const HIGHLIGHT_COLORS = {
x: 0xff6b6b,
y: 0x5dff7f,
z: 0x5dafff,
xyz: 0xffffaa,
}
export function createScaleGizmo(options: ScaleGizmoOptions = {}): ScaleGizmo {
const { handleSize = 0.04, highlightScale = 1.3, lineColor = 0x888888 } = options
const group = new THREE.Group()
group.name = '__scaleGizmo__'
group.visible = false
// Store current bounds
const currentBox = new THREE.Box3()
const boxCenter = new THREE.Vector3()
const boxSize = new THREE.Vector3()
// Bounding box wireframe
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxEdges = new THREE.EdgesGeometry(boxGeometry)
const boxLineMaterial = new THREE.LineBasicMaterial({ color: lineColor })
const boxLine = new THREE.LineSegments(boxEdges, boxLineMaterial)
boxLine.name = '__scaleGizmo_box__'
group.add(boxLine)
// Store handles
const cornerHandles: THREE.Mesh[] = []
const faceHandles: THREE.Mesh[] = []
const handleMaterials: Map<THREE.Mesh, THREE.MeshBasicMaterial> = new Map()
// Track highlighted handle
let highlightedHandle: THREE.Mesh | null = null
// Create corner handles (8 corners)
const corners = [
new THREE.Vector3(-1, -1, -1),
new THREE.Vector3(-1, -1, 1),
new THREE.Vector3(-1, 1, -1),
new THREE.Vector3(-1, 1, 1),
new THREE.Vector3(1, -1, -1),
new THREE.Vector3(1, -1, 1),
new THREE.Vector3(1, 1, -1),
new THREE.Vector3(1, 1, 1),
]
// Create geometry at unit size - scaling happens in updateScale
const cornerGeometry = new THREE.BoxGeometry(1, 1, 1)
for (const corner of corners) {
const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS.xyz })
const handle = new THREE.Mesh(cornerGeometry, material)
handle.name = `__scaleGizmo_corner_${corner.x}_${corner.y}_${corner.z}__`
handle.userData = {
isGizmoHandle: true,
type: 'scale',
handleType: 'corner' as ScaleHandleType,
axis: 'xyz' as ScaleAxis,
corner: corner.clone(),
}
handleMaterials.set(handle, material)
cornerHandles.push(handle)
group.add(handle)
}
// Create face center handles (6 faces)
const faces: { position: THREE.Vector3; axis: 'x' | 'y' | 'z'; face: string }[] = [
{ position: new THREE.Vector3(1, 0, 0), axis: 'x', face: 'x+' },
{ position: new THREE.Vector3(-1, 0, 0), axis: 'x', face: 'x-' },
{ position: new THREE.Vector3(0, 1, 0), axis: 'y', face: 'y+' },
{ position: new THREE.Vector3(0, -1, 0), axis: 'y', face: 'y-' },
{ position: new THREE.Vector3(0, 0, 1), axis: 'z', face: 'z+' },
{ position: new THREE.Vector3(0, 0, -1), axis: 'z', face: 'z-' },
]
// Create geometry at unit size - scaling happens in updateScale
const faceGeometry = new THREE.BoxGeometry(1, 1, 1)
for (const faceInfo of faces) {
const material = new THREE.MeshBasicMaterial({ color: AXIS_COLORS[faceInfo.axis] })
const handle = new THREE.Mesh(faceGeometry, material)
handle.name = `__scaleGizmo_face_${faceInfo.face}__`
handle.userData = {
isGizmoHandle: true,
type: 'scale',
handleType: 'face' as ScaleHandleType,
axis: faceInfo.axis as ScaleAxis,
face: faceInfo.face,
faceDirection: faceInfo.position.clone(),
}
handleMaterials.set(handle, material)
faceHandles.push(handle)
group.add(handle)
}
function updateHandlePositions(): void {
const halfSize = boxSize.clone().multiplyScalar(0.5)
// Update corner handle positions
for (const handle of cornerHandles) {
const corner = handle.userData.corner as THREE.Vector3
handle.position.set(
boxCenter.x + corner.x * halfSize.x,
boxCenter.y + corner.y * halfSize.y,
boxCenter.z + corner.z * halfSize.z
)
}
// Update face handle positions
for (const handle of faceHandles) {
const dir = handle.userData.faceDirection as THREE.Vector3
handle.position.set(
boxCenter.x + dir.x * halfSize.x,
boxCenter.y + dir.y * halfSize.y,
boxCenter.z + dir.z * halfSize.z
)
}
// Update bounding box wireframe
boxLine.position.copy(boxCenter)
boxLine.scale.copy(boxSize)
}
function show(): void {
group.visible = true
}
function hide(): void {
group.visible = false
}
function setBounds(box: THREE.Box3): void {
currentBox.copy(box)
box.getCenter(boxCenter)
box.getSize(boxSize)
// Ensure minimum size
if (boxSize.x < 0.1) boxSize.x = 0.1
if (boxSize.y < 0.1) boxSize.y = 0.1
if (boxSize.z < 0.1) boxSize.z = 0.1
updateHandlePositions()
}
function getHoveredHandle(raycaster: THREE.Raycaster): ScaleHandleInfo | null {
if (!group.visible) return null
const allHandles = [...cornerHandles, ...faceHandles]
const intersects = raycaster.intersectObjects(allHandles, false)
if (intersects.length > 0) {
const hit = intersects[0]!.object
if (hit.userData?.isGizmoHandle && hit.userData?.type === 'scale') {
return {
type: hit.userData.handleType,
axis: hit.userData.axis,
corner: hit.userData.corner?.clone(),
face: hit.userData.face,
}
}
}
return null
}
function setHighlightedHandle(handleInfo: ScaleHandleInfo | null): void {
// Reset previous highlight
if (highlightedHandle) {
const material = handleMaterials.get(highlightedHandle)
if (material) {
const axis = highlightedHandle.userData.axis as ScaleAxis
material.color.setHex(AXIS_COLORS[axis])
}
highlightedHandle.scale.setScalar(1)
highlightedHandle = null
}
if (!handleInfo) return
// Find and highlight the matching handle
const allHandles = [...cornerHandles, ...faceHandles]
for (const handle of allHandles) {
const userData = handle.userData
if (userData.handleType === handleInfo.type) {
let matches = false
if (handleInfo.type === 'corner' && handleInfo.corner) {
const corner = userData.corner as THREE.Vector3
matches = corner.equals(handleInfo.corner)
} else if (handleInfo.type === 'face' && handleInfo.face) {
matches = userData.face === handleInfo.face
}
if (matches) {
highlightedHandle = handle
const material = handleMaterials.get(handle)
if (material) {
const axis = userData.axis as ScaleAxis
material.color.setHex(HIGHLIGHT_COLORS[axis])
}
handle.scale.setScalar(highlightScale)
break
}
}
}
}
function updateScale(camera: THREE.Camera): void {
if (!group.visible) return
// Make handles screen-size invariant
// handleSize determines the target screen size (as a fraction of view)
const distance = camera.position.distanceTo(boxCenter)
let scale: number
if (camera instanceof THREE.PerspectiveCamera) {
const vFov = (camera.fov * Math.PI) / 180
const worldHeight = 2 * Math.tan(vFov / 2) * distance
// handleSize controls how big handles appear on screen
scale = (worldHeight * handleSize) / 2
} else if (camera instanceof THREE.OrthographicCamera) {
const viewHeight = camera.top - camera.bottom
scale = (viewHeight * handleSize) / 2
} else {
scale = handleSize
}
// Scale all handles uniformly for screen-invariant size
for (const handle of [...cornerHandles, ...faceHandles]) {
const isHighlighted = handle === highlightedHandle
const baseScale = isHighlighted ? highlightScale : 1
handle.scale.setScalar(baseScale * scale)
}
}
function dispose(): void {
boxGeometry.dispose()
boxEdges.dispose()
boxLineMaterial.dispose()
cornerGeometry.dispose()
faceGeometry.dispose()
for (const material of handleMaterials.values()) {
material.dispose()
}
}
return {
group,
show,
hide,
setBounds,
getHoveredHandle,
setHighlightedHandle,
updateScale,
dispose,
}
}
export interface ScaleDragState {
isDragging: boolean
handleInfo: ScaleHandleInfo | null
isRightDrag: boolean
startMousePosition: THREE.Vector2
startBounds: THREE.Box3
startMeshScales: Map<string, THREE.Vector3>
startMeshPositions: Map<string, THREE.Vector3>
handleWorldPosition: THREE.Vector3 // World position of the handle being dragged
}
export function createScaleDragState(): ScaleDragState {
return {
isDragging: false,
handleInfo: null,
isRightDrag: false,
startMousePosition: new THREE.Vector2(),
startBounds: new THREE.Box3(),
startMeshScales: new Map(),
startMeshPositions: new Map(),
handleWorldPosition: new THREE.Vector3(),
}
}
/**
* Clamp scale factor to avoid degenerate cases (zero scale).
* Allows negative values for object flipping/inversion.
*/
function clampScaleFactor(factor: number): number {
const minAbsScale = 0.01
if (Math.abs(factor) < minAbsScale) {
// If near zero, push to the minimum on the side we were approaching from
return factor >= 0 ? minAbsScale : -minAbsScale
}
return factor
}
/**
* 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,
handleInfo: ScaleHandleInfo,
handleWorldPosition: THREE.Vector3
): THREE.Vector3 {
// Calculate mouse delta in screen space
const mouseDelta = mouse.clone().sub(startMouse)
// 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
}
// Clamp to prevent zero scale, but allow negative for inversion
const clampedFactor = clampScaleFactor(baseFactor)
// Apply scale based on handle type
if (handleInfo.type === 'corner') {
// Uniform scaling for corners
return new THREE.Vector3(clampedFactor, clampedFactor, clampedFactor)
} else {
// Axis-specific scaling for faces
const scale = new THREE.Vector3(1, 1, 1)
if (handleInfo.axis === 'x') scale.x = clampedFactor
else if (handleInfo.axis === 'y') scale.y = clampedFactor
else if (handleInfo.axis === 'z') scale.z = clampedFactor
return scale
}
}
/**
* 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.
*/
export function getFreeformScaleFactor(
mouse: THREE.Vector2,
camera: THREE.Camera,
anchor: THREE.Vector3,
handleWorldPosition: THREE.Vector3
): THREE.Vector3 {
// Get camera's forward direction (where it's looking)
const cameraForward = new THREE.Vector3()
camera.getWorldDirection(cameraForward)
// 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 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)
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 intersection with the scaling plane
const intersection = new THREE.Vector3()
const hit = raycaster.ray.intersectPlane(plane, intersection)
if (!hit) {
// Fallback if ray doesn't hit plane (e.g., parallel)
return new THREE.Vector3(1, 1, 1)
}
// Calculate scale factors based on world-space positions
const anchorToHandle = handleWorldPosition.clone().sub(anchor)
const anchorToMouse = intersection.clone().sub(anchor)
const result = new THREE.Vector3(1, 1, 1)
const minComponent = 0.001
// Y axis always scales
if (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)
}
}
return result
}