import { Document, type Accessor, type GLTF } from "@gltf-transform/core"; import { TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS } from "../model-import-limits"; import type { ToolcraftModelDecodeContext, ToolcraftModelSourceBundleTransfer, } from "../model-import-types"; type PrimitiveFixtureOptions = Readonly<{ indices?: readonly number[] | null; mode?: GLTF.MeshPrimitiveMode; positions?: readonly number[]; }>; export type ModelFixture = Readonly<{ bin: Uint8Array; json: GLTF.IGLTF; }>; export type CoreTriangleDocumentFixture = Readonly<{ document: Document; indices: Accessor; positions: Accessor; }>; const encoder = new TextEncoder(); function align4(value: number): number { return (value + 3) & ~3; } function viewBytes(view: ArrayBufferView): Uint8Array { return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); } function joinViews(views: readonly ArrayBufferView[]): { bin: Uint8Array; bufferViews: GLTF.IBufferView[]; } { const bufferViews: GLTF.IBufferView[] = []; let byteLength = 0; for (const view of views) { byteLength = align4(byteLength); bufferViews.push({ buffer: 0, byteLength: view.byteLength, byteOffset: byteLength }); byteLength += view.byteLength; } const bin = new Uint8Array(align4(byteLength)); views.forEach((view, index) => { bin.set(viewBytes(view), bufferViews[index]!.byteOffset); }); return { bin, bufferViews }; } function cloneJson(json: GLTF.IGLTF): GLTF.IGLTF { return JSON.parse(JSON.stringify(json)) as GLTF.IGLTF; } function arrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.slice().buffer; } export function createCoreTriangleDocumentFixture( options: Readonly<{ indices?: readonly number[]; instanceMeshOnEveryNode?: boolean; nodeCount?: number; positions?: readonly number[]; primitiveCount?: number; }> = {}, ): CoreTriangleDocumentFixture { const document = new Document(); const buffer = document.createBuffer(); const positions = document .createAccessor() .setBuffer(buffer) .setType("VEC3") .setArray( new Float32Array( options.positions ?? [0, 0, 0, 1, 0, 0, 0, 1, 0], ), ); const indices = document .createAccessor() .setBuffer(buffer) .setType("SCALAR") .setArray(new Uint16Array(options.indices ?? [0, 1, 2])); const mesh = document.createMesh(); for (let index = 0; index < (options.primitiveCount ?? 1); index += 1) { mesh.addPrimitive( document .createPrimitive() .setAttribute("POSITION", positions) .setIndices(indices), ); } const scene = document.createScene(); scene.addChild(document.createNode().setMesh(mesh)); for (let index = 1; index < (options.nodeCount ?? 1); index += 1) { const node = document.createNode(); if (options.instanceMeshOnEveryNode) node.setMesh(mesh); scene.addChild(node); } document.getRoot().setDefaultScene(scene); return { document, indices, positions }; } export function createPrimitiveFixture( options: PrimitiveFixtureOptions = {}, ): ModelFixture { const positions = new Float32Array( options.positions ?? [0, 0, 0, 1, 0, 0, 0, 1, 0], ); const indices = options.indices === null ? null : new Uint16Array(options.indices ?? [0, 1, 2]); const joined = joinViews(indices ? [positions, indices] : [positions]); const accessors: GLTF.IAccessor[] = [ { bufferView: 0, componentType: 5126, count: positions.length / 3, type: "VEC3", }, ]; if (indices) { accessors.push({ bufferView: 1, componentType: 5123, count: indices.length, type: "SCALAR", }); } const primitive: GLTF.IMeshPrimitive = { attributes: { POSITION: 0 }, mode: options.mode ?? 4, }; if (indices) primitive.indices = 1; return { bin: joined.bin, json: { accessors, asset: { version: "2.0" }, buffers: [{ byteLength: joined.bin.byteLength, uri: "mesh.bin" }], bufferViews: joined.bufferViews, meshes: [{ primitives: [primitive] }], nodes: [{ mesh: 0, name: "Mesh" }], scene: 0, scenes: [{ nodes: [0] }], }, }; } export function createHierarchyFixture(): ModelFixture { const firstPositions = new Float32Array([ 0, 0, 0, 2, 0, 0, 0, 3, 0, ]); const firstIndices = new Uint16Array([0, 1, 2]); const secondPositions = new Float32Array([ -4, -2, 1, -2, -2, 1, -4, -1, 1, ]); const secondIndices = new Uint16Array([0, 1, 2]); const joined = joinViews([ firstPositions, firstIndices, secondPositions, secondIndices, ]); return { bin: joined.bin, json: { accessors: [ { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, { bufferView: 2, componentType: 5126, count: 3, type: "VEC3" }, { bufferView: 3, componentType: 5123, count: 3, type: "SCALAR" }, ], asset: { version: "2.0" }, buffers: [{ byteLength: joined.bin.byteLength, uri: "mesh.bin" }], bufferViews: joined.bufferViews, meshes: [ { primitives: [ { attributes: { POSITION: 0 }, indices: 1, mode: 4 }, { attributes: { POSITION: 2 }, indices: 3, mode: 4 }, ], }, ], nodes: [ { children: [1], name: "Parent", translation: [100, 0, 0] }, { matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 5, 6, 7, 1], mesh: 0, name: "Child", }, { mesh: 0, name: "Instance", translation: [-50, 0, 0] }, ], scene: 0, scenes: [{ nodes: [0, 2] }], }, }; } export function createIgnoredFeaturesFixture(): ModelFixture { const fixture = createPrimitiveFixture(); const json = cloneJson(fixture.json); json.meshes![0]!.weights = [0]; json.meshes![0]!.primitives[0]!.targets = [{ POSITION: 0 }]; json.nodes![0]!.skin = 0; json.nodes![0]!.weights = [0]; json.nodes!.push({ name: "Joint" }); json.skins = [{ joints: [1], name: "Rig" }]; json.animations = [ { channels: [{ sampler: 0, target: { node: 0, path: "translation" } }], name: "Move", samplers: [{ input: 1, output: 0 }], }, ]; return { bin: fixture.bin, json }; } export function createNormalizedQuantizedFixture(): ModelFixture { const positions = new Int16Array([ 0, 0, 0, 32767, 0, 0, 0, 32767, 0, ]); const normals = new Int8Array([ 0, 0, 127, 0, 0, 127, 0, 0, 127, ]); const indices = new Uint16Array([0, 1, 2]); const joined = joinViews([positions, normals, indices]); return { bin: joined.bin, json: { accessors: [ { bufferView: 0, componentType: 5122, count: 3, normalized: true, type: "VEC3", }, { bufferView: 1, componentType: 5120, count: 3, normalized: true, type: "VEC3", }, { bufferView: 2, componentType: 5123, count: 3, type: "SCALAR" }, ], asset: { version: "2.0" }, buffers: [{ byteLength: joined.bin.byteLength, uri: "mesh.bin" }], bufferViews: joined.bufferViews, extensionsRequired: ["KHR_mesh_quantization"], extensionsUsed: ["KHR_mesh_quantization"], meshes: [ { primitives: [ { attributes: { NORMAL: 1, POSITION: 0 }, indices: 2, mode: 4, }, ], }, ], nodes: [{ mesh: 0 }], scene: 0, scenes: [{ nodes: [0] }], }, }; } export function createAppearanceFixture(): ModelFixture { const fixture = createPrimitiveFixture(); const json = cloneJson(fixture.json); json.images = [{ uri: "https://example.invalid/texture.png" }]; json.textures = [{ source: 0 }]; json.materials = [ { extensions: { KHR_materials_unlit: {} }, pbrMetallicRoughness: { baseColorTexture: { index: 0 } }, }, ]; json.meshes![0]!.primitives[0]!.material = 0; json.extensionsUsed = ["KHR_materials_unlit"]; json.extensionsRequired = ["KHR_materials_unlit"]; return { bin: fixture.bin, json }; } export function createExtensionOnlyBehaviorFixture(): ModelFixture { const fixture = createPrimitiveFixture(); const json = cloneJson(fixture.json); json.cameras = [{ type: "perspective", perspective: { yfov: 1, znear: 0.1 } }]; json.nodes![0]!.camera = 0; (json as unknown as Record).extensions = { KHR_lights_punctual: { lights: [{ color: [1, 1, 1], type: "point" }], }, }; (json.nodes![0] as unknown as Record).extensions = { KHR_lights_punctual: { light: 0 }, KHR_node_visibility: { visible: false }, }; json.extensionsUsed = ["KHR_lights_punctual", "KHR_node_visibility"]; json.extensionsRequired = ["KHR_lights_punctual", "KHR_node_visibility"]; return { bin: fixture.bin, json }; } export function createSparsePositionFixture( sparseIndices: readonly number[], ): ModelFixture { const positions = new Float32Array(9); const indices = new Uint16Array([0, 1, 2]); const sparseIndexValues = new Uint8Array(sparseIndices); const sparsePositions = new Float32Array(sparseIndices.length * 3); const joined = joinViews([ positions, indices, sparseIndexValues, sparsePositions, ]); return { bin: joined.bin, json: { accessors: [ { bufferView: 0, componentType: 5126, count: 3, sparse: { count: sparseIndices.length, indices: { bufferView: 2, componentType: 5121 }, values: { bufferView: 3 }, }, type: "VEC3", }, { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, ], asset: { version: "2.0" }, buffers: [{ byteLength: joined.bin.byteLength, uri: "mesh.bin" }], bufferViews: joined.bufferViews, meshes: [ { primitives: [ { attributes: { POSITION: 0 }, indices: 1, mode: 4 }, ], }, ], nodes: [{ mesh: 0 }], scene: 0, scenes: [{ nodes: [0] }], }, }; } export function createGltfTransfer( fixture: ModelFixture, rootPath = "scene.gltf", bufferPath = `${rootPath.slice(0, Math.max(0, rootPath.lastIndexOf("/") + 1))}mesh.bin`, ): ToolcraftModelSourceBundleTransfer { const rootBytes = encoder.encode(JSON.stringify(fixture.json)); return Object.freeze({ aggregateDigest: "fixture-gltf", rootPath, sourceFiles: Object.freeze([ Object.freeze({ bytes: arrayBuffer(rootBytes), contentDigest: "fixture-gltf-root", mimeType: "model/gltf+json", path: rootPath, }), Object.freeze({ bytes: arrayBuffer(fixture.bin), contentDigest: "fixture-gltf-bin", mimeType: "application/octet-stream", path: bufferPath, }), ]), }); } export function encodeGlb(fixture: ModelFixture): Uint8Array { const json = cloneJson(fixture.json); if (json.buffers?.[0]) delete json.buffers[0].uri; const rawJson = encoder.encode(JSON.stringify(json)); const jsonLength = align4(rawJson.byteLength); const binLength = align4(fixture.bin.byteLength); const totalLength = 12 + 8 + jsonLength + 8 + binLength; const glb = new Uint8Array(totalLength); const data = new DataView(glb.buffer); data.setUint32(0, 0x46546c67, true); data.setUint32(4, 2, true); data.setUint32(8, totalLength, true); data.setUint32(12, jsonLength, true); data.setUint32(16, 0x4e4f534a, true); glb.fill(0x20, 20, 20 + jsonLength); glb.set(rawJson, 20); const binHeader = 20 + jsonLength; data.setUint32(binHeader, binLength, true); data.setUint32(binHeader + 4, 0x004e4942, true); glb.set(fixture.bin, binHeader + 8); return glb; } export function createGlbTransfer( fixture: ModelFixture, rootPath = "scene.glb", ): ToolcraftModelSourceBundleTransfer { return transferForGlbBytes(encodeGlb(fixture), rootPath); } export function transferForGlbBytes( bytes: Uint8Array, rootPath = "scene.glb", ): ToolcraftModelSourceBundleTransfer { return Object.freeze({ aggregateDigest: "fixture-glb", rootPath, sourceFiles: Object.freeze([ Object.freeze({ bytes: arrayBuffer(bytes), contentDigest: "fixture-glb-root", mimeType: "model/gltf-binary", path: rootPath, }), ]), }); } export function rewriteGlbJson( bytes: Uint8Array, rewrite: (json: GLTF.IGLTF) => void, ): Uint8Array { const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const jsonLength = data.getUint32(12, true); const json = JSON.parse( new TextDecoder().decode(bytes.subarray(20, 20 + jsonLength)).trimEnd(), ) as GLTF.IGLTF; const binHeader = 20 + jsonLength; const binLength = data.getUint32(binHeader, true); const bin = bytes.slice(binHeader + 8, binHeader + 8 + binLength); rewrite(json); return encodeGlb({ bin, json }); } export function contextFor( bundle: ToolcraftModelSourceBundleTransfer, options: Readonly<{ limits?: Partial; signal?: AbortSignal; }> = {}, ): ToolcraftModelDecodeContext { return { bundle, limits: { ...TOOLCRAFT_DEFAULT_MODEL_IMPORT_LIMITS, ...options.limits }, signal: options.signal ?? new AbortController().signal, }; } export function transferForStaticModelBytes( bytes: Uint8Array, rootPath: string, mimeType = "application/octet-stream", ): ToolcraftModelSourceBundleTransfer { return Object.freeze({ aggregateDigest: `fixture-${rootPath}`, rootPath, sourceFiles: Object.freeze([ Object.freeze({ bytes: arrayBuffer(bytes), contentDigest: `fixture-${rootPath}-root`, mimeType, path: rootPath, }), ]), }); } export const OBJ_STATIC_GEOMETRY_FIXTURE = encoder.encode(` mtllib ignored.mtl o First v 10 20 30 v 12 20 30 v 10 23 30 usemtl ignored-red f 1 2 3 o Second v -4 -2 1 v -2 -2 1 v -4 -1 1 usemtl ignored-blue f 4 5 6 `); export const ASCII_STL_FIXTURE = encoder.encode(`solid first facet normal 0 0 1 outer loop vertex 10 20 30 vertex 12 20 30 vertex 10 23 30 endloop endfacet endsolid first `); export function createBinaryStlFixture(): Uint8Array { const bytes = new Uint8Array(84 + 50); bytes.set(encoder.encode("Toolcraft binary STL fixture").subarray(0, 80)); const view = new DataView(bytes.buffer); view.setUint32(80, 1, true); const values = [ 0, 0, 1, -4, -2, 1, -2, -2, 1, -4, -1, 1, ]; values.forEach((value, index) => { view.setFloat32(84 + index * 4, value, true); }); view.setUint16(84 + 48, 0, true); return bytes; } export function createBinaryColoredStlFixture(): Uint8Array { const bytes = createBinaryStlFixture(); bytes.set([0x43, 0x4f, 0x4c, 0x4f, 0x52, 0x3d, 0xff, 0x00, 0x00, 0x80]); new DataView(bytes.buffer).setUint16(84 + 48, 0x8000, true); return bytes; } export const ASCII_PLY_FIXTURE = encoder.encode(`ply format ascii 1.0 comment appearance data is intentionally ignored element vertex 4 property float x property float y property float z property uchar red property uchar green property uchar blue element face 2 property list uchar int vertex_indices end_header 10 20 30 255 0 0 12 20 30 0 255 0 12 23 30 0 0 255 10 23 30 255 255 255 3 0 1 2 3 0 2 3 `); export function createBinaryPlyFixture( endian: "big" | "little" = "little", ): Uint8Array { const littleEndian = endian === "little"; const header = encoder.encode(`ply format binary_${endian}_endian 1.0 element vertex 4 property float x property float y property float z element face 2 property list uchar int vertex_indices end_header `); const body = new Uint8Array(4 * 12 + 2 * 13); const view = new DataView(body.buffer); const positions = [ 10, 20, 30, 12, 20, 30, 12, 23, 30, 10, 23, 30, ]; positions.forEach((value, index) => { view.setFloat32(index * 4, value, littleEndian); }); let offset = positions.length * 4; for (const triangle of [[0, 1, 2], [0, 2, 3]] as const) { view.setUint8(offset, 3); offset += 1; for (const index of triangle) { view.setInt32(offset, index, littleEndian); offset += 4; } } const bytes = new Uint8Array(header.byteLength + body.byteLength); bytes.set(header); bytes.set(body, header.byteLength); return bytes; } function decodeBase64(value: string): Uint8Array { const decoded = atob(value); const bytes = new Uint8Array(decoded.length); for (let index = 0; index < decoded.length; index += 1) { bytes[index] = decoded.charCodeAt(index); } return bytes; } const DRACO_GLB_BASE64 = "Z2xURgIAAAA0AwAApAIAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjQuMSIsInZlcnNpb24iOiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiU0NBTEFSIiwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjN9LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6MywibWF4IjpbMSwxLDBdLCJtaW4iOlswLDAsMF19LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6M31dLCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjowLCJieXRlT2Zmc2V0IjowLCJieXRlTGVuZ3RoIjoxMTV9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6MTE1fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MSwiTk9STUFMIjoyfSwibW9kZSI6NCwiaW5kaWNlcyI6MCwiZXh0ZW5zaW9ucyI6eyJLSFJfZHJhY29fbWVzaF9jb21wcmVzc2lvbiI6eyJidWZmZXJWaWV3IjowLCJhdHRyaWJ1dGVzIjp7IlBPU0lUSU9OIjowLCJOT1JNQUwiOjF9fX19XX1dLCJub2RlcyI6W3sibWVzaCI6MH1dLCJzY2VuZXMiOlt7Im5vZGVzIjpbMF19XSwiZXh0ZW5zaW9uc1VzZWQiOlsiS0hSX2RyYWNvX21lc2hfY29tcHJlc3Npb24iXSwiZXh0ZW5zaW9uc1JlcXVpcmVkIjpbIktIUl9kcmFjb19tZXNoX2NvbXByZXNzaW9uIl19dAAAAEJJTgBEUkFDTwICAQEAAAADAQEBAAABB/8BEQEBAAL/AAAAAAABAAkDAAACAQEJAwABAwEBAQADA1UVrSoDBHCBMRAAAAAA/z8AAAAAAAAAAAAAAAAAAAAAgD8OAAMBAAoDrSobVRUDr1qBAP4D/wMAAP8BAAAKAA=="; const MESHOPT_GLB_BASE64 = "Z2xURgIAAADwBQAATAUAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjQuMSIsInZlcnNpb24iOiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjozLCJtYXgiOlsxLDEsMF0sIm1pbiI6WzAsMCwwXSwibm9ybWFsaXplZCI6ZmFsc2UsImJ5dGVPZmZzZXQiOjAsImJ1ZmZlclZpZXciOjB9LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6Mywibm9ybWFsaXplZCI6ZmFsc2UsImJ5dGVPZmZzZXQiOjAsImJ1ZmZlclZpZXciOjF9LHsidHlwZSI6IlNDQUxBUiIsImNvbXBvbmVudFR5cGUiOjUxMjMsImNvdW50IjozLCJub3JtYWxpemVkIjpmYWxzZSwiYnl0ZU9mZnNldCI6MCwiYnVmZmVyVmlldyI6Mn1dLCJidWZmZXJWaWV3cyI6W3siYnVmZmVyIjoxLCJieXRlT2Zmc2V0IjowLCJieXRlTGVuZ3RoIjozNiwidGFyZ2V0IjozNDk2MiwiYnl0ZVN0cmlkZSI6MTIsImV4dGVuc2lvbnMiOnsiRVhUX21lc2hvcHRfY29tcHJlc3Npb24iOnsiYnVmZmVyIjowLCJieXRlT2Zmc2V0IjowLCJieXRlTGVuZ3RoIjo2NywibW9kZSI6IkFUVFJJQlVURVMiLCJieXRlU3RyaWRlIjoxMiwiY291bnQiOjN9fX0seyJidWZmZXIiOjEsImJ5dGVPZmZzZXQiOjM2LCJieXRlTGVuZ3RoIjozNiwidGFyZ2V0IjozNDk2MiwiYnl0ZVN0cmlkZSI6MTIsImV4dGVuc2lvbnMiOnsiRVhUX21lc2hvcHRfY29tcHJlc3Npb24iOnsiYnVmZmVyIjowLCJieXRlT2Zmc2V0Ijo2OCwiYnl0ZUxlbmd0aCI6NDUsIm1vZGUiOiJBVFRSSUJVVEVTIiwiYnl0ZVN0cmlkZSI6MTIsImNvdW50IjozfX19LHsiYnVmZmVyIjoxLCJieXRlT2Zmc2V0Ijo3MiwiYnl0ZUxlbmd0aCI6NiwidGFyZ2V0IjozNDk2MywiZXh0ZW5zaW9ucyI6eyJFWFRfbWVzaG9wdF9jb21wcmVzc2lvbiI6eyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjExNiwiYnl0ZUxlbmd0aCI6MTgsIm1vZGUiOiJUUklBTkdMRVMiLCJieXRlU3RyaWRlIjoyLCJjb3VudCI6M319fV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjEzNn0seyJieXRlTGVuZ3RoIjo4MCwiZXh0ZW5zaW9ucyI6eyJFWFRfbWVzaG9wdF9jb21wcmVzc2lvbiI6eyJmYWxsYmFjayI6dHJ1ZX19fV0sIm1lc2hlcyI6W3sicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MCwiTk9STUFMIjoxfSwibW9kZSI6NCwiaW5kaWNlcyI6Mn1dfV0sIm5vZGVzIjpbeyJtZXNoIjowfV0sInNjZW5lcyI6W3sibm9kZXMiOlswXX1dLCJleHRlbnNpb25zVXNlZCI6WyJFWFRfbWVzaG9wdF9jb21wcmVzc2lvbiJdLCJleHRlbnNpb25zUmVxdWlyZWQiOlsiRVhUX21lc2hvcHRfY29tcHJlc3Npb24iXX0gICCIAAAAQklOAKAAAAE8AAAA//8BPAAAAH59AAABDAAAAP8BDAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAA4fAAdodWZ3iphmWJaJgBaQAAAAA="; export const REAL_DRACO_GLB = decodeBase64(DRACO_GLB_BASE64); export const REAL_MESHOPT_GLB = decodeBase64(MESHOPT_GLB_BASE64);