| @@ -95,8 +95,10 @@ To make changes and run the example, click on the CodePen button on the top righ | |||
| - [DepthBufferPlugin](#depthbufferplugin) - Pre-rendering of depth buffer | |||
| - [NormalBufferPlugin](#normalbufferplugin) - Pre-rendering of normal buffer | |||
| - [GBufferPlugin](#gbufferplugin) - Pre-rendering of depth-normal and flags buffers in a single pass | |||
| - [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 | |||
| - [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 | |||
| - [PopmotionPlugin](#popmotionplugin) - Integrates with popmotion.io library for animation/tweening | |||
| - [CameraViewPlugin](#cameraviewplugin) - Add support for saving, loading, animating, looping between camera views | |||
| @@ -2227,7 +2229,6 @@ const normalTarget = normalPlugin.target; | |||
| // Use the normal target by accessing `normalTarget.texture`. | |||
| ``` | |||
| ## GBufferPlugin | |||
| [//]: # (todo: image) | |||
| @@ -2250,6 +2251,51 @@ const normalDepth = gBufferPlugin.normalDepthTexture; | |||
| const gBufferFlags = gBufferPlugin.flagsTexture; | |||
| ``` | |||
| ## CanvasSnapshotPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#canvas-snapshot-plugin/) — | |||
| [Source Code](./src/plugins/export/CanvasSnapshotPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/CanvasSnapshotPlugin.html) | |||
| Canvas Snapshot Plugin adds support for taking snapshots of the canvas and exporting them as images and data urls. It includes options to take snapshot of a region, mime type, quality render scale and scaling the output image. Check out the interface [CanvasSnapshotOptions](https://threepipe.org/docs/interfaces/CanvasSnapshotOptions.html) for more details. | |||
| ```typescript | |||
| import {ThreeViewer, CanvasSnapshotPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| const snapshotPlugin = viewer.addPluginSync(new CanvasSnapshotPlugin()) | |||
| // download a snapshot. | |||
| await snapshotPlugin.downloadSnapshot('image.webp', { // all parameters are optional | |||
| scale: 1, // scale the final image | |||
| timeout: 0, // wait before taking the snapshot, in ms | |||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||
| displayPixelRatio: 2, // render scale | |||
| mimeType: 'image/webp', // mime type of the image | |||
| waitForProgressive: true, // wait for progressive rendering to finish (ProgressivePlugin). true by default | |||
| rect: { // region to take snapshot. eg. crop center of the canvas | |||
| height: viewer.canvas.clientHeight / 2, | |||
| width: viewer.canvas.clientWidth / 2, | |||
| x: viewer.canvas.clientWidth / 4, | |||
| y: viewer.canvas.clientHeight / 4, | |||
| }, | |||
| }) | |||
| // get data url (string) | |||
| const dataUrl = await snapshotPlugin.getDataUrl({ // all parameters are optional | |||
| displayPixelRatio: 2, // render scale | |||
| mimeType: 'image/webp', // mime type of the image | |||
| }) | |||
| // get File | |||
| const file = await snapshotPlugin.getFile('file.jpeg', { // all parameters are optional | |||
| mimeType: 'image/jpeg', // mime type of the image | |||
| }) | |||
| ``` | |||
| ## PickingPlugin | |||
| [//]: # (todo: image) | |||
| @@ -2325,7 +2371,7 @@ pickingPlugin.addEventListener('hoverObjectChanged', (e)=>{ | |||
| [Example](https://threepipe.org/examples/#transform-controls-plugin/) — | |||
| [Source Code](./src/plugins/interaction/TransformControlsPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/TransformControlsPlugin.html) | |||
| [API Reference](https://threepipe.org/docs/classes/TransformControlsPlugin.html) | |||
| Transform Controls Plugin adds support for moving, rotating and scaling objects in the viewer with interactive widgets. | |||
| @@ -2346,6 +2392,27 @@ const transfromControlsPlugin = viewer.addPluginSync(new TransformControlsPlugin | |||
| console.log(transfromControlsPlugin.transformControls) | |||
| ``` | |||
| ## ContactShadowGroundPlugin | |||
| [//]: # (todo: image) | |||
| [Example](https://threepipe.org/examples/#contact-shadow-ground-plugin/) — | |||
| [Source Code](./src/plugins/extras/ContactShadowGroundPlugin.ts) — | |||
| [API Reference](https://threepipe.org/docs/classes/ContactShadowGroundPlugin.html) | |||
| Contact Shadow Ground Plugin adds a ground plane with three.js contact shadows to the viewer scene. | |||
| The plane is added to the scene root at runtime and not saved with scene export. Instead the plugin settings are saved with the scene. | |||
| It inherits from the base class [BaseGroundPlugin](https://threepipe.org/docs/classes/BaseGroundPlugin.html) which provides generic ground plane functionality. Check the source code for more details. With the property `autoAdjustTransform`, the ground plane is automatically adjusted based on the bounding box of the scene. | |||
| ```typescript | |||
| import {ThreeViewer, ContactShadowGroundPlugin} from 'threepipe' | |||
| const viewer = new ThreeViewer({...}) | |||
| viewer.addPluginSync(new ContactShadowGroundPlugin()) | |||
| ``` | |||
| ## GLTFAnimationPlugin | |||
| @@ -0,0 +1,35 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Canvas Snapshot 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" | |||
| } | |||
| } | |||
| </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,63 @@ | |||
| import {_testFinish, CanvasSnapshotPlugin, isWebpExportSupported, ThreeViewer} from 'threepipe' | |||
| import {createSimpleButtons} from '../examples-utils/simple-bottom-buttons.js' | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| renderScale: 'auto', | |||
| }) | |||
| async function init() { | |||
| const snapshotPlugin = viewer.addPluginSync(new CanvasSnapshotPlugin()) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| await viewer.load('https://threejs.org/examples/models/gltf/kira.glb', { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| createSimpleButtons({ | |||
| ['Download snapshot (rect png)']: async(btn: HTMLButtonElement) => { | |||
| btn.disabled = true | |||
| await snapshotPlugin.downloadSnapshot('snapshot.png', { | |||
| scale: 1, // scale the final image | |||
| displayPixelRatio: 2, // render scale | |||
| mimeType: 'image/png', // mime type of the image | |||
| rect: { // region to take snapshot. Crop center of the canvas | |||
| height: viewer.canvas.clientHeight / 2, | |||
| width: viewer.canvas.clientWidth / 2, | |||
| x: viewer.canvas.clientWidth / 4, | |||
| y: viewer.canvas.clientHeight / 4, | |||
| }, | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| ['Download snapshot (jpeg)']: async(btn: HTMLButtonElement) => { | |||
| btn.disabled = true | |||
| await snapshotPlugin.downloadSnapshot('snapshot.jpeg', { | |||
| mimeType: 'image/jpeg', // mime type of the image | |||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||
| displayPixelRatio: 2, // render scale | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| ['Download snapshot (webp)']: async(btn: HTMLButtonElement) => { | |||
| btn.disabled = true | |||
| if (!isWebpExportSupported()) { | |||
| alert('WebP export is not supported in this browser, try the latest version of chrome, firefox or edge.') | |||
| btn.disabled = false | |||
| return | |||
| } | |||
| await snapshotPlugin.downloadSnapshot('snapshot.webp', { | |||
| mimeType: 'image/webp', // mime type of the image | |||
| scale: 1, // scale the final image | |||
| quality: 0.9, // quality of the image (0-1) only for jpeg and webp | |||
| displayPixelRatio: 2, // render scale | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| }) | |||
| } | |||
| init().then(_testFinish) | |||
| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Contact Shadow Ground 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,22 @@ | |||
| import {_testFinish, ContactShadowGroundPlugin, IObject3D, ThreeViewer} from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| }) | |||
| viewer.addPluginSync(ContactShadowGroundPlugin) | |||
| await Promise.all([ | |||
| viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr'), | |||
| viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf'), | |||
| ]) | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| ui.setupPluginUi(ContactShadowGroundPlugin, {expanded: true}) | |||
| } | |||
| init().then(_testFinish) | |||
| @@ -6,6 +6,8 @@ const viewer = new ThreeViewer({ | |||
| msaa: true, | |||
| }) | |||
| // Note: see also: CanvasSnapshotPlugin | |||
| async function init() { | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| @@ -265,6 +265,7 @@ | |||
| </ul> | |||
| <h2 class="category">Export</h2> | |||
| <ul> | |||
| <li><a href="./canvas-snapshot-plugin/">Canvas Snapshot Plugin<br/>(Image Snapshot) </a></li> | |||
| <li><a href="./image-snapshot-export/">PNG, JPEG, WEBP Export<br/>(Image Snapshot) </a></li> | |||
| <li><a href="./render-target-export/">EXR, PNG, JPEG, WEBP Export<br/>(Render Target Export) </a></li> | |||
| <li><a href="./glb-export/">GLB Export </a></li> | |||
| @@ -299,6 +300,7 @@ | |||
| </ul> | |||
| <h2 class="category">Utils</h2> | |||
| <ul> | |||
| <li><a href="./contact-shadow-ground-plugin/">Contact Shadow Ground Plugin</a></li> | |||
| <li><a href="./hdri-ground-plugin/">HDRi Ground Plugin <br/>(Projected Skybox)</a></li> | |||
| <li><a href="./render-target-preview/">Render Target Preview Plugin </a></li> | |||
| <li><a href="./object3d-generator-plugin/">Object3D Generator Plugin <br/>(Lights, Cameras)</a></li> | |||
| @@ -1,8 +1,10 @@ | |||
| import { | |||
| _testFinish, | |||
| CameraViewPlugin, | |||
| CanvasSnapshotPlugin, | |||
| ChromaticAberrationPlugin, | |||
| ClearcoatTintPlugin, | |||
| ContactShadowGroundPlugin, | |||
| CustomBumpMapPlugin, | |||
| DepthBufferPlugin, | |||
| DropzonePlugin, | |||
| @@ -48,6 +50,7 @@ async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| renderScale: 'auto', | |||
| msaa: true, | |||
| rgbm: true, | |||
| zPrepass: false, // set it to true if you only have opaque objects in the scene to get better performance. | |||
| @@ -97,6 +100,8 @@ async function init() { | |||
| Object3DWidgetsPlugin, | |||
| Object3DGeneratorPlugin, | |||
| GaussianSplattingPlugin, | |||
| ContactShadowGroundPlugin, | |||
| CanvasSnapshotPlugin, | |||
| ...extraImportPlugins, | |||
| ]) | |||
| @@ -108,9 +113,11 @@ async function init() { | |||
| editor.loadPlugins({ | |||
| ['Viewer']: [ViewerUiConfigPlugin, SceneUiConfigPlugin, DropzonePlugin, FullScreenPlugin, TweakpaneUiPlugin], | |||
| ['Scene']: [ContactShadowGroundPlugin], | |||
| ['Interaction']: [HierarchyUiPlugin, TransformControlsPlugin, PickingPlugin, Object3DGeneratorPlugin, GeometryGeneratorPlugin, EditorViewWidgetPlugin, Object3DWidgetsPlugin], | |||
| ['GBuffer']: [GBufferPlugin, DepthBufferPlugin, NormalBufferPlugin], | |||
| ['Post-processing']: [TonemapPlugin, ProgressivePlugin, FrameFadePlugin, VignettePlugin, ChromaticAberrationPlugin, FilmicGrainPlugin], | |||
| ['Export']: [CanvasSnapshotPlugin], | |||
| ['Animation']: [GLTFAnimationPlugin, CameraViewPlugin], | |||
| ['Extras']: [HDRiGroundPlugin, Rhino3dmLoadPlugin, ClearcoatTintPlugin, FragmentClippingExtensionPlugin, NoiseBumpMaterialPlugin, CustomBumpMapPlugin, VirtualCamerasPlugin], | |||
| ['Debug']: [RenderTargetPreviewPlugin], | |||
| @@ -0,0 +1,304 @@ | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {IGeometry, iGeometryCommons, IMaterial, ISceneEvent, Mesh2, PhysicalMaterial, UnlitMaterial} from '../../core' | |||
| import {BufferAttribute, Euler, InterleavedBufferAttribute, PlaneGeometry, Vector3} from 'three' | |||
| import {onChange, onChange2, serialize} from 'ts-browser-helpers' | |||
| import {OrbitControls3} from '../../three' | |||
| import {uiConfig, uiFolderContainer, uiNumber, uiToggle} from 'uiconfig.js' | |||
| @uiFolderContainer('Ground') | |||
| export class BaseGroundPlugin<TEvent extends string = ''> extends AViewerPluginSync<TEvent> { | |||
| public static readonly PluginType: string = 'BaseGroundPlugin' | |||
| get enabled() { | |||
| return this.visible | |||
| } | |||
| set enabled(value) { | |||
| this.visible = value | |||
| } | |||
| protected _geometry: IGeometry&PlaneGeometry | |||
| protected _mesh: Mesh2<IGeometry&PlaneGeometry, IMaterial> | |||
| private _transformNeedRefresh = true | |||
| constructor() { | |||
| super() | |||
| this._refreshMaterial = this._refreshMaterial.bind(this) | |||
| this._refreshTransform = this._refreshTransform.bind(this) | |||
| this._refreshCameraLimits = this._refreshCameraLimits.bind(this) | |||
| this.refresh = this.refresh.bind(this) | |||
| this._refresh2 = this._refresh2.bind(this) | |||
| this._onSceneUpdate = this._onSceneUpdate.bind(this) | |||
| this._preRender = this._preRender.bind(this) | |||
| this._postFrame = this._postFrame.bind(this) | |||
| this._geometry = iGeometryCommons.upgradeGeometry.call(new PlaneGeometry(1, 1, 1, 1)) | |||
| this._geometry.attributes.uv2 = (this._geometry.attributes.uv as any as BufferAttribute | InterleavedBufferAttribute).clone() | |||
| this._geometry.attributes.uv2.needsUpdate = true | |||
| this._mesh = this._createMesh() | |||
| this.refresh() | |||
| } | |||
| @uiToggle('Visible') | |||
| @onChange(BaseGroundPlugin.prototype.refreshTransform) | |||
| @serialize() visible = true | |||
| @uiNumber('Size') | |||
| @onChange2(BaseGroundPlugin.prototype._onSceneUpdate) | |||
| @serialize() size = 8 | |||
| @uiNumber('Height (yOffset)') | |||
| @onChange2(BaseGroundPlugin.prototype._onSceneUpdate) | |||
| @serialize() yOffset = 0 | |||
| @uiToggle('Render to Depth') | |||
| @onChange(BaseGroundPlugin.prototype._refresh2) | |||
| @serialize() renderToDepth = true | |||
| /** | |||
| * If false, the ground will not be tonemapped in post processing. | |||
| * note: this will only work when {@link GBufferPlugin} is being used. Also needs {@link renderToDepth} to be true. | |||
| */ | |||
| @uiToggle('Tonemap Ground') | |||
| @onChange(BaseGroundPlugin.prototype._refresh2) | |||
| @serialize() tonemapGround = true | |||
| /** | |||
| * If true, the camera will be limited to not go below the ground. | |||
| * note: this will only work when {@link OrbitControls3} or three.js OrbitControls are being used. | |||
| */ | |||
| @uiToggle('Limit Camera Above Ground') | |||
| @onChange(BaseGroundPlugin.prototype._refreshCameraLimits) | |||
| @serialize() limitCameraAboveGround = false | |||
| @uiToggle('Auto Adjust Transform') | |||
| @onChange(BaseGroundPlugin.prototype.refreshTransform) | |||
| @serialize() autoAdjustTransform = true | |||
| @serialize('material') | |||
| @uiConfig() | |||
| protected _material?: PhysicalMaterial | |||
| onAdded(viewer: ThreeViewer): void { | |||
| super.onAdded(viewer) | |||
| // if (viewer.getPlugin('TweakpaneUi')) console.error('TweakpaneUiPlugin must be added after Ground Plugin') | |||
| viewer.scene.addObject(this._mesh, {addToRoot: true}) | |||
| viewer.scene.addEventListener('sceneUpdate', this._onSceneUpdate) // todo: refresh when update... | |||
| viewer.scene.addEventListener('addSceneObject', this._onSceneUpdate) | |||
| viewer.addEventListener('preRender', this._preRender) | |||
| viewer.addEventListener('postFrame', this._postFrame) | |||
| this.refresh() | |||
| } | |||
| onRemove(viewer: ThreeViewer): void { | |||
| this._mesh?.dispose(true) | |||
| this._removeMaterial() | |||
| viewer.scene.removeEventListener('sceneUpdate', this._onSceneUpdate) | |||
| viewer.scene.removeEventListener('addSceneObject', this._onSceneUpdate) | |||
| viewer.removeEventListener('postFrame', this._postFrame) | |||
| viewer.removeEventListener('preRender', this._preRender) | |||
| return super.onRemove(viewer) | |||
| } | |||
| protected _postFrame() { | |||
| if (this._transformNeedRefresh) this._refreshTransform() | |||
| if (!this._viewer) return | |||
| } | |||
| protected _preRender() { | |||
| if (!this._viewer) return | |||
| } | |||
| dispose(): void { | |||
| this._removeMaterial() | |||
| this._geometry.dispose() | |||
| this._material?.dispose() // todo | |||
| this._mesh?.dispose?.() | |||
| super.dispose() | |||
| } | |||
| protected _removeMaterial() { | |||
| if (!this._material) return | |||
| // this._manager?.materials?.unregisterMaterial(this._material) | |||
| this._material.userData.renderToDepth = this._material.userData.__renderToDepth | |||
| this._material.userData.__renderToDepth = undefined | |||
| // todo reset gBufferData.tonemapEnabled also | |||
| this._material = undefined | |||
| } | |||
| protected _onSceneUpdate(event?: ISceneEvent) { | |||
| if (event?.geometryChanged === false) return | |||
| if (event?.updateGround !== false) | |||
| this.refreshTransform() | |||
| } | |||
| /** | |||
| * Extra flag for plugins to disable transform refresh like when animating or dragging | |||
| */ | |||
| enableRefreshTransform = true | |||
| refreshTransform(): void { | |||
| if (!this.enableRefreshTransform) return | |||
| this._transformNeedRefresh = true | |||
| } | |||
| public refresh(): void { | |||
| if (!this._viewer) return | |||
| this._refreshMaterial() | |||
| this.refreshTransform() | |||
| this._refreshCameraLimits() | |||
| } | |||
| // because of inheritance breaks onChange | |||
| private _refresh2(): void { | |||
| this.refresh() | |||
| } | |||
| private _cameraLimitsSet = false | |||
| private _cameraLastMaxPolarAngle = Math.PI | |||
| private _refreshCameraLimits() { | |||
| const orbit = this._viewer?.scene.mainCamera.controls as OrbitControls3 | |||
| if (!orbit) return | |||
| if (orbit.maxPolarAngle === undefined) { | |||
| console.warn('refreshCameraLimits only available with orbit controls.') | |||
| return | |||
| } | |||
| if (this.limitCameraAboveGround) { | |||
| if (!this._cameraLimitsSet) this._cameraLastMaxPolarAngle = orbit.maxPolarAngle | |||
| orbit.maxPolarAngle = Math.PI / 2 | |||
| this._cameraLimitsSet = true | |||
| } else if (this._cameraLimitsSet) { | |||
| orbit.maxPolarAngle = this._cameraLastMaxPolarAngle | |||
| this._cameraLimitsSet = false | |||
| } | |||
| } | |||
| protected _refreshTransform() { | |||
| if (!this._mesh) return | |||
| if (!this._viewer) return | |||
| let updated = false | |||
| if (this.visible !== this._mesh.visible) { | |||
| this._mesh.visible = this.visible | |||
| updated = true | |||
| } | |||
| if (this.isDisabled()) { | |||
| if (updated) this._viewer?.scene.setDirty() | |||
| return | |||
| } | |||
| if (this.autoAdjustTransform) { | |||
| this._mesh.userData.bboxVisible = false | |||
| const bbox = this._viewer.scene.getBounds(true) | |||
| this._mesh.userData.bboxVisible = true | |||
| const v = bbox.getCenter( | |||
| new Vector3()).sub(new Vector3(0, | |||
| bbox.getSize(new Vector3()).y / 2 + this.yOffset, | |||
| 0)) | |||
| updated = updated || v.clone().sub(this._mesh.position).length() > 0.0001 | |||
| if (updated) { | |||
| this._mesh.position.copy(v) | |||
| } | |||
| } | |||
| updated = updated || Math.abs(this._mesh.scale.x - this.size) > 0.0001 | |||
| // todo: check rotation, someone could externally change it | |||
| if (updated) { | |||
| this._mesh.scale.setScalar(this.size) | |||
| // this._mesh.lookAt(new Vector3().fromArray(this._options.up)) | |||
| this._mesh.setRotationFromEuler(new Euler(-Math.PI / 2., 0, this._mesh.rotation.z)) | |||
| this._mesh.matrixWorldNeedsUpdate = true | |||
| this._mesh.setDirty() | |||
| // this._viewer.scene.setDirty() | |||
| } | |||
| this._transformNeedRefresh = false | |||
| } | |||
| protected _createMesh(mesh?: Mesh2<IGeometry&PlaneGeometry, IMaterial>): Mesh2<IGeometry&PlaneGeometry, IMaterial> { | |||
| if (!mesh) mesh = new Mesh2(this._geometry, new UnlitMaterial()) | |||
| else mesh.geometry = this._geometry | |||
| if (mesh) { | |||
| mesh.userData.physicsMass = 0 | |||
| mesh.userData.userSelectable = false | |||
| mesh.castShadow = true | |||
| mesh.receiveShadow = true | |||
| mesh.name = 'Ground Plane' | |||
| } | |||
| return mesh | |||
| } | |||
| protected _createMaterial(material?: PhysicalMaterial): PhysicalMaterial { | |||
| if (!material) material = new PhysicalMaterial({ | |||
| name: 'BaseGroundMaterial', | |||
| color: 0xffffff, | |||
| }) | |||
| material.userData.runtimeMaterial = true | |||
| return material | |||
| } | |||
| protected _refreshMaterial(): boolean { | |||
| if (!this._viewer) return false | |||
| if (this.isDisabled()) return false | |||
| const mat = this._material ?? this._createMaterial() | |||
| const isNewMaterial = mat !== this._material | |||
| if (isNewMaterial) { // new material | |||
| this._removeMaterial() | |||
| this._material = mat | |||
| const id = this._material?.uuid | |||
| if (!id) console.warn('No material found for ground') | |||
| this._viewer.scene.setDirty() | |||
| if (this._mesh && this._material) { | |||
| this._material.roughness = 0.2 | |||
| this._material.metalness = 0.5 | |||
| this._mesh.material = this._material // for update event handlers. | |||
| } | |||
| } | |||
| if (this._material) { | |||
| if (this._material.userData.__renderToDepth === undefined) { | |||
| this._material.userData.__renderToDepth = this._material.userData.renderToDepth | |||
| } | |||
| if (this._material.userData.renderToDepth !== this.renderToDepth) { | |||
| this._material.userData.renderToDepth = this.renderToDepth // required to work with SSR, SSAO etc when the ground is transparent / transmissive | |||
| } | |||
| if (!this._material.userData.gBufferData) this._material.userData.gBufferData = {} | |||
| if (this._material.userData.gBufferData.__tonemapEnabled === undefined) { | |||
| this._material.userData.gBufferData.__tonemapEnabled = this._material.userData.gBufferData.tonemapEnabled | |||
| } | |||
| if (this._material.userData.gBufferData.tonemapEnabled !== this.tonemapGround) { | |||
| this._material.userData.gBufferData.tonemapEnabled = this.tonemapGround | |||
| } | |||
| this._material.userData.ssaoDisabled = true | |||
| this._material.userData.sscsDisabled = true | |||
| // if (this._material.userData.__postTonemap === undefined) { | |||
| // this._material.userData.__postTonemap = this._material.userData.postTonemap | |||
| // } | |||
| // if (this._material.userData.postTonemap !== this.tonemapGround) { | |||
| // this._material.userData.postTonemap = this.tonemapGround | |||
| // } | |||
| } | |||
| this._viewer.setDirty(this) // todo: something else also? | |||
| return isNewMaterial | |||
| } | |||
| get material() { | |||
| return this._material | |||
| } | |||
| get mesh() { | |||
| return this._mesh | |||
| } | |||
| fromJSON(data: any, meta?: any): this | null { | |||
| if (data.options) { | |||
| console.error('todo: support old webgi v0 file') | |||
| } | |||
| if (!super.fromJSON(data, meta)) return null | |||
| // if (this._material && this._material.transmission >= 0.01) this._material.transparent = true | |||
| this.refresh() | |||
| // Note: baked shadow reset is done in ShadowMapBaker.fromJSON | |||
| return this | |||
| } | |||
| } | |||
| @@ -0,0 +1,123 @@ | |||
| import {serialize, timeout} from 'ts-browser-helpers' | |||
| import {AViewerPluginSync} from '../../viewer' | |||
| import {uiButton, uiConfig, uiFolderContainer, uiInput} from 'uiconfig.js' | |||
| import {CanvasSnapshot, CanvasSnapshotOptions} from '../../utils/canvas-snapshot' | |||
| import {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | |||
| @uiFolderContainer('Canvas Snapshot (Image Export)') | |||
| export class CanvasSnapshotPlugin extends AViewerPluginSync<''> { | |||
| static readonly PluginType = 'CanvasSnapshotPlugin' | |||
| enabled = true | |||
| constructor() { | |||
| super() | |||
| this.downloadSnapshot = this.downloadSnapshot.bind(this) | |||
| this.getDataUrl({}) | |||
| } | |||
| /** | |||
| * Returns a File object with screenshot of the viewer canvas | |||
| * @param filename default is {@link CanvasSnapshotPlugin.filename} | |||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | |||
| */ | |||
| async getFile(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<File|undefined> { | |||
| options.getDataUrl = false | |||
| return await this._getFile(filename || this.filename, options) as File | |||
| } | |||
| /** | |||
| * Returns a data url of the screenshot of the viewer canvas | |||
| * @param options waitForProgressive: wait for progressive rendering to finish, default: true | |||
| */ | |||
| async getDataUrl(options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<string> { | |||
| options.getDataUrl = true | |||
| return await this._getFile('', options) as string ?? '' | |||
| } | |||
| private async _getFile(filename: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<File|string|undefined> { | |||
| const viewer = this._viewer | |||
| const canvas = this._viewer?.canvas | |||
| if (!viewer || !canvas) return undefined | |||
| const dpr = viewer.renderManager.renderScale | |||
| if (options.displayPixelRatio !== undefined && options.displayPixelRatio !== dpr) { | |||
| viewer.renderManager.renderScale = options.displayPixelRatio | |||
| } | |||
| if (options.timeout) await timeout(options.timeout) | |||
| const progressive = viewer.getPlugin(ProgressivePlugin) | |||
| if (options.waitForProgressive !== false && progressive) { | |||
| // todo: disable interactions and all so that frameCount is not affected | |||
| await new Promise<void>((res)=>{ | |||
| const listener = () => { | |||
| if (!progressive.isConverged(true)) return | |||
| viewer.removeEventListener('postFrame', listener) | |||
| res() | |||
| } | |||
| viewer.addEventListener('postFrame', listener) | |||
| }) | |||
| } else await viewer.doOnce('postFrame') | |||
| options.displayPixelRatio = 1 | |||
| const rect = options.rect | |||
| if (rect && viewer.renderManager.renderScale !== 1) { | |||
| options.rect = { | |||
| ...rect, | |||
| x: rect.x * viewer.renderManager.renderScale, | |||
| y: rect.y * viewer.renderManager.renderScale, | |||
| width: rect.width * viewer.renderManager.renderScale, | |||
| height: rect.height * viewer.renderManager.renderScale, | |||
| } | |||
| } | |||
| const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||
| options.rect = rect | |||
| options.displayPixelRatio = viewer.renderManager.renderScale | |||
| viewer.renderManager.renderScale = dpr | |||
| return file | |||
| } | |||
| @uiInput('Filename') | |||
| @serialize() | |||
| filename = 'snapshot.png' | |||
| /** | |||
| * Only for {@link downloadSnapshot} and functions using that | |||
| */ | |||
| @uiConfig() | |||
| @serialize() | |||
| defaultOptions: CanvasSnapshotOptions&{waitForProgressive?: boolean} = { | |||
| waitForProgressive: true, | |||
| displayPixelRatio: window.devicePixelRatio, | |||
| scale: 1, | |||
| timeout: 0, | |||
| quality: 0.9, | |||
| } | |||
| @uiButton('Download .png') | |||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<void> { | |||
| if (!this._viewer) return | |||
| if (!options.mimeType && !filename) this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||
| const file = await this.getFile(filename, {...this.defaultOptions, ...options}) | |||
| if (file) await this._viewer.exportBlob(file, file.name) | |||
| } | |||
| @uiButton('Download .jpeg') | |||
| protected async _downloadJpeg(): Promise<void> { | |||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.jpeg' | |||
| return this.downloadSnapshot(undefined, {mimeType: 'image/jpeg'}) | |||
| } | |||
| @uiButton('Download .webp') | |||
| protected async _downloadWebp(): Promise<void> { | |||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.webp' | |||
| return this.downloadSnapshot(undefined, {mimeType: 'image/webp'}) | |||
| } | |||
| } | |||
| /** | |||
| * @deprecated - use {@link CanvasSnapshotPlugin} | |||
| */ | |||
| export class CanvasSnipperPlugin extends CanvasSnapshotPlugin { | |||
| static readonly PluginType: any = 'CanvasSnipper' | |||
| constructor() { | |||
| super() | |||
| console.warn('CanvasSnipperPlugin is deprecated, use CanvasSnapshotPlugin') | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| import {AViewerPluginSync} from '../../viewer' | |||
| import {downloadBlob} from 'ts-browser-helpers' | |||
| export class FileTransferPlugin extends AViewerPluginSync<'transferFile'> { | |||
| enabled = true | |||
| static readonly PluginType = 'FileTransferPlugin' | |||
| toJSON: any = undefined | |||
| async exportFile(file: File|Blob, name?: string) { | |||
| name = name || (file as File).name || 'file_export' | |||
| this.dispatchEvent({type: 'transferFile', path: name, state: 'exporting'}) | |||
| await this.actions.exportFile(file, name, ({state, progress})=>{ | |||
| this.dispatchEvent({type: 'transferFile', path: name, state: state ?? 'exporting', progress}) | |||
| }) | |||
| this.dispatchEvent({type: 'transferFile', path: name, state: 'done'}) | |||
| } | |||
| readonly defaultActions = { | |||
| exportFile: async(blob: Blob, name: string, _onProgress?: (d: {state?: string, progress?: number})=>void)=>{ | |||
| downloadBlob(blob, name) | |||
| }, | |||
| } | |||
| actions = {...this.defaultActions} | |||
| } | |||
| @@ -0,0 +1,197 @@ | |||
| import {onChange, serialize} from 'ts-browser-helpers' | |||
| import { | |||
| BasicDepthPacking, | |||
| Color, | |||
| Euler, | |||
| LinearFilter, | |||
| MeshDepthMaterial, | |||
| NoBlending, | |||
| NoColorSpace, | |||
| OrthographicCamera, | |||
| RGBAFormat, | |||
| UnsignedByteType, | |||
| Vector3, | |||
| WebGLRenderTarget, | |||
| } from 'three' | |||
| import {BaseGroundPlugin} from '../base/BaseGroundPlugin' | |||
| import {GBufferRenderPass} from '../../postprocessing' | |||
| import {ThreeViewer} from '../../viewer' | |||
| import {IRenderTarget} from '../../rendering' | |||
| import {uiFolderContainer, uiSlider, uiToggle} from 'uiconfig.js' | |||
| import {HVBlurHelper} from '../../three/utils/HVBlurHelper' | |||
| import {shaderReplaceString} from '../../utils' | |||
| @uiFolderContainer('Contact Shadow Ground') | |||
| export class ContactShadowGroundPlugin extends BaseGroundPlugin { | |||
| static readonly PluginType = 'ContactShadowGroundPlugin' | |||
| @uiToggle('Contact Shadows') | |||
| @onChange(ContactShadowGroundPlugin.prototype.refresh) | |||
| @serialize() contactShadows = true | |||
| @uiSlider('Shadow Scale', [0, 2]) | |||
| @serialize() | |||
| @onChange(ContactShadowGroundPlugin.prototype._refreshShadowCameraFrustum) | |||
| shadowScale = 1 | |||
| @uiSlider('Shadow Height', [0, 20]) | |||
| @serialize() | |||
| @onChange(ContactShadowGroundPlugin.prototype._refreshShadowCameraFrustum) | |||
| shadowHeight = 5 | |||
| @uiSlider('Blur Amount', [0, 10]) | |||
| @serialize() | |||
| @onChange(ContactShadowGroundPlugin.prototype._setDirty) | |||
| blurAmount = 1 | |||
| shadowCamera = new OrthographicCamera(-1, 1, 1, -1, 0.001, this.shadowHeight) | |||
| private _depthPass?: GBufferRenderPass<'contactShadowGround', WebGLRenderTarget> | |||
| private _blurHelper?: HVBlurHelper | |||
| constructor() { | |||
| super() | |||
| this._refreshShadowCameraFrustum = this._refreshShadowCameraFrustum.bind(this) | |||
| this.refresh = this.refresh.bind(this) | |||
| } | |||
| onAdded(viewer: ThreeViewer): void { | |||
| const target = viewer.renderManager.createTarget<IRenderTarget & WebGLRenderTarget>({ | |||
| type: UnsignedByteType, | |||
| format: RGBAFormat, | |||
| colorSpace: NoColorSpace, | |||
| size: {width: 512, height: 512}, | |||
| generateMipmaps: false, | |||
| depthBuffer: true, | |||
| minFilter: LinearFilter, | |||
| magFilter: LinearFilter, | |||
| // isAntialiased: this._viewer.isAntialiased, | |||
| }) | |||
| target.texture.name = 'groundContactDepthTexture' | |||
| // https://github.com/mrdoob/three.js/blob/master/examples/webgl_shadow_contact.html | |||
| const material = new MeshDepthMaterial({ | |||
| // depthPacking: RGBADepthPacking, // todo | |||
| depthPacking: BasicDepthPacking, | |||
| transparent: false, | |||
| blending: NoBlending, | |||
| }) | |||
| material.onBeforeCompile = (shader) => { | |||
| shader.uniforms.opacity.value = 1. | |||
| shader.fragmentShader = shaderReplaceString(shader.fragmentShader, | |||
| 'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );', | |||
| 'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), 1.0 );', | |||
| // 'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * darkness );', | |||
| ) | |||
| } | |||
| this._depthPass = new GBufferRenderPass('contactShadowGround', target, material, new Color(0, 0, 0), 0) | |||
| this._blurHelper = new HVBlurHelper(viewer) | |||
| super.onAdded(viewer) | |||
| } | |||
| onRemove(viewer: ThreeViewer): void { | |||
| const target = this._depthPass?.target | |||
| if (target) this._viewer?.renderManager.disposeTarget(target) | |||
| this._depthPass?.dispose() | |||
| this._depthPass = undefined | |||
| this._blurHelper?.dispose() | |||
| this._blurHelper = undefined | |||
| return super.onRemove(viewer) | |||
| } | |||
| // todo: dispose target, material, pass and stuff | |||
| protected _postFrame() { | |||
| super._postFrame() | |||
| if (!this._viewer) return | |||
| } | |||
| protected _preRender() { | |||
| super._preRender() | |||
| if (!this._viewer || !this._depthPass || !this._blurHelper) return | |||
| this._depthPass.scene = this._viewer.scene | |||
| this._depthPass.camera = this.shadowCamera | |||
| this._depthPass.render(this._viewer.renderManager.renderer, null) | |||
| const blurTarget = this._viewer.renderManager.getTempTarget<IRenderTarget&WebGLRenderTarget>({ | |||
| type: UnsignedByteType, | |||
| format: RGBAFormat, | |||
| colorSpace: NoColorSpace, | |||
| size: {width: 1024, height: 1024}, | |||
| generateMipmaps: false, | |||
| depthBuffer: false, | |||
| minFilter: LinearFilter, | |||
| magFilter: LinearFilter, | |||
| // isAntialiased: this._viewer.isAntialiased, | |||
| }) | |||
| this._blurHelper.blur(this._depthPass.target.texture, this._depthPass.target, blurTarget, this.blurAmount / 256) | |||
| this._blurHelper.blur(this._depthPass.target.texture, this._depthPass.target, blurTarget, 0.4 * this.blurAmount / 256) | |||
| this._viewer.renderManager.releaseTempTarget(blurTarget) | |||
| } | |||
| protected _refreshTransform() { | |||
| super._refreshTransform() | |||
| if (!this._mesh) return | |||
| if (!this._viewer) return | |||
| this.shadowCamera.position.copy(this._mesh.getWorldPosition(new Vector3())) | |||
| this.shadowCamera.setRotationFromEuler(new Euler(Math.PI / 2., 0, 0)) | |||
| this.shadowCamera.updateMatrixWorld() | |||
| this._refreshShadowCameraFrustum() | |||
| this._mesh.scale.y = -this.size | |||
| } | |||
| private _refreshShadowCameraFrustum() { | |||
| if (!this.shadowCamera) return | |||
| this.shadowCamera.left = -this.size / (2 * this.shadowScale) | |||
| this.shadowCamera.right = this.size / (2 * this.shadowScale) | |||
| this.shadowCamera.top = this.size / (2 * this.shadowScale) | |||
| this.shadowCamera.bottom = -this.size / (2 * this.shadowScale) | |||
| this.shadowCamera.far = this.shadowHeight | |||
| this.shadowCamera.updateProjectionMatrix() | |||
| this._setDirty() | |||
| } | |||
| private _setDirty() { | |||
| this._viewer?.setDirty() | |||
| } | |||
| protected _removeMaterial() { | |||
| if (!this._material) return | |||
| // todo: remove map or render target thats assigned | |||
| super._removeMaterial() | |||
| } | |||
| public refresh(): void { | |||
| if (!this._viewer) return | |||
| // todo: shadow enabled check | |||
| super.refresh() | |||
| } | |||
| protected _refreshMaterial(): boolean { | |||
| if (!this._viewer) return false | |||
| const isNewMaterial = super._refreshMaterial() | |||
| if (!this._material) return isNewMaterial | |||
| this._material.alphaMap = this._depthPass?.target.texture || null | |||
| if (isNewMaterial) { | |||
| this._material.roughness = 1 | |||
| this._material.metalness = 0 | |||
| this._material.color.set(0x111111) | |||
| this._material.transparent = true | |||
| this._material.userData.ssreflDisabled = true // todo: unset this in remove material. | |||
| this._material.userData.ssreflNonPhysical = false | |||
| // this._material.materialObject.userData.inverseAlphaMap = false // this must be false, if getting inverted colors, check clear color of gbuffer render pass. | |||
| } | |||
| return isNewMaterial | |||
| } | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| // base | |||
| export {PipelinePassPlugin} from './base/PipelinePassPlugin' | |||
| export {BaseImporterPlugin} from './base/BaseImporterPlugin' | |||
| export {BaseGroundPlugin} from './base/BaseGroundPlugin' | |||
| // pipeline | |||
| export {ProgressivePlugin} from './pipeline/ProgressivePlugin' | |||
| @@ -34,6 +35,10 @@ export {STLLoadPlugin} from './import/STLLoadPlugin' | |||
| export {KTXLoadPlugin} from './import/KTXLoadPlugin' | |||
| export {KTX2LoadPlugin} from './import/KTX2LoadPlugin' | |||
| // export | |||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin} from './export/CanvasSnapshotPlugin' | |||
| export {FileTransferPlugin} from './export/FileTransferPlugin' | |||
| // postprocessing | |||
| export {AScreenPassExtensionPlugin} from './postprocessing/AScreenPassExtensionPlugin' | |||
| export {TonemapPlugin} from './postprocessing/TonemapPlugin' | |||
| @@ -59,3 +64,4 @@ export {VirtualCamerasPlugin} from './rendering/VirtualCamerasPlugin' | |||
| export {HDRiGroundPlugin} from './extras/HDRiGroundPlugin' | |||
| export {Object3DWidgetsPlugin} from './extras/Object3DWidgetsPlugin' | |||
| export {Object3DGeneratorPlugin} from './extras/Object3DGeneratorPlugin' | |||
| export {ContactShadowGroundPlugin} from './extras/ContactShadowGroundPlugin' | |||
| @@ -0,0 +1,38 @@ | |||
| import {ShaderMaterial, Texture, WebGLRenderTarget} from 'three' | |||
| import {HorizontalBlurShader} from 'three/examples/jsm/shaders/HorizontalBlurShader' | |||
| import {VerticalBlurShader} from 'three/examples/jsm/shaders/VerticalBlurShader' | |||
| import {ThreeViewer} from '../../viewer' | |||
| import {IRenderTarget} from '../../rendering' | |||
| export class HVBlurHelper { | |||
| horizontalBlurMaterial = new ShaderMaterial(HorizontalBlurShader) | |||
| verticalBlurMaterial = new ShaderMaterial(VerticalBlurShader) | |||
| constructor(private _viewer: ThreeViewer) { | |||
| this.horizontalBlurMaterial.depthTest = false | |||
| this.verticalBlurMaterial.depthTest = false | |||
| } | |||
| blur(source: Texture, dest: IRenderTarget & WebGLRenderTarget, tempTarget: IRenderTarget & WebGLRenderTarget, amountMultiplier = 1) { | |||
| this.horizontalBlurMaterial.uniforms.h.value = amountMultiplier | |||
| this.verticalBlurMaterial.uniforms.v.value = amountMultiplier | |||
| this._viewer.renderManager.blit(tempTarget, { | |||
| material: this.horizontalBlurMaterial, | |||
| clear: true, | |||
| source: source, // this._depthPass.target.texture, | |||
| }) | |||
| // this._viewer.renderManager.blit(this._depthPass.target, { | |||
| this._viewer.renderManager.blit(dest, { | |||
| material: this.verticalBlurMaterial, | |||
| clear: true, | |||
| source: tempTarget.texture, | |||
| }) | |||
| } | |||
| dispose() { | |||
| this.horizontalBlurMaterial.dispose() | |||
| this.verticalBlurMaterial.dispose() | |||
| } | |||
| } | |||
| @@ -8,6 +8,7 @@ export {getTextureDataType, textureToCanvas, textureDataToImageData, textureToDa | |||
| export {threeConstMappings} from './const-mappings' | |||
| export {ObjectPicker} from './ObjectPicker' | |||
| export {autoGPUInstanceMeshes} from './gpu-instancing' | |||
| export {HVBlurHelper} from './HVBlurHelper' | |||
| export {ViewHelper2, type GizmoOrientation, type DomPlacement} from './ViewHelper2' | |||
| // export {} from './constants' | |||
| @@ -0,0 +1,143 @@ | |||
| import {now} from 'ts-browser-helpers' | |||
| export interface CanvasSnapshotRect { | |||
| height: number; | |||
| width: number; | |||
| x: number; | |||
| y: number; | |||
| /** | |||
| * Use if canvas.width !== canvas.clientWidth or height and rect is based on client rect | |||
| */ | |||
| assumeClientRect?: boolean; | |||
| } | |||
| export interface CanvasSnapshotOptions { | |||
| getDataUrl?: boolean, | |||
| mimeType?: string, | |||
| quality?: number, // between 0 and 1, only for image/jpeg or image/webp | |||
| rect?: CanvasSnapshotRect, | |||
| scale?: number, | |||
| timeout?: number, // in ms, if not specified, will be based on progressive rendering or 200ms | |||
| displayPixelRatio?: number, | |||
| } | |||
| export class CanvasSnapshot { | |||
| public static Debug = false | |||
| public static async GetClonedCanvas( | |||
| canvas: HTMLCanvasElement, | |||
| { | |||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false}, | |||
| displayPixelRatio = 1, | |||
| scale = 1, | |||
| }: CanvasSnapshotOptions): Promise<HTMLCanvasElement> { | |||
| // return canvas.toDataURL(mimeType); | |||
| // in Safari, images are flipped when premultipliedAlpha is true in canvas, so it works with 2d context, see: https://github.com/pixijs/pixi.js/blob/dev/packages/extract/src/Extract.ts and https://github.com/pixijs/pixi.js/issues/2951 | |||
| const destCanvas = document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas') as HTMLCanvasElement | |||
| destCanvas.width = rect.width * scale * displayPixelRatio | |||
| destCanvas.height = rect.height * scale * displayPixelRatio | |||
| // const iRect = {...rect} | |||
| if (rect.assumeClientRect) { | |||
| rect.x *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||
| rect.y *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||
| rect.width *= canvas.width / (displayPixelRatio * canvas.clientWidth) | |||
| rect.height *= canvas.height / (displayPixelRatio * canvas.clientHeight) | |||
| } | |||
| const destCtx = destCanvas.getContext('2d') | |||
| if (!destCtx) { | |||
| console.error('snapshot: cannot create context') | |||
| return destCanvas | |||
| } | |||
| // console.log(canvas.style.background) | |||
| const background = canvas.style.background || canvas.parentElement?.style.background || '' | |||
| if (background.includes('url')) { | |||
| const url = /url\("(.*)"\)/ig.exec(background)?.[1] | |||
| if (url) { | |||
| const img = new Image() | |||
| img.src = url | |||
| await new Promise<void>((resolve, reject) => { | |||
| img.onload = () => resolve() | |||
| img.onerror = () => reject() | |||
| if (img.complete) resolve() | |||
| }) | |||
| destCtx.drawImage(img, | |||
| img.width * rect.x * displayPixelRatio / canvas.width, img.height * rect.y * displayPixelRatio / canvas.height, | |||
| img.width * rect.width * displayPixelRatio / canvas.width, img.height * rect.height * displayPixelRatio / canvas.height, | |||
| 0, 0, | |||
| destCanvas.width, | |||
| destCanvas.height, | |||
| ) | |||
| } | |||
| } else { | |||
| destCtx.fillStyle = canvas.style.background || canvas.parentElement?.style.backgroundColor || '#00000000' | |||
| destCtx.fillRect(0, 0, destCanvas.width, destCanvas.height) | |||
| } | |||
| destCtx?.drawImage( | |||
| canvas, | |||
| rect.x * displayPixelRatio, rect.y * displayPixelRatio, rect.width * displayPixelRatio, rect.height * displayPixelRatio, | |||
| 0, 0, destCanvas.width, destCanvas.height, | |||
| ) | |||
| const debug = this.Debug | |||
| if (debug) { | |||
| // console.log( | |||
| // destCanvas, | |||
| // ) | |||
| document.body.appendChild(destCanvas) | |||
| destCanvas.style.position = 'absolute' | |||
| destCanvas.style.top = '0' | |||
| destCanvas.style.left = '0' | |||
| destCanvas.style.borderWidth = '2px' | |||
| destCanvas.style.borderColor = '#ff00ff' | |||
| setTimeout(() => destCanvas.remove(), 5000) | |||
| } | |||
| return destCanvas | |||
| } | |||
| public static async GetDataUrl(canvas: HTMLCanvasElement, {mimeType = 'image/png', quality, ...options}: CanvasSnapshotOptions): Promise<string> { | |||
| const clone = await this.GetClonedCanvas(canvas, options) | |||
| const url = clone.toDataURL(mimeType, quality) | |||
| if (!this.Debug) clone.remove() | |||
| return url | |||
| } | |||
| // set one of canvas or context to draw in. | |||
| public static async GetImage(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<HTMLImageElement> { | |||
| const imgUrl = await this.GetDataUrl(canvas, options) | |||
| return new Promise<HTMLImageElement>((resolve, reject) => { | |||
| const img = new Image() | |||
| img.onload = () => resolve(img) | |||
| img.onerror = () => reject() | |||
| img.src = imgUrl | |||
| }) | |||
| } | |||
| public static async GetBlob(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<Blob> { | |||
| const clone = await this.GetClonedCanvas(canvas, options) | |||
| const blob = await new Promise<Blob>((resolve, reject) => { | |||
| clone.toBlob((b) => { | |||
| if (b) resolve(b) | |||
| else reject() | |||
| }, options.mimeType ?? 'image/png', options.quality) | |||
| }) | |||
| if (!this.Debug) clone.remove() | |||
| return blob | |||
| } | |||
| public static async GetFile(canvas: HTMLCanvasElement, filename = 'image.png', options: CanvasSnapshotOptions = {}): Promise<File|string> { | |||
| return options.getDataUrl ? await this.GetDataUrl(canvas, options) : new File([await this.GetBlob(canvas, options)], filename, { | |||
| type: options.mimeType ?? 'image/png', | |||
| lastModified: now(), | |||
| }) | |||
| } | |||
| } | |||
| @@ -10,7 +10,7 @@ import { | |||
| Vector2, | |||
| Vector3, | |||
| } from 'three' | |||
| import {Class, createCanvasElement, onChange, serialize, ValOrArr} from 'ts-browser-helpers' | |||
| import {Class, createCanvasElement, downloadBlob, onChange, serialize, ValOrArr} from 'ts-browser-helpers' | |||
| import {TViewerScreenShader} from '../postprocessing' | |||
| import { | |||
| AddObjectOptions, | |||
| @@ -52,7 +52,8 @@ import { | |||
| import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin' | |||
| import {uiConfig, UiObjectConfig, uiPanelContainer} from 'uiconfig.js' | |||
| import {IRenderTarget} from '../rendering' | |||
| import type {CameraViewPlugin, ProgressivePlugin} from '../plugins' | |||
| import type {CanvasSnapshotPlugin, FileTransferPlugin} from '../plugins' | |||
| import {CameraViewPlugin, ProgressivePlugin} from '../plugins' | |||
| // noinspection ES6PreferShortImport | |||
| import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin' | |||
| // noinspection ES6PreferShortImport | |||
| @@ -131,9 +132,9 @@ export interface ThreeViewerOptions { | |||
| * Render scale, 1 = full resolution, 0.5 = half resolution, 2 = double resolution. | |||
| * Same as pixelRatio in three.js | |||
| * Can be set to `window.devicePixelRatio` to render at device resolution in browsers. | |||
| * An optimal value is `Math.min(2, window.devicePixelRatio)` to prevent issues on mobile. | |||
| * An optimal value is `Math.min(2, window.devicePixelRatio)` to prevent issues on mobile. This is set when 'auto' is passed. | |||
| */ | |||
| renderScale?: number | |||
| renderScale?: number | 'auto' | |||
| debug?: boolean | |||
| @@ -408,7 +409,9 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| zPrepass: options.zPrepass ?? options.useGBufferDepth ?? false, | |||
| depthBuffer: !(options.zPrepass ?? options.useGBufferDepth ?? false), | |||
| screenShader: options.screenShader, | |||
| renderScale: options.renderScale, | |||
| renderScale: typeof options.renderScale === 'string' ? options.renderScale === 'auto' ? | |||
| Math.min(2, window.devicePixelRatio) : parseFloat(options.renderScale) : | |||
| options.renderScale, | |||
| }) | |||
| this.renderManager.addEventListener('animationLoop', this._animationLoop as any) | |||
| this.renderManager.addEventListener('resize', ()=> this._scene.mainCamera.refreshAspect()) | |||
| @@ -516,6 +519,10 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| } | |||
| async getScreenshotBlob({mimeType = 'image/jpeg', quality = 90} = {}): Promise<Blob | null | undefined> { | |||
| const plugin = this.getPlugin<CanvasSnapshotPlugin>('CanvasSnapshotPlugin') | |||
| if (plugin) { | |||
| return plugin.getFile('snapshot.' + mimeType.split('/')[1], {mimeType, quality, waitForProgressive: true}) | |||
| } | |||
| const blobPromise = async()=> new Promise<Blob|null>((resolve) => { | |||
| this._canvas.toBlob((blob) => { | |||
| resolve(blob) | |||
| @@ -530,7 +537,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| }) | |||
| } | |||
| async getScreenshotDataUrl({mimeType = 'image/jpeg', quality = 90} = {}): Promise<string | null | undefined> { | |||
| async getScreenshotDataUrl({mimeType = 'image/jpeg', quality = 0.9} = {}): Promise<string | null | undefined> { | |||
| if (!this.renderEnabled) return this._canvas.toDataURL(mimeType, quality) | |||
| return await this.doOnce('postFrame', () => this._canvas.toDataURL(mimeType, quality)) | |||
| } | |||
| @@ -1079,6 +1086,22 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| super.dispatchEvent({...event, type: '*', eType: event.type}) | |||
| } | |||
| /** | |||
| * 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. | |||
| * @param blob - The blob or file to export/download | |||
| * @param name | |||
| */ | |||
| async exportBlob(blob: Blob|File, name?: string) { | |||
| const tr = this.getPlugin<FileTransferPlugin>('FileTransferPlugin') | |||
| name = name ?? (blob as File).name ?? 'file' | |||
| if (!tr) { | |||
| downloadBlob(blob, name) | |||
| return | |||
| } | |||
| await tr.exportFile(blob, name) | |||
| } | |||
| private _setActiveCameraView(event: any = {}): void { | |||
| if (event.type === 'setView') { | |||
| if (!event.camera) { | |||