// What each model asks for, and what you have on hand. // // A Bambu Studio / OrcaSlicer 3MF names the filaments its plates use. Reading // that means shelling out to Python, so it is done once per model in the // background — like thumbnails and fingerprints — and kept in models.filaments. // With that stored, "what can I print right now" is a plain query rather than // hundreds of subprocesses. const db = require('./db'); const converter = require('./converter'); const BATCH = 25; // Models that have never been looked at, paired with their first loose 3MF. // A model whose only 3MF sits inside an archive is left for a later pass rather // than paying an extraction for each one. const PENDING = ` FROM models m LEFT JOIN LATERAL ( SELECT f.file_path FROM files f WHERE f.model_id = m.id AND f.format = '3mf' ORDER BY f.id LIMIT 1 ) f3 ON true WHERE m.deleted_at IS NULL AND m.filaments_status IS NULL`; // "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not. const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); // How many slots of each material a project asks for. A four-colour print lists // four entries, and needs four spools loaded — collapsing them to "it uses PLA" // would call it printable on a single spool. function slotsNeeded(filaments) { const need = new Map(); for (const f of filaments || []) { const type = normalise(f.type); if (type) need.set(type, (need.get(type) || 0) + 1); } return [...need].map(([material, count]) => ({ material, count })); } // Where the shelf falls short: { material, need, have } per material. // Colour is deliberately not matched — a multi-colour print needs one spool per // slot, but printing it in different colours still works. function shortfall(filaments, spools) { const have = new Map(); for (const s of spools || []) { if (s.remaining_g !== null && s.remaining_g !== undefined && Number(s.remaining_g) <= 0) continue; const type = normalise(s.material); if (type) have.set(type, (have.get(type) || 0) + 1); } return slotsNeeded(filaments) .map(({ material, count }) => ({ material, need: count, have: have.get(material) || 0 })) .filter((s) => s.have < s.need); } // True when you have a spool for every slot. A model we know nothing about is // not claimed to be printable. function isPrintable(filaments, spools) { if (!filaments || !filaments.length) return false; return shortfall(filaments, spools).length === 0; } // One pass. Returns whether it did any work, so it fits the background worker // loop in fingerprint.js. async function run() { const { rows } = await db.query( `SELECT m.id, f3.file_path ${PENDING} ORDER BY m.id LIMIT ${BATCH}`); if (!rows.length) return false; for (const row of rows) { if (!row.file_path) { await mark(row.id, 'none', null); continue; } try { const filaments = await converter.readFilaments(row.file_path); // A plain 3MF mesh names no filaments — that is an answer, not a failure. await mark(row.id, filaments.length ? 'done' : 'none', filaments.length ? filaments : null); } catch (err) { console.warn(`Could not read filaments for model ${row.id}:`, err.message); await mark(row.id, 'error', null); } } return true; } async function mark(modelId, status, filaments) { await db.query('UPDATE models SET filaments = $2, filaments_status = $3 WHERE id = $1', [modelId, filaments ? JSON.stringify(filaments) : null, status]); } async function pendingCount() { const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`); return rows[0].n; } // Look at every model again — after adding files, say. async function reread() { await db.query('UPDATE models SET filaments_status = NULL'); } module.exports = { run, pendingCount, reread, slotsNeeded, shortfall, isPrintable, normalise };