553 lines
17 KiB
TypeScript
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
|
|
}
|