@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,250 lines (1,244 loc) • 4.56 MB
JavaScript
/**
* Ensures the global `Symbol.metadata` well-known symbol exists.
*
* The serialization decorator store keys its metadata off `Symbol.metadata`. On a runtime that
* supports `Symbol` but does not yet expose `Symbol.metadata` natively, the symbol must be created
* so decorated classes can attach their serialization metadata to their constructor.
*
* This module installs the symbol as a one-time, idempotent side effect. Import it for its side
* effect at package entry points before any decorated class is evaluated.
*/
function ApplySymbolMetadataPolyfill() {
if (typeof Symbol !== "undefined" && !Symbol.metadata) {
Object.defineProperty(Symbol, "metadata", {
configurable: true,
writable: true,
value: Symbol("Symbol.metadata"),
});
}
}
ApplySymbolMetadataPolyfill();
/* 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;
});
/* IMP! DO NOT CHANGE THE NUMBERING OF EXISTING ERROR CODES */
/**
* Error codes for BaseError
*/
const ErrorCodes = {
// Mesh errors 0-999
/** Invalid or empty mesh vertex positions. */
MeshInvalidPositionsError: 0,
// GLTFLoader errors 2000-2999
/** Unexpected magic number found in GLTF file header. */
GLTFLoaderUnexpectedMagicError: 2000,
// SceneLoader errors 3000-3999
/** SceneLoader generic error code. Ideally wraps the inner exception. */
SceneLoaderError: 3000,
// File related errors 4000-4999
/** Load file error */
LoadFileError: 4000,
/** Request file error */
RequestFileError: 4001,
/** Read file error */
ReadFileError: 4002,
};
/**
* Application runtime error
*/
class RuntimeError extends BaseError {
/**
* Creates a new RuntimeError
* @param message defines the message of the error
* @param errorCode the error code
* @param innerError the error that caused the outer error
*/
constructor(message, errorCode, innerError) {
super(message);
this.errorCode = errorCode;
this.innerError = innerError;
this.name = "RuntimeError";
BaseError._setPrototypeOf(this, RuntimeError.prototype);
}
}
/**
* 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, internalAbortCont