UNPKG

lume

Version:

Build next-level interactive web applications.

277 lines (234 loc) 8.39 kB
import {createEffect, onCleanup} from 'solid-js' // import {stringAttribute, booleanAttribute} from '../attribute.js' import {stringAttribute, booleanAttribute} from '@lume/element' import {behavior} from '../Behavior.js' import {receiver} from '../PropReceiver.js' import {ClipPlane} from '../../core/ClipPlane.js' import {MeshBehavior} from './MeshBehavior.js' import type {MaterialBehavior} from './index.js' import type {Scene} from '../../core/Scene.js' export type ClipPlanesBehaviorAttributes = | 'clipPlanes' | 'clipIntersection' | 'clipShadows' | 'flipClip' | 'clipDisabled' let refCount = 0 /** * @class ClipPlanesBehavior * * When applied to an element with GL content, allows specifying one or more * [`<lume-clip-plane>`](../../core/ClipPlane) elements to clip the content with. * * This class extends from `MeshBehavior`, enforcing that the behavior can be used * only on elements that have a geometry and material. * * <live-code id="example"></live-code> * <script> * example.content = clipPlaneExample * </script> * * @extends MeshBehavior */ export @behavior class ClipPlanesBehavior extends MeshBehavior { /** * @property {boolean} clipIntersection * * `attribute` * * Default: 'false' * * Changes the behavior of clipping planes so that only their intersection * is clipped, rather than their union. */ @booleanAttribute @receiver clipIntersection = false /** * @property {boolean} clipShadows * * `attribute` * * Default: `false` * * Defines whether to clip shadows * according to the clipping planes specified on this material. Default is * false. */ @booleanAttribute @receiver clipShadows = true // TODO reactive array? #clipPlanes: Array<ClipPlane> = [] #rawClipPlanes: string | Array<ClipPlane | string> = [] /** * @property {string | Array<ClipPlane | string | null>} clipPlanes * * *attribute* * * Default: `[]` * * The `clip-planes` attribute accepts one or more selectors, comma * separated, that define which [`<lume-clip-plane>`](../../core/ClipPlane) * elements are to be used as clip planes. If a selector matches an element * that is not a `<lume-clip-plane>`, it is ignored. If a selector matches * more than one element, all of them that are clip planes are used. * * ```html * <lume-box has="clip-planes" clip-planes=".foo, .bar, #baz"></lume-box> * ``` * * The property can also be set with a string (comma separated selectors), * or a mixed array of strings (selectors) or `<lume-clip-plane>` element * instances. * * ```js * el.clipPlanes = ".some-plane" * // or * const plane = document.querySelector('.some-clip-plane') * el.clipPlanes = [plane, "#someOtherPlane"] * ``` * * The property getter returns the currently applicable collection of * `<lume-clip-plane>` instances, not the original string or array of values * passed into the attribute or setter. Applicable planes are those that are * connected into the document, and that participate in rendering (composed, * either in the top level document, in a ShadowRoot, or distributed to a * slot in a ShadowRoot). */ // TODO #279, move setter logic into an effect like we did with ProjectedMaterialBehavior. @stringAttribute @receiver get clipPlanes(): Array<ClipPlane> { return this.#clipPlanes } @stringAttribute set clipPlanes(value: string | Array<ClipPlane | string>) { this.#rawClipPlanes = value let array: Array<ClipPlane | string> = [] if (typeof value === 'string') { array = [value.trim()] } else if (Array.isArray(value)) { array = value } else { throw new TypeError('Invalid value for clipPlanes') } this.#clipPlanes = [] for (const v of array) { if (typeof v !== 'string') { // TODO #279: This setter is non-reactive to v.scene, so it will // not update if the element becomes composed into a Lume scene. if (v instanceof ClipPlane && v.scene) this.#clipPlanes.push(v) continue } else if (!v) { // skip empty strings, they cause an error with querySelectorAll continue } let root = this.element.getRootNode() as Document | ShadowRoot | null // TODO Should we not search up the composed tree, and stay only // in the current ShadowRoot? while (root) { const els = root.querySelectorAll(v) for (let i = 0, l = els.length; i < l; i += 1) { const el = els.item(i) as Element | null if (!el) continue // Find only planes participating in rendering (i.e. in the // composed tree, noting that .scene is null when not // composed) // TODO #279: This setter is non-reactive to el.scene, so it will // not update if the element becomes composed into a Lume scene. if (el instanceof ClipPlane && el.scene) this.#clipPlanes.push(el) // TODO check the target is in the same scene // TODO We aren't observing el.scene, so if the element // becomes a particpant in the scene later nothing will // happen. // TODO If an element was not yet upgraded, it will not // be found here. We need to wait for upgrade. // TODO We need to also react to added/removed elements. } root = root instanceof ShadowRoot ? (root.host.getRootNode() as Document | ShadowRoot) : null } } } /** * @property {boolean} flipClip * * *attribute* * * Default: `false` * * By default, the side of a plane that is clipped is in its positive Z * direction. Setting this to `true` will reverse clipping to the other * side. */ @booleanAttribute @receiver flipClip = false /** * @property {boolean} clipDisabled * * *attribute* * * Default: `false` * * If `true`, clipping is not applied. */ @booleanAttribute @receiver clipDisabled = false /** * `reactive` */ get material() { const mat = this.element.behaviors.find(name => name.endsWith('-material')) as MaterialBehavior | null return mat?.meshComponent ?? null } #observer: MutationObserver | null = null override connectedCallback() { super.connectedCallback() let lastScene: Scene | null = null this.createEffect(() => { if (!this.element.scene) return lastScene = this.element.scene // Trigger the setter again in case it returned early if there was // no scene. Depending on code load order, el.scene inside of set // clipPlanes might be null despite that it is a valid Lume element. // TODO #279: Instead of this hack, move away // from getters/setters, make all logic fully reactive to avoid // worrying about code execution order. https://github.com/lume/lume/issues/279 this.clipPlanes = this.#rawClipPlanes if (!refCount) this.element.scene.localClipping = true refCount++ // TODO we need to observe all the way up the composed tree, or we // should make the querying scoped only to the nearest root, for // consistency. This covers most cases, for now. this.#observer = new MutationObserver(() => { // 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.clipPlanes = this.#rawClipPlanes }) this.#observer.observe(this.element.getRootNode(), {childList: true, subtree: true}) createEffect(() => { const {clipPlanes, clipIntersection, clipShadows, flipClip} = this const mat = this.material if (!mat) return this.element.needsUpdate() if (!clipPlanes.length || this.clipDisabled) { mat.clippingPlanes = null // FIXME upstream: don't forget this or Three.js has a bug that // still attempts to perform clipping even if clippingPlanes is // null. https://github.com/munrocket/three.js/pull/5 mat.clipShadows = false return } if (!mat.clippingPlanes) mat.clippingPlanes = [] mat.clippingPlanes.length = 0 mat.clipIntersection = clipIntersection mat.clipShadows = clipShadows for (const plane of clipPlanes) { mat.clippingPlanes.push(flipClip ? plane.threeInverseClip : plane.threeClip) } }) onCleanup(() => { this.#observer?.disconnect() this.#observer = null refCount-- if (!refCount) lastScene!.localClipping = false lastScene = null }) }) } } if (globalThis.window?.document && !elementBehaviors.has('clip-planes')) elementBehaviors.define('clip-planes', ClipPlanesBehavior)