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:
parent
ecdcafc967
commit
8814e79669
4 changed files with 84 additions and 44 deletions
|
|
@ -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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,22 +25,38 @@ const PENDING = `
|
|||
// "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not.
|
||||
const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
|
||||
|
||||
// The materials a model needs that nothing on hand provides. Colour is ignored
|
||||
// on purpose: matching a project's exact colours would make almost nothing
|
||||
// printable, and a different colour still prints.
|
||||
function missingMaterials(filaments, spools) {
|
||||
const onHand = new Set(spools
|
||||
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0)
|
||||
.map((s) => normalise(s.material)));
|
||||
const needed = [...new Set((filaments || []).map((f) => normalise(f.type)).filter(Boolean))];
|
||||
return needed.filter((type) => !onHand.has(type));
|
||||
// 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 }));
|
||||
}
|
||||
|
||||
// True when every filament the model names is on a spool you still have.
|
||||
// A model we know nothing about is not claimed to be printable.
|
||||
// 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 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
|
||||
|
|
@ -82,4 +98,4 @@ async function reread() {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -266,21 +266,35 @@ const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial
|
|||
const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
|
||||
|
||||
function printability(m) {
|
||||
const needed = [...new Set(((m && m.filaments) || []).map((f) => filamentType(f.type)).filter(Boolean))];
|
||||
if (!needed.length) return { known: false, needed: [], missing: [], ok: false };
|
||||
const onHand = new Set(spools
|
||||
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0)
|
||||
.map((s) => filamentType(s.material)));
|
||||
const missing = needed.filter((t) => !onHand.has(t));
|
||||
return { known: true, needed, missing, ok: missing.length === 0 };
|
||||
// One entry per slot: a four-colour print needs four spools, not one.
|
||||
const need = new Map();
|
||||
for (const f of (m && m.filaments) || []) {
|
||||
const type = filamentType(f.type);
|
||||
if (type) need.set(type, (need.get(type) || 0) + 1);
|
||||
}
|
||||
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) {
|
||||
const p = printability(m);
|
||||
if (!p.known) return '';
|
||||
return p.ok
|
||||
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>'
|
||||
: `<span class="print-badge no" title="Not on the shelf: ${escapeHtml(p.missing.join(', '))}">needs ${escapeHtml(p.missing.join(', '))}</span>`;
|
||||
if (p.ok) return '<span class="print-badge ok" title="A spool for every slot">✔ printable</span>';
|
||||
const label = p.short.map((s) => `${s.need - s.have} more ${s.material}`).join(', ');
|
||||
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 fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
|
||||
|
|
@ -2063,10 +2077,11 @@ function renderDetailPrintable(model) {
|
|||
}
|
||||
el.innerHTML = p.ok
|
||||
? `<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 class="lic-note">Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}.
|
||||
Colour isn’t matched — a different colour still prints.</div>`;
|
||||
<div class="lic-note">${escapeHtml(needsSummary(p))}. You have
|
||||
${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) {
|
||||
|
|
|
|||
|
|
@ -4,16 +4,28 @@ const filaments = require('../lib/filaments');
|
|||
|
||||
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', () => {
|
||||
const project = [{ type: 'PLA', colour: '#00AE42' }, { type: 'PETG', colour: '#000000' }];
|
||||
assert.equal(filaments.isPrintable(project, spools('PLA', 'PETG', 'ABS')), true);
|
||||
assert.deepEqual(filaments.missingMaterials(project, spools('PLA', 'PETG')), []);
|
||||
test('one spool per slot: a four-colour print needs four spools', () => {
|
||||
// The case that caught this out: an AMS bookmark in four PLA colours, and one
|
||||
// spool of white on the shelf. Owning "PLA" is not enough.
|
||||
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', () => {
|
||||
const project = [{ type: 'PLA' }, { type: 'PETG' }, { type: 'TPU' }];
|
||||
assert.deepEqual(filaments.missingMaterials(project, spools('PLA')), ['PETG', 'TPU']);
|
||||
assert.equal(filaments.isPrintable(project, spools('PLA')), false);
|
||||
test('a single-filament model prints from a single spool', () => {
|
||||
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFF' }], spools('PLA')), true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
@ -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', () => {
|
||||
assert.equal(filaments.normalise(' pla basic '), 'PLA BASIC');
|
||||
// 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.missingMaterials([{ type: 'pla' }], spools('PLA')), []);
|
||||
assert.deepEqual(filaments.shortfall([{ type: 'PLA-CF' }], spools('PLA')), [{ material: 'PLA-CF', need: 1, have: 0 }]);
|
||||
assert.deepEqual(filaments.shortfall([{ type: 'pla' }], spools('PLA')), []);
|
||||
});
|
||||
|
||||
test('an empty spool doesn’t count, but an unweighed one does', () => {
|
||||
const project = [{ type: 'PLA' }];
|
||||
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: 0 }]), ['PLA']);
|
||||
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: null }]), []);
|
||||
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA' }]), []);
|
||||
assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA', remaining_g: 0 }]), [{ material: 'PLA', need: 1, have: 0 }]);
|
||||
assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA', remaining_g: null }]), []);
|
||||
assert.deepEqual(filaments.shortfall(project, [{ material: 'PLA' }]), []);
|
||||
});
|
||||
|
||||
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([], spools('PLA')), false);
|
||||
});
|
||||
|
||||
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']);
|
||||
assert.deepEqual(filaments.slotsNeeded(null), []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue