import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { expect, test, type Page } from "@playwright/test"; const root = path.resolve(import.meta.dirname, "../../.."); const fixtureRoot = path.join(root, "tests/files/web/m12_obj_multi_v1"); const sourceObj = fs.readFileSync(path.join(fixtureRoot, "multi-object.obj")); const sourceMtl = fs.readFileSync(path.join(fixtureRoot, "multi-object.mtl")); const texture = fs.readFileSync(path.join(fixtureRoot, "m12_obj_texture.png")); const reportPath = path.join(root, "tests/golden/M12-07C/web-roundtrip-report.json"); const sha256 = (bytes: Uint8Array | Buffer | string) => crypto.createHash("sha256").update(bytes).digest("hex"); async function runBrowserRoundtrip(page: Page, textureAssets: string[]) { return page.evaluate(async (input: { obj: number[]; mtl: number[]; textureAssets: string[] }) => new Promise((resolve, reject) => { const worker = new Worker("/src/workers/obj-roundtrip-test.worker.ts", { type: "module" }); worker.onmessage = (event: MessageEvent) => { worker.terminate(); resolve(event.data); }; worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); }; const obj = Uint8Array.from(input.obj).buffer; const mtl = Uint8Array.from(input.mtl).buffer; worker.postMessage({ obj, mtl, textureAssets: input.textureAssets }, [obj, mtl]); }), { obj: Array.from(sourceObj), mtl: Array.from(sourceMtl), textureAssets }); } test("M12-07C round-trips a Web OBJ through desktop Blender and reports texture loss", async ({ page }) => { await page.goto("/"); const bound = await runBrowserRoundtrip(page, ["m12_obj_texture.png"]); const missing = await runBrowserRoundtrip(page, []); expect(bound.ok).toBe(true); expect(bound.lossReport).toEqual({ schemaVersion: 1, operation: "OBJ_EXPORT_LOSS_REPORT", canRoundTrip: true, warningCount: 0, warnings: [] }); expect(missing.lossReport.warnings.map((warning: { code: string }) => warning.code)).toEqual(["OBJ_TEXTURE_ORIGIN_UNRESOLVED", "OBJ_TEXTURE_ORIGIN_UNRESOLVED"]); const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "m12-07c-web-obj-")); try { const webObjPath = path.join(temporary, "web-output.obj"); const webMtlPath = path.join(temporary, "single-mesh.mtl"); fs.writeFileSync(webObjPath, bound.obj, "utf8"); fs.writeFileSync(webMtlPath, bound.mtl, "utf8"); fs.writeFileSync(path.join(temporary, "m12_obj_texture.png"), texture); const desktopReportPath = path.join(temporary, "desktop-report.json"); const blender = process.env.BLENDER_BIN ?? path.join(root, "build_blender_5.2.0/bin/blender"); const result = spawnSync(blender, ["-b", "--factory-startup", "--python", path.join(root, "tools/web/check-obj-web-roundtrip.py"), "--", webObjPath, desktopReportPath], { cwd: root, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); const desktop = JSON.parse(fs.readFileSync(desktopReportPath, "utf8")); const report = { schemaVersion: 1, task: "M12-07C", operation: "WEB_OBJ_TO_DESKTOP_ROUNDTRIP", source: { objSha256: sha256(sourceObj), mtlSha256: sha256(sourceMtl), textureSha256: sha256(texture) }, browser: { imported: { schemaVersion: bound.imported.schemaVersion, objectCount: bound.imported.objects.length, positionCount: bound.imported.positions.length, texcoordCount: bound.imported.texcoords.length, normalCount: bound.imported.normals.length, faceCount: bound.imported.faces.length, materialCount: bound.imported.materials.length, }, outputObjSha256: sha256(Buffer.from(bound.obj)), outputMtlSha256: sha256(Buffer.from(bound.mtl)), lossReport: bound.lossReport, missingTextureLoss: missing.lossReport, }, desktop, comparisons: { objectCountExact: desktop.objectCount === bound.imported.objects.length, triangleCountExact: desktop.objects.reduce((sum: number, object: { triangleCount: number }) => sum + object.triangleCount, 0) === bound.imported.faces.length, uvLayerPresent: desktop.objects.every((object: { uvLayers: string[] }) => object.uvLayers.includes("UVMap")), materialPresent: desktop.objects.every((object: { materials: string[] }) => object.materials.length === 1), }, nextTask: "M12-07D", }; if (process.env.UPDATE_OBJ_ROUNDTRIP === "1") { fs.mkdirSync(path.dirname(reportPath), { recursive: true }); fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n"); } const expected = JSON.parse(fs.readFileSync(reportPath, "utf8")); expect(report).toEqual(expected); expect(report.comparisons).toEqual({ objectCountExact: true, triangleCountExact: true, uvLayerPresent: true, materialPresent: true }); } finally { fs.rmSync(temporary, { recursive: true, force: true }); } });