threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
1,074 lines • 61 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 { OrthographicCamera2, 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, uiToggle } from 'uiconfig.js';
import { CameraViewPlugin, } from '../plugins';
// noinspection ES6PreferShortImport
import { DropzonePlugin } from '../plugins/interaction/DropzonePlugin';
// noinspection ES6PreferShortImport
import { TonemapPlugin } from '../plugins/postprocessing/TonemapPlugin';
import { VERSION } from './version';
import { Object3DManager } from '../assetmanager/Object3DManager';
import { ViewerTimeline } from '../utils/ViewerTimeline';
/**
* 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();
this.type = 'ThreeViewer';
/**
* 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; // todo rename to animation loop enabled?
/**
* Main timeline for the viewer.
*
* It's a WIP, API might change.
*/
this.timeline = new ViewerTimeline();
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;
/**
* 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.userData.autoRegisterInManager === false)
return;
this.object3dManager.registerObject(object);
if (object.material) {
if (!this.assetManager) {
console.error('AssetManager is not initialized yet, cannot register object', object);
return;
}
const mats = Array.isArray(object.material) ? object.material : [object.material];
for (const mat of mats) {
if (mat.userData.autoRegisterInManager === false)
continue;
this.assetManager.materials.registerMaterial(mat);
}
}
},
};
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();
/**
* 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 = [];
/**
* 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.deleteImportedViewerConfigOnLoad = true;
this.deleteImportedViewerConfigOnLoadWait = 2000; // ms
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._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') {
event.camera?.setCanvas(this._canvas, false);
// this._scene.mainCamera.setCanvas(undefined, false) // todo is this required?
this._scene.mainCamera = event.camera || undefined; // event.camera should have been upgraded when added to the scene.
}
};
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._stopPropagation = (e) => {
if (!this.scene.mainCamera.canUserInteract)
return;
e.stopPropagation();
};
this._pluginListeners = {
add: [],
remove: [],
};
this._onAddSceneObject = (e) => {
const object = e?.object;
if (!object)
return;
};
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; // todo listen to canvas container change
// if (getComputedStyle(this._container).position === 'static') {
// this.console.warn('ThreeViewer - The canvas container has static position, it must be set to relative or absolute for some plugins to work properly.')
// }
this.setDirty = this.setDirty.bind(this);
this._animationLoop = this._animationLoop.bind(this);
if (debug && options.statsJS !== false) {
this.renderStats = new GLStatsJS(this._container);
this.renderStats.show();
}
if (!window.threeViewers)
window.threeViewers = [];
window.threeViewers.push(this);
// camera
const camera = options.camera?.type === 'orthographic' ?
new OrthographicCamera2(options.camera?.controlsMode ?? 'orbit', this._canvas) :
new PerspectiveCamera2(options.camera?.controlsMode ?? 'orbit', this._canvas);
camera.name = 'Default Camera' + (camera.type === 'OrthographicCamera' ? ' (Ortho)' : '');
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.object3dManager = new Object3DManager();
this._scene = new RootScene(camera, this._objectProcessor);
this._scene.setBackgroundColor('#ffffff');
this._scene.addEventListener('addSceneObject', this._onAddSceneObject);
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);
});
this._scene.modelRoot.scale.setScalar(options.modelRootScale ?? 1);
this.object3dManager.setRoot(this._scene);
// 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.');
}
const rmClass = options.rmClass ?? ViewerRenderManager;
this.renderManager = new rmClass({
canvas: this._canvas,
msaa: options.msaa ?? options.isAntialiased ?? false,
rgbm: options.rgbm ?? options.useRgbm ?? true,
zPrepass: options.zPrepass ?? options.useGBufferDepth ?? false,
depthBuffer: !(options.zPrepass ?? options.useGBufferDepth ?? false),
stencilBuffer: options.stencil,
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);
}
if (options.stopPointerEventPropagation) {
// Stop event propagation in the viewer to prevent flickity etc. from dragging
this._canvas.addEventListener('pointerdown', this._stopPropagation);
this._canvas.addEventListener('touchstart', this._stopPropagation);
this._canvas.addEventListener('mousedown', this._stopPropagation);
}
}
/**
* 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, but not the viewer itself, use `viewer.scene.disposeSceneModels()`
*/
dispose(clear = true) {
this.renderEnabled = false;
// TODO - return promise?
// todo: dispose stuff from constructor etc
if (clear) {
for (const [key, plugin] of [...Object.entries(this.plugins)]) {
if (key === plugin.constructor.OldPluginType)
continue;
this.removePlugin(plugin, true);
}
}
this._scene.dispose(clear);
this.renderManager.dispose(clear);
if (clear) {
this.object3dManager.dispose();
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.
*
* This also triggers the 'update' event on the viewer. Note - update event might be triggered multiple times in a single frame, use preFrame or preRender events to get notified only once per 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++) {
// from setDirty
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.entries(this.plugins).filter(([key, plugin]) => plugin.dirty && key !== plugin.constructor.OldPluginType);
if (dirtyPlugins.length > 0) {
// console.log('dirty plugins', dirtyPlugins)
this.setDirty(dirtyPlugins);
}
// again, setDirty might have been called in preFrame
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 });
// console.log('render')
const render = () => {
const cam = this._scene.mainCamera;
this._scene.renderCamera = cam;
if (cam.visible)
this.renderManager.render(this._scene, this.renderManager.defaultRenderToScreen);
};
if (this.debug) {
render();
}
else {
try {
render();
}
catch (e) {
this.console.error('ThreeViewer: Uncaught error while rendering frame.');
this.console.error(e);
if (this.debug)
throw e;
this.renderEnabled = false;
this.dispatchEvent({ type: 'renderError', error: e });
}
}
this.dispatchEvent({ type: 'postRender', target: this });
}
}
this.timeline.update(this);
this.dispatchEvent({ type: 'postFrame', target: this });
this.renderManager.onPostFrame();
this.object3dManager.onPostFrame(this.timeline);
// this is update after postFrame, because other plugins etc will update the scene in postFrame or preFrame listeners
this.timeline.update2(this);
// if (!needsRender) // break if no frame rendered (should not break)
// 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 extends IViewerPlugin | 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 ${oldType}`);
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]);
}
const add = () => {
this.plugins[type] = p;
const oldType = p.constructor.OldPluginType;
if (oldType && this.plugins[oldType])
this.console.error(`ThreeViewer: Plugin type mismatch ${oldType}`);
if (oldType)
this.plugins[oldType] = p;
p.onAdded(this);
};
if (this.debug) {
add();
}
else {
try {
add();
}
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);
}
/**
* 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%'
// https://stackoverflow.com/questions/21664940/force-browser-to-trigger-reflow-while-changing-css
void this._canvas.offsetHeight;
this.resize(); // this is also required in case the browwser doesnt support/fire observer
}
// 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
*
* Check the example for more details - https://threepipe.org/examples/#viewer-render-size/
* @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 = Math.floor(size.width);
const height = Math.floor(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) {
let res = imported;
if (imported.userData?.rootSceneModelRoot) {
const obj = imported;
this._scene.loadModelRoot(obj, options);
if (options?.importConfig !== false) {
if (obj.importedViewerConfig) {
await this.importConfig(obj.importedViewerConfig);
// @ts-expect-error no type for this
if (obj._deletedImportedViewerConfig)
delete obj._deletedImportedViewerConfig;
// @ts-expect-error no type for this
}
else if (obj._deletedImportedViewerConfig)
this.console.error('ThreeViewer - Imported viewer config was deleted, cannot import it again. Set `viewer.deleteImportedViewerConfigOnLoad` to `false` to keep it in the object for reuse workflows.');
}
if (this.deleteImportedViewerConfigOnLoad && obj.importedViewerConfig) {
setTimeout(() => {
if (!obj.importedViewerConfig)
return;
delete obj.importedViewerConfig; // any useful data in the config should be loaded into userData.__importData by then
// @ts-expect-error no type for this
obj._deletedImportedViewerConfig = true; // for console warning above
}, this.deleteImportedViewerConfigOnLoadWait);
}
res = this._scene.modelRoot;
}
else {
this._scene.addObject(imported, options);
}
return res;
}
/**
* 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 (p[0] === p[1].constructor.OldPluginType)
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' && json.type !== 'ThreeViewer') {
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) {
if (typeof binary !== 'boolean')
binary = true; // its a meta, ignore it
if (pluginFilter !== undefined && !Array.isArray(pluginFilter))
pluginFilter = undefined; // non standard param.
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 returned from {@link toJSON}.
* @param meta - The meta object, see {@link SerializationMetaType}
* @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?.__isLoadedReso