- threemf_stream reads Bambu Studio / OrcaSlicer project metadata: plates
(name, objects, preview images), filaments, and each part's type and
extruder. Placements now record which build item and part they belong to.
- convert_3mf.py leaves out negative/modifier/support parts, colours each
part by its filament (a part's extruder 0 falls back to its object's), and
takes --plate N. Geometry is still stored once per (part, colour).
- New threemf_info.py lists plates and filaments without loading geometry
and extracts plate preview images; served via /api/{files,entries}/:id/project.
- /glb?plate=N builds and caches a per-plate preview. Cached GLB names carry
a converter version, so previews made before colours are rebuilt.
- Model detail: plate chips (with the slicer's plate images) under the
viewer — multi-plate projects open on plate 1 — plus "All plates", and a
Filaments section with colour swatches.
- Fingerprints leave out negative/modifier parts, so a project matches the
model it prints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
81 lines
4 KiB
JavaScript
81 lines
4 KiB
JavaScript
// Slicer-project handling of 3MF files: plates, filament colours and part types.
|
||
// plates.3mf is laid out like a Bambu Studio project:
|
||
// plate 1 "Left": object 10 = a 20 mm cube (part 1, filament slot 2)
|
||
// + a negative part (part 2) that must never be drawn
|
||
// plate 2 "Right": object 20 = a 10x10x40 tower (part 3, no slot of its own:
|
||
// uses its object's slot 3), placed at x = 300
|
||
// Filaments: #FF0000 PLA, #00FF00 PETG, #0000FF TPU. Only plate 1 has a preview image.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
const { execFileSync, spawnSync } = require('child_process');
|
||
const { PYTHON } = require('../lib/python');
|
||
|
||
const TOOLS = path.join(__dirname, '..', 'tools');
|
||
const FIXTURE = path.join(__dirname, 'fixtures', 'plates.3mf');
|
||
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
|
||
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-project-'));
|
||
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||
|
||
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
|
||
|
||
// Converts (optionally one plate) and returns { summary, colours } where colours
|
||
// are the base colours of the meshes drawn, one per placed node.
|
||
function convert(plate) {
|
||
const out = path.join(tmp, `plate-${plate || 'all'}.glb`);
|
||
const args = [path.join(TOOLS, 'convert_3mf.py'), FIXTURE, out, ...(plate ? ['--plate', String(plate)] : [])];
|
||
const summary = lastJson(execFileSync(PYTHON, args, { encoding: 'utf8' }));
|
||
const glb = fs.readFileSync(out);
|
||
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
|
||
const colours = gltf.nodes.filter((n) => n.mesh !== undefined).map((n) => {
|
||
const material = gltf.materials[gltf.meshes[n.mesh].primitives[0].material];
|
||
return material.pbrMetallicRoughness.baseColorFactor.slice(0, 3).map((c) => Math.round(c * 255));
|
||
});
|
||
return { summary, colours };
|
||
}
|
||
|
||
test('project info lists plates, their previews and the filaments', { skip }, () => {
|
||
const images = path.join(tmp, 'images');
|
||
const info = lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), FIXTURE, '--images', images], { encoding: 'utf8' }));
|
||
assert.deepEqual(info.plates, [
|
||
{ index: 1, name: 'Left', objectCount: 1, thumbnail: true },
|
||
{ index: 2, name: 'Right', objectCount: 1, thumbnail: false },
|
||
]);
|
||
assert.deepEqual(info.filaments.map((f) => [f.colour, f.type]), [['#FF0000', 'PLA'], ['#00FF00', 'PETG'], ['#0000FF', 'TPU']]);
|
||
assert.ok(fs.existsSync(path.join(images, '1.png')));
|
||
assert.ok(!fs.existsSync(path.join(images, '2.png')));
|
||
});
|
||
|
||
test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => {
|
||
const { summary, colours } = convert(1);
|
||
assert.equal(summary.faces, 12);
|
||
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [20, 20, 20]);
|
||
assert.deepEqual(colours, [[0, 255, 0]]);
|
||
});
|
||
|
||
test('a part without its own filament uses its object’s', { skip }, () => {
|
||
const { summary, colours } = convert(2);
|
||
assert.equal(summary.faces, 12);
|
||
assert.deepEqual(colours, [[0, 0, 255]]);
|
||
});
|
||
|
||
test('all plates convert together', { skip }, () => {
|
||
const { summary, colours } = convert(null);
|
||
assert.equal(summary.faces, 24);
|
||
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [315, 20, 40]);
|
||
assert.deepEqual(colours.sort(), [[0, 0, 255], [0, 255, 0]]);
|
||
});
|
||
|
||
test('asking for a plate that does not exist fails with a clear error', { skip }, () => {
|
||
const run = spawnSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), FIXTURE, path.join(tmp, 'none.glb'), '--plate', '5'], { encoding: 'utf8' });
|
||
assert.equal(run.status, 1);
|
||
assert.match(lastJson(run.stdout).error, /no plate 5/);
|
||
});
|
||
|
||
test('fingerprints leave out negative parts', { skip }, () => {
|
||
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), FIXTURE], { encoding: 'utf8' });
|
||
assert.equal(lastJson(out).fp.faces, 24);
|
||
});
|