- 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>
97 lines
4.9 KiB
JavaScript
97 lines
4.9 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 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);
|
||
});
|