@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,020 lines (1,008 loc) • 42.2 kB
JavaScript
import { isDevEnvironment, showBalloonWarning } from "../debug/index.js";
import { PUBLIC_KEY, VERSION } from "../engine_constants.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 } from "../engine_setup.js";
import { nameToThreeTonemapping } from "../engine_tonemapping.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 { calculateProgress01, EngineLoadingView } from "./needle-engine.loading.js";
// 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";
// #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 <needle-button>
* @see {@link NeedleMenu} for the built-in menu configuration component
*/
export class NeedleEngineWebComponent extends HTMLElement {
static get observedAttributes() {
return observedAttributes;
}
get loadingProgress01() { return this._loadingProgress01; }
get loadingFinished() { 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() {
const attr = this.getAttribute("camera-controls");
if (attr == null)
return null;
if (attr === null || attr === "False" || attr === "false" || attr === "0" || attr === "none")
return false;
return true;
}
set cameraControls(value) {
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
*/
getContext() {
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.
*/
get context() { return this._context; }
_context;
_overlay_ar;
_loadingProgress01 = 0;
_loadingView;
_previousSrc = 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 */
_didFullyLoad = false;
_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);
}
ensureInitialized() {
if (!this._didInitialize) {
this._didInitialize = true;
this.initializeDom();
}
}
// #region Init DOM
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["needle:codegen_files"];
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, oldValue, newValue) {
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");
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() {
const attribute = (this.getAttribute("tonemapping") || this.getAttribute("tone-mapping"));
return attribute;
}
_loadId = 0;
_abortController = null;
_lastSourceFiles = null;
_createContextPromise = null;
/**
* Check if we have a context. If not a new one is created.
*/
getOrCreateContext() {
if (!this._context) {
if (debug)
console.warn("Create new context");
this._context = new Context({ domElement: this });
}
return this._context;
}
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 !== "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 = [];
const progressEventDetail = {
context: this._context,
name: "",
progress: {},
index: 0,
count: filesToLoad.length,
totalProgress01: this._loadingProgress01
};
const progressEvent = new CustomEvent("progress", { detail: progressEventDetail });
const displayNames = new Array();
const controller = new AbortController();
this._abortController = controller;
const args = {
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
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"));
}
}
}
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 */
onReady = () => this._loadingView?.onLoadingFinished();
onError = () => this._loadingView?.setMessage("Loading failed!");
getSourceFiles() {
const src = this.getAttribute("src");
if (!src)
return [];
let filesToLoad;
// 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;
}
checkIfSourceHasChanged(current, previous) {
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;
}
_previouslyRegisteredMap = new Map();
ensureLoadStartIsRegistered() {
const attributeValue = this.getAttribute("loadstart");
if (attributeValue)
this.registerEventFromAttribute("loadstart", attributeValue);
}
registerEventFromAttribute(eventName, code) {
const prev = this._previouslyRegisteredMap.get(eventName);
if (prev) {
this._previouslyRegisteredMap.delete(eventName);
this.removeEventListener(eventName, prev);
}
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);
}
}
}
setPublicKey() {
if (PUBLIC_KEY && PUBLIC_KEY.length > 0)
this.setAttribute("public-key", PUBLIC_KEY);
}
setVersion() {
if (VERSION && VERSION.length > 0) {
this.setAttribute("version", VERSION);
}
}
/**
* @internal
*/
getAROverlayContainer() {
return this._overlay_ar.createOverlayContainer(this);
}
/**
* @internal
*/
getVROverlayContainer() {
for (let i = 0; i < this.children.length; i++) {
const ch = this.children[i];
if (ch.classList.contains("vr"))
return ch;
}
return null;
}
/**
* @internal
*/
onEnterAR(session) {
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) {
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) {
const context = this.getOrCreateContext();
this.onSetupVR();
this.dispatchEvent(new CustomEvent("enter-vr", { detail: { session: session, context: context } }));
}
/**
* @internal
*/
onExitVR(session) {
const context = this.getOrCreateContext();
this.onSetupDesktop();
this.dispatchEvent(new CustomEvent("exit-vr", { detail: { session: session, context: context } }));
}
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));
}
onSetupVR() {
this.classList.remove(arSessionActiveClassName);
this.classList.remove(desktopSessionActiveClassName);
this.foreachHtmlElement(ch => this.setupElementsForMode(ch, vrContainerClassName));
}
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));
}
setupElementsForMode(el, currentSessionType, _session = 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";
}
}
}
}
foreachHtmlElement(cb) {
for (let i = 0; i < this.children.length; i++) {
const ch = this.children[i];
if (ch.style)
cb(ch);
}
}
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);
}
}
// The ones we're using internally:
/*
setAttribute(name: "tabindex", value: string): void;
*/
setAttribute(qualifiedName, value) {
super.setAttribute(qualifiedName, value);
}
// The ones we're using interally:
/*
getAttribute(name: "autostart"): string | null;
getAttribute(name: "tabindex"): string | null;
*/
getAttribute(qualifiedName) {
return super.getAttribute(qualifiedName);
}
// 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(type: "keydown", listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: "drop", listener: (ev: DragEvent) => void, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: "dragover", listener: (ev: DragEvent) => void, options?: boolean | AddEventListenerOptions): void;
*/
addEventListener(type, listener, options) {
return super.addEventListener(type, listener, options);
}
}
if (typeof window !== "undefined" && !window.customElements.get(htmlTagName))
window.customElements.define(htmlTagName, NeedleEngineWebComponent);
/** Quick testing for the types as declared above */
/*
const elem = document.querySelector("needle-engine")!;
elem.setAttribute("src", "model.glb");
elem.addEventListener("loadfinished", (ev) => {
const context = ev.detail.context;
});
elem.setAttribute("dracoDecoderType", "wasm");
elem.addEventListener("enter-ar", (ev) => {
const session = ev.detail.session;
});
elem.addEventListener("mousedown", (ev) => {
console.log("regular mousedown event", ev);
});
const onDragOverEvent = (e: DragEvent) => {
};
elem.addEventListener("dragover", onDragOverEvent);
*/
/* END Type testing */
// #region Utility functions
function getDisplayName(str) {
if (str.startsWith("blob:")) {
return "blob";
}
const parts = str.split("/");
let name = parts[parts.length - 1];
// Remove params
const paramsIndex = name.indexOf("?");
if (paramsIndex > 0)
name = name.substring(0, paramsIndex);
const equalSign = name.indexOf("=");
if (equalSign > 0)
name = name.substring(equalSign);
const extension = name.split(".").pop();
const extensions = ["glb", "gltf", "usdz", "usd", "fbx", "obj", "mtl"];
const matchedIndex = !extension ? -1 : extensions.indexOf(extension.toLowerCase());
if (extension && matchedIndex >= 0) {
name = name.substring(0, name.length - extension.length - 1);
}
name = decodeURIComponent(name);
if (name.length > 3) {
let displayName = "";
let lastCharacterWasSpace = false;
const ignoredCharacters = ["(", ")", "[", "]", "{", "}", ":", ";", ",", ".", "!", "?"];
for (let i = 0; i < name.length; i++) {
let c = name[i];
if (c === "_" || c === "-")
c = " ";
if (c === ' ' && displayName.length <= 0)
continue;
const isIgnored = ignoredCharacters.includes(c);
if (isIgnored)
continue;
const isFirstCharacter = displayName.length === 0;
if (isFirstCharacter) {
c = c.toUpperCase();
}
if (lastCharacterWasSpace && c === " ") {
continue;
}
if (lastCharacterWasSpace) {
c = c.toUpperCase();
}
lastCharacterWasSpace = false;
displayName += c;
if (c === " ") {
lastCharacterWasSpace = true;
}
}
if (isDevEnvironment() && name !== displayName)
console.debug("Generated display name: \"" + name + "\" → \"" + displayName + "\"");
return displayName.trim();
}
if (isDevEnvironment())
console.debug("Loading: use default name", name);
return name;
}
function handleLoadingBlur(needleEngineElement) {
onStart((ctx) => {
const userBlurSetting = needleEngineElement.getAttribute("loading-blur");
if (userBlurSetting !== null && userBlurSetting !== "0") {
if (ctx.domElement === needleEngineElement) {
const promise = ctx.lodsManager.manager?.awaitLoading({
frames: 5,
signal: AbortSignal.timeout(10_000), // Limit how long the page can be blurred
maxPromisesPerObject: 1,
}).catch(_ => {
// Ignore errors (none are expected tho...)
});
let blur = "20px";
if (userBlurSetting.endsWith("px"))
blur = userBlurSetting;
const duration = 170;
// If the scene has a transparent background we apply a blur to the canvas directly to not *also* blur images
// But don't always use this effect because the edges don't look as good as with a backdrop filter
if (ctx.scene.background === null) {
const domElement = needleEngineElement;
const canvas = ctx.renderer.domElement;
const originalFilterValue = canvas.style.filter;
const originalOverflowValue = canvas.style.overflow;
canvas.style.filter += `blur(${blur})`;
domElement.style.overflow = "hidden";
promise?.then(() => {
const animation = canvas.animate([{
filter: "blur(0px)",
}
], { duration: duration, easing: "ease-in", });
animation.onfinish = () => {
canvas.style.filter = originalFilterValue;
domElement.style.overflow = originalOverflowValue;
};
});
}
else {
const blurryElement = document.createElement("div");
ctx.domElement.prepend(blurryElement);
blurryElement.style.cssText = "position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none";
blurryElement.style.backdropFilter = `blur(${blur})`;
promise?.then(() => {
const animation = blurryElement.animate([{
backdropFilter: "blur(0px)",
opacity: 0,
}
], { duration: duration, easing: "ease-in", });
animation.onfinish = () => {
blurryElement.remove();
};
});
}
}
}
}, { once: true });
}
//# sourceMappingURL=needle-engine.js.map