import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; import ts from "../../web/node_modules/typescript/lib/typescript.js"; const root = path.resolve(new URL("../../", import.meta.url).pathname); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "web-glb-blender-")); function identity(translation = [0, 0, 0]) { return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, translation[0], translation[1], translation[2], 1]; } function geometry(meshId, positions) { const positionBuffer = Float32Array.from(positions).buffer; const indexBuffer = Uint32Array.from([0, 1, 2]).buffer; return { schemaVersion: 1, meshId, byteLength: positionBuffer.byteLength + indexBuffer.byteLength, positions: positionBuffer, indices: indexBuffer, }; } try { const exporterSource = fs.readFileSync(path.join(root, "web/protocol/glb-export.ts"), "utf8"); const transpiled = ts.transpileModule(exporterSource, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: "glb-export.ts", }); const exporterPath = path.join(temporary, "glb-export.cjs"); fs.writeFileSync(exporterPath, transpiled.outputText); const { exportGLB } = await import(pathToFileURL(exporterPath).href); const roundTripPositions = [0, 0, 0, 1, 0, 0, 0, 1, 0]; const skinPositions = [0, 0, 0, 0.5, 0, 0, 0, 0, 1]; const snapshot = { schemaVersion: 1, revision: 7, sceneId: "scene:BlenderRoundTrip", source: { kind: "mock" }, coordinateSystem: { upAxis: "Z", forwardAxis: "-Y", handedness: "RIGHT", unitSystem: 0, unitScale: 1 }, nodes: [ { id: "object:Armature", name: "Armature", type: "ARMATURE", parentId: null, dataId: "armature:Rig", visible: true, selectable: true, localMatrix: identity(), worldMatrix: identity(), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }, { id: "object:RoundTripMesh", name: "RoundTripMesh", type: "MESH", parentId: null, dataId: "mesh:RoundTripMesh", visible: true, selectable: true, localMatrix: identity([2, -3, 4]), worldMatrix: identity([2, -3, 4]), transform: { translation: [2, -3, 4], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }, { id: "object:SkinnedMesh", name: "SkinnedMesh", type: "MESH", parentId: null, dataId: "mesh:SkinnedMesh", visible: true, selectable: true, localMatrix: identity(), worldMatrix: identity(), transform: { translation: [0, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1], rotationMode: 1 } }, ], meshes: [ { id: "mesh:RoundTripMesh", name: "RoundTripMesh", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:RoundTripMesh", topology: "triangles", materialSlotIds: ["material:RoundTrip"], shapeKeys: [{ name: "Lift", positions: [0, 0, 0, 1, 0, 0, 0, 1, 1] }] }, { id: "mesh:SkinnedMesh", name: "SkinnedMesh", vertexCount: 3, edgeCount: 3, faceCount: 1, cornerCount: 3, triangleCount: 1, geometryStatus: "binary", geometryBufferId: "mesh:SkinnedMesh", topology: "triangles", materialSlotIds: ["material:Texture"], skinWeights: { boneNames: ["Root", "Tip"], indices: [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0], weights: [0.75, 0.25, 0, 0, 0.5, 0.5, 0, 0, 0.25, 0.75, 0, 0], bindMatrix: identity(), armatureId: "armature:Rig", jointIds: ["bone:Root", "bone:Tip"] } }, ], armatures: [{ id: "armature:Rig", name: "Rig", objectId: "object:Armature", bones: [ { id: "bone:Root", name: "Root", parentId: null, head: [0, 0, 0], tail: [0, 0, 1], restMatrix: identity(), poseMatrix: identity() }, { id: "bone:Tip", name: "Tip", parentId: "bone:Root", head: [0, 0, 1], tail: [0, 0, 2], restMatrix: identity([0, 0, 1]), poseMatrix: identity([0, 0, 1]) }, ] }], materials: [ { id: "material:RoundTrip", name: "RoundTripMaterial", baseColor: [0.25, 0.5, 0.75, 0.8], roughness: 0.6, metallic: 0.35, emissionColor: [0.05, 0.1, 0.2, 1], alpha: 0.8, ior: 1.6, specularIORLevel: 0.35, transmissionWeight: 0.27, coatWeight: 0.64, coatRoughness: 0.12, emissionStrength: 3.5 }, { id: "material:Texture", name: "TextureMaterial", baseColor: [1, 1, 1, 1], roughness: 0.4, metallic: 0, emissionColor: [0, 0, 0, 1], alpha: 1, ior: 1.45, imageIds: ["image:RoundTripTexture"] }, ], images: [{ id: "image:RoundTripTexture", name: "RoundTripTexture", assetId: "asset:RoundTripTexture", mimeType: "image/png", packed: true }], animations: [{ id: "action:Move", name: "Move", targetId: "object:RoundTripMesh", frameStart: 1, frameEnd: 25, channels: [ { path: "location[0]", keyframes: [{ frame: 1, value: [2] }, { frame: 25, value: [4] }] }, { path: "location[1]", keyframes: [{ frame: 1, value: [-3] }, { frame: 25, value: [-3] }] }, { path: "location[2]", keyframes: [{ frame: 1, value: [4] }, { frame: 25, value: [4] }] }, ] }], cameras: [], lights: [], worlds: [], collections: [], scenes: [], activeObjectId: "object:RoundTripMesh", frame: { current: 1, start: 1, end: 25 }, }; const texture = fs.readFileSync(path.join(root, "tests/files/web/resources/udim_1001.png")); const exported = exportGLB( snapshot, [geometry("mesh:RoundTripMesh", roundTripPositions), geometry("mesh:SkinnedMesh", skinPositions)], [{ assetId: "asset:RoundTripTexture", mimeType: "image/png", data: texture.buffer.slice(texture.byteOffset, texture.byteOffset + texture.byteLength) }], ); assert.equal(exported.report.canExport, true, JSON.stringify(exported.report.warnings)); assert.ok(exported.glb?.byteLength > 1000, "GLB output is unexpectedly small"); const glbPath = path.join(temporary, "roundtrip.glb"); const reportPath = path.join(temporary, "blender-report.json"); fs.writeFileSync(glbPath, new Uint8Array(exported.glb)); const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); const imported = spawnSync(blender, ["-b", "--python", path.join(root, "tools/web/blender-check-glb-roundtrip.py"), "--", glbPath, reportPath], { cwd: root, encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); assert.equal(imported.status, 0, `${imported.stdout}\n${imported.stderr}`); assert.ok(fs.existsSync(reportPath), `Blender produced no round-trip report\n${imported.stdout}\n${imported.stderr}`); const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); if (process.env.GLB_ROUNDTRIP_DEBUG === "1") process.stderr.write(`${JSON.stringify(report.material, null, 2)}\n`); assert.equal(report.mesh.vertexCount, 3); assert.equal(report.mesh.triangleCount, 1); assert.deepEqual(report.mesh.positions, [[0, 0, 0], [1, 0, 0], [0, 1, 0]]); assert.deepEqual(report.mesh.frame1WorldTranslation, [2, -3, 4]); assert.deepEqual(report.mesh.frame25WorldTranslation, [4, -3, 4]); assert.deepEqual(report.mesh.shapeKeys.Lift, [0, 0, 1]); assert.deepEqual(report.material.principled.baseColor, [0.25, 0.5, 0.75, 1]); assert.equal(report.material.principled.alpha, 0.8); assert.equal(report.material.principled.metallic, 0.35); assert.equal(report.material.principled.roughness, 0.6); assert.equal(report.material.principled.ior, 1.6); assert.equal(report.material.principled.specularIORLevel, 0.35); assert.equal(report.material.principled.transmissionWeight, 0.27); assert.equal(report.material.principled.coatWeight, 0.64); assert.equal(report.material.principled.coatRoughness, 0.12); assert.equal(report.material.principled.emissionStrength, 3.5); assert.equal(report.material.texture.present, true); assert.equal(report.material.texture.interpolation, "Linear"); assert.equal(report.material.texture.extension, "REPEAT"); assert.equal(report.material.texture.imageWidth, 8); assert.equal(report.material.texture.imageHeight, 8); assert.equal(report.material.texture.colorSpace, "sRGB"); assert.ok(report.animation.actionCount >= 1); assert.equal(report.skin.armatureCount, 1); assert.deepEqual(report.skin.bones, ["Root", "Tip"]); assert.deepEqual(report.skin.boneParents, { Root: null, Tip: "Root" }); assert.ok(report.skin.modifierCount >= 1); assert.equal(report.skin.vertexGroupCount, 2); assert.deepEqual(report.skin.weightSums, [1, 1, 1]); process.stdout.write(`glb-blender-roundtrip-ok bytes=${exported.glb.byteLength} vertices=3 morphs=1 bones=2 actions=${report.animation.actionCount} texture=8x8\n`); } finally { fs.rmSync(temporary, { recursive: true, force: true }); }