import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; import { describe, expect, it } from "vitest"; import { decodePaintState, encodePaintState, extractBambuSettings, mapSettingsToSlic3r, parsePlates, normalize3mf, normalizeProjectSettings, stateToMaterialIndex, } from "../src/services/threeMfNormalizer"; // Pure: no database. The cache test points storage at a temp dir before loading config. const MODEL_NS = 'xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p"'; const SINGLE_COMPONENT_ROOT = ` `; function modelXml(body: string, head = ' Test Cube'): string { return ` ${head} BambuStudio-02.07.01.57 ${body} `; } const MESH_OBJECT = ` `; async function makeBambuZip(overrides: { rootXml?: string } = {}): Promise { const zip = new JSZip(); zip.file("3D/3dmodel.model", overrides.rootXml ?? modelXml(SINGLE_COMPONENT_ROOT)); zip.file("3D/Objects/object_1.model", MESH_OBJECT); zip.file("[Content_Types].xml", ''); zip.file("_rels/.rels", ''); zip.file( "Metadata/project_settings.config", JSON.stringify({ layer_height: "0.2", wall_loops: "3", line_width: "0.42", inner_wall_line_width: "0.45", outer_wall_line_width: "0.42", sparse_infill_density: "15%", sparse_infill_pattern: "crosshatch", enable_support: "1", filament_colour: ["#FFFFFF", "#C52C18"], filament_type: ["PLA", "PLA"], filament_diameter: ["1.75", "1.75"], nozzle_temperature: ["220", "220"], filament_settings_id: ["Generic PLA @BBL A1M", "Generic PLA @BBL A1M"], printer_model: "Bambu Lab A1 mini", machine_start_gcode: "M104 S200", printable_area: ["0x0", "180x180"], }), ); zip.file( "Metadata/model_settings.config", ` `, ); zip.file("Metadata/slice_info.config", ""); zip.file("Metadata/plate_1.gcode", "; gcode"); zip.file("3D/_rels/3dmodel.model.rels", ""); return zip.generateAsync({ type: "uint8array" }); } async function normalizeZip(zip: JSZip) { return normalize3mf(await zip.generateAsync({ type: "uint8array" })); } async function readOut(bytes: Uint8Array, name: string): Promise { const file = (await JSZip.loadAsync(bytes)).file(name); if (!file) throw new Error(`missing ${name}`); return file.async("string"); } describe("extractBambuSettings", () => { it("reads flat project settings", () => { const flat = extractBambuSettings({ wall_loops: "4", layer_height: "0.16" }); expect(flat.wall_loops).toBe("4"); expect(flat.process_settings).toBeUndefined(); }); it("merges legacy nested process_settings.1", () => { expect( extractBambuSettings({ printer_model: "X1", process_settings: { "1": { wall_loops: "6" } } }).wall_loops, ).toBe("6"); }); }); describe("mapSettingsToSlic3r", () => { it("maps walls, layer height, line widths and infill", () => { const mapped = mapSettingsToSlic3r({ layer_height: "0.2", wall_loops: "3", line_width: "0.42", inner_wall_line_width: "0.45", outer_wall_line_width: "0.42", sparse_infill_density: "15%", sparse_infill_pattern: "crosshatch", enable_support: "1", }); expect(mapped).toMatchObject({ layer_height: "0.2", perimeters: "3", extrusion_width: "0.42", perimeter_extrusion_width: "0.45", external_perimeter_extrusion_width: "0.42", fill_density: "15%", fill_pattern: "grid", support_material: "1", }); }); it("keeps filament arrays and widens extruder_colour to the full palette", () => { const mapped = mapSettingsToSlic3r({ filament_colour: ["#FFFFFF", "#161616", "#C52C18"], filament_type: ["PLA", "PETG", "PLA"], nozzle_temperature: ["210", "240", "210"], extruder_colour: ["#018001"], }); expect(mapped.filament_type).toEqual(["PLA", "PETG", "PLA"]); expect(mapped.temperature).toEqual(["210", "240", "210"]); expect(mapped.extruder_colour).toEqual(["#FFFFFF", "#161616", "#C52C18"]); }); }); describe("normalizeProjectSettings", () => { it("keeps designer process settings and strips printer, machine and sentinel values", () => { const cleaned = normalizeProjectSettings({ wall_loops: "3", filament_colour: ["#FFFFFF", "#C52C18"], extruder_colour: ["#018001"], filament_settings_id: ["Generic PLA @BBL A1M"], printer_model: "Bambu Lab A1 mini", machine_start_gcode: "M104", use_relative_e_distances: "1", raft_first_layer_expansion: "-1", tree_support_wall_count: -1, filament_retraction_length: ["nil", "nil"], ensure_vertical_shell_thickness: "enabled", wall_filament: "0", }); expect(cleaned).toEqual({ wall_loops: "3", filament_colour: ["#FFFFFF", "#C52C18"], extruder_colour: ["#FFFFFF", "#C52C18"], ensure_vertical_shell_thickness: "ensure_all", }); }); }); describe("paint state", () => { it("round-trips Bambu TriangleSelector states", () => { expect(decodePaintState("4")).toBe(1); expect(decodePaintState("8")).toBe(2); expect(decodePaintState("0C")).toBe(3); expect(decodePaintState("2C")).toBe(5); expect(decodePaintState("")).toBe(0); expect(encodePaintState(1)).toBe("4"); expect(encodePaintState(3)).toBe("0C"); expect(decodePaintState(encodePaintState(4))).toBe(4); expect(stateToMaterialIndex(1, 2, 5)).toBe(0); expect(stateToMaterialIndex(0, 2, 5)).toBe(1); expect(stateToMaterialIndex(5, 1, 5)).toBe(4); }); }); describe("normalize3mf", () => { it("flattens the production extension into one core model", async () => { const result = await normalize3mf(await makeBambuZip()); const out = await JSZip.loadAsync(result.bytes); expect(out.file("3D/Objects/object_1.model")).toBeNull(); const xml = await readOut(result.bytes, "3D/3dmodel.model"); expect(xml).toMatch(/Thingport<\/metadata>/); expect(result.report.flattened).toBe(true); }); it("keeps painted faces and filament colors", async () => { const result = await normalize3mf(await makeBambuZip()); const xml = await readOut(result.bytes, "3D/3dmodel.model"); expect(xml).toMatch(/paint_color="4"/); expect(xml).toMatch(/slic3rpe:mmu_segmentation="8"/); expect(xml).toMatch(/p1="0"/); expect(xml).toMatch(/p1="1"/); expect(xml).toMatch(/displaycolor="#FFFFFFFF"/); expect(xml).toMatch(/displaycolor="#C52C18FF"/); expect(result.report).toMatchObject({ paintColors: 2, triangles: 2, vertices: 4 }); }); it("writes Slic3r settings alongside, not inside, existing metadata", async () => { const result = await normalize3mf(await makeBambuZip()); const xml = await readOut(result.bytes, "3D/3dmodel.model"); expect(xml).toMatch(/Test Cube<\/metadata>/); expect(xml).toMatch(/3<\/metadata>/); const slic3r = await readOut(result.bytes, "Metadata/Slic3r_PE.config"); expect(slic3r).toMatch(/; perimeters = 3/); expect(slic3r).toMatch(/; fill_density = 15%/); expect(slic3r).toMatch(/; extruder_colour = #FFFFFF;#C52C18/); }); it("closes an unclosed empty Copyright tag", async () => { const head = ' Broly\n \n MakerWorld'; const result = await normalize3mf(await makeBambuZip({ rootXml: modelXml(SINGLE_COMPONENT_ROOT, head) })); const xml = await readOut(result.bytes, "3D/3dmodel.model"); expect(xml).toMatch(//); expect(xml).toMatch(/MakerWorld<\/metadata>/); expect(xml).not.toMatch(//); }); it("strips G-code, slice info and the printer profile", async () => { const result = await normalize3mf(await makeBambuZip()); const out = await JSZip.loadAsync(result.bytes); expect(out.file("Metadata/plate_1.gcode")).toBeNull(); expect(out.file("Metadata/slice_info.config")).toBeNull(); expect(out.file("3D/_rels/3dmodel.model.rels")).toBeNull(); const project = JSON.parse(await readOut(result.bytes, "Metadata/project_settings.config")); expect(project.wall_loops).toBe("3"); expect(project.printer_model).toBeUndefined(); expect(project.filament_settings_id).toBeUndefined(); expect(project.machine_start_gcode).toBeUndefined(); }); it("rejects archives without a model", async () => { const zip = new JSZip(); zip.file("readme.txt", "no model"); await expect(normalizeZip(zip)).rejects.toThrow(/No valid 3D model/); }); it("still normalizes an already-flat 3MF", async () => { const zip = new JSZip(); zip.file( "3D/3dmodel.model", ` `, ); const result = await normalizeZip(zip); expect(result.report).toMatchObject({ triangles: 1, flattened: false }); expect(await readOut(result.bytes, "3D/3dmodel.model")).toMatch(/pid="/); }); it("merges a multi-part assembly into one object that keeps each part's filament", async () => { const zip = new JSZip(); zip.file( "3D/3dmodel.model", ` `, ); zip.file( "3D/Objects/object_1.model", ` `, ); zip.file( "Metadata/project_settings.config", JSON.stringify({ filament_colour: ["#FBFCFF", "#F2910B", "#000000"], filament_type: ["PLA", "PLA", "PLA"] }), ); zip.file( "Metadata/model_settings.config", ` `, ); const result = await normalizeZip(zip); const xml = await readOut(result.bytes, "3D/3dmodel.model"); expect(xml.match(//); expect(slic3rModel).toMatch(//); expect(parsePlates(modelCfg)[0].instances).toHaveLength(1); }); it("keeps multiple plates, remapped to the flattened object ids", async () => { const zip = new JSZip(); zip.file( "3D/3dmodel.model", ` `, ); zip.file("3D/Objects/object_1.model", MESH_OBJECT); zip.file( "Metadata/model_settings.config", ` `, ); const result = await normalizeZip(zip); const xml = await readOut(result.bytes, "3D/3dmodel.model"); const itemIds = [...xml.matchAll(/ m[1]); expect(itemIds).toHaveLength(2); expect(itemIds[0]).not.toBe(itemIds[1]); const modelCfg = await readOut(result.bytes, "Metadata/model_settings.config"); const plates = parsePlates(modelCfg); expect(plates.map((p) => p.instances[0].objectId)).toEqual(itemIds); expect(modelCfg).toMatch(//); expect(result.report.plates).toBe(2); }); }); describe("normalized3mfFor", () => { it("normalizes in a worker, caches the copy and falls back to null for a broken file", async () => { const storage = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-normalize-")); process.env.FILE_STORAGE = storage; const { normalized3mfFor } = await import("../src/services/normalized3mfCache"); const src = path.join(storage, "model.3mf"); fs.writeFileSync(src, await makeBambuZip()); const first = await normalized3mfFor("plate-a", src); expect(first).not.toBeNull(); expect(await readOut(fs.readFileSync(first!), "3D/3dmodel.model")).toMatch(/Thingport/); const mtime = fs.statSync(first!).mtimeMs; expect(await normalized3mfFor("plate-a", src)).toBe(first); expect(fs.statSync(first!).mtimeMs).toBe(mtime); const broken = path.join(storage, "broken.3mf"); fs.writeFileSync(broken, "not a zip"); expect(await normalized3mfFor("plate-b", broken)).toBeNull(); fs.rmSync(storage, { recursive: true, force: true }); }); });