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.

340 lines (295 loc) 13.3 kB
import {Object3DComponent} from './Object3DComponent' import {ComponentCtx, StatePropConfig, TypedType, TypeSystem} from './componentTypes' import {objectHasOwn} from 'ts-browser-helpers' import {generateUiConfig, generateValueConfig, UiObjectConfig} from 'uiconfig.js' import {ReferenceManager} from './ReferenceManager' interface PropMeta { config: StatePropConfig; propType: TypedType; defaultValue: any; defaultValueType: TypedType; propKey: keyof Object3DComponent; } // const compType = /* @__PURE__ */ new WeakMap<typeof Object3DComponent, Set<string>>() export function getComponentTypes(comp: typeof Object3DComponent) { // const cache = compType.get(comp) // if (cache) return cache const types = new Set<string>() let base = comp while (base && base !== Object3DComponent && base !== Function.prototype) { if (base.ComponentType) types.add(base.ComponentType) base = Object.getPrototypeOf(base) } // compType.set(comp, types) return types } export function setupComponent(comp: Object3DComponent, ctx: ComponentCtx) { if (!comp.isObject3DComponent) throw new Error('EntityComponentPlugin: invalid component instance') comp.ctx = ctx const props = getComponentStateProperties(comp.constructor) const uiConfig = comp.uiConfig ?? { type: 'folder', label: ()=>comp._sType ? `Missing (${comp._sType})` : comp.constructor.ComponentType, expanded: false, children: generateUiConfig(comp), } const uiChildren = uiConfig.children as UiObjectConfig['children'] | undefined const propsMeta: PropMeta[] = [] props.forEach(stateProp=>{ const propKey: keyof typeof comp = stateProp.key as any if (!objectHasOwn(comp, propKey)) { console.error('Object3DComponent: state property not found on component', stateProp, comp) return } const defaultValue = stateProp.default ?? comp[propKey] if (defaultValue === undefined) { console.error('Object3DComponent: state property default value must be set', stateProp, comp) return } const defaultValueType = TypeSystem.GetType(defaultValue) if (!defaultValueType) { console.error('Object3DComponent: unsupported type for state property', stateProp, defaultValue) return } const propType = stateProp.type ?? TypeSystem.NonLiteral(defaultValueType) if (!TypeSystem.CanAssign(defaultValueType, propType)) { console.error('Object3DComponent: default value type is not compatible with state property type', stateProp, defaultValue, defaultValueType, propType) return } // console.log('[Object3DComponent] setup state property', stateProp.key, 'type:', propType, 'default:', defaultValue) // const refDef = TypeSystem.GetClass(defaultValueType) // if (refDef) { // const id = refDef.getId(defaultValue) // if (!id) { // console.error('Object3DComponent: cannot get reference id for default value of state property', stateProp, defaultValue, id) // } else { // ReferenceManager.Add(id, defaultValue, comp) // ignore ret value // } // } delete comp[propKey] Object.defineProperty(comp, propKey, { get: ()=>{ return getStateProperty(comp, propKey, defaultValue) }, set: (v)=>{ setStateProperty(v, comp, propKey, propType, defaultValue) // todo set dirty on object? }, enumerable: true, configurable: true, }) assignVal(defaultValueType, defaultValue, defaultValue, comp, propKey) const prop = { config: stateProp, propType, defaultValue, defaultValueType, propKey, } propsMeta.push(prop) if (uiChildren) { const uiC = generateComponentPropertyUi(comp, prop) if (uiC?.length) uiChildren.push(...uiC) } }) ComponentCache.InstanceProperties.set(comp, propsMeta) uiChildren?.push({ type: 'button', label: 'Reset to Default', uuid: 'reset_to_default', tags: ['context-menu'], value: () => { const lastState = {} as any propsMeta.forEach(({propKey, defaultValue}) => { // @ts-expect-error todo fix ts comp[propKey] = defaultValue lastState[propKey] = comp[propKey] }) return ()=>{ // undo propsMeta.forEach(({propKey}) => { // @ts-expect-error todo fix ts comp[propKey] = lastState[propKey] }) } }, }, { type: 'button', label: 'Remove Component', uuid: 'remove_component', tags: ['context-menu'], value: () => { return ctx.ecp.removeComponent(comp.object, comp.uuid) }, }) // todo sort children based on props order comp.uiConfig = uiConfig } export function refreshAllStateProperties(comp: Object3DComponent, lastState: Object3DComponent['stateRef']) { if (!comp.isObject3DComponent) throw new Error('EntityComponentPlugin: invalid component instance') const props = ComponentCache.InstanceProperties.get(comp) if (!props) return for (const {propKey, defaultValue, propType} of props) { // console.log('[Object3DComponent] refresh state property', propKey) const oldValueRaw = lastState ? lastState[propKey] : undefined const valRaw = comp.stateRef[propKey] let val const cc = ReferenceManager.GetCached(valRaw) if (cc !== undefined) val = cc else val = getStateProperty(comp, propKey, defaultValue, valRaw, false) setStateProperty(val, comp, propKey, propType, defaultValue, oldValueRaw) } } export function teardownComponent(comp: Object3DComponent) { if (!comp.isObject3DComponent) throw new Error('EntityComponentPlugin: invalid component instance') // const props = getComponentStateProperties(comp.constructor) // props.forEach(p=>{ // }) ReferenceManager.Delete(comp) ComponentCache.InstanceProperties.delete(comp) } class ComponentCache { static TypeProperties: WeakMap<typeof Object3DComponent, StatePropConfig[]> = new Map() static InstanceProperties: WeakMap<Object3DComponent, PropMeta[]> = new Map() } function getComponentStateProperties(comp: typeof Object3DComponent) { const cache = ComponentCache.TypeProperties.get(comp) if (cache) return cache const stateProps : Map<string, [StatePropConfig, any, number]> = new Map() let base = comp let order = 0 while (base && base !== Function.prototype) { if (base.StateProperties) { for (const p of base.StateProperties) { const key = typeof p === 'string' ? p : p.key const exis = stateProps.get(key) if (exis) { const cons = exis[1] if (cons === base) { // same class property duplicate console.error('Object3DComponent: duplicate state property in class', key, base) } else { // derived class property overrides base class property console.warn('Object3DComponent: state property overridden in derived class', key, base, cons) } continue } // todo check order in prop config also stateProps.set(key, [typeof p === 'string' ? {key: p} : p, base, order]) order += 0.001 } } if (base === Object3DComponent) break base = Object.getPrototypeOf(base) order -= 1 } return Array.from(stateProps.values()) .sort((a, b)=>a[2] - b[2]) .map(v=>v[0]) } function getStateProperty(comp: Object3DComponent, propKey: keyof typeof comp, defaultValue: any, val?: any, warn = true) { val = val ?? comp.stateRef[propKey] if (val === undefined) return defaultValue const resolved = ReferenceManager.GetOp(val, warn) if (resolved !== undefined) return resolved return val } function setStateProperty(v: any, comp: Object3DComponent, propKey: keyof typeof comp, propType: TypedType, defaultValue: any, oldValueRaw?: any) { if (v === undefined) { console.error('Object3DComponent: state property cannot be set to undefined', propKey, v) return } const oldValue = oldValueRaw !== undefined ? getStateProperty(comp, propKey, defaultValue, oldValueRaw) : comp[propKey] if (oldValue === v) return const valType = TypeSystem.GetType(v) if (!valType) { console.error('Object3DComponent: unsupported type for state property', propKey, v) return } // todo skip type checking when running in production if (!TypeSystem.CanAssign(valType, propType)) { console.error('Object3DComponent: assigned value type is not compatible with state property type', propKey, v, valType, propType) return } if (oldValueRaw === undefined) oldValueRaw = comp.stateRef[propKey] ReferenceManager.RemoveOp(oldValueRaw, comp) assignVal(valType, v, defaultValue, comp, propKey) if (oldValue !== undefined && oldValue !== v) { comp.stateChangeHandlers[propKey]?.forEach(fn => fn(v, oldValue, propKey)) } } function assignVal(valType: TypedType, v: any, defaultValue: any, comp: Object3DComponent, propKey: keyof typeof comp) { let res const refDefNew = TypeSystem.GetClass(valType) if (refDefNew) { const newRefId = refDefNew.getId(v) if (!newRefId) { console.error('Object3DComponent: cannot get reference id for value of state property', propKey, v) return } else { res = ReferenceManager.Add(newRefId, v, comp) } } else { // if it's not a class instance and same as default value, we can just delete it from stateRef to save space if (v === defaultValue) { res = undefined } else { res = v } } if (res === undefined) delete comp.stateRef[propKey] else comp.stateRef[propKey] = res } function generateComponentPropertyUi(comp: Object3DComponent, prop: PropMeta) { const {config: stateProp, propType, defaultValue, propKey} = prop // todo flatten nested union types const types = typeof propType === 'string' ? [propType] : 'oneOf' in propType ? Array.from(propType.oneOf) : ['unknown'] const canBeNull = types.includes('null')/* || types.includes('undefined')*/ const classTypes = types.map(t=>TypeSystem.GetClass(t)).filter(t=>t !== undefined) const res: UiObjectConfig[] = [] // only class types or null if (classTypes.length === types.length && !canBeNull || classTypes.length === types.length - 1 && canBeNull ) { const newConfig: UiObjectConfig = { type: 'reference' as const, label: stateProp.label ?? propKey + '', property: [comp, propKey], classTypes: classTypes, allowNull: canBeNull, defaultValue: defaultValue, // todo onchange refresh parent } res.push(newConfig) } // filter only the types that are literal types const literalTypes = types.map(t=>typeof t === 'string' ? t.startsWith('"') && t.endsWith('"') ? JSON.parse(t) : !isNaN(Number(t)) ? Number(t) : t === 'true' ? true : t === 'false' ? false : typeof t === 'number' || typeof t === 'boolean' ? t : undefined // just in case : undefined).filter(t=>t !== undefined) const uiC = ()=> { // should we bind to stateRef? but it could be changed, also it could be deleted when default value is set const config = generateValueConfig(comp, propKey, stateProp.label, undefined, false) // todo use other metadata like description, hooks if (config) { if (typeof config === 'object') { config.uuid = propKey // similar to react key config.dispatchMode = 'immediate' // todo make this the default in uiconfig/uiconfig-react // config.multiline = true } // all types are literal types, can be a dropdown. we can do more advanced type analysis later if (literalTypes.length > 0 && literalTypes.length === types.length) { if (config.type === 'input' || config.type === 'number' || config.type === 'string') { config.type = 'dropdown' config.children = literalTypes.map(t => { const label = typeof t === 'string' ? t : typeof t === 'number' ? t.toString() : typeof t === 'boolean' ? t ? 'true' : 'false' : t + '' return {label: label, value: t} }) } } return config } return undefined } res.push(uiC) return res }