printhive/lib/filaments.js
dlawler489 1581f767fa Fill "any colour" slots last, not first
Found on the live library: setting slot 2 of a four-colour bookmark to "any
colour" made it take the one Jade White spool, leaving slot 4 — which asks for
#FFFFFF — with nothing, and the verdict stuck at "needs 3 colours" when it
should have improved to 2.

The assignment sorts candidate pairs by colour distance and scored a colourless
slot 0, a perfect match, so it always claimed a spool ahead of a slot that
needed that exact colour. Colourless slots now sort last and take whatever is
left over.

Fixed in both the server and the browser copy, which mirror each other, and
pinned by a test built from the reported case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:27:58 +10:00

173 lines
6.5 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);
}
module.exports = {
run, pendingCount, reread, cleanSlots,
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
};