@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.
863 lines • 32.1 kB
JavaScript
// use for typesafe interface method calls
import { Quaternion, Vector2, Vector3, Vector4 } from "three";
import { ContextRegistry } from "./engine_context_registry.js";
// https://schneidenbach.gitbooks.io/typescript-cookbook/content/nameof-operator.html
/** @internal */
export const nameofFactory = () => (name) => name;
/** @internal */
export function nameof(name) {
return nameofFactory()(name);
}
/** @internal */
export function isDebugMode() {
return getParam("debug") ? true : false;
}
/**
* The circular buffer class can be used to cache objects that don't need to be created every frame.
* This structure is used for e.g. Vector3 or Quaternion objects in the engine when calling `getTempVector3` or `getTempQuaternion`.
*
* @example Create a circular buffer that caches Vector3 objects. Max size is 10.
* ```typescript
* const buffer = new CircularBuffer(() => new Vector3(), 10);
* const vec = buffer.get();
* ```
*
* @example Create a circular buffer that caches Quaternion objects. Max size is 1000.
* ```typescript
* const buffer = new CircularBuffer(() => new Quaternion(), 1000);
* const quat = buffer.get();
* ```
*/
export class CircularBuffer {
_factory;
_cache = [];
_maxSize;
_index = 0;
constructor(factory, maxSize) {
this._factory = factory;
this._maxSize = maxSize;
}
get() {
const i = this._index % this._maxSize;
this._index++;
if (this._cache.length <= i) {
this._cache[i] = this._factory();
}
return this._cache[i];
}
}
let showHelp = false;
const requestedParams = new Array();
if (typeof window !== "undefined") {
setTimeout(() => {
// const debugHelp = getParam("debughelp");
if (showHelp) {
const params = {};
const url = new URL(window.location.href);
const exampleUrl = new URL(url);
exampleUrl.searchParams.append("console", "");
const exampleUrlStr = exampleUrl.toString().replace(/=$|=(?=&)/g, '');
// Filter the params we're interested in
for (const param of requestedParams) {
const url2 = new URL(url);
url2.searchParams.append(param, "");
// Save url with clean parameters (remove trailing = and empty parameters)
params[param] = url2.toString().replace(/=$|=(?=&)/g, '');
}
console.log("🌵 ?help: Debug Options for Needle Engine.\n" +
"Append any of these parameters to the URL to enable specific debug options.\n" +
`Example: ${exampleUrlStr} will show an onscreen console window.`);
const postfix = showHelp === true ? "" : ` (containing "${showHelp}")`;
console.group("Available URL parameters:" + postfix);
for (const key of Object.keys(params).sort()) {
// If ?help= is a string we only want to show the parameters that contain the string
if (typeof showHelp === "string") {
if (!key.toLowerCase().includes(showHelp.toLowerCase()))
continue;
}
console.groupCollapsed(key);
// Needs to be a separate log, otherwise Safari doesn't turn the next line into a URL:
console.log("Reload with this flag enabled:");
console.log(params[key]);
console.groupEnd();
}
console.groupEnd();
}
}, 100);
}
export function getUrlParams() {
// "window" may not exist in node.js
return new URLSearchParams(globalThis.location?.search);
}
/**
* Checks if a URL parameter exists and returns its value.
* Useful for debugging, feature flags, and configuration.
*
* @param paramName The URL parameter name to check
* @returns
* - `true` if the parameter exists without a value (e.g. `?debug`)
* - `false` if the parameter doesn't exist or is set to `0`
* - The numeric value if it's a number (e.g. `?level=5` returns `5`)
* - The string value otherwise (e.g. `?name=test` returns `"test"`)
*
* @example Check debug mode
* ```ts
* if (getParam("debug")) {
* console.log("Debug mode enabled");
* }
* ```
* @example Get a numeric value
* ```ts
* const level = getParam("level"); // Returns number if ?level=5
* ```
*/
export function getParam(paramName) {
if (showHelp && !requestedParams.includes(paramName))
requestedParams.push(paramName);
const urlParams = getUrlParams();
if (urlParams.has(paramName)) {
const val = urlParams.get(paramName);
if (val) {
const num = Number(val);
if (!isNaN(num))
return num;
return val;
}
else
return true;
}
return false;
}
showHelp = getParam("help");
export function setParam(paramName, paramValue) {
const urlParams = getUrlParams();
if (urlParams.has(paramName)) {
urlParams.set(paramName, paramValue);
}
else
urlParams.append(paramName, paramValue);
document.location.search = urlParams.toString();
}
/** Sets an URL parameter without reloading the website */
export function setParamWithoutReload(paramName, paramValue, appendHistory = true) {
const urlParams = getUrlParams();
if (urlParams.has(paramName)) {
if (paramValue === null)
urlParams.delete(paramName);
else
urlParams.set(paramName, paramValue);
}
else if (paramValue !== null)
urlParams.append(paramName, paramValue);
if (appendHistory)
pushState(paramName, urlParams);
else
setState(paramName, urlParams);
}
/** Sets or adds an URL query parameter */
export function setOrAddParamsToUrl(url, paramName, paramValue) {
if (url.has(paramName)) {
url.set(paramName, paramValue.toString());
}
else
url.append(paramName, paramValue.toString());
}
/** Adds an entry to the browser history. Internally uses `window.history.pushState` */
export function pushState(title, urlParams, state) {
window.history.pushState(state, title, "?" + urlParams.toString());
}
/** Replaces the current entry in the browser history. Internally uses `window.history.replaceState` */
export function setState(title, urlParams, state) {
window.history.replaceState(state, title, "?" + urlParams.toString());
}
// for room id
/** Generates a random id string of the given length */
export function makeId(length) {
var result = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() *
charactersLength));
}
return result;
}
/** Generates a random number
* @deprecated use Mathf.random(min, max)
*/
export function randomNumber(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const adjectives = ["smol", "tiny", "giant", "interesting", "smart", "bright", "dull", "extreme", "beautiful", "pretty", "dark", "epic", "salty", "silly", "funny", "lame", "lazy", "loud", "lucky", "mad", "mean", "mighty", "mysterious", "nasty", "odd", "old", "powerful", "quiet", "rapid", "scary", "shiny", "shy", "silly", "smooth", "sour", "spicy", "stupid", "sweet", "tasty", "terrible", "ugly", "unusual", "vast", "wet", "wild", "witty", "wrong", "zany", "zealous", "zippy", "zombie", "zorro"];
const nouns = ["cat", "dog", "mouse", "pig", "cow", "horse", "sheep", "chicken", "duck", "goat", "panda", "tiger", "lion", "elephant", "monkey", "bird", "fish", "snake", "frog", "turtle", "hamster", "penguin", "kangaroo", "whale", "dolphin", "crocodile", "snail", "ant", "bee", "beetle", "butterfly", "dragon", "eagle", "fish", "giraffe", "lizard", "panda", "penguin", "rabbit", "snake", "spider", "tiger", "zebra"];
/** Generates a random id string from a list of adjectives and nouns */
export function makeIdFromRandomWords() {
const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
return randomAdjective + "_" + randomNoun;
}
// for url parameters
export function sanitizeString(str) {
str = str.replace(/[^a-z0-9áéÃóúñü \.,_-]/gim, "");
return str.trim();
}
// TODO: taken from scene utils
/**
* @param globalObjectIdentifier The guid of the object to find
* @param obj The object to search in
* @param recursive If true the search will be recursive
* @param searchComponents If true the search will also search components
* @returns the first object that has the globalObjectIdentifier as a guid */
export function tryFindObject(globalObjectIdentifier, obj, recursive = true, searchComponents = false) {
if (obj === undefined || obj === null)
return null;
if (obj.userData && obj.userData.guid === globalObjectIdentifier)
return obj;
else if (obj.guid == globalObjectIdentifier)
return obj;
if (searchComponents) {
if (obj.userData?.components) {
for (const comp of obj.userData.components) {
if (comp.guid === globalObjectIdentifier)
return comp;
}
}
}
if (recursive) {
if (obj.scenes) {
for (const i in obj.scenes) {
const scene = obj.scenes[i];
const found = tryFindObject(globalObjectIdentifier, scene, recursive, searchComponents);
if (found)
return found;
}
}
if (obj.children) {
for (const i in obj.children) {
const child = obj.children[i];
const found = tryFindObject(globalObjectIdentifier, child, recursive, searchComponents);
if (found)
return found;
}
}
}
}
/** Deep clones an object
* @param obj The object to clone
* @param predicate A function that can be used to skip certain properties from being cloned
* @returns The cloned object
* @example
* const clone = deepClone(obj, (owner, propertyName, current) => {
* if (propertyName === "dontCloneMe") return false;
* return true;
* });
* */
export function deepClone(obj, predicate) {
if (obj !== null && obj !== undefined && typeof obj === "object") {
let clone;
if (Array.isArray(obj))
clone = [];
else {
clone = Object.create(obj);
Object.assign(clone, obj);
}
for (const key of Object.keys(obj)) {
const val = obj[key];
if (predicate && !predicate(obj, key, val)) {
// console.log("SKIP", val);
clone[key] = val;
}
else if (val?.clone !== undefined && typeof val.clone === "function")
clone[key] = val.clone();
else
clone[key] = deepClone(val, predicate);
}
return clone;
}
return obj;
}
/** Wait for a specific amount of milliseconds to pass
* @returns a promise that resolves after a certain amount of milliseconds
* @example
* ```typescript
* await delay(1000);
* ```
*/
export function delay(milliseconds) {
return new Promise((resolve, _reject) => {
setTimeout(resolve, milliseconds);
});
}
/** Will wait for a specific amount of frames to pass
* @param frameCount The amount of frames to wait for
* @param context The context to use, if not provided the current context will be used
* @returns a promise that resolves after a certain amount of frames
* @example
* ```typescript
* await delayForFrames(10);
* ```
*/
export function delayForFrames(frameCount, context) {
if (frameCount <= 0)
return Promise.resolve();
if (!context)
context = ContextRegistry.Current;
if (!context)
return Promise.reject("No context");
const endFrame = context.time.frameCount + frameCount;
return new Promise((resolve, reject) => {
if (!context)
return reject("No context");
const cb = () => {
if (context.time.frameCount >= endFrame) {
context.pre_update_callbacks.splice(context.pre_update_callbacks.indexOf(cb), 1);
resolve();
}
};
context.pre_update_callbacks.push(cb);
});
}
// 1) if a timeline is exported via menu item the audio clip path is relative to the glb (same folder)
// we need to detect that here and build the new audio source path relative to the new glb location
// the same is/might be true for any file that is/will be exported via menu item
// 2) if the needle.config assetDirectory is modified (from e.g. /assets to /needle/assets) when building a distributable our vite transform and copy plugin will move the files to dist/assets hence we cannot use project-relative paths (because the path changes). What we do instead if make all paths serialized in a glb relative to the glb. The rel: prefix is used to detect urls that need to be resolved.
const debugGetPath = getParam("debugresolveurl");
export const relativePathPrefix = "rel:";
/** @deprecated use resolveUrl instead */
export function getPath(source, uri) {
return resolveUrl(source, uri);
}
/**
* Use to resolve a url serialized in a glTF file
* @param source The uri of the loading file
* @param uri The uri of the file to resolve, can be absolute or relative
* @returns The resolved uri
*/
export function resolveUrl(source, uri) {
if (uri === undefined) {
if (debugGetPath)
console.warn("getPath: uri is undefined, returning uri", uri);
return uri;
}
if (uri.startsWith("./")) {
return uri;
}
if (uri.startsWith("http")) {
if (debugGetPath)
console.warn("getPath: uri is absolute, returning uri", uri);
return uri;
}
if (source === undefined) {
if (debugGetPath)
console.warn("getPath: source is undefined, returning uri", uri);
return uri;
}
if (uri.startsWith(relativePathPrefix)) {
uri = uri.substring(4);
}
const pathIndex = source.lastIndexOf("/");
if (pathIndex >= 0) {
// Take the source uri as the base path
const basePath = source.substring(0, pathIndex + 1);
// make sure we don't have double slashes
while (basePath.endsWith("/") && uri.startsWith("/"))
uri = uri.substring(1);
// Append the relative uri
const newUri = basePath + uri;
// newUri = new URL(newUri, globalThis.location.href).href;
if (debugGetPath)
console.log("source:", source, "changed uri \nfrom", uri, "\nto ", newUri, "\nbasePath: " + basePath);
return newUri;
}
return uri;
}
export function toSourceId(src) {
if (!src)
return undefined;
src = src.trim();
src = src.split("?")[0]?.split("#")[0];
return src;
}
// TODO: make it possible to add multiple watches to the same object property
class WatchImpl {
subscribeWrite(callback) {
this.writeCallbacks.push(callback);
}
unsubscribeWrite(callback) {
const i = this.writeCallbacks.indexOf(callback);
if (i === -1)
return;
this.writeCallbacks.splice(i, 1);
}
writeCallbacks = [];
constructor(object, prop) {
this._object = object;
this._prop = prop;
this._wrapperProp = Symbol("$" + prop);
this.apply();
}
_applied = false;
_object;
_prop;
_wrapperProp;
apply() {
if (this._applied)
return;
if (!this._object)
return;
const object = this._object;
const prop = this._prop;
if (object[prop] === undefined)
return;
this._applied = true;
if (object[this._wrapperProp] !== undefined) {
console.warn("Watcher is being applied to an object that already has a wrapper property. This is not (yet) supported");
}
// create a wrapper property
const current = object[prop];
object[this._wrapperProp] = current;
// create wrapper methods
const getter = () => {
return object[this._wrapperProp];
};
const setter = (value) => {
object[this._wrapperProp] = value;
for (const write of this.writeCallbacks) {
write(value, this._prop);
}
};
// add the wrapper to the object
Object.defineProperty(object, prop, {
get: getter,
set: setter
});
}
revoke() {
if (!this._applied)
return;
if (!this._object)
return;
this._applied = false;
const object = this._object;
const prop = this._prop;
Reflect.deleteProperty(object, prop);
const current = object[this._wrapperProp];
object[prop] = current;
Reflect.deleteProperty(object, this._wrapperProp);
}
dispose() {
this.revoke();
this.writeCallbacks.length = 0;
this._object = null;
}
}
export class Watch {
_watches = [];
constructor(object, str) {
if (Array.isArray(str)) {
for (const s of str) {
this._watches.push(new Watch(object, s));
}
}
else {
this._watches.push(new WatchImpl(object, str));
}
}
subscribeWrite(callback) {
for (const w of this._watches) {
w.subscribeWrite(callback);
}
}
unsubscribeWrite(callback) {
for (const w of this._watches) {
w.unsubscribeWrite(callback);
}
}
apply() {
for (const w of this._watches) {
w.apply();
}
}
revoke() {
for (const w of this._watches) {
w.revoke();
}
}
dispose() {
for (const w of this._watches) {
w.dispose();
}
this._watches.length = 0;
}
}
const watchesKey = Symbol("needle:watches");
/** Subscribe to an object being written to
* Currently supporting Vector3
*/
export function watchWrite(vec, cb) {
if (!vec[watchesKey]) {
if (vec instanceof Vector2) {
vec[watchesKey] = new Watch(vec, ["x", "y"]);
}
else if (vec instanceof Vector3) {
vec[watchesKey] = new Watch(vec, ["x", "y", "z"]);
}
else if (vec instanceof Vector4 || vec instanceof Quaternion) {
vec[watchesKey] = new Watch(vec, ["x", "y", "z", "w"]);
}
else {
return false;
}
}
vec[watchesKey].subscribeWrite(cb);
return true;
}
export function unwatchWrite(vec, cb) {
if (!vec)
return;
const watch = vec[watchesKey];
if (!watch)
return;
watch.unsubscribeWrite(cb);
}
;
/**
* Utility functions to detect certain device types (mobile, desktop), browsers, or capabilities.
* @category Utilities
*/
export var DeviceUtilities;
(function (DeviceUtilities) {
let _isDesktop;
/** @returns `true` for MacOS or Windows devices. `false` for Hololens and other headsets. */
function isDesktop() {
if (_isDesktop !== undefined)
return _isDesktop;
const ua = window.navigator.userAgent;
const standalone = /Windows|MacOS|Mac OS/.test(ua);
const isHololens = /Windows NT/.test(ua) && /Edg/.test(ua) && !/Win64/.test(ua);
return _isDesktop = standalone && !isHololens && !isiOS();
}
DeviceUtilities.isDesktop = isDesktop;
let _ismobile;
/** @returns `true` if it's a phone or tablet */
function isMobileDevice() {
if (_ismobile !== undefined)
return _ismobile;
// eslint-disable-next-line @typescript-eslint/no-deprecated
if ((typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1)) {
return _ismobile = true;
}
return _ismobile = /iPhone|iPad|iPod|Android|IEMobile/i.test(navigator.userAgent);
}
DeviceUtilities.isMobileDevice = isMobileDevice;
/** @deprecated use {@link isiPad} instead */
function isIPad() {
return isiPad();
}
DeviceUtilities.isIPad = isIPad;
let __isiPad;
/** @returns `true` if we're currently on an iPad */
function isiPad() {
if (__isiPad !== undefined)
return __isiPad;
const userAgent = navigator.userAgent.toLowerCase();
return __isiPad = /iPad/.test(navigator.userAgent) || userAgent.includes("macintosh") && "ontouchend" in document;
}
DeviceUtilities.isiPad = isiPad;
let __isAndroidDevice;
/** @returns `true` if we're currently on an Android device */
function isAndroidDevice() {
if (__isAndroidDevice !== undefined)
return __isAndroidDevice;
return __isAndroidDevice = /Android/.test(navigator.userAgent);
}
DeviceUtilities.isAndroidDevice = isAndroidDevice;
let __isMozillaXR;
/** @returns `true` if we're currently using the Mozilla XR Browser (only available for iOS) */
function isMozillaXR() {
if (__isMozillaXR !== undefined)
return __isMozillaXR;
return __isMozillaXR = /WebXRViewer\//i.test(navigator.userAgent);
}
DeviceUtilities.isMozillaXR = isMozillaXR;
let __isNeedleAppClip;
/** @returns `true` if we're currently in the Needle App Clip */
function isNeedleAppClip() {
if (__isNeedleAppClip !== undefined)
return __isNeedleAppClip;
return __isNeedleAppClip = /NeedleAppClip\//i.test(navigator.userAgent);
}
DeviceUtilities.isNeedleAppClip = isNeedleAppClip;
let __isMacOS;
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
/** @returns `true` for MacOS devices */
function isMacOS() {
if (__isMacOS !== undefined)
return __isMacOS;
if (isiOS() || isiPad())
return __isMacOS = false;
const userAgent = navigator.userAgent.toLowerCase();
if (navigator.userAgentData) {
// Use modern UA Client Hints API if available
return __isMacOS = navigator.userAgentData.platform === 'macOS';
}
else {
// Fallback to user agent string parsing
return __isMacOS = userAgent.includes('mac os x') || userAgent.includes('macintosh');
}
}
DeviceUtilities.isMacOS = isMacOS;
let __isVisionOS;
/** @returns `true` for VisionOS devices */
function isVisionOS() {
if (__isVisionOS !== undefined)
return __isVisionOS;
return __isVisionOS = (isiPad() && "xr" in navigator && supportsQuickLookAR());
}
DeviceUtilities.isVisionOS = isVisionOS;
let __isiOS;
const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];
/** @returns `true` for mobile Apple devices like iPad, iPhone, iPod, Vision Pro, ... */
function isiOS() {
if (__isiOS !== undefined)
return __isiOS;
// eslint-disable-next-line @typescript-eslint/no-deprecated
return __isiOS = iosDevices.includes(navigator.platform)
// iPad on iOS 13 detection
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document);
}
DeviceUtilities.isiOS = isiOS;
let __isSafari;
/** @returns `true` if we're currently on safari */
function isSafari() {
if (__isSafari !== undefined)
return __isSafari;
__isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
return __isSafari;
}
DeviceUtilities.isSafari = isSafari;
let __isQuest;
/** @returns `true` for Meta Quest devices and browser. */
function isQuest() {
if (__isQuest !== undefined)
return __isQuest;
return __isQuest = navigator.userAgent.includes("OculusBrowser");
}
DeviceUtilities.isQuest = isQuest;
let __supportsQuickLookAR;
/** @returns `true` if the browser has `<a rel="ar">` support, which indicates USDZ QuickLook support. */
function supportsQuickLookAR() {
if (__supportsQuickLookAR !== undefined)
return __supportsQuickLookAR;
const a = document.createElement("a");
__supportsQuickLookAR = a.relList.supports("ar");
return __supportsQuickLookAR;
}
DeviceUtilities.supportsQuickLookAR = supportsQuickLookAR;
/** @returns `true` if the user allowed to use the microphone */
async function microphonePermissionsGranted() {
try {
//@ts-ignore
const res = await navigator.permissions.query({ name: 'microphone' });
if (res.state === "denied") {
return false;
}
return true;
}
catch (err) {
console.error("Error querying `microphone` permissions.", err);
return false;
}
}
DeviceUtilities.microphonePermissionsGranted = microphonePermissionsGranted;
let __iOSVersion;
function getiOSVersion() {
if (__iOSVersion !== undefined)
return __iOSVersion;
const match = navigator.userAgent.match(/iPhone OS (\d+_\d+)/);
if (match)
__iOSVersion = match[1].replace("_", ".");
if (!__iOSVersion) {
// Look for "(Macintosh;" or "(iPhone;" or "(iPad;" and then check Version/18.0
const match2 = navigator.userAgent.match(/(?:\(Macintosh;|iPhone;|iPad;).*Version\/(\d+\.\d+)/);
if (match2)
__iOSVersion = match2[1];
}
// if we dont have any match we set it to null to avoid running the check again
if (!__iOSVersion) {
__iOSVersion = null;
}
return __iOSVersion;
}
DeviceUtilities.getiOSVersion = getiOSVersion;
let __chromeVersion;
function getChromeVersion() {
if (__chromeVersion !== undefined)
return __chromeVersion;
const match = navigator.userAgent.match(/(?:CriOS|Chrome)\/(\d+\.\d+\.\d+\.\d+)/);
if (match) {
const result = match[1].replace("_", ".");
__chromeVersion = result;
}
else
__chromeVersion = null;
return __chromeVersion;
}
DeviceUtilities.getChromeVersion = getChromeVersion;
let __safariVersion;
function getSafariVersion() {
if (__safariVersion !== undefined)
return __safariVersion;
const match = navigator.userAgent.match(/Version\/(\d+\.\d+)/);
if (match && isSafari()) {
__safariVersion = match[1];
}
else
__safariVersion = null;
return __safariVersion;
}
DeviceUtilities.getSafariVersion = getSafariVersion;
})(DeviceUtilities || (DeviceUtilities = {}));
/**
* @deprecated use {@link DeviceUtilities.isDesktop} instead
*/
export function isDesktop() {
return DeviceUtilities.isDesktop();
}
/**
* @deprecated use {@link DeviceUtilities.isMobileDevice} instead
*/
export function isMobileDevice() {
return DeviceUtilities.isMobileDevice();
}
/** @deprecated use {@link DeviceUtilities.isiPad} instead */
export function isIPad() {
return DeviceUtilities.isiPad();
}
/** @deprecated use {@link DeviceUtilities.isiPad} instead */
export function isiPad() {
return DeviceUtilities.isiPad();
}
/** @deprecated use {@link DeviceUtilities.isAndroidDevice} instead */
export function isAndroidDevice() {
return DeviceUtilities.isAndroidDevice();
}
/** @deprecated use {@link DeviceUtilities.isMozillaXR} instead */
export function isMozillaXR() {
return DeviceUtilities.isMozillaXR();
}
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
/** @deprecated use {@link DeviceUtilities.isMacOS} instead */
export function isMacOS() {
return DeviceUtilities.isMacOS();
}
/** @deprecated use {@link DeviceUtilities.isiOS} instead */
export function isiOS() {
return DeviceUtilities.isiOS();
}
/** @deprecated use {@link DeviceUtilities.isSafari} instead */
export function isSafari() {
return DeviceUtilities.isSafari();
}
/** @deprecated use {@link DeviceUtilities.isQuest} instead */
export function isQuest() {
return DeviceUtilities.isQuest();
}
/** @deprecated use {@link DeviceUtilities.microphonePermissionsGranted} instead */
export async function microphonePermissionsGranted() {
return DeviceUtilities.microphonePermissionsGranted();
}
const mutationObserverMap = new WeakMap();
/**
* Register a callback when an {@link HTMLElement} attribute changes.
* This is used, for example, by the Skybox component to watch for changes to the environment-* and skybox-* attributes.
* @returns A function that can be used to unregister the callback
*/
export function addAttributeChangeCallback(domElement, name, callback) {
if (!mutationObserverMap.get(domElement)) {
const observer = new MutationObserver((mutations) => {
handleMutations(domElement, mutations);
});
mutationObserverMap.set(domElement, {
observer,
attributeChangedListeners: new Map(),
});
observer.observe(domElement, { attributes: true });
}
const listeners = mutationObserverMap.get(domElement).attributeChangedListeners;
if (!listeners.has(name)) {
listeners.set(name, []);
}
listeners.get(name).push(callback);
return () => {
removeAttributeChangeCallback(domElement, name, callback);
};
}
;
/**
* Unregister a callback previously registered with {@link addAttributeChangeCallback}.
*/
export function removeAttributeChangeCallback(domElement, name, callback) {
if (!mutationObserverMap.get(domElement))
return;
const listeners = mutationObserverMap.get(domElement).attributeChangedListeners;
if (!listeners.has(name))
return;
const arr = listeners.get(name);
const index = arr.indexOf(callback);
if (index === -1)
return;
arr.splice(index, 1);
if (arr.length <= 0) {
listeners.delete(name);
const entry = mutationObserverMap.get(domElement);
entry?.observer.disconnect();
mutationObserverMap.delete(domElement);
}
}
function handleMutations(domElement, mutations) {
const listeners = mutationObserverMap.get(domElement).attributeChangedListeners;
for (const mut of mutations) {
if (mut.type === "attributes") {
const name = mut.attributeName;
const value = domElement.getAttribute(name);
if (listeners.has(name)) {
for (const listener of listeners.get(name)) {
listener(value);
}
}
}
}
}
/** Used by `PromiseAllWithErrors` */
export class PromiseErrorResult {
reason;
constructor(reason) {
this.reason = reason;
}
}
/** Can be used to simplify Promise error handling and if errors are acceptable.
* Promise.all will just fail if any of the provided promises fails and not return or cancel pending promises or partial results
* Using Promise.allSettled (or this method) instead will return a result for each promise and not automatically fail if any of the promises fails.
* Instead it will return a promise containing information if any of the promises failed
* and the actual results will be available as `results` array
**/
export async function PromiseAllWithErrors(promise) {
const results = await Promise.allSettled(promise).catch(err => {
return [
new PromiseErrorResult(err.message)
];
});
let anyFailed = false;
const res = results.map(x => {
if ("value" in x)
return x.value;
anyFailed = true;
return new PromiseErrorResult(x.reason);
});
return {
anyFailed: anyFailed,
results: res,
};
}
//# sourceMappingURL=engine_utils.js.map