UNPKG

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
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