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>
This commit is contained in:
dlawler489 2026-09-15 07:49:58 +10:00
parent d4b7e29348
commit fcb418f119
12 changed files with 486 additions and 45 deletions

View file

@ -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 <n>.png / <n>-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 };

View file

@ -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),

View file

@ -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) => `
<button class="file-tab${itemKey(item) === activeKey ? ' active' : ''}" data-key="${itemKey(item)}">${escapeHtml(item.name)}</button>`).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) => `
<button type="button" class="plate-chip${item.plate === p.index ? ' active' : ''}" data-plate="${p.index}"
title="${escapeHtml(p.name || `Plate ${p.index}`)} · ${p.objectCount} object${p.objectCount === 1 ? '' : 's'}">
${p.thumbnailUrl ? `<img src="${escapeHtml(p.thumbnailUrl)}" alt="">` : ''}
<span>${p.index}${p.name ? ` · ${escapeHtml(p.name)}` : ''}</span>
</button>`).join('')
+ `<button type="button" class="plate-chip${item.plate ? '' : ' active'}" data-plate="all"><span>All plates</span></button>`;
}
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) => `
<div class="filament">
<span class="swatch" style="background:${f.colour.slice(0, 7)}"></span>
<span>Slot ${f.slot}${f.type ? ` · ${escapeHtml(f.type)}` : ''}</span>
<em>${f.colour.slice(0, 7).toUpperCase()}</em>
</div>`).join('');
}
function updateCardThumbnail(modelId, thumbnailUrl) {
const m = findModel(modelId);
if (m) m.thumbnailUrl = thumbnailUrl;

View file

@ -409,6 +409,7 @@
<div class="file-detail-content">
<div class="file-detail-preview">
<div id="detailViewer" class="model-viewer"></div>
<div id="detailPlates" class="plate-chips" hidden></div>
<div id="detailFileTabs" class="file-tabs"></div>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
</div>
@ -442,6 +443,10 @@
<h3>Print Settings</h3>
<div id="detailPrintSettings"></div>
</div>
<div class="info-section" id="detailFilamentsSection" hidden>
<h3>Filaments</h3>
<div id="detailFilaments" class="filament-list"></div>
</div>
<div class="info-section">
<h3>Files</h3>
<div id="detailFileInfo"></div>

View file

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

View file

@ -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(); // "<kind>-<id>" -> 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,

BIN
test/fixtures/plates.3mf vendored Normal file

Binary file not shown.

81
test/project.test.js Normal file
View file

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

View file

@ -1,25 +1,43 @@
#!/usr/bin/env python3
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
Usage: convert_3mf.py <input.3mf> <output.glb>
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
Usage: convert_3mf.py <input.3mf> <output.glb> [--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 <in.3mf> <out.glb>"}))
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())

View file

@ -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

44
tools/threemf_info.py Normal file
View file

@ -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 <file.3mf> [--images DIR]
With --images, each plate's preview is written to DIR/<n>.png and DIR/<n>-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())

View file

@ -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)