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:
parent
d4b7e29348
commit
fcb418f119
12 changed files with 486 additions and 45 deletions
|
|
@ -1,22 +1,37 @@
|
||||||
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
|
// 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');
|
const { runScript } = require('./python');
|
||||||
|
|
||||||
// Returns { glb, volume_cm3, bbox, faces } or throws.
|
// Parses a sidecar script's last JSON line, turning its error / failure into a throw.
|
||||||
async function convert3mfToGlb(inputPath, outputPath) {
|
function parseResult({ err, stdout, stderr }, what) {
|
||||||
const { err, stdout, stderr } = await runScript('convert_3mf.py', [inputPath, outputPath], { timeout: 120000 });
|
|
||||||
let result = null;
|
let result = null;
|
||||||
try {
|
try {
|
||||||
result = JSON.parse(stdout.trim().split('\n').pop());
|
result = JSON.parse(stdout.trim().split('\n').pop());
|
||||||
} catch { /* handled below */ }
|
} catch { /* handled below */ }
|
||||||
if (result && result.error) throw new Error(result.error);
|
if (result && result.error) throw new Error(result.error);
|
||||||
if (err) {
|
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);
|
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;
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ async function deleteModel(id) {
|
||||||
if (result.rowCount === 0) return null;
|
if (result.rowCount === 0) return null;
|
||||||
return {
|
return {
|
||||||
fileIds: rows.map((r) => r.id),
|
fileIds: rows.map((r) => r.id),
|
||||||
|
entryIds: entries.rows.map((e) => e.id),
|
||||||
filePaths: rows.map((r) => r.file_path),
|
filePaths: rows.map((r) => r.file_path),
|
||||||
glbPaths: [
|
glbPaths: [
|
||||||
...rows.map((r) => r.glb_path).filter(Boolean),
|
...rows.map((r) => r.glb_path).filter(Boolean),
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,13 @@ function setupEventListeners() {
|
||||||
renderViewerTabs(itemKey(item));
|
renderViewerTabs(itemKey(item));
|
||||||
loadViewer(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) => {
|
document.getElementById('detailFileInfo').addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('[data-view-entry]');
|
const btn = e.target.closest('[data-view-entry]');
|
||||||
if (!btn) return;
|
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));
|
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
|
// Viewer tabs: 3D files first, then pictures/PDFs/text (entries inside
|
||||||
// archives get a tab when opened from the file list).
|
// archives get a tab when opened from the file list).
|
||||||
const { RENDERABLE, DOCUMENT_FORMATS } = window.ModelViewer;
|
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? }.
|
// An item is something the viewer can show: { kind: 'file' | 'entry', id, format, name, archiveId? }.
|
||||||
const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz'];
|
const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz'];
|
||||||
let detailItems = [];
|
let detailItems = [];
|
||||||
|
let activeItemKey = null;
|
||||||
const archiveEntryCache = new Map(); // archive file id -> entries
|
const archiveEntryCache = new Map(); // archive file id -> entries
|
||||||
const itemKey = (item) => `${item.kind}:${item.id}`;
|
const itemKey = (item) => `${item.kind}:${item.id}`;
|
||||||
|
|
||||||
function renderViewerTabs(activeKey) {
|
function renderViewerTabs(activeKey) {
|
||||||
|
activeItemKey = activeKey;
|
||||||
document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => `
|
document.getElementById('detailFileTabs').innerHTML = detailItems.map((item) => `
|
||||||
<button class="file-tab${itemKey(item) === activeKey ? ' active' : ''}" data-key="${itemKey(item)}">${escapeHtml(item.name)}</button>`).join('');
|
<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.
|
// documents are served inline, everything else raw.
|
||||||
function viewerSourceFor(item) {
|
function viewerSourceFor(item) {
|
||||||
const base = `/api/${item.kind === 'entry' ? 'entries' : 'files'}/${item.id}`;
|
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 };
|
if (window.ModelViewer.DOCUMENT_FORMATS.includes(item.format)) return { url: `${base}/raw?inline=1`, format: item.format };
|
||||||
return { url: `${base}/raw`, 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 src = viewerSourceFor(item);
|
||||||
const model = findModel(fileDetailModal.dataset.currentModelId);
|
const model = findModel(fileDetailModal.dataset.currentModelId);
|
||||||
const resolve = resolverFor(model, item.kind === 'entry' ? archiveEntryCache.get(item.archiveId) : null);
|
const resolve = resolverFor(model, item.kind === 'entry' ? archiveEntryCache.get(item.archiveId) : null);
|
||||||
|
|
@ -1155,6 +1171,52 @@ function loadViewer(item) {
|
||||||
{ fallbackImage: currentDetailThumb, resolve });
|
{ 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) {
|
function updateCardThumbnail(modelId, thumbnailUrl) {
|
||||||
const m = findModel(modelId);
|
const m = findModel(modelId);
|
||||||
if (m) m.thumbnailUrl = thumbnailUrl;
|
if (m) m.thumbnailUrl = thumbnailUrl;
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@
|
||||||
<div class="file-detail-content">
|
<div class="file-detail-content">
|
||||||
<div class="file-detail-preview">
|
<div class="file-detail-preview">
|
||||||
<div id="detailViewer" class="model-viewer"></div>
|
<div id="detailViewer" class="model-viewer"></div>
|
||||||
|
<div id="detailPlates" class="plate-chips" hidden></div>
|
||||||
<div id="detailFileTabs" class="file-tabs"></div>
|
<div id="detailFileTabs" class="file-tabs"></div>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -442,6 +443,10 @@
|
||||||
<h3>Print Settings</h3>
|
<h3>Print Settings</h3>
|
||||||
<div id="detailPrintSettings"></div>
|
<div id="detailPrintSettings"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-section" id="detailFilamentsSection" hidden>
|
||||||
|
<h3>Filaments</h3>
|
||||||
|
<div id="detailFilaments" class="filament-list"></div>
|
||||||
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<h3>Files</h3>
|
<h3>Files</h3>
|
||||||
<div id="detailFileInfo"></div>
|
<div id="detailFileInfo"></div>
|
||||||
|
|
|
||||||
|
|
@ -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 { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; }
|
||||||
.link-btn:hover { text-decoration: underline; }
|
.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-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 { 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); }
|
.file-tab:hover { color: var(--text); }
|
||||||
|
|
|
||||||
98
server.js
98
server.js
|
|
@ -482,6 +482,7 @@ async function purgeModel(id) {
|
||||||
const removed = await store.deleteModel(id);
|
const removed = await store.deleteModel(id);
|
||||||
if (!removed) return;
|
if (!removed) return;
|
||||||
removed.fileIds.forEach(archives.removeExtracted);
|
removed.fileIds.forEach(archives.removeExtracted);
|
||||||
|
removeDerivedFiles(removed.fileIds, removed.entryIds);
|
||||||
for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) {
|
for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) {
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
try { fs.unlinkSync(p); } catch (err) {
|
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
|
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
|
||||||
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
|
// 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
|
const glbConversions = new Map(); // output path -> in-flight Promise
|
||||||
|
|
||||||
// Converts into a temporary file and renames it into place, so a half-written
|
// Converts into a temporary file and renames it into place, so a half-written
|
||||||
// GLB is never served.
|
// GLB is never served.
|
||||||
function convertOnce(inputPath, outPath) {
|
function convertOnce(inputPath, outPath, plate = null) {
|
||||||
if (!glbConversions.has(outPath)) {
|
if (!glbConversions.has(outPath)) {
|
||||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
const tmp = `${outPath}.part`;
|
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; })
|
.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); }));
|
.finally(() => { fs.rmSync(tmp, { force: true }); glbConversions.delete(outPath); }));
|
||||||
}
|
}
|
||||||
return glbConversions.get(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) => {
|
app.get('/api/files/:id/glb', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const file = await store.getFile(req.params.id);
|
const file = await store.getFile(req.params.id);
|
||||||
if (!file) return res.status(404).json({ error: 'File not found' });
|
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' });
|
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;
|
let glbPath = file.glb_path;
|
||||||
if (!glbPath || !fs.existsSync(glbPath)) {
|
if (!glbPath || path.basename(glbPath) !== glbName(file.id) || !fs.existsSync(glbPath)) {
|
||||||
glbPath = path.join('./glb', `${file.id}.glb`);
|
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));
|
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 });
|
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);
|
const entry = await store.getArchiveEntry(req.params.id);
|
||||||
if (!entry) return res.status(404).json({ error: 'Entry not found' });
|
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' });
|
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`);
|
const plate = plateParam(req);
|
||||||
if (!fs.existsSync(glbPath)) await convertOnce(await archives.extractEntry(entry.archive, entry), glbPath);
|
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);
|
res.sendFile(glbPath);
|
||||||
} catch (e) { next(e); }
|
} 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
|
// ----------------------------------------------------------------- import
|
||||||
// Unified import. Thingiverse downloads files automatically (needs a token);
|
// Unified import. Thingiverse downloads files automatically (needs a token);
|
||||||
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
// Printables / MakerWorld / any other URL import page metadata + preview image,
|
||||||
|
|
|
||||||
BIN
test/fixtures/plates.3mf
vendored
Normal file
BIN
test/fixtures/plates.3mf
vendored
Normal file
Binary file not shown.
81
test/project.test.js
Normal file
81
test/project.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -1,25 +1,43 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
|
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
|
||||||
|
|
||||||
Usage: convert_3mf.py <input.3mf> <output.glb>
|
Usage: convert_3mf.py <input.3mf> <output.glb> [--plate N]
|
||||||
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
|
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 json
|
||||||
|
|
||||||
import trimesh
|
import trimesh
|
||||||
|
from trimesh.visual import TextureVisuals
|
||||||
|
from trimesh.visual.material import PBRMaterial
|
||||||
|
|
||||||
import threemf_stream
|
import threemf_stream
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def mesh_in(vertices, faces, colour):
|
||||||
if len(sys.argv) != 3:
|
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
||||||
print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
|
if colour:
|
||||||
return 2
|
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:
|
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:
|
except Exception as exc:
|
||||||
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
|
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -27,24 +45,26 @@ def main():
|
||||||
print(json.dumps({"error": "no geometry found in 3MF"}))
|
print(json.dumps({"error": "no geometry found in 3MF"}))
|
||||||
return 1
|
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()
|
scene = trimesh.Scene()
|
||||||
added = set()
|
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}"
|
node = f"part{i}"
|
||||||
if key in added:
|
if geom in added:
|
||||||
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=transform, geometry=key)
|
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=p.transform, geometry=geom)
|
||||||
else:
|
else:
|
||||||
v, f = meshes[key]
|
v, f = meshes[p.key]
|
||||||
scene.add_geometry(trimesh.Trimesh(vertices=v, faces=f, process=False),
|
scene.add_geometry(mesh_in(v, f, colour), node_name=node, geom_name=geom, transform=p.transform)
|
||||||
node_name=node, geom_name=key, transform=transform)
|
added.add(geom)
|
||||||
added.add(key)
|
scene.export(args.output, file_type="glb")
|
||||||
scene.export(out_path, file_type="glb")
|
|
||||||
|
|
||||||
stats = threemf_stream.measure(meshes, placements)
|
stats = threemf_stream.measure(meshes, placements)
|
||||||
ext = stats["extents"]
|
ext = stats["extents"]
|
||||||
print(json.dumps({
|
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)},
|
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
|
||||||
"faces": stats["faces"],
|
"faces": stats["faces"],
|
||||||
# mm^3 -> cm^3 to match the rest of the app
|
# mm^3 -> cm^3 to match the rest of the app
|
||||||
|
|
@ -54,4 +74,4 @@ def main():
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ import threemf_stream
|
||||||
|
|
||||||
def measure(path):
|
def measure(path):
|
||||||
if path.lower().endswith(".3mf"):
|
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"]:
|
if not stats["faces"]:
|
||||||
raise ValueError("no geometry found")
|
raise ValueError("no geometry found")
|
||||||
return stats
|
return stats
|
||||||
|
|
|
||||||
44
tools/threemf_info.py
Normal file
44
tools/threemf_info.py
Normal 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())
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
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
|
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
|
import zipfile
|
||||||
from array import array
|
from array import array
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from lxml import etree
|
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}
|
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)
|
PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True)
|
||||||
MAX_DEPTH = 32
|
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):
|
def _local(name):
|
||||||
|
|
@ -33,6 +44,13 @@ def _path_attr(el):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _int(value):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _matrix(text):
|
def _matrix(text):
|
||||||
m = np.eye(4)
|
m = np.eye(4)
|
||||||
if text:
|
if text:
|
||||||
|
|
@ -98,7 +116,7 @@ def load(path):
|
||||||
"""Returns (meshes, placements).
|
"""Returns (meshes, placements).
|
||||||
|
|
||||||
meshes: {key: (vertices, faces)} — each object once, in file units.
|
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:
|
with zipfile.ZipFile(path) as z:
|
||||||
names = set(z.namelist())
|
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])
|
scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0])
|
||||||
meshes, placements = {}, []
|
meshes, placements = {}, []
|
||||||
|
|
||||||
def walk(name, oid, transform, depth):
|
def walk(name, oid, transform, depth, item, instance, part_id):
|
||||||
if depth > MAX_DEPTH:
|
if depth > MAX_DEPTH:
|
||||||
raise ValueError("3MF components nest too deeply")
|
raise ValueError("3MF components nest too deeply")
|
||||||
obj = part(name)[0].get(oid)
|
obj = part(name)[0].get(oid)
|
||||||
|
|
@ -128,23 +146,117 @@ def load(path):
|
||||||
if len(f):
|
if len(f):
|
||||||
key = f"{name}#{oid}"
|
key = f"{name}#{oid}"
|
||||||
meshes.setdefault(key, (v, f))
|
meshes.setdefault(key, (v, f))
|
||||||
placements.append((key, transform))
|
placements.append(Placement(key, transform, item, instance, part_id))
|
||||||
for cpath, coid, m in comps:
|
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:
|
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
|
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):
|
def measure(meshes, placements):
|
||||||
"""Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything
|
"""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
|
placed, as if concatenated into one mesh — computed per instance to keep
|
||||||
memory flat."""
|
memory flat."""
|
||||||
faces, area, volume = 0, 0.0, 0.0
|
faces, area, volume = 0, 0.0, 0.0
|
||||||
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
|
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
|
||||||
for key, t in placements:
|
for p in placements:
|
||||||
v, f = meshes[key]
|
v, f = meshes[p.key]
|
||||||
|
t = p.transform
|
||||||
w = v @ t[:3, :3].T + t[:3, 3]
|
w = v @ t[:3, :3].T + t[:3, 3]
|
||||||
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
|
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
|
||||||
cross = np.cross(b - a, c - a)
|
cross = np.cross(b - a, c - a)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue