| @@ -1,6 +1,6 @@ | |||
| { | |||
| "name": "threepipe", | |||
| "version": "0.0.5", | |||
| "version": "0.0.6-dev", | |||
| "description": "A 3D viewer framework built on top of three.js in TypeScript with a focus on quality rendering, modularity and extensibility.", | |||
| "main": "src/index.ts", | |||
| "module": "dist/index.mjs", | |||
| @@ -87,7 +87,7 @@ | |||
| "rollup-plugin-license": "^3.0.1", | |||
| "rollup-plugin-postcss": "^4.0.2", | |||
| "stats.js": "^0.17.0", | |||
| "three": "https://github.com/repalash/three.js-modded/releases/download/v0.152.2006/package.tgz", | |||
| "three": "https://github.com/repalash/three.js-modded/releases/download/v0.152.2007/package.tgz", | |||
| "tslib": "^2.5.0", | |||
| "tweakpane": "^3.1.9", | |||
| "@tweakpane/core": "^1.1.8", | |||
| @@ -99,7 +99,7 @@ | |||
| "tweakpane-image-plugin": "https://github.com/repalash/tweakpane-image-plugin/releases/download/v1.1.403/package.tgz" | |||
| }, | |||
| "dependencies": { | |||
| "@types/three": "https://github.com/repalash/three-ts-types/releases/download/v0.152.1006/package.tgz", | |||
| "@types/three": "https://github.com/repalash/three-ts-types/releases/download/v0.152.1008/package.tgz", | |||
| "@types/webxr": "^0.5.1", | |||
| "@types/wicg-file-system-access": "^2020.9.5", | |||
| "ts-browser-helpers": "^0.6.0" | |||
| @@ -109,10 +109,10 @@ | |||
| "uiconfig.js": "^0.0.4", | |||
| "ts-browser-helpers": "^0.5.0", | |||
| "uiconfig-tweakpane": "^0.0.3", | |||
| "three": "https://github.com/repalash/three.js-modded/releases/download/v0.152.2006/package.tgz", | |||
| "three-f": "https://github.com/repalash/three.js-modded/archive/refs/tags/v0.152.2006.tar.gz", | |||
| "@types/three": "https://github.com/repalash/three-ts-types/releases/download/v0.152.1006/package.tgz", | |||
| "@types/three-f": "https://github.com/repalash/three-ts-types/archive/refs/tags/v0.152.1006.tar.gz", | |||
| "three": "https://github.com/repalash/three.js-modded/releases/download/v0.152.2007/package.tgz", | |||
| "three-f": "https://github.com/repalash/three.js-modded/archive/refs/tags/v0.152.2007.tar.gz", | |||
| "@types/three": "https://github.com/repalash/three-ts-types/releases/download/v0.152.1008/package.tgz", | |||
| "@types/three-f": "https://github.com/repalash/three-ts-types/archive/refs/tags/v0.152.1008.tar.gz", | |||
| "@types/three-pkg": "https://gitpkg.now.sh/repalash/three-ts-types/types/three?modded_three", | |||
| "tweakpane-image-plugin": "git+ssh://github.com/repalash/tweakpane-image-plugin.git#52d5542047fd07d2e7225b5b67c9f7620366f2c7" | |||
| }, | |||
| @@ -1,23 +1,29 @@ | |||
| import {GLTFExporter, GLTFExporterOptions, GLTFExporterPlugin} from 'three/examples/jsm/exporters/GLTFExporter.js' | |||
| import {IExportParser} from '../IExporter' | |||
| import {AnyOptions} from 'ts-browser-helpers' | |||
| import {GLTFWriter2} from './GLTFWriter2' | |||
| import {Object3D} from 'three' | |||
| import {ThreeViewer} from '../../viewer' | |||
| import {GLTFObject3DExtrasExtension} from '../gltf/GLTFObject3DExtrasExtension' | |||
| import {GLTFLightExtrasExtension} from '../gltf/GLTFLightExtrasExtension' | |||
| import {GLTFMaterialsBumpMapExtension} from '../gltf/GLTFMaterialsBumpMapExtension' | |||
| import {GLTFMaterialsDisplacementMapExtension} from '../gltf/GLTFMaterialsDisplacementMapExtension' | |||
| import {GLTFMaterialsLightMapExtension} from '../gltf/GLTFMaterialsLightMapExtension' | |||
| import {GLTFMaterialsAlphaMapExtension} from '../gltf/GLTFMaterialsAlphaMapExtension' | |||
| import {GLTFMaterialExtrasExtension} from '../gltf/GLTFMaterialExtrasExtension' | |||
| import {GLTFViewerConfigExtension} from '../gltf/GLTFViewerConfigExtension' | |||
| import { | |||
| GLTFLightExtrasExtension, | |||
| GLTFMaterialExtrasExtension, | |||
| GLTFMaterialsAlphaMapExtension, | |||
| GLTFMaterialsBumpMapExtension, | |||
| GLTFMaterialsDisplacementMapExtension, | |||
| GLTFMaterialsLightMapExtension, | |||
| GLTFObject3DExtrasExtension, | |||
| GLTFViewerConfigExtension, | |||
| } from '../gltf' | |||
| import {glbEncryptionProcessor} from '../gltf/gltfEncyptionHelpers' | |||
| export type GLTFExporter2Options = GLTFExporterOptions & { | |||
| /** | |||
| * embed images in glb even when remote url is available, default = false | |||
| * embed images in glb even when remote url is available, {@default false} | |||
| */ | |||
| embedUrlImages?: boolean, | |||
| /** | |||
| * Embed previews of images in glb, {@default false} | |||
| */ | |||
| embedUrlImagePreviews?: boolean, | |||
| /** | |||
| * export viewer config (scene settings) | |||
| */ | |||
| @@ -27,19 +33,53 @@ export type GLTFExporter2Options = GLTFExporterOptions & { | |||
| */ | |||
| exportExt?: string, | |||
| preserveUUIDs?: boolean, | |||
| externalImagesInExtras?: boolean, // see GLTFDracoExporter and extras extension | |||
| encodeUint16Rgbe?: boolean // see GLTFViewerExport->processViewer, default = true | |||
| /** | |||
| * see GLTFDracoExporter and {@link GLTFMaterialExtrasExtension} | |||
| */ | |||
| externalImagesInExtras?: boolean, | |||
| /** | |||
| * see GLTFViewerExport->processViewer, {@default false} | |||
| */ | |||
| encodeUint16Rgbe?: boolean | |||
| /** | |||
| * Number of spaces to use when exporting to json, {@default 2} | |||
| */ | |||
| jsonSpaces?: number, | |||
| /** | |||
| * Encrypt the exported file in a GLB container using {@link encryptKey}, {@default false}. Works only for glb export. | |||
| */ | |||
| encrypt?: boolean, | |||
| /** | |||
| * Encryption key, if not provided, will be prompted, {@default undefined}. Works only for glb export. | |||
| */ | |||
| encryptKey?: string|Uint8Array, | |||
| [key: string]: any | |||
| } | |||
| export class GLTFExporter2 extends GLTFExporter implements IExportParser { | |||
| constructor() { | |||
| super() | |||
| this.processors.push(glbEncryptionProcessor) | |||
| } | |||
| register(callback: (writer: GLTFWriter2)=>GLTFExporterPlugin): this { | |||
| return super.register(callback as any) | |||
| } | |||
| async parseAsync(obj: any, options: AnyOptions): Promise<Blob> { | |||
| processors: ((obj: ArrayBuffer|any|Blob, options: GLTFExporter2Options) => Promise<ArrayBuffer|any|Blob>)[] = [] | |||
| async parseAsync(obj: ArrayBuffer|any, options: GLTFExporter2Options): Promise<Blob> { | |||
| if (!obj) throw new Error('No object to export') | |||
| const gltf = !obj.__isGLTFOutput && (Array.isArray(obj) || obj.isObject3D) ? await new Promise((resolve, reject) => this.parse(obj, resolve, reject, options)) : obj | |||
| let gltf = !obj.__isGLTFOutput && (Array.isArray(obj) || obj.isObject3D) ? await new Promise((resolve, reject) => this.parse(obj, resolve, reject, options)) : obj | |||
| for (const processor of this.processors) { | |||
| gltf = await processor(gltf, options) | |||
| } | |||
| if (gltf && gltf instanceof Blob) return gltf | |||
| if (gltf && typeof gltf === 'object' && !gltf.byteLength) { // byteLength is for ArrayBuffer | |||
| return new Blob([JSON.stringify(gltf, (k, v)=> k.startsWith('__') ? undefined : v, options.jsonSpaces ?? 2)], {type: 'model/gltf+json'}) | |||
| } else if (gltf) { | |||
| @@ -54,7 +94,7 @@ export class GLTFExporter2 extends GLTFExporter implements IExportParser { | |||
| onDone: (gltf: ArrayBuffer | {[key: string]: any}) => void, | |||
| onError: (error: ErrorEvent) => void, | |||
| options: GLTFExporter2Options = {}, | |||
| ): any { | |||
| ): void { | |||
| const gltfOptions: GLTFWriter2['options'] = { | |||
| // default options | |||
| binary: false, | |||
| @@ -172,7 +172,10 @@ export class GLTFWriter2 extends GLTFExporter.Utils.GLTFWriter { | |||
| !this.options.exporterOptions.embedUrlImages | |||
| && (map.userData.rootPath.startsWith('http') || map.userData.rootPath.startsWith('data:')) | |||
| ) { | |||
| map.source.data = null // handled below in GLTFWriter2.processImage | |||
| if (map.source.data) { // handled below in GLTFWriter2.processImage | |||
| if (!this.options.exporterOptions.embedUrlImagePreviews || (map as any).isDataTexture) map.source.data = null // todo make sure its only Texture, check for svg etc | |||
| else map.source.data._savePreview = true | |||
| } | |||
| delete map.userData.mimeType // for extensions like ktx2 | |||
| } | |||
| @@ -198,14 +201,26 @@ export class GLTFWriter2 extends GLTFExporter.Utils.GLTFWriter { | |||
| if (map.userData.rootPath && !this.options.exporterOptions.embedUrlImages | |||
| && (map.userData.rootPath.startsWith('http') || map.userData.rootPath.startsWith('data:')) | |||
| ) { | |||
| map.source.data = srcData | |||
| if (map.source.data) delete map.source.data._savePreview | |||
| else map.source.data = srcData | |||
| map.userData.mimeType = mimeType | |||
| if (!textureDef) { | |||
| console.error('textureDef is null', processed, map) | |||
| return processed | |||
| } | |||
| if (textureDef.source >= 0) { | |||
| console.warn('textureDef.source is already set', processed, map) | |||
| // console.warn('textureDef.source is already set', processed, map) | |||
| const img = this.json.images[textureDef.source] | |||
| if (img.uri) { | |||
| console.warn('uri already set', img.uri) | |||
| } else { | |||
| img.uri = map.userData.rootPath | |||
| img.mimeType = mimeType | |||
| if (!img.extras) img.extras = {} | |||
| img.extras.flipY = map.flipY | |||
| img.extras.uri = map.userData.rootPath // uri is removed by gltf-transform if bufferView is set | |||
| } | |||
| } else { | |||
| textureDef.source = this.processImageUri(map.image, map.userData.rootPath, map.flipY, mimeType) | |||
| } | |||
| @@ -220,7 +235,8 @@ export class GLTFWriter2 extends GLTFExporter.Utils.GLTFWriter { | |||
| // Add extra check for null images. This is set in processTexture when we have a rootPath | |||
| processImage(image: any, format: PixelFormat, flipY: boolean, mimeType = 'image/png') { | |||
| if (!image) return -1 | |||
| return super.processImage(image, format, flipY, mimeType) | |||
| // @ts-expect-error todo: update in three-ts-types | |||
| return super.processImage(image, format, flipY, mimeType, image._savePreview ? 32 : undefined, image._savePreview ? 32 : undefined) | |||
| } | |||
| /** | |||
| @@ -2,6 +2,7 @@ import type {GLTF, GLTFLoaderPlugin, GLTFParser} from 'three/examples/jsm/loader | |||
| import {ThreeSerialization} from '../../utils/serialization' | |||
| import {DoubleSide, Material} from 'three' | |||
| import type {GLTFExporterPlugin, GLTFWriter} from 'three/examples/jsm/exporters/GLTFExporter' | |||
| import {ITexture} from '../../core' | |||
| export class GLTFMaterialExtrasExtension { | |||
| static readonly WebGiMaterialExtrasExtension = 'WEBGI_material_extras' | |||
| @@ -205,9 +206,15 @@ export class GLTFMaterialExtrasExtension { | |||
| const resources = this.materialExternalResources[material.uuid] | |||
| if (resources) { | |||
| Object.entries(resources).forEach(([k, v]) => { | |||
| Object.entries(resources).forEach(([k, v]: [string, any|ITexture]) => { | |||
| if (k.startsWith('_')) return | |||
| let setFlag = false | |||
| if (v?.userData && v.userData.embedUrlImagePreviews === undefined) { // check ThreeSerialization texture serialization and GLTFWriter2.processTexture | |||
| v.userData.embedUrlImagePreviews = w.options.exporterOptions?.embedUrlImagePreviews | |||
| setFlag = true | |||
| } | |||
| dat[k] = ThreeSerialization.Serialize(v, this.serializedMeta) | |||
| if (v?.userData && setFlag) delete v.userData.embedUrlImagePreviews | |||
| }) | |||
| } | |||
| if (Object.keys(dat).length > 0) { | |||
| @@ -3,7 +3,9 @@ import type {GLTFWriter} from 'three/examples/jsm/exporters/GLTFExporter' | |||
| import {ISerializedViewerConfig, ThreeViewer} from '../../viewer' | |||
| import {Group, ImageUtils} from 'three' | |||
| import {RGBEPNGLoader} from '../import/RGBEPNGLoader' | |||
| import {SerializationResourcesType} from '../../utils/serialization' | |||
| import {SerializationResourcesType} from '../../utils' | |||
| import {RootSceneImportResult} from '../IAssetImporter' | |||
| import {halfFloatToRgbe} from '../../three' | |||
| export class GLTFViewerConfigExtension { | |||
| @@ -30,13 +32,20 @@ export class GLTFViewerConfigExtension { | |||
| } | |||
| scene = scenes[0] | |||
| } | |||
| const resultScene = resultScenes[0] | |||
| const resultScene = resultScenes.length > 0 ? resultScenes[0] : undefined | |||
| const viewerConfig: Partial<ISerializedViewerConfig> = scene.extensions?.[this.ViewerConfigGLTFExtension] | |||
| const viewerConfig1: Partial<ISerializedViewerConfig> = scene.extensions?.[this.ViewerConfigGLTFExtension] | |||
| // console.log({...viewerConfig?.resources}) | |||
| if (!viewerConfig) return {} | |||
| if (!viewerConfig1) return {} | |||
| const viewerConfig: ISerializedViewerConfig = { | |||
| type: 'ThreeViewer', | |||
| version: '0', | |||
| plugins: [], | |||
| assetType: 'config', | |||
| ...viewerConfig1, | |||
| } | |||
| if (viewerConfig.resources) { | |||
| await this._parseArrayBuffers(viewerConfig.resources, parser) | |||
| @@ -45,7 +54,7 @@ export class GLTFViewerConfigExtension { | |||
| viewerConfig.resources = await viewer.loadConfigResources(viewerConfig.resources || {}, extraResources) | |||
| ;(resultScene as any).importedViewerConfig = viewerConfig // todo | |||
| if (resultScene) (resultScene as RootSceneImportResult).importedViewerConfig = viewerConfig | |||
| } | |||
| return viewerConfig | |||
| @@ -128,12 +137,13 @@ export class GLTFViewerConfigExtension { | |||
| const blob = new Blob([bufferView]) | |||
| // const blob2 = new Blob([await blob.text()], {type: img.mimeType}) | |||
| let url = URL.createObjectURL(blob) | |||
| if ((buff.encodingVersion || 1) < 2) { | |||
| const encodingVersion = buff.encodingVersion || 1 | |||
| if (encodingVersion < 2) { | |||
| url = 'data:image/png;base64,' + btoa(await blob.text()) | |||
| } | |||
| // fetch(url).then(async r=>r.blob()).then(b=>console.log(b)) | |||
| // console.log(view2) | |||
| buff.data = (await new RGBEPNGLoader().parseAsync(url, undefined, true)).data | |||
| buff.data = (await new RGBEPNGLoader().parseAsync(url, undefined, encodingVersion < 3)).data | |||
| URL.revokeObjectURL(url) | |||
| delete buff.encoding | |||
| delete buff.encodingVersion | |||
| @@ -196,19 +206,19 @@ export class GLTFViewerConfigExtension { | |||
| if (encodeUint16Rgbe && buffer.type === 'Uint16Array' && buffer.width > 0 && buffer.height > 0) { // import for this is handled in gltf.ts:importViewer. | |||
| // todo: also check if this is indeed an hdr image or something else like LUT or other kind of embedded file. | |||
| const encodingVersion: any = 3 | |||
| // todo: can we optimize this? this is too many steps | |||
| const d = halfFloatToRgbe(buffer.data, 4) | |||
| const d = encodingVersion < 3 ? halfFloatToRgbe2(buffer.data, 4) : halfFloatToRgbe(buffer.data, 4) | |||
| const id = new ImageData(d, buffer.width, buffer.height) | |||
| // @ts-expect-error patched three | |||
| const b64 = ImageUtils.getDataURL(id, true).split(',')[1] | |||
| // console.log(b64) | |||
| const encodingVersion: any = 2 | |||
| // @ts-expect-error patched three. todo: update the types | |||
| const b64 = ImageUtils.getDataURL(id, true).split(',')[1] | |||
| mime = 'image/png' | |||
| if (encodingVersion === 1) { | |||
| buffer.data = atob(b64) | |||
| } else if (encodingVersion === 2) { | |||
| } else if (encodingVersion === 2 || encodingVersion === 3) { | |||
| buffer.data = Uint8Array.from(atob(b64), c => c.charCodeAt(0)) | |||
| } else { | |||
| throw new Error('Invalid encoding version') | |||
| @@ -271,9 +281,15 @@ export class GLTFViewerConfigExtension { | |||
| } | |||
| // adapted from https://github.com/enkimute/hdrpng.js/blob/3a62b3ae2940189777df9f669df5ece3e78d9c16/hdrpng.js#L235 | |||
| // channels = 4 for RGBA data or 3 for RGB data. buffer from THREE.DataTexture | |||
| function halfFloatToRgbe(buffer: Uint16Array, channels = 3, res?: Uint8ClampedArray): Uint8ClampedArray { | |||
| /** | |||
| * @deprecated old version. see {@link halfFloatToRgbe} to convert half float buffer to rgbe | |||
| * adapted from https://github.com/enkimute/hdrpng.js/blob/3a62b3ae2940189777df9f669df5ece3e78d9c16/hdrpng.js#L235 | |||
| * channels = 4 for RGBA data or 3 for RGB data. buffer from THREE.DataTexture | |||
| * @param buffer | |||
| * @param channels | |||
| * @param res | |||
| */ | |||
| function halfFloatToRgbe2(buffer: Uint16Array, channels = 3, res?: Uint8ClampedArray): Uint8ClampedArray { | |||
| let r, g, b, v, s | |||
| const l = buffer.byteLength / (channels * 2) | 0 | |||
| res = res || new Uint8ClampedArray(l * 4) | |||
| @@ -0,0 +1,68 @@ | |||
| import {aesGcmDecrypt, aesGcmEncrypt, getOrCall} from 'ts-browser-helpers' | |||
| import {makeGLBFile} from '../../utils' | |||
| import {GLTFExporter2Options} from '../export' | |||
| import {GLTFBinaryExtension} from 'three/examples/jsm/loaders/GLTFLoader.js' | |||
| import {GLTFPreparser} from '../import' | |||
| /** | |||
| * Sample encryption processor for {@link GLTFExporter2} that wraps the glb in a new glb with encrypted content and encryption metadata. | |||
| * Uses AES-GCM ({@link aesGcmEncrypt}) for encryption since it is widely supported across browsers and js environments. | |||
| * @param gltf | |||
| * @param options | |||
| */ | |||
| export const glbEncryptionProcessor = async(gltf: ArrayBuffer|any, options: GLTFExporter2Options) => { | |||
| if (!gltf || typeof gltf === 'object' || !gltf.byteLength) return gltf | |||
| if (!options.encryptKey && window && window.prompt) { | |||
| options.encryptKey = window.prompt('GLTFEncryption: Enter encryption key/password') || '' | |||
| } | |||
| if (!options.encryptKey) { | |||
| console.warn('GLTF Export: encryption key not provided, skipping encryption') | |||
| return gltf | |||
| } | |||
| const buffer = await aesGcmEncrypt(new Uint8Array(gltf as ArrayBuffer), options.encryptKey) | |||
| return makeGLBFile(buffer, { | |||
| asset: { | |||
| version: '2.0', | |||
| generator: 'ThreePipeGLBWrapper', | |||
| encryption: { | |||
| type: 'aesgcm', | |||
| version: 1, | |||
| }, | |||
| }, | |||
| }) | |||
| } | |||
| export interface IGLBEncryptionPreparser extends GLTFPreparser{ | |||
| key: string | ((encryption: any, json: any)=>string|Promise<string>) | |||
| } | |||
| /** | |||
| * Sample encryption preparser for {@link GLTFLoader2} that unwraps the glb container and decrypts the content. The encryption key can be provided in the file or set in this const is prompted from the user. | |||
| */ | |||
| export const glbEncryptionPreparser: IGLBEncryptionPreparser = { | |||
| key: (encryption: any, _: any) => { | |||
| return encryption.key || window && window.prompt && window.prompt('GLTFEncryption: Please enter the password/key for this model') || '' | |||
| }, | |||
| async process(dat: string | ArrayBuffer) { | |||
| if (typeof dat === 'string') return dat | |||
| const prefixBytes = 100 | |||
| const prefix = new TextDecoder().decode(new Uint8Array(dat, 0, prefixBytes)) | |||
| if (!prefix.includes('GLBWrapper')) return dat | |||
| const binaryExtension = new GLTFBinaryExtension(dat) | |||
| const json = JSON.parse(binaryExtension.content || '{}') | |||
| let dat2 = binaryExtension.body || dat | |||
| const encryption = json.asset?.encryption | |||
| if (!encryption) return dat2 | |||
| const type = encryption.type | |||
| const version = encryption.version | |||
| if (type === 'aesgcm' && version === 1) { | |||
| const key = await getOrCall(this.key, encryption, json) || '' | |||
| try { | |||
| dat2 = (await aesGcmDecrypt(new Uint8Array(dat2), key)).buffer | |||
| } catch (e) { | |||
| throw new ErrorEvent('decryption error') | |||
| } | |||
| } | |||
| return dat2 | |||
| }, | |||
| } | |||
| @@ -6,3 +6,4 @@ export {GLTFMaterialsDisplacementMapExtension} from './GLTFMaterialsDisplacement | |||
| export {GLTFMaterialsLightMapExtension} from './GLTFMaterialsLightMapExtension' | |||
| export {GLTFObject3DExtrasExtension} from './GLTFObject3DExtrasExtension' | |||
| export {GLTFViewerConfigExtension} from './GLTFViewerConfigExtension' | |||
| export {glbEncryptionPreparser, glbEncryptionProcessor, type IGLBEncryptionPreparser} from './gltfEncyptionHelpers' | |||
| @@ -13,12 +13,13 @@ import {GLTFMaterialsDisplacementMapExtension} from '../gltf/GLTFMaterialsDispla | |||
| import {GLTFMaterialsAlphaMapExtension} from '../gltf/GLTFMaterialsAlphaMapExtension' | |||
| import {RootSceneImportResult} from '../IAssetImporter' | |||
| import {ILoader} from '../IImporter' | |||
| import {glbEncryptionPreparser} from '../gltf' | |||
| export class GLTFLoader2 extends GLTFLoader implements ILoader<GLTF, Object3D|undefined> { | |||
| isGLTFLoader2 = true | |||
| constructor(manager: LoadingManager) { | |||
| super(manager) | |||
| this.preparsers.push(glbEncryptionPreparser) | |||
| } | |||
| static ImportExtensions: ((parser: GLTFParser) => GLTFLoaderPlugin)[] = [ | |||
| @@ -30,7 +31,32 @@ export class GLTFLoader2 extends GLTFLoader implements ILoader<GLTF, Object3D|un | |||
| GLTFMaterialsAlphaMapExtension.Import, | |||
| ] | |||
| /** | |||
| * Preparsers are run on the arraybuffer/string before parsing to read the glb/gltf data | |||
| */ | |||
| preparsers: GLTFPreparser[] = [] | |||
| async preparse(data: ArrayBuffer | string): Promise<ArrayBuffer | string> { | |||
| for (const preparser of this.preparsers) { | |||
| data = await preparser.process(data) | |||
| } | |||
| return data | |||
| } | |||
| parse(data: ArrayBuffer | string, path: string, onLoad: (gltf: GLTF) => void, onError?: (event: ErrorEvent) => void) { | |||
| this.preparse.call(this, data) | |||
| .then((res: ArrayBuffer | string) => res ? super.parse(res, path, onLoad, onError) : onError && onError(new ErrorEvent('no data'))) | |||
| .catch((e: any) => { | |||
| console.error(e) | |||
| if (onError) onError(e ?? new ErrorEvent('unknown error')) | |||
| }) | |||
| } | |||
| /** | |||
| * This is run post parse to extract the result scene from the GLTF object | |||
| * @param res | |||
| * @param _ | |||
| */ | |||
| transform(res: GLTF, _: AnyOptions): Object3D|undefined { | |||
| // todo: support loading of multiple scenes? | |||
| const scene: RootSceneImportResult|undefined = res ? res.scene || !!res.scenes && res.scenes.length > 0 && res.scenes[0] : undefined as any | |||
| @@ -49,7 +75,6 @@ export class GLTFLoader2 extends GLTFLoader implements ILoader<GLTF, Object3D|un | |||
| return scene | |||
| } | |||
| register(callback: (parser: GLTFParser) => GLTFLoaderPlugin): this { | |||
| return super.register(callback) as this | |||
| } | |||
| @@ -104,3 +129,7 @@ export class GLTFLoader2 extends GLTFLoader implements ILoader<GLTF, Object3D|un | |||
| } | |||
| export interface GLTFPreparser{ | |||
| process(dat: string | ArrayBuffer): Promise<string | ArrayBuffer> | |||
| [key: string]: any | |||
| } | |||
| @@ -47,7 +47,7 @@ export class RGBEPNGLoader extends FileLoader { | |||
| if (created) URL.revokeObjectURL(url) | |||
| let aType: any = Uint8Array | |||
| if (this.type === HalfFloatType) aType = Uint16Array | |||
| else if (this.type === FloatType) aType = Uint32Array | |||
| else if (this.type === FloatType) aType = Float32Array | |||
| const buffer = rgbeToHalfFloat(imageData.data, 4, aType, isFloat16Data) | |||
| return {data: buffer, width: imageData.width, height: imageData.height} | |||
| } | |||
| @@ -3,6 +3,6 @@ export {SimpleJSONLoader} from './SimpleJSONLoader' | |||
| export {MTLLoader2, type MaterialCreator} from './MTLLoader2' | |||
| export {OBJLoader2} from './OBJLoader2' | |||
| export {ZipLoader} from './ZipLoader' | |||
| export {GLTFLoader2} from './GLTFLoader2' | |||
| export {GLTFLoader2, type GLTFPreparser} from './GLTFLoader2' | |||
| export {DRACOLoader2} from './DRACOLoader2' | |||
| export {RGBEPNGLoader} from './RGBEPNGLoader' | |||
| @@ -4,6 +4,7 @@ import {ChangeEvent} from 'uiconfig.js' | |||
| export interface ITextureUserData{ | |||
| mimeType?: string | |||
| embedUrlImagePreviews?: boolean | |||
| disposeOnIdle?: boolean // automatically dispose when added to a material and then not used in any material | |||
| __appliedMaterials?: Set<IMaterial> | |||
| } | |||
| @@ -1,4 +1,4 @@ | |||
| import {Color, DataTexture, LinearSRGBColorSpace, RGBAFormat, UnsignedByteType, Vector4} from 'three' | |||
| import {Color, DataTexture, DataUtils, LinearSRGBColorSpace, RGBAFormat, UnsignedByteType, Vector4} from 'three' | |||
| export function dataTextureFromColor(color: Color) { | |||
| const dataTexture = new DataTexture(new Uint8Array([Math.floor(color.r * 255), Math.floor(color.g * 255), Math.floor(color.b * 255), 255]), 1, 1, RGBAFormat, UnsignedByteType) | |||
| @@ -12,3 +12,29 @@ export function dataTextureFromVec4(color: Vector4) { | |||
| dataTexture.needsUpdate = true | |||
| return dataTexture | |||
| } | |||
| /** | |||
| * Convert half float buffer to rgbe | |||
| * adapted from https://github.com/enkimute/hdrpng.js/blob/3a62b3ae2940189777df9f669df5ece3e78d9c16/hdrpng.js#L235 | |||
| * channels = 4 for RGBA data or 3 for RGB data. buffer from THREE.DataTexture | |||
| * @param buffer | |||
| * @param channels | |||
| * @param res | |||
| */ | |||
| export function halfFloatToRgbe(buffer: Uint16Array, channels = 3, res?: Uint8ClampedArray): Uint8ClampedArray { | |||
| let r, g, b, v, s | |||
| const l = buffer.byteLength / (channels * 2) | 0 | |||
| res = res || new Uint8ClampedArray(l * 4) | |||
| for (let i = 0;i < l;i++) { | |||
| r = DataUtils.fromHalfFloat(buffer[i * channels]) * 65504 | |||
| g = DataUtils.fromHalfFloat(buffer[i * channels + 1]) * 65504 | |||
| b = DataUtils.fromHalfFloat(buffer[i * channels + 2]) * 65504 | |||
| v = Math.max(Math.max(r, g), b) | |||
| const e = Math.ceil(Math.log2(v)); s = Math.pow(2, e - 8) | |||
| res[i * 4] = r / s | 0 | |||
| res[i * 4 + 1] = g / s | 0 | |||
| res[i * 4 + 2] = b / s | 0 | |||
| res[i * 4 + 3] = e + 128 | |||
| } | |||
| return res | |||
| } | |||
| @@ -1,9 +1,9 @@ | |||
| export {computeScreenSpaceBoundingBox} from './bbox' | |||
| export {overrideThreeCache} from './cache' | |||
| export {dataTextureFromColor, dataTextureFromVec4} from './conversion' | |||
| export {dataTextureFromColor, dataTextureFromVec4, halfFloatToRgbe} from './conversion' | |||
| export {uniform, matDefine} from './decorators' | |||
| export {getEncodingComponents, getTexelEncoding, getTexelDecoding, getTexelDecoding2, getTexelDecodingFunction, getTexelEncodingFunction, getTextureColorSpaceFromMap} from './encoding' | |||
| export {generateUUID, toIndexedGeometry} from './misc' | |||
| export {getTextureDataType} from './texture' | |||
| export {getTextureDataType, textureToCanvas, textureDataToImageData, textureToDataUrl, imageToCanvas} from './texture' | |||
| // export {} from './constants' | |||
| @@ -1,4 +1,17 @@ | |||
| import {FloatType, HalfFloatType, TextureDataType, UnsignedByteType, WebGLRenderer} from 'three' | |||
| import { | |||
| ColorSpace, | |||
| DataTexture, | |||
| DataUtils, | |||
| FloatType, | |||
| HalfFloatType, | |||
| LinearSRGBColorSpace, | |||
| Texture, | |||
| TextureDataType, | |||
| UnsignedByteType, | |||
| WebGLRenderer, | |||
| } from 'three' | |||
| import {TextureImageData} from 'three/src/textures/types' | |||
| import {LinearToSRGB} from 'ts-browser-helpers' | |||
| export function getTextureDataType(renderer?: WebGLRenderer): TextureDataType { | |||
| if (!renderer) return UnsignedByteType | |||
| @@ -7,3 +20,88 @@ export function getTextureDataType(renderer?: WebGLRenderer): TextureDataType { | |||
| return halfFloatSupport ? HalfFloatType : floatSupport ? FloatType : UnsignedByteType | |||
| } | |||
| export function textureDataToImageData(imgData: TextureImageData | ImageData | {data: Float32Array|Uint16Array|Uint8Array, width: number, height: number}, colorSpace?: ColorSpace, outData?: ImageData) { | |||
| const data = outData?.data ?? new Uint8ClampedArray(imgData.height * imgData.width * 4) | |||
| const isFloat32 = imgData.data instanceof Float32Array | |||
| const isUint16 = imgData.data instanceof Uint16Array | |||
| for (let i = 0; i < data.length; i++) { | |||
| if (isFloat32) { // Float32 | |||
| data[i] = imgData.data[i] * 255 | |||
| } else if (isUint16) { // Uint16 (half float) | |||
| data[i] = DataUtils.fromHalfFloat(imgData.data[i]) * 255 | |||
| } else { // Uint8 | |||
| data[i] = imgData.data[i] | |||
| } | |||
| if (colorSpace === LinearSRGBColorSpace) { | |||
| data[i] = LinearToSRGB(data[i] / 255.0) * 255 | |||
| } | |||
| // todo: rgbm? | |||
| } | |||
| return outData ?? new ImageData(data, imgData.width, imgData.height) | |||
| } | |||
| /** | |||
| * | |||
| * @param texture | |||
| * @param maxWidth | |||
| * @param flipY | |||
| * @param canvas | |||
| */ | |||
| export function textureToCanvas(texture: Texture|DataTexture, maxWidth: number, flipY = false, canvas?: HTMLCanvasElement) { | |||
| let img | |||
| if ((texture as DataTexture).isDataTexture) img = textureDataToImageData(texture.image, texture.colorSpace) | |||
| else img = texture.image | |||
| return imageToCanvas(img, maxWidth, flipY, canvas) | |||
| } | |||
| export function imageToCanvas(image: TexImageSource, maxWidth: number, flipY = false, canvas?: HTMLCanvasElement) { | |||
| canvas = canvas || document.createElement('canvas') | |||
| // resize it to the size of our image | |||
| canvas.width = Math.min(maxWidth, image.width as number) | |||
| canvas.height = Math.floor(1.0 + canvas.width * (image.height as number) / (image.width as number)) | |||
| const ctx = canvas.getContext('2d') | |||
| if (!ctx) { | |||
| console.error('textureToDataUrl: could not get canvas context') | |||
| return canvas | |||
| } | |||
| if (flipY === true) { | |||
| ctx.translate(0, canvas.height) | |||
| ctx.scale(1, -1) | |||
| } | |||
| if ((image as ImageData).data !== undefined) { // THREE.DataTexture | |||
| const imageData = image as ImageData | |||
| if (image.width !== canvas.width || image.height !== canvas.height) { | |||
| const tempCanvas = document.createElement('canvas') | |||
| tempCanvas.width = image.width | |||
| tempCanvas.height = image.height | |||
| const tempCtx = tempCanvas.getContext('2d') | |||
| if (!tempCtx) { | |||
| console.error('textureToDataUrl: could not get temp canvas context') | |||
| ctx.putImageData(imageData, 0, 0) | |||
| } else { | |||
| tempCtx.putImageData(imageData, 0, 0) | |||
| ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height) | |||
| } | |||
| } else { | |||
| ctx.putImageData(imageData, 0, 0) | |||
| } | |||
| } else { | |||
| ctx.drawImage(image as any, 0, 0, canvas.width, canvas.height) | |||
| } | |||
| return canvas | |||
| } | |||
| export function textureToDataUrl(texture: Texture|DataTexture, maxWidth: number, flipY: boolean, mimeType?: string, quality?: number) { | |||
| return textureToCanvas(texture, maxWidth, flipY).toDataURL(mimeType, quality) | |||
| } | |||
| @@ -0,0 +1,86 @@ | |||
| import type {BlobExt} from '../assetmanager' | |||
| /** | |||
| * Returns a buffer aligned to 4-byte boundary. | |||
| * https://github.com/mrdoob/three.js/blob/4dbd0065f2ec29b89c250d8582f61e9f4792e077/examples/jsm/exporters/GLTFExporter.js#L381 | |||
| * @param arrayBuffer Buffer to pad | |||
| * @param paddingByte (Optional) | |||
| * @returns The same buffer if it's already aligned to 4-byte boundary or a new buffer | |||
| */ | |||
| function getPaddedArrayBuffer(arrayBuffer: ArrayBuffer, paddingByte = 0): ArrayBuffer { | |||
| const paddedLength = getPaddedBufferSize(arrayBuffer.byteLength) | |||
| if (paddedLength !== arrayBuffer.byteLength) { | |||
| const array = new Uint8Array(paddedLength) | |||
| array.set(new Uint8Array(arrayBuffer)) | |||
| if (paddingByte !== 0) { | |||
| for (let i = arrayBuffer.byteLength; i < paddedLength; i++) { | |||
| array[ i ] = paddingByte | |||
| } | |||
| } | |||
| return array.buffer | |||
| } | |||
| return arrayBuffer | |||
| } | |||
| /** | |||
| * Get the required size + padding for a buffer, rounded to the next 4-byte boundary. | |||
| * https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#data-alignment | |||
| * | |||
| * @param bufferSize The size the original buffer. | |||
| * @returns new buffer size with required padding. | |||
| * | |||
| */ | |||
| function getPaddedBufferSize(bufferSize: number) { | |||
| return Math.ceil(bufferSize / 4) * 4 | |||
| } | |||
| // GLB constants | |||
| // https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#glb-file-format-specification | |||
| const GLB_HEADER_BYTES = 12 | |||
| const GLB_HEADER_MAGIC = 0x46546C67 | |||
| const GLB_VERSION = 2 | |||
| const GLB_CHUNK_PREFIX_BYTES = 8 | |||
| const GLB_CHUNK_TYPE_JSON = 0x4E4F534A | |||
| const GLB_CHUNK_TYPE_BIN = 0x004E4942 | |||
| // https://github.com/mrdoob/three.js/blob/4dbd0065f2ec29b89c250d8582f61e9f4792e077/examples/jsm/exporters/GLTFExporter.js#L558 | |||
| export function makeGLBFile(buffers: ArrayBuffer, json: any): BlobExt { | |||
| // Binary chunk. | |||
| const binaryChunk = getPaddedArrayBuffer(buffers) | |||
| const binaryChunkPrefix = new DataView(new ArrayBuffer(GLB_CHUNK_PREFIX_BYTES)) | |||
| binaryChunkPrefix.setUint32(0, binaryChunk.byteLength, true) | |||
| binaryChunkPrefix.setUint32(4, GLB_CHUNK_TYPE_BIN, true) | |||
| // JSON chunk. | |||
| const jsonChunk = getPaddedArrayBuffer(new TextEncoder().encode(JSON.stringify(json || {})).buffer, 0x20) | |||
| const jsonChunkPrefix = new DataView(new ArrayBuffer(GLB_CHUNK_PREFIX_BYTES)) | |||
| jsonChunkPrefix.setUint32(0, jsonChunk.byteLength, true) | |||
| jsonChunkPrefix.setUint32(4, GLB_CHUNK_TYPE_JSON, true) | |||
| // GLB header. | |||
| const header = new ArrayBuffer(GLB_HEADER_BYTES) | |||
| const headerView = new DataView(header) | |||
| headerView.setUint32(0, GLB_HEADER_MAGIC, true) | |||
| headerView.setUint32(4, GLB_VERSION, true) | |||
| const totalByteLength = GLB_HEADER_BYTES | |||
| + jsonChunkPrefix.byteLength + jsonChunk.byteLength | |||
| + binaryChunkPrefix.byteLength + binaryChunk.byteLength | |||
| headerView.setUint32(8, totalByteLength, true) | |||
| const glbBlob: BlobExt = new Blob([ | |||
| header, | |||
| jsonChunkPrefix, | |||
| jsonChunk, | |||
| binaryChunkPrefix, | |||
| binaryChunk, | |||
| ], {type: 'model/gltf+binary'}) as any | |||
| glbBlob.ext = 'glb' | |||
| return glbBlob | |||
| } | |||
| @@ -5,5 +5,6 @@ export {CustomContextMenu} from './CustomContextMenu' | |||
| export {Dropzone, type DropFile, type ListenerCallback, type DropEventType} from './Dropzone' | |||
| export {ThreeSerialization, type SerializationMetaType, type SerializationResourcesType, MetaImporter, metaToResources, getEmptyMeta, metaFromResources, convertArrayBufferToStringsInMeta, convertStringsToArrayBuffersInMeta, copyMaterialUserData, copyObject3DUserData, copyUserData, copyTextureUserData} from './serialization' | |||
| export {shaderReplaceString} from './shader-helpers' | |||
| export {makeGLBFile} from './gltf' | |||
| export {serialize, serializable, Serialization} from 'ts-browser-helpers' | |||
| @@ -18,6 +18,7 @@ import {IAssetImporter} from '../assetmanager' | |||
| import {ThreeViewer} from '../viewer' | |||
| import {ITexture} from '../core' | |||
| import {IRenderTarget, RenderManager} from '../rendering' | |||
| import {textureToCanvas} from '../three/utils/texture' | |||
| const copier = (c: any) => (v: any, o: any) => o?.copy?.(v) ?? new c().copy(v) | |||
| export class ThreeSerialization { | |||
| @@ -49,18 +50,28 @@ export class ThreeSerialization { | |||
| serialize: (obj: any, meta?: SerializationMetaType) => { | |||
| if (!obj?.isTexture) throw new Error('Expected a texture') | |||
| if (obj.isRenderTargetTexture) return undefined // todo: support render targets | |||
| // if (obj.isRenderTargetTexture && !obj.userData?.serializableRenderTarget) return undefined | |||
| if (meta?.textures[obj.uuid]) return {uuid: obj.uuid, resource: 'textures'} | |||
| const imgData = obj.source.data | |||
| if (obj.userData.rootPath) obj.source.data = null // if root-path exists we don't need to serialize the image data | |||
| const hasRootPath = !obj.isRenderTargetTexture && obj.userData.rootPath | |||
| if (hasRootPath) { | |||
| if (obj.source.data) { | |||
| if (!obj.userData.embedUrlImagePreviews) // todo make sure its only Texture, check for svg etc | |||
| obj.source.data = null // handled in GLTFWriter2.processImage | |||
| else { | |||
| obj.source.data = textureToCanvas(obj, 16, obj.flipY) // todo: check flipY | |||
| } | |||
| } | |||
| } | |||
| const ud = obj.userData | |||
| obj.userData = {} // toJSON will call JSON.stringify, which will serialize userData | |||
| const meta2 = {images: {} as any} // in-case meta is undefined | |||
| let res = obj.toJSON(meta || meta2) | |||
| if (!meta && res.image) res.image = obj.userData.rootPath ? undefined : meta2.images[res.image] | |||
| if (!meta && res.image) res.image = hasRootPath && !obj.userData.embedUrlImagePreviews ? undefined : meta2.images[res.image] | |||
| obj.userData = ud | |||
| res.userData = Serialization.Serialize(copyTextureUserData({}, ud), meta, false) | |||
| if (obj.userData.rootPath) { | |||
| if (meta) delete meta.images[obj.source.uuid] // because its empty. uuid still stored in the texture.image | |||
| if (hasRootPath) { | |||
| if (meta && !obj.userData.embedUrlImagePreviews) delete meta.images[obj.source.uuid] // because its empty. uuid still stored in the texture.image | |||
| obj.source.data = imgData | |||
| } | |||
| @@ -612,6 +623,15 @@ export class MetaImporter { | |||
| // }) | |||
| // } | |||
| if (Array.isArray(json.textures)) { | |||
| console.error('TODO: check file format') | |||
| json.textures = json.textures.reduce((acc, cur) => { | |||
| if (!cur) return acc | |||
| acc[cur.uuid] = cur | |||
| return acc | |||
| }) | |||
| } | |||
| await MetaImporter.LoadRootPathTextures({textures: json.textures, images: resources.images}, assetImporter) | |||
| // console.log(json.textures) | |||
| @@ -623,6 +643,20 @@ export class MetaImporter { | |||
| } | |||
| resources.textures = json.textures ? objLoader.parseTextures(textures, resources.images) : {} | |||
| // replace the source of the textures(which has preview) with the loaded images, see {@link LoadRootPathTextures} for `rootPathPromise` | |||
| // todo: should this be moved after processRaw? | |||
| const textures2 = {...resources.textures} | |||
| for (const inpTexture of Object.values(json.textures)) { | |||
| inpTexture.rootPathPromise?.then((v: Source|null) => { | |||
| if (!v) return | |||
| const texture = textures2[inpTexture.uuid] | |||
| texture.dispose() | |||
| texture.source = v | |||
| texture.source.needsUpdate = true | |||
| texture.needsUpdate = true | |||
| }) | |||
| } | |||
| for (const entry of Object.entries(resources.textures)) { | |||
| entry[1] = await assetImporter.processRawSingle(entry[1], {}) | |||
| if (entry[1]) resources.textures[entry[0]] = entry[1] | |||
| @@ -687,25 +721,33 @@ export class MetaImporter { | |||
| static async LoadRootPathTextures({textures, images}: Pick<SerializationMetaType, 'textures'|'images'>, importer: IAssetImporter) { | |||
| const pms = [] | |||
| for (const inpTexture of Object.values(textures ?? {} as any) as any as any[]) { | |||
| const path = inpTexture?.userData?.rootPath // done separately(from parseTextures2) for hdr etc textures. | |||
| if (path && (!inpTexture.image || !images[inpTexture.image])) { | |||
| pms.push(importer.importSingle<ITexture>(path, {processRaw: false}).then(texture => { | |||
| const source = texture?.source as any | |||
| // const image = texture?.image as any | |||
| if (!texture || !source) return | |||
| // console.log(typeof image) | |||
| const source2 = new Source(source.data) | |||
| if (inpTexture.image) source2.uuid = inpTexture.image | |||
| for (const inpTexture of Array.isArray(textures) ? textures : Object.values(textures ?? {} as any) as any as any[]) { | |||
| const path = inpTexture?.userData?.rootPath | |||
| const hasImage = inpTexture.image && images[inpTexture.image] // its possible to have both image and rootPath, then the image will be preview image. | |||
| if (!path) continue | |||
| // console.warn(path, inpTexture, images) | |||
| const promise = importer.importSingle<ITexture>(path, {processRaw: false}).then((texture) => { | |||
| const source = texture?.source as any | |||
| // const image = texture?.image as any | |||
| if (!texture || !source) return null | |||
| // console.log(typeof image) | |||
| const source2 = new Source(source.data) | |||
| if (inpTexture.image) source2.uuid = inpTexture.image | |||
| inpTexture.image = source2.uuid | |||
| if (!hasImage) | |||
| images[source2.uuid] = source2 | |||
| inpTexture.image = source2.uuid | |||
| texture.dispose() // todo: what happens when we reimport a cached disposed texture asset, is three.js able to recreate the webgl texture on render? | |||
| }).catch((e)=>{ | |||
| console.error(e) | |||
| delete inpTexture.userData.rootPath | |||
| })) | |||
| } | |||
| texture.dispose() // todo: what happens when we reimport a cached disposed texture asset, is three.js able to recreate the webgl texture on render? | |||
| return source2 | |||
| }).catch((e) => { | |||
| console.error(e) | |||
| delete inpTexture.userData.rootPath | |||
| return null | |||
| }) | |||
| if (hasImage) inpTexture.rootPathPromise = promise | |||
| else pms.push(promise) | |||
| } | |||
| await Promise.allSettled(pms) | |||
| } | |||