UNPKG

@babylonjs/viewer

Version:

The Babylon Viewer aims to simplify a specific but common Babylon.js use case: loading, viewing, and interacting with a 3D model.

1,237 lines (1,232 loc) 510 kB
/* eslint-disable @typescript-eslint/naming-convention */ /** * Base error. Due to limitations of typedoc-check and missing documentation * in lib.es5.d.ts, cannot extend Error directly for RuntimeError. * @ignore */ class BaseError extends Error { } // See https://stackoverflow.com/questions/12915412/how-do-i-extend-a-host-object-e-g-error-in-typescript // and https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work // Polyfill for Object.setPrototypeOf if necessary. BaseError._setPrototypeOf = Object.setPrototypeOf || ((o, proto) => { o.__proto__ = proto; // eslint-disable-next-line @typescript-eslint/no-unsafe-return return o; }); /** * Used for flow control when an operation is aborted, such as with AbortController. */ class AbortError extends BaseError { constructor(message = "Operation aborted") { super(message); this.name = "AbortError"; BaseError._setPrototypeOf(this, AbortError.prototype); } } /** * Wrapper class for promise with external resolve and reject. */ class Deferred { /** * The resolve method of the promise associated with this deferred object. */ get resolve() { return this._resolve; } /** * The reject method of the promise associated with this deferred object. */ get reject() { return this._reject; } /** * Constructor for this deferred object. */ constructor() { this.promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } } /** * Provides a simple way of creating the rough equivalent of an async critical section. * * @example * ```typescript * const myLock = new AsyncLock(); * * private async MyFuncAsync(): Promise<void> { * await myLock.lockAsync(async () => { * await operation1Async(); * await operation2Async(); * }); * } * ``` */ class AsyncLock { constructor() { this._currentOperation = Promise.resolve(); } /** * Executes the provided function when the lock is acquired (e.g. when the previous operation finishes). * @param func The function to execute. * @param signal An optional signal that can be used to abort the operation. * @returns A promise that resolves when the func finishes executing. */ // eslint-disable-next-line @typescript-eslint/promise-function-async, no-restricted-syntax lockAsync(func, signal) { signal?.throwIfAborted(); const wrappedFunc = signal ? // eslint-disable-next-line @typescript-eslint/promise-function-async () => { signal.throwIfAborted(); return func(); } : func; // eslint-disable-next-line github/no-then const newOperation = this._currentOperation.then(wrappedFunc); // NOTE: It would be simpler to just hold a Promise<unknown>, but this class should not prevent an object held by the returned promise from being garbage collected. this._currentOperation = new Promise((resolve) => { // eslint-disable-next-line github/no-then newOperation.then(() => resolve(), resolve); }); return newOperation; } /** * Executes the provided function when all the specified locks are acquired. * @param func The function to execute. * @param locks The locks to acquire. * @param signal An optional signal that can be used to abort the operation. * @returns A promise that resolves when the func finishes executing. */ static async LockAsync(func, locks, signal) { signal?.throwIfAborted(); if (locks.length === 0) { return await func(); } const deferred = new Deferred(); let acquiredLocks = 0; for (const lock of locks) { lock.lockAsync(async () => { acquiredLocks++; if (acquiredLocks === locks.length) { deferred.resolve(await func()); } return await deferred.promise; // eslint-disable-next-line github/no-then }, signal).catch((e) => deferred.reject(e)); } return await deferred.promise; } } /* eslint-disable no-console */ /** * Logger used throughout the application to allow configuration of * the log level required for the messages. */ class Logger { static _CheckLimit(message, limit) { let entry = Logger._LogLimitOutputs[message]; if (!entry) { entry = { limit, current: 1 }; Logger._LogLimitOutputs[message] = entry; } else { entry.current++; } return entry.current <= entry.limit; } static _GenerateLimitMessage(message, level = 1) { const entry = Logger._LogLimitOutputs[message]; if (!entry || !Logger.MessageLimitReached) { return; } const type = this._Levels[level]; if (entry.current === entry.limit) { Logger[type.name](Logger.MessageLimitReached.replace(/%LIMIT%/g, "" + entry.limit).replace(/%TYPE%/g, type.name ?? "")); } } static _AddLogEntry(entry) { Logger._LogCache = entry + Logger._LogCache; if (Logger.OnNewCacheEntry) { Logger.OnNewCacheEntry(entry); } } static _FormatMessage(message) { const padStr = (i) => (i < 10 ? "0" + i : "" + i); const date = new Date(); return "[" + padStr(date.getHours()) + ":" + padStr(date.getMinutes()) + ":" + padStr(date.getSeconds()) + "]: " + message; } // eslint-disable-next-line @typescript-eslint/no-unused-vars static _LogDisabled(message, limit) { // nothing to do } static _LogEnabled(level = 1, message, limit) { // take first message if array const msg = Array.isArray(message) ? message[0] : message; if (limit !== undefined && !Logger._CheckLimit(msg, limit)) { return; } const formattedMessage = Logger._FormatMessage(msg); const type = this._Levels[level]; const optionals = Array.isArray(message) ? message.slice(1) : []; type.logFunc && type.logFunc("BJS - " + formattedMessage, ...optionals); const entry = `<div style='color:${type.color}'>${formattedMessage}</div><br>`; Logger._AddLogEntry(entry); Logger._GenerateLimitMessage(msg, level); } /** * Gets current log cache (list of logs) */ static get LogCache() { return Logger._LogCache; } /** * Clears the log cache */ static ClearLogCache() { Logger._LogCache = ""; Logger._LogLimitOutputs = {}; Logger.errorsCount = 0; } /** * Sets the current log level. This property is a bit field, allowing you to combine different levels (MessageLogLevel / WarningLogLevel / ErrorLogLevel). * Use NoneLogLevel to disable logging and AllLogLevel for a quick way to enable all levels. */ static set LogLevels(level) { Logger.Log = Logger._LogDisabled; Logger.Warn = Logger._LogDisabled; Logger.Error = Logger._LogDisabled; const levels = [Logger.MessageLogLevel, Logger.WarningLogLevel, Logger.ErrorLogLevel]; for (const l of levels) { if ((level & l) === l) { const type = this._Levels[l]; Logger[type.name] = Logger._LogEnabled.bind(Logger, l); } } } } /** * No log */ Logger.NoneLogLevel = 0; /** * Only message logs */ Logger.MessageLogLevel = 1; /** * Only warning logs */ Logger.WarningLogLevel = 2; /** * Only error logs */ Logger.ErrorLogLevel = 4; /** * All logs */ Logger.AllLogLevel = 7; /** * Message to display when a message has been logged too many times */ Logger.MessageLimitReached = "Too many %TYPE%s (%LIMIT%), no more %TYPE%s will be reported for this message."; Logger._LogCache = ""; Logger._LogLimitOutputs = {}; // levels according to the (binary) numbering. Logger._Levels = [ {}, { color: "white", logFunc: console.log, name: "Log" }, { color: "orange", logFunc: console.warn, name: "Warn" }, {}, { color: "red", logFunc: console.error, name: "Error" }, ]; /** * Gets a value indicating the number of loading errors * @ignorenaming */ // eslint-disable-next-line @typescript-eslint/naming-convention Logger.errorsCount = 0; /** * Log a message to the console */ Logger.Log = Logger._LogEnabled.bind(Logger, Logger.MessageLogLevel); /** * Write a warning message to the console */ Logger.Warn = Logger._LogEnabled.bind(Logger, Logger.WarningLogLevel); /** * Write an error message to the console */ Logger.Error = Logger._LogEnabled.bind(Logger, Logger.ErrorLogLevel); /** This file must only contain pure code and pure imports */ const IsWeakRefSupported = typeof WeakRef !== "undefined"; /** * A class serves as a medium between the observable and its observers */ class EventState { /** * Create a new EventState * @param mask defines the mask associated with this state * @param skipNextObservers defines a flag which will instruct the observable to skip following observers when set to true * @param target defines the original target of the state * @param currentTarget defines the current target of the state */ constructor(mask, skipNextObservers = false, target, currentTarget) { this.initialize(mask, skipNextObservers, target, currentTarget); } /** * Initialize the current event state * @param mask defines the mask associated with this state * @param skipNextObservers defines a flag which will instruct the observable to skip following observers when set to true * @param target defines the original target of the state * @param currentTarget defines the current target of the state * @returns the current event state */ initialize(mask, skipNextObservers = false, target, currentTarget) { this.mask = mask; this.skipNextObservers = skipNextObservers; this.target = target; this.currentTarget = currentTarget; return this; } } /** * Represent an observer registered to a given Observable object. */ class Observer { /** * Creates a new observer * @param callback defines the callback to call when the observer is notified * @param mask defines the mask of the observer (used to filter notifications) * @param scope defines the current scope used to restore the JS context */ constructor( /** * Defines the callback to call when the observer is notified */ callback, /** * Defines the mask of the observer (used to filter notifications) */ mask, /** * [null] Defines the current scope used to restore the JS context */ scope = null) { this.callback = callback; this.mask = mask; this.scope = scope; /** @internal */ this._willBeUnregistered = false; /** * Gets or sets a property defining that the observer as to be unregistered after the next notification */ this.unregisterOnNextCall = false; /** * this function can be used to remove the observer from the observable. * It will be set by the observable that the observer belongs to. * @internal */ this._remove = null; } /** * Remove the observer from its observable * This can be used instead of using the observable's remove function. * @param defer if true, the removal will be deferred to avoid callback skipping (default: false) */ remove(defer = false) { if (this._remove) { this._remove(defer); } } } /** * The Observable class is a simple implementation of the Observable pattern. * * There's one slight particularity though: a given Observable can notify its observer using a particular mask value, only the Observers registered with this mask value will be notified. * This enable a more fine grained execution without having to rely on multiple different Observable objects. * For instance you may have a given Observable that have four different types of notifications: Move (mask = 0x01), Stop (mask = 0x02), Turn Right (mask = 0X04), Turn Left (mask = 0X08). * A given observer can register itself with only Move and Stop (mask = 0x03), then it will only be notified when one of these two occurs and will never be for Turn Left/Right. */ class Observable { /** * Create an observable from a Promise. * @param promise a promise to observe for fulfillment. * @param onErrorObservable an observable to notify if a promise was rejected. * @returns the new Observable */ static FromPromise(promise, onErrorObservable) { const observable = new Observable(); promise // eslint-disable-next-line github/no-then .then((ret) => { observable.notifyObservers(ret); }) // eslint-disable-next-line github/no-then .catch((err) => { if (onErrorObservable) { onErrorObservable.notifyObservers(err); } else { throw err; } }); return observable; } /** * Gets the list of observers * Note that observers that were recently deleted may still be present in the list because they are only really deleted on the next javascript tick! */ get observers() { return this._observers; } /** * Creates a new observable * @param onObserverAdded defines a callback to call when a new observer is added * @param notifyIfTriggered If set to true the observable will notify when an observer was added if the observable was already triggered. */ constructor(onObserverAdded, /** * [false] If set to true the observable will notify when an observer was added if the observable was already triggered. * This is helpful to single-state observables like the scene onReady or the dispose observable. */ notifyIfTriggered = false) { this.notifyIfTriggered = notifyIfTriggered; this._observers = new Array(); this._numObserversMarkedAsDeleted = 0; this._hasNotified = false; this._eventState = new EventState(0); if (onObserverAdded) { this._onObserverAdded = onObserverAdded; } } add(callback, mask = -1, insertFirst = false, scope = null, unregisterOnFirstCall = false) { if (!callback) { return null; } const observer = new Observer(callback, mask, scope); observer.unregisterOnNextCall = unregisterOnFirstCall; if (insertFirst) { this._observers.unshift(observer); } else { this._observers.push(observer); } if (this._onObserverAdded) { this._onObserverAdded(observer); } // If the observable was already triggered and the observable is set to notify if triggered, notify the new observer if (this._hasNotified && this.notifyIfTriggered) { if (this._lastNotifiedValue !== undefined) { this.notifyObserver(observer, this._lastNotifiedValue); } } // attach the remove function to the observer const observableWeakRef = IsWeakRefSupported ? new WeakRef(this) : { deref: () => this }; observer._remove = (defer = false) => { const observable = observableWeakRef.deref(); if (observable) { defer ? observable.remove(observer) : observable._remove(observer); } }; return observer; } addOnce(callback) { return this.add(callback, undefined, undefined, undefined, true); } /** * Remove an Observer from the Observable object * @param observer the instance of the Observer to remove * @returns false if it doesn't belong to this Observable */ remove(observer) { if (!observer) { return false; } observer._remove = null; const index = this._observers.indexOf(observer); if (index !== -1) { this._deferUnregister(observer); return true; } return false; } /** * Remove a callback from the Observable object * @param callback the callback to remove * @param scope optional scope. If used only the callbacks with this scope will be removed * @returns false if it doesn't belong to this Observable */ removeCallback(callback, scope) { for (let index = 0; index < this._observers.length; index++) { const observer = this._observers[index]; if (observer._willBeUnregistered) { continue; } if (observer.callback === callback && (!scope || scope === observer.scope)) { this._deferUnregister(observer); return true; } } return false; } /** * @internal */ _deferUnregister(observer) { if (observer._willBeUnregistered) { return; } this._numObserversMarkedAsDeleted++; observer.unregisterOnNextCall = false; observer._willBeUnregistered = true; setTimeout(() => { this._remove(observer); }, 0); } // This should only be called when not iterating over _observers to avoid callback skipping. // Removes an observer from the _observer Array. _remove(observer, updateCounter = true) { if (!observer) { return false; } const index = this._observers.indexOf(observer); if (index !== -1) { if (updateCounter) { this._numObserversMarkedAsDeleted--; } this._observers.splice(index, 1); return true; } return false; } /** * Moves the observable to the top of the observer list making it get called first when notified * @param observer the observer to move */ makeObserverTopPriority(observer) { this._remove(observer, false); this._observers.unshift(observer); } /** * Moves the observable to the bottom of the observer list making it get called last when notified * @param observer the observer to move */ makeObserverBottomPriority(observer) { this._remove(observer, false); this._observers.push(observer); } /** * Notify all Observers by calling their respective callback with the given data * Will return true if all observers were executed, false if an observer set skipNextObservers to true, then prevent the subsequent ones to execute * @param eventData defines the data to send to all observers * @param mask defines the mask of the current notification (observers with incompatible mask (ie mask & observer.mask === 0) will not be notified) * @param target defines the original target of the state * @param currentTarget defines the current target of the state * @param userInfo defines any user info to send to observers * @returns false if the complete observer chain was not processed (because one observer set the skipNextObservers to true) */ notifyObservers(eventData, mask = -1, target, currentTarget, userInfo) { // this prevents potential memory leaks - if an object is disposed but the observable doesn't get cleared. if (this.notifyIfTriggered) { this._hasNotified = true; this._lastNotifiedValue = eventData; } if (!this._observers.length) { return true; } const state = this._eventState; state.mask = mask; state.target = target; state.currentTarget = currentTarget; state.skipNextObservers = false; state.lastReturnValue = eventData; state.userInfo = userInfo; for (const obs of this._observers) { if (obs._willBeUnregistered) { continue; } if (obs.mask & mask) { if (obs.unregisterOnNextCall) { this._deferUnregister(obs); } if (obs.scope) { state.lastReturnValue = obs.callback.apply(obs.scope, [eventData, state]); } else { state.lastReturnValue = obs.callback(eventData, state); } } if (state.skipNextObservers) { return false; } } return true; } /** * Notify a specific observer * @param observer defines the observer to notify * @param eventData defines the data to be sent to each callback * @param mask is used to filter observers defaults to -1 */ notifyObserver(observer, eventData, mask = -1) { // this prevents potential memory leaks - if an object is disposed but the observable doesn't get cleared. if (this.notifyIfTriggered) { this._hasNotified = true; this._lastNotifiedValue = eventData; } if (observer._willBeUnregistered) { return; } const state = this._eventState; state.mask = mask; state.skipNextObservers = false; if (observer.unregisterOnNextCall) { this._deferUnregister(observer); } observer.callback(eventData, state); } /** * Gets a boolean indicating if the observable has at least one observer * @returns true is the Observable has at least one Observer registered */ hasObservers() { return this._observers.length - this._numObserversMarkedAsDeleted > 0; } /** * Clear the list of observers */ clear() { while (this._observers.length) { const o = this._observers.pop(); if (o) { o._remove = null; } } this._onObserverAdded = null; this._numObserversMarkedAsDeleted = 0; this.cleanLastNotifiedState(); } /** * Clean the last notified state - both the internal last value and the has-notified flag */ cleanLastNotifiedState() { this._hasNotified = false; this._lastNotifiedValue = undefined; } /** * Clone the current observable * @returns a new observable */ clone() { const result = new Observable(); result._observers = this._observers.slice(0); return result; } /** * Does this observable handles observer registered with a given mask * @param mask defines the mask to be tested * @returns whether or not one observer registered with the given mask is handled **/ hasSpecificMask(mask = -1) { for (const obs of this._observers) { if (obs.mask & mask || obs.mask === mask) { return true; } } return false; } } /** * Throws if any of the supplied abort signals is aborted. * @param abortSignals The signals to check. */ function throwIfAborted(...abortSignals) { for (const signal of abortSignals) { signal?.throwIfAborted(); } } /** * Fire-and-forget wrapper for a promise that logs non-abort errors. * @param promise The promise to observe. */ function observePromise(promise) { // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { try { await promise; } catch (error) { if (!(error instanceof AbortError)) { Logger.Error(error); } } })(); } const shadowQualityOptions = ["none", "normal", "high"]; const toneMappingOptions = ["none", "standard", "aces", "neutral"]; /** * Checks if the given value is a valid tone mapping option. * @param value The value to check. * @returns True if the value is a valid tone mapping option, otherwise false. */ function IsToneMapping(value) { return toneMappingOptions.includes(value); } /** * Checks if the given value is a valid shadow quality option. * @param value The value to check. * @returns True if the value is a valid shadow quality option, otherwise false. */ function IsShadowQuality(value) { return shadowQualityOptions.includes(value); } /** * Provides the result of a hot spot query. */ class ViewerHotSpotResult { constructor() { /** * 2D canvas position in pixels. */ this.screenPosition = [NaN, NaN]; /** * 3D world coordinates. */ this.worldPosition = [NaN, NaN, NaN]; /** * Visibility range is [-1..1]. A value of 0 means camera eye is on the plane. */ this.visibility = NaN; } } /** * The default values for {@link ViewerBaseOptions}. Used as fallbacks by both the full Viewer * and the Lite Viewer; each re-exports this as its own `DefaultViewerOptions`. */ const DefaultViewerBaseOptions = { clearColor: [0, 0, 0, 0], autoSuspendRendering: true, environmentConfig: { intensity: 1, blur: 0.3, rotation: 0, }, environmentLighting: "auto", environmentSkybox: "none", cameraAutoOrbit: { enabled: false, delay: 2000, speed: 0.05, }, animationAutoPlay: false, animationSpeed: 1, shadowConfig: { quality: "none", }, postProcessing: { toneMapping: "neutral", contrast: 1, exposure: 1, ssao: "auto", }, useRightHandedSystem: false, useOpenPBR: false, }; /** * @internal * Orbit angle (radians) applied to the arc-rotate camera on every automatic reframe (model load * and animation switch). Shared by the full Viewer and ViewerLite so both frame to the same default * viewpoint. Exported from `viewerBase` for internal sharing only — intentionally not re-exported * from the package index, so it is not part of the public API. */ const FramingCameraAlpha = Math.PI / 2; /** * @internal * Elevation angle (radians) applied to the arc-rotate camera on every automatic reframe. Shared by * the full Viewer and ViewerLite. Exported from `viewerBase` for internal sharing only — * intentionally not re-exported from the package index, so it is not part of the public API. */ const FramingCameraBeta = Math.PI / 2.4; /** * Common base for the full Babylon.js {@link Viewer} and the lite Viewer. * * Encapsulates the pieces that are identical between both engine backends: * - The 18 public observables exposed by the viewer surface area * - In-flight load operation tracking (used to compute aggregate `loadingProgress`) * - The `_throwIfDisposedOrAborted` helper used at the start of every async operation * - The disposed flag and observable teardown in `dispose()` * * Subclasses are responsible for everything engine-specific (scene/engine creation, * model + environment loading orchestration, camera, post-processing, shadows, etc.) * and for declaring `implements IViewer` themselves so the public API contract is * verified at the leaf class level. */ class ViewerBase { constructor() { // ── Observables ── // Concrete `Observable<T>` is exposed publicly (it satisfies `IReadonlyObservable<T>` // for the `IViewer` interface contract). Subclasses notify directly via `this.onXxx.notifyObservers()`. /** * Fired when the environment has changed. */ this.onEnvironmentChanged = new Observable(); /** * Fired when the environment configuration has changed. */ this.onEnvironmentConfigurationChanged = new Observable(); /** * Fired when an error occurs while loading the environment. */ this.onEnvironmentError = new Observable(); /** * Fired when the shadows configuration changes. */ this.onShadowsConfigurationChanged = new Observable(); /** * Fired when the post processing state changes. */ this.onPostProcessingChanged = new Observable(); /** * Fired when a model is loaded into the viewer (or unloaded from the viewer). * @remarks * The event argument is the source that was loaded, or null if no model is loaded. */ this.onModelChanged = new Observable(); /** * Fired when an error occurs while loading a model. */ this.onModelError = new Observable(); /** * Fired when progress changes on loading activity. */ this.onLoadingProgressChanged = new Observable(); /** * Fired when the camera auto orbit state changes. */ this.onCameraAutoOrbitChanged = new Observable(); /** * Fired when the selected animation changes. */ this.onSelectedAnimationChanged = new Observable(); /** * Fired when the animation speed changes. */ this.onAnimationSpeedChanged = new Observable(); /** * Fired when the selected animation is playing or paused. */ this.onIsAnimationPlayingChanged = new Observable(); /** * Fired when the current point on the selected animation timeline changes. */ this.onAnimationProgressChanged = new Observable(); /** * Fired when the selected material variant changes. */ this.onSelectedMaterialVariantChanged = new Observable(); /** * Fired when the hot spots object changes to a complete new object instance. */ this.onHotSpotsChanged = new Observable(); /** * Fired when the cameras as hot spots property changes. */ this.onCamerasAsHotSpotsChanged = new Observable(); /** * Fired after each frame is rendered. */ this.onAfterRenderObservable = new Observable(); /** * Fired when the clear color changes. */ this.onClearColorChanged = new Observable(); /** * @internal Tracks in-flight load operations (model + environment + shadows) so that * `loadingProgress` can return either an aggregate progress number, `true` (indeterminate), * or `false` (no operations in flight). */ this._loadOperations = new Set(); /** @internal True after `dispose()` has been called. */ this._isDisposed = false; // ────────────────────────────────────────────────────────────────────────── // Environment loading orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding lighting-side environment loads. */ this._loadEnvironmentLightingLock = new AsyncLock(); /** Abort controller for the currently in-flight lighting-side load (null when none). */ this._loadEnvironmentLightingAbortController = null; /** Lock guarding skybox-side environment loads. */ this._loadEnvironmentSkyboxLock = new AsyncLock(); /** Abort controller for the currently in-flight skybox-side load (null when none). */ this._loadEnvironmentSkyboxAbortController = null; // ── Environment configuration (intensity / blur / rotation) ── /** @internal Current environment intensity. Initialized from options in subclass constructors. */ this._environmentIntensity = DefaultViewerBaseOptions.environmentConfig.intensity; /** @internal Current environment skybox blur. Initialized from options in subclass constructors. */ this._environmentBlur = DefaultViewerBaseOptions.environmentConfig.blur; /** @internal Current environment rotation in radians. Initialized from options in subclass constructors. */ this._environmentRotation = DefaultViewerBaseOptions.environmentConfig.rotation; // ── Camera auto-orbit ── /** @internal Initialized from options in subclass constructors. */ this._autoOrbitEnabled = DefaultViewerBaseOptions.cameraAutoOrbit.enabled; /** @internal Initialized from options in subclass constructors. */ this._autoOrbitSpeed = DefaultViewerBaseOptions.cameraAutoOrbit.speed; /** @internal Initialized from options in subclass constructors. */ this._autoOrbitDelay = DefaultViewerBaseOptions.cameraAutoOrbit.delay; // ── Clear color ── /** * @internal The current scene clear color, stored as a stable mutable record so consumers * holding a reference returned by the `clearColor` getter see updates from the setter and from * `reset("environment")`. The setter mutates this object in-place rather than replacing it. */ this._clearColor = { r: 0, g: 0, b: 0, a: 0 }; // ── Hot spots ── /** @internal Pure state — no engine state. Subclasses initialize via the public `hotSpots` setter in their constructor body. */ this._hotSpots = {}; // ────────────────────────────────────────────────────────────────────────── // Model loading orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding model loads (and resets). */ this._loadModelLock = new AsyncLock(); /** Abort controller for the currently in-flight model load (null when none). */ this._loadModelAbortController = null; // ────────────────────────────────────────────────────────────────────────── // Shadow update orchestration // ────────────────────────────────────────────────────────────────────────── /** Lock guarding shadow updates. */ this._updateShadowsLock = new AsyncLock(); /** Abort controller for the currently in-flight shadow update (null when none). */ this._shadowsAbortController = null; /** * @internal The currently committed shadow quality. Subclasses initialize this from their * options in their constructor and read it in their `_loadModelImpl` etc. The base class * commits a new value here only after `_updateShadowsImpl` succeeds, so failed/aborted * shadow updates don't leave this field out of sync with engine state. */ this._shadowQuality = DefaultViewerBaseOptions.shadowConfig.quality; } /** * The current loading progress. False when no load is in flight, true when at least one * load is in flight with indeterminate progress, or a number between 0 and 1 representing * the average of all in-flight loads' progress. */ get loadingProgress() { if (this._loadOperations.size > 0) { let totalProgress = 0; for (const operation of this._loadOperations) { if (operation.progress == null) { return true; } totalProgress += operation.progress; } return totalProgress / this._loadOperations.size; } return false; } /** * Begin tracking a new load operation. Subclasses call this at the start of an async load * and dispose the returned handle when it completes (or fails). The handle exposes a * `progress` setter that, when assigned, fires `onLoadingProgressChanged`. * @returns A handle that can be disposed when the operation completes; the `progress` setter * updates the aggregate `loadingProgress` as the operation runs. */ _beginLoadOperation() { // eslint-disable-next-line @typescript-eslint/no-this-alias const viewer = this; let progress = null; const loadOperation = { get progress() { return progress; }, set progress(value) { progress = value; viewer.onLoadingProgressChanged.notifyObservers(); }, dispose: () => { viewer._loadOperations.delete(loadOperation); viewer.onLoadingProgressChanged.notifyObservers(); }, }; this._loadOperations.add(loadOperation); this.onLoadingProgressChanged.notifyObservers(); return loadOperation; } /** * @internal Throws if the viewer has been disposed or any of the supplied abort signals * are aborted. Used at the start of every async operation to bail out early. * @param abortSignals Optional abort signals to check. */ _throwIfDisposedOrAborted(...abortSignals) { if (this._isDisposed) { throw new Error("Viewer is disposed."); } throwIfAborted(...abortSignals); } /** * @internal The abort signal of the currently in-flight lighting-side load, or `undefined` if * none. Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async * work (shadows, post-processing, etc.) when the user starts a new lighting load. */ get _loadEnvironmentLightingAbortSignal() { return this._loadEnvironmentLightingAbortController?.signal; } /** * @internal The abort signal of the currently in-flight skybox-side load, or `undefined` if * none. Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async * work (shadows, post-processing, etc.) when the user starts a new skybox load. */ get _loadEnvironmentSkyboxAbortSignal() { return this._loadEnvironmentSkyboxAbortController?.signal; } /** * Loads an environment from the specified URL. The lighting and skybox sides have * independent locks and abort controllers so concurrent requests for one side don't * cancel an in-flight load for the other. * @param url The URL of the environment to load. * @param options Selects which sides to update (defaults to both) and forwards engine-specific extras. * @param abortSignal Optional signal that can be used to abort the load externally. * @returns A promise that resolves when the environment has finished loading. */ async loadEnvironment(url, options, abortSignal) { return await this._updateEnvironment(url, options, abortSignal); } /** * Removes the loaded environment. By default removes both lighting and skybox; pass `options` * to remove only one side. Subclasses (notably the full Viewer) may override to add backend-specific * fallback behavior such as substituting a default environment for lighting when the scene contains * PBR materials. * @param options Selects which sides to remove (defaults to both). * @param abortSignal Optional signal that can be used to abort the operation externally. * @returns A promise that resolves when the environment has finished resetting. */ async resetEnvironment(options, abortSignal) { return await this._updateEnvironment(undefined, options, abortSignal); } /** * @internal Internal helper exposing the dual-lock orchestration with a nullable URL. * Used by the public `loadEnvironment` (with a string URL) and by subclass `resetEnvironment` * implementations (which pass `undefined` to clear or `"auto"` to load defaults). * * Subclasses should NOT override this — override the abstract `_loadEnvironmentImpl` instead. */ async _updateEnvironment(url, options, abortSignal) { this._throwIfDisposedOrAborted(abortSignal); // Default semantics: omitting `options` updates both; passing `options` updates only // the sides whose flag is truthy. `{ lighting: true }` alone updates only lighting, // `{ skybox: true }` alone updates only skybox. const updateLighting = options ? !!options.lighting : true; const updateSkybox = options ? !!options.skybox : true; if (!updateLighting && !updateSkybox) { return; } // Resolved options object — `lighting` and `skybox` are guaranteed booleans here, while // engine-specific extras (e.g. `extension`) are forwarded as-is. Passed to the impl so // it can use both the resolved flags and the original extras without duplicating the // default-resolution logic. const resolvedOptions = { ...options, lighting: updateLighting, skybox: updateSkybox }; const locks = []; const internalAbortControllers = []; if (updateLighting) { this._loadEnvironmentLightingAbortController?.abort(new AbortError("New environment lighting is being loaded before previous environment lighting finished loading.")); const lightingAbortController = (this._loadEnvironmentLightingAbortController = new AbortController()); locks.push(this._loadEnvironmentLightingLock); internalAbortControllers.push(lightingAbortController); } if (updateSkybox) { this._loadEnvironmentSkyboxAbortController?.abort(new AbortError("New environment skybox is being loaded before previous environment skybox finished loading.")); const skyboxAbortController = (this._loadEnvironmentSkyboxAbortController = new AbortController()); locks.push(this._loadEnvironmentSkyboxLock); internalAbortControllers.push(skyboxAbortController); } // Composite abort fires only when ALL relevant internal aborts fire — a skybox-only // re-request shouldn't cancel an in-progress lighting load when both were requested // together. const compositeAbortController = new AbortController(); const checkAllAborted = () => { if (internalAbortControllers.every((c) => c.signal.aborted)) { compositeAbortController.abort(new AbortError(internalAbortControllers.map((controller) => controller.signal.reason).join(" | "))); } }; for (const controller of internalAbortControllers) { controller.signal.addEventListener("abort", checkAllAborted); } try { await AsyncLock.LockAsync(async () => { throwIfAborted(abortSignal, compositeAbortController.signal); await this._loadEnvironmentImpl(url?.trim() ?? url, resolvedOptions, abortSignal, compositeAbortController.signal); }, locks); } finally { for (const controller of internalAbortControllers) { controller.signal.removeEventListener("abort", checkAllAborted); } } } get environmentConfig() { return { intensity: this._environmentIntensity, blur: this._environmentBlur, rotation: this._environmentRotation, }; } set environmentConfig(value) { if (value.blur !== undefined && value.blur !== this._environmentBlur) { this._environmentBlur = value.blur; this._applyEnvironmentBlur(); } if (value.intensity !== undefined && value.intensity !== this._environmentIntensity) { this._environmentIntensity = value.intensity; this._applyEnvironmentIntensity(); } if (value.rotation !== undefined && value.rotation !== this._environmentRotation) { this._environmentRotation = value.rotation; this._applyEnvironmentRotation(); } this.onEnvironmentConfigurationChanged.notifyObservers(); } get cameraAutoOrbit() { return { enabled: this._autoOrbitEnabled, speed: this._autoOrbitSpeed, delay: this._autoOrbitDelay, }; } set cameraAutoOrbit(value) { let changed = false; if (value.enabled !== undefined && value.enabled !== this._autoOrbitEnabled) { this._autoOrbitEnabled = value.enabled; this._applyCameraAutoOrbitEnabled(); changed = true; } if (value.speed !== undefined && value.speed !== this._autoOrbitSpeed) { this._autoOrbitSpeed = value.speed; this._applyCameraAutoOrbitSpeed(); changed = true; } if (value.delay !== undefined && value.delay !== this._autoOrbitDelay) { this._autoOrbitDelay = value.delay; this._applyCameraAutoOrbitDelay(); changed = true; } if (changed) { this.onCameraAutoOrbitChanged.notifyObservers(); } } /** * The viewer clear color (e.g. background). */ get clearColor() { return this._clearColor; } set clearColor(value) { this._clearColor.r = value.r; this._clearColor.g = value.g; this._clearColor.b = value.b; this._clearColor.a = value.a; this._applyClearColor(); this.onClearColorChanged.notifyObservers(); } /** * The set of defined hotspots. */ get hotSpots() { return this._hotSpots; } set hotSpots(value) { this._hotSpots = value; this.onHotSpotsChanged.notifyObservers(); } /** * @internal The abort signal of the currently in-flight model load, or `undefined` if none. * Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async work * (shadows, environment fallback, etc.) when the user starts a new model load. */ get _loadModelAbortSignal() { return this._loadModelAbortController?.signal; } /** * Loads a 3D model from the specified source. * @param source The source of the model to load. * @param options Engine-specific options for loading the model. * @param abortSignal Optional signal that can be used to abort the load externally. * @returns A promise that resolves when the model has finished loading. */ async loadModel(source, options, abortSignal) { return await this._updateModel(source, options, abortSignal); } /** * Unloads the current 3D model if one is loaded. * @param abortSignal Optional signal that can be used to abort the reset. * @returns A promise that resolves when the current model has been unloaded. */ async resetModel(abortSignal) { return await this._updateModel(undefined, undefined, abortSignal); } /** * @internal Internal helper exposing the model load orchestration with `source: undefined` meaning * "unload the current model". Subclasses should NOT override this — override `_loadModelImpl` instead. */ async _updateModel(source, options, abortSignal) { this._throwIfDisposedOrAborted(abortSignal); this._loadModelAbortController?.abort(new AbortError("New model is being loaded before previous model finished loading.")); const internalAbortController = (this._loadModelAbortController = new AbortController()); await this._loadModelLock.lockAsync(async () => { throwIfAborted(abortSignal, internalAbortController.signal); await this._loadModelImpl(source, options, abortSignal, internalAbortController.signal); }); // Post-lock follow-up (e.g. operations that need other locks). Skip if a newer model load // has already aborted us — the new load owns the post-load work for its own state. if (!internalAbortController.signal.aborted) { await this._afterLoadModel(source, options, abortSignal, internalAbortController.signal); } } /** * @internal Optional post-lock hook invoked AFTER the model load lock is released, allowing * subclasses to do follow-up work that needs other locks (e.g. environment fallback). The base * skips this hook if the load was superseded by a newer one before we got here. * * Implementations that await additional work should re-check `internalAbortSignal.aborted` * after each await to avoid acting on stale state (a newer load may have started during the * await window). * * Default: no-op. */ async _afterLoadModel( // eslint-disable-next-line @typescript-eslint/no-unused-vars source, // eslint-disable-next-line @typescript-eslint/no-unused-vars options, // eslint-disable-next-line @typescript-eslint/no-unused-vars abortSignal, // eslint-disable-next-line @typescript-eslint/no-unused-vars internalAbortSignal) { // Default: no-op. } /** * @internal The abort signal of the currently in-flight shadow update, or `undefined` if none. * Subclasses can use this in `_throwIfDisposedOrAborted` to bail out of dependent async work * when the user starts a new shadow update. */ get _shadowsAbortSignal() { return this._shadowsAbortController?.signal; } /** * Gets the current shadow configuration. */ get shadowConfig() { return { quality: this._shadowQuality }; } /** * Updates the shadow configuration. Skips work if the requested value matches the currently * committed one. Subclasses can override this to validate the requested quality (e.g. throw on * unsupported combinations) before delegating to `super.updateShadows(value, abortSignal)`. * @param value The new shadow configuration. * @param abortSignal Optional signal that can be used to abort the update externally. * @returns A promise that resolves when the shadow update completes. */ async updateShadows(value, abortSignal) { if (value.quality === undefined || value.qualit