@gltf-transform/core
Version:
glTF 2.0 SDK for JavaScript and TypeScript, on Web and Node.js.
308 lines (253 loc) • 11.3 kB
text/typescript
import { Format, GLB_BUFFER, VertexLayout } from '../constants.js';
import type { Document } from '../document.js';
import type { Extension } from '../extension.js';
import type { JSONDocument } from '../json-document.js';
import type { GLTF } from '../types/gltf.js';
import { BufferUtils, FileUtils, type ILogger, Logger, uuid } from '../utils/index.js';
import { GLTFReader } from './reader.js';
import { GLTFWriter, type WriterOptions } from './writer.js';
enum ChunkType {
JSON = 0x4e4f534a,
BIN = 0x004e4942,
}
type PublicWriterOptions = Partial<Pick<WriterOptions, 'format' | 'basename'>>;
/**
* *Abstract I/O service.*
*
* The most common use of the I/O service is to read/write a {@link Document} with a given path.
* Methods are also available for converting in-memory representations of raw glTF files, both
* binary (*Uint8Array*) and JSON ({@link JSONDocument}).
*
* For platform-specific implementations, see {@link NodeIO}, {@link WebIO}, and {@link DenoIO}.
*
* @category I/O
*/
export abstract class PlatformIO {
protected _logger: ILogger = Logger.DEFAULT_INSTANCE;
private _extensions = new Set<typeof Extension>();
private _dependencies: { [key: string]: unknown } = {};
private _vertexLayout = VertexLayout.INTERLEAVED;
/** @hidden */
public lastReadBytes = 0;
/** @hidden */
public lastWriteBytes = 0;
/** Sets the {@link Logger} used by this I/O instance. Defaults to Logger.DEFAULT_INSTANCE. */
public setLogger(logger: ILogger): this {
this._logger = logger;
return this;
}
/** Registers extensions, enabling I/O class to read and write glTF assets requiring them. */
public registerExtensions(extensions: (typeof Extension)[]): this {
for (const extension of extensions) {
this._extensions.add(extension);
extension.register();
}
return this;
}
/** Registers dependencies used (e.g. by extensions) in the I/O process. */
public registerDependencies(dependencies: { [key: string]: unknown }): this {
Object.assign(this._dependencies, dependencies);
return this;
}
/**
* Sets the vertex layout method used by this I/O instance. Defaults to
* VertexLayout.INTERLEAVED.
*/
public setVertexLayout(layout: VertexLayout): this {
this._vertexLayout = layout;
return this;
}
/**********************************************************************************************
* Abstract.
*/
protected abstract readURI(uri: string, type: 'view'): Promise<Uint8Array>;
protected abstract readURI(uri: string, type: 'text'): Promise<string>;
protected abstract readURI(uri: string, type: 'view' | 'text'): Promise<Uint8Array | string>;
protected abstract resolve(base: string, path: string): string;
protected abstract dirname(uri: string): string;
/**********************************************************************************************
* Public Read API.
*/
/** Reads a {@link Document} from the given URI. */
public async read(uri: string): Promise<Document> {
return await this.readJSON(await this.readAsJSON(uri));
}
/** Loads a URI and returns a {@link JSONDocument} struct, without parsing. */
public async readAsJSON(uri: string): Promise<JSONDocument> {
const view = await this.readURI(uri, 'view');
this.lastReadBytes = view.byteLength;
const jsonDoc = isGLB(view)
? this._binaryToJSON(view)
: { json: JSON.parse(BufferUtils.decodeText(view)), resources: {} };
// Read external resources first, before Data URIs are replaced.
await this._readResourcesExternal(jsonDoc, this.dirname(uri));
this._readResourcesInternal(jsonDoc);
return jsonDoc;
}
/** Converts glTF-formatted JSON and a resource map to a {@link Document}. */
public async readJSON(jsonDoc: JSONDocument): Promise<Document> {
jsonDoc = this._copyJSON(jsonDoc);
this._readResourcesInternal(jsonDoc);
return GLTFReader.read(jsonDoc, {
extensions: Array.from(this._extensions),
dependencies: this._dependencies,
logger: this._logger,
});
}
/** Converts a GLB-formatted Uint8Array to a {@link JSONDocument}. */
public async binaryToJSON(glb: Uint8Array): Promise<JSONDocument> {
const jsonDoc = this._binaryToJSON(BufferUtils.assertView(glb));
this._readResourcesInternal(jsonDoc);
const json = jsonDoc.json;
// Check for external references, which can't be resolved by this method.
if (json.buffers && json.buffers.some((bufferDef) => isExternalBuffer(jsonDoc, bufferDef))) {
throw new Error('Cannot resolve external buffers with binaryToJSON().');
} else if (json.images && json.images.some((imageDef) => isExternalImage(jsonDoc, imageDef))) {
throw new Error('Cannot resolve external images with binaryToJSON().');
}
return jsonDoc;
}
/** Converts a GLB-formatted Uint8Array to a {@link Document}. */
public async readBinary(glb: Uint8Array): Promise<Document> {
return this.readJSON(await this.binaryToJSON(BufferUtils.assertView(glb)));
}
/**********************************************************************************************
* Public Write API.
*/
/** Converts a {@link Document} to glTF-formatted JSON and a resource map. */
public async writeJSON(doc: Document, _options: PublicWriterOptions = {}): Promise<JSONDocument> {
if (_options.format === Format.GLB && doc.getRoot().listBuffers().length > 1) {
throw new Error('GLB must have 0–1 buffers.');
}
return GLTFWriter.write(doc, {
format: _options.format || Format.GLTF,
basename: _options.basename || '',
logger: this._logger,
vertexLayout: this._vertexLayout,
dependencies: { ...this._dependencies },
extensions: Array.from(this._extensions),
} as Required<WriterOptions>);
}
/** Converts a {@link Document} to a GLB-formatted Uint8Array. */
public async writeBinary(doc: Document): Promise<Uint8Array> {
const { json, resources } = await this.writeJSON(doc, { format: Format.GLB });
const header = new Uint32Array([0x46546c67, 2, 12]);
const jsonText = JSON.stringify(json);
const jsonChunkData = BufferUtils.pad(BufferUtils.encodeText(jsonText), 0x20);
const jsonChunkHeader = BufferUtils.toView(new Uint32Array([jsonChunkData.byteLength, 0x4e4f534a]));
const jsonChunk = BufferUtils.concat([jsonChunkHeader, jsonChunkData]);
header[header.length - 1] += jsonChunk.byteLength;
const binBuffer = Object.values(resources)[0];
if (!binBuffer || !binBuffer.byteLength) {
return BufferUtils.concat([BufferUtils.toView(header), jsonChunk]);
}
const binChunkData = BufferUtils.pad(binBuffer, 0x00);
const binChunkHeader = BufferUtils.toView(new Uint32Array([binChunkData.byteLength, 0x004e4942]));
const binChunk = BufferUtils.concat([binChunkHeader, binChunkData]);
header[header.length - 1] += binChunk.byteLength;
return BufferUtils.concat([BufferUtils.toView(header), jsonChunk, binChunk]);
}
/**********************************************************************************************
* Internal.
*/
private async _readResourcesExternal(jsonDoc: JSONDocument, base: string): Promise<void> {
const images = jsonDoc.json.images || [];
const buffers = jsonDoc.json.buffers || [];
const pendingResources: Array<Promise<void>> = [...images, ...buffers].map(
async (resource: GLTF.IBuffer | GLTF.IImage): Promise<void> => {
const uri = resource.uri;
if (!uri || uri.match(/data:/)) return Promise.resolve();
jsonDoc.resources[uri] = await this.readURI(this.resolve(base, uri), 'view');
this.lastReadBytes += jsonDoc.resources[uri].byteLength;
},
);
await Promise.all(pendingResources);
}
private _readResourcesInternal(jsonDoc: JSONDocument): void {
// NOTICE: This method may be called more than once during the loading
// process (e.g. WebIO.read) and should handle that safely.
function resolveResource(resource: GLTF.IBuffer | GLTF.IImage) {
if (!resource.uri) return;
if (resource.uri in jsonDoc.resources) {
BufferUtils.assertView(jsonDoc.resources[resource.uri]);
return;
}
if (resource.uri.match(/data:/)) {
// Rewrite Data URIs to something short and unique.
const resourceUUID = `__${uuid()}.${FileUtils.extension(resource.uri)}`;
jsonDoc.resources[resourceUUID] = BufferUtils.createBufferFromDataURI(resource.uri);
resource.uri = resourceUUID;
}
}
// Unpack images.
const images = jsonDoc.json.images || [];
images.forEach((image: GLTF.IImage) => {
if (image.bufferView === undefined && image.uri === undefined) {
throw new Error('Missing resource URI or buffer view.');
}
resolveResource(image);
});
// Unpack buffers.
const buffers = jsonDoc.json.buffers || [];
buffers.forEach(resolveResource);
}
/**
* Creates a shallow copy of glTF-formatted {@link JSONDocument}.
*
* Images, Buffers, and Resources objects are deep copies so that PlatformIO can safely
* modify them during the parsing process. Other properties are shallow copies, and buffers
* are passed by reference.
*/
private _copyJSON(jsonDoc: JSONDocument): JSONDocument {
const { images, buffers } = jsonDoc.json;
jsonDoc = { json: { ...jsonDoc.json }, resources: { ...jsonDoc.resources } };
if (images) {
jsonDoc.json.images = images.map((image) => ({ ...image }));
}
if (buffers) {
jsonDoc.json.buffers = buffers.map((buffer) => ({ ...buffer }));
}
return jsonDoc;
}
/** Internal version of binaryToJSON; does not warn about external resources. */
private _binaryToJSON(glb: Uint8Array): JSONDocument {
// Decode and verify GLB header.
if (!isGLB(glb)) {
throw new Error('Invalid glTF 2.0 binary.');
}
// Decode JSON chunk.
const jsonChunkHeader = new Uint32Array(glb.buffer, glb.byteOffset + 12, 2);
if (jsonChunkHeader[1] !== ChunkType.JSON) {
throw new Error('Missing required GLB JSON chunk.');
}
const jsonByteOffset = 20;
const jsonByteLength = jsonChunkHeader[0];
const jsonText = BufferUtils.decodeText(BufferUtils.toView(glb, jsonByteOffset, jsonByteLength));
const json = JSON.parse(jsonText) as GLTF.IGLTF;
// Decode BIN chunk.
const binByteOffset = jsonByteOffset + jsonByteLength;
if (glb.byteLength <= binByteOffset) {
return { json, resources: {} };
}
const binChunkHeader = new Uint32Array(glb.buffer, glb.byteOffset + binByteOffset, 2);
if (binChunkHeader[1] !== ChunkType.BIN) {
// Allow GLB files without BIN chunk, but with unknown chunk
// Spec: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#chunks-overview
return { json, resources: {} };
}
const binByteLength = binChunkHeader[0];
const binBuffer = BufferUtils.toView(glb, binByteOffset + 8, binByteLength);
return { json, resources: { [GLB_BUFFER]: binBuffer } };
}
}
function isExternalBuffer(jsonDocument: JSONDocument, bufferDef: GLTF.IBuffer): boolean {
return bufferDef.uri !== undefined && !(bufferDef.uri in jsonDocument.resources);
}
function isExternalImage(jsonDocument: JSONDocument, imageDef: GLTF.IImage): boolean {
return imageDef.uri !== undefined && !(imageDef.uri in jsonDocument.resources) && imageDef.bufferView === undefined;
}
function isGLB(view: Uint8Array): boolean {
if (view.byteLength < 3 * Uint32Array.BYTES_PER_ELEMENT) return false;
const header = new Uint32Array(view.buffer, view.byteOffset, 3);
return header[0] === 0x46546c67 && header[1] === 2;
}