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.
441 lines (387 loc) • 17.7 kB
text/typescript
import {EventListener2, Object3D} from 'three'
import {Class, onChange, safeSetProperty, serialize} from 'ts-browser-helpers'
import {AViewerPluginEventMap, AViewerPluginSync, ThreeViewer} from '../../viewer'
import {bindToValue, BoxSelectionWidget, ObjectPicker, SelectionWidget} from '../../three'
import {IMaterial, IObject3D, IScene, ISceneEventMap} from '../../core'
import {UiObjectConfig} from 'uiconfig.js'
import {FrameFadePlugin} from '../pipeline/FrameFadePlugin'
import {type UndoManagerPlugin} from './UndoManagerPlugin'
import {ObjectPickerEventMap} from '../../three/utils/ObjectPicker'
import {CameraViewPlugin} from '../animation/CameraViewPlugin'
export interface PickingPluginEventMap extends AViewerPluginEventMap, ObjectPickerEventMap{
}
export class PickingPlugin extends AViewerPluginSync<PickingPluginEventMap> {
enabled = true
get picker(): ObjectPicker|undefined {
return this._picker
}
static readonly PluginType = 'Picking'
static readonly OldPluginType = 'PickingPlugin' // todo: swap
private _picker?: ObjectPicker
private _widget?: SelectionWidget
private _hoverWidget?: SelectionWidget
private _pickUi: boolean
dependencies = [CameraViewPlugin]
get hoverEnabled() {
return this._picker?.hoverEnabled ?? false
}
set hoverEnabled(v: boolean) {
if (!this._picker) return
this._picker.hoverEnabled = v
this.uiConfig && this.uiConfig.uiRefresh?.()
}
selectionMode: 'object' | 'material' = 'object'
autoFocus
// @serialize() // todo
autoFocusHover = false
/**
* Note: this is for runtime use only, not serialized
*/
widgetEnabled = true
protected _widgetEnabledChange() {
if (!this._widget) return
if (this.widgetEnabled && (this._picker?.selectedObject as IObject3D)?.isObject3D)
this._widget.attach(this._picker!.selectedObject as IObject3D)
else
this._widget.detach()
this.uiConfig?.uiRefresh?.(true)
}
setDirty() {
if (!this._viewer) return
if (this.isDisabled()) this.setSelectedObject(undefined)
this._viewer.setDirty()
}
constructor(selection: Class<SelectionWidget>|undefined = BoxSelectionWidget, pickUi = true, autoFocus = false) {
super()
if (selection) {
this._widget = new selection()
this._hoverWidget = new selection()
if (this._hoverWidget.lineMaterial) {
this._hoverWidget.lineMaterial.linewidth! /= 2
this._hoverWidget.lineMaterial.color!.set('#aa2222')
}
}
this._pickUi = pickUi
this.autoFocus = autoFocus
this.dispatchEvent = this.dispatchEvent.bind(this)
}
getSelectedObject<T extends IObject3D|IMaterial = IObject3D|IMaterial>(): T|undefined {
if (this.isDisabled()) return
return this._picker?.selectedObject as T || undefined
}
setSelectedObject(object: IObject3D|IMaterial|undefined, focusCamera = false, trackUndo = true) { // todo: listen to object disposed
const disabled = this.isDisabled()
if (disabled && !object) return
if (!this._picker) return
const t = this.autoFocus
this.autoFocus = false
this._picker.setSelected(object || null, trackUndo)
this.autoFocus = t
if (!disabled && object && this.selectionMode === 'object' && (t || focusCamera)) this.focusObject(object as IObject3D)
}
onAdded(viewer: ThreeViewer): void {
super.onAdded(viewer)
this.setDirty()
this._picker = new ObjectPicker(viewer.scene.modelRoot, viewer.canvas, viewer.scene.mainCamera, (obj)=>{
const hasMat = obj.material
if (!hasMat) return false
let o: IObject3D|null = obj
let ret = false
while (o) {
if (!o.visible) return false
if (o.assetType === 'model' || o.assetType === 'light') ret = true
if (o.assetType === 'widget') return false
if (o.userData.userSelectable === false) return false
if (o.userData.bboxVisible === false) return false
// todo colorwrite?
o = o.parent
}
return ret
})
if (this._widget) viewer.scene.addObject(this._widget, {addToRoot: true})
if (this._hoverWidget) viewer.scene.addObject(this._hoverWidget, {addToRoot: true})
this._picker.addEventListener('selectedObjectChanged', this._selectedObjectChanged)
this._picker.addEventListener('hoverObjectChanged', this._hoverObjectChanged)
this._picker.addEventListener('hitObject', this._onObjectHit)
this._picker.addEventListener('selectionModeChanged', this._selectionModeChanged)
// on material drop on selected object
// viewer.scene.addEventListener('addSceneObject', async(e) => {
// const obj = e.object
// const selected: IModel<Mesh> = this.getSelectedObject()! as any
// if (selected
// && obj?.assetType === 'material'
// && typeof selected?.setMaterial === 'function'
// && selected?.modelObject?.isMesh
// && await viewer.confirm('Applying material: Apply material to the selected object?')
// ) {
// const oldMat = selected.material
// if (Array.isArray(oldMat)) {
// console.warn('Dropping on material array not yet fully supported.')
// selected.setMaterial(obj)
// } else {
// let meshes: IModel<Mesh>[] = Array.from(oldMat?.userData.__appliedMeshes ?? [])
// const c = meshes.length > 1 ? !await viewer.confirm('Applying material: Apply to all objects using this material?') : meshes.length < 1
// if (c) meshes = [selected]
// for (const mesh of meshes) {
// if (mesh) mesh.setMaterial?.(obj)
// }
// }
// }
// })
viewer.scene.addEventListener('select', this._onObjectSelectEvent)
viewer.scene.addEventListener('sceneUpdate', this._onSceneUpdate)
viewer.scene.addEventListener('mainCameraChange', this._mainCameraChange)
viewer.forPlugin<UndoManagerPlugin>('UndoManagerPlugin', (um)=>{
if (!this._picker) return
this._picker.undoManager = um.undoManager
}, ()=>{
if (!this._picker) return
this._picker.undoManager = undefined
})
}
onRemove(viewer: ThreeViewer) {
viewer.scene.removeEventListener('select', this._onObjectSelectEvent)
viewer.scene.removeEventListener('sceneUpdate', this._onSceneUpdate)
viewer.scene.removeEventListener('mainCameraChange', this._mainCameraChange)
this._widget?.removeFromParent()
this._hoverWidget?.removeFromParent()
if (this._picker) {
this._picker.removeEventListener('selectedObjectChanged', this._selectedObjectChanged)
this._picker.removeEventListener('hoverObjectChanged', this._hoverObjectChanged)
this._picker.removeEventListener('hitObject', this._onObjectHit)
this._picker.removeEventListener('selectionModeChanged', this._selectionModeChanged)
this._picker.dispose()
this._picker.undoManager = undefined // because setting above
this._picker = undefined
}
super.onRemove(viewer)
}
dispose() {
super.dispose()
this._widget?.dispose()
this._hoverWidget?.dispose()
}
private _mainCameraChange = ()=>{
if (!this._picker || !this._viewer) return
this._picker.camera = this._viewer.scene.mainCamera
}
private _sceneUpdated = false
private _onSceneUpdate: EventListener2<'sceneUpdate', ISceneEventMap, IScene> = (e)=>{
if (!e.hierarchyChanged) return
this._sceneUpdated = true
}
private _checkSelectedInScene() {
if (this.isDisabled() || !this._viewer) return
const s = this.getSelectedObject()
if (!s || !(s as IObject3D).isObject3D) return // ignoring checking for materials in scene
let inScene = false
;(s as IObject3D).traverseAncestors((o) => {
if (inScene || o !== this._viewer!.scene) return
inScene = true
})
if (!inScene) this.setSelectedObject(undefined, false, false)
}
protected _viewerListeners = {
preFrame: ()=>{
if (!this._viewer || !this._picker) return
if (this._sceneUpdated) {
this._checkSelectedInScene()
this._sceneUpdated = false
}
},
}
private _onObjectSelectEvent: EventListener2<'select', ISceneEventMap, IScene> = (e)=>{
if (e.source === PickingPlugin.PluginType) return
if (e.object === undefined && e.value === undefined) console.error('PickingPlugin - Error handling object/material `select` event `e.object` or `e.value` must be set for picking, `value` can be null to unselect')
else this.setSelectedObject(e.object || e.value, this.autoFocus || e.focusCamera, true)
}
private _selectedObjectChanged: EventListener2<'selectedObjectChanged', ObjectPickerEventMap, ObjectPicker> = (e: any) => {
if (!this._viewer) return
this.dispatchEvent(e)
const selected = this._picker?.selectedObject || undefined // or use e.object. doing this so that listeners can change the selected object in dispatch above
const frameFade = this._viewer.getPlugin(FrameFadePlugin)
if (frameFade) {
if (selected) frameFade.disable(this)
else frameFade.enable(this)
}
this._viewer.scene.autoNearFarEnabled = !selected // for widgets etc, this can be removed when they are rendered in a separate pass
if (this._pickUi) {
const ui = this.uiConfig
ui.children = [...this._uiConfigChildren]
if (selected) {
if ((selected as IObject3D).isObject3D) {
const obj = (selected as IObject3D)
ui.children.push(
{
type: 'button',
label: 'Focus',
value: () => {
if (!obj.isObject3D) return
// const selected = this.getSelectedObject()
if (selected.assetType && obj.parentRoot) // todo also check if acceptChildEvents is set on some parent?
obj.dispatchEvent({
type: 'select',
ui: true,
object: obj,
bubbleToParent: true,
focusCamera: true,
})
else
this.setSelectedObject(obj, true)
},
},
{
type: 'button',
label: 'Select Parent',
hidden: () => !obj.parent,
value: () => {
if (!obj.isObject3D) return
const parent = obj.parent
if (parent) {
if (parent.assetType && parent.parentRoot) // todo also check if acceptChildEvents is set on some parent?
parent.dispatchEvent({
type: 'select',
ui: true,
bubbleToParent: true,
object: parent,
})
else
this.setSelectedObject(parent, false)
}
},
},
)
}
let c = selected.uiConfig
if (c) {
if (c.type === 'folder') safeSetProperty(c, 'expanded', true, true)
ui.children.push(c)
// todo children need to be added back to config on selection change or error
// const objChildren = c.children
// find all children after type divider
// const dividerIndex = objChildren?.findIndex((c1) => typeof c1 === 'object' && (c1.type === 'divider' || c1.type === 'separator')) ?? -1
// if (dividerIndex >= 0) {
// ui.children.push(...objChildren!.slice(dividerIndex + 1))
// c.children = objChildren!.slice(0, dividerIndex)
// }
} else {
// check materials
const mats = (selected as IObject3D).materials ?? [(selected as IObject3D).material as IMaterial]
for (const m of mats) {
c = m?.uiConfig
if (c) ui.children.push(c)
}
}
} else {
ui.children.push(this._pickPromptUi)
}
ui.uiRefresh?.()
}
const widget = this._widget
if (widget && this.widgetEnabled) {
if ((selected as IObject3D)?.isObject3D) widget.attach((selected as IObject3D))
else widget.detach()
}
// if (selected) selected.dispatchEvent({type: 'selected', source: PickingPlugin.PluginType, object: selected})
this._viewer.setDirty()
if (this.autoFocus && this.selectionMode === 'object') {
// this._viewer.resetCamera({rootObject: selected, centerOffset: new Vector3(4, 4, 4)})
this.focusObject(selected as IObject3D | undefined)
}
}
private _hoverObjectChanged = (e: any) => {
if (!this._viewer) return
this.dispatchEvent(e)
const selected = this._picker?.hoverObject || undefined
const widget = this._hoverWidget
if (widget && this.widgetEnabled) {
if ((selected as IObject3D)?.isObject3D) widget.attach((selected as IObject3D))
else widget.detach()
}
// if (selected) selected.dispatchEvent({type: 'selected', source: PickingPlugin.PluginType, object: selected})
this._viewer?.setDirty()
if (this.autoFocusHover && this.selectionMode === 'object') {
// this._viewer?.resetCamera({rootObject: selected, centerOffset: new Vector3(4, 4, 4)})
this.focusObject(selected as IObject3D | undefined)
}
}
private _onObjectHit = (e: any)=>{
if (!this._viewer) return
if (this.isDisabled()) {
e.intersects.selectedObject = null
return
}
this.dispatchEvent(e)
}
private _selectionModeChanged = (e: any)=>{
if (!this._viewer) return
this.dispatchEvent(e)
if (this.isDisabled()) return
this.uiConfig?.uiRefresh?.(true, 'postFrame', 1)
}
public async focusObject(selected?: Object3D|null) {
this._viewer?.fitToView(selected ?? undefined, 1.25, 1000, 'easeOut')
}
private _pickPromptUi: UiObjectConfig = {
type: 'button',
label: 'Select an object to see its properties',
readOnly: true,
hidden: () => this.getSelectedObject() !== undefined,
}
private _uiConfigChildren: UiObjectConfig[] = [
{
label: 'Enabled',
type: 'checkbox',
property: [this, 'enabled'],
},
{
label: 'Hover Enabled',
type: 'checkbox',
property: [this, 'hoverEnabled'],
onChange: ()=>this.uiConfig.uiRefresh?.(true), // for autoFocusHover
},
// {
// label: 'Selection Mode',
// type: 'dropdown',
// children: ['object', 'material'].map(v=>({label: v, value: v})),
// onChange: ()=>this.uiConfig.uiRefresh?.(true),
// },
{
label: 'Auto Focus',
type: 'checkbox',
property: [this, 'autoFocus'],
onChange: ()=>{
const o = this.getSelectedObject()
if (this.autoFocus && o) this.setSelectedObject(o, true)
},
},
{
label: 'Auto Focus on Hover',
type: 'checkbox',
hidden: ()=>!this.hoverEnabled,
property: [this, 'autoFocusHover'],
},
{
label: 'Widget Enabled',
type: 'checkbox',
property: [this, 'widgetEnabled'],
},
]
uiConfig: UiObjectConfig = {
type: 'panel',
label: 'Picker',
expanded: true,
children: [
...this._uiConfigChildren,
this._pickPromptUi,
],
}
get widget(): SelectionWidget | undefined {
return this._widget
}
}