printhive/lib/filaments.js
dlawler489 8814e79669 Count slots, not materials: four colours need four spools
Reported from the library: a four-colour AMS bookmark showed as "printable now"
against a single spool of Jade White. The matching collapsed the project's four
slots into the set of materials it used — {PLA} — and then asked only whether a
PLA spool existed. A sixteen-colour model would have passed the same way.

Ignoring colour is right: printing a bookmark in red instead of white still
prints. Ignoring slot count is not: four slots need four spools loaded. Matching
now counts slots per material and reports the shortfall — "needs 3 more PLA",
and on the model page "Needs 4 × PLA. You have 1 of 4 PLA."

The tests carried the same mistake, asserting that one spool satisfied a
multi-slot project, so they are rewritten around the reported case.

Also documents the feature in the README, which had no entry for it at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:41:40 +10:00

101 lines
3.8 KiB
JavaScript

// 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 };