From 7b018aefdb5fe88ec678627e399c6047ac73d443 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 12:47:18 +1000 Subject: [PATCH] Match slots by colour, not just material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- lib/filaments.js | 104 +++++++++++++++++++++++++++--------- public/app.js | 118 ++++++++++++++++++++++++++++++----------- public/styles.css | 2 + test/filaments.test.js | 100 ++++++++++++++++++++++------------ 5 files changed, 234 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 4b58f42..9d822e9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/filaments.js b/lib/filaments.js index 9c20529..7e8f280 100644 --- a/lib/filaments.js +++ b/lib/filaments.js @@ -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, +}; diff --git a/public/app.js b/public/app.js index e7794f8..5ebb07a 100644 --- a/public/app.js +++ b/public/app.js @@ -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 '✔ printable'; - const label = p.short.map((s) => `${s.need - s.have} more ${s.material}`).join(', '); - return `needs ${escapeHtml(label)}`; + if (p.ok) return `✔ printable`; + const n = p.unfilled.length; + return `needs ${n} colour${n === 1 ? '' : 's'}`; } 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 = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
'; return; } - el.innerHTML = p.ok - ? `
✔ printable now
-
${escapeHtml(needsSummary(p))} — a spool for every slot.
` - : `
not right now
-
${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.
`; + const rows = p.slots.map((s, i) => ` +
+ + Slot ${i + 1} · ${escapeHtml(s.material)} + ${escapeHtml(s.colour || 'any colour')} + ${s.filled + ? `${escapeHtml(s.by)}` + : `none${s.nearest + ? `nearest: ${escapeHtml(s.nearest.name)}` : ''}`} +
`).join(''); + el.innerHTML = `
${p.ok + ? '✔ printable now' + : `needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}`}
+
${rows}
+
A spool fills one slot, and its colour has to be close to what the slot asks for.
`; } function renderDetailLicense(model) { @@ -2264,7 +2318,7 @@ function filamentPanelHtml() {
- + diff --git a/public/styles.css b/public/styles.css index 3904f64..0a57e02 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/test/filaments.test.js b/test/filaments.test.js index 3140f66..fa20e00 100644 --- a/test/filaments.test.js +++ b/test/filaments.test.js @@ -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, []), []); });