import { html, LitElement } from "lit"; import { Lunchbox, ThreeLunchbox } from "."; import * as THREE from "three"; import { closestPassShadow } from "./utils"; export class HtmlAnchor extends LitElement { private parentLunchbox: Lunchbox | null = null; private frame = -1; private scratchV3 = new THREE.Vector3(); private scratchFrustum = new THREE.Frustum(); private scratchMatrix4 = new THREE.Matrix4(); /** Try attaching the update function to pass the parent's position to children. */ tryAttachUpdate() { const instance = this.parentLunchbox?.instance; if (!instance) return false; if (!instance.isObject3D) throw new Error('html-anchor must be the child of an Object3D'); // const lunchboxParent = closestPassShadow(this, 'three-lunchbox') as ThreeLunchbox | null; const lunchboxParent = closestPassShadow(this, (el) => { return !!(el as ThreeLunchbox)?.three?.renderer; }) as unknown as Pick | null; if (!lunchboxParent) { console.error('three-lunchbox parent required for html-anchor'); return false; } const camera = lunchboxParent.three.camera; if (!camera) { console.error('camera required for html-anchor'); return false; } const renderer = lunchboxParent.three.renderer; if (!renderer?.domElement) { console.error('renderer and DOM element required for html-anchor'); return false; } const update = () => { this.frame = requestAnimationFrame(update); camera.updateMatrix(); camera.updateMatrixWorld(); // get projected position instance.getWorldPosition(this.scratchV3); const worldPosition = this.scratchV3.clone(); this.scratchV3.project(camera); this.scratchV3.multiplyScalar(0.5).addScalar(0.5); this.scratchV3.y = 1 - this.scratchV3.y; const rendererSize = this.scratchV3.clone().set(renderer.domElement.width, renderer.domElement.height, 1); rendererSize.divideScalar(devicePixelRatio); this.scratchV3.multiply(rendererSize); // get distance from camera const distance = instance.position.clone().distanceTo(camera.getWorldPosition(this.scratchV3.clone())); // get presence in camera frustum this.scratchFrustum.setFromProjectionMatrix(this.scratchMatrix4.clone().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)); const inFrustum = this.scratchFrustum.containsPoint(worldPosition); // apply position data as CSS vars this.style.setProperty('--left', `${this.scratchV3.x}px`); this.style.setProperty('--top', `${this.scratchV3.y}px`); this.style.setProperty('--distance-from-camera', `${distance}`); this.style.setProperty('--in-frustum', `${inFrustum ? 1 : 0}`); if (inFrustum){ this.classList.add('in-frustum'); } else { this.classList.remove('in-frustum'); } } update(); return true; } // Setup - save local parent and try adding update override connectedCallback() { super.connectedCallback(); const parent = this.parentNode as Lunchbox; if (!parent) { throw new Error('html-anchor requires a 3D parent'); } this.parentLunchbox = parent; const attached = this.tryAttachUpdate(); if (!attached) { this.parentLunchbox.addEventListener('instanceadded', () => { const attachedAfterInstanceCreated = this.tryAttachUpdate(); if (!attachedAfterInstanceCreated) throw new Error('error attaching html-anchor to Object3D') }, { once: true }); } } // Teardown override disconnectedCallback(): void { if (this.frame !== -1) cancelAnimationFrame(this.frame); } override render(): unknown { return html`` } override createRenderRoot() { return this; } }