import assert from "node:assert/strict"; import test from "node:test"; import { loadGltf } from "../src/importers/worker"; function close(actual: ArrayLike, expected: ArrayLike) { assert.equal(actual.length, expected.length); for (let index = 0; index < actual.length; index++) assert.ok( Math.abs(actual[index] - expected[index]) < 1e-5, `${actual[index]} != ${expected[index]} at ${index}`, ); } function dataUrl(type: string, value: Uint8Array | string) { return `data:${type};base64,${Buffer.from(value).toString("base64")}`; } function binaryBuilder() { let bytes = new Uint8Array(); return { append(source: ArrayBufferView, alignment: number) { const input = new Uint8Array( source.buffer, source.byteOffset, source.byteLength, ); const offset = Math.ceil(bytes.length / alignment) * alignment; const next = new Uint8Array(offset + input.length); next.set(bytes); next.set(input, offset); bytes = next; return { byteOffset: offset, byteLength: input.length }; }, get bytes() { return bytes; }, }; } function glb(document: object, binary: Uint8Array) { const json = new TextEncoder().encode(JSON.stringify(document)); const jsonLength = Math.ceil(json.length / 4) * 4; const binaryLength = Math.ceil(binary.length / 4) * 4; const result = new Uint8Array(12 + 8 + jsonLength + 8 + binaryLength); const view = new DataView(result.buffer); view.setUint32(0, 0x46546c67, true); view.setUint32(4, 2, true); view.setUint32(8, result.length, true); view.setUint32(12, jsonLength, true); view.setUint32(16, 0x4e4f534a, true); result.fill(0x20, 20, 20 + jsonLength); result.set(json, 20); const binaryHeader = 20 + jsonLength; view.setUint32(binaryHeader, binaryLength, true); view.setUint32(binaryHeader + 4, 0x004e4942, true); result.set(binary, binaryHeader + 8); return result; } test("JSON glTF parsing covers accessors, transforms, materials, lights, and cameras", async () => { const binary = binaryBuilder(); const positions = binary.append( new Float32Array([0, 0, 0, 99, 1, 0, 0, 99, 0, 1, 0, 99]), 4, ); const uvs = binary.append(new Uint16Array([0, 0, 65_535, 0, 0, 65_535]), 2); const colors = binary.append( new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255]), 1, ); const indices = binary.append(new Uint16Array([0, 1, 2]), 2); const sparseIndices = binary.append(new Uint8Array([1]), 1); const sparsePositions = binary.append(new Float32Array([2, 0, 0]), 4); const texture = { index: 0 }; const document = { asset: { version: "2.0" }, extensions: { KHR_lights_punctual: { lights: [ { type: "spot", color: [0.2, 0.4, 0.6], intensity: 7, range: 8, spot: { innerConeAngle: 0.1, outerConeAngle: 0.5 }, }, ], }, }, buffers: [ { byteLength: binary.bytes.length, uri: dataUrl("application/octet-stream", binary.bytes), }, ], bufferViews: [ { buffer: 0, ...positions, byteStride: 16 }, { buffer: 0, ...uvs }, { buffer: 0, ...colors }, { buffer: 0, ...indices }, { buffer: 0, ...sparseIndices }, { buffer: 0, ...sparsePositions }, ], accessors: [ { bufferView: 0, componentType: 5126, count: 3, type: "VEC3", sparse: { count: 1, indices: { bufferView: 4, componentType: 5121 }, values: { bufferView: 5 }, }, }, { bufferView: 1, componentType: 5123, normalized: true, count: 3, type: "VEC2", }, { bufferView: 2, componentType: 5121, normalized: true, count: 3, type: "VEC3", }, { bufferView: 3, componentType: 5123, count: 3, type: "SCALAR", }, ], images: [ { uri: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", }, ], samplers: [ { magFilter: 9728, minFilter: 9984, wrapS: 33071, wrapT: 33648 }, ], textures: [{ source: 0, sampler: 0 }], materials: [ { alphaMode: "BLEND", doubleSided: true, normalTexture: { ...texture, scale: 0.75 }, emissiveTexture: texture, emissiveFactor: [0.4, 0.5, 0.6], occlusionTexture: { ...texture, strength: 0.7 }, extensions: { KHR_materials_unlit: {}, KHR_materials_pbrSpecularGlossiness: { diffuseFactor: [0.1, 0.2, 0.3, 0.4], specularFactor: [0.5, 0.6, 0.7], glossinessFactor: 0.75, diffuseTexture: { ...texture, extensions: { KHR_texture_transform: { offset: [0.1, 0.2], scale: [0.3, 0.4], rotation: 0.5, texCoord: 1, }, }, }, specularGlossinessTexture: texture, }, KHR_materials_transmission: { transmissionFactor: 0.8, transmissionTexture: texture, }, KHR_materials_volume: { thicknessFactor: 0.9, thicknessTexture: texture, attenuationDistance: 3, attenuationColor: [0.2, 0.3, 0.4], }, KHR_materials_clearcoat: { clearcoatFactor: 0.45, clearcoatRoughnessFactor: 0.25, clearcoatTexture: texture, }, KHR_materials_sheen: { sheenColorFactor: [0.3, 0.2, 0.1], sheenRoughnessFactor: 0.35, }, KHR_materials_specular: { specularFactor: 0.55, specularColorFactor: [0.7, 0.8, 0.9], specularTexture: texture, specularColorTexture: texture, }, KHR_materials_iridescence: { iridescenceFactor: 0.65, iridescenceIor: 1.4, iridescenceThicknessMinimum: 120, iridescenceThicknessMaximum: 360, iridescenceTexture: texture, iridescenceThicknessTexture: texture, }, KHR_materials_anisotropy: { anisotropyStrength: 0.75, anisotropyRotation: 0.15, anisotropyTexture: texture, }, KHR_materials_emissive_strength: { emissiveStrength: 4 }, KHR_materials_ior: { ior: 1.6 }, }, }, ], meshes: [ { primitives: [ { attributes: { POSITION: 0, TEXCOORD_0: 1, TEXCOORD_1: 1, COLOR_0: 2, }, indices: 3, material: 0, }, ], }, ], cameras: [{ type: "perspective", perspective: { yfov: 1.1 } }], nodes: [ { mesh: 0, camera: 0, translation: [5, 0, 0], scale: [-1, 1, 1], extensions: { KHR_lights_punctual: { light: 0 } }, }, ], }; const previousCreateImageBitmap = globalThis.createImageBitmap; Object.defineProperty(globalThis, "createImageBitmap", { configurable: true, value: async () => ({ width: 1, height: 1 }), }); try { const result = await loadGltf( dataUrl("model/gltf+json", JSON.stringify(document)), ); assert.equal(result.primitives.length, 1); const primitive = result.primitives[0]; close(primitive.positions, [5, 0, 0, 3, 0, 0, 5, 1, 0]); assert.deepEqual(Array.from(primitive.indices), [0, 2, 1]); close(primitive.normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]); close(primitive.uvs, [0, 0, 1, 0, 0, 1]); close(primitive.uvs2, [0, 0, 1, 0, 0, 1]); close(primitive.colors, [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1]); assert.equal(primitive.tangents.length, 12); assert.equal(Array.from(primitive.tangents).every(Number.isFinite), true); const material = result.materials[0]; close(material.baseColor, [0.1, 0.2, 0.3, 0.4]); assert.equal(material.metallic, 0); assert.equal(material.roughness, 0.25); assert.equal(material.flags, 30); assert.equal(material.transmission, 0.8); assert.equal(material.ior, 1.6); assert.equal(material.thickness, 0.9); assert.equal(material.attenuationDistance, 3); close(material.attenuationColor, [0.2, 0.3, 0.4]); assert.equal(material.emissiveStrength, 4); assert.equal(material.clearcoat, 0.45); assert.equal(material.clearcoatRoughness, 0.25); assert.equal(material.specular, 0.55); close(material.specularColor, [0.5, 0.6, 0.7]); close(material.specularExtensionColor, [0.7, 0.8, 0.9]); assert.equal(material.iridescence, 0.65); assert.equal(material.anisotropy, 0.75); assert.deepEqual( [ material.baseColorTexture, material.metallicRoughnessTexture, material.normalTexture, material.emissiveTexture, material.occlusionTexture, material.transmissionTexture, material.thicknessTexture, material.clearcoatTexture, material.iridescenceTexture, material.iridescenceThicknessTexture, material.anisotropyTexture, material.specularTexture, material.specularColorTexture, ], Array(13).fill(0), ); assert.deepEqual(material.textureInfos[0], { texture: 0, texCoord: 1, offset: [0.1, 0.2], scale: [0.3, 0.4], rotation: 0.5, }); assert.equal(result.textures.length, 1); assert.deepEqual(result.textures[0].sampler, { magFilter: "nearest", minFilter: "nearest", mipmapFilter: "nearest", addressModeU: "clamp-to-edge", addressModeV: "mirror-repeat", }); assert.deepEqual(result.camera, { direction: [0, 0, 1], fov: 1.1 }); assert.equal(result.lights.length, 1); const { rotor, ...light } = result.lights[0]; assert.deepEqual(light, { type: "spot", color: [0.2, 0.4, 0.6], intensity: 7, range: 8, innerAngle: 0.1, outerAngle: 0.5, position: [5, 0, 0], }); close(rotor, [0, 0, 0, 1]); } finally { if (previousCreateImageBitmap) Object.defineProperty(globalThis, "createImageBitmap", { configurable: true, value: previousCreateImageBitmap, }); else delete (globalThis as any).createImageBitmap; } }); test("binary GLB parsing consumes JSON and BIN chunks", async () => { const binary = new Uint8Array( new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer, ); const document = { asset: { version: "2.0" }, buffers: [{ byteLength: binary.length }], bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: binary.length }], accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], }; const result = await loadGltf( dataUrl("model/gltf-binary", glb(document, binary)), ); assert.equal(result.primitives.length, 1); close(result.primitives[0].positions, [0, 0, 0, 1, 0, 0, 0, 1, 0]); close(result.primitives[0].normals, [0, 0, 1, 0, 0, 1, 0, 0, 1]); assert.deepEqual(Array.from(result.primitives[0].indices), [0, 1, 2]); assert.deepEqual(result.materials, []); assert.deepEqual(result.textures, []); });