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.
302 lines (274 loc) • 11.8 kB
text/typescript
import {Object3D, Vector3} from 'three'
import {AViewerPluginSync, ThreeViewer} from '../../viewer'
import {PickingPlugin} from '../interaction/PickingPlugin'
import {UiObjectConfig} from 'uiconfig.js'
import {serialize} from 'ts-browser-helpers'
import {snapObject} from '../../three'
import {IObject3D} from '../../core'
/**
* Switch Node Plugin (Base)
*
* This plugin allows you to configure object variations in a file and apply them in the scene.
* Each SwitchNode is a parent object with multiple direct children. Only one child is visible at a time.
* This works by toggling the `visible` property of the children of a parent object.
* The plugin interfaces with the picking plugin and also provides uiConfig to show and edit the variations.
* It also provides a function to create snapshot previews of individual variations. This creates a limited render of the object with the selected child visible.
* To get a proper render, it's better to render it offline and set the image as a preview.
*
* See `SwitchNodePlugin` in [plugin-configurator](https://threepipe.org/plugins/configurator/docs/index.html) for example on inheriting with a custom UI renderer.
*
* @category Plugins
*/
export class SwitchNodeBasePlugin extends AViewerPluginSync {
public static readonly PluginType = 'SwitchNodePlugin'
enabled = true
private _picking: PickingPlugin | undefined
private _uiNeedRefresh = false
constructor() {
super()
this._postFrame = this._postFrame.bind(this)
this.refreshUiConfig = this.refreshUiConfig.bind(this)
this.addEventListener('deserialize', async() => {
// await timeout(200) // not needed actually
this.refreshUi()
})
}
onAdded(viewer: ThreeViewer) {
super.onAdded(viewer)
// todo subscribe to plugin add event if picking is not added yet.
this._picking = viewer.getPlugin<PickingPlugin>('Picking')
this._picking?.addEventListener('selectedObjectChanged', this.refreshUiConfig) // don't call this.refreshUi here
viewer.addEventListener('postFrame', this._postFrame)
}
onRemove(viewer: ThreeViewer) {
this._picking = viewer.getPlugin<PickingPlugin>('Picking')
this._picking?.removeEventListener('selectedObjectChanged', this.refreshUiConfig)
viewer.removeEventListener('postFrame', this._postFrame)
super.onRemove(viewer)
}
protected _postFrame() {
if (this._uiNeedRefresh) this._refreshUi() // only call this from here.
}
/**
* Whether refreshScene should be called when a node is selected. Refreshing scene will notify the plugins about the update, like shadows can be baked.
* Disable this when nothing significant geometry/node changes happen when switch nodes are changed.
*/
refreshScene = true
/**
* Select a switch node variation with name or uuid.
* @param node
* @param nameOrUuid
* @param setDirty - set dirty in the viewer after update.
*/
selectNode(node: ObjectSwitchNode, nameOrUuid: string|number, setDirty = true) {
const obj = this._viewer?.scene.getObjectByName(node.name)
if (!obj || obj.children.length < 1) return
const child = typeof nameOrUuid === 'number' ?
obj.children[nameOrUuid] :
obj.children.find(c => c.name === nameOrUuid || c.uuid === nameOrUuid)
if (!child) {
this._viewer?.console.warn('SwitchNodePlugin: child not found', nameOrUuid)
return false
}
node.selected = child.name || child.uuid
let changed = false
for (const child1 of obj.children) {
const visible = child1.visible
child1.visible = (child1.name || child1.uuid) === node.selected
changed = changed || visible !== child1.visible
}
if (changed && setDirty) this._viewer!.scene.setDirty({refreshScene: this.refreshScene, frameFade: true})
return changed
}
/**
* Apply all variations(by selected index or first item) when a config is loaded
*/
applyOnLoad = true
/**
* Reapply all selected variations again.
* Useful when the scene is loaded or changed and the variations are not applied.
*/
reapplyAll() {
this.variations.forEach(v => this.selectNode(v, v.selected || 0, false))
this._viewer!.scene.setDirty({refreshScene: true, frameFade: true})
}
fromJSON(data: any, meta?: any): this | Promise<this | null> | null {
this.variations = []
if (!super.fromJSON(data, meta)) return null // its not a promise
if (data.applyOnLoad === undefined) { // old files
this.applyOnLoad = true // setting true because all the items will be visible otherwise.
}
if (this.applyOnLoad) this.reapplyAll()
return this
}
refreshUi() {
if (!this.enabled) return
this._uiNeedRefresh = true
}
protected _refreshUi(): boolean {
if (!this.enabled) return false
if (!this._viewer) return false
this._uiNeedRefresh = false
if (this.autoSnapIcons) this.snapIcons()
this.refreshUiConfig()
return true
}
refreshUiConfig() {
if (!this.enabled) return
this.uiConfig.uiRefresh?.()
}
variations: ObjectSwitchNode[] = []
protected _selectedSwitchNode = (): Object3D | undefined => {
const obj = this._picking?.getSelectedObject<IObject3D>() // (?.material || undefined) as IMaterial | undefined
if (!obj?.isObject3D) return undefined
const nodes = this.variations.map(v => v.name)
let found: Object3D | undefined = undefined
obj.traverseAncestors(a => {
if (found) return
if (!a.name) return
if (nodes.includes(a.name)) found = a
})
return found
}
/**
* Get the preview for a switch node variation
* Should be called from preFrame ideally. (or preRender but set viewerSetDirty = false)
* @param child - Child Object to get the preview for
* @param variation - Switch node variation that contains the child.
* @param viewerSetDirty - call viewer.setDirty() after setting the preview. So that the preview is cleared from the canvas.
*/
getPreview(variation: ObjectSwitchNode, child: Object3D, viewerSetDirty = true): string {
if (!this._viewer || !variation) return ''
// const m = typeof material === 'number' ? variation.materials[material] : material
const cv = variation.camView
const camOffset = new Vector3(
(cv.includes('right') ? 1 : 0) - (cv.includes('left') ? 1 : 0),
(cv.includes('top') ? 1 : 0) - (cv.includes('bottom') ? 1 : 0),
(cv.includes('front') ? 1 : 0) - (cv.includes('back') ? 1 : 0)
)
if (!variation.camDistance) variation.camDistance = 1
const image = snapObject(this._viewer!.renderManager.renderer, child, this._viewer?.scene, 7, camOffset.multiplyScalar(variation.camDistance * 2))
if (viewerSetDirty) this._viewer.setDirty() // because called from preFrame
return image
}
addNode(node: ObjectSwitchNode, refreshUi = true) {
this.variations.push(node)
if (refreshUi) this.refreshUi()
}
/**
* If true, the plugin will automatically take snapshots of the icons in _refreshUi and put them in the object.userdata.__icon
* Otherwise, call {@link snapIcons} manually
*/
autoSnapIcons = false
/**
* Snapshots icons and puts in the userdata.__icon
*/
snapIcons() {
for (const variation of this.variations) {
const obj = this._viewer!.scene.getObjectByName(variation.name)
if (!obj) {
console.warn('no object found for variation, skipping', variation)
continue
}
if (obj.children.length < 1) {
console.warn('SwitchNode does not have enough children', variation)
}
for (const child of obj.children) {
if (child.userData.__icon) return
const image = this.getPreview(variation, child, false)
if (image) child.userData.__icon = image
}
}
}
uiConfig: UiObjectConfig = {
label: 'Switch Node Plugin',
type: 'folder',
// expanded: true,
children: [
{
type: 'checkbox',
label: 'Enabled',
property: [this, 'enabled'],
},
() => [
{
type: 'folder',
label: 'All nodes',
expanded: true,
children: [
this.variations.map(v => ({
type: 'input',
label: v.title,
property: [v, 'name'],
onChange: () => this.refreshUi(),
})),
],
},
{
type: 'button',
label: 'Add Node',
value: () => {
this.addNode({
name: 'switch_node',
selected: '',
title: 'Switch Node',
camView: 'front',
camDistance: 1,
})
},
},
{
type: 'button',
label: 'Refresh UI',
value: () => this.refreshUi(),
},
{
type: 'input',
label: 'Selected node title',
hidden: () => !this._selectedSwitchNode(),
property: () => {
const node = this._selectedSwitchNode()
if (!node) return []
return [this.variations.find(v => v.name === node.name), 'title']
},
onChange: () => this.refreshUi(),
},
{
type: 'slider',
bounds: [0.01, 2],
stepSize: 0.01,
label: 'Cam Distance',
hidden: () => !this._selectedSwitchNode(),
property: () => {
const node = this._selectedSwitchNode()
if (!node) return []
return [this.variations.find(v => v.name === node.name), 'camDistance']
},
// onChange: ()=> this.refreshUi(),
},
{
type: 'dropdown',
label: 'Cam View',
hidden: () => !this._selectedSwitchNode(),
property: () => {
const node = this._selectedSwitchNode()
if (!node) return []
return [this.variations.find(v => v.name === node.name), 'camView']
},
onChange: () => this.refreshUi(),
children: ['top', 'bottom', 'front', 'back', 'left', 'right'].map(k => ({
label: k,
value: k,
})),
},
],
],
}
}
export interface ObjectSwitchNode{
name: string,
title: string,
selected: string,
camView: 'top'|'bottom'|'front'|'back'|'left'|'right'|string,
camDistance: number,
}