printhive/test/project.test.js
dlawler489 f3a69c82ec Open models in your slicer and drag files out
- Settings → Slicer: pick Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, a
  custom open-link template, or plain download (remembered per browser)
- Bambu Studio / OrcaSlicer launch via their URL schemes with a 10-minute
  signed download link (/dl/<token>/<name>) that needs no session cookie
- PrusaSlicer and Cura fall back to a download (they can't open our links)
- 3MF projects report which app saved them; opening one in a different
  slicer asks first
- "slicer" action on every file row and archive entry
- Drag model cards (file, or zip of all files) and file/entry rows out to
  the desktop via Chromium's DownloadURL

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 09:15:37 +10:00

97 lines
4.9 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 AdmZip = require('adm-zip');
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('project info names the app that saved the 3MF', { skip }, () => {
const info = (file) => lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), file], { encoding: 'utf8' }));
const zip = new AdmZip(FIXTURE);
const model = zip.readAsText('3D/3dmodel.model').replace(/\s*<metadata name="Application">[^<]*<\/metadata>/, '');
const plain = path.join(tmp, 'plain.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model));
zip.writeZip(plain);
assert.equal(info(plain).application, null);
const saved = path.join(tmp, 'saved.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model.replace(/<model\b[^>]*>/, (m) => `${m}\n <metadata name="Application">BambuStudio-02.01.01.52</metadata>`)));
zip.writeZip(saved);
assert.equal(info(saved).application, 'BambuStudio-02.01.01.52');
});
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);
});