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 });
});
});