UNPKG

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.

622 lines (569 loc) 25.8 kB
import {AViewerPluginEventMap, AViewerPluginSync, ThreeViewer} from '../../viewer' import {IAnimationLoopEvent, IObject3D} from '../../core' import {UiObjectConfig} from 'uiconfig.js' import { ComponentCtx, ComponentJSON, getComponentTypes, Object3DComponent, setupComponent, TObject3DComponent, } from './components' import {ViewerEventMap} from '../../viewer/ThreeViewer' import {teardownComponent} from './components/setupComponent' export type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends (((...args: any[]) => any)|undefined) ? K : never; }[keyof T] & string export interface EntityComponentPluginEventMap extends AViewerPluginEventMap{ registerComponent: {component: Object3DComponent, object: IObject3D} unregisterComponent: {component: Object3DComponent, object: IObject3D} addComponentType: {cls: TObject3DComponent} removeComponentType: {cls: TObject3DComponent} } /** * Entity Component Framework plugin for threepipe. * Allows attaching reusable components to IObject3D instances. * Components can have their own serializable/runtime state, lifecycle methods, and update logic. * Components are defined as classes extending Object3DComponent. * * This system is not documented at the moment. */ export class EntityComponentPlugin extends AViewerPluginSync<EntityComponentPluginEventMap> { public static readonly PluginType = 'EntityComponentPlugin' enabled = true protected _running: boolean get running() { return this._running } start() { if (this._running) return this._running = true for (const comp of this._components.values()) { try { comp.start() } catch (e) { console.error('EntityComponentPlugin: Error starting component', comp) console.error(e) } } if (this._viewer && this._components.size) this._viewer.setDirty(this) } stop() { if (!this._running) return this._running = false for (const comp of this._components.values()) { try { comp.stop() } catch (e) { console.error('EntityComponentPlugin: Error stopping component', comp) console.error(e) } } if (this._viewer && this._components.size) this._viewer.setDirty(this) } componentsDispatch<T extends FunctionPropertyNames<Object3DComponent>>( type: T, ...args: Parameters<NonNullable<Object3DComponent[T]>> ) { EntityComponentPlugin.ComponentsDispatch([...this._components.values()], type as any, args) } private readonly _components: Map<string, Object3DComponent> = new Map() private _typeToComponents: Map<string, Set<Object3DComponent>> = new Map() readonly componentTypes: Map<string, TObject3DComponent> = new Map() static readonly ObjectToComponents: WeakMap<IObject3D, Object3DComponent[]> = new Map() static ObjectDispatch<T extends FunctionPropertyNames<Object3DComponent>>( object: IObject3D, type: T, ...args: Parameters<NonNullable<Object3DComponent[T]>> ) { const comps = EntityComponentPlugin.ObjectToComponents.get(object) if (comps) { EntityComponentPlugin.ComponentsDispatch(comps, type, args) } } static ComponentsDispatch<T extends FunctionPropertyNames<Object3DComponent>>( comps: Object3DComponent[], type: T, args: Parameters<NonNullable<Object3DComponent[T]>>, ) { for (const comp of comps) { const l = comp[type] if (typeof l === 'function') { try { l.apply(comp, args) } catch (err) { console.error(`EntityComponentPlugin: Error in component ${comp.constructor.ComponentType} handling ${type}`, comp, err) } } } } static UserDataKey = EntityComponentPlugin.PluginType constructor(running = true) { super() this.componentTypes.set(Object3DComponent.ComponentType, Object3DComponent) this._running = running } addComponentType(type: TObject3DComponent) { if (!type || typeof type !== 'function') { throw new Error('EntityComponentPlugin: invalid component type') } if (!type.ComponentType || typeof type.ComponentType !== 'string') { throw new Error('EntityComponentPlugin: component type must have a valid string "ComponentType" property') } if (this.componentTypes.has(type.ComponentType)) { console.warn(`EntityComponentPlugin: component type "${type.ComponentType}" already registered`) return false } this.componentTypes.set(type.ComponentType, type) this.dispatchEvent({type: 'addComponentType', cls:type}) // loop through objects this._viewer?.object3dManager.getObjects().forEach(object=>this._objectAdd({object, componentType: type.ComponentType})) return true } removeComponentType(type: TObject3DComponent) { if (!type || typeof type !== 'function') { throw new Error('EntityComponentPlugin: invalid component type') } if (!type.ComponentType || typeof type.ComponentType !== 'string') { throw new Error('EntityComponentPlugin: component type must have a valid string "ComponentType" property') } if (!this.componentTypes.has(type.ComponentType)) { console.warn(`EntityComponentPlugin: component type "${type.ComponentType}" not registered`) return false } this.dispatchEvent({type: 'removeComponentType', cls:type}) // loop through objects this._viewer?.object3dManager.getObjects().forEach(object=>this._objectRemove({object, componentType: type.ComponentType})) this.componentTypes.delete(type.ComponentType) return true } hasComponentType(type: TObject3DComponent | string) { const typeStr = typeof type === 'string' ? type : type.ComponentType return this.componentTypes.has(typeStr) } private _onRemove: (()=>void)[] = [] onAdded(viewer: ThreeViewer) { super.onAdded(viewer) viewer.object3dManager.getObjects().forEach(object=>this._objectAdd({object})) viewer.object3dManager.addEventListener('objectAdd', this._objectAdd) viewer.object3dManager.addEventListener('objectRemove', this._objectRemove) viewer.scene.addEventListener('objectUpdate', this._objectUpdate) const offUpdate = viewer.on('preFrame', { order: 1, callback: this._preFrame, }) if (offUpdate) this._onRemove.push(offUpdate) } private _preFrame = (e: ViewerEventMap['preFrame'])=>{ if (this.isDisabled() || !this._running || !this._viewer?.renderEnabled) return let dirty = false // todo component exec sort order? this._components.forEach((comp)=>{ try { const res = comp.update(e as IAnimationLoopEvent) if (res === true) dirty = true } catch (err: any) { console.error(`EntityComponentPlugin: Error updating component ${comp.constructor.ComponentType}`, comp, err) } }) if (dirty) this._viewer?.setDirty(this) } onRemove(viewer: ThreeViewer) { viewer.object3dManager.removeEventListener('objectAdd', this._objectAdd) viewer.object3dManager.removeEventListener('objectRemove', this._objectRemove) viewer.object3dManager.getObjects().forEach(object=>this._objectRemove({object})) viewer.scene.removeEventListener('objectUpdate', this._objectUpdate) this._onRemove.forEach(f=>f()) super.onRemove(viewer) } static GetObjectData(obj: IObject3D) { let data = obj.userData[EntityComponentPlugin.UserDataKey] as Record<string, ComponentJSON>|undefined if (data) { if (typeof data !== 'object') { console.warn(`EntityComponentPlugin: userData.${EntityComponentPlugin.UserDataKey} is not an object`, obj) data = {} obj.userData[EntityComponentPlugin.UserDataKey] = data } } return data || null } addComponent<T extends TObject3DComponent = TObject3DComponent>(obj: IObject3D, stateOrType: ComponentJSON|string|T, id?: string) { if (!this._viewer) throw new Error('EntityComponentPlugin: no viewer') const state = !stateOrType ? {type: 'Object3DComponent', state: {}} : typeof stateOrType === 'string' ? {type: stateOrType, state: {}} : (stateOrType as TObject3DComponent).ComponentType ? {type: (stateOrType as TObject3DComponent).ComponentType, state: {}} : stateOrType && typeof (stateOrType as ComponentJSON).type === 'string' && (stateOrType as ComponentJSON).state ? (stateOrType as ComponentJSON) : {type: 'Object3DComponent', state: {}} if ((stateOrType as TObject3DComponent).ComponentType) { if (!this.hasComponentType((stateOrType as TObject3DComponent))) { this.addComponentType(stateOrType as TObject3DComponent) } } const comp = this.registerComponent(obj, state, id) as InstanceType<T> if (!comp) throw new Error('EntityComponentPlugin: cannot create component of type ' + state.type) let data = EntityComponentPlugin.GetObjectData(obj) if (!data) { data = {} obj.userData[EntityComponentPlugin.UserDataKey] = data } if (!data[comp.uuid]) { data[comp.uuid] = {state: comp.stateRef, type: comp.constructor.ComponentType} } else { data[comp.uuid].type = comp.constructor.ComponentType data[comp.uuid].state = comp.stateRef } obj.setDirty && obj.setDirty({change: `userData.${EntityComponentPlugin.UserDataKey}`, source: 'EntityComponentPlugin.addComponent', refreshUi: true}) // undo/redo action const action = { undo: ()=>{ const r = this.removeComponent(obj, action.component.uuid) if (r) action.redo = r.undo }, redo: ()=>{ this.addComponent(obj, stateOrType) }, component: comp, } return action } removeComponent(obj: IObject3D, id: string) { if (!this._viewer) return const comp = this._components.get(obj.uuid + id) if (!comp) return const type = comp.constructor.ComponentType const state = this.unregisterComponent(comp) const data = EntityComponentPlugin.GetObjectData(obj) if (data) { delete data[id] if (Object.keys(data).length === 0) { delete obj.userData[EntityComponentPlugin.UserDataKey] } obj.setDirty && obj.setDirty({change: `userData.${EntityComponentPlugin.UserDataKey}`, source: 'EntityComponentPlugin.removeComponent', refreshUi: true}) } const action = { state: state, undo: ()=>{ if (action.state) this.addComponent(obj, {type, state: action.state}, id) }, redo: ()=>{ this.removeComponent(obj, id) }, } return action } static GetComponentData<T extends TObject3DComponent = TObject3DComponent>(obj: IObject3D, type: string|T) { if (!obj) return null const data = EntityComponentPlugin.GetObjectData(obj) if (!data) return null const typeTarget = typeof type === 'string' ? [type] : [...getComponentTypes(type)] for (const [k, v] of Object.entries(data)) { for (const t of typeTarget) { if (v.type === t) return {id: k, ...v} as {id: string} & ComponentJSON } } const c = EntityComponentPlugin.GetComponent(obj, type) if (c) return {id: c.uuid, type: c.constructor.ComponentType, state: c.stateRef} as {id: string} & ComponentJSON return null } static GetComponents<T extends TObject3DComponent = TObject3DComponent>(obj: IObject3D, type: string|T) { if (!obj) return [] const comps = EntityComponentPlugin.ObjectToComponents.get(obj) || [] const typeTarget = typeof type === 'string' ? [type] : [...getComponentTypes(type)] return comps.filter(c=>{ const types = getComponentTypes(c.constructor) return typeTarget.some(t=>types.has(t)) }) as InstanceType<T>[] } static GetComponent<T extends TObject3DComponent = TObject3DComponent>(obj: IObject3D, type: string|T) { if (!obj) return null const comps = EntityComponentPlugin.ObjectToComponents.get(obj) || [] const typeTarget = typeof type === 'string' ? [type] : [...getComponentTypes(type)] for (const c of comps) { const types = getComponentTypes(c.constructor) for (const t of typeTarget) { if (types.has(t)) return c as InstanceType<T> } } return null } static GetComponentInParent<T extends TObject3DComponent = TObject3DComponent>(object: IObject3D, type: string|T) { if (!object) return null let obj: IObject3D|null = object let comp: InstanceType<T> | null = null while (!comp && obj) { comp = EntityComponentPlugin.GetComponent(obj, type) obj = obj.parent } return comp } static GetComponentsInParent<T extends TObject3DComponent = TObject3DComponent>(object: IObject3D, type: string|T) { if (!object) return [] let obj: IObject3D|null = object const comps: InstanceType<T>[] = [] while (obj) { comps.push(...EntityComponentPlugin.GetComponents(obj, type)) obj = obj.parent } return comps } /** * Get all components of a specific type from the plugin instance * @param type - The component type (string or class) * @returns Array of components matching the specified type */ getComponentsOfType<T extends TObject3DComponent = TObject3DComponent>(type: string|T): InstanceType<T>[] { const typeStr = typeof type === 'string' ? type : type.ComponentType const compSet = this._typeToComponents.get(typeStr) if (!compSet) return [] return [...compSet] as InstanceType<T>[] } /** * Get the first component of a specific type from the plugin instance * @param type - The component type (string or class) * @returns The first component matching the specified type, or null if not found */ getComponentOfType<T extends TObject3DComponent = TObject3DComponent>(type: string|T): InstanceType<T> | null { const typeStr = typeof type === 'string' ? type : type.ComponentType const compSet = this._typeToComponents.get(typeStr) if (!compSet || compSet.size === 0) return null return compSet.values().next().value as InstanceType<T> } registerComponent(obj: IObject3D, state: ComponentJSON, id?: string) { if (!this._viewer) throw new Error('EntityComponentPlugin: no viewer') if (!obj) throw new Error('EntityComponentPlugin: no object') if (!state || typeof state !== 'object') { console.warn('EntityComponentPlugin: invalid component state', state, obj) state = {type: 'Object3DComponent', state: {}} } if (id) { const comp = this._components.get(obj.uuid + id) if (comp) { if (comp.object !== obj) { console.error(`EntityComponentPlugin: component with id ${id} already exists on a different object`) comp.object = obj } if (comp.constructor.ComponentType !== state.type) { console.warn(`EntityComponentPlugin: component with id ${id} type mismatch (${comp.constructor.ComponentType} != ${state.type}), removing previous component and creating new one`) this.unregisterComponent(comp) // continue to create new component } else { comp.setState(state.state) } return comp } } const cls = this.componentTypes.get(state.type) // todo why making a new one for every component? const ctx: ComponentCtx = { viewer: this._viewer, ecp: this, // object: obj, plugin: (p)=>{ const i = ctx.viewer?.getPlugin(p) if (!i) { throw new Error(`EntityComponentPlugin: cannot find plugin ${typeof p === 'string' ? p : p.name}`) } return i }, // component(c) { // const comp = EntityComponentPlugin.GetComponent(ctx.object, c) // if (!comp) { // throw new Error(`EntityComponentPlugin: cannot find component ${typeof c === 'string' ? c : c.name} on object`) // } // return comp // }, } let comp try { // todo when cls doesnt exist, and a component type is registered, it needs to be recreated comp = cls ? new cls() : new Object3DComponent() if (!cls) { console.error('EntityComponentPlugin: unknown component type ' + state.type, obj) comp._sType = state.type } if (id) comp.uuid = id setupComponent(comp, ctx) } catch (e) { console.error('EntityComponentPlugin: Error creating component of type ' + state.type) console.error(e) return null } this._components.set(obj.uuid + comp.uuid, comp) EntityComponentPlugin.ObjectToComponents.set(obj, [...EntityComponentPlugin.ObjectToComponents.get(obj) || [], comp]) const typeSet = this._typeToComponents.get(comp.constructor.ComponentType) || new Set() typeSet.add(comp) this._typeToComponents.set(comp.constructor.ComponentType, typeSet) try { comp.init(obj, state.state) } catch (e) { console.error('EntityComponentPlugin: Error initializing component', comp) console.error(e) } this.dispatchEvent({type: 'registerComponent', component: comp, object: obj}) try { if (this.running) comp.start() } catch (e) { console.error('EntityComponentPlugin: Error starting component', comp) console.error(e) } return comp } unregisterComponent(comp: Object3DComponent) { if (!comp) return const obj = comp.object let state: Record<string, any>|null = null if (!obj) { console.warn('EntityComponentPlugin: component already destroyed', comp) } else { try { if (this.running) comp.stop() } catch (e) { console.error('EntityComponentPlugin: Error stopping component', comp) console.error(e) } // this.dispatchEvent({type: 'unregisterComponent', component: comp, object: obj}) try { state = comp.destroy() } catch (e) { console.error('EntityComponentPlugin: Error destroying component', comp) console.error(e) } } this._components.delete(obj.uuid + comp.uuid) const typeSet = this._typeToComponents.get(comp.constructor.ComponentType) if (typeSet) { typeSet.delete(comp) if (typeSet.size === 0) { this._typeToComponents.delete(comp.constructor.ComponentType) } } teardownComponent(comp) const comps = EntityComponentPlugin.ObjectToComponents.get(obj) || [] const index = comps.indexOf(comp) if (index !== -1) { comps.splice(index, 1) if (comps.length === 0) { EntityComponentPlugin.ObjectToComponents.delete(obj) } } if (obj) this.dispatchEvent({type: 'unregisterComponent', component: comp, object: obj}) return state } static AddObjectUiConfig = true private _objectAdd = (e: {object?: IObject3D, componentType?: string})=>{ const obj = e.object if (!obj) return if (obj.isWidget) return // Add getComponent method to object if (!obj.getComponent) { obj.getComponent = <T extends TObject3DComponent>(type: T | string, self = false) => { if (self) return EntityComponentPlugin.GetComponent(obj, type) return EntityComponentPlugin.GetComponentInParent(obj, type) || this.getComponentOfType(type) } } if (!(obj as any)._compUiInit && obj.uiConfig?.children && EntityComponentPlugin.AddObjectUiConfig) { (obj as any)._compUiInit = true const dropdown = { type: 'dropdown', label: 'Add Component', value: '', children: [{ label: 'Select component type', value: '', }, ()=>{ return [...this.componentTypes.values()].map(v=>({ label: v.ComponentType, value: v.ComponentType, })) }], } obj.uiConfig.children.push({ type: 'folder', label: 'Components', tags: [EntityComponentPlugin.PluginType], children: [ dropdown, { type: 'button', label: 'Add Component', // disabled: ()=>!dropdown.value, onClick: () => { if (!dropdown.value) return return this.addComponent(obj, dropdown.value) }, }, ()=>{ const data = EntityComponentPlugin.GetObjectData(obj) const children = !data ? [] : Object.keys(data).map((k)=>{ const comp = this._components.get(obj.uuid + k) return comp?.uiConfig }).filter(c=>!!c) as UiObjectConfig[] return children }, ]}) } const data = EntityComponentPlugin.GetObjectData(obj) if (!data) return Object.entries(data).forEach(([k, v])=>{ if (e.componentType && v.type !== e.componentType) return const comp = this.registerComponent(obj, v, k) if (comp) data[k].state = comp.stateRef }) } private _objectRemove = (e: {object?: IObject3D, componentType?: string})=>{ const obj = e.object if (!obj) return const data = EntityComponentPlugin.GetObjectData(obj) // Remove getComponent method from object if (obj.getComponent) { delete obj.getComponent } // remove ui config by tags if ((obj as any)._compUiInit && obj.uiConfig?.children) { (obj as any)._compUiInit = false obj.uiConfig.children = obj.uiConfig.children.filter(c=>{ if (typeof c === 'object' && c.tags && Array.isArray(c.tags) && c.tags.includes(EntityComponentPlugin.PluginType)) { return false } }) } if (!data) return Object.entries(data).forEach(([k, v])=>{ if (e.componentType && v.type !== e.componentType) return const comp = this._components.get(obj.uuid + k) if (comp) { if (comp.object !== obj) { console.warn(`EntityComponentPlugin: component with id ${k} exists on a different object`) return } const r = this.unregisterComponent(comp) if (r) v.state = r } else if (v) { // console.warn(`EntityComponentPlugin: component with id ${k} not found`, obj) // data[k] = v } }) } private _objectUpdate = (e: {object?: IObject3D, change?: string})=>{ if (e.change === 'deserialize') { const obj = e.object if (!obj) return this._objectAdd(e) } } } export const ECS = EntityComponentPlugin // Augment IObject3D interface to include getComponent method declare module '../../core/IObject' { interface IObject3D { /** * Get a component attached to this object or in its parent hierarchy, or get a component of the specified type from the global registry. * This method is added by EntityComponentPlugin when the object is added to the scene. * @param type - The component type (string or class) * @param self - If true, only search this object; if false, search parents and global registry * @returns The component instance if found, or null */ getComponent?<T extends TObject3DComponent>(type: T | string, self?: boolean): InstanceType<T> | null } }