| @@ -99,6 +99,9 @@ To make changes and run the example, click on the CodePen button on the top righ | |||
| - [SSAOPlugin](#ssaoplugin) - Add SSAO(Screen Space Ambient Occlusion) for physical materials. | |||
| - [CanvasSnapshotPlugin](#canvassnapshotplugin) - Add support for taking snapshots of the canvas | |||
| - [PickingPlugin](#pickingplugin) - Adds support for selecting objects in the viewer with user interactions and selection widgets | |||
| - [LoadingScreenPlugin](#loadingscreenplugin) - Shows a configurable loading screen overlay over the canvas. | |||
| - [FullScreenPlugin](#fullscreenplugin) - Adds support for moving the canvas or the container fullscreen mode in browsers | |||
| - [InteractionPromptPlugin](#interactionpromptplugin) - Adds an animated hand icon over canvas to prompt the user to interact | |||
| - [TransformControlsPlugin](#transformcontrolsplugin) - Adds support for moving, rotating and scaling objects in the viewer with interactive widgets | |||
| - [ContactShadowGroundPlugin](#contactshadowgroundplugin) - Adds a ground plane at runtime with contact shadows | |||
| - [GLTFAnimationPlugin](#gltfanimationplugin) - Add support for playing and seeking gltf animations | |||
| @@ -2441,6 +2444,103 @@ pickingPlugin.addEventListener('hoverObjectChanged', (e)=>{ | |||
| ``` | |||
| ## FullScreenPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#fullscreen-plugin/) — | |||
| [Source Code](./src/plugins/interaction/FullScreenPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/FullScreenPlugin.html) | |||
| A simple plugin that provides functions to enter, exit and toggle full screen mode and check if the viewer is in full screen mode. Either the canvas or the whole container can be set to full screen. | |||
| ```typescript | |||
| import {ThreeViewer, FullScreenPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const fullscreen = viewer.addPluginSync(new FullScreenPlugin()) | |||
| // enter full screen | |||
| await fullscreen.enter(viewer.container) // viewer.canvas is used if no element is passed | |||
| // exit full screen | |||
| await fullscreen.exit() | |||
| // toggle | |||
| await fullscreen.toggle(viewer.container) | |||
| // check if full screen | |||
| console.log(fullScreenPlugin.isFullScreen()) | |||
| ``` | |||
| ## LoadingScreenPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#loading-screen-plugin/) — | |||
| [Source Code](./src/plugins/interaction/LoadingScreenPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/LoadingScreenPlugin.html) | |||
| Loading Screen Plugin adds configurable overlay with a logo, loading text, spinner and the list of loading items. It also provides options to minimize and maximize the loading popup when there is no objects in the scene. | |||
| The overlay is automatically added to the viewer container and shown when any files are loading. Behaviour can be configured to change how its shown and hidden, and can even be triggered programmatically. | |||
| ```typescript | |||
| import {ThreeViewer, LoadingScreenPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const loadingScreen = viewer.addPluginSync(new LoadingScreenPlugin()) | |||
| loadingScreen.loadingTextHeader = 'Loading Helmet 3D Model' | |||
| loadingScreen.errorTextHeader = 'Error Loading Helmet 3D Model' | |||
| loadingScreen.showFileNames = true | |||
| loadingScreen.showProcessStates = true | |||
| loadingScreen.showProgress = true | |||
| loadingScreen.backgroundOpacity = 0.4 // 0-1 | |||
| loadingScreen.backgroundBlur = 28 // px | |||
| ``` | |||
| See also the base class [AAssetManagerProcessStatePlugin](https://threepipe.org/docs/classes/AAssetManagerProcessStatePlugin.html) to write a custom loading plugin. | |||
| ## InteractionPromptPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#interaction-prompt-plugin/) — | |||
| [Source Code](./src/plugins/interaction/InteractionPromptPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/InteractionPromptPlugin.html) | |||
| Interaction Prompt Plugin adds a hand pointer icon over the canvas that moves to prompt the user to interact with the 3d scene. To use, simply add the plugin to the viewer. | |||
| The default pointer icon from [google/model-viewer](https://github.com/google/model-viewer) and can be configured with the `pointerIcon` property. | |||
| The pointer is automatically shown when some object is in the scene and the camera is not moving. | |||
| The animation starts after a delay and stops on user interaction. It then restarts after a delay after the user stops interacting | |||
| The plugin provides several options and functions to configure the automatic behaviour or trigger the animation manually. | |||
| ```typescript | |||
| import {ThreeViewer, InteractionPromptPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const interactionPrompt = viewer.addPluginSync(new InteractionPromptPlugin()) | |||
| // change duration | |||
| interactionPrompt.animationDuration = 3000 | |||
| // change animation distance in pixels | |||
| interactionPrompt.animationDistance = 100 | |||
| // disable auto start when the camera stops | |||
| interactionPrompt.autoStart = false | |||
| interactionPrompt.autoStop = false | |||
| // manually start and stop | |||
| interactionPrompt.startAnimation() | |||
| // ... | |||
| interactionPrompt.stopAnimation() | |||
| ``` | |||
| Note - The pointer is automatically shown/hidden when animation is started/stopped. | |||
| ## TransformControlsPlugin | |||
| [//]: # (todo: image) | |||
| @@ -340,7 +340,9 @@ | |||
| <li><a href="./dropzone-plugin/">Dropzone (Drag & Drop) Plugin </a></li> | |||
| <li><a href="./transform-controls-plugin/">Transform Controls Plugin </a></li> | |||
| <li><a href="./editor-view-widget-plugin/">Editor View Widget Plugin </a></li> | |||
| <li><a href="./loading-screen-plugin/">FullScreen Plugin </a></li> | |||
| <li><a href="./fullscreen-plugin/">FullScreen Plugin </a></li> | |||
| <li><a href="./interaction-prompt-plugin/">Interaction Prompt Plugin </a></li> | |||
| <li><a href="./device-orientation-controls-plugin/">Device Orientation Controls Plugin (Gyroscope) </a></li> | |||
| <li><a href="./pointer-lock-controls-plugin/">Pointer Lock(FPS) Controls Plugin </a></li> | |||
| <li><a href="./three-first-person-controls-plugin/">Three First Person(look around) Controls Plugin </a></li> | |||
| @@ -403,6 +405,7 @@ | |||
| <li><a href="./fragment-clipping-extension-plugin/">Fragment Clipping Extension Plugin </a></li> | |||
| <li><a href="./noise-bump-material-plugin/">SparkleBump(NoiseBump) Material Plugin </a></li> | |||
| <li><a href="./custom-bump-map-plugin/">Custom Bump Map Plugin </a></li> | |||
| <li><a href="./parallax-mapping-plugin/">Parallax Mapping Plugin </a></li> | |||
| </ul> | |||
| <h2 class="category">Utils</h2> | |||
| <ul> | |||
| @@ -412,6 +415,7 @@ | |||
| <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> | |||
| <li><a href="./geometry-generator-plugin/">Geometry Generator Plugin </a></li> | |||
| <li><a href="./object3d-widgets-plugin/">Object3D Widgets Plugin <br/>(Lights, Cameras)</a></li> | |||
| <li><a href="./gltf-khr-material-variants-plugin/">glTF KHR Material Variants Plugin </a></li> | |||
| <li><a href="./geometry-uv-preview/">Geometry UV Preview Plugin </a></li> | |||
| <li><a href="./parallel-asset-import/">Parallel Asset Import </a></li> | |||
| <li><a href="./obj-to-glb/">Convert OBJ to GLB </a></li> | |||
| @@ -0,0 +1,52 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Interaction Prompt Plugin</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| <div class="line"></div> | |||
| <div class="line2"></div> | |||
| <style> | |||
| .line, .line2{ | |||
| position: absolute; z-index: 100; | |||
| background-color: black; | |||
| } | |||
| .line{ | |||
| width: 1px; height: 100%; | |||
| top: 0; left: calc(50% - 0.5px); | |||
| } | |||
| .line2{ | |||
| height: 1px; width: 100%; | |||
| left: 0; top: calc(50% - 0.5px); | |||
| } | |||
| </style> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,30 @@ | |||
| import {_testFinish, InteractionPromptPlugin, IObject3D, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| }) | |||
| const interactionPrompt = viewer.addPluginSync(new InteractionPromptPlugin()) | |||
| // distance in pixels | |||
| interactionPrompt.animationDistance = 120 | |||
| // duration of one loop in ms | |||
| interactionPrompt.animationDuration = 2000 | |||
| // set pause duration in between animations | |||
| interactionPrompt.animationPauseDuration = 1000 | |||
| // delay before starting the animation again | |||
| interactionPrompt.autoStartDelay = 5000 | |||
| // rotation distance in radians | |||
| interactionPrompt.rotationDistance = 1 | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| await viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf') | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| ui.setupPluginUi(InteractionPromptPlugin, {expanded: true}) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Loading Screen Plugin</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <!-- Import maps polyfill --> | |||
| <!-- Remove this when import maps will be widely supported --> | |||
| <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> | |||
| <script type="importmap"> | |||
| { | |||
| "imports": { | |||
| "threepipe": "./../../dist/index.mjs", | |||
| "@threepipe/plugin-tweakpane": "./../../plugins/tweakpane/dist/index.mjs" | |||
| } | |||
| } | |||
| </script> | |||
| <style id="example-style"> | |||
| html, body, #canvas-container, #mcanvas { | |||
| width: 100%; | |||
| height: 100%; | |||
| margin: 0; | |||
| overflow: hidden; | |||
| } | |||
| </style> | |||
| <script type="module" src="../examples-utils/simple-code-preview.mjs"></script> | |||
| <script id="example-script" type="module" src="./script.js" data-scripts="./script.ts;./script.js"></script> | |||
| </head> | |||
| <body> | |||
| <div id="canvas-container"> | |||
| <canvas id="mcanvas"></canvas> | |||
| </div> | |||
| </body> | |||
| @@ -0,0 +1,27 @@ | |||
| import {_testFinish, IObject3D, LoadingScreenPlugin, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| }) | |||
| const loadingScreen = viewer.addPluginSync(new LoadingScreenPlugin()) | |||
| loadingScreen.loadingTextHeader = 'Loading Helmet 3D Model' | |||
| loadingScreen.errorTextHeader = 'Error Loading Helmet 3D Model' | |||
| loadingScreen.showFileNames = true | |||
| loadingScreen.showProcessStates = true | |||
| loadingScreen.showProgress = true | |||
| loadingScreen.backgroundOpacity = 0.4 // 0-1 | |||
| loadingScreen.backgroundBlur = 28 // px | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| await viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf') | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| ui.setupPluginUi(LoadingScreenPlugin, {expanded: true}) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -17,16 +17,20 @@ import { | |||
| GBufferPlugin, | |||
| getUrlQueryParam, | |||
| GLTFAnimationPlugin, | |||
| GLTFKHRMaterialVariantsPlugin, | |||
| HalfFloatType, | |||
| HDRiGroundPlugin, | |||
| HemisphereLight, | |||
| InteractionPromptPlugin, | |||
| KTX2LoadPlugin, | |||
| KTXLoadPlugin, | |||
| LoadingScreenPlugin, | |||
| MeshOptSimplifyModifierPlugin, | |||
| NoiseBumpMaterialPlugin, | |||
| NormalBufferPlugin, | |||
| Object3DGeneratorPlugin, | |||
| Object3DWidgetsPlugin, | |||
| ParallaxMappingPlugin, | |||
| PickingPlugin, | |||
| PLYLoadPlugin, | |||
| PointerLockControlsPlugin, | |||
| @@ -86,6 +90,8 @@ async function init() { | |||
| FragmentClippingExtensionPlugin, | |||
| NoiseBumpMaterialPlugin, | |||
| CustomBumpMapPlugin, | |||
| new ParallaxMappingPlugin(false), | |||
| GLTFKHRMaterialVariantsPlugin, | |||
| VirtualCamerasPlugin, | |||
| // new SceneUiConfigPlugin(), // this is already in ViewerUiPlugin | |||
| new GBufferPlugin(HalfFloatType, true, true, true), | |||
| @@ -127,12 +133,13 @@ async function init() { | |||
| rt.addTarget(viewer.getPlugin(NormalBufferPlugin)?.target, 'normal', false, true, false) | |||
| editor.loadPlugins({ | |||
| ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin, TweakpaneUiPlugin], | |||
| ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin, TweakpaneUiPlugin, LoadingScreenPlugin, InteractionPromptPlugin], | |||
| ['Scene']: [SSAAPlugin, ContactShadowGroundPlugin], | |||
| ['Interaction']: [HierarchyUiPlugin, TransformControlsPlugin, PickingPlugin, Object3DGeneratorPlugin, GeometryGeneratorPlugin, EditorViewWidgetPlugin, Object3DWidgetsPlugin, MeshOptSimplifyModifierPlugin], | |||
| ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | |||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, SSAOPlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | |||
| ['Export']: [CanvasSnapshotPlugin], | |||
| ['Configuration']: [GLTFKHRMaterialVariantsPlugin], | |||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | |||
| ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | |||
| ['Debug']: [RenderTargetPreviewPlugin], | |||
| @@ -0,0 +1,135 @@ | |||
| import {createDiv, onChange, serialize} from 'ts-browser-helpers' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {uiToggle} from 'uiconfig.js' | |||
| import {FileTransferPlugin} from '../export/FileTransferPlugin' | |||
| export abstract class AAssetManagerProcessStatePlugin<T extends string = ''> extends AViewerPluginSync<T> { | |||
| @uiToggle('Enabled') | |||
| @onChange(AAssetManagerProcessStatePlugin.prototype._onEnabledChange) | |||
| @serialize() enabled = true | |||
| protected _mainDiv: HTMLDivElement | |||
| protected _contentDiv: HTMLDivElement | undefined | |||
| private _onEnabledChange() { | |||
| if (!this.enabled) this._mainDiv.style.display = 'none' | |||
| } | |||
| protected constructor(suffix: string, public readonly container?: HTMLElement) { | |||
| super() | |||
| this._mainDiv = createDiv({ | |||
| id: 'assetManager' + suffix, | |||
| addToBody: false, | |||
| innerHTML: '', | |||
| }) | |||
| this._contentDiv = createDiv({ | |||
| id: 'assetManager' + suffix + 'Content', | |||
| addToBody: false, | |||
| innerHTML: '', | |||
| }) | |||
| if (!this.enabled) { | |||
| this._mainDiv.style.display = 'none' | |||
| } | |||
| this._mainDiv.appendChild(this._contentDiv) | |||
| } | |||
| protected abstract _updateMainDiv(processState: Map<string, {state: string, progress?: number|undefined}>): void | |||
| processState: Map<string, {state: string, progress: number|undefined}> = new Map() | |||
| onAdded(viewer: ThreeViewer) { | |||
| super.onAdded(viewer) | |||
| ;(this.container ?? viewer.container).appendChild(this._mainDiv) | |||
| this._updateMainDiv(this.processState) | |||
| // todo remove all these listeners onRemove | |||
| viewer.assetManager.importer.addEventListener('importFile', (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set(data.path, { | |||
| state: data.state, | |||
| progress: data.progress ? data.progress * 100 : undefined, | |||
| }) | |||
| } else { | |||
| this.processState.delete(data.path) | |||
| } | |||
| // console.log('importFile', data) | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.assetManager.importer.addEventListener('processRawStart', (data: any) => { | |||
| this.processState.set(data.path, { | |||
| state: 'processing', | |||
| progress: undefined, | |||
| }) | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.assetManager.importer.addEventListener('processRaw', (data: any) => { | |||
| this.processState.delete(data.path) | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.assetManager.exporter.addEventListener('exportFile', (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set(data.obj.name, { | |||
| state: data.state, | |||
| progress: data.progress ? data.progress * 100 : undefined, | |||
| }) | |||
| } else { | |||
| this.processState.delete(data.obj.name) | |||
| } | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.getPlugin<FileTransferPlugin>('FileTransferPlugin')?.addEventListener('transferFile', (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set(data.path, { | |||
| state: data.state, | |||
| progress: data.progress ? data.progress * 100 : undefined, | |||
| }) | |||
| } else { | |||
| this.processState.delete(data.path) | |||
| } | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| // todo; remove or move to plugin | |||
| viewer.getPlugin/* <MaterialConfiguratorPlugin>*/('MaterialConfiguratorPlugin')?.addEventListener('progress' as any, (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set('MatpreviewGeneration', { | |||
| state: data.state, | |||
| progress: 0, | |||
| }) | |||
| } else { | |||
| this.processState.delete('MatpreviewGeneration') | |||
| } | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.getPlugin/* <SwitchNodePlugin>*/('SwitchNodePlugin')?.addEventListener('progress' as any, (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set('SwitchNodeGeneration', { | |||
| state: data.state, | |||
| progress: 0, | |||
| }) | |||
| } else { | |||
| this.processState.delete('SwitchNodeGeneration') | |||
| } | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| viewer.getPlugin('ThemePlugin')?.addEventListener('progress' as any, (data: any) => { | |||
| if (data.state !== 'done') { | |||
| this.processState.set('ThemeInit', { | |||
| state: data.state, | |||
| progress: 0, | |||
| }) | |||
| } else { | |||
| this.processState.delete('ThemeInit') | |||
| } | |||
| this._updateMainDiv(this.processState) | |||
| }) | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| this._mainDiv.remove() | |||
| this._contentDiv?.remove() | |||
| this.processState.clear() | |||
| return super.onRemove(viewer) | |||
| } | |||
| } | |||
| @@ -64,7 +64,7 @@ export class HDRiGroundPlugin extends AViewerPluginSync<'', ThreeViewer> { | |||
| // const bgui = this._viewer.getPlugin<SimpleBackgroundEnvUiPlugin>('SimpleBackgroundEnvUiPlugin1') | |||
| // if (bgui) { | |||
| // bgui.envmapBg = true | |||
| // bgui.uiConfig.uiRefresh?.('postFrame', true) | |||
| // bgui.uiConfig.uiRefresh?.(true, 'postFrame') | |||
| // } else | |||
| this._viewer.scene.background = 'environment' | |||
| } else this.enabled = false | |||
| @@ -3,6 +3,7 @@ export {PipelinePassPlugin} from './base/PipelinePassPlugin' | |||
| export {BaseImporterPlugin} from './base/BaseImporterPlugin' | |||
| export {BaseGroundPlugin} from './base/BaseGroundPlugin' | |||
| export {ACameraControlsPlugin} from './base/ACameraControlsPlugin' | |||
| export {AAssetManagerProcessStatePlugin} from './base/AAssetManagerProcessStatePlugin' | |||
| // pipeline | |||
| export {ProgressivePlugin} from './pipeline/ProgressivePlugin' | |||
| @@ -26,6 +27,8 @@ export {SceneUiConfigPlugin} from './ui/SceneUiConfigPlugin' | |||
| // interaction | |||
| export {DropzonePlugin, type DropzonePluginOptions} from './interaction/DropzonePlugin' | |||
| export {FullScreenPlugin} from './interaction/FullScreenPlugin' | |||
| export {LoadingScreenPlugin} from './interaction/LoadingScreenPlugin' | |||
| export {InteractionPromptPlugin} from './interaction/InteractionPromptPlugin' | |||
| export {PickingPlugin} from './interaction/PickingPlugin' | |||
| export {TransformControlsPlugin} from './interaction/TransformControlsPlugin' | |||
| export {EditorViewWidgetPlugin} from './interaction/EditorViewWidgetPlugin' | |||
| @@ -0,0 +1,289 @@ | |||
| import {Spherical, Vector3} from 'three' | |||
| import {IEvent, now, objectHasOwn, onChange, serialize} from 'ts-browser-helpers' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {uiButton, uiFolderContainer, uiInput, uiMonitor, uiToggle} from 'uiconfig.js' | |||
| import {OrbitControls3} from '../../three' | |||
| /** | |||
| * Interaction Prompt Plugin | |||
| * | |||
| * A plugin that adds a hand pointer icon over the canvas that moves to prompt the user to interact with the 3d scene. | |||
| * Pointer icon from [google/model-viewer](https://github.com/google/model-viewer) | |||
| * | |||
| * The pointer is automatically shown when some object is in the scene and the camera is not moving. | |||
| * The animation starts after a delay and stops on user interaction. It then restarts after a delay after the user stops interacting | |||
| * | |||
| * The plugin provides several options and functions to configure the automatic behaviour or trigger the animation manually. | |||
| * @todo - create example | |||
| * @category Plugins | |||
| */ | |||
| @uiFolderContainer('Interaction Prompt') | |||
| export class InteractionPromptPlugin extends AViewerPluginSync<''> { | |||
| static readonly PluginType = 'InteractionPromptPlugin' | |||
| @serialize() | |||
| @uiToggle() enabled | |||
| currentSphericalPosition?: Spherical | |||
| animationRunning = false | |||
| cursorEl?: HTMLElement | |||
| // interactionsDisabled = false | |||
| /** | |||
| * Animation duration in ms | |||
| */ | |||
| @serialize() | |||
| @uiInput() animationDuration = 2000 | |||
| /** | |||
| * Animation distance in pixels | |||
| */ | |||
| @serialize() | |||
| @uiInput() animationDistance = 80 | |||
| @serialize() | |||
| @uiInput() animationPauseDuration = 6000 | |||
| /** | |||
| * Camera Rotation distance in radians. | |||
| */ | |||
| @serialize() | |||
| @uiInput() rotationDistance = 0.3 | |||
| /** | |||
| * Move the pointer icon up or down. | |||
| * Y offset in the range -1 to 1. | |||
| * 0 is the center of the screen, -1 is the top and 1 is the bottom. | |||
| */ | |||
| @serialize() | |||
| @uiInput() yOffset = 0 | |||
| /** | |||
| * Autostart after camera stop | |||
| */ | |||
| @serialize() | |||
| @uiToggle() autoStart = true | |||
| /** | |||
| * Time in ms to wait before auto start after the camera stops. | |||
| */ | |||
| @serialize() | |||
| @uiInput() autoStartDelay = 30000 | |||
| /** | |||
| * Auto stop on user interaction pointer down or wheel | |||
| */ | |||
| @serialize() | |||
| @uiToggle() autoStop = true | |||
| /** | |||
| * Auto start on scene object load. This requires {@link autoStart} to be true | |||
| */ | |||
| @serialize() | |||
| @uiToggle() autoStartOnObjectLoad = true | |||
| @serialize() | |||
| @uiToggle() autoStartOnObjectLoadDelay = 3000 | |||
| @uiMonitor() currentTime = 0 | |||
| @uiMonitor() lastActionTime = Infinity | |||
| constructor(enabled = true) { | |||
| super() | |||
| this.enabled = enabled | |||
| } | |||
| // private _xDamper = new Damper(50) | |||
| /** | |||
| * Pointer icon svg | |||
| * Note: This is directly added to the DOM | |||
| */ | |||
| @onChange(InteractionPromptPlugin.prototype._pointerIconChanged) | |||
| pointerIcon = `<svg xmlns="http://www.w3.org/2000/svg" style="transform: translate(-50%, -25%);" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="36"> | |||
| <defs> | |||
| <path id="A" d="M.001.232h24.997V36H.001z"></path> | |||
| </defs> | |||
| <g transform="translate(-11 -4)" fill="none" fill-rule="evenodd"> | |||
| <path fill-opacity="0" fill="#fff" d="M0 0h44v44H0z"></path> | |||
| <g transform="translate(11 3)"> | |||
| <path d="M8.733 11.165c.04-1.108.766-2.027 1.743-2.307a2.54 2.54 0 0 1 .628-.089c.16 0 .314.017.463.044 1.088.2 1.9 1.092 1.9 2.16v8.88h1.26c2.943-1.39 5-4.45 5-8.025a9.01 9.01 0 0 0-1.9-5.56l-.43-.5c-.765-.838-1.683-1.522-2.712-2-1.057-.49-2.226-.77-3.46-.77s-2.4.278-3.46.77c-1.03.478-1.947 1.162-2.71 2l-.43.5a9.01 9.01 0 0 0-1.9 5.56 9.04 9.04 0 0 0 .094 1.305c.03.21.088.41.13.617l.136.624c.083.286.196.56.305.832l.124.333a8.78 8.78 0 0 0 .509.953l.065.122a8.69 8.69 0 0 0 3.521 3.191l1.11.537v-9.178z" fill-opacity=".5" fill="#e4e4e4"></path> | |||
| <path d="M22.94 26.218l-2.76 7.74c-.172.485-.676.8-1.253.8H12.24c-1.606 0-3.092-.68-3.98-1.82-1.592-2.048-3.647-3.822-6.11-5.27-.095-.055-.15-.137-.152-.23-.004-.1.046-.196.193-.297.56-.393 1.234-.6 1.926-.6a3.43 3.43 0 0 1 .691.069l4.922.994V10.972c0-.663.615-1.203 1.37-1.203s1.373.54 1.373 1.203v9.882h2.953c.273 0 .533.073.757.21l6.257 3.874c.027.017.045.042.07.06.41.296.586.77.426 1.22M4.1 16.614c-.024-.04-.042-.083-.065-.122a8.69 8.69 0 0 1-.509-.953c-.048-.107-.08-.223-.124-.333l-.305-.832c-.058-.202-.09-.416-.136-.624l-.13-.617a9.03 9.03 0 0 1-.094-1.305c0-2.107.714-4.04 1.9-5.56l.43-.5c.764-.84 1.682-1.523 2.71-2 1.058-.49 2.226-.77 3.46-.77s2.402.28 3.46.77c1.03.477 1.947 1.16 2.712 2l.428.5a9 9 0 0 1 1.901 5.559c0 3.577-2.056 6.636-5 8.026h-1.26v-8.882c0-1.067-.822-1.96-1.9-2.16-.15-.028-.304-.044-.463-.044-.22 0-.427.037-.628.09-.977.28-1.703 1.198-1.743 2.306v9.178l-1.11-.537C6.18 19.098 4.96 18 4.1 16.614M22.97 24.09l-6.256-3.874c-.102-.063-.218-.098-.33-.144 2.683-1.8 4.354-4.855 4.354-8.243 0-.486-.037-.964-.104-1.43a9.97 9.97 0 0 0-1.57-4.128l-.295-.408-.066-.092a10.05 10.05 0 0 0-.949-1.078c-.342-.334-.708-.643-1.094-.922-1.155-.834-2.492-1.412-3.94-1.65l-.732-.088-.748-.03a9.29 9.29 0 0 0-1.482.119c-1.447.238-2.786.816-3.94 1.65a9.33 9.33 0 0 0-.813.686 9.59 9.59 0 0 0-.845.877l-.385.437-.36.5-.288.468-.418.778-.04.09c-.593 1.28-.93 2.71-.93 4.222 0 3.832 2.182 7.342 5.56 8.938l1.437.68v4.946L5 25.64a4.44 4.44 0 0 0-.888-.086c-.017 0-.034.003-.05.003-.252.004-.503.033-.75.08a5.08 5.08 0 0 0-.237.056c-.193.046-.382.107-.568.18-.075.03-.15.057-.225.1-.25.114-.494.244-.723.405a1.31 1.31 0 0 0-.566 1.122 1.28 1.28 0 0 0 .645 1.051C4 29.925 5.96 31.614 7.473 33.563a5.06 5.06 0 0 0 .434.491c1.086 1.082 2.656 1.713 4.326 1.715h6.697c.748-.001 1.43-.333 1.858-.872.142-.18.256-.38.336-.602l2.757-7.74c.094-.26.13-.53.112-.794s-.088-.52-.203-.76a2.19 2.19 0 0 0-.821-.91" fill-opacity=".6" fill="#000"></path> | |||
| <path d="M22.444 24.94l-6.257-3.874a1.45 1.45 0 0 0-.757-.211h-2.953v-9.88c0-.663-.616-1.203-1.373-1.203s-1.37.54-1.37 1.203v16.643l-4.922-.994a3.44 3.44 0 0 0-.692-.069 3.35 3.35 0 0 0-1.925.598c-.147.102-.198.198-.194.298.004.094.058.176.153.23 2.462 1.448 4.517 3.22 6.11 5.27.887 1.14 2.373 1.82 3.98 1.82h6.686c.577 0 1.08-.326 1.253-.8l2.76-7.74c.16-.448-.017-.923-.426-1.22-.025-.02-.043-.043-.07-.06z" fill="#fff"></path> | |||
| <g transform="translate(0 .769)"> | |||
| <mask id="B" fill="#fff"> | |||
| <use xlink:href="#A"></use> | |||
| </mask> | |||
| <path d="M23.993 24.992a1.96 1.96 0 0 1-.111.794l-2.758 7.74c-.08.22-.194.423-.336.602-.427.54-1.11.87-1.857.872h-6.698c-1.67-.002-3.24-.633-4.326-1.715-.154-.154-.3-.318-.434-.49C5.96 30.846 4 29.157 1.646 27.773c-.385-.225-.626-.618-.645-1.05a1.31 1.31 0 0 1 .566-1.122 4.56 4.56 0 0 1 .723-.405l.225-.1a4.3 4.3 0 0 1 .568-.18l.237-.056c.248-.046.5-.075.75-.08.018 0 .034-.003.05-.003.303-.001.597.027.89.086l3.722.752V20.68l-1.436-.68c-3.377-1.596-5.56-5.106-5.56-8.938 0-1.51.336-2.94.93-4.222.015-.03.025-.06.04-.09.127-.267.268-.525.418-.778.093-.16.186-.316.288-.468.063-.095.133-.186.2-.277L3.773 5c.118-.155.26-.29.385-.437.266-.3.544-.604.845-.877a9.33 9.33 0 0 1 .813-.686C6.97 2.167 8.31 1.59 9.757 1.35a9.27 9.27 0 0 1 1.481-.119 8.82 8.82 0 0 1 .748.031c.247.02.49.05.733.088 1.448.238 2.786.816 3.94 1.65.387.28.752.588 1.094.922a9.94 9.94 0 0 1 .949 1.078l.066.092c.102.133.203.268.295.408a9.97 9.97 0 0 1 1.571 4.128c.066.467.103.945.103 1.43 0 3.388-1.67 6.453-4.353 8.243.11.046.227.08.33.144l6.256 3.874c.37.23.645.55.82.9.115.24.185.498.203.76m.697-1.195c-.265-.55-.677-1.007-1.194-1.326l-5.323-3.297c2.255-2.037 3.564-4.97 3.564-8.114 0-2.19-.637-4.304-1.84-6.114-.126-.188-.26-.37-.4-.552-.645-.848-1.402-1.6-2.252-2.204C15.472.91 13.393.232 11.238.232A10.21 10.21 0 0 0 5.23 2.19c-.848.614-1.606 1.356-2.253 2.205-.136.18-.272.363-.398.55C1.374 6.756.737 8.87.737 11.06c0 4.218 2.407 8.08 6.133 9.842l.863.41v3.092l-2.525-.51c-.356-.07-.717-.106-1.076-.106a5.45 5.45 0 0 0-3.14.996c-.653.46-1.022 1.202-.99 1.983a2.28 2.28 0 0 0 1.138 1.872c2.24 1.318 4.106 2.923 5.543 4.772 1.26 1.62 3.333 2.59 5.55 2.592h6.698c1.42-.001 2.68-.86 3.134-2.138l2.76-7.74c.272-.757.224-1.584-.134-2.325" fill-opacity=".05" fill="#000" mask="url(#B)"></path> | |||
| </g> | |||
| </g> | |||
| </g> | |||
| </svg>` | |||
| onAdded(viewer: ThreeViewer) { | |||
| super.onAdded(viewer) | |||
| // legacy, required for files. remove later? todo use OldPluginType | |||
| { | |||
| if (objectHasOwn(viewer.plugins, 'InteractionPointerPlugin')) { | |||
| delete viewer.plugins.InteractionPointerPlugin | |||
| } | |||
| // eslint-disable-next-line @typescript-eslint/no-this-alias | |||
| const p = this | |||
| Object.defineProperty(viewer.plugins, 'InteractionPointerPlugin', { | |||
| get(): any { | |||
| console.warn('InteractionPromptPlugin: PluginType renamed from InteractionPointerPlugin to InteractionPromptPlugin. Please update your code/vjson.') | |||
| return p | |||
| }, | |||
| configurable: true, // required to be able to delete | |||
| }) | |||
| } | |||
| this.lastActionTime = Infinity | |||
| viewer.addEventListener('preFrame', this._preFrame) | |||
| viewer.container.addEventListener('pointerdown', this._pointerDown, true) // true is for capturing, this is required to enable orbit controls. https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#capture | |||
| viewer.container.addEventListener('wheel', this._pointerDown, true) | |||
| viewer.scene.addEventListener('addSceneObject', this._addSceneObject) | |||
| viewer.scene.addEventListener('mainCameraUpdate', this._mainCameraUpdate) | |||
| this._initializeCursor() | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| this.stopAnimation() | |||
| viewer.removeEventListener('preFrame', this._preFrame) | |||
| viewer.container.removeEventListener('pointerdown', this._pointerDown, true) | |||
| viewer.container.removeEventListener('wheel', this._pointerDown, true) | |||
| viewer.scene.removeEventListener('addSceneObject', this._addSceneObject) | |||
| viewer.scene.removeEventListener('mainCameraUpdate', this._mainCameraUpdate) | |||
| if (this.cursorEl) { | |||
| this.cursorEl.remove() | |||
| } | |||
| return super.onRemove(viewer) | |||
| } | |||
| private _mainCameraUpdate = (e: any)=>{ | |||
| if (this.isDisabled()) return | |||
| if (e.change === 'deserialize') { | |||
| this.stopAnimation() | |||
| this.startAnimation() | |||
| } else { | |||
| this.lastActionTime = now() | |||
| } | |||
| } | |||
| private _addSceneObject = ()=>{ | |||
| if (this.autoStartOnObjectLoad) { | |||
| this.lastActionTime = now() - this.autoStartDelay + this.autoStartOnObjectLoadDelay | |||
| } | |||
| } | |||
| protected _pointerIconChanged() { | |||
| if (!this.cursorEl) return | |||
| this.cursorEl.innerHTML = this.pointerIcon | |||
| } | |||
| private _initializeCursor() { | |||
| this.cursorEl = document.createElement('div') | |||
| this.cursorEl.style.position = 'absolute' | |||
| this.cursorEl.style.top = '0' | |||
| this.cursorEl.style.left = '0' | |||
| this.cursorEl.style.width = '10px' | |||
| this.cursorEl.style.height = '10px' | |||
| this.cursorEl.style.opacity = '0' | |||
| // this.cursorEl.style.transition = 'opacity 0.25s ease-in-out' | |||
| // this.cursorEl.innerHTML = this.pointerIcon | |||
| this._pointerIconChanged() | |||
| this._viewer!.container.appendChild(this.cursorEl) | |||
| } | |||
| @serialize() | |||
| onlyOnOrbitControls = true | |||
| private _orbitWarning = false | |||
| @uiButton() startAnimation = () => { | |||
| if (!this._viewer || !this.cursorEl || this.isDisabled()) return | |||
| if ((this._viewer.scene.mainCamera.controls as OrbitControls3)?.type !== 'OrbitControls' && this.onlyOnOrbitControls) { | |||
| if (!this._orbitWarning) console.warn('InteractionPromptPlugin requires OrbitControls, to run anyway, set onlyOnOrbitControls to false') | |||
| this._orbitWarning = true | |||
| return | |||
| } | |||
| if (this._viewer.scene.modelRoot.children.length === 0) return | |||
| this.currentSphericalPosition = new Spherical().setFromVector3(new Vector3().subVectors( | |||
| this._viewer.scene.mainCamera.position, | |||
| this._viewer.scene.mainCamera.target | |||
| )) | |||
| this.cursorEl.style.opacity = '1' | |||
| this.currentTime = 0 | |||
| this.animationRunning = true | |||
| this._viewer.scene.mainCamera.setInteractions(false, InteractionPromptPlugin.PluginType) | |||
| // if (this._viewer.scene.mainCamera.interactionsEnabled) { | |||
| // this.interactionsDisabled = true | |||
| // this._viewer.scene.mainCamera.interactionsEnabled = false | |||
| // } | |||
| } | |||
| @uiButton() stopAnimation = () => { | |||
| if (!this._viewer || !this.cursorEl) return // dont check for enabled here. | |||
| this.animationRunning = false | |||
| this.cursorEl.style.opacity = '0' | |||
| this._viewer.scene.mainCamera.setInteractions(true, InteractionPromptPlugin.PluginType) | |||
| // if (this.interactionsDisabled) { | |||
| // this._viewer.scene.mainCamera.interactionsEnabled = true | |||
| // this.interactionsDisabled = false | |||
| // } | |||
| } | |||
| private _pointerDown = () => { | |||
| if (this.isDisabled()) return | |||
| if (this.autoStop) this.stopAnimation() | |||
| this.lastActionTime = now() | |||
| } | |||
| private _x = 0 | |||
| private _preFrame = async(ev: IEvent<any>) => { | |||
| if (!this._viewer || !this.cursorEl) return | |||
| if (this.isDisabled() && this.animationRunning) { | |||
| this.stopAnimation() | |||
| } | |||
| if (this.isDisabled()) return | |||
| if (!this.animationRunning && this.autoStart && this.lastActionTime + this.autoStartDelay < now()) | |||
| this.startAnimation() | |||
| if (!this.animationRunning) return | |||
| if (this.currentTime <= this.animationDuration) { | |||
| this.cursorEl.style.opacity = '1' | |||
| // this.currentTime = this._xDamper.update(this.currentTime, this.currentTime + ev.deltaTime, 50, 0) | |||
| const x = this.currentTime / this.animationDuration | |||
| this._x = Math.sin(Math.PI * 2 * x) // this._xDamper.update( this._x,newX , ev.deltaTime , 1) | |||
| if (x < 0.25 || x > 0.75) { | |||
| this._x *= this._x * Math.sign(this._x) | |||
| } | |||
| } else { | |||
| this.cursorEl.style.opacity = '0' | |||
| this._x = 0 | |||
| } | |||
| if (this.currentTime <= this.animationDuration + 50) { // because of precision issues. we need _x to be 0 | |||
| const sphericalPosition = this.currentSphericalPosition!.clone() | |||
| sphericalPosition.theta += this._x * this.rotationDistance | |||
| this._viewer.scene.mainCamera.position.setFromSpherical(sphericalPosition).add(this._viewer.scene.mainCamera.target) | |||
| this._viewer.scene.mainCamera.setDirty() | |||
| } | |||
| const canvasBounds = this._viewer.container.getBoundingClientRect() | |||
| const cursorX = canvasBounds.width / 2 + -this._x * Math.min(this.animationDistance, canvasBounds.width / 4) | |||
| const cursorY = canvasBounds.height / 2 + this.yOffset * canvasBounds.height / 2 | |||
| this.cursorEl.style.transform = `translate(${Math.floor(cursorX)}px, ${Math.floor(cursorY)}px)` | |||
| this.currentTime += ev.deltaTime | |||
| if (this.currentTime > this.animationDuration + this.animationPauseDuration) { | |||
| this.currentTime = 0 | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,88 @@ | |||
| #assetManagerLoadingScreen { | |||
| z-index: 50; | |||
| position: absolute; | |||
| font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; | |||
| bottom: 0; | |||
| right: 0; | |||
| min-width: 100%; | |||
| min-height: 100%; | |||
| color: #333; | |||
| font-size: 1rem; | |||
| gap: 1rem; | |||
| display: flex; | |||
| align-content: center; | |||
| justify-content: center; | |||
| align-items: center; | |||
| flex-direction: column; | |||
| opacity: 1; | |||
| transition: opacity 0.5s ease-in-out, min-width 0.5s, min-height 0.5s, bottom 0.5s, right 0.5s; | |||
| overflow: hidden; | |||
| background: transparent; | |||
| backdrop-filter: blur(16px); | |||
| background-blend-mode: luminosity; | |||
| --b-opacity: 0.8; | |||
| --b-background: #fff; | |||
| } | |||
| #assetManagerLoadingScreen::before { | |||
| content: ""; | |||
| position: absolute; | |||
| top: 0; | |||
| left: 0; | |||
| width: 100%; | |||
| height: 100%; | |||
| z-index: -1; | |||
| opacity: var(--b-opacity); | |||
| background: var(--b-background); | |||
| } | |||
| #assetManagerLoadingScreenContent { | |||
| padding-top: 0.5rem; | |||
| } | |||
| .loadingScreenProcessState { | |||
| font-weight: bold; | |||
| } | |||
| #assetManagerLoadingScreen.minimizedLoadingScreen { | |||
| top: unset; | |||
| left: unset; | |||
| bottom: 2rem; | |||
| right: 2rem; | |||
| min-width: 0; | |||
| min-height: 0; | |||
| max-width: 80vw; | |||
| max-height: 80vh; | |||
| width: max-content; | |||
| height: max-content; | |||
| padding: 1.5rem; | |||
| border-radius: 0.5rem; | |||
| } | |||
| .loadingScreenFilesElement { | |||
| min-height: 4rem; | |||
| padding: 1rem; | |||
| } | |||
| .loadingScreenLogoElement { | |||
| margin-bottom: 0.5rem; | |||
| max-width: 80%; | |||
| /* for small screens */ | |||
| } | |||
| .loadingScreenLogoElement img { | |||
| min-height: 3rem; | |||
| max-height: 5rem; | |||
| max-width: 100%; | |||
| object-fit: contain; | |||
| } | |||
| .loadingScreenLogoImage { | |||
| width: max-content; | |||
| height: max-content; | |||
| } | |||
| .minimizedLoadingScreen .loadingScreenLoadingElement { | |||
| display: none; | |||
| } | |||
| .minimizedLoadingScreen .loadingScreenFilesElement { | |||
| min-height: 0; | |||
| } | |||
| .minimizedLoadingScreen .loadingScreenLogoElement { | |||
| min-height: 0; | |||
| display: none; | |||
| } | |||
| .minimizedLoadingScreen #assetManagerLoadingScreenContent { | |||
| display: none; | |||
| } | |||
| @@ -0,0 +1,246 @@ | |||
| import {createDiv, createStyles, onChange, serialize, timeout} from 'ts-browser-helpers' | |||
| import styles from './LoadingScreenPlugin.css?inline' | |||
| import spinner1 from './loaders/spinner1.css?inline' | |||
| import {uiButton, uiDropdown, uiFolderContainer, uiInput, uiSlider, uiToggle} from 'uiconfig.js' | |||
| import {AAssetManagerProcessStatePlugin} from '../base/AAssetManagerProcessStatePlugin' | |||
| import {ThreeViewer} from '../../viewer' | |||
| /** | |||
| * Loading Screen Plugin | |||
| * | |||
| * Shows a configurable loading screen overlay over the canvas. | |||
| * | |||
| * @category Plugins | |||
| */ | |||
| @uiFolderContainer('Loading Screen') | |||
| export class LoadingScreenPlugin extends AAssetManagerProcessStatePlugin { | |||
| public static readonly PluginType = 'LoadingScreenPlugin' | |||
| styles = styles | |||
| spinners = [{ | |||
| styles: spinner1, | |||
| html: '<span class="loader"></span>', | |||
| }] | |||
| refresh() { | |||
| this._updateMainDiv(this._isPreviewing ? this._previewState : this.processState, false) | |||
| } | |||
| @uiDropdown('Loader', ['Spinner 1'].map((v, i) => ({value: i, label: v}))) | |||
| @serialize() loader = 0 | |||
| @uiInput('Loading text header') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() loadingTextHeader = 'Loading Files' | |||
| @uiInput('Error text header') | |||
| @serialize() errorTextHeader = 'Error Loading Files' | |||
| @uiToggle('Show file names') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() showFileNames = true | |||
| @uiToggle('Show process states') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() showProcessStates = true | |||
| @uiToggle('Show progress') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() showProgress = true | |||
| @uiToggle('Hide on only errors') | |||
| @serialize() hideOnOnlyErrors = true | |||
| @uiToggle('Hide on files load') | |||
| @serialize() hideOnFilesLoad = true | |||
| @uiToggle('Hide on scene object load') | |||
| @serialize() hideOnSceneObjectLoad = false | |||
| /** | |||
| * Minimize when scene has objects | |||
| * Note: also checks for scene.environment and doesnt minimize when environment is null or undefined | |||
| * @default true | |||
| */ | |||
| @uiToggle('Minimize on scene object load') | |||
| @serialize() minimizeOnSceneObjectLoad = true | |||
| @uiToggle('Show when files start loading') | |||
| @serialize() showOnFilesLoading = true | |||
| @uiToggle('Show when scene empty') | |||
| @serialize() showOnSceneEmpty = true | |||
| @uiInput('Hide delay (ms)') | |||
| @serialize() hideDelay = 500 | |||
| @uiSlider('Background Opacity', [0, 1]) | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() backgroundOpacity = 0.5 | |||
| @uiSlider('Background Blur', [0, 100]) | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() backgroundBlur = 24 | |||
| @uiInput('Background Color') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() background = '#ffffff' | |||
| @uiInput('Text Color') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() textColor = '#222222' | |||
| @uiInput('Logo Image') | |||
| @onChange(LoadingScreenPlugin.prototype.refresh) | |||
| @serialize() logoImage = 'https://static.webgi.xyz/logo.svg' | |||
| private _isPreviewing = false | |||
| private _previewState = new Map([['file.glb', {state: 'downloading', progress: 50}], ['environment.hdr', {state: 'adding'}]]) | |||
| @uiButton('Toggle preview') | |||
| togglePreview() { | |||
| this.maximize() | |||
| this._isPreviewing = !this._isPreviewing | |||
| this.refresh() | |||
| if (this._isPreviewing) | |||
| this.show() | |||
| else | |||
| this.hideWithDelay() | |||
| } | |||
| loadingElement = createDiv({classList: ['loadingScreenLoadingElement'], addToBody: false}) | |||
| filesElement = createDiv({classList: ['loadingScreenFilesElement'], addToBody: false}) | |||
| logoElement = createDiv({classList: ['loadingScreenLogoElement'], addToBody: false}) | |||
| constructor(container?: HTMLElement) { | |||
| super('LoadingScreen', container) | |||
| // const popupClose = createDiv({ | |||
| // id: 'assetManagerLoadingScreenClose', | |||
| // addToBody: false, | |||
| // innerHTML: '✕', | |||
| // }) | |||
| // popupClose.addEventListener('click', () => { | |||
| // this._mainDiv.style.display = 'none' | |||
| // }) | |||
| // this._mainDiv.appendChild(popupClose) | |||
| this._mainDiv.prepend(this.loadingElement) | |||
| this._mainDiv.prepend(this.logoElement) | |||
| this._mainDiv.appendChild(this.filesElement) | |||
| } | |||
| private _isHidden = false | |||
| async hide() { | |||
| this._isHidden = true | |||
| this._mainDiv.style.opacity = '0' | |||
| await timeout(502) | |||
| if (this._isHidden) { | |||
| this._mainDiv.style.display = 'none' | |||
| this._showMainDiv() | |||
| } | |||
| } | |||
| async hideWithDelay() { | |||
| this._isHidden = true | |||
| await timeout(this.hideDelay) | |||
| if (!this._isHidden) return | |||
| return this.hide() | |||
| } | |||
| show() { | |||
| if (!this._isHidden) return | |||
| this._isHidden = false | |||
| this._showMainDiv() | |||
| this._mainDiv.style.display = 'flex' | |||
| } | |||
| protected _showMainDiv() { | |||
| // this._mainDiv.style.opacity = this.opacity.toString() | |||
| this._mainDiv.style.opacity = '1' | |||
| } | |||
| @uiButton('Minimize') | |||
| minimize() { | |||
| this._mainDiv.classList.add('minimizedLoadingScreen') | |||
| if (!this.showFileNames) this.loadingElement.style.display = 'block' | |||
| } | |||
| @uiButton('Maximize') | |||
| maximize() { | |||
| this._mainDiv.classList.remove('minimizedLoadingScreen') | |||
| this.loadingElement.style.display = '' | |||
| } | |||
| private _setHTML(elem: HTMLElement, html:string) { | |||
| if (elem.innerHTML !== html) elem.innerHTML = html | |||
| } | |||
| protected _updateMainDiv(processState: Map<string, {state: string, progress?: number|undefined}>, updateVisibility = true) { | |||
| if (!this._viewer) return | |||
| if (!this._contentDiv) return | |||
| if (!this.enabled) { | |||
| this._mainDiv.style.display = 'none' | |||
| return | |||
| } | |||
| if (this.showFileNames) { | |||
| let text = '' | |||
| processState.forEach((v, k) => { | |||
| text += (this.showProcessStates ? `<span class="loadingScreenProcessState">${v.state}</span>: ` : '') + | |||
| (k || '').split('/').pop() + | |||
| (this.showProgress && v.progress ? ' - ' + (v.progress.toFixed(0) + '%') : '') + | |||
| '<br>' | |||
| }) | |||
| this._setHTML(this.filesElement, text) | |||
| } else { | |||
| this._setHTML(this.filesElement, '') | |||
| } | |||
| const errors = [...processState.values()].filter(v => v.state === 'error') | |||
| if (errors.length > 0 && errors.length === processState.size && !this.hideOnOnlyErrors) { | |||
| this._setHTML(this._contentDiv, this.errorTextHeader) | |||
| } else { | |||
| this._setHTML(this._contentDiv, this.loadingTextHeader) | |||
| } | |||
| this._setHTML(this.loadingElement, this.spinners[this.loader].html) | |||
| this._mainDiv.style.setProperty('--b-opacity', this.backgroundOpacity.toString()) | |||
| this._mainDiv.style.setProperty('--b-background', this.background) | |||
| ;(this._mainDiv.style as any).backdropFilter = `blur(${this.backgroundBlur}px)` | |||
| this._mainDiv.style.color = this.textColor | |||
| this._setHTML(this.logoElement, this.logoImage ? `<img class="loadingScreenLogoImage" src="${this.logoImage}"/>` : '') | |||
| if (updateVisibility) { | |||
| if (this.hideOnFilesLoad && (processState.size === 0 || | |||
| errors.length === processState.size && this.hideOnOnlyErrors)) { | |||
| this.hideDelay ? this.hideWithDelay() : this.hide() | |||
| } else if (processState.size > 0 && this.showOnFilesLoading) { | |||
| this.show() | |||
| } | |||
| } | |||
| } | |||
| private _sceneUpdate = (e: any) => { | |||
| if (!this._viewer) return | |||
| if (!e.hierarchyChanged) return | |||
| const sceneObjects = this._viewer.scene.modelRoot.children | |||
| if (sceneObjects.length === 0 && this.showOnSceneEmpty) { | |||
| this.show() | |||
| } | |||
| // console.log(sceneObjects.length) | |||
| if (sceneObjects.length > 0) { | |||
| if (this.hideOnSceneObjectLoad) | |||
| this.hideWithDelay() | |||
| else if (this.minimizeOnSceneObjectLoad && this._viewer.scene.environment) | |||
| timeout(this.hideDelay + 300).then(()=>this.minimize()) | |||
| } else if (this.minimizeOnSceneObjectLoad) | |||
| this.maximize() | |||
| } | |||
| stylesheet?: HTMLStyleElement | |||
| stylesheetLoader?: HTMLStyleElement[] | |||
| onAdded(viewer: ThreeViewer) { | |||
| this.stylesheet = createStyles(styles, viewer.container) | |||
| this.stylesheetLoader = this.spinners.map(s => createStyles(s.styles, viewer.container)) | |||
| viewer.scene.addEventListener('sceneUpdate', this._sceneUpdate) | |||
| super.onAdded(viewer) | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| viewer.scene.removeEventListener('sceneUpdate', this._sceneUpdate) | |||
| this.stylesheet?.remove() | |||
| this.stylesheet = undefined | |||
| this.stylesheetLoader?.forEach(s => s.remove()) | |||
| this.stylesheetLoader = undefined | |||
| return super.onRemove(viewer) | |||
| } | |||
| } | |||
| @@ -0,0 +1,23 @@ | |||
| .loader { | |||
| width: 48px; | |||
| height: 48px; | |||
| border: 5px solid #333; | |||
| border-bottom-color: transparent; | |||
| border-radius: 50%; | |||
| display: inline-block; | |||
| box-sizing: border-box; | |||
| animation: rotation 1s linear infinite; | |||
| } | |||
| @keyframes rotation { | |||
| 0% { | |||
| transform: rotate(0deg); | |||
| } | |||
| 100% { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| .loadingScreenLoadingElement{ | |||
| } | |||
| @@ -1201,10 +1201,10 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| } | |||
| /** | |||
| * Uses the {@link FileTransferPlugin} to export a blob. If the plugin is not available, it will download the blob. | |||
| * FileTransferPlugin can be configured by other plugins to export the blob to a specific location like local file system, cloud storage, etc. | |||
| * Uses the {@link FileTransferPlugin} to export a Blob/File. If the plugin is not available, it will download the blob. | |||
| * `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 | |||
| * @param name - name of the file, if not provided, the name of the file is used if it's a file. | |||
| */ | |||
| async exportBlob(blob: Blob|File, name?: string) { | |||
| const tr = this.getPlugin<FileTransferPlugin>('FileTransferPlugin') | |||