import { afterAll, beforeAll, describe, expect, it } from "vitest"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { writeZip } from "../src/utils/zipWriter"; import { generateModelPreviewGlb, modelPreviewGlbExists, modelPreviewGlbPath, modelPreviewState, } from "../src/services/modelPreviewCache"; // A small 2-object/2-extruder/2-plate Bambu-style .3mf. const MODEL_XML = ` `; const MODEL_SETTINGS_XML = ` `; const PROJECT_SETTINGS_JSON = JSON.stringify({ filament_colour: ["#00B800", "#FF0000"], printable_area: ["0x0", "256x0", "256x256", "0x256"], }); async function buildFixture3mf(destPath: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-fixture-")); const modelPath = path.join(dir, "3dmodel.model"); const settingsPath = path.join(dir, "model_settings.config"); const projectPath = path.join(dir, "project_settings.config"); await fs.writeFile(modelPath, MODEL_XML); await fs.writeFile(settingsPath, MODEL_SETTINGS_XML); await fs.writeFile(projectPath, PROJECT_SETTINGS_JSON); await writeZip(destPath, [ { arcname: "3D/3dmodel.model", filePath: modelPath }, { arcname: "Metadata/model_settings.config", filePath: settingsPath }, { arcname: "Metadata/project_settings.config", filePath: projectPath }, ]); } // A wrapper object whose component references another object in the same document instead of // having its own mesh. const WRAPPER_MODEL_XML = ` `; const WRAPPER_MODEL_SETTINGS_XML = ` `; async function buildWrapperFixture3mf(destPath: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-wrapper-fixture-")); const modelPath = path.join(dir, "3dmodel.model"); const settingsPath = path.join(dir, "model_settings.config"); const projectPath = path.join(dir, "project_settings.config"); await fs.writeFile(modelPath, WRAPPER_MODEL_XML); await fs.writeFile(settingsPath, WRAPPER_MODEL_SETTINGS_XML); await fs.writeFile(projectPath, PROJECT_SETTINGS_JSON); await writeZip(destPath, [ { arcname: "3D/3dmodel.model", filePath: modelPath }, { arcname: "Metadata/model_settings.config", filePath: settingsPath }, { arcname: "Metadata/project_settings.config", filePath: projectPath }, ]); } // `+ 0` turns -0 into 0, which toEqual would treat as different. function positionsOf(mesh: any): number[] { return Array.from(mesh.geometry.attributes.position.array as ArrayLike, (v) => v + 0); } describe("modelPreviewCache", () => { let fixturePath: string; const plateId = `test-fixture-${Date.now()}`; beforeAll(async () => { // This test needs no database, only the cache directory db.ts normally creates. await fs.mkdir(path.dirname(modelPreviewGlbPath("x")), { recursive: true }); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-out-")); fixturePath = path.join(dir, "fixture.3mf"); await buildFixture3mf(fixturePath); }); afterAll(async () => { await fs.rm(modelPreviewGlbPath(plateId), { force: true }); }); it("generates a GLB with both plates, correct colors, and correctly transformed geometry", async () => { await generateModelPreviewGlb(plateId, fixturePath); expect(modelPreviewGlbExists(plateId)).toBe(true); const THREE = await import("three"); const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js"); const buf = fsSync.readFileSync(modelPreviewGlbPath(plateId)); const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); const gltf = await new Promise((resolve, reject) => { new GLTFLoader().parse(arrayBuffer, "", resolve, reject); }); let root: any = null; gltf.scene.traverse((obj: any) => { if (obj.userData?.thingportPreview) root = obj; }); expect(root).toBeTruthy(); const meta = JSON.parse(root.userData.thingportPreview); expect(meta.plates).toEqual([ { index: 1, name: "First plate", objectCount: 1 }, { index: 2, name: "Second plate", objectCount: 1 }, ]); expect(meta.filamentColors).toEqual(["#00B800", "#FF0000"]); expect(meta.buildVolume).toEqual({ x: 256, y: 256 }); const meshes: Record = {}; gltf.scene.traverse((obj: any) => { if (obj.isMesh) { meshes[obj.name] = { color: `#${obj.material.color.getHexString()}`, positions: positionsOf(obj), }; } }); expect(Object.keys(meshes).toSorted()).toEqual(["extruder-0", "extruder-1"]); expect(meshes["extruder-0"].color.toLowerCase()).toBe("#00b800"); expect(meshes["extruder-1"].color.toLowerCase()).toBe("#ff0000"); // Z-up -> Y-up rotation (x,y,z -> x,z,-y). A plain swap would mirror the model. expect(meshes["extruder-0"].positions).toEqual([0, 0, 0, 10, 0, 0, 0, 0, -10]); expect(meshes["extruder-1"].positions).toEqual([50, 0, 0, 70, 0, 0, 50, 0, -20]); void THREE; // imported only to force-load three before GLTFLoader in some module graphs }); it("is idempotent -- a second call while the GLB already exists is a fast no-op", async () => { const before = fsSync.statSync(modelPreviewGlbPath(plateId)).mtimeMs; await generateModelPreviewGlb(plateId, fixturePath); const after = fsSync.statSync(modelPreviewGlbPath(plateId)).mtimeMs; expect(after).toBe(before); }); }); describe("modelPreviewCache -- internal references", () => { let wrapperFixturePath: string; const wrapperPlateId = `test-fixture-wrapper-${Date.now()}`; beforeAll(async () => { await fs.mkdir(path.dirname(modelPreviewGlbPath("x")), { recursive: true }); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-wrapper-out-")); wrapperFixturePath = path.join(dir, "wrapper.3mf"); await buildWrapperFixture3mf(wrapperFixturePath); }); afterAll(async () => { await fs.rm(modelPreviewGlbPath(wrapperPlateId), { force: true }); }); it("resolves geometry through a same-document reference with no p:path", async () => { await generateModelPreviewGlb(wrapperPlateId, wrapperFixturePath); expect(modelPreviewGlbExists(wrapperPlateId)).toBe(true); const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js"); const buf = fsSync.readFileSync(modelPreviewGlbPath(wrapperPlateId)); const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); const gltf = await new Promise((resolve, reject) => { new GLTFLoader().parse(arrayBuffer, "", resolve, reject); }); let root: any = null; gltf.scene.traverse((obj: any) => { if (obj.userData?.thingportPreview) root = obj; }); expect(root).toBeTruthy(); const meta = JSON.parse(root.userData.thingportPreview); expect(meta.plates).toEqual([{ index: 1, name: "Only plate", objectCount: 1 }]); const meshes: Record = {}; gltf.scene.traverse((obj: any) => { if (obj.isMesh) { meshes[obj.name] = { positions: positionsOf(obj) }; } }); expect(Object.keys(meshes)).toEqual(["extruder-0"]); // Translated by the component's +5 X, then rotated. expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, -10]); }); }); async function buildModelOnly3mf(destPath: string, modelXml: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-model-only-")); const modelPath = path.join(dir, "3dmodel.model"); await fs.writeFile(modelPath, modelXml); await writeZip(destPath, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]); } const ONE_TRIANGLE_MESH = ``; function singleItemModelXml(objects: string, buildItemObjectId: string): string { return ` ${objects} `; } function fanOut(refId: string): string { return `${Array.from({ length: 20 }, () => ``).join("")}`; } describe("modelPreviewCache -- memory safety", () => { let outDir: string; const plateIds: string[] = []; const newPlateId = (label: string) => { const id = `test-fixture-${label}-${Date.now()}`; plateIds.push(id); return id; }; beforeAll(async () => { await fs.mkdir(path.dirname(modelPreviewGlbPath("x")), { recursive: true }); outDir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-safety-out-")); }); afterAll(async () => { for (const id of plateIds) { for (const ext of [".v3.glb", ".v3.error", ".pending"]) { await fs.rm(path.join(path.dirname(modelPreviewGlbPath("x")), `${id}${ext}`), { force: true }); } } }); it("refuses a file whose rendered triangle count explodes through nested component references", async () => { // 64M rendered triangles from a few-KB file: must bail on the count, not by building geometry. let objects = `${ONE_TRIANGLE_MESH}`; for (let id = 2; id <= 7; id++) objects += `${fanOut(String(id - 1))}`; const fixture = path.join(outDir, "fanout.3mf"); await buildModelOnly3mf(fixture, singleItemModelXml(objects, "7")); const plateId = newPlateId("fanout"); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewGlbExists(plateId)).toBe(false); const errorPath = path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.v3.error`); expect(fsSync.readFileSync(errorPath, "utf-8")).toBe("permanent: too-complex"); }); it("resolves a two-level wrapper chain to exactly one copy of the geometry", async () => { const objects = `${ONE_TRIANGLE_MESH}` + `` + ``; const fixture = path.join(outDir, "chain.3mf"); await buildModelOnly3mf(fixture, singleItemModelXml(objects, "3")); const plateId = newPlateId("chain"); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewGlbExists(plateId)).toBe(true); const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js"); const buf = fsSync.readFileSync(modelPreviewGlbPath(plateId)); const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); const gltf = await new Promise((resolve, reject) => { new GLTFLoader().parse(arrayBuffer, "", resolve, reject); }); const meshes: any[] = []; gltf.scene.traverse((obj: any) => { if (obj.isMesh) meshes.push(obj); }); expect(meshes).toHaveLength(1); expect(positionsOf(meshes[0])).toEqual([0, 0, 0, 10, 0, 0, 0, 0, -10]); }); it("never retries a plate whose previous generation died mid-run", async () => { const fixture = path.join(outDir, "crashed.3mf"); await buildModelOnly3mf( fixture, singleItemModelXml(`${ONE_TRIANGLE_MESH}`, "1"), ); const plateId = newPlateId("crashed"); await fs.writeFile( path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.pending`), "left by a killed process", ); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewGlbExists(plateId)).toBe(false); }); it("removes its in-progress marker once generation finishes", async () => { const fixture = path.join(outDir, "ok.3mf"); await buildModelOnly3mf( fixture, singleItemModelXml(`${ONE_TRIANGLE_MESH}`, "1"), ); const plateId = newPlateId("ok"); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewGlbExists(plateId)).toBe(true); expect(fsSync.existsSync(path.join(path.dirname(modelPreviewGlbPath("x")), `${plateId}.pending`))).toBe(false); }); }); function cacheDir(): string { return path.dirname(modelPreviewGlbPath("x")); } function triangleAt(x: number): string { return ``; } async function loadGlbMeshes(plateId: string): Promise { const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js"); const buf = fsSync.readFileSync(modelPreviewGlbPath(plateId)); const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); const gltf = await new Promise((resolve, reject) => { new GLTFLoader().parse(arrayBuffer, "", resolve, reject); }); const meshes: any[] = []; gltf.scene.traverse((obj: any) => { if (obj.isMesh) meshes.push(obj); }); return meshes; } describe("modelPreviewCache -- multi-part objects sharing one part file", () => { const plateIds: string[] = []; afterAll(async () => { for (const id of plateIds) { for (const ext of [".v3.glb", ".v3.error", ".pending"]) await fs.rm(path.join(cacheDir(), `${id}${ext}`), { force: true }); } }); // Bambu Studio stores every part of a multi-part object in one file, referenced by objectid. it("takes only the referenced object from the file for each component, in its own colour", async () => { await fs.mkdir(cacheDir(), { recursive: true }); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-3mf-parts-")); const mainPath = path.join(dir, "3dmodel.model"); const partsPath = path.join(dir, "object_1.model"); const settingsPath = path.join(dir, "model_settings.config"); await fs.writeFile( mainPath, ` `, ); await fs.writeFile( partsPath, ` ${triangleAt(0)} ${triangleAt(100)} `, ); await fs.writeFile( settingsPath, ` `, ); const fixture = path.join(dir, "parts.3mf"); await writeZip(fixture, [ { arcname: "3D/3dmodel.model", filePath: mainPath }, { arcname: "3D/Objects/object_1.model", filePath: partsPath }, { arcname: "Metadata/model_settings.config", filePath: settingsPath }, ]); const plateId = `test-fixture-parts-${Date.now()}`; plateIds.push(plateId); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewState(plateId)).toBe("ready"); const meshes = await loadGlbMeshes(plateId); const byName = Object.fromEntries(meshes.map((m) => [m.name, positionsOf(m)])); expect(byName).toEqual({ "extruder-0": [0, 0, 0, 10, 0, 0, 0, 0, -10], "extruder-1": [100, 0, 0, 110, 0, 0, 100, 0, -10], }); }); }); describe("modelPreviewState", () => { const plateIds: string[] = []; const newPlateId = (label: string) => { const id = `test-fixture-state-${label}-${Date.now()}`; plateIds.push(id); return id; }; afterAll(async () => { for (const id of plateIds) { for (const ext of [".v3.glb", ".v3.error", ".pending"]) await fs.rm(path.join(cacheDir(), `${id}${ext}`), { force: true }); } }); it("says a too-heavy file failed, so the viewer won't parse it in the browser", async () => { let objects = `${ONE_TRIANGLE_MESH}`; for (let id = 2; id <= 7; id++) objects += `${fanOut(String(id - 1))}`; const fixture = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-")), "heavy.3mf"); await buildModelOnly3mf(fixture, singleItemModelXml(objects, "7")); const plateId = newPlateId("heavy"); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewState(plateId)).toBe("failed"); }); it("says a layout it can't read is unsupported, leaving it to the browser's loaders", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-")); const modelPath = path.join(dir, "model.model"); await fs.writeFile(modelPath, singleItemModelXml(`${ONE_TRIANGLE_MESH}`, "1")); const fixture = path.join(dir, "elsewhere.3mf"); // Valid 3MF (the path comes from _rels/.rels), but not where this parser looks. await writeZip(fixture, [{ arcname: "3D/model.model", filePath: modelPath }]); const plateId = newPlateId("unsupported"); await generateModelPreviewGlb(plateId, fixture); expect(modelPreviewState(plateId)).toBe("unsupported"); }); it("says generating while a render is queued or running", async () => { const fixture = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "thingport-state-")), "ok.3mf"); await buildModelOnly3mf( fixture, singleItemModelXml(`${ONE_TRIANGLE_MESH}`, "1"), ); const plateId = newPlateId("running"); const run = generateModelPreviewGlb(plateId, fixture); expect(modelPreviewState(plateId)).toBe("generating"); await run; expect(modelPreviewState(plateId)).toBe("ready"); }); });