printhive/test/project.test.js
dlawler489 fcb418f119 Show 3MF projects plate by plate in their filament colours
- 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>
2026-09-15 07:49:58 +10:00

81 lines
4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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