UNPKG

lume

Version:

Build next-level interactive web applications.

332 lines (280 loc) 11.1 kB
import 'element-behaviors' import {stringAttribute} from '@lume/element' import {onCleanup, createEffect} from 'solid-js' import {signal} from 'classy-solid' import {ProjectedMaterial} from '@lume/three-projected-material/dist/ProjectedMaterial.js' import {OrthographicCamera} from 'three/src/cameras/OrthographicCamera.js' import {Texture} from 'three/src/textures/Texture.js' import {behavior} from '../../Behavior.js' import {receiver} from '../../PropReceiver.js' import {PhysicalMaterialBehavior, type PhysicalMaterialBehaviorAttributes} from './PhysicalMaterialBehavior.js' import {TextureProjector} from '../../../textures/TextureProjector.js' import type {Element3D} from '../../../core/Element3D.js' import {upwardRoots} from '../../../utils/upwardRoots.js' import {querySelectorUpward} from '../../../utils/querySelectorUpward.js' export type ProjectedMaterialBehaviorAttributes = | PhysicalMaterialBehaviorAttributes | 'textureProjectors' | 'projectedTextures' // deprecated /** * @class ProjectedMaterialBehavior * * Behavior: `projected-material` * * A physical material with the added ability to have additional textures * projected onto it with * [`<lume-texture-projector>`](../../../textures/TextureProjector) elements. * * Project a texture onto a mesh using a `<lume-texture-projector>` and * associating it with the `texture-projectors` attribute: * * ```html * <!-- Define a texture projector somewhere. --> * <lume-texture-projector src="path/to/image.jpg" class="some-projector"></lume-texture-projector> * * <!-- Use the projected-material on the mesh, and associate it with the projector: --> * <lume-box * has="projected-material" * size="100 100 100" * texture-projectors=".some-projector" * ></lume-box> * ``` * * <live-code id="example"></live-code> * <script> * example.content = projectedTextureExample * </script> * * @extends PhysicalMaterialBehavior */ export @behavior class ProjectedMaterialBehavior extends PhysicalMaterialBehavior { /** The computed value after the user sets this.textureProjectors. F.e. any strings are queried from DOM, and this array contains only DOM element references. */ @signal accessor #associatedProjectors: Array<TextureProjector> = [] /** * @property {Array<TextureProjector>} associatedProjectors * * `readonly` `signal` * * The list of `TextureProjector` elements that are projecting onto the * current owner element, normalized from * [`.textureProjectors`](#textureprojectors) with selectors queried and * null values ignored. * * This returns the currently associated array of `<lume-texture-projector>` * instances, not the original string or array of values passed to * [`.textureProjectors`](#textureprojectors). */ get associatedProjectors() { return this.#associatedProjectors } /** The raw value the user set on this.textureProjectors */ #textureProjectorsRaw: string | Array<TextureProjector | string> = [] /** * @property {string | Array<TextureProjector | string | null>} textureProjectors * * `string attribute` * * Default: `[]` * * The `texture-projectors` attribute accepts one or more selectors, comma * separated, that define which * [`<lume-texture-projector>`](../../core/TextureProjector) elements are to * project an image onto the owner element. If a selector matches an element * that is not a `<lume-texture-projector>`, it is ignored (note that * non-upgraded elements will not be detected, make sure to load element * definitions up front which is the default if you're simply importing * `lume`). * If a selector matches * more than one element, only the first `<lume-texture-projector>` will be used * (in the near future we will allow multiple projectors to project). * * ```html * <lume-box has="projected-material" texture-projectors=".foo, .bar, #baz"></lume-box> * ``` * * The `textureProjectors` JS property can be set with a string of comma * separated selectors, or a mixed array of strings (selectors) or * `<lume-texture-projector>` element instances, making the JS property more * flexible for scenarios where selectors are not enough (f.e. maybe you * need to get a reference to an element from some other part of the DOM, * perhaps from a tree inside a ShadowRoot, or you are programmatically * creating elements, etc). * * ```js * el.textureProjectors = ".some-texture-projector" * // or * const projector = document.querySelector('.some-texture-projector') * el.textureProjectors = [projector, "#someOtherTextureProjector"] * ``` * * Texture projectors that are not in the composed tree (i.e. not * participating in rendering) will be ignored. The texture projectors that * will be associated are those that are connected into the document, and * that participate in rendering (i.e. composed, either in the top level * document, in a ShadowRoot, or distributed to a slot in a ShadowRoot). * This is the same as with the browser's built-in elements: a `<div>` * element that is connected into the DOM but not slotted to its parent's * `.shadowRoot` will not participate in the visual output. */ @stringAttribute @receiver get textureProjectors(): string | Array<TextureProjector | string> { return this.#textureProjectorsRaw } @stringAttribute set textureProjectors(value: string | Array<TextureProjector | string>) { this.#textureProjectorsRaw = value } /** * @deprecated * @property {string | Array<TextureProjector | string | null>} projectedTextures * * `string attribute` * * *deprecated*: renamed to [`.textureProjectors`](#textureprojectors). */ @stringAttribute @receiver get projectedTextures() { return this.textureProjectors } @stringAttribute set projectedTextures(value) { this.textureProjectors = value } override _createComponent() { // TODO multiple projected textures. // Only one projected texture for now. Handling a material array is // needed for multiple projections, unless we update ProjectedMaterial // to supported multiple textures/cameras so that we can have a single // material. Probably the mat.project and mat.updateFromCamera methods // should accept a camera from the outside rather than using one that is // contained in the material. return new ProjectedMaterial() } #observer: MutationObserver | null = null override connectedCallback() { super.connectedCallback() let queuedRequery = false this.#observer = new MutationObserver(() => { if (queuedRequery) return queuedRequery = true // Use a timeout for batching so this doesn't run a ton of times during DOM parsing. setTimeout(() => { queuedRequery = false // TODO this could be more efficient if we check the added nodes directly, but for now we re-run the query logic. // This triggers the setter logic. this.textureProjectors = this.#textureProjectorsRaw }, 0) }) for (const root of upwardRoots(this.element)) this.#observer.observe(root, {childList: true, subtree: true}) this.createEffect(() => { const mat = this.meshComponent if (!mat) return const three = this.element.three if (three.material !== mat) return createEffect(() => { this.textureProjectors let array: Array<TextureProjector | string> = [] if (typeof this.#textureProjectorsRaw === 'string') { array = [this.#textureProjectorsRaw.trim()] } else if (Array.isArray(this.#textureProjectorsRaw)) { array = this.#textureProjectorsRaw } else { throw new TypeError('Invalid value for textureProjectors') } const projectors = [] for (const value of array) { if (typeof value !== 'string') { // Consider only elements that participate in rendering (i.e. that are in the composed tree) if (value.scene && value instanceof TextureProjector) projectors.push(value) continue } else if (!value) { // skip empty strings, they cause an error with querySelectorAll continue } // TODO Should we not search up the composed tree, and stay only // in the current ShadowRoot? for (const el of querySelectorUpward(this.element, value)) { if (!el) continue // Consider only elements that participate in rendering (i.e. that are in the composed tree) if ((el as Element3D).scene && el instanceof TextureProjector) projectors.push(el) // TODO If an element was not yet upgraded, it will not // be found here. We could wait for upgrade. } } this.#associatedProjectors = projectors // trigger }) this._handleTexture( () => this.#associatedProjectors[0]?.src ?? '', (mat, tex) => (mat.texture = tex || new Texture()), mat => !!mat.texture, () => {}, true, ) createEffect(() => { const tex = this.#associatedProjectors[0] if (!tex) return createEffect(() => { mat.fitment = tex.fitment mat.frontFacesOnly = tex.frontFacesOnly this.element.needsUpdate() }) }) createEffect(() => { const tex = this.#associatedProjectors[0] if (!tex) return // if the camera changes const cam = tex.threeCamera if (!cam) return if (three.material !== mat) return mat.camera = cam mat.updateFromCamera() mat.project(three as any, false) this.element.needsUpdate() // Do we need this? onCleanup(() => { const mat = this.meshComponent // If the whole behavior was cleaned up, mat will be undefined // here due to #disposeMeshComponent in the base class onCleanup if (!mat) return if (three.material !== mat) return mat.camera = new OrthographicCamera(0.00000001, 0.00000001, 0.00000001, 0.00000001) mat.updateFromCamera() mat.project(three as any, false) this.element.needsUpdate() }) }) createEffect(() => { const tex = this.#associatedProjectors[0] if (!tex) return createEffect(() => { tex.calculatedSize // dependency mat.updateFromCamera() // needed because the size of the texture projector affects the camera projection this.element.needsUpdate() }) }) createEffect(() => { if (three.material !== mat) return // triggered when this.element has its world matrix updated. this.element.version mat.project(three as any, false) }) createEffect(() => { const tex = this.#associatedProjectors[0] if (!tex) return createEffect(() => { if (three.material !== mat) return // triggered when tex has its world matrix updated, which // transforms the camera we use for texture projection. tex.version mat.project(three as any, false) this.element.needsUpdate() // The texture element updated, so make sure this.element does too. }) }) }) } override disconnectedCallback(): void { super.disconnectedCallback() this.#observer?.disconnect() this.#observer = null } } if (globalThis.window?.document && !elementBehaviors.has('projected-material')) elementBehaviors.define('projected-material', ProjectedMaterialBehavior)