import { ComponentTypeToTypedArray, Format, GLB_BUFFER, PropertyType, type TypedArray, VERSION, VertexLayout, } from '../constants.js'; import type { Document } from '../document.js'; import type { Extension } from '../extension.js'; import type { JSONDocument } from '../json-document.js'; import { Accessor, type AnimationSampler, Camera, Material } from '../properties/index.js'; import type { GLTF } from '../types/gltf.js'; import { BufferUtils, Logger, MathUtils } from '../utils/index.js'; import { WriterContext } from './writer-context.js'; const { BufferViewUsage } = WriterContext; const { UNSIGNED_INT, UNSIGNED_SHORT, UNSIGNED_BYTE } = Accessor.ComponentType; export interface WriterOptions { format: Format; logger?: Logger; basename?: string; vertexLayout?: VertexLayout; dependencies?: { [key: string]: unknown }; extensions?: (typeof Extension)[]; } const SUPPORTED_PREWRITE_TYPES = new Set([ PropertyType.ACCESSOR, PropertyType.BUFFER, PropertyType.MATERIAL, PropertyType.MESH, ]); /** * @internal * @hidden */ export class GLTFWriter { public static write(doc: Document, options: Required): JSONDocument { const graph = doc.getGraph(); const root = doc.getRoot(); const json = { asset: { generator: `glTF-Transform ${VERSION}`, ...root.getAsset() }, extras: { ...root.getExtras() }, } as GLTF.IGLTF; const jsonDoc = { json, resources: {} } as JSONDocument; const context = new WriterContext(doc, jsonDoc, options); const logger = options.logger || Logger.DEFAULT_INSTANCE; /* Extensions (1/2). */ // Extensions present on the Document are not written unless they are also registered with // the I/O class. This ensures that setup in `extension.register()` is completed, and // allows a Document to be written with specific extensions disabled. const extensionsRegistered = new Set(options.extensions.map((ext) => ext.EXTENSION_NAME)); const extensionsUsed = doc .getRoot() .listExtensionsUsed() .filter((ext) => extensionsRegistered.has(ext.extensionName)) .sort((a, b) => (a.extensionName > b.extensionName ? 1 : -1)); const extensionsRequired = doc .getRoot() .listExtensionsRequired() .filter((ext) => extensionsRegistered.has(ext.extensionName)) .sort((a, b) => (a.extensionName > b.extensionName ? 1 : -1)); if (extensionsUsed.length < doc.getRoot().listExtensionsUsed().length) { logger.warn('Some extensions were not registered for I/O, and will not be written.'); } for (const extension of extensionsUsed) { // Warn on unsupported prewrite hooks. const unsupportedHooks = extension.prewriteTypes.filter((type) => !SUPPORTED_PREWRITE_TYPES.has(type)); if (unsupportedHooks.length) { logger.warn( `Prewrite hooks for some types (${unsupportedHooks.join()}), requested by extension ` + `${extension.extensionName}, are unsupported. Please file an issue or a PR.`, ); } // Install dependencies. for (const key of extension.writeDependencies) { extension.install(key, options.dependencies[key]); } } /* Utilities. */ interface BufferViewResult { byteLength: number; buffers: Uint8Array[]; } /** * Pack a group of accessors into a sequential buffer view. Appends accessor and buffer view * definitions to the root JSON lists. * * @param accessors Accessors to be included. * @param bufferIndex Buffer to write to. * @param bufferByteOffset Current offset into the buffer, accounting for other buffer views. * @param bufferViewTarget (Optional) target use of the buffer view. */ function concatAccessors( accessors: Accessor[], bufferIndex: number, bufferByteOffset: number, bufferViewTarget?: number, ): BufferViewResult { const buffers: Uint8Array[] = []; let byteLength = 0; // Create accessor definitions, determining size of final buffer view. for (const accessor of accessors) { const accessorDef = context.createAccessorDef(accessor); accessorDef.bufferView = json.bufferViews!.length; const accessorArray = accessor.getArray()!; const data = BufferUtils.pad(BufferUtils.toView(accessorArray)); accessorDef.byteOffset = byteLength; byteLength += data.byteLength; buffers.push(data); context.accessorIndexMap.set(accessor, json.accessors!.length); json.accessors!.push(accessorDef); } // Create buffer view definition. const bufferViewData = BufferUtils.concat(buffers); const bufferViewDef: GLTF.IBufferView = { buffer: bufferIndex, byteOffset: bufferByteOffset, byteLength: bufferViewData.byteLength, }; if (bufferViewTarget) bufferViewDef.target = bufferViewTarget; json.bufferViews!.push(bufferViewDef); return { buffers, byteLength }; } /** * Pack a group of accessors into an interleaved buffer view. Appends accessor and buffer * view definitions to the root JSON lists. Buffer view target is implicitly attribute data. * * References: * - [Apple • Best Practices for Working with Vertex Data](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/TechniquesforWorkingwithVertexData/TechniquesforWorkingwithVertexData.html) * - [Khronos • Vertex Specification Best Practices](https://www.khronos.org/opengl/wiki/Vertex_Specification_Best_Practices) * * @param accessors Accessors to be included. * @param bufferIndex Buffer to write to. * @param bufferByteOffset Offset into the buffer, accounting for other buffer views. */ function interleaveAccessors( accessors: Accessor[], bufferIndex: number, bufferByteOffset: number, ): BufferViewResult { const vertexCount = accessors[0].getCount(); let byteStride = 0; // Create accessor definitions, determining size and stride of final buffer view. for (const accessor of accessors) { const accessorDef = context.createAccessorDef(accessor); accessorDef.bufferView = json.bufferViews!.length; accessorDef.byteOffset = byteStride; const elementSize = accessor.getElementSize(); const componentSize = accessor.getComponentSize(); byteStride += BufferUtils.padNumber(elementSize * componentSize); context.accessorIndexMap.set(accessor, json.accessors!.length); json.accessors!.push(accessorDef); } // Allocate interleaved buffer view. const byteLength = vertexCount * byteStride; const buffer = new ArrayBuffer(byteLength); const view = new DataView(buffer); // Write interleaved accessor data to the buffer view. for (let i = 0; i < vertexCount; i++) { let vertexByteOffset = 0; for (const accessor of accessors) { const elementSize = accessor.getElementSize(); const componentSize = accessor.getComponentSize(); const componentType = accessor.getComponentType(); const array = accessor.getArray()!; for (let j = 0; j < elementSize; j++) { const viewByteOffset = i * byteStride + vertexByteOffset + j * componentSize; const value = array[i * elementSize + j]; switch (componentType) { case Accessor.ComponentType.FLOAT: view.setFloat32(viewByteOffset, value, true); break; case Accessor.ComponentType.BYTE: view.setInt8(viewByteOffset, value); break; case Accessor.ComponentType.SHORT: view.setInt16(viewByteOffset, value, true); break; case Accessor.ComponentType.UNSIGNED_BYTE: view.setUint8(viewByteOffset, value); break; case Accessor.ComponentType.UNSIGNED_SHORT: view.setUint16(viewByteOffset, value, true); break; case Accessor.ComponentType.UNSIGNED_INT: view.setUint32(viewByteOffset, value, true); break; default: throw new Error('Unexpected component type: ' + componentType); } } vertexByteOffset += BufferUtils.padNumber(elementSize * componentSize); } } // Create buffer view definition. const bufferViewDef: GLTF.IBufferView = { buffer: bufferIndex, byteOffset: bufferByteOffset, byteLength: byteLength, byteStride: byteStride, target: WriterContext.BufferViewTarget.ARRAY_BUFFER, }; json.bufferViews!.push(bufferViewDef); return { byteLength, buffers: [new Uint8Array(buffer)] }; } /** * Pack a group of sparse accessors. Appends accessor and buffer view * definitions to the root JSON lists. * * @param accessors Accessors to be included. * @param bufferIndex Buffer to write to. * @param bufferByteOffset Current offset into the buffer, accounting for other buffer views. */ function concatSparseAccessors( accessors: Accessor[], bufferIndex: number, bufferByteOffset: number, ): BufferViewResult { const buffers: Uint8Array[] = []; let byteLength = 0; interface SparseData { accessorDef: GLTF.IAccessor; count: number; indices?: number[]; values?: TypedArray; indicesByteOffset?: number; valuesByteOffset?: number; } const sparseData = new Map(); let maxIndex = -Infinity; let needSparseWarning = false; // (1) Write accessor definitions, gathering indices and values. for (const accessor of accessors) { const accessorDef = context.createAccessorDef(accessor); json.accessors!.push(accessorDef); context.accessorIndexMap.set(accessor, json.accessors!.length - 1); const indices = []; const values = []; const el = [] as number[]; const base = new Array(accessor.getElementSize()).fill(0); for (let i = 0, il = accessor.getCount(); i < il; i++) { accessor.getElement(i, el); if (MathUtils.eq(el, base, 0)) continue; maxIndex = Math.max(i, maxIndex); indices.push(i); for (let j = 0; j < el.length; j++) values.push(el[j]); } const count = indices.length; const data: SparseData = { accessorDef, count }; sparseData.set(accessor, data); if (count === 0) continue; if (count > accessor.getCount() / 2) { needSparseWarning = true; } const ValueArray = ComponentTypeToTypedArray[accessor.getComponentType()]; data.indices = indices; data.values = new ValueArray(values); } // (2) Early exit if all sparse accessors are just zero-filled arrays. if (!Number.isFinite(maxIndex)) { return { buffers, byteLength }; } if (needSparseWarning) { logger.warn(`Some sparse accessors have >50% non-zero elements, which may increase file size.`); } // (3) Write index buffer view. const IndexArray = maxIndex < 255 ? Uint8Array : maxIndex < 65535 ? Uint16Array : Uint32Array; const IndexComponentType = maxIndex < 255 ? UNSIGNED_BYTE : maxIndex < 65535 ? UNSIGNED_SHORT : UNSIGNED_INT; const indicesBufferViewDef: GLTF.IBufferView = { buffer: bufferIndex, byteOffset: bufferByteOffset + byteLength, byteLength: 0, }; for (const accessor of accessors) { const data = sparseData.get(accessor)!; if (data.count === 0) continue; data.indicesByteOffset = indicesBufferViewDef.byteLength; const buffer = BufferUtils.pad(BufferUtils.toView(new IndexArray(data.indices!))); buffers.push(buffer); byteLength += buffer.byteLength; indicesBufferViewDef.byteLength += buffer.byteLength; } json.bufferViews!.push(indicesBufferViewDef); const indicesBufferViewIndex = json.bufferViews!.length - 1; // (4) Write value buffer view. const valuesBufferViewDef: GLTF.IBufferView = { buffer: bufferIndex, byteOffset: bufferByteOffset + byteLength, byteLength: 0, }; for (const accessor of accessors) { const data = sparseData.get(accessor)!; if (data.count === 0) continue; data.valuesByteOffset = valuesBufferViewDef.byteLength; const buffer = BufferUtils.pad(BufferUtils.toView(data.values!)); buffers.push(buffer); byteLength += buffer.byteLength; valuesBufferViewDef.byteLength += buffer.byteLength; } json.bufferViews!.push(valuesBufferViewDef); const valuesBufferViewIndex = json.bufferViews!.length - 1; // (5) Write accessor sparse entries. for (const accessor of accessors) { const data = sparseData.get(accessor) as Required; if (data.count === 0) continue; data.accessorDef.sparse = { count: data.count, indices: { bufferView: indicesBufferViewIndex, byteOffset: data.indicesByteOffset, componentType: IndexComponentType, }, values: { bufferView: valuesBufferViewIndex, byteOffset: data.valuesByteOffset, }, }; } return { buffers, byteLength }; } json.accessors = []; json.bufferViews = []; /* Textures. */ // glTF Transform's "Texture" properties correspond 1:1 with glTF "Image" properties, and // with image files. The glTF file may contain more one texture per image, where images // are reused with different sampler properties. json.samplers = []; json.textures = []; json.images = root.listTextures().map((texture, textureIndex) => { const imageDef = context.createPropertyDef(texture) as GLTF.IImage; if (texture.getMimeType()) { imageDef.mimeType = texture.getMimeType(); } const image = texture.getImage(); if (image) { context.createImageData(imageDef, image, texture); } context.imageIndexMap.set(texture, textureIndex); return imageDef; }); /* Accessors. */ extensionsUsed .filter((extension) => extension.prewriteTypes.includes(PropertyType.ACCESSOR)) .forEach((extension) => extension.prewrite(context, PropertyType.ACCESSOR)); root.listAccessors().forEach((accessor) => { // Attributes are grouped and interleaved in one buffer view per mesh primitive. // Indices for all primitives are grouped into a single buffer view. IBMs are grouped // into a single buffer view. Other usage (if specified by extensions) also goes into // a dedicated buffer view. Everything else goes into a miscellaneous buffer view. // Certain accessor usage should group data into buffer views by the accessor parent. // The `accessorParents` map uses the first parent of each accessor for this purpose. const groupByParent = context.accessorUsageGroupedByParent; const accessorParents = context.accessorParents; // Skip if already written by an extension. if (context.accessorIndexMap.has(accessor)) return; // Assign usage for core accessor usage types (explicit targets and implicit usage). const usage = context.getAccessorUsage(accessor); context.addAccessorToUsageGroup(accessor, usage); // For accessor usage that requires grouping by parent (vertex and instance // attributes) organize buffer views accordingly. if (groupByParent.has(usage)) { const parent = graph.listParents(accessor).find((parent) => parent.propertyType !== PropertyType.ROOT)!; accessorParents.set(accessor, parent); } }); /* Buffers, buffer views. */ extensionsUsed .filter((extension) => extension.prewriteTypes.includes(PropertyType.BUFFER)) .forEach((extension) => extension.prewrite(context, PropertyType.BUFFER)); const needsBuffer = root.listAccessors().length > 0 || context.otherBufferViews.size > 0 || (root.listTextures().length > 0 && options.format === Format.GLB); if (needsBuffer && root.listBuffers().length === 0) { throw new Error('Buffer required for Document resources, but none was found.'); } json.buffers = []; root.listBuffers().forEach((buffer, index) => { const bufferDef = context.createPropertyDef(buffer) as GLTF.IBuffer; const groupByParent = context.accessorUsageGroupedByParent; const accessors = buffer.listParents().filter((property) => property instanceof Accessor) as Accessor[]; const uniqueParents = new Set(accessors.map((accessor) => context.accessorParents.get(accessor))); const parentToIndex = new Map(Array.from(uniqueParents).map((parent, index) => [parent, index])); // Group by usage and (first) parent, including vertex and instance attributes. type AccessorGroup = { usage: string; accessors: Accessor[] }; const accessorGroups: Record = {}; for (const accessor of accessors) { // Skip if already written by an extension. if (context.accessorIndexMap.has(accessor)) continue; const usage = context.getAccessorUsage(accessor); let key = usage; if (groupByParent.has(usage)) { const parent = context.accessorParents.get(accessor); key += `:${parentToIndex.get(parent)}`; } accessorGroups[key] ||= { usage, accessors: [] }; accessorGroups[key].accessors.push(accessor); } // Write accessor groups to buffer views. const buffers: Uint8Array[] = []; const bufferIndex = json.buffers!.length; let bufferByteLength = 0; for (const { usage, accessors: groupAccessors } of Object.values(accessorGroups)) { if (usage === BufferViewUsage.ARRAY_BUFFER && options.vertexLayout === VertexLayout.INTERLEAVED) { // (1) Interleaved vertex attributes. const result = interleaveAccessors(groupAccessors, bufferIndex, bufferByteLength); bufferByteLength += result.byteLength; for (const buffer of result.buffers) { buffers.push(buffer); } } else if (usage === BufferViewUsage.ARRAY_BUFFER) { // (2) Non-interleaved vertex attributes. for (const accessor of groupAccessors) { // We 'interleave' a single accessor because the method pads to // 4-byte boundaries, which concatAccessors() does not. const result = interleaveAccessors([accessor], bufferIndex, bufferByteLength); bufferByteLength += result.byteLength; for (const buffer of result.buffers) { buffers.push(buffer); } } } else if (usage === BufferViewUsage.SPARSE) { // (3) Sparse accessors. const result = concatSparseAccessors(groupAccessors, bufferIndex, bufferByteLength); bufferByteLength += result.byteLength; for (const buffer of result.buffers) { buffers.push(buffer); } } else if (usage === BufferViewUsage.ELEMENT_ARRAY_BUFFER) { // (4) Indices. const target = WriterContext.BufferViewTarget.ELEMENT_ARRAY_BUFFER; const result = concatAccessors(groupAccessors, bufferIndex, bufferByteLength, target); bufferByteLength += result.byteLength; for (const buffer of result.buffers) { buffers.push(buffer); } } else { // (5) Other. const result = concatAccessors(groupAccessors, bufferIndex, bufferByteLength); bufferByteLength += result.byteLength; for (const buffer of result.buffers) { buffers.push(buffer); } } } // We only support embedded images in GLB, where the embedded buffer must be the first. // Additional buffers are currently left empty (see EXT_meshopt_compression fallback). if (context.imageBufferViews.length && index === 0) { for (let i = 0; i < context.imageBufferViews.length; i++) { json.bufferViews![json.images![i].bufferView!].byteOffset = bufferByteLength; bufferByteLength += context.imageBufferViews[i].byteLength; buffers.push(context.imageBufferViews[i]); if (bufferByteLength % 8) { // See: https://github.com/KhronosGroup/glTF/issues/1935 const imagePadding = 8 - (bufferByteLength % 8); bufferByteLength += imagePadding; buffers.push(new Uint8Array(imagePadding)); } } } if (context.otherBufferViews.has(buffer)) { for (const data of context.otherBufferViews.get(buffer)!) { json.bufferViews!.push({ buffer: bufferIndex, byteOffset: bufferByteLength, byteLength: data.byteLength, }); context.otherBufferViewsIndexMap.set(data, json.bufferViews!.length - 1); bufferByteLength += data.byteLength; buffers.push(data); } } if (bufferByteLength) { // Assign buffer URI. let uri: string; if (options.format === Format.GLB) { uri = GLB_BUFFER; } else { uri = context.bufferURIGenerator.createURI(buffer, 'bin'); bufferDef.uri = uri; } // Write buffer views to buffer. bufferDef.byteLength = bufferByteLength; context.assignResourceURI(uri, BufferUtils.concat(buffers), true); } json.buffers!.push(bufferDef); context.bufferIndexMap.set(buffer, index); }); if (root.listAccessors().find((a) => !a.getBuffer())) { logger.warn('Skipped writing one or more Accessors: no Buffer assigned.'); } /* Materials. */ extensionsUsed .filter((extension) => extension.prewriteTypes.includes(PropertyType.MATERIAL)) .forEach((extension) => extension.prewrite(context, PropertyType.MATERIAL)); json.materials = root.listMaterials().map((material, index) => { const materialDef = context.createPropertyDef(material) as GLTF.IMaterial; // Program state & blending. if (material.getAlphaMode() !== Material.AlphaMode.OPAQUE) { materialDef.alphaMode = material.getAlphaMode(); } if (material.getAlphaMode() === Material.AlphaMode.MASK) { materialDef.alphaCutoff = material.getAlphaCutoff(); } if (material.getDoubleSided()) materialDef.doubleSided = true; // Factors. materialDef.pbrMetallicRoughness = {}; if (!MathUtils.eq(material.getBaseColorFactor(), [1, 1, 1, 1])) { materialDef.pbrMetallicRoughness.baseColorFactor = material.getBaseColorFactor(); } if (!MathUtils.eq(material.getEmissiveFactor(), [0, 0, 0])) { materialDef.emissiveFactor = material.getEmissiveFactor(); } if (material.getRoughnessFactor() !== 1) { materialDef.pbrMetallicRoughness.roughnessFactor = material.getRoughnessFactor(); } if (material.getMetallicFactor() !== 1) { materialDef.pbrMetallicRoughness.metallicFactor = material.getMetallicFactor(); } // Textures. if (material.getBaseColorTexture()) { const texture = material.getBaseColorTexture()!; const textureInfo = material.getBaseColorTextureInfo()!; materialDef.pbrMetallicRoughness.baseColorTexture = context.createTextureInfoDef(texture, textureInfo); } if (material.getEmissiveTexture()) { const texture = material.getEmissiveTexture()!; const textureInfo = material.getEmissiveTextureInfo()!; materialDef.emissiveTexture = context.createTextureInfoDef(texture, textureInfo); } if (material.getNormalTexture()) { const texture = material.getNormalTexture()!; const textureInfo = material.getNormalTextureInfo()!; const textureInfoDef = context.createTextureInfoDef( texture, textureInfo, ) as GLTF.IMaterialNormalTextureInfo; if (material.getNormalScale() !== 1) { textureInfoDef.scale = material.getNormalScale(); } materialDef.normalTexture = textureInfoDef; } if (material.getOcclusionTexture()) { const texture = material.getOcclusionTexture()!; const textureInfo = material.getOcclusionTextureInfo()!; const textureInfoDef = context.createTextureInfoDef( texture, textureInfo, ) as GLTF.IMaterialOcclusionTextureInfo; if (material.getOcclusionStrength() !== 1) { textureInfoDef.strength = material.getOcclusionStrength(); } materialDef.occlusionTexture = textureInfoDef; } if (material.getMetallicRoughnessTexture()) { const texture = material.getMetallicRoughnessTexture()!; const textureInfo = material.getMetallicRoughnessTextureInfo()!; materialDef.pbrMetallicRoughness.metallicRoughnessTexture = context.createTextureInfoDef( texture, textureInfo, ); } context.materialIndexMap.set(material, index); return materialDef; }); /* Meshes. */ extensionsUsed .filter((extension) => extension.prewriteTypes.includes(PropertyType.MESH)) .forEach((extension) => extension.prewrite(context, PropertyType.MESH)); json.meshes = root.listMeshes().map((mesh, index) => { const meshDef = context.createPropertyDef(mesh) as GLTF.IMesh; let targetNames: string[] | null = null; meshDef.primitives = mesh.listPrimitives().map((primitive) => { const primitiveDef: GLTF.IMeshPrimitive = { attributes: {} }; primitiveDef.mode = primitive.getMode(); const material = primitive.getMaterial(); if (material) { primitiveDef.material = context.materialIndexMap.get(material); } if (Object.keys(primitive.getExtras()).length) { primitiveDef.extras = primitive.getExtras(); } const indices = primitive.getIndices(); if (indices) { primitiveDef.indices = context.accessorIndexMap.get(indices); } for (const semantic of primitive.listSemantics()) { primitiveDef.attributes[semantic] = context.accessorIndexMap.get( primitive.getAttribute(semantic)!, )!; } for (const target of primitive.listTargets()) { const targetDef = {} as { [name: string]: number }; for (const semantic of target.listSemantics()) { targetDef[semantic] = context.accessorIndexMap.get(target.getAttribute(semantic)!)!; } primitiveDef.targets = primitiveDef.targets || []; primitiveDef.targets.push(targetDef); } if (primitive.listTargets().length && !targetNames) { targetNames = primitive.listTargets().map((target) => target.getName()); } return primitiveDef; }); if (mesh.getWeights().length) { meshDef.weights = mesh.getWeights(); } if (targetNames) { meshDef.extras = meshDef.extras || {}; meshDef.extras['targetNames'] = targetNames; } context.meshIndexMap.set(mesh, index); return meshDef; }); /** Cameras. */ json.cameras = root.listCameras().map((camera, index) => { const cameraDef = context.createPropertyDef(camera) as GLTF.ICamera; cameraDef.type = camera.getType(); if (cameraDef.type === Camera.Type.PERSPECTIVE) { cameraDef.perspective = { znear: camera.getZNear(), zfar: camera.getZFar(), yfov: camera.getYFov(), }; const aspectRatio = camera.getAspectRatio(); if (aspectRatio !== null) { cameraDef.perspective.aspectRatio = aspectRatio; } } else { cameraDef.orthographic = { znear: camera.getZNear(), zfar: camera.getZFar(), xmag: camera.getXMag(), ymag: camera.getYMag(), }; } context.cameraIndexMap.set(camera, index); return cameraDef; }); /* Nodes. */ json.nodes = root.listNodes().map((node, index) => { const nodeDef = context.createPropertyDef(node) as GLTF.INode; if (!MathUtils.eq(node.getTranslation(), [0, 0, 0])) { nodeDef.translation = node.getTranslation(); } if (!MathUtils.eq(node.getRotation(), [0, 0, 0, 1])) { nodeDef.rotation = node.getRotation(); } if (!MathUtils.eq(node.getScale(), [1, 1, 1])) { nodeDef.scale = node.getScale(); } if (node.getWeights().length) { nodeDef.weights = node.getWeights(); } // Attachments (mesh, camera, skin) defined later in writing process. context.nodeIndexMap.set(node, index); return nodeDef; }); /** Skins. */ json.skins = root.listSkins().map((skin, index) => { const skinDef = context.createPropertyDef(skin) as GLTF.ISkin; const inverseBindMatrices = skin.getInverseBindMatrices(); if (inverseBindMatrices) { skinDef.inverseBindMatrices = context.accessorIndexMap.get(inverseBindMatrices); } const skeleton = skin.getSkeleton(); if (skeleton) { skinDef.skeleton = context.nodeIndexMap.get(skeleton); } skinDef.joints = skin.listJoints().map((joint) => context.nodeIndexMap.get(joint)!); context.skinIndexMap.set(skin, index); return skinDef; }); /** Node attachments. */ root.listNodes().forEach((node, index) => { const nodeDef = json.nodes![index]; const mesh = node.getMesh(); if (mesh) { nodeDef.mesh = context.meshIndexMap.get(mesh); } const camera = node.getCamera(); if (camera) { nodeDef.camera = context.cameraIndexMap.get(camera); } const skin = node.getSkin(); if (skin) { nodeDef.skin = context.skinIndexMap.get(skin); } if (node.listChildren().length > 0) { nodeDef.children = node.listChildren().map((node) => context.nodeIndexMap.get(node)!); } }); /** Animations. */ json.animations = root.listAnimations().map((animation, index) => { const animationDef = context.createPropertyDef(animation) as GLTF.IAnimation; const samplerIndexMap: Map = new Map(); animationDef.samplers = animation.listSamplers().map((sampler, samplerIndex) => { const samplerDef = context.createPropertyDef(sampler) as GLTF.IAnimationSampler; samplerDef.input = context.accessorIndexMap.get(sampler.getInput()!)!; samplerDef.output = context.accessorIndexMap.get(sampler.getOutput()!)!; samplerDef.interpolation = sampler.getInterpolation(); samplerIndexMap.set(sampler, samplerIndex); return samplerDef; }); animationDef.channels = animation.listChannels().map((channel) => { const channelDef = context.createPropertyDef(channel) as GLTF.IAnimationChannel; channelDef.sampler = samplerIndexMap.get(channel.getSampler()!)!; channelDef.target = { node: context.nodeIndexMap.get(channel.getTargetNode()!)!, path: channel.getTargetPath()!, }; return channelDef; }); context.animationIndexMap.set(animation, index); return animationDef; }); /* Scenes. */ json.scenes = root.listScenes().map((scene, index) => { const sceneDef = context.createPropertyDef(scene) as GLTF.IScene; sceneDef.nodes = scene.listChildren().map((node) => context.nodeIndexMap.get(node)!); context.sceneIndexMap.set(scene, index); return sceneDef; }); const defaultScene = root.getDefaultScene(); if (defaultScene) { json.scene = root.listScenes().indexOf(defaultScene); } /* Extensions (2/2). */ json.extensionsUsed = extensionsUsed.map((ext) => ext.extensionName); json.extensionsRequired = extensionsRequired.map((ext) => ext.extensionName); extensionsUsed.forEach((extension) => extension.write(context)); // clean(json as unknown as Record); return jsonDoc; } } /** * Removes empty and null values from an object. * @param object * @internal */ function clean(object: Record): void { const unused: string[] = []; for (const key in object) { const value = object[key]; if (Array.isArray(value) && value.length === 0) { unused.push(key); } else if (value === null || value === '') { unused.push(key); } else if (value && typeof value === 'object' && Object.keys(value).length === 0) { unused.push(key); } } for (const key of unused) { delete object[key]; } }