3mf: a slicer's negative parts, modifiers and support blockers are written back beside the part, each its own kind with its settings verbatim (#104) -- Bambu/Orca and PrusaSlicer 3 as one assembly named in both configs (each reads its own, checked with all three slicers' command lines), PrusaSlicer 2 as triangle ranges of one mesh; never into the object holding the fins, since a negative part cuts every part of its object

This commit is contained in:
matthewtrahan authored and Matthew Trahan committed 2026-10-14 21:25:19 -05:00
1 parent 1bcda45da7
commit 2029c4b63a
3 files changed
+214 -16

No files matched your search

+7
View File
@@ -176,6 +176,13 @@ that aren't fin geometry, because the file format is equally part of the product
- a plate's objects come back **separately and named** (from
`Metadata/model_settings.config`) so the caller can let the user pick which to
fin, rather than merging a plate of distinct models into one soup;
- a slicer's **negative parts, modifiers and support blockers** (#104) stay out of the
part, kept by kind with their settings in each slicer's own words, and the export
writes them back beside the part (never into the object holding the fins: a
negative part cuts every part of its object) -- Bambu/Orca and PrusaSlicer 3 as one
assembly named in both configs, PrusaSlicer 2 as triangle ranges of one mesh;
`cut.test.js` pins that each real slicer file, exported uncut with its negative
and opened again, cuts to the same part (or its saved session would not restore);
- a broken file **fails loudly**: a triangle indexing a missing vertex drops that
face alone (dropping a partial one would shear the rest of the mesh), and a
non-ZIP or mesh-free package throws rather than opening blank.
+85
View File
@@ -649,6 +649,91 @@ for (const [file, name] of [['bambu.3mf', 'Generic-Cube'], ['orca.3mf', 'Generic
});
}
// --- the slicer's parts written back (#104) ----------------------------------
// The export carries them out beside the part, each its own kind and its settings
// verbatim, apart from the supports (a negative part cuts every part of its object).
/** Write `extras` beside a 1 mm cube with a block of supports, read it back. */
async function writeExtras(extras, { partTris = CUBE, colors = null, separate = true } = {}) {
const blob = writeThreeMF(partTris, triples(block(3, 4, 0, 1, 0, 1)), 'cube',
{ separate, colors, extras: extras.map((e) => ({ ...e, tris: triples(e.positions) })) });
const bytes = new Uint8Array(await blob.arrayBuffer());
return { r: await readThreeMF(bytes), parts: await unzip(bytes) };
}
const NEG = { kind: 'negative', name: 'hole', settings: { bambu: [] }, positions: block(0.5, 2, 0.5, 2, 0.5, 2) };
const MOD = { kind: 'modifier', name: 'dense', positions: block(-1, 0.5, 0, 1, 0, 1),
settings: { bambu: [['sparse_infill_density', '80%'], ['extruder', '2']] } };
Deno.test('3MF reader: a slicer part keeps its settings, in its own slicer\'s words', async () => {
const b = (await bambuParts({ subtype: 'modifier_part' })).objects[0].extras[0];
assert(JSON.stringify(b.settings) === '{"bambu":[]}', `bambu ${JSON.stringify(b.settings)}`);
const p = (await prusaVolumes([[0, 0, vtype('ModelPart')], [1, 1, vtype('ParameterModifier')
+ '<metadata type="volume" key="fill_density" value="40%"/><metadata type="volume" key="matrix" value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1"/>'],
[2, 2, vtype('ModelPart')]])).objects[0].extras[0];
// where it sits is not a setting: the export writes that afresh
assert(JSON.stringify(p.settings) === '{"prusa2":[["fill_density","40%"]]}', `prusa2 ${JSON.stringify(p.settings)}`);
const ps3 = (await readThreeMF(await Deno.readFile(new URL('prusaslicer3.3mf', NEG_FIX)))).objects[0].extras[0];
assert(JSON.stringify(ps3.settings) === '{"prusa3":{"wipe_into_infill":false}}', `prusa3 ${JSON.stringify(ps3.settings)}`);
});
Deno.test('3MF export: negative part and modifier go back out beside the part, the supports apart', async () => {
const { r, parts } = await writeExtras([NEG, MOD]);
assert(r.objects.length === 2, `${r.objects.length} objects`);
const [part, supports] = r.objects;
assert(part.tris === 12 && supports.extras.length === 0 && / supports$/.test(supports.name),
`part ${part.tris} tris, supports ${supports.name} with ${supports.extras.length} extras`);
assert(part.extras.map((e) => `${e.kind}:${e.name}`).join() === 'negative:hole,modifier:dense',
`extras ${part.extras.map((e) => `${e.kind}:${e.name}`)}`);
assert(JSON.stringify(part.extras[1].settings.bambu) === JSON.stringify(MOD.settings.bambu),
`modifier settings ${JSON.stringify(part.extras[1].settings)}`);
// in register with the part: both moved by the one bed shift
const shift = bounds(part.positions).lo[0] - 0;
assertClose(bounds(part.extras[0].positions).lo[0] - shift, 0.5, 1e-5, 'negative x');
assertClose(bounds(part.extras[1].positions).lo[0] - shift, -1, 1e-5, 'modifier x');
// both slicers' configs (Bambu/Orca, PrusaSlicer 3), and no PrusaSlicer 2 one
assert(parts.has('Metadata/model_settings.config') && parts.has('Metadata/PrusaSlicer3_project.json')
&& !parts.has('Metadata/Slic3r_PE_model.config'), `parts ${[...parts.keys()]}`);
// PrusaSlicer 3 asserts on a volume without a volume_settings object
const ps3 = JSON.parse(new TextDecoder().decode(parts.get('Metadata/PrusaSlicer3_project.json')));
assert(ps3.objects[0].volumes.every((v) => v.volume_settings && typeof v.volume_settings === 'object'),
`volumes ${JSON.stringify(ps3.objects[0].volumes)}`);
// and neither config is read for a file that claims to be the slicer's own project
const model = new TextDecoder().decode(parts.get('3D/3dmodel.model'));
assert(model.includes('<metadata name="Application">Support Fins</metadata>'), 'Application changed');
});
Deno.test('3MF export: read back and written again, the settings of both slicers survive', async () => {
const both = { ...MOD, settings: { bambu: [['wall_loops', '4']], prusa3: { perimeters: 4 } } };
const { r } = await writeExtras([both]);
const again = await writeExtras(r.objects[0].extras);
const s = again.r.objects[0].extras[0].settings;
assert(JSON.stringify(s) === '{"bambu":[["wall_loops","4"]],"prusa3":{"perimeters":4}}', `settings ${JSON.stringify(s)}`);
});
Deno.test('3MF export: a PrusaSlicer 2 part goes back as triangle ranges of one mesh', async () => {
const neg = { ...NEG, settings: { prusa2: [] } };
const mod = { ...MOD, settings: { prusa2: [['fill_density', '40%']] } };
// a degenerate part triangle is dropped by the writer: the ranges must not shift
const partTris = [...CUBE, [0, 0, 0], [0, 0, 0], [1, 0, 0]];
const paint = []; paint[11] = { name: 'slic3rpe:mmu_segmentation', v: '8' };
const { r, parts } = await writeExtras([neg, mod], { partTris, colors: { paint, rgb: [] } });
assert(parts.has('Metadata/Slic3r_PE_model.config') && !parts.has('Metadata/model_settings.config'),
`parts ${[...parts.keys()]}`);
const [part] = r.objects;
assert(part.tris === 12 && part.extras.map((e) => e.kind).join() === 'negative,modifier',
`part ${part.tris} tris, extras ${part.extras.map((e) => e.kind)}`);
assert(part.extras[0].positions.length === NEG.positions.length && part.extras[1].positions.length === MOD.positions.length,
'a slicer part took the wrong triangles');
assert(JSON.stringify(part.extras[1].settings.prusa2) === '[["fill_density","40%"]]', `settings ${JSON.stringify(part.extras[1].settings)}`);
assert(part.colors?.paint[11]?.v === '8', 'the part lost its paint');
});
Deno.test('3MF export: the locked form writes no slicer parts (they would cut the fins)', async () => {
const { r, parts } = await writeExtras([NEG], { separate: false });
assert(!parts.has('Metadata/model_settings.config') && r.objects.every((o) => o.extras.length === 0),
'a negative part rode into the object holding the fins');
});
// --- unit: more production-extension shapes --------------------------------
Deno.test('3MF production extension: a RELATIVE p:path resolves', async () => {