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>
This commit is contained in:
dlawler489 2026-09-17 12:41:40 +10:00
parent ecdcafc967
commit 8814e79669
4 changed files with 84 additions and 44 deletions

View file

@ -27,6 +27,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images. - **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images.
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change. - **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page. - **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. A four-slot print needs four spools; colours aren't matched, since printing it in different colours still works. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable.
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk). - **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked. - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.

View file

@ -25,22 +25,38 @@ const PENDING = `
// "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not. // "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not.
const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
// The materials a model needs that nothing on hand provides. Colour is ignored // How many slots of each material a project asks for. A four-colour print lists
// on purpose: matching a project's exact colours would make almost nothing // four entries, and needs four spools loaded — collapsing them to "it uses PLA"
// printable, and a different colour still prints. // would call it printable on a single spool.
function missingMaterials(filaments, spools) { function slotsNeeded(filaments) {
const onHand = new Set(spools const need = new Map();
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0) for (const f of filaments || []) {
.map((s) => normalise(s.material))); const type = normalise(f.type);
const needed = [...new Set((filaments || []).map((f) => normalise(f.type)).filter(Boolean))]; if (type) need.set(type, (need.get(type) || 0) + 1);
return needed.filter((type) => !onHand.has(type)); }
return [...need].map(([material, count]) => ({ material, count }));
} }
// True when every filament the model names is on a spool you still have. // Where the shelf falls short: { material, need, have } per material.
// A model we know nothing about is not claimed to be printable. // 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) { function isPrintable(filaments, spools) {
if (!filaments || !filaments.length) return false; if (!filaments || !filaments.length) return false;
return missingMaterials(filaments, spools).length === 0; return shortfall(filaments, spools).length === 0;
} }
// One pass. Returns whether it did any work, so it fits the background worker // One pass. Returns whether it did any work, so it fits the background worker
@ -82,4 +98,4 @@ async function reread() {
await db.query('UPDATE models SET filaments_status = NULL'); await db.query('UPDATE models SET filaments_status = NULL');
} }
module.exports = { run, pendingCount, reread, missingMaterials, isPrintable, normalise }; module.exports = { run, pendingCount, reread, slotsNeeded, shortfall, isPrintable, normalise };

View file

@ -266,21 +266,35 @@ const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial
const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
function printability(m) { function printability(m) {
const needed = [...new Set(((m && m.filaments) || []).map((f) => filamentType(f.type)).filter(Boolean))]; // One entry per slot: a four-colour print needs four spools, not one.
if (!needed.length) return { known: false, needed: [], missing: [], ok: false }; const need = new Map();
const onHand = new Set(spools for (const f of (m && m.filaments) || []) {
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0) const type = filamentType(f.type);
.map((s) => filamentType(s.material))); if (type) need.set(type, (need.get(type) || 0) + 1);
const missing = needed.filter((t) => !onHand.has(t)); }
return { known: true, needed, missing, ok: missing.length === 0 }; if (!need.size) return { known: false, slots: [], short: [], ok: false };
const have = new Map();
for (const s of spools) {
if (s.remaining_g != null && Number(s.remaining_g) <= 0) continue;
const type = filamentType(s.material);
if (type) have.set(type, (have.get(type) || 0) + 1);
}
const slots = [...need].map(([material, count]) => ({ material, count }));
const short = slots
.map(({ material, count }) => ({ material, need: count, have: have.get(material) || 0 }))
.filter((s) => s.have < s.need);
return { known: true, slots, short, ok: short.length === 0 };
} }
const needsSummary = (p) => `Needs ${p.slots.map((s) => (s.count > 1 ? `${s.count} × ${s.material}` : s.material)).join(', ')}`;
function printBadgeHtml(m) { function printBadgeHtml(m) {
const p = printability(m); const p = printability(m);
if (!p.known) return ''; if (!p.known) return '';
return p.ok if (p.ok) return '<span class="print-badge ok" title="A spool for every slot">✔ printable</span>';
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>' const label = p.short.map((s) => `${s.need - s.have} more ${s.material}`).join(', ');
: `<span class="print-badge no" title="Not on the shelf: ${escapeHtml(p.missing.join(', '))}">needs ${escapeHtml(p.missing.join(', '))}</span>`; return `<span class="print-badge no" title="${escapeHtml(needsSummary(p))}">needs ${escapeHtml(label)}</span>`;
} }
const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0); const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`; const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
@ -2063,10 +2077,11 @@ function renderDetailPrintable(model) {
} }
el.innerHTML = p.ok el.innerHTML = p.ok
? `<div><span class="print-badge ok">✔ printable now</span></div> ? `<div><span class="print-badge ok">✔ printable now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.</div>` <div class="lic-note">${escapeHtml(needsSummary(p))} — a spool for every slot.</div>`
: `<div><span class="print-badge no">not right now</span></div> : `<div><span class="print-badge no">not right now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}. <div class="lic-note">${escapeHtml(needsSummary(p))}. You have
Colour isn’t matched — a different colour still prints.</div>`; ${escapeHtml(p.short.map((s) => `${s.have} of ${s.need} ${s.material}`).join(', '))}.
Colours aren’t matched — a multi-colour print needs one spool per slot, but they needn’t be the original colours.</div>`;
} }
function renderDetailLicense(model) { function renderDetailLicense(model) {

View file

@ -4,16 +4,28 @@ const filaments = require('../lib/filaments');
const spools = (...materials) => materials.map((m, i) => ({ id: i + 1, material: m, remaining_g: 500 })); const spools = (...materials) => materials.map((m, i) => ({ id: i + 1, material: m, remaining_g: 500 }));
test('a model is printable when every filament it names is on a spool', () => { test('one spool per slot: a four-colour print needs four spools', () => {
const project = [{ type: 'PLA', colour: '#00AE42' }, { type: 'PETG', colour: '#000000' }]; // The case that caught this out: an AMS bookmark in four PLA colours, and one
assert.equal(filaments.isPrintable(project, spools('PLA', 'PETG', 'ABS')), true); // spool of white on the shelf. Owning "PLA" is not enough.
assert.deepEqual(filaments.missingMaterials(project, spools('PLA', 'PETG')), []); const fourColour = [
{ type: 'PLA', colour: '#000000' }, { type: 'PLA', colour: '#586579' },
{ type: 'PLA', colour: '#8E9089' }, { type: 'PLA', colour: '#FFFFFF' },
];
assert.equal(filaments.isPrintable(fourColour, spools('PLA')), false);
assert.deepEqual(filaments.shortfall(fourColour, spools('PLA')), [{ material: 'PLA', need: 4, have: 1 }]);
assert.equal(filaments.isPrintable(fourColour, spools('PLA', 'PLA', 'PLA', 'PLA')), true);
assert.deepEqual(filaments.shortfall(fourColour, spools('PLA', 'PLA', 'PLA', 'PLA')), []);
}); });
test('what is missing is named, not just refused', () => { test('a single-filament model prints from a single spool', () => {
const project = [{ type: 'PLA' }, { type: 'PETG' }, { type: 'TPU' }]; assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFF' }], spools('PLA')), true);
assert.deepEqual(filaments.missingMaterials(project, spools('PLA')), ['PETG', 'TPU']); });
assert.equal(filaments.isPrintable(project, spools('PLA')), false);
test('slots are counted per material', () => {
const mixed = [{ type: 'PLA' }, { type: 'PLA' }, { type: 'PETG' }];
assert.deepEqual(filaments.slotsNeeded(mixed), [{ material: 'PLA', count: 2 }, { material: 'PETG', count: 1 }]);
assert.deepEqual(filaments.shortfall(mixed, spools('PLA', 'PETG')), [{ material: 'PLA', need: 2, have: 1 }]);
assert.equal(filaments.isPrintable(mixed, spools('PLA', 'PLA', 'PETG')), true);
}); });
test('colour is ignored — a different colour still prints', () => { test('colour is ignored — a different colour still prints', () => {
@ -24,23 +36,19 @@ test('colour is ignored — a different colour still prints', () => {
test('case and spacing don’t matter, but a different material does', () => { test('case and spacing don’t matter, but a different material does', () => {
assert.equal(filaments.normalise(' pla basic '), 'PLA BASIC'); assert.equal(filaments.normalise(' pla basic '), 'PLA BASIC');
// PLA-CF is carbon filled: a plain PLA spool is not a substitute. // PLA-CF is carbon filled: a plain PLA spool is not a substitute.
assert.deepEqual(filaments.missingMaterials([{ type: 'PLA-CF' }], spools('PLA')), ['PLA-CF']); assert.deepEqual(filaments.shortfall([{ type: 'PLA-CF' }], spools('PLA')), [{ material: 'PLA-CF', need: 1, have: 0 }]);
assert.deepEqual(filaments.missingMaterials([{ type: 'pla' }], spools('PLA')), []); assert.deepEqual(filaments.shortfall([{ type: 'pla' }], spools('PLA')), []);
}); });
test('an empty spool doesn’t count, but an unweighed one does', () => { test('an empty spool doesn’t count, but an unweighed one does', () => {
const project = [{ type: 'PLA' }]; const project = [{ type: 'PLA' }];
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: 0 }]), ['PLA']); assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA', remaining_g: 0 }]), [{ material: 'PLA', need: 1, have: 0 }]);
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: null }]), []); assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA', remaining_g: null }]), []);
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA' }]), []); assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA' }]), []);
}); });
test('a model we know nothing about is not claimed to be printable', () => { test('a model we know nothing about is not claimed to be printable', () => {
assert.equal(filaments.isPrintable(null, spools('PLA')), false); assert.equal(filaments.isPrintable(null, spools('PLA')), false);
assert.equal(filaments.isPrintable([], spools('PLA')), false); assert.equal(filaments.isPrintable([], spools('PLA')), false);
}); assert.deepEqual(filaments.slotsNeeded(null), []);
test('the same material twice is asked for once', () => {
const project = [{ type: 'PLA', colour: '#FFF' }, { type: 'PLA', colour: '#000' }];
assert.deepEqual(filaments.missingMaterials(project, []), ['PLA']);
}); });