threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
912 lines (779 loc) • 35.9 kB
text/typescript
/* eslint-disable */
import {
BoxGeometry,
Camera,
CylinderGeometry,
Group,
LineBasicMaterial,
MathUtils,
Matrix4,
Mesh,
Object3DEventMap,
MeshBasicMaterial,
Object3D,
OrthographicCamera,
PerspectiveCamera,
Plane,
Quaternion,
Raycaster,
Ray,
SphereGeometry,
TorusGeometry,
Vector2,
Vector3,
} from 'three'
// ============================================================================
// Math helpers (ported from drei)
// ============================================================================
const _vec1 = new Vector3()
const _vec2 = new Vector3()
function calculateOffset(clickPoint: Vector3, normal: Vector3, rayStart: Vector3, rayDir: Vector3): number {
const e1 = normal.dot(normal)
const e2 = normal.dot(clickPoint) - normal.dot(rayStart)
const e3 = normal.dot(rayDir)
if (e3 === 0) return -e2 / e1
_vec1.copy(rayDir).multiplyScalar(e1 / e3).sub(normal)
_vec2.copy(rayDir).multiplyScalar(e2 / e3).add(rayStart).sub(clickPoint)
return -_vec1.dot(_vec2) / _vec1.dot(_vec1)
}
function decomposeIntoBasis(e1: Vector3, e2: Vector3, offset: Vector3): [number, number] {
const i1 = Math.abs(e1.x) >= Math.abs(e1.y) && Math.abs(e1.x) >= Math.abs(e1.z) ? 0
: Math.abs(e1.y) >= Math.abs(e1.x) && Math.abs(e1.y) >= Math.abs(e1.z) ? 1 : 2
const order = [0, 1, 2].sort((a, b) => Math.abs(e2.getComponent(b)) - Math.abs(e2.getComponent(a)))
const i2 = i1 === order[0] ? order[1] : order[0]
const a1 = e1.getComponent(i1), a2 = e1.getComponent(i2)
const b1 = e2.getComponent(i1), b2 = e2.getComponent(i2)
const c1 = offset.getComponent(i1), c2 = offset.getComponent(i2)
const y = (c2 - c1 * (a2 / a1)) / (b2 - b1 * (a2 / a1))
const x = (c1 - y * b1) / a1
return [x, y]
}
// ============================================================================
// Reusable temps
// ============================================================================
const _mL0 = new Matrix4()
const _mW0 = new Matrix4()
const _mP = new Matrix4()
const _mPInv = new Matrix4()
const _mW = new Matrix4()
const _mL = new Matrix4()
const _mL0Inv = new Matrix4()
const _mdL = new Matrix4()
const _offsetMatrix = new Matrix4()
const _rotMatrix = new Matrix4()
const _scaleMatrix = new Matrix4()
const _pointer = new Vector2()
const _worldPos = new Vector3()
const _camPos = new Vector3()
const _posNew = new Vector3()
// const _scaleV = new Vector3()
const _ray = new Ray()
const _intersection = new Vector3()
const _upV = new Vector3(0, 1, 0)
const _xDir = new Vector3(1, 0, 0)
const _yDir = new Vector3(0, 1, 0)
const _zDir = new Vector3(0, 0, 1)
// ============================================================================
// Handle types
// ============================================================================
type HandleType = 'arrow' | 'slider' | 'rotator' | 'scaler'
interface HandleInfo {
type: HandleType
axis: 0 | 1 | 2
gizmoMeshes: Mesh[] // visible meshes
pickerMeshes: Mesh[] // invisible picker meshes
materials: MeshBasicMaterial[]
// drag state
clickInfo: any
extraState: any
}
// ============================================================================
// PivotControls
// ============================================================================
export interface PivotControlsEventMap {
change: {}
objectChange: {}
mouseDown: { mode: string }
mouseUp: { mode: string }
'dragging-changed': { value: boolean }
}
/**
* PivotControls - A gizmo that shows all transform handles simultaneously
* (translation arrows, plane sliders, rotation arcs, scaling spheres).
* Follows the same architecture as TransformControls — single class,
* mesh-based geometry, ObjectConstructors for material injection.
*/
export class PivotControls extends Group<PivotControlsEventMap & Object3DEventMap> {
static ObjectConstructors: {
MeshBasicMaterial: typeof MeshBasicMaterial
LineBasicMaterial: typeof LineBasicMaterial
} = {
MeshBasicMaterial: MeshBasicMaterial,
LineBasicMaterial: LineBasicMaterial,
}
camera: Camera
domElement: HTMLCanvasElement
object: Object3D | undefined
enabled = true
autoTransform = true
/** Scale multiplier for the gizmo, same as TransformControls 'size'. Applied per-frame. */
gizmoScale = 1.25
activeAxes: [boolean, boolean, boolean] = [true, true, true]
disableAxes = false
disableSliders = false
disableRotations = false
disableScaling = false
translationLimits?: ([number, number] | undefined)[]
rotationLimits?: ([number, number] | undefined)[]
scaleLimits?: ([number, number] | undefined)[]
axisColors: [number, number, number] = [0xEF0065, 0x1EBE00, 0x0093FD]
hoveredColor = 0xffff40
handleOpacity = 0.95
/** Coordinate space for transformations. 'world' or 'local'. */
space: 'world' | 'local' = 'world'
/** Snap values when shift is held. Set to null to disable snapping for that transform. */
translationSnap: number | null = 0.5
rotationSnap: number | null = 15 // degrees
scaleSnap: number | null = 0.1
/** Whether gizmo materials use depth testing. false = always visible through objects. */
depthTest = false
/** When true, gizmo maintains constant screen size regardless of camera distance (default). */
fixed = true
/** When true, alt/option+drag on a scale sphere applies uniform scale. */
uniformScaleEnabled = true
/** Show value annotations during drag. */
annotations = true
private _gizmoGroup: Group
private _handles: HandleInfo[] = []
private _raycaster = new Raycaster()
private _activeHandle: HandleInfo | null = null
private _dragging = false
private _hoveredHandle: HandleInfo | null = null
private _translation: [number, number, number] = [0, 0, 0]
private _uniformScaling = false
private _annotationEl: HTMLDivElement | null = null
private _onPointerDown: (e: PointerEvent) => void
private _onPointerMove: (e: PointerEvent) => void
private _onPointerUp: (e: PointerEvent) => void
constructor(camera: Camera, domElement: HTMLCanvasElement) {
super()
this.camera = camera
this.domElement = domElement
this._gizmoGroup = new Group()
this.add(this._gizmoGroup)
this._buildHandles()
this._onPointerDown = this._handlePointerDown.bind(this)
this._onPointerMove = this._handlePointerMove.bind(this)
this._onPointerUp = this._handlePointerUp.bind(this)
domElement.addEventListener('pointerdown', this._onPointerDown)
domElement.addEventListener('pointermove', this._onPointerMove)
domElement.addEventListener('pointerup', this._onPointerUp)
this._onKeyDown = this._handleKeyDown.bind(this)
window.addEventListener('keydown', this._onKeyDown)
}
// ========================================================================
// Keyboard shortcuts (consistent with TransformControls2)
// ========================================================================
private _onKeyDown: (e: KeyboardEvent) => void
private _handleKeyDown(event: KeyboardEvent): void {
if (!this.enabled || !this.object) return
if (event.metaKey || event.ctrlKey) return
if ((event.target as any)?.tagName === 'TEXTAREA' || (event.target as any)?.tagName === 'INPUT') return
switch (event.code) {
case 'KeyQ':
this.space = this.space === 'local' ? 'world' : 'local'
break
case 'Equal':
case 'NumpadAdd':
case 'Plus':
this.gizmoScale = this.gizmoScale + 0.1
break
case 'Minus':
case 'NumpadSubtract':
case 'Underscore':
this.gizmoScale = Math.max(this.gizmoScale - 0.1, 0.1)
break
case 'KeyX':
this.activeAxes[0] = !this.activeAxes[0]
this.updateHandleVisibility()
break
case 'KeyY':
this.activeAxes[1] = !this.activeAxes[1]
this.updateHandleVisibility()
break
case 'KeyZ':
this.activeAxes[2] = !this.activeAxes[2]
this.updateHandleVisibility()
break
case 'Space':
this.enabled = !this.enabled
break
default:
return
}
this.dispatchEvent({type: 'change'})
}
// ========================================================================
// Annotation overlay
// ========================================================================
private _ensureAnnotationEl(): HTMLDivElement {
if (!this._annotationEl) {
const el = document.createElement('div')
el.style.cssText = 'position:absolute;pointer-events:none;display:none;' +
'background:#151520;color:white;padding:6px 8px;border-radius:7px;' +
'white-space:nowrap;z-index:1000;'
this.domElement.parentElement?.appendChild(el)
this._annotationEl = el
}
return this._annotationEl
}
private _showAnnotation(text: string, worldPos: Vector3): void {
if (!this.annotations) return
const el = this._ensureAnnotationEl()
el.textContent = text
el.style.display = 'block'
// Project world position to screen
const v = worldPos.clone().project(this.camera)
const rect = this.domElement.getBoundingClientRect()
const x = (v.x * 0.5 + 0.5) * rect.width
const y = (-v.y * 0.5 + 0.5) * rect.height
el.style.left = (rect.left + x + 12) + 'px'
el.style.top = (rect.top + y - 12) + 'px'
}
private _hideAnnotation(): void {
if (this._annotationEl) this._annotationEl.style.display = 'none'
}
private _updateAnnotation(h: HandleInfo): void {
if (!this.annotations || !this.object) return
const axisLabels = ['X', 'Y', 'Z']
let text = ''
if (h.type === 'arrow') {
text = `${axisLabels[h.axis]}: ${this._translation[h.axis].toFixed(2)}`
} else if (h.type === 'slider') {
const a1 = (h.axis + 1) % 3, a2 = (h.axis + 2) % 3
text = `${axisLabels[a1]}: ${this._translation[a1].toFixed(2)}, ${axisLabels[a2]}: ${this._translation[a2].toFixed(2)}`
} else if (h.type === 'rotator') {
const deg = (h.extraState.angle * 180 / Math.PI).toFixed(0)
text = `${axisLabels[h.axis]}: ${deg}°`
} else if (h.type === 'scaler') {
const label = this._uniformScaling ? 'Uniform' : axisLabels[h.axis]
text = `${label}: ${h.extraState.scaleCur.toFixed(2)}`
}
this._showAnnotation(text, this.position)
}
// ========================================================================
// Build gizmo geometry
// ========================================================================
private _buildHandles(): void {
this._handles = []
this._gizmoGroup.clear()
const OC = PivotControls.ObjectConstructors
// Build geometry at unit scale. gizmoScale is applied in updateGizmoScale() per frame.
const s = 1
const dt = this.depthTest
const makeMat = (color: number, opacity = this.handleOpacity) => new OC.MeshBasicMaterial({
allowOverride: false,
depthTest: dt,
depthWrite: dt,
fog: false,
toneMapped: false,
transparent: true,
color,
opacity,
})
const makePickerMat = () => new OC.MeshBasicMaterial({
allowOverride: false,
depthTest: false,
depthWrite: false,
fog: false,
toneMapped: false,
transparent: true,
opacity: 0.15,
visible: false,
})
const dirs = [_xDir, _yDir, _zDir]
// --- Axis arrows (translate) ---
for (let i = 0; i < 3; i++) {
const mat = makeMat(this.axisColors[i])
const pMat = makePickerMat()
// Shaft: thin cylinder
const shaftGeom = new CylinderGeometry(0.0075 * s, 0.0075 * s, 0.5 * s, 3)
shaftGeom.translate(0, 0.25 * s, 0)
const shaft = new Mesh(shaftGeom, mat)
shaft.renderOrder = 500
shaft.raycast = () => {}
// Cone tip
const coneGeom = new CylinderGeometry(0, 0.04 * s, 0.1 * s, 12)
coneGeom.translate(0, 0.05 * s, 0)
const cone = new Mesh(coneGeom, mat)
cone.position.set(0, 0.5 * s, 0)
cone.renderOrder = 500
cone.raycast = () => {}
// Picker
const pickerGeom = new CylinderGeometry(0.2 * s, 0, 0.6 * s, 4)
pickerGeom.translate(0, 0.3 * s, 0)
const picker = new Mesh(pickerGeom, pMat)
picker.visible = false
// Orient along axis direction
const q = new Quaternion().setFromUnitVectors(_upV, dirs[i])
const group = new Group()
group.quaternion.copy(q)
group.add(shaft, cone, picker)
this._gizmoGroup.add(group)
const handle: HandleInfo = {
type: 'arrow', axis: i as 0|1|2,
gizmoMeshes: [shaft, cone], pickerMeshes: [picker],
materials: [mat], clickInfo: null, extraState: null,
}
picker.userData._pivotHandle = handle
this._handles.push(handle)
}
// --- Plane sliders (translate on plane) ---
// axis 2 = XY plane, axis 1 = XZ plane, axis 0 = YZ plane
const sliderDefs: [number, Vector3, Vector3][] = [[2, _xDir, _yDir], [1, _zDir, _xDir], [0, _yDir, _zDir]]
for (const [axis, d1, d2] of sliderDefs) {
const mat = makeMat(this.axisColors[axis], 0.75)
const pMat = makePickerMat()
const planeSize = 0.2 * s
const planeGeom = new BoxGeometry(planeSize, planeSize, 0.01 * s)
const planeMesh = new Mesh(planeGeom, mat)
planeMesh.renderOrder = 500
planeMesh.raycast = () => {}
const pickerGeom = new BoxGeometry(planeSize * 1.25, planeSize * 1.25, 0.01 * s)
const picker = new Mesh(pickerGeom, pMat)
picker.visible = false
// Position offset like TransformControls XY/YZ/XZ
const d1n = d1.clone().normalize()
const d2n = d2.clone().normalize()
const normal = d1n.clone().cross(d2n)
const pos = d1n.clone().multiplyScalar(planeSize).add(d2n.clone().multiplyScalar(planeSize))
const group = new Group()
const basis = new Matrix4().makeBasis(d1n, d2n, normal)
group.applyMatrix4(basis)
group.position.copy(pos)
group.add(planeMesh, picker)
this._gizmoGroup.add(group)
const handle: HandleInfo = {
type: 'slider', axis: axis as 0|1|2,
gizmoMeshes: [planeMesh], pickerMeshes: [picker],
materials: [mat], clickInfo: null, extraState: null,
}
picker.userData._pivotHandle = handle
this._handles.push(handle)
}
// --- Axis rotators ---
// axis 2 = around Z (XY plane), axis 1 = around Y (XZ plane), axis 0 = around X (YZ plane)
const rotatorDefs: [number, Vector3, Vector3][] = [[2, _xDir, _yDir], [1, _zDir, _xDir], [0, _yDir, _zDir]]
for (const [axis, d1, d2] of rotatorDefs) {
const mat = makeMat(this.axisColors[axis])
const pMat = makePickerMat()
// Visible: quarter torus (like TransformControls CircleGeometry)
const r = 0.5 * s
const torusGeom = new TorusGeometry(r, 0.0075 * s, 3, 16, Math.PI / 2)
const torus = new Mesh(torusGeom, mat)
torus.renderOrder = 500
torus.raycast = () => {}
// Picker: thicker quarter torus (like TransformControls CircleGeometry2)
const pickerGeom = new TorusGeometry(r, 0.1 * s, 4, 12, Math.PI / 2)
const picker = new Mesh(pickerGeom, pMat)
picker.visible = false
const d1n = d1.clone().normalize()
const d2n = d2.clone().normalize()
const normal = d1n.clone().cross(d2n)
const group = new Group()
const basis = new Matrix4().makeBasis(d1n, d2n, normal)
group.applyMatrix4(basis)
group.add(torus, picker)
this._gizmoGroup.add(group)
const handle: HandleInfo = {
type: 'rotator', axis: axis as 0|1|2,
gizmoMeshes: [torus], pickerMeshes: [picker],
materials: [mat], clickInfo: null, extraState: { angle0: 0, angle: 0 },
}
picker.userData._pivotHandle = handle
this._handles.push(handle)
}
// --- Scaling spheres ---
for (let i = 0; i < 3; i++) {
const mat = makeMat(this.axisColors[i])
const r = 0.04 * s
const sphereGeom = new SphereGeometry(r, 12, 12)
const sphere = new Mesh(sphereGeom, mat)
sphere.renderOrder = 500
const q = new Quaternion().setFromUnitVectors(_upV, dirs[i])
const group = new Group()
group.quaternion.copy(q)
// Place sphere at the end of the arrow
sphere.position.set(0, 0.6 * s + r, 0)
group.add(sphere)
this._gizmoGroup.add(group)
const handle: HandleInfo = {
type: 'scaler', axis: i as 0|1|2,
gizmoMeshes: [sphere], pickerMeshes: [sphere], // sphere is its own picker
materials: [mat], clickInfo: null, extraState: { scale0: 1, scaleCur: 1 },
}
sphere.userData._pivotHandle = handle
this._handles.push(handle)
}
this.updateHandleVisibility()
this._gizmoGroup.traverse(c => {
c.castShadow = false
c.receiveShadow = false
c.userData.__keepShadowDef = true
})
}
updateHandleVisibility(): void {
if (!this._handles) return
for (const h of this._handles) {
let vis = true
if (h.type === 'arrow') vis = !this.disableAxes && this.activeAxes[h.axis]
else if (h.type === 'scaler') vis = !this.disableScaling && this.activeAxes[h.axis]
else if (h.type === 'slider') {
// slider axis 2 = XY (needs X,Y active), 1 = XZ (needs X,Z), 0 = YZ (needs Y,Z)
const pairs: [number,number][] = [[1,2],[0,2],[0,1]]
const [a,b] = pairs[h.axis]
vis = !this.disableSliders && this.activeAxes[a] && this.activeAxes[b]
} else if (h.type === 'rotator') {
const pairs: [number,number][] = [[1,2],[0,2],[0,1]]
const [a,b] = pairs[h.axis]
vis = !this.disableRotations && this.activeAxes[a] && this.activeAxes[b]
}
for (const m of h.gizmoMeshes) m.visible = vis
for (const m of h.pickerMeshes) {
// pickers are always invisible but we toggle their raycast-ability via parent
if (m.parent) m.parent.visible = vis
}
}
}
// ========================================================================
// Public API
// ========================================================================
attach(object: Object3D): this {
this.object = object
this.visible = true
this._translation = [0, 0, 0]
this.updateHandleVisibility()
this.updateMatrixWorld(true)
this.dispatchEvent({type: 'change'})
return this
}
detach(): this {
if (this._dragging) this._endDrag()
this.object = undefined
this.visible = false
this._activeHandle = null
this._hideAnnotation()
this.dispatchEvent({type: 'change'})
return this
}
updateGizmoScale(): void {
if (!this.object || !this.visible) return
this.object.updateWorldMatrix(true, false)
const objectWorldPos = new Vector3().setFromMatrixPosition(this.object.matrixWorld)
this.position.copy(objectWorldPos)
// In local space, orient gizmo to match object's world rotation (without scale)
// Force world space for multi-select dummy (has no meaningful rotation)
if (this.space === 'local' && !this.object.userData?.isMultiSelectDummy) {
this.object.matrixWorld.decompose(_worldPos, this.quaternion, _camPos) // _camPos used as temp for scale
} else {
this.quaternion.identity()
}
if (this.fixed) {
// Fixed screen-size scaling (same formula as TransformControls)
_worldPos.copy(objectWorldPos)
_camPos.setFromMatrixPosition(this.camera.matrixWorld)
let factor: number
if ((this.camera as OrthographicCamera).isOrthographicCamera) {
const cam = this.camera as OrthographicCamera
factor = (cam.top - cam.bottom) / cam.zoom
} else {
const cam = this.camera as PerspectiveCamera
factor = _worldPos.distanceTo(_camPos) *
Math.min(1.9 * Math.tan(Math.PI * cam.fov / 360) / cam.zoom, 7)
}
this._gizmoGroup.scale.setScalar(factor * this.gizmoScale / 4)
} else {
// World-space scale
this._gizmoGroup.scale.setScalar(this.gizmoScale)
}
// Visual feedback: enlarge all scale spheres during uniform scaling
for (const h of this._handles) {
if (h.type === 'scaler') {
const scaleFactor = this._uniformScaling ? 1.8 : 1
for (const m of h.gizmoMeshes) m.scale.setScalar(scaleFactor)
}
}
}
rebuild(): void {
this._buildHandles()
}
dispose(): void {
this.domElement.removeEventListener('pointerdown', this._onPointerDown)
this.domElement.removeEventListener('pointermove', this._onPointerMove)
this.domElement.removeEventListener('pointerup', this._onPointerUp)
window.removeEventListener('keydown', this._onKeyDown)
if (this._annotationEl) {
this._annotationEl.remove()
this._annotationEl = null
}
}
// ========================================================================
// Pointer handling
// ========================================================================
private _getNDC(event: PointerEvent): Vector2 {
const rect = this.domElement.getBoundingClientRect()
_pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
_pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
return _pointer
}
private _getAllPickers(): Object3D[] {
const result: Object3D[] = []
for (const h of this._handles) {
for (const m of h.pickerMeshes) result.push(m)
}
return result
}
private _findHandle(object: Object3D): HandleInfo | null {
let obj: Object3D | null = object
while (obj) {
if (obj.userData._pivotHandle) return obj.userData._pivotHandle as HandleInfo
obj = obj.parent
}
return null
}
private _handlePointerDown(event: PointerEvent): void {
if (!this.enabled || !this.object || !this.visible) return
this._raycaster.setFromCamera(this._getNDC(event), this.camera)
const intersects = this._raycaster.intersectObjects(this._getAllPickers(), true)
if (intersects.length === 0) return
const handle = this._findHandle(intersects[0].object)
if (!handle) return
event.stopPropagation()
this._activeHandle = handle
this._dragging = true
_mL0.copy(this.object.matrix)
_mW0.copy(this.object.matrixWorld)
this._onDragStart(handle, intersects[0].point, event)
this.domElement.setPointerCapture(event.pointerId)
this.dispatchEvent({type: 'mouseDown', mode: this._getMode(handle)})
this.dispatchEvent({type: 'dragging-changed', value: true})
}
private _handlePointerMove(event: PointerEvent): void {
if (!this.enabled || !this.object || !this.visible) return
this._raycaster.setFromCamera(this._getNDC(event), this.camera)
if (this._dragging && this._activeHandle) {
const deltaWorld = this._onDragMove(this._activeHandle, this._raycaster.ray, event)
if (deltaWorld) {
this._applyTransform(deltaWorld)
this._updateAnnotation(this._activeHandle)
}
} else {
// Hover
const intersects = this._raycaster.intersectObjects(this._getAllPickers(), true)
const newHover = intersects.length > 0 ? this._findHandle(intersects[0].object) : null
if (newHover !== this._hoveredHandle) {
// Reset old
if (this._hoveredHandle) {
for (const mat of this._hoveredHandle.materials)
mat.color.set(this.axisColors[this._hoveredHandle.axis])
}
// Highlight new
if (newHover) {
for (const mat of newHover.materials)
mat.color.set(this.hoveredColor)
}
this._hoveredHandle = newHover
this.dispatchEvent({type: 'change'})
}
}
}
private _handlePointerUp(event: PointerEvent): void {
if (!this._dragging || !this._activeHandle) return
event.stopPropagation()
this.domElement.releasePointerCapture(event.pointerId)
this._endDrag()
}
private _endDrag(): void {
if (!this._activeHandle) return
this._onDragEnd(this._activeHandle)
this._hideAnnotation()
const mode = this._getMode(this._activeHandle)
this._activeHandle = null
this._dragging = false
this.dispatchEvent({type: 'mouseUp', mode})
this.dispatchEvent({type: 'dragging-changed', value: false})
}
// ========================================================================
// Per-handle drag logic
// ========================================================================
private _onDragStart(h: HandleInfo, point: Vector3, _event: PointerEvent): void {
const parent = h.gizmoMeshes[0].parent!
parent.updateWorldMatrix(true, true)
if (h.type === 'arrow') {
const rotation = new Matrix4().extractRotation(parent.matrixWorld)
const dir = _upV.clone().applyMatrix4(rotation).normalize()
h.clickInfo = { clickPoint: point.clone(), dir }
h.extraState = this._translation[h.axis]
} else if (h.type === 'slider') {
const e1 = new Vector3().setFromMatrixColumn(parent.matrixWorld, 0).normalize()
const e2 = new Vector3().setFromMatrixColumn(parent.matrixWorld, 1).normalize()
const normal = new Vector3().setFromMatrixColumn(parent.matrixWorld, 2).normalize()
const origin = new Vector3().setFromMatrixPosition(parent.matrixWorld)
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, origin)
h.clickInfo = { clickPoint: point.clone(), e1, e2, plane }
h.extraState = {
x0: this._translation[(h.axis + 1) % 3],
y0: this._translation[(h.axis + 2) % 3],
}
} else if (h.type === 'rotator') {
const origin = new Vector3().setFromMatrixPosition(parent.matrixWorld)
const normal = new Vector3().setFromMatrixColumn(parent.matrixWorld, 2).normalize()
// Compute a perpendicular direction in screen space for linear rotation
const eye = new Vector3().setFromMatrixPosition(this.camera.matrixWorld).sub(origin).normalize()
const tangent = normal.clone().cross(eye)
if (tangent.length() === 0) tangent.copy(eye).cross(_upV) // fallback
tangent.normalize()
const rotSpeed = 20 / origin.distanceTo(new Vector3().setFromMatrixPosition(this.camera.matrixWorld))
h.clickInfo = { clickPoint: point.clone(), origin, normal, tangent, rotSpeed }
h.extraState = { angle: 0 }
} else if (h.type === 'scaler') {
const rotation = new Matrix4().extractRotation(parent.matrixWorld)
const dir = _upV.clone().applyMatrix4(rotation).normalize()
const mPLG = parent.matrixWorld.clone()
const mPLGInv = mPLG.clone().invert()
h.clickInfo = { clickPoint: point.clone(), dir, mPLG, mPLGInv }
}
}
private _onDragMove(h: HandleInfo, ray: Ray, event: PointerEvent): Matrix4 | null {
if (!h.clickInfo) return null
if (h.type === 'arrow') {
const { clickPoint, dir } = h.clickInfo
const offset0 = h.extraState as number
const limits = this.translationLimits?.[h.axis]
let offset = calculateOffset(clickPoint, dir, ray.origin, ray.direction)
if (event.shiftKey && this.translationSnap !== null) {
const snap = this.translationSnap
offset = Math.round(offset / snap) * snap
}
if (limits) {
if (limits[0] !== undefined) offset = Math.max(offset, limits[0] - offset0)
if (limits[1] !== undefined) offset = Math.min(offset, limits[1] - offset0)
}
this._translation[h.axis] = offset0 + offset
_offsetMatrix.makeTranslation(dir.x * offset, dir.y * offset, dir.z * offset)
return _offsetMatrix
} else if (h.type === 'slider') {
const { clickPoint, e1, e2, plane } = h.clickInfo
const { x0, y0 } = h.extraState
_ray.copy(ray)
_ray.intersectPlane(plane, _intersection)
_ray.direction.negate()
_ray.intersectPlane(plane, _intersection)
_intersection.sub(clickPoint)
let [ox, oy] = decomposeIntoBasis(e1, e2, _intersection)
if (event.shiftKey && this.translationSnap !== null) {
const snap = this.translationSnap
ox = Math.round(ox / snap) * snap
oy = Math.round(oy / snap) * snap
}
const limX = this.translationLimits?.[(h.axis + 1) % 3]
const limY = this.translationLimits?.[(h.axis + 2) % 3]
if (limX) {
if (limX[0] !== undefined) ox = Math.max(ox, limX[0] - x0)
if (limX[1] !== undefined) ox = Math.min(ox, limX[1] - x0)
}
if (limY) {
if (limY[0] !== undefined) oy = Math.max(oy, limY[0] - y0)
if (limY[1] !== undefined) oy = Math.min(oy, limY[1] - y0)
}
this._translation[(h.axis + 1) % 3] = x0 + ox
this._translation[(h.axis + 2) % 3] = y0 + oy
_offsetMatrix.makeTranslation(ox * e1.x + oy * e2.x, ox * e1.y + oy * e2.y, ox * e1.z + oy * e2.z)
return _offsetMatrix
} else if (h.type === 'rotator') {
const { clickPoint, origin, normal, tangent, rotSpeed } = h.clickInfo
const limits = this.rotationLimits?.[h.axis] as [number,number] | undefined
// Linear rotation: project ray-plane intersection offset onto tangent direction
// This gives continuous rotation without atan2 wrapping (same approach as TransformControls)
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, origin)
_ray.copy(ray)
if (!_ray.intersectPlane(plane, _intersection)) {
_ray.direction.negate()
_ray.intersectPlane(plane, _intersection)
}
_intersection.sub(clickPoint)
let da = _intersection.dot(tangent) * rotSpeed
if (event.shiftKey && this.rotationSnap !== null) {
const snapRad = this.rotationSnap * Math.PI / 180
da = Math.round(da / snapRad) * snapRad
}
if (limits) {
da = MathUtils.clamp(da, limits[0], limits[1])
}
h.extraState.angle = da
_rotMatrix.makeRotationAxis(normal, da)
_posNew.copy(origin).applyMatrix4(_rotMatrix).sub(origin).negate()
_rotMatrix.setPosition(_posNew)
return _rotMatrix
} else if (h.type === 'scaler') {
const { clickPoint, dir, mPLG, mPLGInv } = h.clickInfo
const limits = this.scaleLimits?.[h.axis] as [number,number] | undefined
const offsetW = calculateOffset(clickPoint, dir, ray.origin, ray.direction)
let upscale = Math.pow(2, offsetW * 0.2)
// Shift = snap, Alt = uniform scale
if (event.shiftKey && this.scaleSnap !== null) {
upscale = Math.round(upscale / this.scaleSnap) * this.scaleSnap
if (upscale === 0) upscale = this.scaleSnap
}
const isUniform = this.uniformScaleEnabled && event.altKey
this._uniformScaling = isUniform
const min = limits ? limits[0] : 1e-5
upscale = Math.max(upscale, min / h.extraState.scale0)
if (limits && limits[1] !== undefined) upscale = Math.min(upscale, limits[1] / h.extraState.scale0)
h.extraState.scaleCur = h.extraState.scale0 * upscale
if (isUniform) {
// Uniform scale: apply same factor to all axes
_scaleMatrix.makeScale(upscale, upscale, upscale)
} else {
// Single-axis scale in the handle's local space (local Y = world axis direction)
_scaleMatrix.makeScale(1, upscale, 1).premultiply(mPLG).multiply(mPLGInv)
}
return _scaleMatrix
}
return null
}
private _onDragEnd(h: HandleInfo): void {
if (h.type === 'rotator') h.extraState.angle0 = h.extraState.angle
if (h.type === 'scaler') h.extraState.scale0 = h.extraState.scaleCur
this._uniformScaling = false
h.clickInfo = null
}
// ========================================================================
// Transform application (drei matrix math)
// ========================================================================
private _applyTransform(mdW: Matrix4): void {
if (!this.object) return
if (this.object.parent) {
_mP.copy(this.object.parent.matrixWorld)
} else {
_mP.identity()
}
_mPInv.copy(_mP).invert()
_mW.copy(_mW0).premultiply(mdW)
_mL.copy(_mW).premultiply(_mPInv)
_mL0Inv.copy(_mL0).invert()
_mdL.copy(_mL).multiply(_mL0Inv)
if (this.autoTransform) {
this.object.matrix.copy(_mL)
this.object.matrix.decompose(this.object.position, this.object.quaternion, this.object.scale)
}
this.dispatchEvent({type: 'objectChange'})
this.dispatchEvent({type: 'change'})
}
private _getMode(h: HandleInfo): string {
if (h.type === 'arrow' || h.type === 'slider') return 'translate'
if (h.type === 'rotator') return 'rotate'
if (h.type === 'scaler') return 'scale'
return 'unknown'
}
}