mattercontrol/src/composables/useScaleGizmo.ts

553 lines
17 KiB
TypeScript

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 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
// 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
const 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 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,
camera: THREE.Camera,
anchor: THREE.Vector3,
handleWorldPosition: THREE.Vector3,
axis: 'x' | 'y' | 'z'
): THREE.Vector3 {
// 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)
// Intersect with constraint plane
const intersection = new THREE.Vector3()
const hit = raycaster.ray.intersectPlane(plane, intersection)
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)
const handleDist = anchorToHandle.dot(axisDir)
// Scale factor is the ratio
let scaleFactor = 1.0
if (Math.abs(handleDist) > 0.001) {
scaleFactor = targetDist / 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
}
/**
* Calculate scale factor for free-form corner scaling (left-drag).
* Constrains scaling to a plane aligned to major world axes.
* 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,
camera: THREE.Camera,
anchor: THREE.Vector3,
handleWorldPosition: THREE.Vector3
): THREE.Vector3 {
// Get camera's forward direction (where it's looking)
const viewDir = new THREE.Vector3()
camera.getWorldDirection(viewDir)
// 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
}
// 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 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
// 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)
}
if (scaleZ && Math.abs(anchorToHandle.z) > minComponent) {
result.z = clampScaleFactor(anchorToMouse.z / anchorToHandle.z)
}
return result
}