Match slots by colour, not just material

Counting slots fixed one half of the problem; this fixes the other. A slot is
now filled by a spool of the same material whose colour is close to what the
slot asks for, one spool per slot, best matches assigned first. So four PLA
slots in black, blue-grey, grey and white are not satisfied by four spools of
white — only the white slot is.

Closeness is redmean distance with a tolerance of 50, which treats genuinely
near shades as the same colour while keeping #000000 and #161616 (about 66
apart) distinct. Slot colours arrive as #RRGGBB and #RRGGBBAA — the alpha
channel is parsed off rather than making the colour unreadable.

The model page now lists every slot: its colour, which spool fills it, and the
nearest thing on the shelf when nothing does.

Two things this fixes in code I wrote an hour ago: renderDetailPrintable still
called a function the rewrite removed, so opening any model would have thrown;
and the README claimed colours weren't matched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-17 12:47:18 +10:00
parent 8814e79669
commit 7b018aefdb
5 changed files with 234 additions and 92 deletions

View file

@ -27,7 +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.
- **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. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. 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.

View file

@ -25,35 +25,86 @@ 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, ' ');
// 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 }));
// 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 };
}
// 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.
// 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;
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); 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) {
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);
return assignSpools(filaments, spools).filter((s) => !s.filled);
}
// True when you have a spool for every slot. A model we know nothing about is
// not claimed to be printable.
// 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;
@ -98,4 +149,7 @@ async function reread() {
await db.query('UPDATE models SET filaments_status = NULL');
}
module.exports = { run, pendingCount, reread, slotsNeeded, shortfall, isPrintable, normalise };
module.exports = {
run, pendingCount, reread,
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
};

View file

@ -265,36 +265,82 @@ const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial
// rather than "no" — only slicer projects name their filaments.
const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
function printability(m) {
// 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 };
// Mirrors lib/filaments.js. Bambu writes #RRGGBB and #RRGGBBAA; alpha isn't colour.
function rgbOf(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 };
}
const needsSummary = (p) => `Needs ${p.slots.map((s) => (s.count > 1 ? `${s.count} × ${s.material}` : s.material)).join(', ')}`;
// Redmean distance: nearer to how an eye judges colours than plain RGB.
function colourDistance(a, b) {
const x = rgbOf(a);
const y = rgbOf(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);
}
const COLOUR_TOLERANCE = 50; // keeps #000000 and #161616 (~66 apart) distinct
// Which spool fills which slot — best colour matches first, one spool per slot.
function printability(m) {
const slots = ((m && m.filaments) || []).map((f, i) => ({
index: i, material: filamentType(f.type), colour: f.colour || null,
}));
if (!slots.length) return { known: false, slots: [], unfilled: [], ok: false };
const shelf = spools
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0)
.map((s, i) => ({ key: s.id == null ? `i${i}` : `s${s.id}`, material: filamentType(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;
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); 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 used = new Set();
for (const p of pairs) {
if (filledBy.has(p.slot) || used.has(p.spool)) continue;
filledBy.set(p.slot, p.spool);
used.add(p.spool);
}
const resolved = 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 };
});
const unfilled = resolved.filter((s) => !s.filled);
return { known: true, slots: resolved, unfilled, ok: unfilled.length === 0 };
}
function printBadgeHtml(m) {
const p = printability(m);
if (!p.known) return '';
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>`;
if (p.ok) return `<span class="print-badge ok" title="A spool for every slot">✔ printable</span>`;
const n = p.unfilled.length;
return `<span class="print-badge no" title="${n} of ${p.slots.length} slots have no matching spool">needs ${n} colour${n === 1 ? '' : 's'}</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'}`;
@ -2075,13 +2121,21 @@ function renderDetailPrintable(model) {
el.innerHTML = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
el.innerHTML = p.ok
? `<div><span class="print-badge ok">✔ printable now</span></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">${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>`;
const rows = p.slots.map((s, i) => `
<div class="slot-row">
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
<span>Slot ${i + 1} · ${escapeHtml(s.material)}</span>
<span class="form-hint">${escapeHtml(s.colour || 'any colour')}</span>
${s.filled
? `<span class="print-badge ok">${escapeHtml(s.by)}</span>`
: `<span class="print-badge no">none</span>${s.nearest
? `<span class="form-hint">nearest: ${escapeHtml(s.nearest.name)}</span>` : ''}`}
</div>`).join('');
el.innerHTML = `<div>${p.ok
? '<span class="print-badge ok">✔ printable now</span>'
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}</div>
<div class="slot-list">${rows}</div>
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>`;
}
function renderDetailLicense(model) {
@ -2264,7 +2318,7 @@ function filamentPanelHtml() {
<form id="spoolForm" class="watch-form">
<input type="text" id="spoolMaterial" placeholder="Material (PLA, PETG…)" aria-label="Material" required>
<input type="text" id="spoolColour" placeholder="Colour name" aria-label="Colour">
<input type="color" id="spoolHex" value="#22c55e" aria-label="Colour swatch" title="Colour">
<input type="color" id="spoolHex" value="#ffffff" aria-label="Colour swatch" title="The spool's colour — matched against what each slot asks for">
<input type="text" id="spoolBrand" placeholder="Brand" aria-label="Brand">
<input type="number" id="spoolRemaining" placeholder="Grams left" aria-label="Grams left" min="0" step="10">
<button type="submit" class="btn btn-primary" id="spoolSubmit">Add spool</button>

View file

@ -464,6 +464,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; }
.slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
.slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }

View file

@ -2,53 +2,85 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const filaments = require('../lib/filaments');
const spools = (...materials) => materials.map((m, i) => ({ id: i + 1, material: m, remaining_g: 500 }));
const spool = (material, hex, name) => ({ id: hex, material, colour_hex: hex, colour: name || hex, remaining_g: 500 });
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')), []);
// The model that started this: four PLA slots, one spool of Jade White.
const pooh = [
{ type: 'PLA', colour: '#000000' },
{ type: 'PLA', colour: '#586579' },
{ type: 'PLA', colour: '#8E9089' },
{ type: 'PLA', colour: '#FFFFFF' },
];
test('a four-colour print needs those colours, not four spools of anything', () => {
const shelf = [spool('PLA', '#ffffff', 'Jade White')];
const slots = filaments.assignSpools(pooh, shelf);
assert.deepEqual(slots.map((s) => s.filled), [false, false, false, true]);
assert.equal(slots[3].by, 'Jade White');
assert.equal(filaments.isPrintable(pooh, shelf), false);
assert.equal(filaments.shortfall(pooh, shelf).length, 3);
// Four white spools still can't print it: the other three colours are missing.
const fourWhite = ['a', 'b', 'c', 'd'].map((k) => ({ ...spool('PLA', '#ffffff', 'Jade White'), id: k }));
assert.equal(filaments.isPrintable(pooh, fourWhite), false);
});
test('a single-filament model prints from a single spool', () => {
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFF' }], spools('PLA')), true);
test('with every colour on the shelf it prints', () => {
const shelf = [spool('PLA', '#000000', 'Black'), spool('PLA', '#586579', 'Blue Grey'),
spool('PLA', '#8E9089', 'Grey'), spool('PLA', '#ffffff', 'Jade White')];
assert.equal(filaments.isPrintable(pooh, shelf), true);
assert.deepEqual(filaments.assignSpools(pooh, shelf).map((s) => s.by),
['Black', 'Blue Grey', 'Grey', 'Jade White']);
});
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('one spool cannot fill two slots of the same colour', () => {
const twoBlack = [{ type: 'PLA', colour: '#000000' }, { type: 'PLA', colour: '#000000' }];
assert.equal(filaments.isPrintable(twoBlack, [spool('PLA', '#000000', 'Black')]), false);
assert.equal(filaments.isPrintable(twoBlack,
[spool('PLA', '#000000', 'Black'), { ...spool('PLA', '#000000', 'Black 2'), id: 'b2' }]), true);
});
test('colour is ignored — a different colour still prints', () => {
const project = [{ type: 'PLA', colour: '#FF0000' }];
assert.equal(filaments.isPrintable(project, [{ material: 'PLA', colour_hex: '#0000FF', remaining_g: 200 }]), true);
test('8-digit hex with alpha parses as its colour', () => {
assert.deepEqual(filaments.rgb('#FFFFFFFF'), { r: 255, g: 255, b: 255 });
assert.deepEqual(filaments.rgb('#586579'), { r: 88, g: 101, b: 121 });
assert.equal(filaments.rgb('not a colour'), null);
// A slot written with alpha still matches a plain spool of that colour.
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFFFFFFF' }],
[spool('PLA', '#ffffff', 'Jade White')]), true);
});
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.shortfall([{ type: 'PLA-CF' }], spools('PLA')), [{ material: 'PLA-CF', need: 1, have: 0 }]);
assert.deepEqual(filaments.shortfall([{ type: 'pla' }], spools('PLA')), []);
test('near shades count, but near-black is not black', () => {
const nearWhite = [{ type: 'PLA', colour: '#FDFDFD' }];
assert.equal(filaments.isPrintable(nearWhite, [spool('PLA', '#ffffff', 'Jade White')]), true);
// #161616 sits about 66 away from black, beyond the tolerance of 50.
assert.ok(filaments.colourDistance('#000000', '#161616') > filaments.COLOUR_TOLERANCE);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#161616' }],
[spool('PLA', '#000000', 'Black')]), false);
});
test('the nearest spool is named when a slot can’t be filled', () => {
const [slot] = filaments.assignSpools([{ type: 'PLA', colour: '#000000' }],
[spool('PLA', '#ffffff', 'Jade White')]);
assert.equal(slot.filled, false);
assert.equal(slot.nearest.name, 'Jade White');
assert.ok(slot.nearest.distance > 0);
});
test('material still matters, and a slot with no colour takes any spool of it', () => {
assert.equal(filaments.isPrintable([{ type: 'PETG', colour: '#ffffff' }],
[spool('PLA', '#ffffff', 'Jade White')]), false);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }],
[spool('PLA', '#123456', 'Anything')]), true);
});
test('an empty spool doesn’t count, but an unweighed one does', () => {
const project = [{ type: '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' }]), []);
const one = [{ type: 'PLA', colour: '#ffffff' }];
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: 0 }]), false);
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: null }]), true);
});
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);
assert.deepEqual(filaments.slotsNeeded(null), []);
assert.equal(filaments.isPrintable(null, [spool('PLA', '#ffffff')]), false);
assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false);
assert.deepEqual(filaments.assignSpools(null, []), []);
});