Every Bambu spool reports an RFID identity — a spool id and a tag id, both unique — so a printer's report can now keep the Filament page in step: a new tagged spool is created, named and coded from the bundled filament list; one already known is moved to its current slot; one you added by hand before the printer was connected is adopted rather than duplicated; and one the printer no longer reports is marked as taken out. It runs on "Save & test connection" and every ten minutes in the background, still only reading from the printer. Untagged spools are listed, not stored: without a tag, one spool of a colour is indistinguishable from the next, so guessing would mean duplicates. The planning is a pure function, tested against the cases that would cause duplicates. The database side was exercised separately against a real Postgres: three successive reports created, moved and unloaded spools as intended, and a deliberate duplicate was refused by the partial unique index. The amount left isn't taken from the printer yet: an AMS lite only reports full or empty, so it's left blank until printers can say which kind of AMS they have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
7.2 KiB
JavaScript
189 lines
7.2 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, ' ');
|
|
|
|
// 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;
|
|
// A slot that takes any colour is filled last, from whatever is left:
|
|
// scoring it 0 would let it claim a spool an exact-match slot needs.
|
|
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: Number.MAX_SAFE_INTEGER }); 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. Slots you set by hand
|
|
// are left alone: they aren't the reader's to overwrite.
|
|
async function reread() {
|
|
await db.query("UPDATE models SET filaments_status = NULL WHERE filaments_status IS DISTINCT FROM 'edited'");
|
|
}
|
|
|
|
// Slots as sent from the page. Keeps the material, accepts only a proper hex as
|
|
// a colour, and drops anything with no material — a slot that asks for nothing
|
|
// would quietly count as satisfied.
|
|
function cleanSlots(list) {
|
|
if (!Array.isArray(list)) return null;
|
|
return list
|
|
.map((f) => ({
|
|
type: String((f && f.type) || '').trim(),
|
|
colour: /^#[0-9a-f]{6}$/i.test(String((f && f.colour) || '').trim())
|
|
? String(f.colour).trim().toUpperCase()
|
|
: null,
|
|
}))
|
|
.filter((f) => f.type);
|
|
}
|
|
|
|
// Bambu's own name and code for a spool, from the bundled filament list: the
|
|
// same material and product line, and the closest colour within a tight
|
|
// threshold — this identifies a colour rather than substituting for one.
|
|
function paletteMatch(palette, material, type, hex) {
|
|
let best = null;
|
|
for (const f of palette || []) {
|
|
if (normalise(f.material) !== normalise(material)) continue;
|
|
if (type && normalise(f.type) !== normalise(type)) continue;
|
|
for (const c of f.colours || []) {
|
|
const d = colourDistance(hex, c);
|
|
if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, code: f.code || null, type: f.type };
|
|
}
|
|
}
|
|
return best && { name: best.name, code: best.code, type: best.type };
|
|
}
|
|
|
|
module.exports = {
|
|
run, pendingCount, reread, cleanSlots, paletteMatch,
|
|
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
|
|
};
|