// 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>/, ''); 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(/]*>/, (m) => `${m}\n BambuStudio-02.01.01.52`))); 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); });