From fcb418f119411e19d38d0db4b4c9209338b01bc6 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Tue, 15 Sep 2026 07:49:58 +1000 Subject: [PATCH] Show 3MF projects plate by plate in their filament colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/converter.js | 29 ++++++--- lib/store.js | 1 + public/app.js | 66 +++++++++++++++++++- public/index.html | 5 ++ public/styles.css | 11 ++++ server.js | 98 +++++++++++++++++++++++++++-- test/fixtures/plates.3mf | Bin 0 -> 2342 bytes test/project.test.js | 81 ++++++++++++++++++++++++ tools/convert_3mf.py | 60 ++++++++++++------ tools/fingerprint.py | 6 +- tools/threemf_info.py | 44 +++++++++++++ tools/threemf_stream.py | 130 ++++++++++++++++++++++++++++++++++++--- 12 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 test/fixtures/plates.3mf create mode 100644 test/project.test.js create mode 100644 tools/threemf_info.py diff --git a/lib/converter.js b/lib/converter.js index 827db60..68f199e 100644 --- a/lib/converter.js +++ b/lib/converter.js @@ -1,22 +1,37 @@ // Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can -// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't). +// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't), +// and reads their slicer project details (plates, filaments). const { runScript } = require('./python'); -// Returns { glb, volume_cm3, bbox, faces } or throws. -async function convert3mfToGlb(inputPath, outputPath) { - const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 }); +// Parses a sidecar script's last JSON line, turning its error / failure into a throw. +function parseResult({ err, stdout, stderr }, what) { let result = null; try { result = JSON.parse(stdout.trim().split('\n').pop()); } catch { /* handled below */ } if (result && result.error) throw new Error(result.error); if (err) { - if (err.killed) throw new Error('Timed out converting this 3MF for preview.'); + if (err.killed) throw new Error(`Timed out ${what}.`); throw new Error(stderr ? stderr.trim().split('\n').slice(-3).join(' ') : err.message); } - if (!result) throw new Error('Converter returned unparseable output.'); + if (!result) throw new Error(`Unparseable output ${what}.`); return result; } -module.exports = { convert3mfToGlb }; +// Returns { glb, plate, volume_cm3, bbox, faces } or throws. `plate` limits the +// output to one plate of a slicer project. +async function convert3mfToGlb(inputPath, outputPath, { plate = null } = {}) { + const args = [inputPath, outputPath]; + if (plate) args.push('--plate', String(plate)); + return parseResult(await runScript('convert_3mf.py', args, { timeout: 120000 }), 'converting this 3MF for preview'); +} + +// Returns { plates: [{ index, name, objectCount, thumbnail }], filaments: [{ colour, type }] }. +// Plate previews are written into imagesDir as .png / -small.png. +async function readProjectInfo(inputPath, imagesDir) { + return parseResult(await runScript('threemf_info.py', [inputPath, '--images', imagesDir], { timeout: 60000 }), + 'reading this 3MF project'); +} + +module.exports = { convert3mfToGlb, readProjectInfo }; diff --git a/lib/store.js b/lib/store.js index a18aa21..fdfbd31 100644 --- a/lib/store.js +++ b/lib/store.js @@ -126,6 +126,7 @@ async function deleteModel(id) { if (result.rowCount === 0) return null; return { fileIds: rows.map((r) => r.id), + entryIds: entries.rows.map((e) => e.id), filePaths: rows.map((r) => r.file_path), glbPaths: [ ...rows.map((r) => r.glb_path).filter(Boolean), diff --git a/public/app.js b/public/app.js index 9fb77fa..e845f97 100644 --- a/public/app.js +++ b/public/app.js @@ -707,6 +707,13 @@ function setupEventListeners() { renderViewerTabs(itemKey(item)); loadViewer(item); }); + document.getElementById('detailPlates').addEventListener('click', (e) => { + const chip = e.target.closest('.plate-chip'); + const item = chip && detailItems.find((i) => itemKey(i) === activeItemKey); + if (!item) return; + item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate); + loadViewer(item); + }); document.getElementById('detailFileInfo').addEventListener('click', (e) => { const btn = e.target.closest('[data-view-entry]'); if (!btn) return; @@ -945,6 +952,8 @@ function openModelDetail(modelId) { model.files.filter((f) => ARCHIVE_FORMATS.includes(f.format)).forEach((f) => loadArchiveEntries(model.id, f.id)); } + renderProjectPanels(null); + // Viewer tabs: 3D files first, then pictures/PDFs/text (entries inside // archives get a tab when opened from the file list). const { RENDERABLE, DOCUMENT_FORMATS } = window.ModelViewer; @@ -968,10 +977,12 @@ function openModelDetail(modelId) { // An item is something the viewer can show: { kind: 'file' | 'entry', id, format, name, archiveId? }. const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz']; let detailItems = []; +let activeItemKey = null; const archiveEntryCache = new Map(); // archive file id -> entries const itemKey = (item) => `${item.kind}:${item.id}`; function renderViewerTabs(activeKey) { + activeItemKey = activeKey; document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => ` `).join(''); } @@ -986,7 +997,7 @@ function openViewerItem(item) { // documents are served inline, everything else raw. function viewerSourceFor(item) { const base = `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}`; - if (item.format === '3mf') return { url: `${base}/glb`, format: 'glb' }; + if (item.format === '3mf') return { url: `${base}/glb${item.plate ? `?plate=${item.plate}` : ''}`, format: 'glb' }; if (window.ModelViewer.DOCUMENT_FORMATS.includes(item.format)) return { url: `${base}/raw?inline=1`, format: item.format }; return { url: `${base}/raw`, format: item.format }; } @@ -1144,7 +1155,12 @@ async function handleRefreshMetadata() { } } -function loadViewer(item) { +async function loadViewer(item) { + if (item.format === '3mf') { + await loadProjectInfo(item); + if (activeItemKey !== itemKey(item)) return; // another tab was opened meanwhile + } + renderProjectPanels(item); const src = viewerSourceFor(item); const model = findModel(fileDetailModal.dataset.currentModelId); const resolve = resolverFor(model, item.kind === 'entry' ? archiveEntryCache.get(item.archiveId) : null); @@ -1155,6 +1171,52 @@ function loadViewer(item) { { fallbackImage: currentDetailThumb, resolve }); } +// Plates and filaments of a 3MF slicer project, fetched once per viewer item. +// Projects with several plates open on their first plate, like the slicer. +function loadProjectInfo(item) { + if (!item.projectPromise) { + item.projectPromise = fetch(`/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}/project`) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null) + .then((project) => { + item.project = project || { plates: [], filaments: [] }; + if (item.plate === undefined && item.project.plates.length > 1) item.plate = item.project.plates[0].index; + }); + } + return item.projectPromise; +} + +const HEX_COLOUR = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i; + +function renderProjectPanels(item) { + const project = item && item.format === '3mf' ? item.project : null; + const platesEl = document.getElementById('detailPlates'); + if (!project || project.plates.length < 2) { + platesEl.hidden = true; + platesEl.innerHTML = ''; + } else { + platesEl.hidden = false; + platesEl.innerHTML = project.plates.map((p) => ` + `).join('') + + ``; + } + + const filaments = ((project && project.filaments) || []) + .map((f, i) => ({ ...f, slot: i + 1 })) + .filter((f) => HEX_COLOUR.test(f.colour || '')); + document.getElementById('detailFilamentsSection').hidden = filaments.length === 0; + document.getElementById('detailFilaments').innerHTML = filaments.map((f) => ` +
+ + Slot ${f.slot}${f.type ? ` · ${escapeHtml(f.type)}` : ''} + ${f.colour.slice(0, 7).toUpperCase()} +
`).join(''); +} + function updateCardThumbnail(modelId, thumbnailUrl) { const m = findModel(modelId); if (m) m.thumbnailUrl = thumbnailUrl; diff --git a/public/index.html b/public/index.html index e38ceb1..bbf2369 100644 --- a/public/index.html +++ b/public/index.html @@ -409,6 +409,7 @@
+
@@ -442,6 +443,10 @@

Print Settings

+

Files

diff --git a/public/styles.css b/public/styles.css index 12c4c34..f7680f3 100644 --- a/public/styles.css +++ b/public/styles.css @@ -228,6 +228,17 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .link-btn { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; } .link-btn:hover { text-decoration: underline; } +.plate-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.plate-chip { display: flex; align-items: center; gap: 6px; min-height: 30px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 2px 10px 2px 3px; border-radius: var(--radius-sm); font-family: inherit; font-size: 12px; cursor: pointer; } +.plate-chip:not(:has(img)) { padding-left: 10px; } +.plate-chip img { width: 28px; height: 28px; object-fit: cover; border-radius: 4px; background: var(--surface-3); } +.plate-chip:hover { color: var(--text); border-color: var(--border-strong); } +.plate-chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } +.info-section .filament-list { display: flex; flex-direction: column; gap: 6px; } +.info-section .filament { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.filament em { font-style: normal; font-family: var(--mono); font-size: 11px; color: var(--text-dim); } +.swatch { width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; } + .file-tabs { display: flex; flex-wrap: wrap; gap: 6px; } .file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; } .file-tab:hover { color: var(--text); } diff --git a/server.js b/server.js index 55516e7..80ae980 100644 --- a/server.js +++ b/server.js @@ -482,6 +482,7 @@ async function purgeModel(id) { const removed = await store.deleteModel(id); if (!removed) return; removed.fileIds.forEach(archives.removeExtracted); + removeDerivedFiles(removed.fileIds, removed.entryIds); for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) { if (!p) continue; try { fs.unlinkSync(p); } catch (err) { @@ -590,30 +591,52 @@ app.get('/api/entries/:id/raw', async (req, res, next) => { // Serve a 3MF as GLB, converting on first request (cached thereafter). This lets // the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox. +// ?plate=N converts just that plate of a slicer project. const glbConversions = new Map(); // output path -> in-flight Promise // Converts into a temporary file and renames it into place, so a half-written // GLB is never served. -function convertOnce(inputPath, outPath) { +function convertOnce(inputPath, outPath, plate = null) { if (!glbConversions.has(outPath)) { fs.mkdirSync(path.dirname(outPath), { recursive: true }); const tmp = `${outPath}.part`; - glbConversions.set(outPath, converter.convert3mfToGlb(inputPath, tmp) + glbConversions.set(outPath, converter.convert3mfToGlb(inputPath, tmp, { plate }) .then((result) => { fs.renameSync(tmp, outPath); return result; }) + .catch((err) => { + if (/no plate \d+/.test(err.message)) err.status = 404; + throw err; + }) .finally(() => { fs.rmSync(tmp, { force: true }); glbConversions.delete(outPath); })); } return glbConversions.get(outPath); } +// Bump when the converter's output changes (e.g. filament colours), so previews +// cached by an older version get rebuilt instead of served. +const GLB_VERSION = 2; +const glbName = (base, plate) => `${base}${plate ? `-plate${plate}` : ''}-v${GLB_VERSION}.glb`; + +function plateParam(req) { + const n = Number(req.query.plate); + return Number.isInteger(n) && n > 0 ? n : null; +} + app.get('/api/files/:id/glb', async (req, res, next) => { try { const file = await store.getFile(req.params.id); if (!file) return res.status(404).json({ error: 'File not found' }); if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); + const plate = plateParam(req); + if (plate) { + const platePath = path.resolve('./glb', glbName(file.id, plate)); + if (!fs.existsSync(platePath)) await convertOnce(path.resolve(file.file_path), platePath, plate); + return res.sendFile(platePath); + } let glbPath = file.glb_path; - if (!glbPath || !fs.existsSync(glbPath)) { - glbPath = path.join('./glb', `${file.id}.glb`); + if (!glbPath || path.basename(glbPath) !== glbName(file.id) || !fs.existsSync(glbPath)) { + if (glbPath) fs.rmSync(glbPath, { force: true }); // made by an older converter + glbPath = path.join('./glb', glbName(file.id)); const result = await convertOnce(path.resolve(file.file_path), path.resolve(glbPath)); await store.updateFileConversion(file.id, { glb_path: glbPath, volume: result.volume_cm3, bbox: result.bbox }); } @@ -626,12 +649,75 @@ app.get('/api/entries/:id/glb', async (req, res, next) => { const entry = await store.getArchiveEntry(req.params.id); if (!entry) return res.status(404).json({ error: 'Entry not found' }); if (entry.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); - const glbPath = path.resolve('./glb', `entry-${entry.id}.glb`); - if (!fs.existsSync(glbPath)) await convertOnce(await archives.extractEntry(entry.archive, entry), glbPath); + const plate = plateParam(req); + const glbPath = path.resolve('./glb', glbName(`entry-${entry.id}`, plate)); + if (!fs.existsSync(glbPath)) await convertOnce(await archives.extractEntry(entry.archive, entry), glbPath, plate); res.sendFile(glbPath); } catch (e) { next(e); } }); +// Plates and filaments of a 3MF slicer project (Bambu Studio / OrcaSlicer), +// read once per server run. Plate previews land in thumbnails/plates/. +const PLATE_IMAGE_DIR = './thumbnails/plates'; +const projectInfoCache = new Map(); // "-" -> Promise + +async function threeMfPath(kind, id) { + const unsupported = () => Object.assign(new Error('Only 3MF files have plates'), { status: 415 }); + if (kind === 'files') { + const file = await store.getFile(id); + if (!file) return null; + if (file.format !== '3mf') throw unsupported(); + return path.resolve(file.file_path); + } + const entry = await store.getArchiveEntry(id); + if (!entry) return null; + if (entry.format !== '3mf') throw unsupported(); + return archives.extractEntry(entry.archive, entry); +} + +for (const kind of ['files', 'entries']) { + app.get(`/api/${kind}/:id/project`, async (req, res, next) => { + try { + const key = `${kind}-${Number(req.params.id)}`; + if (!projectInfoCache.has(key)) { + const abs = await threeMfPath(kind, req.params.id); + if (!abs) return res.status(404).json({ error: 'Not found' }); + const imagesDir = path.resolve(PLATE_IMAGE_DIR, key); + const job = converter.readProjectInfo(abs, imagesDir).then((info) => ({ + ...info, + plates: info.plates.map((p) => { + const image = [`${p.index}-small.png`, `${p.index}.png`].find((n) => fs.existsSync(path.join(imagesDir, n))); + return { ...p, thumbnailUrl: image ? `/thumbnails/plates/${key}/${image}` : null }; + }), + })); + projectInfoCache.set(key, job); + job.catch(() => projectInfoCache.delete(key)); + } + res.json(await projectInfoCache.get(key)); + } catch (e) { next(e); } + }); +} + +// Removes cached previews (GLBs incl. per-plate ones) and plate images for +// deleted files and archive entries. +function removeDerivedFiles(fileIds, entryIds) { + const owned = new Set([...fileIds.map(String), ...entryIds.map((id) => `entry-${id}`)]); + let names = []; + try { names = fs.readdirSync('./glb'); } catch { /* no previews yet */ } + for (const name of names) { + const m = /^(.+?)(?:-plate\d+)?(?:-v\d+)?\.glb$/.exec(name); + if (m && owned.has(m[1])) fs.rmSync(path.join('./glb', name), { force: true }); + } + for (const id of fileIds) { + fs.rmSync(path.join(PLATE_IMAGE_DIR, `files-${id}`), { recursive: true, force: true }); + projectInfoCache.delete(`files-${id}`); + } + for (const id of entryIds) { + fs.rmSync(path.join(PLATE_IMAGE_DIR, `entries-${id}`), { recursive: true, force: true }); + projectInfoCache.delete(`entries-${id}`); + } +} + // ----------------------------------------------------------------- import // Unified import. Thingiverse downloads files automatically (needs a token); // Printables / MakerWorld / any other URL import page metadata + preview image, diff --git a/test/fixtures/plates.3mf b/test/fixtures/plates.3mf new file mode 100644 index 0000000000000000000000000000000000000000..5bd71b6a175caa7536086e4d9e4a3633e7994ade GIT binary patch literal 2342 zcmZ`)2{e>zAAbj9uNhmGYv0C=4A&ChTvNG>Eg^eli?P%&!;FzOly$mpwq#cbSq5|Y zh^(WVWf0*rcdp7-$tON0%GUR)bFR$kzVCUS=Y7t5{=fhE{r;Q14L6TC008^|-UR8v zW7cBC0B?8*0EmI-*By0%D%PEq8C3Tfttv{6o_)%J~Xr4c$oA{P@6!tDzbpq?DW3--at((9Krsq*k!j!7mxn8 zv=-hyGjNs)jRHiE9Q~j@Do~*?N;vYd-$uGB_dtn(-LG zxEPx-Pjob(g4doIeu(qWUa^oxD7a&A;yR_XutbMsKfl?*^ntrCpel}@^k~nDTv`4` z^E&!+CFG**c(0DVX!MwfW^i69H4+qPiXWfZ4kByvd`FcI%jw#Cu0QX zh4uT`hH{s!e)Q9_l!U*dHOJFD`Oki~h=-k0vwAciN20rTG?s85{#o?Tbh{A7Y6#t| zxU=kd53UQv7FVq zLd6BT)j}D&bgNHNhj^4rJ>k9BenD-uR=o+JphpD&K>DkocIPi(eMv+l{>$m5`&HS+ z%;Aumic)b)>*BGSl=H!N%@-#NysrSa>aik$fNe{ef^+(vGpen)5lzd%C?mx3lPvtH z#}GN^94m(oRf^KR8$CHm*C6ffXvrmlW82)h@p?$LcWHEUd3q$QrG^zD6rX5Dvdmu6r;vvw^A#4CyFK*pk1ZfliU6IXq^{3hq@1-#I+&u)^guHdBXy%01#+L8S|dC4Oev3>xD)#`^9bQ3$6oAMKOU1xGjKb)W=KSu)S?TroZ;|Igx7e?(uqCIGSu2Zb-`cr2L6#iE)imXJs0Ez_wXD z+Tz|HrUU*|cO)1lDh~k2gJH78l6?GpNIu9fS?@)}l1M=z0Yq(Ie29NgKy?PnHC6{6 zx4A80JntdTasycIK{LZSVm-ic3c<8v?&S~i%KbD!YVhm9)@*#(w49x>g+Y0m)s$}l z-j_!?Gb3sAX$=wAD%oN1@QdB$sjMcGpAw7%O_S8$%E>4QrEy~n zrX)Uo6cJ4(`3u)Il*gyLNI(t9M#Z&?7c9HVtCT0jOSMY+1Yj(ggV(KpQw{!g3?CX& zS$C!-vI1orml5bfr~Gv?%q{g|;oM~x7Qgx(1rz(D9aX@aI>w9}Y|H9O6Tj4B_An{Y2TeFO#B^Tl zhen0lg>U~B@LsN1grRQsp4V)$xG5?1ikQlJyHw=(^7d7Z&MErnmI`LKV}eWZTd4_Q z_}#+b6uZ*Jx}RaiupQ7$vKt)gMIJd#JDuoqnoQIKxYXpJYFKV=IA0%6XvX;%ToW~| z?}k%t0ia-!QUv@59s!pVg;$856$18$``sNLIm=z&;MyH%e}|iZ^C4luk|cx#EC??X z-^`^=pZE5D^?^+g;2M!tWOVYVwh|4NfFDKdB%h{GjC++adA)oS{t z8#LE`wlC;>J~o8ScQh3PJwv#}x&L`=g6_XO(O~+0cZhNVaE_j@<^T{4*#ByQ1A}`@PRK2l+b{0_x67bmX@;s literal 0 HcmV?d00001 diff --git a/test/project.test.js b/test/project.test.js new file mode 100644 index 0000000..78f268b --- /dev/null +++ b/test/project.test.js @@ -0,0 +1,81 @@ +// 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); +}); diff --git a/tools/convert_3mf.py b/tools/convert_3mf.py index fb0f90a..7a98bff 100644 --- a/tools/convert_3mf.py +++ b/tools/convert_3mf.py @@ -1,25 +1,43 @@ #!/usr/bin/env python3 """Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB. -Usage: convert_3mf.py -Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout. +Usage: convert_3mf.py [--plate N] +Prints a JSON summary {glb, plate, volume_cm3, bbox, faces} to stdout. +Negative/modifier/support parts are left out, parts are coloured by their +filament when the project says so, and --plate keeps just that plate. """ -import sys +import argparse import json import trimesh +from trimesh.visual import TextureVisuals +from trimesh.visual.material import PBRMaterial import threemf_stream -def main(): - if len(sys.argv) != 3: - print(json.dumps({"error": "usage: convert_3mf.py "})) - return 2 +def mesh_in(vertices, faces, colour): + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + if colour: + rgb = [int(colour[i:i + 2], 16) for i in (1, 3, 5)] + mesh.visual = TextureVisuals(material=PBRMaterial( + baseColorFactor=rgb + [255], metallicFactor=0.1, roughnessFactor=0.7)) + return mesh + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("output") + parser.add_argument("--plate", type=int) + args = parser.parse_args() - in_path, out_path = sys.argv[1], sys.argv[2] try: - meshes, placements = threemf_stream.load(in_path) + meshes, placements = threemf_stream.load(args.input) + project = threemf_stream.read_project(args.input) + placements = threemf_stream.printable(project, placements) + if args.plate is not None: + placements = threemf_stream.on_plate(project, placements, args.plate) except Exception as exc: print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"})) return 1 @@ -27,24 +45,26 @@ def main(): print(json.dumps({"error": "no geometry found in 3MF"})) return 1 - # Each object is stored once; repeated placements become instances of it. + # Each (object, colour) is stored once; repeated placements become instances of it. scene = trimesh.Scene() added = set() - for i, (key, transform) in enumerate(placements): + for i, p in enumerate(placements): + colour = threemf_stream.filament_colour(project, p) + geom = f"{p.key}|{colour or ''}" node = f"part{i}" - if key in added: - scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=transform, geometry=key) + if geom in added: + scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=p.transform, geometry=geom) else: - v, f = meshes[key] - scene.add_geometry(trimesh.Trimesh(vertices=v, faces=f, process=False), - node_name=node, geom_name=key, transform=transform) - added.add(key) - scene.export(out_path, file_type="glb") + v, f = meshes[p.key] + scene.add_geometry(mesh_in(v, f, colour), node_name=node, geom_name=geom, transform=p.transform) + added.add(geom) + scene.export(args.output, file_type="glb") stats = threemf_stream.measure(meshes, placements) ext = stats["extents"] print(json.dumps({ - "glb": out_path, + "glb": args.output, + "plate": args.plate, "bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)}, "faces": stats["faces"], # mm^3 -> cm^3 to match the rest of the app @@ -54,4 +74,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/tools/fingerprint.py b/tools/fingerprint.py index edac29f..acfe8d8 100644 --- a/tools/fingerprint.py +++ b/tools/fingerprint.py @@ -15,7 +15,11 @@ import threemf_stream def measure(path): if path.lower().endswith(".3mf"): - stats = threemf_stream.measure(*threemf_stream.load(path)) + # Leave out negative/modifier/support parts, so a project matches the + # model it prints (e.g. its STL export). + meshes, placements = threemf_stream.load(path) + placements = threemf_stream.printable(threemf_stream.read_project(path), placements) + stats = threemf_stream.measure(meshes, placements) if not stats["faces"]: raise ValueError("no geometry found") return stats diff --git a/tools/threemf_info.py b/tools/threemf_info.py new file mode 100644 index 0000000..583559d --- /dev/null +++ b/tools/threemf_info.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Print a 3MF project's plates and filaments as JSON, without loading geometry. + +Usage: threemf_info.py [--images DIR] +With --images, each plate's preview is written to DIR/.png and DIR/-small.png. +""" +import argparse +import json +import os +import zipfile + +import threemf_stream + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input") + parser.add_argument("--images") + args = parser.parse_args() + + try: + project = threemf_stream.read_project(args.input) + if args.images and project["plates"]: + os.makedirs(args.images, exist_ok=True) + with zipfile.ZipFile(args.input) as z: + for plate in project["plates"]: + for name, suffix in ((plate["thumbnail"], ""), (plate["thumbnail_small"], "-small")): + if name: + with open(os.path.join(args.images, f"{plate['index']}{suffix}.png"), "wb") as out: + out.write(z.read(name)) + except Exception as exc: + print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"})) + return 1 + + print(json.dumps({ + "plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]), + "thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]], + "filaments": project["filaments"], + })) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/threemf_stream.py b/tools/threemf_stream.py index 4a2d11a..a7e3808 100644 --- a/tools/threemf_stream.py +++ b/tools/threemf_stream.py @@ -1,4 +1,4 @@ -"""Streaming 3MF reader shared by convert_3mf.py and fingerprint.py. +"""Streaming 3MF reader shared by convert_3mf.py, fingerprint.py and threemf_info.py. trimesh's 3MF loader re-reads a whole production-extension part file (every mesh in it) for each component that points into that file. Bambu Studio @@ -8,10 +8,14 @@ the geometry multiplies until the process runs out of memory. This reader parses each part file once, element by element, resolves each component to the one object it names, and returns every object's mesh once plus the transforms where it is placed — so parts reused across plates share -their geometry. +their geometry. It also reads Bambu Studio / OrcaSlicer project metadata +(plates, filaments, part types) when the file has it. """ +import json +import re import zipfile from array import array +from collections import namedtuple import numpy as np from lxml import etree @@ -19,6 +23,13 @@ from lxml import etree UNIT_MM = {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0} PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True) MAX_DEPTH = 32 +# Slicer part types that shape or steer printing but aren't printed themselves. +HIDDEN_SUBTYPES = {"negative_part", "modifier_part", "support_blocker", "support_enforcer"} + +# One placed copy of an object's mesh. `item` is the object id of the build item +# it belongs to and `instance` which occurrence of that id it is; `part` is the +# component directly under that object (how slicer metadata names parts). +Placement = namedtuple("Placement", "key transform item instance part") def _local(name): @@ -33,6 +44,13 @@ def _path_attr(el): return None +def _int(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + def _matrix(text): m = np.eye(4) if text: @@ -98,7 +116,7 @@ def load(path): """Returns (meshes, placements). meshes: {key: (vertices, faces)} — each object once, in file units. - placements: [(key, 4x4 transform)] — one per placed instance, mapping to millimetres. + placements: [Placement] — one per placed instance; transforms map to millimetres. """ with zipfile.ZipFile(path) as z: names = set(z.namelist()) @@ -118,7 +136,7 @@ def load(path): scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0]) meshes, placements = {}, [] - def walk(name, oid, transform, depth): + def walk(name, oid, transform, depth, item, instance, part_id): if depth > MAX_DEPTH: raise ValueError("3MF components nest too deeply") obj = part(name)[0].get(oid) @@ -128,23 +146,117 @@ def load(path): if len(f): key = f"{name}#{oid}" meshes.setdefault(key, (v, f)) - placements.append((key, transform)) + placements.append(Placement(key, transform, item, instance, part_id)) for cpath, coid, m in comps: - walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1) + walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1, + item, instance, part_id if depth else coid) + seen = {} for oid, m in items: - walk(root, oid, scale @ m, 0) + instance = seen.get(oid, 0) + seen[oid] = instance + 1 + walk(root, oid, scale @ m, 0, oid, instance, None) return meshes, placements +def read_project(path): + """Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent: + + {plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}], + filaments: [{colour, type}], + objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}} + """ + project = {"plates": [], "filaments": [], "objects": {}} + with zipfile.ZipFile(path) as z: + names = set(z.namelist()) + + if "Metadata/project_settings.config" in names: + try: + settings = json.loads(z.read("Metadata/project_settings.config")) + colours = settings.get("filament_colour") or [] + types = settings.get("filament_type") or [] + project["filaments"] = [ + {"colour": c, "type": types[i] if i < len(types) else None} for i, c in enumerate(colours)] + except (ValueError, UnicodeDecodeError, AttributeError): + pass + + if "Metadata/model_settings.config" in names: + try: + root = etree.fromstring(z.read("Metadata/model_settings.config"), etree.XMLParser(**PARSER)) + except etree.XMLSyntaxError: + return project + + def meta(el): + return {m.get("key"): m.get("value") for m in el.findall("metadata") if m.get("key")} + + for obj in root.findall("object"): + info = meta(obj) + project["objects"][obj.get("id")] = { + "name": info.get("name"), + "extruder": _int(info.get("extruder")), + "parts": { + p.get("id"): {"extruder": _int(meta(p).get("extruder")), "subtype": p.get("subtype") or "normal_part"} + for p in obj.findall("part") + }, + } + for plate in root.findall("plate"): + info = meta(plate) + index = _int(info.get("plater_id")) or len(project["plates"]) + 1 + large = info.get("thumbnail_file") or f"Metadata/plate_{index}.png" + small = f"Metadata/plate_{index}_small.png" + project["plates"].append({ + "index": index, + "name": info.get("plater_name") or "", + "objects": [(meta(i).get("object_id"), _int(meta(i).get("instance_id")) or 0) + for i in plate.findall("model_instance")], + "thumbnail": large if large in names else None, + "thumbnail_small": small if small in names else None, + }) + return project + + +def printable(project, placements): + """Drops negative, modifier and support parts (known from slicer metadata).""" + def visible(p): + obj = project["objects"].get(p.item) + part = obj and obj["parts"].get(p.part) + return not part or part["subtype"] not in HIDDEN_SUBTYPES + return [p for p in placements if visible(p)] + + +def on_plate(project, placements, plate_index): + plate = next((pl for pl in project["plates"] if pl["index"] == plate_index), None) + if plate is None: + raise ValueError(f"no plate {plate_index} in this project") + wanted = set(plate["objects"]) + return [p for p in placements if (p.item, p.instance) in wanted] + + +def filament_colour(project, placement): + """The '#RRGGBB' colour a placed part prints in, or None if unknown. + A part's extruder of 0 (or none) means it uses its object's extruder.""" + obj = project["objects"].get(placement.item) + if not obj: + return None + part = obj["parts"].get(placement.part) or {} + extruder = part.get("extruder") or obj["extruder"] + filaments = project["filaments"] + if extruder and 1 <= extruder <= len(filaments): + colour = filaments[extruder - 1]["colour"] + if isinstance(colour, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", colour): + return colour[:7] + return None + + def measure(meshes, placements): """Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything placed, as if concatenated into one mesh — computed per instance to keep memory flat.""" faces, area, volume = 0, 0.0, 0.0 lo, hi = np.full(3, np.inf), np.full(3, -np.inf) - for key, t in placements: - v, f = meshes[key] + for p in placements: + v, f = meshes[p.key] + t = p.transform w = v @ t[:3, :3].T + t[:3, 3] a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]] cross = np.cross(b - a, c - a)