import { LitElement, html } from "lit"; import * as THREE from 'three'; import { IsClass, THREE_UUID_ATTRIBUTE_NAME, getCandidateParent, isClass } from "./utils"; import { setThreeProperty } from "./setThreeProperty"; import { parseAttributeOrPropertyValue } from "./parseAttributeValue"; import { ThreeLunchbox } from "./three-lunchbox"; import { property, state } from "lit/decorators.js"; import { Lunchbox } from "."; export const RAYCASTABLE_ATTRIBUTE_NAME = 'raycast'; export const IGNORED_ATTRIBUTES = [ RAYCASTABLE_ATTRIBUTE_NAME, 'args', 'data', ]; const buildNestedPropertyArray = (input: string) => input.split('-'); /** Standard ThreeJS class */ export abstract class ThreeBase extends LitElement { instance: U | null = null; dispose: (() => void)[] = []; mutationObserver: MutationObserver | null = null; @property({ type: Boolean, attribute: 'try-add-once' }) tryAddOnce = false; @state() connected = false; constructor(private classSource: U){ super(); } observeAttributes() { // Attribute mutation observation // ================== this.mutationObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { // Ignore if no value if (!mutation.attributeName) return; // Ignore if this is one of the observed Lit attributes if (Object.getPrototypeOf(this).constructor.observedAttributes?.includes(mutation.attributeName)) return; const attr = this.attributes.getNamedItem(mutation.attributeName); if (attr) { this.updateProperty(attr); } }); }); this.mutationObserver.observe((this as unknown as Node), { attributes: true, }); } parsedArgs() { const args = (this as { args?: Array }).args ?? this.getAttribute('args') ?? []; const parsedArgs: Array = typeof args === 'string' ? JSON.parse(args) : args; return parsedArgs; } createUnderlyingThreeObject() { // Instance creation // ================== this.instance = new (this.classSource)(...this.parsedArgs().map(arg => parseAttributeOrPropertyValue(arg, this))) as U; } refreshAttributes() { // Populate initial attributes this.getAttributeNames().forEach(attName => { const attr = this.attributes.getNamedItem(attName); if (attr) { this.updateProperty(attr); } }); Array.from(this.attributes).forEach(this.updateProperty.bind(this)); } async onUnderlyingThreeObjectReady() { const instanceAsObject3d = this.instance as unknown as THREE.Object3D; if (instanceAsObject3d.uuid) { this.setAttribute(THREE_UUID_ATTRIBUTE_NAME, instanceAsObject3d.uuid); } // Do some attaching based on common use cases // ================== let parent = getCandidateParent(this); while (this?.connected && !this.tryAddOnce && !parent){ await new Promise(requestAnimationFrame); parent = getCandidateParent(this); } if (parent && (parent as Lunchbox).instance || (parent as ThreeLunchbox)?.three?.scene) { const thisAsGeometry = this.instance as unknown as THREE.BufferGeometry; const thisAsMaterial = this.instance as unknown as THREE.Material; const parentAsMesh = (parent as Lunchbox).instance as unknown as THREE.Mesh; // const thisAsLoader = this.instance as unknown as THREE.Loader; const parentAsAddTarget = ((parent as Lunchbox).instance ?? (parent as ThreeLunchbox).three?.scene) as unknown as { add?: (item: THREE.Object3D) => void }; // const thisIsALoader = this.tagName.toString().toLowerCase().endsWith('-loader'); const instanceAsObject3d = this.instance as unknown as THREE.Object3D; // if we're a geometry or material, attach to parent if (thisAsGeometry.type?.toLowerCase().includes('geometry') && parentAsMesh.geometry) { parentAsMesh.geometry = thisAsGeometry; } else if (thisAsMaterial.type?.toLowerCase().includes('material') && parentAsMesh.material) { parentAsMesh.material = thisAsMaterial; } // if we're a loader, start loading // otherwise, try to add as a child of the parent else if (parentAsAddTarget.add) { // If parent is an add target, add to parent try { parentAsAddTarget.add(instanceAsObject3d); this.dispatchEvent(new CustomEvent('instanceadded', { detail: { instance: this.instance, parent: parentAsAddTarget, }, })); } catch (_) { throw new Error(`Error adding ${this.tagName} to ${parentAsAddTarget}`); } } // try executing attachment if we can // (skip if this is a loader to avoid race condition) const attachAttribute = this.getAttribute('attach'); if (attachAttribute) { this.executeAttach(attachAttribute, this.instance); } } } override connectedCallback(): void { super.connectedCallback(); this.observeAttributes.call(this); this.createUnderlyingThreeObject.call(this); this.refreshAttributes.call(this); // Fire instancecreated event this.dispatchEvent(new CustomEvent('instancecreated', { detail: { instance: this.instance, }, })); this.connected = true; this.onUnderlyingThreeObjectReady.call(this); } /** Update an instance's property. When creating a ``, for example, this sets `mesh.position.y = 0.5`. */ updateProperty(att: Attr) { const { name, value } = att; // find name with correct case, since attribute names are converted to all-lowercase by default let targetCase = name; Object.keys(this.instance ?? {}).forEach(key => { if (key.toLowerCase() === targetCase) { targetCase = key; } }); // nested properties const split = buildNestedPropertyArray(targetCase); // ignore Lunchbox-specific attributes if (IGNORED_ATTRIBUTES.includes(targetCase) || IGNORED_ATTRIBUTES.includes(split[0])) { return; } // handle non-events // ================== // TODO: parse objects as non-JSON (`{"test": 1}` is annoying to write) let parsedValue = parseAttributeOrPropertyValue(value, this); try { parsedValue = JSON.parse(value === '' ? 'true' : value); } catch (_err) { // noop, since allowed values can fail JSON parsing } // current value if (this.instance) { setThreeProperty(this.instance, split, parsedValue); } } executeAttach(targetProperty: string, toAttach: unknown): void { const parent = this.parentElement as ThreeBase; if (parent.instance) { setThreeProperty(parent.instance, buildNestedPropertyArray(targetProperty), toAttach); } } override disconnectedCallback(): void { super.disconnectedCallback(); const toDispose = [this.instance]; this.disposeThreeObjects.call(this, toDispose); this.connected = false; } disposeThreeObjects(toDispose: unknown[]): void { toDispose.forEach(target => { if (!target) return; const instanceAsDisposable = target as { dispose?: () => void }; const instanceAsRemovableFromParent = target as { removeFromParent?: () => void }; instanceAsDisposable.dispose?.(); instanceAsRemovableFromParent.removeFromParent?.(); }); } /** Render */ override render() { return html``; } protected override createRenderRoot() { return this; } } export const buildClass = (targetClass: keyof typeof THREE | IsClass) => { const threeClass = typeof targetClass === 'string' ? THREE[targetClass as keyof typeof THREE] : targetClass; if (!isClass(threeClass)) { return null; } const isLoader = targetClass.toString().toLowerCase().endsWith('loader'); class Extension extends ThreeBase { constructor(){ super(threeClass as T); } } /** Loader class */ class ThreeLoader, U extends IsClass = T> extends ThreeBase { loader: L | null = null; override createUnderlyingThreeObject(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.loader = new (threeClass as any)(...this.parsedArgs().map(arg => parseAttributeOrPropertyValue(arg, this))); } override async onUnderlyingThreeObjectReady() { const src = this.getAttribute('src'); if (!src) throw new Error('Loader requires a source.'); if (!this.loader) throw new Error(`Missing loader ${this.tagName}`); // load and try attaching this.loader.load(src, loaded => { this.instance = loaded; const attachAttribute = this.getAttribute('attach'); if (attachAttribute) { this.executeAttach(attachAttribute, loaded); } this.refreshAttributes.call(this); this.dispatchEvent(new CustomEvent('loaded', { detail: { instance: loaded, }, })); }, undefined, error => { throw new Error(`error loading: ${src}` + error); }); } override disconnectedCallback(): void { super.disconnectedCallback(); this.disposeThreeObjects.call(this, [this.loader]); } } return isLoader ? ThreeLoader : Extension; };