import { LitElement, css, html } from 'lit'; import * as THREE from 'three'; import { THREE_UUID_ATTRIBUTE_NAME } from './utils'; import { RAYCASTABLE_ATTRIBUTE_NAME } from './three-base'; import { AFTER_RENDER_EVENT_NAME, BEFORE_RENDER_EVENT_NAME, Lunchbox, THREE_CLICK_EVENT_NAME, THREE_MOUSE_MOVE_EVENT_NAME, THREE_POINTER_MOVE_EVENT_NAME } from '.'; import { setThreeProperty } from './setThreeProperty'; import { property } from 'lit/decorators.js'; import { parseAttributeOrPropertyValue } from './parseAttributeValue'; const ORTHOGRAPHIC_CAMERA_ATTR_NAME = 'orthographic'; const DEFAULT_DPR = Infinity; /** Wrapper element for ThreeLunchbox. */ export class ThreeLunchbox extends LitElement { // Utils // ================== private scratchV2 = new THREE.Vector2(); // private scratchV3 = new THREE.Vector3(); // TODO: Fully customizable scene, camera, renderer three; /** Function called at startup when creating Lunchbox's default scene. */ createDefaultScene(){ return new THREE.Scene(); } createThree(){ return { scene: this.createDefaultScene(), camera: null as null | THREE.Camera, renderer: null as null | THREE.WebGLRenderer, }; } @property() background: THREE.ColorRepresentation | null = null; @property() dpr: number = DEFAULT_DPR; @property() headless: boolean = false; @property({ attribute: 'manual-render', type: Boolean, }) manualRender = false; @property({ attribute: 'dispatch-before-render', type: Boolean, }) dispatchBeforeRender = false; @property({ attribute: 'dispatch-after-render', type: Boolean, }) dispatchAfterRender = false; @property({ attribute: 'renderer-args', type: Object, }) rendererArgs: ConstructorParameters = []; @property({ attribute: 'camera-args', type: Object, }) cameraArgs: ConstructorParameters = []; /** ResizeObserver to handle container sizing */ resizeObserver: ResizeObserver; constructor() { super(); this.three = this.createThree(); this.resizeObserver = new ResizeObserver(entries => { entries.forEach(({ target, contentRect }) => { if (target === this as unknown as Element) { this.three.renderer?.setSize(contentRect.width * this.dpr, contentRect.height * this.dpr); if (this.three.camera) { const aspect = contentRect.width / contentRect.height; if (this.three.camera.type.toLowerCase() === 'perspectivecamera') { // perspective camera (this.three.camera as THREE.PerspectiveCamera).aspect = aspect; (this.three.camera as THREE.PerspectiveCamera).updateProjectionMatrix(); } else if (this.three.camera.type.toLowerCase() === 'orthographiccamera') { const heightInTermsOfWidth = contentRect.height / contentRect.width; // TODO: more flexible size? const SIZE = 10; (this.three.camera as THREE.OrthographicCamera).top = heightInTermsOfWidth * SIZE; (this.three.camera as THREE.OrthographicCamera).bottom = -heightInTermsOfWidth * SIZE; (this.three.camera as THREE.OrthographicCamera).right = SIZE; (this.three.camera as THREE.OrthographicCamera).left = -SIZE; (this.three.camera as THREE.OrthographicCamera).updateProjectionMatrix(); } // Render on resize to avoid flicker if (!this.manualRender) { this.renderThree(); } } } }); }); } /** To run on start. */ override connectedCallback(): void { super.connectedCallback(); if (this.dpr === DEFAULT_DPR) { this.dpr = window.devicePixelRatio; } if (!this.three.camera){ if (this.getAttribute(ORTHOGRAPHIC_CAMERA_ATTR_NAME) !== null) { this.three.camera = new THREE.OrthographicCamera(...this.cameraArgs); } else { this.three.camera = new THREE.PerspectiveCamera(...(this.cameraArgs.length ? this.cameraArgs : [75])); } } // Camera, scene, renderer information (['scene', 'camera', 'renderer'] as const).forEach(key => { const options = (this as { scene?: object | string, camera?: object | string, renderer?: object | string })[key] ?? this.getAttribute(key) ?? {}; const parsedOptions = typeof options === 'string' ? JSON.parse(options) : options; // properties Object.entries(parsedOptions).forEach(([k, v]) => { if (this.three[key]) { // set property setThreeProperty(this.three[key]!, k.split('-'), parseAttributeOrPropertyValue(v, this)); } }); }); // Resize observer this.resizeObserver.observe(this as unknown as Element); // Background color if (this.background !== null) { this.three.scene.background = new THREE.Color(this.background); } // Prep mouse info if (!this.three.renderer && !this.headless) { const renderer = new THREE.WebGLRenderer(...this.rendererArgs); renderer.domElement.addEventListener('pointermove', this.onPointerMove.bind(this)); renderer.domElement.addEventListener('mousemove', this.onPointerMove.bind(this)); renderer.domElement.addEventListener('click', this.onClick.bind(this)); // this.renderer.domElement.addEventListener('touchstart', this.onClick.bind(this)); this.three.renderer = renderer; } this.dispatchEvent(new CustomEvent('three-ready', { bubbles: true, composed: true, detail: {lunchbox: this}})); // Kick update loop if (!this.manualRender) { this.updateLoop(); } } override disconnectedCallback(): void { this.three.renderer?.domElement.removeEventListener('pointermove', this.onPointerMove.bind(this)); this.three.renderer?.domElement.removeEventListener('mousemove', this.onPointerMove.bind(this)); this.three.renderer?.domElement.removeEventListener('click', this.onClick.bind(this)); // this.renderer.domElement.removeEventListener('touchstart', this.onClick.bind(this)); this.three.renderer?.dispose(); this.resizeObserver.unobserve(this as unknown as Element); cancelAnimationFrame(this.frame); } handleDefaultSlotChange(evt: { target: HTMLSlotElement }) { evt.target.assignedElements().forEach(el => { const elAsThree = el as unknown as Lunchbox; if (elAsThree.instance instanceof THREE.Object3D && el.getAttributeNames().includes(RAYCASTABLE_ATTRIBUTE_NAME)) { // Add to raycast pool if (el.getAttributeNames().includes(RAYCASTABLE_ATTRIBUTE_NAME)) { this.raycastPool.push(elAsThree.instance as any); } } }); this.renderThree(); } // Raycast/pointer information // ================== raycaster = new THREE.Raycaster(); raycastPool: THREE.Object3D[] = []; runRaycast(evt: { clientX: number, clientY: number, }) { if (!this.raycastPool.length || !this.three.camera) return []; const domElementWidth = this.three.renderer?.domElement.width ?? 0; const domElementHeight = this.three.renderer?.domElement.height ?? 0; const ndc = this.scratchV2.clone().set( (evt.clientX / (domElementWidth / this.dpr)) * 2 - 1, -(evt.clientY / (domElementHeight / this.dpr)) * 2 + 1 ); this.raycaster.setFromCamera(ndc, this.three.camera); const intersects = this.raycaster.intersectObjects(this.raycastPool); const matches = intersects.map(intersect => { return { intersect, // TODO: cache result of this query selector somewhere? element: this.querySelector(`[${THREE_UUID_ATTRIBUTE_NAME}="${intersect.object.uuid}"]`) }; }); return matches; } // Pointer movement // ================== onPointerMove(evt: PointerEvent | MouseEvent) { const matches = this.runRaycast.bind(this)(evt); matches.forEach((match: any) => { if (evt.type === 'pointermove') { match.element?.dispatchEvent(new PointerEvent('pointermove')); match.element?.dispatchEvent(new CustomEvent(THREE_POINTER_MOVE_EVENT_NAME, { detail: match })); } else if (evt.type === 'mousemove') { match.element?.dispatchEvent(new MouseEvent('mousemove')); match.element?.dispatchEvent(new CustomEvent(THREE_MOUSE_MOVE_EVENT_NAME, { detail: match })); } }); } // Click handling // ================== onClick(evt: TouchEvent | MouseEvent) { let matches = [] as ReturnType; if (evt instanceof TouchEvent) { const touch = evt.touches[0]; matches = this.runRaycast.bind(this)(touch); matches.forEach(match => { match.element?.dispatchEvent(new TouchEvent('touchstart')); }); } else { matches = this.runRaycast.bind(this)(evt); matches.forEach(match => { match.element?.dispatchEvent(new MouseEvent('click')); }); } matches.forEach(match => { match.element?.dispatchEvent(new CustomEvent(THREE_CLICK_EVENT_NAME, { detail: match })); }); } /** Container styles */ // TODO: fill window, fit-to-container static override styles = css` :host { width: 100%; height: 100%; display: block; } canvas { width: 100%; height: 100%; max-width: 100%; max-height: 100%; } `; /** Render loop */ frame: number = Infinity; updateLoop() { this.renderThree(); if (!this.manualRender) { this.frame = requestAnimationFrame(this.updateLoop.bind(this)); } } /** Render the 3D scene. Optional scene/camera overrides; defaults to the internal scene and camera. */ public renderThree(overrideScene?: THREE.Scene, overrideCamera?: THREE.Camera,) { if (this.dispatchBeforeRender) { this.dispatchEvent(new CustomEvent(BEFORE_RENDER_EVENT_NAME, {})); } if (!this.three.camera) return; this.three.renderer?.render( overrideScene ?? this.three.scene, overrideCamera ?? this.three.camera ); if (this.dispatchAfterRender) { this.dispatchEvent(new CustomEvent(AFTER_RENDER_EVENT_NAME, {})); } } override render() { // TODO: more robust slot changes return html` ${this.three.renderer?.domElement} `; } } declare global { interface GlobalEventHandlersEventMap { 'three-ready': CustomEvent<{lunchbox: ThreeLunchbox}> } interface HTMLElementTagNameMap { 'three-lunchbox': ThreeLunchbox } }