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.
387 lines (333 loc) • 15.5 kB
text/typescript
import {uiButton, uiColor, uiFolderContainer, uiSlider, uiToggle} from 'uiconfig.js'
import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import {PickingPlugin} from './PickingPlugin'
import {JSUndoManager, onChange} from 'ts-browser-helpers'
import {OrbitControls3, TransformControls} from '../../three'
import {Group2, IObject3D, IWidget, UnlitLineMaterial, UnlitMaterial} from '../../core'
import {Mesh, MeshBasicMaterial, Object3D, SphereGeometry, Vector3} from 'three'
import type {UndoManagerPlugin} from './UndoManagerPlugin'
import type {TransformControlsPlugin} from './TransformControlsPlugin'
import type {PivotControlsPlugin} from './PivotControlsPlugin'
import {Box3B} from '../../three/math/Box3B'
// eslint-disable-next-line @typescript-eslint/naming-convention
const _pos = new Vector3()
// eslint-disable-next-line @typescript-eslint/naming-convention
const _camPos = new Vector3()
/**
* PivotEditPlugin provides interactive pivot point (origin) editing for objects.
* Toggle "Edit Pivot" mode to show a translate-only gizmo at the object's pivot.
* Dragging moves only the pivot while the object mesh stays visually in place.
*
* Works alongside both TransformControlsPlugin and PivotControlsPlugin.
*
* @category Plugins - Interaction
*/
export class PivotEditPlugin extends AViewerPluginSync {
public static readonly PluginType = 'PivotEditPlugin'
dependencies = [PickingPlugin]
enabled = true
editPivot = false
showPivotMarker = true
markerScale = 0.5
markerColor = 0xffff00
constructor(enabled = true) {
super()
this.enabled = enabled
}
toJSON: any = undefined
private _pivotGizmo: TransformControls | null = null
private _markerRoot: Group2 | null = null // assetType='model' root for raycasting
private _markerWidget: (Group2 & IWidget) | null = null // assetType='widget' wrapper for handle resolution
private _pivotMarker: Mesh<SphereGeometry, MeshBasicMaterial> | null = null
private _selectedObject: IObject3D | null = null
undoManager?: JSUndoManager
protected _viewerListeners = {
preFrame: () => this._updateMarker(),
}
private _onKeyDown: ((e: KeyboardEvent) => void) | null = null
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)
const picking = viewer.getPlugin(PickingPlugin)!
// Intercept pivot marker clicks before selection happens.
// hitObject fires before setSelected, so setting selectedObject to the
// current selection prevents any selection change — no other plugin sees the click.
picking.addEventListener('hitObject', (event) => {
if (event.intersects?.selectedHandle?.userData.isPivotMarker) {
// Mark consumed so the picker skips selection changes for this click
event.intersects.consumed = true
this.editPivot = !this.editPivot
this._onEditPivotChange()
}
})
picking.addEventListener('selectedObjectChanged', (event) => {
const newObject = event.object as IObject3D | null
const objectChanged = newObject !== this._selectedObject
this._selectedObject = newObject
if (this._markerWidget) this._markerWidget.object = this._selectedObject
// Exit edit mode on object change or deselection
if (this.editPivot && objectChanged) {
this.editPivot = false
this._onEditPivotChange()
}
this._updateMarker()
})
viewer.forPlugin<UndoManagerPlugin>('UndoManagerPlugin', (um) => {
this.undoManager = um.undoManager
}, () => this.undoManager = undefined, this)
this._onKeyDown = (event: KeyboardEvent) => {
if (this.isDisabled()) return
if (event.metaKey || event.ctrlKey) return
if ((event.target as any)?.tagName === 'TEXTAREA' || (event.target as any)?.tagName === 'INPUT') return
if (event.code === 'KeyP') {
this.editPivot = !this.editPivot
this._onEditPivotChange()
} else if (event.code === 'Escape' && this.editPivot) {
this.editPivot = false
this._onEditPivotChange()
}
}
window.addEventListener('keydown', this._onKeyDown)
// Create pivot marker as a widget handle, pickable via PickingPlugin.
// Structure: _markerRoot (Group2, assetType='model', isWidgetRoot)
// └── _markerWidget (Group, assetType='widget', object=selectedObject)
// └── _pivotMarker (Mesh, isWidgetHandle, isPivotMarker)
const mat = new MeshBasicMaterial({
color: this.markerColor, depthTest: false, depthWrite: false,
toneMapped: false, transparent: true, opacity: 0.9,
})
this._pivotMarker = new Mesh(new SphereGeometry(1, 8, 8), mat)
this._pivotMarker.renderOrder = 1000
this._pivotMarker.userData.isWidgetHandle = true
this._pivotMarker.userData.isPivotMarker = true
const markerWidget = new Group2() as Group2 & IWidget
markerWidget.isWidget = true as const
markerWidget.assetType = 'widget' as const
markerWidget.object = this._selectedObject
markerWidget.attach = (obj: any) => { markerWidget.object = obj; return markerWidget }
markerWidget.detach = () => { markerWidget.object = undefined; return markerWidget }
this._markerWidget = markerWidget
this._markerWidget.add(this._pivotMarker)
this._markerRoot = new Group2()
this._markerRoot.userData.isWidgetRoot = true
this._markerRoot.add(this._markerWidget)
this._markerRoot.visible = false
this._markerRoot.traverse(c => {
c.castShadow = false
c.receiveShadow = false
c.userData.__keepShadowDef = true
})
viewer.scene.addObject(this._markerRoot, {addToRoot: true})
}
onRemove(viewer: ThreeViewer) {
if (this._onKeyDown) {
window.removeEventListener('keydown', this._onKeyDown)
this._onKeyDown = null
}
this._destroyGizmo(viewer)
if (this._markerRoot) {
viewer.scene.remove(this._markerRoot)
if (this._pivotMarker) {
this._pivotMarker.geometry.dispose()
this._pivotMarker.material.dispose()
}
this._markerRoot = null
this._markerWidget = null
this._pivotMarker = null
}
super.onRemove(viewer)
}
// ========================================================================
// Presets
// ========================================================================
pivotToCenter(): void {
if (!this._selectedObject?.pivotToBoundsCenter) return
this._recordUndo(this._selectedObject.pivotToBoundsCenter(true))
this._refreshAfterPivotChange()
}
pivotToBottom(): void {
if (!this._selectedObject?.pivotToPoint) return
const bb = new Box3B().expandByObject(this._selectedObject, true, true)
const center = bb.getCenter(new Vector3())
this._applyPivot(new Vector3(center.x, bb.min.y, center.z))
}
pivotToOrigin(): void {
this._applyPivot(new Vector3(0, 0, 0))
}
pivotToPoint(point: Vector3): void {
this._applyPivot(point)
}
private _applyPivot(target: Vector3): void {
if (!this._selectedObject?.pivotToPoint) return
const undoFn = (this._selectedObject.pivotToPoint(target, true) as unknown) as () => void
this._recordUndo(undoFn)
this._refreshAfterPivotChange()
}
// ========================================================================
// Marker
// ========================================================================
private _updateMarker(): void {
if (!this._markerRoot || !this._pivotMarker || !this._viewer) return
if (this.isDisabled() || !this.showPivotMarker || !this._selectedObject) {
this._markerRoot.visible = false
return
}
this._selectedObject.updateWorldMatrix(true, false)
_pos.setFromMatrixPosition(this._selectedObject.matrixWorld)
this._markerRoot.position.copy(_pos)
this._markerRoot.visible = true
// Constant screen-size (same formula as TransformControls)
const camera = this._viewer.scene.mainCamera as any
let factor: number
if (camera.isOrthographicCamera) {
factor = (camera.top - camera.bottom) / camera.zoom
} else {
_camPos.setFromMatrixPosition(camera.matrixWorld)
factor = _pos.distanceTo(_camPos) *
Math.min(1.9 * Math.tan(Math.PI * camera.fov / 360) / camera.zoom, 7)
}
this._pivotMarker.scale.setScalar(factor * this.markerScale * 0.05)
}
// ========================================================================
// Gizmo
// ========================================================================
private _onEditPivotChange(): void {
if (!this._viewer) return
if (this.editPivot) this._attachGizmo()
else this._detachGizmo()
this._viewer.setDirty()
}
private _attachGizmo(): void {
if (!this._viewer || !this._selectedObject) return
this._setTransformControlsEnabled(false)
if (!this._pivotGizmo) {
TransformControls.ObjectConstructors.MeshBasicMaterial = UnlitMaterial as any
TransformControls.ObjectConstructors.LineBasicMaterial = UnlitLineMaterial as any
this._pivotGizmo = new TransformControls(this._viewer.scene.mainCamera, this._viewer.canvas)
this._pivotGizmo.setMode('translate')
this._pivotGizmo.userData.bboxVisible = false
this._pivotGizmo.visible = false
this._pivotGizmo.traverse(c => {
c.castShadow = false
c.receiveShadow = false
c.userData.__keepShadowDef = true
})
this._pivotGizmo.addEventListener('mouseUp', () => {
if (!this._selectedObject?.pivotToPoint || !this._pivotGizmo?.object) return
const newPos = new Vector3().setFromMatrixPosition(this._pivotGizmo.object.matrixWorld)
const undoFn = (this._selectedObject.pivotToPoint(newPos, true) as unknown) as () => void
this._recordUndo(undoFn)
// Update dummy position to the new pivot (pivotToPoint moved the object's origin)
this._refreshAfterPivotChange()
})
this._pivotGizmo.addEventListener('change', () => {
if (this._viewer) this._viewer.setDirty()
})
this._pivotGizmo.addEventListener('dragging-changed', (event: any) => {
if (!this._viewer) return
const controls = this._viewer.scene.mainCamera.controls
if (typeof (controls as any)?.stopDamping === 'function' && controls?.enabled) (controls as OrbitControls3).stopDamping()
this._viewer.scene.mainCamera.setInteractions(!event.value, PivotEditPlugin.PluginType)
})
this._viewer.scene.addObject(this._pivotGizmo as any, {addToRoot: true})
}
this._selectedObject.updateWorldMatrix(true, false)
const pivotPos = new Vector3().setFromMatrixPosition(this._selectedObject.matrixWorld)
if (!this._pivotGizmo.object) {
const dummy = new Object3D()
dummy.position.copy(pivotPos)
this._viewer.scene.addObject(dummy as any, {addToRoot: true})
this._pivotGizmo.attach(dummy)
} else {
this._pivotGizmo.object.position.copy(pivotPos)
this._pivotGizmo.object.updateMatrixWorld(true)
}
}
private _detachGizmo(): void {
if (this._pivotGizmo) {
const dummy = this._pivotGizmo.object
this._pivotGizmo.detach()
if (dummy && this._viewer) this._viewer.scene.remove(dummy)
}
this._setTransformControlsEnabled(true)
}
private _destroyGizmo(viewer: ThreeViewer): void {
if (!this._pivotGizmo) return
this._detachGizmo()
viewer.scene.remove(this._pivotGizmo as any)
this._pivotGizmo.dispose()
this._pivotGizmo = null
}
private _setTransformControlsEnabled(enabled: boolean): void {
if (!this._viewer) return
const tcp = this._viewer.getPlugin<TransformControlsPlugin>('TransformControlsPlugin')
if (tcp) {
if (enabled) tcp.setDirty()
else tcp.transformControls?.detach()
}
const pcp = this._viewer.getPlugin<PivotControlsPlugin>('PivotControlsPlugin')
if (pcp) {
if (enabled) pcp.setDirty()
else pcp.pivotControls?.detach()
}
}
private _onEnabledChange(): void {
if (this.isDisabled() && this.editPivot) {
this.editPivot = false
this._onEditPivotChange()
}
this._updateMarker()
if (this._viewer) this._viewer.setDirty()
}
private _refreshAfterPivotChange(): void {
if (!this._viewer) return
if (this.editPivot && this._selectedObject && this._pivotGizmo?.object) {
this._selectedObject.updateWorldMatrix(true, false)
this._pivotGizmo.object.position.setFromMatrixPosition(this._selectedObject.matrixWorld)
this._pivotGizmo.object.updateMatrixWorld(true)
}
this._updateMarker()
this._viewer.setDirty()
}
private _onDirty(): void {
if (this._viewer) this._viewer.setDirty()
}
private _onMarkerColorChange(): void {
if (this._pivotMarker) this._pivotMarker.material.color.set(this.markerColor)
if (this._viewer) this._viewer.setDirty()
}
// ========================================================================
// Undo
// ========================================================================
private _recordUndo(undoFn: () => void): void {
if (!this.undoManager || !this._selectedObject) return
const obj = this._selectedObject
obj.updateWorldMatrix(true, false)
const newPivotPos = new Vector3().setFromMatrixPosition(obj.matrixWorld)
this.undoManager.record({
undo: () => {
undoFn()
this._refreshAfterPivotChange()
},
redo: () => {
if (obj.pivotToPoint) {
obj.pivotToPoint(newPivotPos, true)
this._refreshAfterPivotChange()
}
},
})
}
}