UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

1,145 lines (1,019 loc) 56.4 kB
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js"; import { PUBLIC_KEY, VERSION } from "../engine_constants.js"; import { ContextEvent, ContextRegistry } from "../engine_context_registry.js"; import { registerLoader } from "../engine_gltf.js"; import { hasCommercialLicense } from "../engine_license.js"; import { onStart } from "../engine_lifecycle_api.js"; import { setDracoDecoderPath, setDracoDecoderType, setKtx2TranscoderPath } from "../engine_loaders.gltf.js"; import { NeedleLoader } from "../engine_loaders.js"; import { Context, ContextCreateArgs } from "../engine_setup.js"; import { nameToThreeTonemapping } from "../engine_tonemapping.js"; import { type INeedleEngineComponent, type LoadedModel } from "../engine_types.js"; import type { addAttributeChangeCallback } from "../engine_utils.js"; import { getParam } from "../engine_utils.js"; import { RGBAColor } from "../js-extensions/RGBAColor.js"; import { ensureFonts } from "./fonts.js"; import { arContainerClassName, AROverlayHandler } from "./needle-engine.ar-overlay.js"; import type { registerObservableAttribute } from "./needle-engine.extras.js"; import { calculateProgress01, EngineLoadingView, type ILoadingViewHandler } from "./needle-engine.loading.js"; declare global { interface HTMLElementTagNameMap { "needle-engine": NeedleEngineWebComponent; } } // registering loader here too to make sure it's imported when using engine via vanilla js registerLoader(NeedleLoader); const debug = getParam("debugwebcomponent"); const htmlTagName = "needle-engine"; const vrContainerClassName = "vr"; const desktopContainerClassname = "desktop"; const knownClasses = [arContainerClassName, vrContainerClassName, desktopContainerClassname]; const arSessionActiveClassName = "ar-session-active"; const desktopSessionActiveClassName = "desktop-session-active"; type TonemappingAttributeOptions = "none" | "linear" | "neutral" | "agx"; // #region Observables /** Keep in sync with the overloads and docs here: * - {@link NeedleEngineWebComponent.setAttribute} * - {@link NeedleEngineWebComponent.getAttribute} * - {@link NeedleEngineWebComponent.addEventListener} * - Docs in {@link [custom-elements.json](../../../custom-elements.json)} * - {@link NeedleEngineWebComponent.attributeChangedCallback} * * Effectively, attributes used with these methods are also observable: * - {@link registerObservableAttribute} or * - {@link addAttributeChangeCallback} * */ const observedAttributes = [ // MainAttributes "src", "hash", "camera-controls", "dracoDecoderPath", "dracoDecoderType", "ktx2DecoderPath", // keep-alive: effectively observed because read when needed // private "public-key", "version", // RenderingAttributes "tone-mapping", "tone-mapping-exposure", "background-blurriness", "background-color", "environment-intensity", // background-image: registered in Skybox.ts // environment-image: registered in Skybox.ts // scene: registered in SceneSwitcher.ts // clickthrough: registered in Clickthrough.ts "focus-rect", // Events "loadstart", "progress", "loadfinished", ] // https://developers.google.com/web/fundamentals/web-components/customelements /** * The `<needle-engine>` web component. See {@link NeedleEngineAttributes} attributes for supported attributes * The web component creates and manages a Needle Engine context, which is responsible for rendering a 3D scene using threejs. * The context is created when the `src` attribute is set, and disposed when the element is removed from the DOM. You can prevent cleanup by setting the `keep-alive` attribute to `true`. * The context is accessible from the `<needle-engine>` element: `document.querySelector("needle-engine").context`. * See {@link https://engine.needle.tools/docs/reference/needle-engine-attributes} * * @example Basic usage * ```html * <needle-engine src="https://example.com/scene.glb"></needle-engine> * ``` * * @example With camera controls disabled * ```html * <needle-engine src="https://example.com/scene.glb" camera-controls="false"></needle-engine> * ``` * * @see {@link NeedleButtonElement} for adding AR/VR/Quicklook buttons via &lt;needle-button&gt; * @see {@link NeedleMenu} for the built-in menu configuration component */ export class NeedleEngineWebComponent extends HTMLElement implements INeedleEngineComponent { static get observedAttributes() { return observedAttributes; } public get loadingProgress01(): number { return this._loadingProgress01; } public get loadingFinished(): boolean { return this.loadingProgress01 > .999; } /** * If set to false the camera controls are disabled. Default is true. * @type {boolean | null} * @memberof NeedleEngineAttributes * @example * <needle-engine camera-controls="false"></needle-engine> * @example * <needle-engine camera-controls="true"></needle-engine> * @example * <needle-engine camera-controls></needle-engine> * @example * <needle-engine></needle-engine> * @returns {boolean | null} if the attribute is not set it returns null */ get cameraControls(): boolean | null { const attr = this.getAttribute("camera-controls") as string; if (attr == null) return null; if (attr === null || attr === "False" || attr === "false" || attr === "0" || attr === "none") return false; return true; } set cameraControls(value: boolean | null) { if (value === null) this.removeAttribute("camera-controls"); else this.setAttribute("camera-controls", value ? "true" : "false"); } /** * Get the current context for this web component instance. The context is created when the src attribute is set and the loading has finished. * The context is disposed when the needle engine is removed from the document (you can prevent this by setting the keep-alive attribute to true). * @returns a promise that resolves to the context when the loading has finished */ public getContext(): Promise<Context> { return new Promise((res, _rej) => { if (this._context && this.loadingFinished) { res(this._context); } else { const cb = () => { this.removeEventListener("loadfinished", cb); if (this._context && this.loadingFinished) { res(this._context); } }; this.addEventListener("loadfinished", cb); } }); } /** * Get the context that is created when the src attribute is set and the loading has finished. */ public get context() { return this._context; } private _context?: Context; private _overlay_ar!: AROverlayHandler; private _loadingProgress01: number = 0; private _loadingView?: ILoadingViewHandler; private _previousSrc: string | null | string[] = null; /** @private set to true after <needle-engine> did load completely at least once. Set to false when < to false when <needle-engine> is removed from the document removed from the document */ private _didFullyLoad: boolean = false; private _didInitialize = false; constructor() { super(); this.attachShadow({ mode: 'open', delegatesFocus: true }); const template = document.createElement('template'); template.innerHTML = `<style> @import url('https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght@8..144,100..1000&display=swap'); :host { position: absolute; display: block; width: max(600px, 100%); height: max(300px, 100%); touch-action: none; -webkit-tap-highlight-color: transparent; } @media (max-width: 600px) { :host { width: 100%; } } @media (max-height: 300px) { :host { height: 100%; } } :host > div.canvas-wrapper { width: 100%; height: 100%; } :host canvas { position: absolute; user-select: none; -webkit-user-select: none; /** allow touch panning but no pinch zoom **/ /** but this doesnt work yet: * touch-action: pan-x, pan-y; **/ -webkit-touch-callout: none; -webkit-user-drag: none; -webkit-user-modify: none; left: 0; top: 0; } :host .content { position: absolute; top: 0; width: 100%; height: 100%; visibility: visible; z-index: 500; /* < must be less than the webxr buttons element */ pointer-events: none; } :host .overlay-content { position: absolute; user-select: auto; pointer-events: all; } :host slot[name="quit-ar"]:hover { cursor: pointer; } :host .quit-ar-button { position: absolute; /** top: env(titlebar-area-y); this doesnt work **/ top: 60px; /** camera access needs a bit more space **/ right: 20px; z-index: 9999; } </style> <div class="canvas-wrapper"> <!-- this wrapper is necessary for WebXR https://github.com/meta-quest/immersive-web-emulator/issues/55 --> <canvas></canvas> </div> <div class="content"> <slot class="overlay-content" style="display: contents;"></slot> </div> `; this.shadowRoot!.appendChild(template.content.cloneNode(true)); // TODO: do we want to rename this event? this.addEventListener("ready", this.onReady); this.addEventListener("error", this.onError); } private ensureInitialized() { if (!this._didInitialize) { this._didInitialize = true; this.initializeDom(); } } // #region Init DOM private initializeDom() { ensureFonts(); this.setAttribute("role", "application"); this.setAttribute("aria-label", "Needle Engine 3D scene"); } /** * @internal */ connectedCallback() { if (debug) console.log("<needle-engine> connected"); this.ensureInitialized(); this.setPublicKey(); this.setVersion(); // If tabindex is not defined we set it to 0 to make it focusable and reachable via keyboard. Also keyboard events will be dispatched to the element (e.g. keydown, keyup) which is used by OrbitControls if (this.getAttribute("tabindex") === null || this.getAttribute("tabindex") === undefined) this.setAttribute("tabindex", "0"); this._overlay_ar = new AROverlayHandler(); this.getOrCreateContext(); this.addEventListener("xr-session-started", this.onXRSessionStarted); this.onSetupDesktop(); if (!this.getAttribute("src")) { const global = (globalThis as any)["needle:codegen_files"] as unknown as string; if (debug) console.log("src is null, trying to load from globalThis[\"needle:codegen_files\"]", global); if (global) { if (debug) console.log("globalThis[\"needle:codegen_files\"]", global); this.setAttribute("src", global); } } if (debug) console.log("src", this.getAttribute("src")); // we have to wait because codegen does set the src attribute when it's loaded // which might happen after the element is connected // if the `src` is then still null we want to initialize the default scene const loadId = this._loadId; setTimeout(() => { if (this.isConnected === false) return; if (loadId !== this._loadId) return; this.onLoad(); }, 1); } /** * @internal */ disconnectedCallback() { this.removeEventListener("xr-session-started", this.onXRSessionStarted); this._didFullyLoad = false; const keepAlive = this.getAttribute("keep-alive"); const dispose = keepAlive == undefined || (keepAlive?.length > 0 && keepAlive !== "true" && keepAlive !== "1"); if (debug) console.warn("<needle-engine> disconnected, keep-alive: \"" + keepAlive + "\"", typeof keepAlive, "Dispose=", dispose); if (dispose) { if (debug) console.warn("<needle-engine> dispose"); this._context?.dispose(); this._context = null!; this._lastSourceFiles = null; this._loadId += 1; } else { if (debug) console.warn("<needle-engine> is not disposed because keep-alive is set"); } } connectedMoveCallback() { // we dont want needle-engine to cleanup JUST because the element is moved in the DOM. See https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks } // #region attributeChanged /** * @internal */ attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (debug) console.log("attributeChangedCallback", name, oldValue, newValue); switch (name) { case "src": if (debug) console.warn("<needle-engine src>\nchanged from \"", oldValue, "\" to \"", newValue, "\"") this.onLoad(); // this._watcher?.onSourceChanged(newValue); break; case "hash": if (this._context) { this._context.hash = newValue; } break; case "loadstart": case "progress": case "loadfinished": if (typeof newValue === "string" && newValue.length > 0) { if (debug) console.log(name + " attribute changed", newValue); this.registerEventFromAttribute(name, newValue); } break; case "dracoDecoderPath": if (debug) console.log("dracoDecoderPath", newValue); setDracoDecoderPath(newValue); break; case "dracoDecoderType": if (newValue === "wasm" || newValue === "js") { if (debug) console.log("dracoDecoderType", newValue); setDracoDecoderType(newValue); } else console.error("Invalid dracoDecoderType", newValue, "expected js or wasm"); break; case "ktx2DecoderPath": if (debug) console.log("ktx2DecoderPath", newValue); setKtx2TranscoderPath(newValue); break; case "tonemapping": case "tone-mapping": case "tone-mapping-exposure": case "background-blurriness": case "background-color": case "environment-intensity": { this.applyAttributes(); break; } case "public-key": { if (newValue != PUBLIC_KEY) this.setPublicKey(); break; } case "version": { if (newValue != VERSION) this.setVersion(); break; } case "focus-rect": { const focus_rect = this.getAttribute("focus-rect") as HTMLElement | string | null; if (focus_rect) { const context = this.getOrCreateContext(); if (focus_rect === null) { context.setCameraFocusRect(null); } else if (typeof focus_rect === "string" && focus_rect.length > 0) { const element = document.querySelector(focus_rect); if (!element) console.warn(`No element found for focus-rect selector: ${focus_rect}`); context.setCameraFocusRect(element instanceof HTMLElement ? element : null); } else if (focus_rect instanceof HTMLElement) { context.setCameraFocusRect(focus_rect); } else { console.warn("Invalid focus-rect value. Expected a CSS selector string or an HTMLElement.", focus_rect); } } } break; } } /** The tonemapping setting configured as an attribute on the <needle-engine> component */ get toneMapping(): TonemappingAttributeOptions | null | undefined { const attribute = (this.getAttribute("tonemapping") || this.getAttribute("tone-mapping")) as TonemappingAttributeOptions | null | undefined; return attribute; } private _loadId: number = 0; private _abortController: AbortController | null = null; private _lastSourceFiles: Array<string> | null = null; private _createContextPromise: Promise<boolean> | null = null; /** * Check if we have a context. If not a new one is created. */ private getOrCreateContext() { if (!this._context) { if (debug) console.warn("Create new context"); this._context = new Context({ domElement: this }); } return this._context; } private async onLoad() { if (!this.isConnected) return; const context = this.getOrCreateContext(); const filesToLoad = this.getSourceFiles(); if (!this.checkIfSourceHasChanged(filesToLoad, this._lastSourceFiles)) { return; } // Abort previous loading (if it's still running) if (this._abortController) { if (debug) console.warn("Abort previous loading process") this._abortController.abort(); this._abortController = null; } this._lastSourceFiles = filesToLoad; const loadId = ++this._loadId; if (filesToLoad === null || filesToLoad === undefined || filesToLoad.length <= 0) { if (debug) console.warn("Clear scene", filesToLoad); context.clear(); if (loadId !== this._loadId) return; } const alias = this.getAttribute("alias"); this.classList.add("loading"); // Loading start events const allowOverridingDefaultLoading = hasCommercialLicense(); // default loading can be overriden by calling preventDefault in the onload start event this.ensureLoadStartIsRegistered(); let useDefaultLoading = this.dispatchEvent(new CustomEvent("loadstart", { detail: { context: context, alias: alias }, cancelable: true })); if (allowOverridingDefaultLoading) { // Handle the <needle-engine hide-loading-overlay> attribute const hideOverlay = this.getAttribute("hide-loading-overlay"); if (hideOverlay !== null && hideOverlay !== undefined && (hideOverlay as any) !== "0") { useDefaultLoading = false; } } // for local development we allow overriding the loading screen - but we notify the user that it won't work in a deployed environment if (useDefaultLoading === false && !allowOverridingDefaultLoading) { if (!isDevEnvironment()) useDefaultLoading = true; console.warn("Needle Engine: You need a commercial license to override the default loading view. Visit https://needle.tools/pricing"); if (isDevEnvironment()) showBalloonWarning("You need a <a target=\"_blank\" href=\"https://needle.tools/pricing\">commercial license</a> to override the default loading view. This will not work in production."); } // create the loading view idf necessary if (!this._loadingView && useDefaultLoading) this._loadingView = new EngineLoadingView(this); if (useDefaultLoading) { // Only show the loading screen immedialty if we haven't loaded anything before if (this._didFullyLoad !== true) this._loadingView?.onLoadingBegin("begin load"); else { // If we have loaded a glb previously and are loading a new glb due to e.g. src change // we don't want to show the loading screen immediately to avoid blinking if the glb to be loaded is tiny // so we wait a bit and only show the loading screen if the loading takes longer than a short moment setTimeout(() => { // If the loading progress is already above ~ 70% we also don't need to show the loading screen anymore if (this._loadingView && this._loadingProgress01 < 0.3 && this._loadId === loadId) this._loadingView.onLoadingBegin("begin load"); }, 300) } } if (debug) console.warn("--------------\nNeedle Engine: Begin loading " + loadId + "\n", filesToLoad); this.onBeforeBeginLoading(); const loadedFiles: Array<LoadedModel> = []; const progressEventDetail = { context: this._context, name: "", progress: {} as ProgressEvent, index: 0, count: filesToLoad.length, totalProgress01: this._loadingProgress01 }; const progressEvent = new CustomEvent("progress", { detail: progressEventDetail }); const displayNames = new Array<string>(); const controller = new AbortController(); this._abortController = controller; const args: ContextCreateArgs = { files: filesToLoad, abortSignal: controller.signal, onLoadingProgress: evt => { if (debug) console.debug("Loading progress: ", evt); if (controller.signal.aborted) return; const index = evt.index; if (!displayNames[index] && evt.name) { displayNames[index] = getDisplayName(evt.name); } evt.name = displayNames[index]; if (useDefaultLoading) this._loadingView?.onLoadingUpdate(evt); progressEventDetail.name = evt.name; progressEventDetail.progress = evt.progress; this._loadingProgress01 = calculateProgress01(evt); progressEventDetail.totalProgress01 = this._loadingProgress01; this.dispatchEvent(progressEvent); }, onLoadingFinished: (_index, file, glTF) => { if (debug) console.debug(`Finished loading \"${file}\" (aborted? ${controller.signal.aborted})`); if (controller.signal.aborted) return; if (glTF) { loadedFiles.push({ src: file, file: glTF }); } } } // Experimental loading blur handleLoadingBlur(this); const currentHash = this.getAttribute("hash"); if (currentHash !== null && currentHash !== undefined) context.hash = currentHash; context.alias = alias; this._createContextPromise = context.create(args); const success = await this._createContextPromise; this.applyAttributes(); if (debug) console.warn("--------------\nNeedle Engine: finished loading " + loadId + "\n", filesToLoad, `Aborted? ${controller.signal.aborted}`); if (controller.signal.aborted) { console.log("Loading finished but aborted...") return; } if (this._loadId !== loadId) { console.log("Load id changed during loading process") return; } this._loadingProgress01 = 1; if (useDefaultLoading && success) { this._loadingView?.onLoadingUpdate(1, "creating scene"); } this._didFullyLoad = true; this.classList.remove("loading"); this.classList.add("loading-finished"); this.dispatchEvent(new CustomEvent("loadfinished", { detail: { context: this._context, src: alias, loadedFiles: loadedFiles, } })); } // #region applyAttributes private applyAttributes() { const context = this.getOrCreateContext(); // set tonemapping if configured if (context.renderer) { const threeTonemapping = nameToThreeTonemapping(this.toneMapping); if (threeTonemapping !== undefined) { context.renderer.toneMapping = threeTonemapping; } const exposure = this.getAttribute("tone-mapping-exposure"); if (exposure !== null && exposure !== undefined) { const value = parseFloat(exposure); if (!isNaN(value)) context.renderer.toneMappingExposure = value; } } const backgroundBlurriness = this.getAttribute("background-blurriness"); if (backgroundBlurriness !== null && backgroundBlurriness !== undefined) { const value = parseFloat(backgroundBlurriness); if (!isNaN(value)) { context.scene.backgroundBlurriness = value; } } const environmentIntensity = this.getAttribute("environment-intensity"); if (environmentIntensity != undefined) { const value = parseFloat(environmentIntensity); if (!isNaN(value)) context.scene.environmentIntensity = value; } const backgroundColor = this.getAttribute("background-color"); if (context.renderer) { if (typeof backgroundColor === "string" && backgroundColor.length > 0) { const rgbaColor = RGBAColor.fromColorRepresentation(backgroundColor); if (debug) console.debug("<needle-engine> background-color changed, str:", backgroundColor, "→", rgbaColor) context.renderer.setClearColor(rgbaColor, rgbaColor.alpha); context.scene.background = null; } // HACK: if we set background-color to a color and then back to null we want the background-image attribute to re-apply else if (this.getAttribute("background-image")) { this.setAttribute("background-image", this.getAttribute("background-image")!); } } } private onXRSessionStarted = () => { const context = this.getOrCreateContext(); const xrSessionMode = context.xrSessionMode; if (xrSessionMode === "immersive-ar") this.onEnterAR(context.xrSession!); else if (xrSessionMode === "immersive-vr") this.onEnterVR(context.xrSession!); // handle session end: context.xrSession?.addEventListener("end", () => { this.dispatchEvent(new CustomEvent("xr-session-ended", { detail: { session: context.xrSession, context: this._context, sessionMode: xrSessionMode } })); if (xrSessionMode === "immersive-ar") this.onExitAR(context.xrSession!); else if (xrSessionMode === "immersive-vr") this.onExitVR(context.xrSession!); }); }; /** called by the context when the first frame has been rendered */ private onReady = () => this._loadingView?.onLoadingFinished(); private onError = () => this._loadingView?.setMessage("Loading failed!"); private getSourceFiles(): Array<string> { const src: string | null | string[] = this.getAttribute("src"); if (!src) return []; let filesToLoad: Array<string>; // When using globalThis the src is an array already if (Array.isArray(src)) { filesToLoad = src; } // When assigned from codegen the src is a stringified array else if (src.startsWith("[") && src.endsWith("]")) { filesToLoad = JSON.parse(src); } // src.toString for an array produces a comma separated list else if (src.includes(",")) { filesToLoad = src.split(","); } else filesToLoad = [src]; // filter out invalid or empty strings for (let i = filesToLoad.length - 1; i >= 0; i--) { const file = filesToLoad[i]; if (file === "null" || file === "undefined" || file?.length <= 0) filesToLoad.splice(i, 1); } return filesToLoad; } private checkIfSourceHasChanged(current: Array<string> | null, previous: Array<string> | null): boolean { if (current?.length !== previous?.length) return true; if (current == null && previous !== null) return true; if (current !== null && previous == null) return true; if (current !== null && previous !== null) { for (let i = 0; i < current?.length; i++) { if (current[i] !== previous[i]) return true; } } return false; } private _previouslyRegisteredMap: Map<string, Function> = new Map(); private ensureLoadStartIsRegistered() { const attributeValue = this.getAttribute("loadstart"); if (attributeValue) this.registerEventFromAttribute("loadstart", attributeValue); } private registerEventFromAttribute(eventName: 'loadfinished' | 'loadstart' | 'progress', code: string) { const prev = this._previouslyRegisteredMap.get(eventName); if (prev) { this._previouslyRegisteredMap.delete(eventName); this.removeEventListener(eventName, prev as any); } if (typeof code === "string" && code.length > 0) { try { // indirect eval https://esbuild.github.io/content-types/#direct-eval const fn = (0, eval)(code); // const fn = new Function(newValue); if (typeof fn === "function") { this._previouslyRegisteredMap.set(eventName, fn); // @ts-ignore // not sure how to type this properly this.addEventListener(eventName, evt => fn?.call(globalThis, this.getOrCreateContext(), evt)); } } catch (err) { console.error("Error registering event " + eventName + "=\"" + code + "\" failed with the following error:\n", err); } } } private setPublicKey() { if (PUBLIC_KEY && PUBLIC_KEY.length > 0) this.setAttribute("public-key", PUBLIC_KEY); } private setVersion() { if (VERSION && VERSION.length > 0) { this.setAttribute("version", VERSION); } } /** * @internal */ getAROverlayContainer(): HTMLElement { return this._overlay_ar.createOverlayContainer(this); } /** * @internal */ getVROverlayContainer(): HTMLElement | null { for (let i = 0; i < this.children.length; i++) { const ch = this.children[i] as HTMLElement; if (ch.classList.contains("vr")) return ch; } return null; } /** * @internal */ onEnterAR(session: XRSession) { const context = this.getOrCreateContext(); this.onSetupAR(); const overlayContainer = this.getAROverlayContainer(); this._overlay_ar.onBegin(context, overlayContainer, session); this.dispatchEvent(new CustomEvent("enter-ar", { detail: { session: session, context: context, htmlContainer: this._overlay_ar?.ARContainer } })); } /** * @internal */ onExitAR(session: XRSession) { const context = this.getOrCreateContext(); this._overlay_ar.onEnd(context); this.onSetupDesktop(); this.dispatchEvent(new CustomEvent("exit-ar", { detail: { session: session, context: context, htmlContainer: this._overlay_ar?.ARContainer } })); } /** * @internal */ onEnterVR(session: XRSession) { const context = this.getOrCreateContext(); this.onSetupVR(); this.dispatchEvent(new CustomEvent("enter-vr", { detail: { session: session, context: context } })); } /** * @internal */ onExitVR(session: XRSession) { const context = this.getOrCreateContext(); this.onSetupDesktop(); this.dispatchEvent(new CustomEvent("exit-vr", { detail: { session: session, context: context } })); } private onSetupAR() { this.classList.add(arSessionActiveClassName); this.classList.remove(desktopSessionActiveClassName); const arContainer = this.getAROverlayContainer(); if (debug) console.warn("onSetupAR:", arContainer) if (arContainer) { arContainer.classList.add(arSessionActiveClassName); arContainer.classList.remove(desktopSessionActiveClassName); } this.foreachHtmlElement(ch => this.setupElementsForMode(ch, arContainerClassName)); } private onSetupVR() { this.classList.remove(arSessionActiveClassName); this.classList.remove(desktopSessionActiveClassName); this.foreachHtmlElement(ch => this.setupElementsForMode(ch, vrContainerClassName)); } private onSetupDesktop() { this.classList.remove(arSessionActiveClassName); this.classList.add(desktopSessionActiveClassName); const arContainer = this.getAROverlayContainer(); if (arContainer) { arContainer.classList.remove(arSessionActiveClassName); arContainer.classList.add(desktopSessionActiveClassName); } this.foreachHtmlElement(ch => this.setupElementsForMode(ch, desktopContainerClassname)); } private setupElementsForMode(el: HTMLElement, currentSessionType: string, _session: XRSession | null = null) { if (el === this._context?.renderer?.domElement) return; if (el.id === "VRButton" || el.id === "ARButton") return; const classList = el.classList; if (classList.contains(currentSessionType)) { el.style.visibility = "visible"; if (el.style.display === "none") el.style.display = "block"; } else { // only modify style for elements that have a known class (e.g. marked as vr ar desktop) for (const known of knownClasses) { if (el.classList.contains(known)) { el.style.visibility = "hidden"; el.style.display = "none"; } } } } private foreachHtmlElement(cb: (HTMLElement) => void) { for (let i = 0; i < this.children.length; i++) { const ch = this.children[i] as HTMLElement; if (ch.style) cb(ch); } } private onBeforeBeginLoading() { const customDracoDecoderPath = this.getAttribute("dracoDecoderPath"); if (customDracoDecoderPath) { if (debug) console.log("using custom draco decoder path", customDracoDecoderPath); setDracoDecoderPath(customDracoDecoderPath); } const customDracoDecoderType = this.getAttribute("dracoDecoderType"); if (customDracoDecoderType) { if (debug) console.log("using custom draco decoder type", customDracoDecoderType); setDracoDecoderType(customDracoDecoderType); } const customKtx2DecoderPath = this.getAttribute("ktx2DecoderPath"); if (customKtx2DecoderPath) { if (debug) console.log("using custom ktx2 decoder path", customKtx2DecoderPath); setKtx2TranscoderPath(customKtx2DecoderPath); } } // #region setAttribute // MainAttributes /** Change which model gets loaded. This will trigger a reload of the scene. * @example src="path/to/scene.glb" * @example src="[./path/scene1.glb, myOtherScene.gltf]" * */ setAttribute(name: 'src', value: string): void; /** Optional. String attached to the context for caching/identification. */ setAttribute(name: 'hash', value: string): void; /** Set to automatically add {@link OrbitControls} to the loaded scene */ setAttribute(name: 'camera-controls', value: string): void; /** Override the default draco decoder path location. */ setAttribute(name: 'dracoDecoderPath', value: string): void; /** Override the default draco library type. */ setAttribute(name: 'dracoDecoderType', value: 'wasm' | 'js'): void; /** Override the default KTX2 transcoder/decoder path */ setAttribute(name: 'ktx2DecoderPath', value: string): void; /** Prevent Needle Engine context from being disposed when the element is removed from the DOM */ setAttribute(name: 'keep-alive', value: 'true' | 'false'): void; /** @private Public key used for licensing and feature gating. */ setAttribute(name: 'public-key', value: string): void; /** @private Engine version string — usually set by the build/runtime. */ setAttribute(name: 'version', value: string): void; // LoadingAttributes // ... // SkyboxAttributes /** URL to .exr, .hdr, .png, .jpg to be used as skybox */ setAttribute(name: 'background-image', value: string): void; /** @private Rotation of the background image in degrees. */ setAttribute(name: 'background-rotation', value: string | number): void; /** @deprecated Use 'environment-image' instead. */ setAttribute(name: 'skybox-image', value: string): void; /** URL to .exr, .hdr, .png, .jpg to be used for lighting */ setAttribute(name: 'environment-image', value: string): void; /** Intensity multiplier for environment lighting. */ setAttribute(name: 'environment-intensity', value: string): void; /** Blurs the background image. Strength between 0 (sharp) and 1 (fully blurred). */ setAttribute(name: 'background-blurriness', value: string): void; /** Intensity multiplier for the background image. */ setAttribute(name: 'background-intensity', value: string): void; /** * CSS background color value to be used if no skybox or background image is provided. * @example "background-color='#ff0000'" will set the background color to red. */ setAttribute(name: 'background-color', value: string): void; // RenderingAttributes /** Enable/disable renderer canvas transparency. */ setAttribute(name: 'transparent', value: 'true' | 'false'): void; /** Enable/disable contact shadows in the rendered scene */ setAttribute(name: 'contact-shadows', value: 'true' | 'false'): void; /** Tonemapping mode. */ setAttribute(name: 'tone-mapping', value: TonemappingAttributeOptions): void; /** Exposure multiplier for tonemapping. */ setAttribute(name: 'tone-mapping-exposure', value: string): void; /** Defines a CSS selector or HTMLElement where the camera should be focused on. Content will be fit into this element. */ setAttribute(name: 'focus-rect', value: string | HTMLElement): void; /** Allow pointer events to pass through transparent parts of the content to the underlying DOM elements. */ setAttribute(name: 'clickthrough', value: 'true' | 'false'): void; /** Automatically fits the model into the camera view on load. */ setAttribute(name: 'auto-fit', value: 'true' | 'false'): void; /** Automatically rotates the model until a user interacts with the scene. */ setAttribute(name: 'auto-rotate', value: 'true' | 'false'): void; /** Play animations automatically on scene load */ setAttribute(name: "autoplay", value: 'true' | 'false'): void; /** @private Used for switching the scene in SceneSwitcher */ setAttribute(name: 'scene', value: string): void; // setAttribute(name: 'loadstart', value: string): void; /** @private Experimental.*/ setAttribute(name: 'loading-blur', value: 'true' | 'false'): void; /** @private */ setAttribute(name: 'alias', value: string): void; /** @private */ setAttribute(name: 'hide-loading-overlay', value: 'true' | 'false'): void; /** @private */ setAttribute(name: 'no-telemetry', value: 'true' | 'false'): void; /** Generic typed setter for known Needle Engine attributes */ // Comment out to see errors inside NE for undocumented attributes // setAttribute<T extends keyof NeedleEngineAttributes>(qualifiedName: T, value: NeedleEngineAttributes[T]): void; setAttribute(qualifiedName: ({} & string), value: string): void; // The ones we're using internally: /* setAttribute(name: "tabindex", value: string): void; */ setAttribute(qualifiedName: string, value: string): void { super.setAttribute(qualifiedName, value); } // #region getAttribute // MainAttributes /** Change which model gets loaded. This will trigger a reload of the scene. * @example src="path/to/scene.glb" * @example src="[./path/scene1.glb, myOtherScene.gltf]" * */ getAttribute(name: 'src'): string | null; /** Optional. String attached to the context for caching/identification. */ getAttribute(name: 'hash'): string | null; /** Set to automatically add {@link OrbitControls} to the loaded scene */ getAttribute(name: 'camera-controls'): "true" | "false" | "none" | null; /** Override the default draco decoder path location. */ getAttribute(name: 'dracoDecoderPath'): string | null; /** Override the default draco library type. */ getAttribute(name: 'dracoDecoderType'): "wasm" | "js" | null; /** Override the default KTX2 transcoder/decoder path */ getAttribute(name: 'ktx2DecoderPath'): string | null; /** Prevent Needle Engine context from being disposed when the element is removed from the DOM */ getAttribute(name: 'keep-alive'): string | null; /** @private Public key used for licensing and feature gating. */ getAttribute(name: 'public-key'): string | null; /** @private Engine version string — usually set by the build/runtime. */ getAttribute(name: 'version'): string | null; // LoadingAttributes // ... // SkyboxAttributes /** URL to .exr, .hdr, .png, .jpg to be used as skybox */ getAttribute(name: 'background-image'): string | null; /** @private Rotation of the background image in degrees. */ getAttribute(name: 'background-rotation'): string | null; /** URL to .exr, .hdr, .png, .jpg to be used for lighting */ getAttribute(name: 'environment-image'): string | null; /** Intensity multiplier for environment lighting. */ getAttribute(name: 'environment-intensity'): string | null; /** Blurs the background image. Strength between 0 (sharp) and 1 (fully blurred). */ getAttribute(name: 'background-blurriness'): string | null; /** Intensity multiplier for the background image. */ getAttribute(name: 'background-intensity'): string | null; /** * CSS background color value to be used if no skybox or background image is provided. * @example "background-color='#ff0000'" will set the background color to red. */ getAttribute(name: 'background-color'): string | null; // RenderingAttributes /** Enable/disable renderer canvas transparency. */ getAttribute(name: 'transparent'): string | null; /** Enable/disable contact shadows in the rendered scene */ getAttribute(name: 'contact-shadows'): string | null; /** @deprecated Use 'contact-shadows' instead. */ getAttribute(name: 'contactshadows'): string | null; /** Tonemapping mode. */ getAttribute(name: 'tone-mapping'): TonemappingAttributeOptions | null; /** @deprecated Use 'tone-mapping' instead. */ getAttribute(name: 'tonemapping'): TonemappingAttributeOptions | null; /** Exposure multiplier for tonemapping. */ getAttribute(name: 'tone-mapping-exposure'): string | null; /** Defines a CSS selector or HTMLElement where the camera should be focused on. Content will be fit into this element. */ getAttribute(name: 'focus-rect'): string | null; /** Allow pointer events to pass through transparent parts of the content to the underlying DOM elements. */ getAttribute(name: 'clickthrough'): string | null; /** Automatically fits the model into the camera view on load. */ getAttribute(name: 'auto-fit'): string | null; /** @deprecated Use 'auto-fit' instead. */ getAttribute(name: 'autofit'): string | null; /** Automatically rotates the model until a user interacts with the scene. */ getAttribute(name: 'auto-rotate'): string | null; /** Play animations automatically on scene load */ getAttribute(name: "autoplay"): string | null; /** @private Used for switching the scene in SceneSwitcher */ getAttribute(name: 'scene'): string | null; // getAttribute(name: 'loadstart'): string | null; /** @private Experimental.*/ getAttribute(name: 'loading-blur'): string | null; /** @private */ getAttribute(name: 'alias'): string | null; /** @private */ getAttribute(name: 'hide-loading-overlay'): string | null; /** @private */ getAttribute(name: 'no-telemetry'): string | null; // Comment these out to get development errors for undocumented NE attributes /** Typed getter for known NeedleEngine attribute names; returns the typed shape declared in NeedleEngineAttributes or null. */ // getAttribute<T extends keyof NeedleEngineAttributes>(qualifiedName: T): NeedleEngineAttributes[T] | null; getAttribute(qualifiedName: ({} & string)): string | null; // The ones we're using interally: /* getAttribute(name: "autostart"): string | null; getAttribute(name: "tabindex"): string | null; */ getAttribute(qualifiedName: string): string | null { return super.getAttribute(qualifiedName); } // #region addEventListener /** * Emitted when loading begins for the scene. The event is cancelable — calling `preventDefault()` * will stop the default loading UI behavior, so apps can implement custom loading flows. */ addEventListener(type: 'loadstart', listener: (ev: CustomEvent<{ context: Context; alias: string | null }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted repeatedly while loading resources. Use the event detail to show progress. */ addEventListener(type: 'progress', listener: (ev: CustomEvent<{ context: Context; name: string; progress: ProgressEvent<EventTarget>; index: number; count: number; totalProgress01: number }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when scene loading has finished. */ addEventListener(type: 'loadfinished', listener: (ev: CustomEvent<{ context: Context; src: string | null; loadedFiles: LoadedModel[] }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when an XR session ends. */ addEventListener(type: 'xr-session-ended', listener: (ev: CustomEvent<{ session: XRSession | null; context: Context; sessionMode: XRSessionMode | undefined }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when entering an AR session. */ addEventListener(type: 'enter-ar', listener: (ev: CustomEvent<{ session: XRSession; context: Context; htmlContainer: HTMLElement | null }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when exiting an AR session. */ addEventListener(type: 'exit-ar', listener: (ev: CustomEvent<{ session: XRSession; context: Context; htmlContainer: HTMLElement | null }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when entering a VR session. */ addEventListener(type: 'enter-vr', listener: (ev: CustomEvent<{ session: XRSession; context: Context }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when exiting a VR session. */ addEventListener(type: 'exit-vr', listener: (ev: CustomEvent<{ session: XRSession; context: Context }>) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when the engine has rendered its first frame and is ready. */ addEventListener(type: 'ready', listener: (ev: Event) => void, options?: boolean | AddEventListenerOptions): void; /** Emitted when an XR session is started. You can do additional setup here. */ addEventListener(type: 'xr-session-started', listener: (ev: CustomEvent<{ session: XRSession; context: Context }>) => void, options?: boolean | AddEventListenerOptions): void; // Sadly not enough to make types work (see comment below) addEventListener<K extends keyof HTMLElementEventMap>(type: ({} & K), listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void; // These are from the super type. Not sure how we can remove intellisense for the "regular" events while still making the types work... addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; // This would be better but doesn't completely solve it // addEventListener(type: ({} & string), listener: any, options?: boolean | AddEventListenerOptions): void; // The ones we're using interally: /* addEventListener(type: "error", listener: (ev: ErrorEvent) => void, options?: boolean | AddEventListenerOptions): void; addEventListener(type: "wheel", listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; addEventListener(ty