import {createDiv, createStyles, css} from 'ts-browser-helpers' import Tree from 'treejs' import { AViewerPluginSync, IObject3D, ISceneEventMap, Object3D, PickingPlugin, ThreeViewer, type UndoManagerPlugin, Event2, } from 'threepipe' import {UiObjectConfig} from 'uiconfig.js' export class HierarchyUiPlugin extends AViewerPluginSync { enabled = true public static readonly PluginType = 'HierarchyUiPlugin' toJSON: any = undefined treeView?: Tree = undefined hierarchyDiv = createDiv({ innerHTML: '', id: 'tpHierarchyContainer', addToBody: false, }) constructor(enabled = true) { super() this.enabled = enabled this.reset = this.reset.bind(this) this._postFrame = this._postFrame.bind(this) this.uiConfig.domChildren = [this.hierarchyDiv] createStyles(css` #tpHierarchyContainer{ width: 100%; height: auto; background-color: transparent; color: var(--tp-container-foreground-color, hsl(230, 7%, 75%)); margin-top: 0; } .treejs .treejs-switcher:before { border-top: 6px solid var(--tp-container-foreground-color, hsl(230, 7%, 75%)) !important; } .treejs .treejs-switcher,.treejs-label,.treejs-checkbox { pointer-events: auto; } .treejs .treejs-node { pointer-events: none; position: relative; } .treejs .treejs-label { position: absolute; height: 16px; line-height: 16px; text-overflow: ellipsis; overflow: hidden; width: calc(100% - 50px); padding: 1px 4px; margin: 1px 0px; border-radius: 2px; box-sizing: content-box; } .treejs .treejs-node-selected .treejs-label { outline-offset: -1px; outline: solid 1px #1890ff; } .treejs .treejs-node-selected > .treejs-label { background-color: #067ce9; color: #eee; outline: none; } `) } reset(e?: Event2<'sceneUpdate', ISceneEventMap, IObject3D>) { if (e?.source === HierarchyUiPlugin.PluginType) return // for infinite loop // visible changed from outside if (e && e.object && e.change === 'visible' && this.treeView) { const nodeId = e.object.uuid // @ts-expect-error no type const node = this.treeView.nodesById[nodeId] as TNode if (node && !!node.status !== e.object.visible) { this.treeView.setValue(nodeId) this.treeView.updateLiElements() } // this.treeView.values = obj.children.reduce(this._findVisible, []) // console.log(this.treeView.values) } if (!e?.hierarchyChanged) return this._needsReset = true } protected async _reset() { this._needsReset = false while (this.hierarchyDiv.firstChild) this.hierarchyDiv.firstChild.remove() const obj = this._viewer?.scene.modelRoot if (!obj) return const data = obj.children.reduce(this._buildData, []) let firstChange = false return new Promise((resolve, _reject) => { this.treeView = new Tree(this.hierarchyDiv, { closeDepth: 1, data, // values: visible, // uuids of visible nodes loaded: function() { this.values = [] resolve() }, onChange: () => { if (!firstChange) { // first time called when loaded firstChange = true return } // timeout(200).then(() => { // wait for the UI to update this._setVisible() // }) }, onItemLabelClick: (item: any) => { const obj1 = this._viewer?.scene.modelRoot.getObjectByProperty('uuid', item) if (!obj1 || !obj.visible) return obj1.dispatchEvent({type: 'select', value: obj1, object: obj1, ui: true}) }, }) }).then(()=>{ this._refreshVisible() this._selectedObjectChanged() }) } private _refreshVisible() { const obj = this._viewer?.scene.modelRoot as Object3D if (!obj) return const visible = obj.children.reduce(this._findVisible, []) this.treeView?.emptyNodesCheckStatus() this.treeView?.treeNodes.map(n=>refreshVisible(n, visible, this.treeView!)) this.treeView?.updateLiElements() } onAdded(viewer: ThreeViewer) { super.onAdded(viewer) viewer.scene.addEventListener('sceneUpdate', this.reset) viewer.addEventListener('postFrame', this._postFrame) viewer.forPlugin('PickingPlugin', (pi)=>{ pi.addEventListener('selectedObjectChanged', this._selectedObjectChanged) }, (pi)=>{ pi.removeEventListener('selectedObjectChanged', this._selectedObjectChanged) }) viewer.forPlugin('UndoManagerPlugin', (um)=>{ this.undoManager = um.undoManager }, ()=>{ this.undoManager = undefined }) this.reset() } undoManager?: UndoManagerPlugin['undoManager'] = undefined private _selectedObjectChanged = ()=>{ const picking = this._viewer?.getPlugin(PickingPlugin) if (!picking || !this.treeView) return const liElem = this.treeView.liElementsById as Record const elems = Object.values(liElem) for (const li of elems) { li.classList.remove('treejs-node-selected') } const selected = picking.getSelectedObject() as Object3D|undefined if (selected?.uuid) { const li = liElem[selected?.uuid] if (li) { li.classList.add('treejs-node-selected') li.scrollIntoView({block: 'nearest', inline: 'nearest'}) } } } onRemove(viewer: ThreeViewer) { // todo: remove UI element. viewer.scene.removeEventListener('sceneUpdate', this.reset) viewer.removeEventListener('postFrame', this._postFrame) viewer.getPlugin(PickingPlugin)?.removeEventListener('selectedObjectChanged', this._selectedObjectChanged) return super.onRemove(viewer) } protected _needsReset = false protected _postFrame() { if (this._needsReset) this._reset() } dispose() { // todo destroy UI element. } uiConfig: UiObjectConfig = { type: 'folder', label: 'Hierarchy', children: [], } private _buildData = (data: any[], obj: IObject3D): any[] => { data.push({ text: obj.name || 'unnamed', id: obj.uuid, children: obj.children.reduce(this._buildData, []), }) return data } private _findVisible = (data: any[], obj: Object3D): any[] => { // only leaf. if (!obj.visible) return data data.push(obj.uuid) data.push(...obj.children.reduce(this._findVisible, [])) return data } private _setVisible = (): void => { this._viewer?.doOnce('postFrame', () => { const obj = this._viewer?.scene.modelRoot if (!obj || !this.treeView) return const changeMap: Map = new Map() function cAdd(o: Object3D, v: boolean) { const c = changeMap.get(o)?.[0] ?? o.visible if (v !== c) changeMap.set(o, [v, o.visible]) } obj.traverse((o)=>{ if (o === obj) return // @ts-expect-error no type const node = this.treeView.nodesById[o.uuid] as TNode|undefined if (node) { cAdd(o, !!node.status) if (o.visible) o.traverseAncestors(p => cAdd(p, true)) } }) const cmd = { redo: (refresh = true)=>{ let changed = false changeMap.entries().forEach(([o, v])=> { if (o.visible === v[0]) return o.visible = v[0] changed = true }) if (!changed) return this._viewer?.scene?.setDirty({refreshScene: true, source: HierarchyUiPlugin.PluginType, updateGround: false}) refresh && this._refreshVisible() }, undo: ()=>{ let changed = false changeMap.entries().forEach(([o, v])=> { if (o.visible === v[1]) return o.visible = v[1] changed = true }) if (!changed) return this._viewer?.scene?.setDirty({refreshScene: true, source: HierarchyUiPlugin.PluginType, updateGround: false}) this._refreshVisible() }, } cmd.redo(false) this.undoManager?.record(cmd) }) } } interface TNode {id: string, status: 0|1|2, text: string, children: TNode[]} // this is required because setValues in Treejs toggles it, not sets it function refreshVisible(node: TNode, visibles: string[], tree: Tree) { const v = visibles.includes(node.id) const last = node.status if (node.children.length) { let allVisible = true for (const child of node.children) { const res = refreshVisible(child, visibles, tree) if (!res) allVisible = false } node.status = v ? allVisible ? 2 : 1 : 0 } else { node.status = v ? 2 : 0 } if (last !== node.status) { // @ts-expect-error no type tree.willUpdateNodesById[node.id] = node } return node.status }