// 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, ' '); // Bambu writes #RRGGBB and #RRGGBBAA; the alpha channel is not a colour. function rgb(hex) { const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim()); if (!m) return null; const n = parseInt(m[1], 16); return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; } // Redmean distance: closer to how an eye judges two colours than plain RGB. // 0 is identical, black to white is about 765. Null when either is unknown. function colourDistance(a, b) { const x = rgb(a); const y = rgb(b); if (!x || !y) return null; const rm = (x.r + y.r) / 2; const dr = x.r - y.r; const dg = x.g - y.g; const db = x.b - y.b; return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db); } // How close counts as the same colour. 50 keeps #000000 and #161616 apart (~66) // while allowing genuinely near shades to stand in for each other. const COLOUR_TOLERANCE = 50; // Which spool fills which slot. One spool cannot fill two slots, so the best // colour matches are assigned first. A slot whose project gives no colour is // satisfied by any spool of the right material. function assignSpools(filaments, spools) { const slots = (filaments || []).map((f, i) => ({ index: i, material: normalise(f.type), colour: f.colour || null, })); const shelf = (spools || []) .filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0) .map((s, i) => ({ key: s.id == null ? `i${i}` : `s${s.id}`, material: normalise(s.material), colour: s.colour_hex || null, name: s.colour || s.material, })); const pairs = []; for (const slot of slots) { for (const spool of shelf) { if (spool.material !== slot.material) continue; if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); continue; } const d = colourDistance(slot.colour, spool.colour); if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d }); } } pairs.sort((a, b) => a.d - b.d); const filledBy = new Map(); const usedSpools = new Set(); for (const p of pairs) { if (filledBy.has(p.slot) || usedSpools.has(p.spool)) continue; filledBy.set(p.slot, p.spool); usedSpools.add(p.spool); } return slots.map((slot) => { const key = filledBy.get(slot.index); const by = key ? shelf.find((s) => s.key === key) : null; let nearest = null; if (!by && slot.colour) { for (const spool of shelf) { if (spool.material !== slot.material) continue; const d = colourDistance(slot.colour, spool.colour); if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) }; } } return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest }; }); } // The slots you can't fill from the shelf. function shortfall(filaments, spools) { return assignSpools(filaments, spools).filter((s) => !s.filled); } // True when every slot has a spool. 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, assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE, };