| @@ -1,11 +1,18 @@ | |||
| import {_testFinish, CanvasSnapshotPlugin, isWebpExportSupported, LoadingScreenPlugin, ThreeViewer} from 'threepipe' | |||
| import { | |||
| _testFinish, | |||
| CanvasSnapshotPlugin, | |||
| isWebpExportSupported, | |||
| LoadingScreenPlugin, | |||
| SSAAPlugin, | |||
| 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', | |||
| plugins: [LoadingScreenPlugin], | |||
| plugins: [LoadingScreenPlugin, SSAAPlugin], | |||
| }) | |||
| async function init() { | |||
| @@ -24,6 +31,8 @@ async function init() { | |||
| 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 | |||
| waitForProgressive: true, | |||
| progressiveFrames: 64, // wait for 64 frames of ProgressivePlugin/SSAA before exporting | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| @@ -73,6 +82,18 @@ async function init() { | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| ['Download 3x3 Tiles (png zip)']: async(btn: HTMLButtonElement) => { | |||
| btn.disabled = true | |||
| await snapshotPlugin.downloadSnapshot('snapshot', { | |||
| mimeType: 'image/png', // 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: 4, // render scale | |||
| tileRows: 3, | |||
| tileColumns: 3, | |||
| }) | |||
| btn.disabled = false | |||
| }, | |||
| }) | |||
| } | |||
| @@ -1,8 +1,37 @@ | |||
| import {serialize, timeout} from 'ts-browser-helpers' | |||
| import {AViewerPluginSync} from '../../viewer' | |||
| import {uiButton, uiConfig, uiFolderContainer, uiInput} from 'uiconfig.js' | |||
| import {uiButton, uiConfig, uiFolderContainer, uiInput, uiVector} from 'uiconfig.js' | |||
| import {CanvasSnapshot, CanvasSnapshotOptions} from '../../utils/canvas-snapshot' | |||
| import {ProgressivePlugin} from '../pipeline/ProgressivePlugin' | |||
| import {Zippable, zipSync} from 'three/examples/jsm/libs/fflate.module.js' | |||
| import {Vector4} from 'three' | |||
| export interface CanvasSnapshotPluginOptions extends CanvasSnapshotOptions{ | |||
| /** | |||
| * If true, will wait for progressive rendering(requires {@link ProgressivePlugin}) to finish before taking snapshot | |||
| * @default true | |||
| */ | |||
| waitForProgressive?: boolean | |||
| /** | |||
| * Number of progressive frames to wait for before taking snapshot | |||
| @default 64 or {@link ProgressivePlugin.maxFrameCount}, whichever is higher | |||
| */ | |||
| progressiveFrames?: number | |||
| /** | |||
| * Time in ms to wait before taking the snapshot. | |||
| * This timeout is applied before `waitForProgressive` if both are specified. | |||
| */ | |||
| timeout?: number, | |||
| /** | |||
| * Number of tile rows to split the image into | |||
| * @default 1 | |||
| */ | |||
| tileRows?: number | |||
| /** | |||
| * Number of tile columns to split the image into | |||
| */ | |||
| tileColumns?: number | |||
| } | |||
| @uiFolderContainer('Canvas Snapshot (Image Export)') | |||
| export class CanvasSnapshotPlugin extends AViewerPluginSync { | |||
| @@ -19,7 +48,7 @@ export class CanvasSnapshotPlugin extends AViewerPluginSync { | |||
| * @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> { | |||
| async getFile(filename?: string, options: CanvasSnapshotPluginOptions = {waitForProgressive: true}): Promise<File|undefined> { | |||
| return await this._getFile(filename || this.filename, {...options, getDataUrl: false}) as File | |||
| } | |||
| @@ -27,88 +56,173 @@ export class CanvasSnapshotPlugin extends AViewerPluginSync { | |||
| * 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> { | |||
| async getDataUrl(options: CanvasSnapshotPluginOptions = {}): Promise<string> { | |||
| return await this._getFile('', {...options, getDataUrl: true}) as string ?? '' | |||
| } | |||
| private async _getFile(filename: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {}): Promise<File|string|undefined> { | |||
| private async _getFile(filename: string, options: CanvasSnapshotPluginOptions = {}): Promise<File|string|string[]|undefined> { | |||
| await this._viewer?.doOnce('postFrame') | |||
| const viewer = this._viewer | |||
| const canvas = this._viewer?.canvas | |||
| if (!viewer || !canvas) return undefined | |||
| viewer.scene.mainCamera.setInteractions(false, CanvasSnapshotPlugin.PluginType) | |||
| 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() | |||
| let waitForProgressive = options.waitForProgressive ?? !!progressive | |||
| if (waitForProgressive && !progressive) { | |||
| viewer.console.warn('CanvasSnapshotPlugin: ProgressivePlugin required to wait for progressive rendering') | |||
| waitForProgressive = false | |||
| } | |||
| if (options.progressiveFrames && !waitForProgressive) { | |||
| viewer.console.warn('CanvasSnapshotPlugin: waitForProgressive must be true to use progressiveFrames') | |||
| } | |||
| const lastMaxFrames = progressive?.maxFrameCount | |||
| if (waitForProgressive && progressive) { | |||
| progressive.maxFrameCount = Math.max(options.progressiveFrames ?? 64, progressive.maxFrameCount) | |||
| viewer.setDirty() | |||
| await viewer.doOnce('postFrame') | |||
| while (!progressive.isConverged(true)) { | |||
| await viewer.doOnce('postFrame') | |||
| // console.log(`rendering ${ 100 * this._viewer!.renderer.frameCount / progressive.maxFrameCount }%`) | |||
| } | |||
| } else { | |||
| viewer.setDirty() | |||
| await viewer.doOnce('postFrame') | |||
| } | |||
| delete options.displayPixelRatio | |||
| // 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, | |||
| // } | |||
| // } | |||
| let file | |||
| if (options.tileRows && options.tileRows > 1 || options.tileColumns && options.tileColumns > 1) { | |||
| const res = await CanvasSnapshot.GetTiledFiles(canvas, filename, Math.max(1, options.tileRows || 1), Math.max(1, options.tileColumns || 1), options) | |||
| if (Array.isArray(res)) { | |||
| if (res.length === 1) file = res[0] | |||
| else if (res.length === 0) file = undefined | |||
| else if (!options.getDataUrl) { | |||
| const zippa: Zippable = {} | |||
| for (const f of res) { | |||
| zippa[(f as File).name] = new Uint8Array(await (f as File).arrayBuffer()) | |||
| } | |||
| const zipped = zipSync(zippa) | |||
| file = new File([zipped], filename + '.zip', {type: 'application/zip', lastModified: Date.now()}) | |||
| } else { | |||
| file = res as string[] | |||
| } | |||
| 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, | |||
| } else { | |||
| file = res | |||
| } | |||
| } else { | |||
| file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||
| } | |||
| const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||
| options.rect = rect | |||
| // const file = await CanvasSnapshot.GetFile(canvas, filename, options) | |||
| // options.rect = rect | |||
| options.displayPixelRatio = viewer.renderManager.renderScale | |||
| if (progressive && lastMaxFrames !== undefined) { | |||
| progressive.maxFrameCount = lastMaxFrames | |||
| } | |||
| viewer.scene.mainCamera.setInteractions(true, CanvasSnapshotPlugin.PluginType) | |||
| viewer.renderManager.renderScale = dpr | |||
| return file | |||
| } | |||
| @uiInput('Filename') | |||
| @serialize() | |||
| filename = 'snapshot.png' | |||
| filename = 'snapshot' | |||
| @uiInput('Frame Count') | |||
| @serialize() | |||
| progressiveFrames = 64 | |||
| @uiInput('Tile Rows') | |||
| @serialize() | |||
| tileRows = 1 | |||
| @uiInput('Tile Columns') | |||
| @serialize() | |||
| tileColumns = 1 | |||
| @uiVector('Crop Rect (x, y, w, h)', [0, 1], 0.001) | |||
| @serialize() | |||
| rect = new Vector4(0, 0, 1, 1) | |||
| private _downloading = false | |||
| /** | |||
| * Only for {@link downloadSnapshot} and functions using that | |||
| */ | |||
| @uiConfig() | |||
| @serialize() | |||
| defaultOptions: CanvasSnapshotOptions&{waitForProgressive?: boolean} = { | |||
| defaultOptions: CanvasSnapshotPluginOptions = { | |||
| waitForProgressive: true, | |||
| displayPixelRatio: window.devicePixelRatio, | |||
| scale: 1, | |||
| timeout: 0, | |||
| quality: 0.9, | |||
| tileRows: 1, | |||
| tileColumns: 1, | |||
| progressiveFrames: 64, | |||
| rect: { | |||
| x: 0, | |||
| y: 0, | |||
| width: 1, | |||
| height: 1, | |||
| normalized: true, | |||
| assumeClientRect: false, | |||
| }, | |||
| } | |||
| // @uiButton('Download .png', {sendArgs: false}) | |||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotOptions&{waitForProgressive?: boolean} = {waitForProgressive: true}): Promise<void> { | |||
| async downloadSnapshot(filename?: string, options: CanvasSnapshotPluginOptions = {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}) | |||
| while (this._downloading) { | |||
| console.warn('CanvasSnipperPlugin: Another rendering already in progress, waiting...') | |||
| await timeout(100) | |||
| } | |||
| this._downloading = true | |||
| // if (!options.mimeType && !filename) this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||
| const file = await this.getFile(filename, {...this.defaultOptions, ...options}).catch(e=>{ | |||
| this._viewer?.console.error('CanvasSnapshotPlugin: Error exporting file', e) | |||
| return null | |||
| }) | |||
| if (file) await this._viewer.exportBlob(file, file.name) | |||
| this._downloading = false | |||
| } | |||
| @uiButton('Download .png') | |||
| protected async _downloadPng(): Promise<void> { | |||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||
| // this.filename = this.filename.split('.').slice(0, -1).join('.') + '.png' | |||
| return this.downloadSnapshot(undefined, {mimeType: 'image/png'}) | |||
| } | |||
| @uiButton('Download .jpeg') | |||
| protected async _downloadJpeg(): Promise<void> { | |||
| this.filename = this.filename.split('.').slice(0, -1).join('.') + '.jpeg' | |||
| // 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' | |||
| // this.filename = this.filename.split('.').slice(0, -1).join('.') + '.webp' | |||
| return this.downloadSnapshot(undefined, {mimeType: 'image/webp'}) | |||
| } | |||
| @@ -7,7 +7,7 @@ import {SimpleEventDispatcher} from 'ts-browser-helpers' | |||
| * | |||
| * 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 { | |||
| export class GLTFMeshOptDecodePlugin extends SimpleEventDispatcher<'initialized'> implements IViewerPluginSync { | |||
| declare ['constructor']: typeof GLTFMeshOptDecodePlugin | |||
| public static readonly PluginType = 'GLTFMeshOptDecodePlugin' | |||
| enabled = true | |||
| @@ -48,7 +48,8 @@ window.dispatchEvent(new CustomEvent('${ev}')) | |||
| this.rootNode.appendChild(s) | |||
| this._script = s | |||
| }) | |||
| return await this._initializing | |||
| await this._initializing | |||
| this.dispatchEvent({type: 'initialized'}) | |||
| } | |||
| dispose() { | |||
| @@ -48,7 +48,7 @@ export {GLTFMeshOptDecodePlugin} from './import/GLTFMeshOptDecodePlugin' | |||
| // export | |||
| export {AssetExporterPlugin, type ExportAssetOptions} from './export/AssetExporterPlugin' | |||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin} from './export/CanvasSnapshotPlugin' | |||
| export {CanvasSnapshotPlugin, CanvasSnipperPlugin, type CanvasSnapshotPluginOptions} from './export/CanvasSnapshotPlugin' | |||
| export {FileTransferPlugin} from './export/FileTransferPlugin' | |||
| // postprocessing | |||
| @@ -3,8 +3,8 @@ import {AViewerPluginEventMap, type ThreeViewer} from '../../viewer/' | |||
| import {AViewerPluginSync} from '../../viewer/AViewerPlugin' | |||
| import {Dropzone} from '../../utils' | |||
| import {uiButton, uiConfig, uiFolderContainer, UiObjectConfig, uiToggle} from 'uiconfig.js' | |||
| import type {AddAssetOptions, ImportFilesOptions, ImportResult} from '../../assetmanager' | |||
| import {serialize} from 'ts-browser-helpers' | |||
| import type {AddAssetOptions, ImportFilesOptions, ImportResult, ImportAddOptions} from '../../assetmanager' | |||
| import {parseFileExtension, serialize} from 'ts-browser-helpers' | |||
| export interface DropzonePluginOptions { | |||
| /** | |||
| @@ -112,13 +112,53 @@ export class DropzonePlugin extends AViewerPluginSync<DropzonePluginEventMap> { | |||
| /** | |||
| * Prompt for file selection using the browser file dialog. | |||
| */ | |||
| @uiButton('Select files') | |||
| @uiButton('Select Local files') | |||
| public promptForFile(): void { | |||
| if (this.isDisabled()) return | |||
| this.allowedExtensions = this._allowedExtensions | |||
| this._inputEl?.click() | |||
| } | |||
| /** | |||
| * Prompt for file url. | |||
| */ | |||
| @uiButton('Import from URL') | |||
| public async promptForUrl(): Promise<void> { | |||
| if (this.isDisabled() || !this._viewer) return | |||
| const res = await this._viewer.dialog.prompt('Enter URL: Enter a public URL for a 3d file with extension', '', true) | |||
| if (!res || !res.length) return | |||
| await this.load(res, {}, true) | |||
| } | |||
| async load(res: string, options?: ImportAddOptions, dialog = false) { | |||
| if (!this._viewer) { | |||
| console.warn('DropzonePlugin: viewer not set') | |||
| return | |||
| } | |||
| if (this.autoImport) { | |||
| const manager = this._viewer.assetManager | |||
| const ext = parseFileExtension(res) | |||
| if (this._allowedExtensions && !this._allowedExtensions.includes(ext)) { | |||
| dialog && await this._viewer.dialog.alert(`DropzonePlugin: file extension ${ext} not allowed`) | |||
| return | |||
| } | |||
| const imported = await manager.importer.import(res, { | |||
| ...this.importOptions, | |||
| ...options ?? {}, | |||
| }) | |||
| const toAdd = [...imported ?? []].flat(2).filter(v => !!v) ?? [] | |||
| if (this.autoAdd) { | |||
| return await manager.loadImported(toAdd, { | |||
| ...this.addOptions, | |||
| ...options ?? {}, | |||
| }) | |||
| } | |||
| return toAdd | |||
| } else { | |||
| dialog && await this._viewer.dialog.alert('DropzonePlugin: autoImport is disabled, file was not imported') | |||
| } | |||
| } | |||
| private _domElement?: HTMLElement | |||
| constructor(options?: DropzonePluginOptions) { | |||
| super() | |||
| @@ -7,19 +7,31 @@ export interface CanvasSnapshotRect { | |||
| y: number; | |||
| /** | |||
| * Use if canvas.width !== canvas.clientWidth or height and rect is based on client rect | |||
| * @default false | |||
| */ | |||
| assumeClientRect?: boolean; | |||
| /** | |||
| * If true, assumes x, y, width, height are normalized to 0-1 | |||
| * @default false | |||
| */ | |||
| normalized?: boolean; | |||
| } | |||
| export interface CanvasSnapshotOptions { | |||
| getDataUrl?: boolean, | |||
| mimeType?: string, | |||
| quality?: number, // between 0 and 1, only for image/jpeg or image/webp | |||
| /** | |||
| * Crop Region to take snapshot. If not set, the whole canvas is used. | |||
| */ | |||
| rect?: CanvasSnapshotRect, | |||
| scale?: number, | |||
| timeout?: number, // in ms, if not specified, will be based on progressive rendering or 200ms | |||
| displayPixelRatio?: number, | |||
| cloneCanvas?: boolean, // default = true | |||
| cloneCanvas?: boolean, // default = true if safari, false otherwise. required for safari where canvas is flipped if premultipliedAlpha is true | |||
| } | |||
| function isSafari() { | |||
| return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome') | |||
| } | |||
| export class CanvasSnapshot { | |||
| @@ -27,26 +39,39 @@ export class CanvasSnapshot { | |||
| public static async GetClonedCanvas( | |||
| canvas: HTMLCanvasElement, | |||
| { | |||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false}, | |||
| rect = {x: 0, y: 0, width: canvas.width, height: canvas.height, assumeClientRect: false, normalized: false}, | |||
| displayPixelRatio = 1, | |||
| scale = 1, | |||
| }: CanvasSnapshotOptions): Promise<HTMLCanvasElement> { | |||
| rect = {...rect} | |||
| // 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) | |||
| if (!rect.normalized) { | |||
| if (rect.assumeClientRect) { | |||
| rect.x = Math.floor(rect.x * canvas.width / (displayPixelRatio * canvas.clientWidth)) | |||
| rect.y = Math.floor(rect.y * canvas.height / (displayPixelRatio * canvas.clientHeight)) | |||
| rect.width = Math.floor(rect.width * canvas.width / (displayPixelRatio * canvas.clientWidth)) | |||
| rect.height = Math.floor(rect.height * canvas.height / (displayPixelRatio * canvas.clientHeight)) | |||
| } | |||
| } else { | |||
| rect.x = Math.floor(rect.x * canvas.width) | |||
| rect.y = Math.floor(rect.y * canvas.height) | |||
| rect.width = Math.floor(rect.width * canvas.width) | |||
| rect.height = Math.floor(rect.height * canvas.height) | |||
| if (rect.assumeClientRect) { | |||
| console.warn('CanvasSnapshot: rect.assumeClientRect is ignored when rect is normalized') | |||
| } | |||
| } | |||
| destCanvas.width = Math.floor(rect.width * scale * displayPixelRatio) | |||
| destCanvas.height = Math.floor(rect.height * scale * displayPixelRatio) | |||
| const destCtx = destCanvas.getContext('2d') | |||
| if (!destCtx) { | |||
| console.error('snapshot: cannot create context') | |||
| @@ -66,8 +91,8 @@ export class CanvasSnapshot { | |||
| 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, | |||
| Math.floor(img.width * rect.x * displayPixelRatio / canvas.width), Math.floor(img.height * rect.y * displayPixelRatio / canvas.height), | |||
| Math.floor(img.width * rect.width * displayPixelRatio / canvas.width), Math.floor(img.height * rect.height * displayPixelRatio / canvas.height), | |||
| 0, 0, | |||
| destCanvas.width, | |||
| destCanvas.height, | |||
| @@ -81,7 +106,7 @@ export class CanvasSnapshot { | |||
| destCtx?.drawImage( | |||
| canvas, | |||
| rect.x * displayPixelRatio, rect.y * displayPixelRatio, rect.width * displayPixelRatio, rect.height * displayPixelRatio, | |||
| Math.floor(rect.x * displayPixelRatio), Math.floor(rect.y * displayPixelRatio), Math.floor(rect.width * displayPixelRatio), Math.floor(rect.height * displayPixelRatio), | |||
| 0, 0, destCanvas.width, destCanvas.height, | |||
| ) | |||
| @@ -103,7 +128,10 @@ export class CanvasSnapshot { | |||
| } | |||
| public static async GetDataUrl(canvas: HTMLCanvasElement, {mimeType = 'image/png', quality, ...options}: CanvasSnapshotOptions): Promise<string> { | |||
| const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| const doClone = isSafari() || options.cloneCanvas || options.rect || options.scale || options.displayPixelRatio | |||
| if (!doClone && (options.rect || options.scale || options.displayPixelRatio)) console.warn('CanvasSnapshot: rect, scale and displayPixelRatio are ignored when cloneCanvas is false') | |||
| const clone = !doClone ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| // const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| const url = clone.toDataURL(mimeType, quality) | |||
| if (!this.Debug && clone !== canvas) clone.remove() | |||
| return url | |||
| @@ -121,12 +149,15 @@ export class CanvasSnapshot { | |||
| } | |||
| public static async GetBlob(canvas: HTMLCanvasElement, options: CanvasSnapshotOptions = {}): Promise<Blob> { | |||
| const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| const doClone = isSafari() || options.cloneCanvas || options.rect || options.scale || options.displayPixelRatio | |||
| if (!doClone && (options.rect || options.scale || options.displayPixelRatio)) console.warn('rect, scale and displayPixelRatio are ignored when cloneCanvas is false') | |||
| const clone = !doClone ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| // const clone = options.cloneCanvas === false ? canvas : await this.GetClonedCanvas(canvas, options) | |||
| const blob = await new Promise<Blob>((resolve, reject) => { | |||
| clone.toBlob((b) => { | |||
| if (b) resolve(b) | |||
| else reject() | |||
| else reject(new Error('CanvasSnapshot Failed to export blob from canvas')) | |||
| }, options.mimeType ?? 'image/png', options.quality) | |||
| }) | |||
| if (!this.Debug && clone !== canvas) clone.remove() | |||
| @@ -134,11 +165,43 @@ export class CanvasSnapshot { | |||
| 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, { | |||
| public static async GetFile(canvas: HTMLCanvasElement, filename = 'image', options: CanvasSnapshotOptions = {}): Promise<File|string> { | |||
| const suffix = '.' + (options.mimeType?.split('/')[1]?.toLowerCase() || 'png') | |||
| const fname = !filename.toLowerCase().endsWith(suffix) ? filename + suffix : filename | |||
| return options.getDataUrl ? await this.GetDataUrl(canvas, options) : new File([await this.GetBlob(canvas, options)], fname, { | |||
| type: options.mimeType ?? 'image/png', | |||
| lastModified: now(), | |||
| }) | |||
| } | |||
| public static async GetTiledFiles(canvas: HTMLCanvasElement, filePrefix = 'image', tileRows = 2, tileCols = 2, options: CanvasSnapshotOptions = {}): Promise<(File|string)[]> { | |||
| const rect = options.rect ?? {x: 0, y: 0, width: 1, height: 1, assumeClientRect: false, normalized: true} | |||
| // rect.width *= options.displayPixelRatio ?? 1 | |||
| // rect.height *= options.displayPixelRatio ?? 1 | |||
| const files = [] | |||
| for (let i = 0; i < tileCols; i++) { | |||
| for (let j = 0; j < tileRows; j++) { | |||
| const ext = options.mimeType?.split('/')[1] ?? 'png' | |||
| const file = await this.GetFile(canvas, `${filePrefix}_${i}_${j}.${ext}`, { | |||
| rect: { | |||
| x: rect.x + i * rect.width / tileCols, | |||
| y: rect.y + j * rect.height / tileRows, | |||
| width: rect.width / tileCols, | |||
| height: rect.height / tileRows, | |||
| assumeClientRect: rect.assumeClientRect, | |||
| normalized: rect.normalized, | |||
| }, | |||
| }).catch(e => { | |||
| console.error(`CanvasSnapshot - Error exporting tiled file ${i}, ${j}`, e) | |||
| return null | |||
| }) | |||
| if (file) | |||
| files.push(file) | |||
| } | |||
| } | |||
| return files | |||
| } | |||
| } | |||
| @@ -17,7 +17,9 @@ next: | |||
| [Source Code](https://github.com/repalash/threepipe/blob/master/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. | |||
| 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, tiled zip export for large resolution, scaling the output image, interfacing with SSAA, Progressive plugins etc. | |||
| Check out the interface [CanvasSnapshotOptions](https://threepipe.org/docs/interfaces/CanvasSnapshotOptions.html) for more details. | |||
| ```typescript | |||
| import {ThreeViewer, CanvasSnapshotPlugin} from 'threepipe' | |||
| @@ -34,22 +36,25 @@ await snapshotPlugin.downloadSnapshot('image.webp', { // all parameters are opti | |||
| displayPixelRatio: 2, // render scale | |||
| mimeType: 'image/webp', // mime type of the image | |||
| waitForProgressive: true, // wait for progressive rendering to finish (ProgressivePlugin). true by default | |||
| progressiveFrames: 64, // number of frames to wait for progressive rendering to finish (ProgressivePlugin). 64 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, | |||
| }, | |||
| // tileRows: 3, // number of rows to tile the image. If more than one, a zip file will be exported | |||
| // tileColumns: 3, // number of columns to tile the image | |||
| }) | |||
| // get data url (string) | |||
| const dataUrl = await snapshotPlugin.getDataUrl({ // all parameters are optional | |||
| const dataUrl: string = 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 | |||
| const file: File = await snapshotPlugin.getFile('file.jpeg', { // all parameters are optional | |||
| mimeType: 'image/jpeg', // mime type of the image | |||
| }) | |||
| ``` | |||