| @@ -0,0 +1,36 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>Asset Exporter 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,39 @@ | |||
| import { | |||
| _testFinish, | |||
| AssetExporterPlugin, | |||
| IObject3D, | |||
| LoadingScreenPlugin, | |||
| SceneUiConfigPlugin, | |||
| ThreeViewer, | |||
| } from 'threepipe' | |||
| import {TweakpaneUiPlugin} from '@threepipe/plugin-tweakpane' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| plugins: [LoadingScreenPlugin, AssetExporterPlugin, SceneUiConfigPlugin], | |||
| }) | |||
| const ui = viewer.addPluginSync(new TweakpaneUiPlugin(true)) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr', { | |||
| setBackground: true, | |||
| }) | |||
| const result = await viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf', { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }) | |||
| ui.setupPluginUi(AssetExporterPlugin, {expanded: true}) | |||
| ui.setupPluginUi(SceneUiConfigPlugin) | |||
| const model = result?.getObjectByName('node_damagedHelmet_-6514') | |||
| const config = model?.uiConfig | |||
| if (config) ui.appendChild(config) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -5,6 +5,8 @@ const viewer = new ThreeViewer({canvas: document.getElementById('mcanvas') as HT | |||
| async function init() { | |||
| // Note: see asset-exporter-plugin example as well | |||
| // load obj + mtl | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| const helmet = await viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf', { | |||
| @@ -13,6 +13,8 @@ async function init() { | |||
| autoSetBackground: true, | |||
| }, | |||
| }, | |||
| // add optional plugins to load gltf with extensions like meshopt_compression, ktx2 etc | |||
| // plugins: [GLTFMeshOptDecodePlugin, KTX2LoadPlugin, GLTFKHRMaterialVariantsPlugin, ...], | |||
| }) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr', { | |||
| @@ -0,0 +1,35 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <title>GLTF MeshOpt Decode Plugin (EXT_meshopt_compression)</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,39 @@ | |||
| import { | |||
| _testFinish, | |||
| GLTFMeshOptDecodePlugin, | |||
| IObject3D, | |||
| KTX2LoadPlugin, | |||
| LoadingScreenPlugin, | |||
| ThreeViewer, | |||
| } from 'threepipe' | |||
| async function init() { | |||
| const viewer = new ThreeViewer({ | |||
| canvas: document.getElementById('mcanvas') as HTMLCanvasElement, | |||
| msaa: true, | |||
| dropzone: { | |||
| addOptions: { | |||
| disposeSceneObjects: true, | |||
| }, | |||
| }, | |||
| plugins: [LoadingScreenPlugin, GLTFMeshOptDecodePlugin, KTX2LoadPlugin], | |||
| }) | |||
| await viewer.setEnvironmentMap('https://threejs.org/examples/textures/equirectangular/venice_sunset_1k.hdr') | |||
| const options = { | |||
| autoCenter: true, | |||
| autoScale: true, | |||
| }; | |||
| (await Promise.all([ | |||
| viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/coffeemat.glb', options), | |||
| viewer.load<IObject3D>('https://threejs.org/examples/models/gltf/facecap.glb', options), | |||
| ])).forEach((result, i) => { | |||
| if (!result) return | |||
| result.position.x = i * 2 - 1 | |||
| }) | |||
| } | |||
| init().finally(_testFinish) | |||
| @@ -139,11 +139,11 @@ export class GLTFLoader2 extends GLTFLoader implements ILoader<GLTF, Object3D|un | |||
| } | |||
| const needsMeshOpt = parser.json?.extensionsUsed?.includes?.('EXT_meshopt_compression') | |||
| if (needsMeshOpt) { | |||
| if ((window as any).MeshoptDecoder) { // added by the plugin or by the user | |||
| this.setMeshoptDecoder((window as any).MeshoptDecoder) | |||
| parser.options.meshoptDecoder = (window as any).MeshoptDecoder as any | |||
| if (window.MeshoptDecoder) { // added by the plugin or by the user | |||
| this.setMeshoptDecoder(window.MeshoptDecoder) | |||
| parser.options.meshoptDecoder = window.MeshoptDecoder | |||
| } else { | |||
| console.error('Add GLTFMeshOptPlugin to viewer to enable EXT_meshopt_compression decode') | |||
| console.error('Add GLTFMeshOptPlugin(and initialize it) to viewer to enable EXT_meshopt_compression decode') | |||
| } | |||
| } | |||
| const needsBasisU = parser.json?.extensionsUsed?.includes?.('KHR_texture_basisu') | |||
| @@ -0,0 +1,209 @@ | |||
| import {serialize} from 'ts-browser-helpers' | |||
| import {BlobExt, ExportFileOptions} from '../../assetmanager' | |||
| import {AViewerPluginSync, ThreeViewer} from '../../viewer' | |||
| import {UiObjectConfig} from 'uiconfig.js' | |||
| import {PickingPlugin} from '../interaction/PickingPlugin' | |||
| // export enum EncoderMethod { | |||
| // EDGEBREAKER = 1, | |||
| // SEQUENTIAL = 0 | |||
| // } | |||
| export interface ExportAssetOptions extends ExportFileOptions { | |||
| convertMeshToIndexed?: boolean // convert mesh to indexed geometry | |||
| name?: string | |||
| compress?: boolean, | |||
| } | |||
| // todo deprecate the plugin and add functionality to AssetManager maybe | |||
| /** | |||
| * Asset Exporter Plugin | |||
| * Provides options and methods to export the scene, object GLB or Viewer Config. | |||
| * All the functionality is available in the viewer directly, this provides only a ui-config and maintains state of the options. | |||
| */ | |||
| export class AssetExporterPlugin extends AViewerPluginSync<''> { | |||
| public static readonly PluginType = 'AssetExporterPlugin' | |||
| enabled = true | |||
| constructor() { | |||
| super() | |||
| this.exportScene = this.exportScene.bind(this) | |||
| } | |||
| onAdded(viewer: ThreeViewer) { | |||
| super.onAdded(viewer) | |||
| // todo Convert all non-indexed geometries to indexed geometries, because DRACO compression requires indexed geometry. | |||
| // this.exporter.processors.add('model', { | |||
| // forAssetType: 'model', | |||
| // processAsync: async(obj: IObject3D, options) => { | |||
| // if (options.convertMeshToIndexed) | |||
| // obj?.traverse((o: IObject3D)=>{ | |||
| // if (!o.geometry) return | |||
| // if (o.geometry.attributes.index) return | |||
| // o.geometry = toIndexedGeometry(o.geometry) | |||
| // }) | |||
| // return obj | |||
| // }, | |||
| // }) | |||
| } | |||
| onRemove(viewer: ThreeViewer) { | |||
| return super.onRemove(viewer) | |||
| } | |||
| @serialize() exportOptions: ExportAssetOptions = { | |||
| name: 'scene', | |||
| viewerConfig: true, | |||
| encodeUint16Rgbe: false, | |||
| convertMeshToIndexed: false, | |||
| embedUrlImagePreviews: false, | |||
| embedUrlImages: false, | |||
| // compress: false, | |||
| // dracoOptions: { | |||
| // encodeSpeed: 5, | |||
| // method: EncoderMethod.EDGEBREAKER, | |||
| // quantizationVolume: 'mesh', | |||
| // quantizationBits: { | |||
| // ['POSITION']: 14, | |||
| // ['NORMAL']: 10, | |||
| // ['COLOR']: 8, | |||
| // ['TEX_COORD']: 12, | |||
| // ['GENERIC']: 12, | |||
| // }, | |||
| // } as EncoderOptions, | |||
| encrypt: false, | |||
| encryptKey: '', | |||
| } | |||
| async exportScene(options?: ExportAssetOptions): Promise<BlobExt | undefined> { | |||
| return this._viewer?.assetManager.exporter?.exportObject(this._viewer?.scene.modelRoot, options || {...this.exportOptions}) | |||
| } | |||
| async downloadSceneGlb() { | |||
| const blob = await this.exportScene(this.exportOptions) | |||
| if (blob) await this._viewer?.exportBlob(blob, this.exportOptions.name + '.' + blob.ext) | |||
| } | |||
| async exportSelected(options?: ExportAssetOptions, download = true) { | |||
| const selected = this._viewer?.getPlugin<PickingPlugin>('PickingPlugin')?.getSelectedObject() as any | |||
| if (!selected) { | |||
| alert('Nothing selected') | |||
| return | |||
| } | |||
| const blob = await this._viewer!.assetManager.exporter.exportObject(selected, options ?? this.exportOptions) | |||
| if (blob && download) await this._viewer?.exportBlob(blob, 'object.' + blob.ext) | |||
| return blob | |||
| } | |||
| uiConfig: UiObjectConfig = { | |||
| type: 'folder', | |||
| label: 'Asset Export', | |||
| children: [ | |||
| { | |||
| type: 'input', | |||
| property: [this.exportOptions, 'name'], | |||
| }, | |||
| { | |||
| type: 'folder', | |||
| label: 'GLB Export', | |||
| children: [ | |||
| // compress ? { | |||
| // type: 'checkbox', | |||
| // label: 'DRACO Compress', | |||
| // property: [this.exportOptions, 'compress'], | |||
| // onChange: ()=>this.uiConfig.uiRefresh?.(true), | |||
| // } : {}, | |||
| // compress && this.exportOptions.dracoOptions ? { | |||
| // type: 'folder', | |||
| // hidden: ()=>!this.exportOptions.compress, | |||
| // label: 'DRACO Options', | |||
| // children: [ | |||
| // { | |||
| // type: 'slider', | |||
| // label: 'Encode Speed', | |||
| // bounds: [1, 10], | |||
| // property: [this.exportOptions.dracoOptions, 'encodeSpeed'], | |||
| // }, | |||
| // { | |||
| // type: 'dropdown', | |||
| // label: 'Encoder Method', | |||
| // property: [this.exportOptions.dracoOptions, 'method'], | |||
| // children: Object.entries(EncoderMethod).map(([k, v]) => ({label: k, value: v})), | |||
| // }, | |||
| // { | |||
| // type: 'dropdown', | |||
| // label: 'Quantization Volume', | |||
| // property: [this.exportOptions.dracoOptions, 'quantizationVolume'], | |||
| // children: ['mesh', 'scene', 'bbox'].map(v => ({label: v})), | |||
| // }, | |||
| // { | |||
| // type: 'folder', | |||
| // label: 'Quantization Bits', | |||
| // children: Object.keys(this.exportOptions.dracoOptions.quantizationBits!).map(k => ({ | |||
| // type: 'slider', | |||
| // label: k, | |||
| // bounds: [1, 16], | |||
| // stepSize: 1, | |||
| // property: [this.exportOptions.dracoOptions.quantizationBits, k], | |||
| // })), | |||
| // }, | |||
| // ], | |||
| // } : {}, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Viewer Config (All Settings)', | |||
| property: [this.exportOptions, 'viewerConfig'], | |||
| onChange: ()=>this.uiConfig.uiRefresh?.(true), | |||
| }, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Embed Image Previews', | |||
| property: [this.exportOptions, 'embedUrlImagePreviews'], | |||
| }, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Encrypt', | |||
| property: [this.exportOptions, 'encrypt'], | |||
| onChange: ()=>this.uiConfig.uiRefresh?.(true), | |||
| }, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Encrypt Password', | |||
| hidden: ()=>!this.exportOptions.encrypt, | |||
| property: [this.exportOptions, 'encryptKey'], | |||
| }, | |||
| { | |||
| type: 'checkbox', | |||
| label: 'Compress hdr env maps', | |||
| hidden: ()=>!this.exportOptions.viewerConfig, | |||
| property: [this.exportOptions, 'encodeUint16Rgbe'], | |||
| }, | |||
| // { // todo | |||
| // type: 'checkbox', | |||
| // label: 'Convert to indexed', | |||
| // property: [this.exportOptions, 'convertMeshToIndexed'], | |||
| // }, | |||
| { | |||
| type: 'button', | |||
| label: 'Export GLB', | |||
| property: [this, 'downloadSceneGlb'], | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Export Viewer Config', | |||
| value: async()=>{ | |||
| const blob = new Blob([JSON.stringify(this._viewer?.exportConfig(false), null, 2)], {type: 'application/json'}) | |||
| if (blob) await this._viewer?.exportBlob(blob, this.exportOptions.name + '.' + ThreeViewer.ConfigTypeSlug) | |||
| }, | |||
| }, | |||
| { | |||
| type: 'button', | |||
| label: 'Export Selected', | |||
| hidden: ()=>!this._viewer?.getPlugin<PickingPlugin>('PickingPlugin'), | |||
| value: async()=>this.exportSelected(this.exportOptions, true), | |||
| }, | |||
| ], | |||
| } | |||
| } | |||
| @@ -0,0 +1,70 @@ | |||
| import {IViewerPluginSync} from '../../viewer' | |||
| import {SimpleEventDispatcher} from 'ts-browser-helpers' | |||
| /** | |||
| * Loads the MeshOpt Decoder module from [meshoptimizer](https://github.com/zeux/meshoptimizer) library at runtime from a customisable cdn url. | |||
| * The loaded module is set in window.MeshoptDecoder and then used by {@link GLTFLoader2} to decode files using [EXT_meshopt_compression](https://github.com/KhronosGroup/glTF/blob/main/extensions/2.0/Vendor/EXT_meshopt_compression/README.md) extension | |||
| * | |||
| * The plugin name includes GLTF, but its not really GLTF specific, it can be used to decode any meshopt compressed files. | |||
| */ | |||
| export class GLTFMeshOptDecodePlugin extends SimpleEventDispatcher<''> implements IViewerPluginSync { | |||
| declare ['constructor']: typeof GLTFMeshOptDecodePlugin | |||
| public static readonly PluginType = 'GLTFMeshOptDecodePlugin' | |||
| enabled = true | |||
| toJSON: any = undefined | |||
| constructor(initialize = true, public readonly rootNode = document.head) { | |||
| super() | |||
| // todo: check if compatible? | |||
| if (initialize) this.initialize() | |||
| } | |||
| get initialized() { | |||
| return !!window.MeshoptDecoder | |||
| } | |||
| // static DECODER_URL = 'https://cdn.jsdelivr.net/gh/zeux/meshoptimizer@master/js/meshopt_decoder.module.js' | |||
| static DECODER_URL = 'https://unpkg.com/meshoptimizer@0.20.0/meshopt_decoder.module.js' | |||
| protected _script?: HTMLScriptElement | |||
| protected _initializing?: Promise<void> = undefined | |||
| async initialize() { | |||
| if (this.initialized) return | |||
| if (this._initializing) return await this._initializing | |||
| const s = document.createElement('script') | |||
| s.type = 'module' | |||
| const ev = Math.random().toString(36).substring(7) | |||
| s.innerHTML = ` | |||
| import { MeshoptDecoder } from '${GLTFMeshOptDecodePlugin.DECODER_URL}'; | |||
| window.MeshoptDecoder = MeshoptDecoder; // setting it before ready as GLTFLoader supports it. | |||
| MeshoptDecoder.ready.then(() => { | |||
| window.dispatchEvent(new CustomEvent('${ev}')) | |||
| }); | |||
| ` | |||
| this._initializing = new Promise<void>((res) => { | |||
| window.addEventListener(ev, ()=>res(), {once: true}) | |||
| this.rootNode.appendChild(s) | |||
| this._script = s | |||
| }) | |||
| return await this._initializing | |||
| } | |||
| dispose() { | |||
| if (this._script) { | |||
| this._script.remove() | |||
| delete window.MeshoptDecoder | |||
| } | |||
| this._script = undefined | |||
| } | |||
| onAdded(): void { return } | |||
| onRemove(): void { return } | |||
| } | |||
| declare global{ | |||
| interface Window{ | |||
| MeshoptDecoder?: any | |||
| } | |||
| } | |||
| @@ -43,8 +43,10 @@ export {PLYLoadPlugin} from './import/PLYLoadPlugin' | |||
| export {STLLoadPlugin} from './import/STLLoadPlugin' | |||
| export {KTXLoadPlugin} from './import/KTXLoadPlugin' | |||
| export {KTX2LoadPlugin} from './import/KTX2LoadPlugin' | |||
| export {GLTFMeshOptDecodePlugin} from './import/GLTFMeshOptDecodePlugin' | |||
| // export | |||
| export {AssetExporterPlugin} from './export/AssetExporterPlugin' | |||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin} from './export/CanvasSnapshotPlugin' | |||
| export {FileTransferPlugin} from './export/FileTransferPlugin' | |||
| @@ -54,8 +54,13 @@ import { | |||
| import {IViewerPlugin, IViewerPluginSync} from './IViewerPlugin' | |||
| import {uiConfig, UiObjectConfig, uiPanelContainer} from 'uiconfig.js' | |||
| import {IRenderTarget} from '../rendering' | |||
| import type {CanvasSnapshotPlugin, FileTransferPlugin} from '../plugins' | |||
| import {CameraViewPlugin, ProgressivePlugin} from '../plugins' | |||
| import { | |||
| AssetExporterPlugin, | |||
| CameraViewPlugin, | |||
| CanvasSnapshotPlugin, | |||
| FileTransferPlugin, | |||
| ProgressivePlugin, | |||
| } from '../plugins' | |||
| // noinspection ES6PreferShortImport | |||
| import {DropzonePlugin, DropzonePluginOptions} from '../plugins/interaction/DropzonePlugin' | |||
| // noinspection ES6PreferShortImport | |||
| @@ -515,7 +520,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| * @param options | |||
| */ | |||
| async export(obj?: IObject3D|IMaterial|ITexture|IRenderTarget|IViewerPlugin|(typeof this), options?: ExportFileOptions) { | |||
| if (!obj) obj = this._scene // this will export the glb with the scene and viewer config | |||
| if (!obj) obj = this._scene.modelRoot // this will export the glb with the scene and viewer config | |||
| if ((<typeof this>obj).type === this.type) return jsonToBlob((<typeof this>obj).exportConfig()) | |||
| if ((<IViewerPlugin>obj).constructor?.PluginType) return jsonToBlob(this.exportPluginConfig(<IViewerPlugin>obj)) | |||
| return await this.assetManager.exporter.exportObject(<IObject3D|IMaterial|ITexture|IRenderTarget>obj, options) | |||
| @@ -524,11 +529,20 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| /** | |||
| * Export the scene to a file (default: glb with viewer config) and return a blob | |||
| * @param options | |||
| * @param useExporterPlugin - uses the {@link AssetExporterPlugin} if available. This is useful to use the options configured by the user in the plugin. | |||
| */ | |||
| async exportScene(options?: ExportFileOptions): Promise<BlobExt | undefined> { | |||
| async exportScene(options?: ExportFileOptions, useExporterPlugin = true): Promise<BlobExt | undefined> { | |||
| const exporter = useExporterPlugin ? this.getPlugin<AssetExporterPlugin>('AssetExporterPlugin') : undefined | |||
| if (exporter) return exporter.exportScene(options) | |||
| return this.assetManager.exporter.exportObject(this._scene.modelRoot, options) | |||
| } | |||
| /** | |||
| * Returns a blob with the screenshot of the canvas. | |||
| * If {@link CanvasSnapshotPlugin} is added, it will be used, otherwise canvas.toBlob will be used directly. | |||
| * @param mimeType default image/jpeg | |||
| * @param quality between 0 and 100 | |||
| */ | |||
| async getScreenshotBlob({mimeType = 'image/jpeg', quality = 90} = {}): Promise<Blob | null | undefined> { | |||
| const plugin = this.getPlugin<CanvasSnapshotPlugin>('CanvasSnapshotPlugin') | |||
| if (plugin) { | |||
| @@ -1077,7 +1091,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| /** | |||
| * Serialize all the viewer and plugin settings and versions. | |||
| * @param binary - Indicate that the output will be converted and saved as binary data. (default: true) | |||
| * @param pluginFilter - List of PluginType to include. If empty, no plugins will be serialized. If undefined, all plugins will be serialized. | |||
| * @param pluginFilter - List of PluginType to include. If empty, no plugins will be serialized. If undefined/not-passed, all plugins will be serialized. | |||
| * @returns {any} - Serializable JSON object. | |||
| */ | |||
| toJSON(binary = true, pluginFilter?: string[]): ISerializedViewerConfig { | |||
| @@ -1202,7 +1216,7 @@ export class ThreeViewer extends EventDispatcher<IViewerEvent, IViewerEventTypes | |||
| /** | |||
| * 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. | |||
| * {@link FileTransferPlugin} can be configured by other plugins to export the blob to a specific location like local file system, cloud storage, etc. | |||
| * @param blob - The blob or file to export/download | |||
| * @param name - name of the file, if not provided, the name of the file is used if it's a file. | |||
| */ | |||