threepipe
Version:
A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.
1,101 lines • 55.1 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var ThreeViewer_1;
import { CanvasTexture, Color, EventDispatcher, LinearSRGBColorSpace, Quaternion, Vector2, Vector3, } from 'three';
import { createCanvasElement, downloadBlob, onChange, serialize } from 'ts-browser-helpers';
import { PerspectiveCamera2, RootScene, } from '../core';
import { ViewerRenderManager } from './ViewerRenderManager';
import { convertArrayBufferToStringsInMeta, getEmptyMeta, GLStatsJS, jsonToBlob, metaFromResources, MetaImporter, metaToResources, ThreeSerialization, windowDialogWrapper, } from '../utils';
import { AssetManager, } from '../assetmanager';
import { uiConfig, uiPanelContainer } from 'uiconfig.js';
// noinspection ES6PreferShortImport
import { DropzonePlugin } from '../plugins/interaction/DropzonePlugin';
// noinspection ES6PreferShortImport
import { TonemapPlugin } from '../plugins/postprocessing/TonemapPlugin';
import { VERSION } from './version';
/**
* Three Viewer
*
* The ThreeViewer is the main class in the framework to manage a scene, render and add plugins to it.
* @category Viewer
*/
let ThreeViewer = ThreeViewer_1 = class ThreeViewer extends EventDispatcher {
get materialManager() {
return this.assetManager.materials;
}
/**
* Scene with object hierarchy used for rendering
*/
get scene() {
return this._scene;
}
/**
* Get the HTML Element containing the canvas
* @returns {HTMLElement}
*/
get container() {
// todo console.warn('container is deprecated, NOTE: subscribe to events when the canvas is moved to another container')
if (this._canvas.parentElement !== this._container) {
this.console.error('ThreeViewer: Canvas is not in the container, this might cause issues with some plugins.');
}
return this._container;
}
/**
* Get the HTML Canvas Element where the viewer is rendering
* @returns {HTMLCanvasElement}
*/
get canvas() {
return this._canvas;
}
get console() {
return ThreeViewer_1.Console;
}
get dialog() {
return ThreeViewer_1.Dialog;
}
/**
* Create a viewer instance for using the webgi viewer SDK.
* @param options - {@link ThreeViewerOptions}
*/
constructor({ debug = false, ...options }) {
super();
/**
* If the viewer is enabled. Set this `false` to disable RAF loop.
* @type {boolean}
*/
this.enabled = true;
/**
* Enable or disable all rendering, Animation loop including any frame/render events won't be fired when this is false.
*/
this.renderEnabled = true;
this.plugins = {};
/**
* Specifies how many frames to render in a single request animation frame. Keep to 1 for realtime rendering.
* Note: should be max (screen refresh rate / animation frame rate) like 60Hz / 30fps
* @type {number}
*/
this.maxFramePerLoop = 1;
/**
* Number of times to run composer render. If set to more than 1, preRender and postRender events will also be called multiple times.
*/
this.rendersPerFrame = 1;
this.type = 'ThreeViewer';
/**
* The ResizeObserver observing the canvas element. Add more elements to this observer to resize viewer on their size change.
* @type {ResizeObserver | undefined}
*/
this.resizeObserver = window?.ResizeObserver ? new window.ResizeObserver(_ => this.resize()) : undefined;
this._needsResize = false;
this._isRenderingFrame = false;
this._objectProcessor = {
processObject: (object) => {
if (object.material) {
if (Array.isArray(object.material))
this.assetManager.materials.registerMaterials(object.material);
else
this.assetManager.materials.registerMaterial(object.material);
}
},
};
this._needsReset = true; // renderer needs reset
// Helpers for tracking main camera change and setting dirty automatically
this._lastCameraPosition = new Vector3();
this._lastCameraQuat = new Quaternion();
this._lastCameraTarget = new Vector3();
this._tempVec = new Vector3();
this._tempQuat = new Quaternion();
/**
* Mark that the canvas is resized. If the size is changed, the renderer and all render targets are resized. This happens before the render of the next frame.
*/
this.resize = () => {
this._needsResize = true;
this.setDirty();
};
this.loadConfigResources = async (json, extraResources) => {
// this.console.log(json)
if (json.__isLoadedResources)
return json;
const meta = metaFromResources(json, this);
return await MetaImporter.ImportMeta(meta, extraResources);
};
this._defaultConfig = {
assetType: 'config',
type: this.type,
version: ThreeViewer_1.VERSION,
metadata: {
generator: 'ThreePipe',
version: 1,
},
plugins: [],
};
// todo: find a better fix for context loss and restore?
this._lastSize = new Vector2();
this._onContextRestore = (_) => {
this.enabled = true;
this._canvas.width = this._lastSize.width;
this._canvas.height = this._lastSize.height;
this.resize();
this._scene.setDirty({ refreshScene: true, frameFade: false });
};
this._onContextLost = (_) => {
this._lastSize.set(this._canvas.width, this._canvas.height);
this._canvas.width = 2;
this._canvas.height = 2;
this.resize();
this.enabled = false;
};
this._pluginListeners = {
add: [],
remove: [],
};
/**
* plugins that are not serialized/deserialized with the viewer from config. useful when loading files exported from the editor, etc
* (runtime only, not serialized itself)
*/
this.serializePluginsIgnored = [];
this.debug = debug;
if (debug)
ThreeViewer_1.ViewerDebugging = true;
this._canvas = options.canvas || createCanvasElement();
let container = options.container;
if (container && !options.canvas)
container.appendChild(this._canvas);
if (!container)
container = this._canvas.parentElement ?? undefined;
if (!container)
throw new Error('No container(or canvas).');
this._container = container;
this.setDirty = this.setDirty.bind(this);
this._animationLoop = this._animationLoop.bind(this);
this._setActiveCameraView = this._setActiveCameraView.bind(this);
this.renderStats = new GLStatsJS(this._container);
if (debug)
this.renderStats.show();
if (!window.threeViewers)
window.threeViewers = [];
window.threeViewers.push(this);
// camera
const camera = new PerspectiveCamera2(options.camera?.controlsMode ?? 'orbit', this._canvas);
camera.name = 'Default Camera';
options.camera?.position ? camera.position.copy(options.camera.position) : camera.position.set(0, 0, 5);
options.camera?.target ? camera.target.copy(options.camera.target) : camera.target.set(0, 0, 0);
camera.setDirty();
camera.userData.autoLookAtTarget = true; // only for when controls are disabled / not available
// Update camera controls postFrame if allowed to interact
this.addEventListener('postFrame', () => {
const cam = this._scene.mainCamera;
if (cam && cam.canUserInteract) {
const d = this.getPlugin('ProgressivePlugin')?.postFrameConvergedRecordingDelta();
// if (d && d > 0) delta = d
if (d !== undefined && d === 0)
return; // not converged yet.
// if d < 0 or undefined: not recording, do nothing
cam.controls?.update();
}
});
// if camera position or target changed in last frame, call setDirty on camera
this.addEventListener('preFrame', () => {
const cam = this._scene.mainCamera;
if (cam.getWorldPosition(this._tempVec).sub(this._lastCameraPosition).lengthSq() // position is in local space
+ this._tempVec.subVectors(cam.target, this._lastCameraTarget).lengthSq() // target is in world space
+ cam.getWorldQuaternion(this._tempQuat).angleTo(this._lastCameraQuat)
> 0.000001)
cam.setDirty();
});
// scene
this._scene = new RootScene(camera, this._objectProcessor);
this._scene.setBackgroundColor('#ffffff');
// this._scene.addEventListener('addSceneObject', this._addSceneObject)
this._scene.addEventListener('setView', this._setActiveCameraView);
this._scene.addEventListener('activateMain', this._setActiveCameraView);
this._scene.addEventListener('materialUpdate', (e) => this.setDirty(this._scene, e));
this._scene.addEventListener('materialChanged', (e) => this.setDirty(this._scene, e));
this._scene.addEventListener('objectUpdate', (e) => this.setDirty(this._scene, e));
this._scene.addEventListener('textureUpdate', (e) => this.setDirty(this._scene, e));
this._scene.addEventListener('sceneUpdate', (e) => {
this.setDirty(this._scene, e);
if (e.geometryChanged === false)
return;
this.renderManager.resetShadows();
});
this._scene.addEventListener('mainCameraUpdate', () => {
this._scene.mainCamera.getWorldPosition(this._lastCameraPosition);
this._lastCameraTarget.copy(this._scene.mainCamera.target);
this._scene.mainCamera.getWorldQuaternion(this._lastCameraQuat);
});
// render manager
if (options.isAntialiased !== undefined || options.useRgbm !== undefined || options.useGBufferDepth !== undefined) {
this.console.warn('isAntialiased, useRgbm and useGBufferDepth are deprecated, use msaa, rgbm and zPrepass instead.');
}
this.renderManager = new ViewerRenderManager({
canvas: this._canvas,
msaa: options.msaa ?? options.isAntialiased ?? false,
rgbm: options.rgbm ?? options.useRgbm ?? false,
zPrepass: options.zPrepass ?? options.useGBufferDepth ?? false,
depthBuffer: !(options.zPrepass ?? options.useGBufferDepth ?? false),
screenShader: options.screenShader,
renderScale: typeof options.renderScale === 'string' ? options.renderScale === 'auto' ?
Math.min(options.maxRenderScale || 2, window.devicePixelRatio) : parseFloat(options.renderScale) :
options.renderScale,
maxHDRIntensity: options.maxHDRIntensity,
});
this.renderManager.addEventListener('animationLoop', this._animationLoop);
this.renderManager.addEventListener('resize', () => this._scene.mainCamera.refreshAspect());
this.renderManager.addEventListener('update', (e) => {
if (e.change === 'registerPass' && e.pass?.materialExtension)
this.assetManager.materials.registerMaterialExtension(e.pass.materialExtension);
else if (e.change === 'unregisterPass' && e.pass?.materialExtension)
this.assetManager.materials.unregisterMaterialExtension(e.pass.materialExtension);
this.setDirty(this.renderManager, e);
});
this.assetManager = new AssetManager(this, options.assetManager);
if (this.resizeObserver)
this.resizeObserver.observe(this._canvas);
// sometimes resize observer is late, so extra check
window && window.addEventListener('resize', this.resize);
this._canvas.addEventListener('webglcontextrestored', this._onContextRestore, false);
this._canvas.addEventListener('webglcontextlost', this._onContextLost, false);
if (options.dropzone) {
this.addPluginSync(new DropzonePlugin(typeof options.dropzone === 'object' ? options.dropzone : undefined));
}
if (options.tonemap !== false) {
this.addPluginSync(new TonemapPlugin());
}
for (const p of options.plugins ?? [])
this.addPluginSync(p);
this.console.log('ThreePipe Viewer instance initialized, version: ', ThreeViewer_1.VERSION);
if (options.load) {
const sources = [options.load.src].flat().filter(s => s);
const promises = sources.map(async (s) => s && this.load(s));
if (options.load.environment)
promises.push(this.setEnvironmentMap(options.load.environment));
if (options.load.background)
promises.push(this.setBackgroundMap(options.load.background));
Promise.all(promises).then(options.onLoad);
}
}
/**
* Add an object/model/material/viewer-config/plugin-preset/... to the viewer scene from url or an {@link IAsset} object.
* Same as {@link AssetManager.addAssetSingle}
* @param obj
* @param options
*/
async load(obj, options) {
if (!obj)
return;
return await this.assetManager.addAssetSingle(obj, options);
}
/**
* Imports an object/model/material/texture/viewer-config/plugin-preset/... to the viewer scene from url or an {@link IAsset} object.
* Same as {@link AssetImporter.importSingle}
* @param obj
* @param options
*/
async import(obj, options) {
if (!obj)
return;
return await this.assetManager.importer.importSingle(obj, options);
}
/**
* Set the environment map of the scene from url or an {@link IAsset} object.
* @param map
* @param setBackground - Set the background image of the scene from the same map.
* @param options - Options for importing the asset. See {@link ImportAssetOptions}
*/
async setEnvironmentMap(map, { setBackground = false, ...options } = {}) {
this._scene.environment = map && !map.isTexture ? await this.assetManager.importer.importSingle(map, options) || null : map || null;
if (setBackground)
return this.setBackgroundMap(this._scene.environment);
return this._scene.environment;
}
/**
* Set the background image of the scene from url or an {@link IAsset} object.
* @param map
* @param setEnvironment - Set the environment map of the scene from the same map.
* @param options - Options for importing the asset. See {@link ImportAssetOptions}
*/
async setBackgroundMap(map, { setEnvironment = false, ...options } = {}) {
this._scene.background = map && !map.isTexture ? await this.assetManager.importer.importSingle(map, options) || null : map || null;
if (setEnvironment)
return this.setEnvironmentMap(this._scene.background);
return this._scene.background;
}
/**
* Exports an object/mesh/material/texture/render-target/plugin-preset/viewer to a blob.
* If no object is given, a glb is exported with the current viewer state.
* @param obj
* @param options
*/
async export(obj, options) {
if (!obj)
obj = this._scene.modelRoot; // this will export the glb with the scene and viewer config
if (obj.type === this.type)
return jsonToBlob(obj.exportConfig());
if (obj.constructor?.PluginType)
return jsonToBlob(this.exportPluginConfig(obj));
return await this.assetManager.exporter.exportObject(obj, options);
}
/**
* Export the scene to a file (default: glb with viewer config) and return a blob
* @param options
* @param useExporterPlugin - uses the {@link AssetExporterPlugin} if available. This is useful to use the options configured by the user in the plugin.
*/
async exportScene(options, useExporterPlugin = true) {
const exporter = useExporterPlugin ? this.getPlugin('AssetExporterPlugin') : undefined;
if (exporter)
return exporter.exportScene(options);
return this.assetManager.exporter.exportObject(this._scene.modelRoot, options);
}
/**
* Returns a blob with the screenshot of the canvas.
* If {@link CanvasSnapshotPlugin} is added, it will be used, otherwise canvas.toBlob will be used directly.
* @param mimeType default image/jpeg
* @param quality between 0 and 100
*/
async getScreenshotBlob({ mimeType = 'image/jpeg', quality = 90 } = {}) {
const plugin = this.getPlugin('CanvasSnapshotPlugin');
if (plugin) {
return plugin.getFile('snapshot.' + mimeType.split('/')[1], { mimeType, quality, waitForProgressive: true });
}
const blobPromise = async () => new Promise((resolve) => {
this._canvas.toBlob((blob) => {
resolve(blob);
}, mimeType, quality);
});
if (!this.renderEnabled)
return blobPromise();
return await this.doOnce('postFrame', async () => {
this.renderEnabled = false;
const blob = await blobPromise();
this.renderEnabled = true;
return blob;
});
}
async getScreenshotDataUrl({ mimeType = 'image/jpeg', quality = 0.9 } = {}) {
if (!this.renderEnabled)
return this._canvas.toDataURL(mimeType, quality);
return await this.doOnce('postFrame', () => this._canvas.toDataURL(mimeType, quality));
}
/**
* Disposes the viewer and frees up all resource and events. Do not use the viewer after calling dispose.
* @note - If you want to reuse the viewer, set viewer.enabled to false instead, then set it to true again when required. To dispose all the objects, materials in the scene use `viewer.scene.disposeSceneModels()`
* This function is not fully implemented yet. There might be some leaks.
* @todo - return promise?
*/
dispose(clear = true) {
// todo: dispose stuff from constructor etc
if (clear) {
for (const plugin of [...Object.values(this.plugins)]) {
this.removePlugin(plugin, true);
}
}
this._scene.dispose(clear);
this.renderManager.dispose(clear);
if (clear) {
this._canvas.removeEventListener('webglcontextrestored', this._onContextRestore, false);
this._canvas.removeEventListener('webglcontextlost', this._onContextLost, false);
window.threeViewers?.splice(window.threeViewers.indexOf(this), 1);
if (this.resizeObserver)
this.resizeObserver.unobserve(this._canvas);
window.removeEventListener('resize', this.resize);
}
this.dispatchEvent({ type: 'dispose', clear });
}
/**
* Set the viewer to dirty and trigger render of the next frame.
* @param source - The source of the dirty event. like plugin or 3d object
* @param event - The event that triggered the dirty event.
*/
setDirty(source, event) {
this._needsReset = true;
source = source ?? this;
this.dispatchEvent({ ...event ?? {}, type: 'update', source });
}
_animationLoop(event) {
if (!this.enabled || !this.renderEnabled)
return;
if (this._isRenderingFrame) {
this.console.warn('animation loop: frame skip'); // not possible actually, since this is not async
return;
}
this._isRenderingFrame = true;
this.renderStats.begin();
for (let i = 0; i < this.maxFramePerLoop; i++) {
if (this._needsReset) {
this.renderManager.reset();
this._needsReset = false;
}
if (this._needsResize) {
const size = [this._canvas.clientWidth, this._canvas.clientHeight];
if (event.xrFrame) { // todo: find a better way to resize for XR.
const cam = this.renderManager.webglRenderer.xr.getCamera()?.cameras[0]?.viewport;
if (cam) {
if (cam.x !== 0 || cam.y !== 0) {
this.console.warn('x and y must be 0?');
}
size[0] = cam.width;
size[1] = cam.height;
this.console.log('resize for xr', size);
}
else {
this._needsResize = false;
}
}
if (this._needsResize) {
this.renderManager.setSize(...size);
this._needsResize = false;
}
}
this.dispatchEvent({ ...event, type: 'preFrame', target: this }); // event will have time, deltaTime and xrFrame
const dirtyPlugins = Object.values(this.plugins).filter(value => value.dirty);
if (dirtyPlugins.length > 0) {
// console.log('dirty plugins', dirtyPlugins)
this.setDirty(dirtyPlugins);
}
if (this._needsReset) {
this.renderManager.reset();
this._needsReset = false;
}
// Check if the renderManger is dirty, which happens when it's reset above or if any pass in the composer is dirty
const needsRender = this.renderManager.needsRender;
if (needsRender) {
for (let j = 0; j < this.rendersPerFrame; j++) {
this.dispatchEvent({ type: 'preRender', target: this });
try {
const cam = this._scene.mainCamera;
this._scene.renderCamera = cam;
if (cam.visible)
this.renderManager.render(this._scene, this.renderManager.defaultRenderToScreen);
}
catch (e) {
this.console.error('ThreeViewer: Error while rendering frame. Enable debug mode to check the errors.');
if (this.debug) {
this.console.error(e);
throw e;
}
// this.enabled = false
}
this.dispatchEvent({ type: 'postRender', target: this });
}
}
this.dispatchEvent({ type: 'postFrame', target: this });
this.renderManager.onPostFrame();
if (!needsRender) // break if no frame rendered
break;
}
this.renderStats.end();
this._isRenderingFrame = false;
}
/**
* Get the Plugin by a constructor type or by the string type.
* Use string type if the plugin is not a dependency and you don't want to bundle the plugin.
* @param type - The class of the plugin to get, or the string type of the plugin to get which is in the static PluginType property of the plugin
* @returns {T | undefined} - The plugin of the specified type.
*/
getPlugin(type) {
return this.plugins[typeof type === 'string' ? type : type.PluginType];
}
/**
* Get the Plugin by a constructor type or add a new plugin of the specified type if it doesn't exist.
* @param type
* @param args - arguments for the constructor of the plugin, used when a new plugin is created.
*/
async getOrAddPlugin(type, ...args) {
const plugin = this.getPlugin(type);
if (plugin)
return plugin;
return this.addPlugin(type, ...args);
}
/**
* Get the Plugin by a constructor type or add a new plugin to the viewer of the specified type if it doesn't exist(sync).
* @param type
* @param args - arguments for the constructor of the plugin, used when a new plugin is created.
*/
getOrAddPluginSync(type, ...args) {
const plugin = this.getPlugin(type);
if (plugin)
return plugin;
return this.addPluginSync(type, ...args);
}
/**
* Add a plugin to the viewer.
* @param plugin - The instance of the plugin to add or the class of the plugin to add.
* @param args - Arguments for the constructor of the plugin, in case a class is passed.
* @returns {Promise<T>} - The plugin added.
*/
async addPlugin(plugin, ...args) {
const p = this._resolvePluginOrClass(plugin, ...args);
if (!p) {
throw new Error('ThreeViewer: Plugin is not defined');
}
const type = p.constructor.PluginType;
if (!p.constructor.PluginType) {
this.console.error('ThreeViewer: PluginType is not defined for', p);
return p;
}
for (const d of p.dependencies || []) {
await this.getOrAddPlugin(d);
}
if (this.plugins[type]) {
this.console.error(`ThreeViewer: Plugin of type ${type} already exists, removing and disposing old plugin. This might break functionality, ensure only one plugin of a type is added`, this.plugins[type], p);
await this.removePlugin(this.plugins[type]);
}
this.plugins[type] = p;
const oldType = p.constructor.OldPluginType;
if (oldType && this.plugins[oldType])
this.console.error('ThreeViewer: Plugin type mismatch');
if (oldType)
this.plugins[oldType] = p;
await p.onAdded(this);
this._onPluginAdd(p);
return p;
}
/**
* Add a plugin to the viewer(sync).
* @param plugin
* @param args
*/
addPluginSync(plugin, ...args) {
const p = this._resolvePluginOrClass(plugin, ...args);
if (!p) {
throw new Error('ThreeViewer: Plugin is not defined');
}
const type = p.constructor.PluginType;
if (!p.constructor.PluginType) {
this.console.error('ThreeViewer: PluginType is not defined for', p);
return p;
}
for (const d of p.dependencies || []) {
this.getOrAddPluginSync(d);
}
if (this.plugins[type]) {
this.console.error(`ThreeViewer: Plugin of type ${type} already exists, removing and disposing old plugin. This might break functionality, ensure only one plugin of a type is added`, this.plugins[type], p);
this.removePluginSync(this.plugins[type]);
}
try {
this.plugins[type] = p;
const oldType = p.constructor.OldPluginType;
if (oldType && this.plugins[oldType])
this.console.error('ThreeViewer: Plugin type mismatch');
if (oldType)
this.plugins[oldType] = p;
p.onAdded(this);
}
catch (e) {
this.console.error('ThreeViewer: Error adding plugin, check console for details', e);
delete this.plugins[type];
}
this._onPluginAdd(p);
return p;
}
/**
* Add multiple plugins to the viewer.
* @param plugins - List of plugin instances or classes
*/
async addPlugins(plugins) {
for (const p of plugins)
await this.addPlugin(p);
}
/**
* Add multiple plugins to the viewer(sync).
* @param plugins - List of plugin instances or classes
*/
addPluginsSync(plugins) {
for (const p of plugins)
this.addPluginSync(p);
}
/**
* Remove a plugin instance or a plugin class. Works similar to {@link ThreeViewer.addPlugin}
* @param p
* @param dispose
* @returns {Promise<void>}
*/
async removePlugin(p, dispose = true) {
const type = p.constructor.PluginType;
if (!this.plugins[type])
return;
await p.onRemove(this);
this._onPluginRemove(p, dispose);
}
/**
* Remove a plugin instance or a plugin class(sync). Works similar to {@link ThreeViewer.addPluginSync}
* @param p
* @param dispose
*/
removePluginSync(p, dispose = true) {
const type = p.constructor.PluginType;
if (!this.plugins[type])
return;
p.onRemove(this);
this._onPluginRemove(p, dispose);
delete this.plugins[type];
if (dispose)
p.dispose();
this.setDirty(p);
}
/**
* Set size of the canvas and update the renderer.
* If no size or width/height is passed, canvas is set to 100% of the container.
*
* See also {@link ThreeViewer.setRenderSize} to set the size of the render target by automatically calculating the renderScale and fitting in container.
*
* Note: Apps using this should ideally set `max-width: 100%` for the canvas in css.
* @param size
*/
setSize(size) {
this._canvas.style.width = size?.width ? size.width + 'px' : '100%';
this._canvas.style.height = size?.height ? size.height + 'px' : '100%';
// this._canvas.style.maxWidth = '100%' // this is upto the app to do.
// this._canvas.style.maxHeight = '100%'
this.resize();
}
// todo make an example for this.
// todo make a constructor parameter for renderSize
// todo make getRenderSize or get renderSize
/**
* Set the render size of the viewer to fit in the container according to the specified mode, maintaining aspect ratio.
* Changes the renderScale accordingly.
* Note: the canvas needs to be centered in the container to work properly, this can be done with the following css on the container:
* ```css
* display: flex;
* justify-content: center;
* align-items: center;
* ```
* or in js:
* ```js
* viewer.container.style.display = 'flex';
* viewer.container.style.justifyContent = 'center';
* viewer.container.style.alignItems = 'center';
* ```
* Modes:
* 'contain': The canvas is scaled to fit within the container while maintaining its aspect ratio. The canvas will be fully visible, but there may be empty space around it.
* 'cover': The canvas is scaled to fill the entire container while maintaining its aspect ratio. Part of the canvas may be clipped to fit the container.
* 'fill': The canvas is stretched to completely fill the container, ignoring its aspect ratio.
* 'scale-down': The canvas is scaled down to fit within the container while maintaining its aspect ratio, but it won't be scaled up if it's smaller than the container.
* 'none': container size is ignored, but devicePixelRatio is used
* @param size - The size to set the render to. The canvas will render to this size.
* @param mode - 'contain', 'cover', 'fill', 'scale-down' or 'none'. Default is 'contain'.
* @param devicePixelRatio - typically set to `window.devicePixelRatio`, or `Math.min(1.5, window.devicePixelRatio)` for performance. Use this only when size is derived from dom elements.
* @param containerSize - (optional) The size of the container, if not passed, the bounding client rect of the container is used.
*/
setRenderSize(size, mode = 'contain', devicePixelRatio = 1, containerSize) {
// todo what about container resize?
const containerRect = containerSize || this.container.getBoundingClientRect();
const containerHeight = containerRect.height;
const containerWidth = containerRect.width;
const width = size.width;
const height = size.height;
const aspect = width / height;
const containerAspect = containerWidth / containerHeight;
const dpr = devicePixelRatio;
let renderWidth, renderHeight;
switch (mode) {
case 'contain':
if (containerAspect > aspect) {
renderWidth = containerHeight * aspect;
renderHeight = containerHeight;
}
else {
renderWidth = containerWidth;
renderHeight = containerWidth / aspect;
}
break;
case 'cover':
if (containerAspect > aspect) {
renderWidth = containerWidth;
renderHeight = containerWidth / aspect;
}
else {
renderWidth = containerHeight * aspect;
renderHeight = containerHeight;
}
break;
case 'fill':
renderWidth = containerWidth;
renderHeight = containerHeight;
break;
case 'scale-down':
if (width < containerWidth && height < containerHeight) {
renderWidth = width;
renderHeight = height;
}
else if (containerAspect > aspect) {
renderWidth = containerHeight * aspect;
renderHeight = containerHeight;
}
else {
renderWidth = containerWidth;
renderHeight = containerWidth / aspect;
}
break;
case 'none':
renderWidth = width;
renderHeight = height;
break;
default:
throw new Error(`Invalid mode: ${mode}`);
}
this.setSize({ width: renderWidth, height: renderHeight });
this.renderManager.renderScale = dpr * height / renderHeight;
}
/**
* Traverse all objects in scene model root.
* @param callback
*/
traverseSceneObjects(callback) {
this._scene.modelRoot.traverse(callback);
}
/**
* Add an object to the scene model root.
* If an imported scene model root is passed, it will be loaded with viewer configuration, unless importConfig is false
* @param imported
* @param options
*/
async addSceneObject(imported, options) {
if (imported.userData?.rootSceneModelRoot) {
const obj = imported;
this._scene.loadModelRoot(obj, options);
if (obj.importedViewerConfig && options?.importConfig !== false)
await this.importConfig(obj.importedViewerConfig);
return this._scene.modelRoot;
}
this._scene.addObject(imported, options);
return imported;
}
/**
* Serialize all the plugins and their settings to save or create presets. Used in {@link toJSON}.
* @param meta - The meta object.
* @param filter - List of PluginType for the to include. If empty, no plugins will be serialized. If undefined, all plugins will be serialized.
* @returns {any[]}
*/
serializePlugins(meta, filter) {
if (filter && filter.length === 0)
return [];
return Object.entries(this.plugins).map(p => {
if (filter && !filter.includes(p[1].constructor.PluginType))
return;
if (this.serializePluginsIgnored.includes(p[1].constructor.PluginType))
return;
// if (!p[1].toJSON) this.console.log(`Plugin of type ${p[0]} is not serializable`)
return p[1].serializeWithViewer !== false ? p[1].toJSON?.(meta) : undefined;
}).filter(p => !!p);
}
/**
* Deserialize all the plugins and their settings from a preset. Used in {@link fromJSON}.
* @param plugins - The output of {@link serializePlugins}.
* @param meta - The meta object.
* @returns {this}
*/
deserializePlugins(plugins, meta) {
plugins.forEach(p => {
if (!p.type) {
this.console.warn('Invalid plugin to import ', p);
return;
}
if (this.serializePluginsIgnored.includes(p.type))
return;
const plugin = this.getPlugin(p.type);
if (!plugin) {
// this.console.warn(`Plugin of type ${p.type} is not added, cannot deserialize`)
return;
}
plugin.fromJSON && plugin.fromJSON(p, meta);
});
return this;
}
/**
* Serialize a single plugin settings.
*/
exportPluginConfig(plugin) {
if (plugin && typeof plugin === 'string' || plugin.PluginType)
plugin = this.getPlugin(plugin);
if (!plugin)
return {};
const meta = getEmptyMeta();
const data = plugin.toJSON?.(meta);
if (!data)
return {};
data.resources = metaToResources(meta);
return data;
}
/**
* Deserialize and import a single plugin settings.
* Can also use {@link ThreeViewer.importConfig} to import only plugin config.
* @param json
* @param plugin
*/
async importPluginConfig(json, plugin) {
// this.console.log('importing plugin preset', json, plugin)
const type = json.type;
plugin = plugin || this.getPlugin(type);
if (!plugin) {
this.console.warn(`No plugin found for type ${type} to import config`);
return undefined;
}
if (!plugin.fromJSON) {
this.console.warn(`Plugin ${type} does not support importing presets`);
return undefined;
}
const resources = json.resources || {};
if (json.resources)
delete json.resources;
const meta = await this.loadConfigResources(resources);
await plugin.fromJSON(json, meta);
if (meta)
json.resources = meta;
return plugin;
}
/**
* Serialize multiple plugin settings.
* @param filter - List of PluginType to include. If empty, no plugins will be serialized. If undefined, all plugins will be serialized.
*/
exportPluginsConfig(filter) {
const meta = getEmptyMeta();
const plugins = this.serializePlugins(meta, filter);
convertArrayBufferToStringsInMeta(meta); // assuming not binary
return {
...this._defaultConfig,
plugins, resources: metaToResources(meta),
};
}
/**
* Serialize all the viewer and plugin settings.
* @param binary - Indicate that the output will be converted and saved as binary data. (default: false)
* @param pluginFilter - List of PluginType to include. If empty, no plugins will be serialized. If undefined, all plugins will be serialized.
*/
exportConfig(binary = false, pluginFilter) {
return this.toJSON(binary, pluginFilter);
}
/**
* Deserialize and import all the viewer and plugin settings, exported with {@link exportConfig}.
*/
async importConfig(json) {
if (json.type !== this.type && json.type !== 'ViewerApp') {
if (this.getPlugin(json.type)) {
return this.importPluginConfig(json);
}
else {
this.console.error(`Unknown config type ${json.type} to import`);
return undefined;
}
}
const resources = await this.loadConfigResources(json.resources || {});
this.fromJSON(json, resources);
}
/**
* Serialize all the viewer and plugin settings and versions.
* @param binary - Indicate that the output will be converted and saved as binary data. (default: true)
* @param pluginFilter - List of PluginType to include. If empty, no plugins will be serialized. If undefined/not-passed, all plugins will be serialized.
* @returns {any} - Serializable JSON object.
*/
toJSON(binary = true, pluginFilter) {
const meta = getEmptyMeta();
const data = Object.assign({
...this._defaultConfig,
plugins: this.serializePlugins(meta, pluginFilter),
}, ThreeSerialization.Serialize(this, meta, true));
// this.console.log(dat)
if (!binary)
convertArrayBufferToStringsInMeta(meta);
data.resources = metaToResources(meta);
return data;
}
/**
* Deserialize all the viewer and plugin settings.
* @note use async {@link ThreeViewer.importConfig} to import a json/config exported with {@link ThreeViewer.exportConfig} or {@link ThreeViewer.toJSON}.
* @param data - The serialized JSON object retured from {@link toJSON}.
* @param meta - The meta object
* @returns {this}
*/
fromJSON(data, meta) {
const data2 = { ...data }; // shallow copy
// region legacy
if (data2.backgroundIntensity !== undefined && data2.scene?.backgroundIntensity === undefined) {
this.console.warn('old file format, backgroundIntensity moved to RootScene');
this._scene.backgroundIntensity = data2.backgroundIntensity;
delete data2.backgroundIntensity;
}
if (data2.useLegacyLights !== undefined && data2.renderManager?.useLegacyLights === undefined) {
this.console.warn('old file format, useLegacyLights moved to RenderManager');
this.renderManager.useLegacyLights = data2.useLegacyLights;
delete data2.useLegacyLights;
}
if (data2.background !== undefined && data2.scene?.background === undefined) {
this.console.warn('old file format, background moved to RootScene');
if (data2.background === 'envMapBackground')
data2.background = 'environment';
else if (typeof data2.background === 'number')
data2.background = new Color().setHex(data2.background, LinearSRGBColorSpace);
else if (typeof data2.background === 'string')
data2.background = new Color().setStyle(data2.background, LinearSRGBColorSpace);
else if (data2.background?.isColor)
data2.background = new Color(data2.background);
if (data2.background?.isColor) { // color
this._scene.backgroundColor = data2.background;
this._scene.background = null;
}
else if (!data2.background) { // null
this._scene.backgroundColor = null;
this._scene.background = null;
}
else { // texture or 'environment'
this._scene.backgroundColor = new Color('#ffffff');
if (!data2.scene)
data2.scene = {};
data2.scene.background = data2.background;
}
delete data2.background;
}
// endregion
if (!meta && data2.resources && data2.resources.__isLoadedResources) {
meta = data2.resources;
delete data2.resources;
}
if (!meta?.__isLoadedResources) {
this.console.error('ThreeViewer: meta in fromJSON is not available or is not loaded resources, call viewer.loadConfigResources first, or directly use viewer.importConfig');
return null;
}
if (Array.isArray(data2.plugins)) {
this.deserializePlugins(data2.plugins, meta);
delete data2.plugins;
}
// meta = meta || data.resources
ThreeSerialization.Deserialize(data2, this, meta, true);
// todo: handle
// __useCount set in ThreeSerialization while deserializing resources
// for (const mat of Object.values(resources.materials) as any) {
// if (!mat.__useCount) this.materialManager?.unregisterMaterial(mat) // todo: also dispose?
// else delete mat.__useCount
// }
// for (const tex of Object.values(resources.textures) as any) {
// if (!tex.__useCount) {
// // todo: dispose?
// } else {
// delete tex.__useCount
// }
// }
return this;
}
async doOnce(event, func) {
return new Promise((resolve) => {
const listener = async (...args) => {
this.removeEventListener(event, listener);
resolve(await func?.(...args));
};
this.addEventListener(event, listener);
});
}
dispatchEvent(event) {
super.dispatchEvent(event);
super.dispatchEvent({ ...event, type: '*', eType: event.type });
}
/**
* Uses the {@link FileTransferPlugin} to export a Blob/File. If the plugin is not available, it will download the blob.
* {@link FileTransferPlugin} can be configured by other plugins to export the blob to a specific location like local file system, cloud storage, etc.
* @param blob - The blob or file to export/download
* @param name - name of the file, if not provided, the name of the file is used if it's a file.
*/
async exportBlob(blob, name) {
const tr = this.getPlugin('FileTransferPlugin');
name = name ?? blob.name ?? 'file';
if (!tr) {
downloadBlob(blob, name);
return;
}
await tr.exportFile(blob, name);
}
_setActiveCameraView(event = {}) {
if (event.type === 'setView') {
if (!event.camera) {
this.console.warn('Cannot find camera', event);
return;
}
const camera = this._scene.mainCamera;
camera.setViewFromCamera(event.camera); // default is worldSpace
}
else if (event.type === 'activateMain')
this._scene.mainCamera = event.camera || undefined; // event.camera should have been upgraded when added to the scene.
}
_resolvePluginOrClass(plugin, ...args) {
let p;
if (plugin.prototype) {
const p1 = this.getPlugin(plugin);
if (p1) {
this.console.error(`Plugin of type ${p1.constructor.PluginType} already exists, no new plugin created`, p1);
return p1;
}
try {
p = new plugin(...args);
}
catch (e) {
this.console.error('ThreeViewer: Error creating plugin', e);
return undefined;
}
}
else
p = plugin;
return p;
}
_renderEnabledChanged() {
this.dispatchEvent({ type: this.renderEnabled ? 'renderEnabled' : 'renderDisabled' });
}
// private _addSceneObject = (e: IEvent<any>) => {
// if (!e || !e.object) return
// const config = e.object.__importedViewerConfig // this is set in gltf.ts when gltf file is imported. This is done here so that scene settings are applied whenever the imported object is added to scene.
// if (!config) return
// this.fromJSON(config, config.resources)
// }
async fitToView(selected, distanceMultiplier = 1.5, duration, ease) {
const camViews = this.getPlugin('CameraViews');
if (!camViews) {
this.console.error('ThreeViewer: CameraViewPlugin (CameraViews) is required for fitToView to work');
return;
}
await camViews?.animateToFitObject(selected, distanceMultiplier, duration, ease, { min: (this.scene.mainCamera.controls?.minDistance ?? 0.5) + 0.5, max: 1000.0 });
}
/**
* Create and get a three.js CanvasTexture from the viewer's canvas.
*/
get canvasTexture() {
if (!this._canvas)
throw new Error('Canvas not found');
if (!this._canvasTexture) {
this._canvasTexture = new CanvasTexture(this._canvas);
this._canvasTexture.flipY = false;
this._canvasTexture.needsUpdate = true;
}
return this._canvasTexture;
}
// todo: create/load texture utils
// region legacy creation functions
// /**
// * Converts a three.js Camera instance to be used in the viewer.
// * @param camera - The three.js OrthographicCamera or PerspectiveCamera instance
// * @returns {CameraController} - A wrapper around the camera with some useful methods and properties.
// */
// createCamera(camera: OrthographicCamera | PerspectiveCamera): CameraController {
// const cam: CameraController = camera.userData.iCamera ?? new CameraController(camera, {
// controlsMode: '',
// controlsEnabled: false,
// }, this._canvas)
// if (camera.userData.autoLookAtTarget === undefined) {
// cam.autoLookAtTarget = false
// camera.userData.autoLookAtTarget = false
// } else {
// cam.autoLookAtTarget = camera.userData.autoLookAtTarget
// }
// return cam
// }
// /**
// * Create a new empty object in the scene or add an existing three.js object to the scene.
// * @param object
// */
// async createObject3D(object?: Object3D): Promise<Object3DModel | undefined> {
// return this.getManager()?.addImportedSingle<Object3DModel>(object || new Object3D(), {autoScale: false, pseudoCenter: false})
// }
// /**
// * Create a new physical material from a template or another material. It returns the same material if a material