UNPKG

threepipe

Version:

A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.

1,026 lines (924 loc) 46.7 kB
import { arrayBufferToBase64, base64ToArrayBuffer, deepAccessObject, getTypedArray, safeSetProperty, Serialization, Serializer, } from 'ts-browser-helpers' import { AnimationClip, ArcCurve, CanvasTexture, CatmullRomCurve3, Color, CubeTexture, CubicBezierCurve, CubicBezierCurve3, Curve, CurvePath, DataTexture, EllipseCurve, Euler, LineCurve, LineCurve3, Material, MaterialLoader, Matrix2, Matrix3, Matrix4, ObjectLoader, Path, QuadraticBezierCurve, QuadraticBezierCurve3, Quaternion, Shape, Source, Spherical, SplineCurve, Texture, Vector2, Vector3, Vector4, } from 'three' import type {AssetImporter, AssetManager, BlobExt, IAssetImporter, ImportResultExtras} from '../assetmanager' import type {ThreeViewer} from '../viewer' import type {IMaterial, IObject3D, ITexture} from '../core' import type {IRenderTarget, RenderManager} from '../rendering' import {isNonRelativeUrl} from './browser-helpers' import {textureToCanvas} from '../three' const copier = (c: any) => (v: any, o: any) => o?.copy?.(v) ?? new c().copy(v) export class ThreeSerialization { static Primitives = [ [Vector2, 'isVector2', ['x', 'y'], 1], [Vector3, 'isVector3', ['x', 'y', 'z'], 1], [Vector4, 'isVector4', ['x', 'y', 'z', 'w'], 1], [Quaternion, 'isQuaternion', ['x', 'y', 'z', 'w'], 1], [Euler, 'isEuler', ['x', 'y', 'z', 'order'], 1], [Color, 'isColor', ['r', 'g', 'b'], 1], [Matrix2, 'isMatrix2', ['elements'], 1], [Matrix3, 'isMatrix3', ['elements'], 1], [Matrix4, 'isMatrix4', ['elements'], 1], [Spherical, 'isSpherical', ['radius', 'phi', 'theta'], 1], // todo Plane etc (has Vector2) ] as const static PrimitiveSerializer(cls: any, isType: string, props: string[]|Readonly<string[]>, priority = 1): Serializer { return { priority: priority, isType: (obj: any) => obj?.[isType] /* || obj?.metadata?.type === cls.name*/, serialize: (obj: any) => { // if (!obj?.[isType]) throw new Error(`Expected a ${cls.name}`) const ret = {[isType]: true} for (const k of props) ret[k] = obj[k] return ret }, deserialize: copier(cls), // @ts-expect-error type in next version type: isType.startsWith('is') ? isType.slice(2) : cls.name, } } static Texture: Serializer = { priority: 2, isType: (obj: any) => obj.isTexture || obj.metadata?.type === 'Texture', 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 const hasRootPath = !obj.isRenderTargetTexture && obj.userData.rootPath && typeof obj.userData.rootPath === 'string' && isNonRelativeUrl(obj.userData.rootPath) let res = {} as any const ud = obj.userData try { // need try catch here because of hasRootPath 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 } } } obj.userData = {} // toJSON will call JSON.stringify, which will serialize userData const meta2 = {images: {} as any} // in-case meta is undefined res = obj.toJSON(meta || meta2) if (!meta && res.image) res.image = hasRootPath && !obj.userData.embedUrlImagePreviews ? undefined : meta2.images[res.image] res.userData = Serialization.Serialize(copyTextureUserData({}, ud), meta, false) } catch (e) { console.error('ThreeSerialization: Unable to serialize texture') console.error(e) } obj.userData = ud // should be outside try catch 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 } if (meta?.textures && res && !res.resource) { if (!meta.textures[res.uuid]) meta.textures[res.uuid] = res res = {uuid: res.uuid, resource: 'textures'} } return res }, deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => { if (dat.isTexture) return dat if (dat.resource === 'textures' && meta?.textures?.[dat.uuid]) return meta.textures[dat.uuid] console.warn('Cannot deserialize texture into object like primitive, since textures need to be loaded asynchronously. Trying with ObjectLoader. Load events might not work properly.', dat, obj) const loader = meta?._context.objectLoader ?? new ObjectLoader(meta?._context.assetImporter?.loadingManager) const data = {...dat} if (typeof data.image === 'string') { if (!meta?.images) { console.error('ThreeSerialization: Cannot deserialize texture with image url without meta.images', data) } else { data.image = meta.images[data.image] } } if (!data.image || typeof data.image === 'string' || !data.image.isSource && !data.image.url) { console.error('ThreeSerialization: Cannot deserialize texture', data) return obj } let imageOnLoad: undefined | (()=>void) if (meta && !data.image.isSource) { if (!meta._context.imagePromises) meta._context.imagePromises = [] meta._context.imagePromises.push(new Promise<void>((resolve) => { imageOnLoad = resolve })) } const sources = data.image.isSource ? {[data.image.uuid]: data.image as Source} : loader.parseImages([data.image], imageOnLoad) data.image = Object.keys(sources)[0] if (meta?.images) meta.images[data.image] = sources[data.image] if (data.userData) data.userData = ThreeSerialization.Deserialize(data.userData, {}, meta) const textures = loader.parseTextures([data], sources) const uuid = Object.keys(textures)[0] if (!uuid || !textures[uuid]) { console.error('ThreeSerialization: Cannot deserialize texture', data) return obj } if (meta?.textures) meta.textures[uuid] = textures[uuid] return textures[uuid] }, } static SerializableMaterials = new Set<IMaterial['constructor']>() static Material: Serializer = { priority: 2, isType: (obj: any) => obj.isMaterial || obj.metadata?.type === 'Material', serialize: (obj: any, meta?: SerializationMetaType) => { if (!obj?.isMaterial) throw new Error('Expected a material') if (meta?.materials?.[obj.uuid]) return {uuid: obj.uuid, resource: 'materials'} // serialize textures separately const meta2 = meta ?? {textures: {}, images: {}} const objTextures: any = {} const tempTextures: any = {} const propList = Object.keys(obj.constructor.MaterialProperties || obj) // todo use MapProperties? or iMaterialCommons.getMapsForMaterial for (const k of propList) { if (k.startsWith('__')) continue // skip private/internal textures/properties const v = obj[k] if (v?.isTexture) { const ser = Serialization.Serialize(v, meta2) objTextures[k] = ser tempTextures[k] = v obj[k] = ser ? {isTexture: true, toJSON: ()=> ser} : null // because of how threejs Material.toJSON serializes textures } } // Serialize without userData because three.js tries to convert it to string. We are serializing it separately const userData = obj.userData obj.userData = {} let res = {} as any try { res = obj.toJSON(meta || meta2, true) // copying userData is handled in toJSON, see MeshStandardMaterial2 serializeMaterialUserData(res, userData, meta) res.userData.uuid = userData.uuid // todo: override generator to mention that this is a custom serializer? if (obj.constructor.TYPE) res.type = obj.constructor.TYPE // override type if specified as static property in the class // Remove undefined values. Note that null values are kept. for (const key of Object.keys(res)) if (res[key] === undefined) delete res[key] } catch (e) { console.error('ThreeSerialization: Unable to serialize material') console.error(e) } obj.userData = userData // Restore textures for (const [k, v] of Object.entries(tempTextures)) { obj[k] = v delete tempTextures[k] } // Add material, textures, images to meta // serialize textures are already added to meta by the texture serializer if (res) { if (meta) { for (const [k, v] of Object.entries(objTextures)) { if (v) res[k] = v // can be undefined because of RenderTargetTexture... } if (meta.materials) { if (!meta.materials[res.uuid]) meta.materials[res.uuid] = res res = {uuid: res.uuid, resource: 'materials'} } } else { for (const [k, v] of Object.entries(objTextures)) { if (v) res[k] = (v as any).uuid // to remain compatible with how three.js saves } res.textures = Object.values(meta2.textures) res.images = Object.values(meta2.images) } } return res }, deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => { function finalCopy(material: Material) { if (material.isMaterial) { if (obj?.isMaterial && obj.uuid === material.uuid) { if (obj !== material && typeof obj.setValues === 'function') { console.warn('ThreeSerialization: Material uuid already exists, copying values to old material') obj.setValues(material) } return obj } else { return material } } return undefined } let ret = finalCopy(dat) if (ret !== undefined) return ret if (dat.resource === 'materials' && meta?.materials?.[dat.uuid]) { ret = finalCopy(meta.materials[dat.uuid]) if (ret !== undefined) return ret console.error('ThreeSerialization: cannot find material in meta', dat, ret) } const type = dat.type if (!type) { console.error('ThreeSerialization: Cannot deserialize material without type', dat) return obj } const data = {...dat} as Record<string, any> if (data.userData) data.userData = Serialization.Deserialize(data.userData, undefined, meta, false) // const textures: Record<string, Texture> = {} for (const [k, v] of Object.entries(data)) { // for textures if (typeof v === 'string' && meta?.textures?.[v]) { data[k] = meta.textures[v] textures[k] = meta.textures[v] } if (!v || !v.resource || typeof v.resource !== 'string') continue const resource = meta?.[v.resource as 'textures'|'extras']?.[v.uuid] data[k] = resource || null if (v.resource === 'textures' && resource?.isTexture) { textures[k] = resource } } // we have 2 options, either obj is null or it is a material. // if the material is not the same type, we can't use it, should we throw an error or create a new material and assign it. maybe a warning and create a new material? // to create a material, we need to know the type, type->material initialization can be done in either material manager or MaterialLoader // data has deserialized textures and userData, assuming the rest can be deserialized by material.fromJSON if (!obj || !obj.isMaterial || obj.type !== type && obj.constructor?.TYPE !== type) { if (obj && Object.keys(obj).length) console.warn('ThreeSerialization: Material type mismatch during deserialize, creating a new material', obj, data, type, obj.constructor?.type) obj = null } // if obj is not null if (obj && (!data.uuid || obj.uuid === data.uuid)) { if (obj.fromJSON) obj.fromJSON(data, meta, true) else if (obj.setValues) obj.setValues(data) else console.error('ThreeSerialization: Cannot deserialize material, no fromJSON or setValues method', obj, data) return obj } // obj is null or type mismatch, so ignore obj and create a new material // find a material class with the type registered in SerializableMaterials const uuid = dat.isMaterial ? undefined : dat.uuid let template = null as IMaterial['constructor'] | null for (const m of ThreeSerialization.SerializableMaterials) { if (m.TYPE === type) { template = m break } } if (!template) { for (const m of ThreeSerialization.SerializableMaterials) { if (m.TypeAlias?.includes(type)) { template = m break } } } if (template) { const material = new template() if (material) { if (uuid) { safeSetProperty(material, 'uuid', uuid, true, true) } if (material.fromJSON) material.fromJSON(data, meta, true) else if (material.setValues) material.setValues(data) else console.error('ThreeSerialization: Cannot deserialize material, no fromJSON or setValues method', material, data) return material } } // todo use loader from context to load instead of this console.warn('Legacy three.js material deserialization') // normal three.js material const loader = new MaterialLoader() for (const [k, v] of Object.entries(textures)) { data[k] = v.uuid } const texs = {...loader.textures} loader.setTextures(textures) const mat = loader.parse(data) if (data.uuid) { safeSetProperty(mat, 'uuid', data.uuid, true, true) } loader.setTextures(texs) ret = finalCopy(mat) if (ret !== undefined) return ret console.error('ThreeSerialization: cannot deserialize material', dat, ret, mat) }, } static RenderTarget: Serializer = { priority: 2, isType: (obj: any) => obj.isWebGLRenderTarget || obj.metadata?.type === 'RenderTarget', serialize: (obj: IRenderTarget, meta?: SerializationMetaType) => { if (!obj?.isWebGLRenderTarget || !obj.uuid) throw new Error('Expected a IRenderTarget') if (meta?.extras[obj.uuid]) return {uuid: obj.uuid, resource: 'extras'} // This is for the class implementing IRenderTarget, check {@link RenderTargetManager} for class implementation const tex = Array.isArray(obj.texture) ? obj.texture[0] : obj.texture let res: any = { metadata: {type: 'RenderTarget'}, uuid: obj.uuid, width: obj.width, height: obj.height, depth: obj.depth, sizeMultiplier: obj.sizeMultiplier, count: Array.isArray(obj.texture) ? obj.texture.length : undefined, isCubeRenderTarget: obj.isWebGLCubeRenderTarget || undefined, isTemporary: obj.isTemporary, textureName: Array.isArray(obj.texture) ? obj.texture.map(t => t.name) : obj.texture?.name, options: { wrapS: tex?.wrapS, wrapT: tex?.wrapT, magFilter: tex?.magFilter, minFilter: tex?.minFilter, format: tex?.format, type: tex?.type, anisotropy: tex?.anisotropy, depthBuffer: !!obj.depthBuffer, stencilBuffer: !!obj.stencilBuffer, generateMipmaps: tex?.generateMipmaps, depthTexture: !!obj.depthTexture, colorSpace: tex?.colorSpace, samples: obj.samples, }, } if (meta?.extras) { if (!meta.extras[res.uuid]) meta.extras[res.uuid] = res res = {uuid: res.uuid, resource: 'extras'} } return res }, deserialize: (dat: any, obj: any, meta?: SerializationMetaType) => { if (obj?.uuid === dat.uuid) return obj if (dat.isWebGLRenderTarget) return dat const renderManager = meta?._context.renderManager if (!renderManager) { console.error('ThreeSerialization: Cannot deserialize render target without render manager', dat) return obj } if (dat.isWebGLCubeRenderTarget || dat.isTemporary) { // todo support cube, temporary render target here console.warn('ThreeSerialization: Cannot deserialize WebGLCubeRenderTarget or temporary render target yet', dat) return obj } const res = renderManager.createTarget({ sizeMultiplier: dat.sizeMultiplier || undefined, size: dat.sizeMultiplier ? undefined : {width: dat.width, height: dat.height}, textureCount: dat.count, ...dat.options, }) if (dat.textureName) { if (Array.isArray(dat.textureName) && Array.isArray(res.texture)) { for (let i = 0; i < dat.textureName.length; i++) { res.texture[i].name = dat.textureName[i] } } else if (!Array.isArray(res.texture)) { res.texture.name = Array.isArray(dat.textureName) ? dat.textureName[0] : dat.textureName } } if (!res) return res res.uuid = dat.uuid if (meta?.extras) meta.extras[dat.uuid] = res return res }, } private static _init = false static Init() { if (this._init) return this._init = true // @ts-expect-error not sure why it's not set in three.js Spherical.prototype.isSpherical = true Serialization.RegisterSerializer(...ThreeSerialization.Primitives.map(p=>ThreeSerialization.PrimitiveSerializer(p[0], p[1], p[2], p[3]))) Serialization.RegisterSerializer(ThreeSerialization.Texture) Serialization.RegisterSerializer(ThreeSerialization.Material) Serialization.RegisterSerializer(ThreeSerialization.RenderTarget) // these classes have toJSON/fromJSON and .type Serialization.SerializableClasses.set('Shape', Shape) // todo this could be large, it should be a resource in meta for duplicates Serialization.SerializableClasses.set('Curve', Curve) Serialization.SerializableClasses.set('CurvePath', CurvePath) Serialization.SerializableClasses.set('Path', Path) Serialization.SerializableClasses.set('ArcCurve', ArcCurve) Serialization.SerializableClasses.set('CatmullRomCurve3', CatmullRomCurve3) Serialization.SerializableClasses.set('CubicBezierCurve', CubicBezierCurve) Serialization.SerializableClasses.set('CubicBezierCurve3', CubicBezierCurve3) Serialization.SerializableClasses.set('EllipseCurve', EllipseCurve) Serialization.SerializableClasses.set('LineCurve', LineCurve) Serialization.SerializableClasses.set('LineCurve3', LineCurve3) Serialization.SerializableClasses.set('QuadraticBezierCurve', QuadraticBezierCurve) Serialization.SerializableClasses.set('QuadraticBezierCurve3', QuadraticBezierCurve3) Serialization.SerializableClasses.set('SplineCurve', SplineCurve) Serialization.SerializableClasses.set('AnimationClip', AnimationClip) // Serialization.SerializableClasses.set('Skeleton', Skeleton) // doesnt have .type. todo add to three.js } static MakeSerializable(constructor: ObjectConstructor, type: string, props?: (string|[string, string])[]) { (constructor.prototype as any).serializableClassId = type Serialization.SerializableClasses.set(type, constructor) if (props) Serialization.TypeMap.set(constructor, props.map(p=>typeof p === 'string' ? [p, p] : p)) } /** * Serialize an object * {@link Serialization.Serialize} */ static Serialize(obj: any, meta?: Partial<SerializationMetaType>, isThis = false) { if (!this._init) this.Init() return Serialization.Serialize(obj, meta, isThis) } /** * Deserialize an object * {@link Serialization.Deserialize} */ static Deserialize(data: any, obj: any, meta?: Partial<SerializationMetaType>, isThis = false) { if (!this._init) this.Init() return Serialization.Deserialize(data, obj, meta, isThis) } } /** * Deep copy/clone from source to dest, assuming both are userData objects for three.js objects/materials/textures etc. * This will clone any property that can be cloned (apart from Object3D, Texture, Material) and deep copy the objects and arrays. * @note Keep synced with copyMaterialUserData in three.js -> Material.js todo: merge these functions? by putting this inside three.js? * @param dest * @param source * @param ignoredKeysInRoot - keys to ignore in the root object */ export function copyUserData(dest: any, source: any, ignoredKeysInRoot: (string|symbol)[] = []): any { if (!source) return dest for (const key of Object.keys(source)) { if (ignoredKeysInRoot.includes(key)) continue if (key.startsWith('__')) continue // double underscore const src = source[key] if (typeof dest[key] === 'function' || typeof src === 'function') continue const skipClone = !src || src.isTexture || src.isObject3D || src.isMaterial || src.isBufferGeometry || src.userDataSkipClone if (!skipClone && typeof src.clone === 'function') dest[key] = src.clone() // else if (!skipClone && (typeof src === 'object' || Array.isArray(src))) else if (!skipClone && (src.constructor === Object || Array.isArray(src))) dest[key] = copyUserData(Array.isArray(src) ? [] : {}, src, []) else dest[key] = src } return dest } /** * Deep copy/clone from source to dest, assuming both are userData objects in Textures. * Same as {@link copyUserData} but ignores uuid in the root object. * @param dest * @param source * @param ignoredKeysInRoot */ export function copyTextureUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any { return copyUserData(dest, source, ignoredKeysInRoot) } /** * Deep copy/clone from source to dest, assuming both are userData objects in Materials. * Same as {@link copyUserData} but ignores uuid in the root object. * @note Keep synced with copyMaterialUserData in three.js -> Material.js * @param dest * @param source * @param ignoredKeysInRoot */ export function copyMaterialUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any { return copyUserData(dest, source, ignoredKeysInRoot) } /** * Deep copy/clone from source to dest, assuming both are userData objects in Object3D. * Same as {@link copyUserData} but ignores uuid in the root object. * @param dest * @param source * @param ignoredKeysInRoot */ export function copyObject3DUserData(dest: any, source: any, ignoredKeysInRoot = ['uuid']): any { return copyUserData(dest, source, ignoredKeysInRoot) } /** * Serialize userData and sets to data.userData. This is required because three.js Material.toJSON does not serialize userData. * @param data * @param userData * @param meta */ function serializeMaterialUserData(data: any, userData: any, meta?: SerializationMetaType) { data.userData = {} copyMaterialUserData(data.userData, userData) // Serialize the userData const meta2 = meta || { // Make meta object for the Serializer from the data. This requires changing from Array to Object for textures and images textures: Object.fromEntries(data.textures?.map((t: any) => [t.uuid, t]) || []), images: Object.fromEntries(data.images?.map((t: any) => [t.uuid, t]) || []), } data.userData = Serialization.Serialize(data.userData, meta2) // here meta is required for textures otherwise images will be lost. Material.toJSON sets the result as meta if not provided. if (!meta) { // Add textures and images to the result if meta is not provided. This is to remain compatible with how three.js saves materials. See (MaterialLoader and JSONMaterialLoader) if (Object.keys(meta2.textures).length > 0) data.textures = Object.values(meta2.textures) if (Object.keys(meta2.images).length > 0) data.images = Object.values(meta2.images) } } /** * Converts array buffers to base64 strings in meta. * This is useful when storing .json files, as storing as number arrays takes a lot of space. * Used in viewer.toJSON() * @param meta */ export function convertArrayBufferToStringsInMeta(meta: SerializationMetaType) { Object.values(meta).forEach((res: any) => { // similar to processViewer in gltf export. if (res) Object.values(res).forEach((item: any) => { if (!item.url) return // console.log(item.url) if (!(item.url.data instanceof ArrayBuffer) && !Array.isArray(item.url.data)) return if (item.url.type === 'Uint16Array') { if (!(item.url.data instanceof Uint16Array)) { // because it can be a typed array item.url.data = new Uint16Array(item.url.data) } item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer) } else if (item.url.type === 'Uint8Array') { if (!(item.url.data instanceof Uint8Array)) { // because it can be a typed array item.url.data = new Uint8Array(item.url.data) } // todo: just use jpeg or PNG encoding for this ? item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer) } else if (item.url.data instanceof ArrayBuffer) { item.url.data = 'data:application/octet-stream;base64,' + arrayBufferToBase64(item.url.data.buffer) } else { console.warn('Unsupported buffer type', item.url.type) } }) }) } /** * Converts strings(base64 or utf-8) to array buffers in meta. This is the reverse of {@link convertArrayBufferToStringsInMeta} * Used in viewer.fromJSON() */ export function convertStringsToArrayBuffersInMeta(meta: SerializationMetaType) { Object.values(meta).forEach((res: any) => { // similar to processViewer in gltf export. if (res) Object.values(res).forEach((item: any) => { if (!item || !item.url) return if (typeof item.url.data !== 'string') return // base64 data uri or any mime type // console.log(item.url.data?.match?.(/^data:.*;base64,(.*)$/)) const dataUriMatch = item.url.data.match(/^data:.*;base64,(.*)$/) if (dataUriMatch?.[1]) { item.url.data = base64ToArrayBuffer(dataUriMatch?.[1]) } else { // utf-8 string, not used at the moment if (item.url.type !== 'Uint8Array') { console.error('ThreeSerialization: Unsupported buffer type string for ', item.url.type, 'use base64') } item.url.data = new TextEncoder().encode(item.url.data).buffer // todo: this doesnt work in ie/edge maybe, but this feature is not used. } }) }) } export function getEmptyMeta(res?: Partial<SerializationResourcesType>): SerializationMetaType { return { // see Object3D.js toJSON for more details geometries: {...res?.geometries}, materials: {...res?.materials}, textures: {...res?.textures}, images: {...res?.images}, shapes: {...res?.shapes}, skeletons: {...res?.skeletons}, animations: {...res?.animations}, extras: {...res?.extras}, typed: {...res?.typed}, _context: {}, } } export interface SerializationResourcesType { geometries: Record<string, any>, materials: Record<string, any>, textures: Record<string, any>, images: Record<string, any>, shapes: Record<string, any>, skeletons: Record<string, any>, animations: Record<string, any>, extras: Record<string, any>, typed: Record<string, any>, object?: any, // todo what is this used for? [key: string]: any, } export interface SerializationMetaType extends SerializationResourcesType { _context: { assetImporter?: AssetImporter, objectLoader?: ObjectLoader, assetManager?: AssetManager, renderManager?: RenderManager, imagePromises?: Promise<any>[], viewer?: ThreeViewer, [key: string]: any, } __isLoadedResources?: boolean } export class MetaImporter { /** * @param json * @param extraResources - preloaded resources in the format of viewer config resources. */ static async ImportMeta(json: SerializationMetaType, extraResources?: Partial<SerializationResourcesType>) { // console.log(json) if (json.__isLoadedResources) return json const resources: SerializationMetaType = metaFromResources() resources.__isLoadedResources = true resources._context = json._context convertStringsToArrayBuffersInMeta(json) // console.log(viewerConfig) const assetImporter = json._context.assetImporter if (!assetImporter) throw new Error('assetImporter not found in meta context, which is required for import meta.') const objLoader = json._context.objectLoader || new ObjectLoader(assetImporter.loadingManager) // see ObjectLoader.parseAsync resources.animations = json.animations ? objLoader.parseAnimations(Object.values(json.animations)) : {} if (extraResources && extraResources.animations) resources.animations = {...resources.animations, ...extraResources.animations} resources.shapes = json.shapes ? objLoader.parseShapes(Object.values(json.shapes)) : {} if (extraResources && extraResources.shapes) resources.shapes = {...resources.shapes, ...extraResources.shapes} resources.geometries = json.geometries ? objLoader.parseGeometries(Object.values(json.geometries), resources.shapes) : {} if (extraResources && extraResources.geometries) resources.geometries = {...resources.geometries, ...extraResources.geometries} resources.images = json.images ? await objLoader.parseImagesAsync(Object.values(json.images)) : {} // local images only like data url and data textures if (extraResources && extraResources.images) resources.images = {...resources.images, ...extraResources.images} // const onLoad = () => { // todo: do it after all the images not after one // Object.values(resources.textures).forEach((t: any) => { // if (t.isTexture && t.image?.complete) t.needsUpdate = true // }) // } if (Array.isArray(json.textures)) { console.error('ThreeSerialization: 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) const textures = [] for (const texture of Object.values(json.textures)) { const tex = {...texture} if (tex.userData) tex.userData = ThreeSerialization.Deserialize(tex.userData, {}, resources) textures.push(tex) } resources.textures = json.textures ? objLoader.parseTextures(textures, resources.images) : {} for (const key1 of Object.keys(resources.textures)) { let tex: Texture|undefined = resources.textures[key1] if (!tex) continue // __texCtor is set in MetaImporter.LoadRootPathTextures if (tex.source.__texCtor) { const newTex: Texture = new tex.source.__texCtor(tex.source.data) if (!newTex || typeof newTex.copy !== 'function') continue newTex.copy(tex) delete tex.source.__texCtor resources.textures[key1] = newTex tex = newTex } if (tex.source.data instanceof HTMLCanvasElement && !(tex as CanvasTexture).isCanvasTexture) { const newTex = new CanvasTexture(tex.source.data).copy(tex) resources.textures[key1] = newTex tex = newTex } } // 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] else delete resources.textures[entry[0]] } if (extraResources && extraResources.textures) resources.textures = {...resources.textures, ...extraResources.textures} const jsonMats: any[] = json.materials ? Object.values(json.materials) : [] resources.materials = {} for (const material of jsonMats) { if (!material?.uuid) continue // Object.entries(material).forEach(([k, data]: [string, any]) => { // if (data && data.resource && data.uuid && data.resource === 'textures') { // for textures put in by serialize.ts // material[k] = data.uuid // } // }) resources.materials[material.uuid] = ThreeSerialization.Deserialize(material, undefined, resources) } if (extraResources && extraResources.materials) resources.materials = {...resources.materials, ...extraResources.materials} if (json.object) { resources.object = objLoader.parseObject(json.object, resources.geometries, resources.materials, resources.textures, resources.animations) if (json.skeletons) { resources.skeletons = objLoader.parseSkeletons(Object.values(json.skeletons), resources.object as any) objLoader.bindSkeletons(resources.object as any, resources.skeletons) } } if (json.extras) { resources.extras = json.extras for (const e of (Object.values(json.extras) as any as any[])) { // todo parallel import if (!e.uuid) continue if (!e.url) { resources.extras[e.uuid] = ThreeSerialization.Deserialize(e, undefined, resources) continue } // see LUTCubeTextureWrapper, KTX2LoadPlugin for sample use if (typeof e.url === 'string') { const r = await assetImporter.importSingle(e.url, e.userData?.rootPathOptions || {}) // todo rootPathOptions is not being set when exporting extras right now if (r) resources.extras[e.uuid] = r } else if (e.url.data) { const file = new File([getTypedArray(e.url.type, e.url.data)], e.url.path) const r = await assetImporter.importSingle({path: file.name, file}, e.userData?.rootPathOptions || {}, undefined, false) // false is passed to mark it as external // todo: userdata? name? other properties? if (r) resources.extras[e.uuid] = r } else { console.warn('invalid URL type while loading extra resource') } } // console.log(resources.extras) } if (extraResources && extraResources.extras) resources.extras = {...resources.extras, ...extraResources.extras} resources.typed = {} if (json.typed) { for (const [key, item] of Object.entries(json.typed)) { if (typeof item.rootPath === 'string' && item.external) { // todo parallel import const r = await assetImporter.importSingle(item.rootPath, item.rootPathOptions || {}) if (r) resources.typed[key] = r } else { resources.typed[key] = ThreeSerialization.Deserialize(item, undefined, resources) } } } if (extraResources && extraResources.typed) resources.typed = {...resources.typed, ...extraResources.typed} // console.log(resources, json) return resources } // todo see _loadObjectDependencies2 static async LoadRootPathTextures({textures, images}: Pick<SerializationMetaType, 'textures'|'images'>, importer: IAssetImporter, usePreviewImages = true) { const pms = [] for (const inpTexture of Array.isArray(textures) ? textures : Object.values(textures ?? {} as any) as any as any[]) { const path = inpTexture?.userData?.rootPath const hasImage = usePreviewImages && inpTexture.image && images[inpTexture.image] // its possible to have both image and rootPath, then the image will be preview image. if (!path) continue const promise = importer.importSingle<ITexture>(path, inpTexture.userData.rootPathOptions || {}).then((texture) => { const source = texture?.source as any if (!texture || !texture.isTexture || !source) { console.error('AssetImporter: Imported rootPath is not a Texture', path, texture) return } // console.log(typeof image) const source2 = new Source(source.data) if (inpTexture.image) source2.uuid = inpTexture.image inpTexture.image = source2.uuid // only these are supported by ObjectLoader.parseTextures, see parseTextures2 if (texture.constructor !== Texture && texture.constructor !== DataTexture && texture.constructor !== CubeTexture) { source2.__texCtor = texture.constructor as typeof Texture } if (!hasImage) images[source2.uuid] = source2 texture.dispose() return source2 }).catch((e) => { console.error('ThreeSerialization: Error loading texture from rootPath', inpTexture.userData.rootPath) console.error(e) delete inpTexture.userData.rootPath return null }) if (hasImage) inpTexture.rootPathPromise = promise else pms.push(promise) } await Promise.allSettled(pms) } } export function metaToResources(meta?: SerializationMetaType): Partial<SerializationResourcesType> { if (!meta) return {} const res: Partial<SerializationResourcesType> = {...meta} if (res._context) delete res._context return res } export function mergeResources(target: Partial<SerializationResourcesType>, source: Partial<SerializationResourcesType>) { for (const key of Object.keys(source)) { if (key === 'object') continue if (!target[key]) target[key] = {} Object.assign(target[key]!, source[key]!) } return target } export function metaFromResources(resources?: Partial<SerializationResourcesType>, viewer?: ThreeViewer): SerializationMetaType { return { ...resources, ...getEmptyMeta(resources), _context: { assetManager: viewer?.assetManager, assetImporter: viewer?.assetManager.importer, renderManager: viewer?.renderManager, viewer: viewer, }, // clear context even if its present in resources } } export function jsonToBlob(json: any): BlobExt { const b = new Blob([JSON.stringify(json)], {type: 'application/json'}) as BlobExt b.ext = 'json' return b } /** * Used in {@link LUTCubeTextureWrapper} and {@link KTX2LoadPlugin} and imported in {@link ThreeViewer.loadConfigResources} * @param texture * @param meta * @param name * @param mime */ export function serializeTextureInExtras(texture: ITexture & ImportResultExtras, meta: any, name?: string, mime?: string) { if (meta?.extras[texture.uuid]) return {uuid: texture.uuid, resource: 'extras'} let url: any = '' if (texture.source?._sourceImgBuffer || texture.__sourceBuffer) { // serialize blob to data in image. // Note: do not change to Uint16Array because it's encoded to rgbe in `processViewer` const data = new Uint8Array(texture.source?._sourceImgBuffer || texture.__sourceBuffer as ArrayBuffer) const mimeType = mime || texture.userData.mimeType || '' url = { data: Array.from(data), // texture need to be a normal array, not a typed array. type: data.constructor.name, path: texture.userData.__sourceBlob?.name || texture.userData.rootPath || 'file.' + mimeType.split('/')[1], } if (mimeType) url.mimeType = mimeType } else if (texture.userData.rootPath) { url = texture.userData.rootPath } else { console.error('ThreeSerialization: Unable to serialize LUT texture, not loaded through asset manager.') } const tex = { uuid: texture.uuid, url, userData: copyTextureUserData({}, texture.userData), type: texture.type, name: name || texture.name, } if (meta?.extras) { meta.extras[texture.uuid] = tex return {uuid: texture.uuid, resource: 'extras'} } return tex } declare module 'three'{ export interface Source{ ['__texCtor']?: typeof Texture } } export function getPartialProps(obj: IObject3D|IMaterial, props1?: string[]) { // copy properties from res1 to obj except those in sProperties const props: Record<string, any> = {} const sProps = Array.isArray(props1) ? props1 : [] for (const sProp of sProps) { const deep = sProp.startsWith('userData.') let res2 if (deep) { res2 = deepAccessObject(sProp.slice('userData.'.length), obj.userData, false) } else { res2 = (obj as any)[sProp] } if (res2 !== undefined) { props[sProp] = res2 } } return props } export function setPartialProps(props: Record<string, any>, obj: IMaterial|IObject3D) { for (const sProp of Object.keys(props)) { const value = props[sProp] const deep = sProp.startsWith('userData.') if (!deep) { (obj as any)[sProp] = value } else { const tar = obj.userData const parts = sProp.split('.') const tarkey = parts.slice(1, -1) const tar2 = parts.length && tarkey.length ? deepAccessObject(tarkey, tar, false) : undefined if (tar2 !== undefined) { const key = parts[parts.length - 1] tar2[key] = value } else { // todo for userData deep property it will fail since parent object wouldnt exist in empty object. we need to create the empty target recursively if it doesnt exists console.warn('ThreeSerialization: setSProps: invalid sProperty', sProp, 'in', obj) } } } }