From 8814e79669dd594ad66e9e0547155fb49b9b1da1 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 12:41:40 +1000 Subject: [PATCH 1/3] Count slots, not materials: four colours need four spools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 1 + lib/filaments.js | 42 +++++++++++++++++++++++++++------------- public/app.js | 41 ++++++++++++++++++++++++++------------- test/filaments.test.js | 44 +++++++++++++++++++++++++----------------- 4 files changed, 84 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 1c9fa5f..4b58f42 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/filaments.js b/lib/filaments.js index f88aae5..9c20529 100644 --- a/lib/filaments.js +++ b/lib/filaments.js @@ -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 }; diff --git a/public/app.js b/public/app.js index 298efad..e7794f8 100644 --- a/public/app.js +++ b/public/app.js @@ -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 - ? '✔ printable' - : `needs ${escapeHtml(p.missing.join(', '))}`; + if (p.ok) return '✔ printable'; + const label = p.short.map((s) => `${s.need - s.have} more ${s.material}`).join(', '); + return `needs ${escapeHtml(label)}`; } 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 ? `
✔ printable now
-
Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.
` +
${escapeHtml(needsSummary(p))} — a spool for every slot.
` : `
not right now
-
Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}. - Colour isn’t matched — a different colour still prints.
`; +
${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.
`; } function renderDetailLicense(model) { diff --git a/test/filaments.test.js b/test/filaments.test.js index 052dacb..3140f66 100644 --- a/test/filaments.test.js +++ b/test/filaments.test.js @@ -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), []); }); 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 2/3] 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, []), []); }); From 2635a1e9f0b6bd51981b0b4777d2e6be45863853 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 13:01:09 +1000 Subject: [PATCH 3/3] Pick spools from the maker's filament list, and name slot colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a spool no longer means typing a hex code: choose "PLA Basic — Jade White (10100)" and the material, colour name, swatch and filament code fill themselves. Slot colours on the model page are named where they match, so a slot reads "#586579 · Basic Blue Gray" instead of bare hex. The filament code is stored per spool. It isn't decoration — the AMS reports filament IDs, so this is what a later read-only printer connection will match a loaded tray against. db/bambu-filaments.json holds Bambu Lab's 325 filaments (material, type, colour name, code, colours), included by kind permission of 3dfilamentprofiles.com, whose terms otherwise reserve the data. The permission and source are recorded in the file itself as well as the README, so nobody reuses it by accident. Naming uses a much tighter threshold than matching (12 against 50): identifying a colour and substituting for one are different questions. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- db/bambu-filaments.json | 334 ++++++++++++++++++++++++++++++++++++++++ db/schema.sql | 2 + lib/store.js | 9 +- public/app.js | 44 +++++- server.js | 8 + 6 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 db/bambu-filaments.json diff --git a/README.md b/README.md index 9d822e9..5414270 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. 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. +- **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. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere. - **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/db/bambu-filaments.json b/db/bambu-filaments.json new file mode 100644 index 0000000..0c27611 --- /dev/null +++ b/db/bambu-filaments.json @@ -0,0 +1,334 @@ +{ + "source": "https://3dfilamentprofiles.com/filaments/bambu-lab", + "permission": "Copied with the site owner's permission, granted on request 2026-09-17. Their terms otherwise prohibit copying, scraping and redistribution — ask them before reusing this file anywhere else.", + "collected": "2026-09-17", + "count": 325, + "note": "Bambu Lab filaments: material, product type, colour name, Bambu filament code (what the AMS reports), and the colour(s) shown. Multi-colour filaments list each colour; a few entries have no published code.", + "filaments": [ +{"material":"ABS","type":"Basic","name":"Azure","code":"40601","colours":["#489FDF"]}, +{"material":"ABS","type":"Basic","name":"Bambu Green","code":"40500","colours":["#00AE42"]}, +{"material":"ABS","type":"Basic","name":"Beige","code":"40401","colours":["#DFD1A7"]}, +{"material":"ABS","type":"Basic","name":"Black","code":"40101","colours":["#000000"]}, +{"material":"ABS","type":"Basic","name":"Blue","code":"40600","colours":["#0A2CA5"]}, +{"material":"ABS","type":"Basic","name":"Grey","code":"10103","colours":["#8E9089"]}, +{"material":"ABS","type":"Basic","name":"Lavender","code":"40701","colours":["#7248BD"]}, +{"material":"ABS","type":"Basic","name":"Mint","code":"40501","colours":["#ADF4DC"]}, +{"material":"ABS","type":"Basic","name":"Navy Blue","code":"40602","colours":["#0C2340"]}, +{"material":"ABS","type":"Basic","name":"Olive","code":"40502","colours":["#789D4A"]}, +{"material":"ABS","type":"Basic","name":"Orange","code":"40300","colours":["#FF6A13"]}, +{"material":"ABS","type":"Basic","name":"Purple","code":"40700","colours":["#AF1685"]}, +{"material":"ABS","type":"Basic","name":"Red","code":"40200","colours":["#D32941"]}, +{"material":"ABS","type":"Basic","name":"Silver","code":"40102","colours":["#87909A"]}, +{"material":"ABS","type":"Basic","name":"Tangerine Yellow","code":"40402","colours":["#FFC72C"]}, +{"material":"ABS","type":"Basic","name":"White","code":"40100","colours":["#FFFFFF"]}, +{"material":"ABS","type":"Basic","name":"Yellow","code":"40400","colours":["#FFFF20"]}, +{"material":"ABS","type":"GF","name":"Black","code":"41101","colours":["#000000"]}, +{"material":"ABS","type":"GF","name":"Blue","code":"41600","colours":["#0C3B95"]}, +{"material":"ABS","type":"GF","name":"Gray","code":"41102","colours":["#C6C6C6"]}, +{"material":"ABS","type":"GF","name":"Green","code":"41500","colours":["#61BF36"]}, +{"material":"ABS","type":"GF","name":"Orange","code":"41300","colours":["#F48438"]}, +{"material":"ABS","type":"GF","name":"Red","code":"41200","colours":["#E83100"]}, +{"material":"ABS","type":"GF","name":"White","code":"41100","colours":["#FFFFFF"]}, +{"material":"ABS","type":"GF","name":"Yellow","code":"41400","colours":["#FFE133"]}, +{"material":"ASA","type":"Aero","name":"White","code":"46100","colours":["#EFF2DE"]}, +{"material":"ASA","type":"Basic","name":"Black","code":"45101","colours":["#000000"]}, +{"material":"ASA","type":"Basic","name":"Blue","code":"45600","colours":["#2140B4"]}, +{"material":"ASA","type":"Basic","name":"Gray","code":"45102","colours":["#8A949E"]}, +{"material":"ASA","type":"Basic","name":"Green","code":"45500","colours":["#00A6A0"]}, +{"material":"ASA","type":"Basic","name":"Red","code":"45200","colours":["#E02928"]}, +{"material":"ASA","type":"Basic","name":"White","code":"45100","colours":["#FFFAF2"]}, +{"material":"ASA","type":"CF","name":"Black","code":"46101","colours":["#000000"]}, +{"material":"HIPS","type":"Support","name":"Support for ABS White","code":"66100","colours":["#FFFFFF"]}, +{"material":"PA6","type":"CF","name":"Black","code":"72100","colours":["#000000"]}, +{"material":"PA6","type":"GF","name":"Black","code":"72104","colours":["#000000"]}, +{"material":"PA6","type":"GF","name":"Blue","code":"72600","colours":["#75AED8"]}, +{"material":"PA6","type":"GF","name":"Brown","code":"72800","colours":["#5B492F"]}, +{"material":"PA6","type":"GF","name":"Gray","code":"72103","colours":["#353533"]}, +{"material":"PA6","type":"GF","name":"Lime","code":"72500","colours":["#C5ED48"]}, +{"material":"PA6","type":"GF","name":"Orange","code":"72200","colours":["#FF4800"]}, +{"material":"PA6","type":"GF","name":"White","code":"72102","colours":["#EAEAE4"]}, +{"material":"PA6","type":"GF","name":"Yellow","code":"72400","colours":["#FFCE00"]}, +{"material":"PAHT","type":"CF","name":"Black","code":"70100","colours":["#000000"]}, +{"material":"PAHT","type":"Support","name":"Support for PA/PET","code":"65500","colours":["#C0DF16"]}, +{"material":"PC","type":"Basic","name":"Black","code":"60101","colours":["#000000"]}, +{"material":"PC","type":"Basic","name":"Clear Black","code":"60102","colours":["#5A5161"]}, +{"material":"PC","type":"Basic","name":"Transparent","code":"60103","colours":["#BFC3C7"]}, +{"material":"PC","type":"Basic","name":"White","code":"60100","colours":["#FFFFFF"]}, +{"material":"PC","type":"FR","name":"Black","code":"63100","colours":["#000000"]}, +{"material":"PC","type":"FR","name":"Gray","code":"63102","colours":["#A8A8AA"]}, +{"material":"PC","type":"FR","name":"White","code":"63101","colours":["#FFFFFF"]}, +{"material":"PET","type":"CF","name":"Black","code":"71100","colours":["#000000"]}, +{"material":"PETG","type":"Basic","name":"Black","code":"30101","colours":["#000000"]}, +{"material":"PETG","type":"Basic","name":"Black","code":"30105","colours":["#000000"]}, +{"material":"PETG","type":"Basic","name":"Blue","code":"30600","colours":["#001489"]}, +{"material":"PETG","type":"Basic","name":"Blue Gray","code":"30601","colours":["#2E4F66"]}, +{"material":"PETG","type":"Basic","name":"Dark Beige","code":"30403","colours":["#DBC8B6"]}, +{"material":"PETG","type":"Basic","name":"Dark Brown","code":"30800","colours":["#4F2C1D"]}, +{"material":"PETG","type":"Basic","name":"Gold","code":"30401","colours":["#F6D86A"]}, +{"material":"PETG","type":"Basic","name":"Gray","code":"30102","colours":["#ADB1B2"]}, +{"material":"PETG","type":"Basic","name":"Gray","code":"30107","colours":["#7F7E83"]}, +{"material":"PETG","type":"Basic","name":"Green","code":"30500","colours":["#009639"]}, +{"material":"PETG","type":"Basic","name":"Green","code":"30502","colours":["#009639"]}, +{"material":"PETG","type":"Basic","name":"Lake Blue","code":"30602","colours":["#0069B1"]}, +{"material":"PETG","type":"Basic","name":"Lime Green","code":"30501","colours":["#7CD82B"]}, +{"material":"PETG","type":"Basic","name":"Misty Blue","code":"30108","colours":["#688197"]}, +{"material":"PETG","type":"Basic","name":"Nature","code":"30104","colours":["#D7D7D7"]}, +{"material":"PETG","type":"Basic","name":"Navy Blue","code":"30604","colours":["#0086D6"]}, +{"material":"PETG","type":"Basic","name":"Orange","code":"30300","colours":["#FF671F"]}, +{"material":"PETG","type":"Basic","name":"Orange","code":"30301","colours":["#FF671F"]}, +{"material":"PETG","type":"Basic","name":"Pine Green","code":"30503","colours":["#034638"]}, +{"material":"PETG","type":"Basic","name":"Purple","code":"30700","colours":["#9E007E"]}, +{"material":"PETG","type":"Basic","name":"Red","code":"30200","colours":["#D6001C"]}, +{"material":"PETG","type":"Basic","name":"Red","code":"30201","colours":["#D6001C"]}, +{"material":"PETG","type":"Basic","name":"Reflex Blue","code":"30603","colours":["#001489"]}, +{"material":"PETG","type":"Basic","name":"Translucent","code":"30103","colours":["#FFFFFF"]}, +{"material":"PETG","type":"Basic","name":"White","code":"30100","colours":["#EDF0F2"]}, +{"material":"PETG","type":"Basic","name":"White","code":"30106","colours":["#FFFFFF"]}, +{"material":"PETG","type":"Basic","name":"Yellow","code":"30400","colours":["#FCE300"]}, +{"material":"PETG","type":"Basic","name":"Yellow","code":"30402","colours":["#FCE300"]}, +{"material":"PETG","type":"CF","name":"Black","code":"31100","colours":["#000000"]}, +{"material":"PETG","type":"CF","name":"Brick Red","code":"31200","colours":["#9F332A"]}, +{"material":"PETG","type":"CF","name":"Indigo Blue","code":"31600","colours":["#324585"]}, +{"material":"PETG","type":"CF","name":"Malachite Green","code":"31500","colours":["#16B08E"]}, +{"material":"PETG","type":"CF","name":"Titan Gray","code":"31101","colours":["#565656"]}, +{"material":"PETG","type":"CF","name":"Violet Purple","code":"31700","colours":["#583061"]}, +{"material":"PETG","type":"HF","name":"Black","code":"33102","colours":["#000000"]}, +{"material":"PETG","type":"HF","name":"Blue","code":"33600","colours":["#002E96"]}, +{"material":"PETG","type":"HF","name":"Cream","code":"33401","colours":["#F9DFB9"]}, +{"material":"PETG","type":"HF","name":"Dark Gray","code":"33103","colours":["#515151"]}, +{"material":"PETG","type":"HF","name":"Forest Green","code":"33502","colours":["#39541A"]}, +{"material":"PETG","type":"HF","name":"Gray","code":"33101","colours":["#ADB1B2"]}, +{"material":"PETG","type":"HF","name":"Green","code":"33500","colours":["#00AE42"]}, +{"material":"PETG","type":"HF","name":"Lake Blue","code":"33601","colours":["#1F79E5"]}, +{"material":"PETG","type":"HF","name":"Lime Green","code":"33501","colours":["#6EE53C"]}, +{"material":"PETG","type":"HF","name":"Orange","code":"33300","colours":["#F75403"]}, +{"material":"PETG","type":"HF","name":"Peanut Brown","code":"33801","colours":["#875718"]}, +{"material":"PETG","type":"HF","name":"Red","code":"33200","colours":["#EB3A3A"]}, +{"material":"PETG","type":"HF","name":"White","code":"33100","colours":["#FFFFFF"]}, +{"material":"PETG","type":"HF","name":"Yellow","code":"33400","colours":["#FFD00B"]}, +{"material":"PETG","type":"Translucent","name":"Brown","code":"32800","colours":["#C9A381"]}, +{"material":"PETG","type":"Translucent","name":"Clear","code":"32101","colours":["#EBEBEB"]}, +{"material":"PETG","type":"Translucent","name":"Gray","code":"32100","colours":["#8E8E8E"]}, +{"material":"PETG","type":"Translucent","name":"Light Blue","code":"32600","colours":["#61B0FF"]}, +{"material":"PETG","type":"Translucent","name":"Olive","code":"32500","colours":["#748C45"]}, +{"material":"PETG","type":"Translucent","name":"Orange","code":"32300","colours":["#FF911A"]}, +{"material":"PETG","type":"Translucent","name":"Pink","code":"32200","colours":["#F9C1BD"]}, +{"material":"PETG","type":"Translucent","name":"Purple","code":"32700","colours":["#D6ABFF"]}, +{"material":"PETG","type":"Translucent","name":"Teal","code":"32501","colours":["#77EDD7"]}, +{"material":"PLA","type":"Aero","name":"Black","code":"14103","colours":["#000000"]}, +{"material":"PLA","type":"Aero","name":"Gray","code":"14104","colours":["#CDCECA"]}, +{"material":"PLA","type":"Aero","name":"White","code":"14102","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Basic","name":"Bambu Green","code":"10501","colours":["#00AE42"]}, +{"material":"PLA","type":"Basic","name":"Beige","code":"10201","colours":["#F7E6DE"]}, +{"material":"PLA","type":"Basic","name":"Black","code":"10101","colours":["#000000"]}, +{"material":"PLA","type":"Basic","name":"Blue","code":"10600","colours":["#0A2989"]}, +{"material":"PLA","type":"Basic","name":"Blue","code":"10601","colours":["#0A2989"]}, +{"material":"PLA","type":"Basic","name":"Blue Gray","code":"10602","colours":["#5B6579"]}, +{"material":"PLA","type":"Basic","name":"Bright Green","code":"10503","colours":["#BECF00"]}, +{"material":"PLA","type":"Basic","name":"Bronze","code":"10801","colours":["#847D48"]}, +{"material":"PLA","type":"Basic","name":"Brown","code":"10800","colours":["#9D432C"]}, +{"material":"PLA","type":"Basic","name":"Cobalt Blue","code":"10604","colours":["#0056B8"]}, +{"material":"PLA","type":"Basic","name":"Cocoa Brown","code":"10802","colours":["#6F5034"]}, +{"material":"PLA","type":"Basic","name":"Cyan","code":"10603","colours":["#0086D6"]}, +{"material":"PLA","type":"Basic","name":"Dark Gray","code":"10105","colours":["#545454"]}, +{"material":"PLA","type":"Basic","name":"Gold","code":"10401","colours":["#E4BD68"]}, +{"material":"PLA","type":"Basic","name":"Gray","code":"10103","colours":["#8E9089"]}, +{"material":"PLA","type":"Basic","name":"Green","code":"10500","colours":["#002914"]}, +{"material":"PLA","type":"Basic","name":"Hot Pink","code":"10204","colours":["#F5547C"]}, +{"material":"PLA","type":"Basic","name":"Indigo Purple","code":"10701","colours":["#482960"]}, +{"material":"PLA","type":"Basic","name":"Jade White","code":"10100","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Basic","name":"Light Gray","code":"10104","colours":["#D1D3D5"]}, +{"material":"PLA","type":"Basic","name":"Magenta","code":"10202","colours":["#EC008C"]}, +{"material":"PLA","type":"Basic","name":"Maroon Red","code":"10205","colours":["#9D2235"]}, +{"material":"PLA","type":"Basic","name":"Mistletoe Green","code":"10502","colours":["#3F8E43"]}, +{"material":"PLA","type":"Basic","name":"Orange","code":"10300","colours":["#FF6A13"]}, +{"material":"PLA","type":"Basic","name":"Pink","code":"10203","colours":["#F55A74"]}, +{"material":"PLA","type":"Basic","name":"Pumpkin Orange","code":"10301","colours":["#FF9016"]}, +{"material":"PLA","type":"Basic","name":"Purple","code":"10700","colours":["#5E43B7"]}, +{"material":"PLA","type":"Basic","name":"Red","code":"10200","colours":["#C12E1F"]}, +{"material":"PLA","type":"Basic","name":"Silver","code":"10102","colours":["#A6A9AA"]}, +{"material":"PLA","type":"Basic","name":"Sunflower Yellow","code":"10402","colours":["#FEC600"]}, +{"material":"PLA","type":"Basic","name":"Turquoise","code":"10605","colours":["#00B1B7"]}, +{"material":"PLA","type":"Basic","name":"Yellow","code":"10400","colours":["#F4EE2A"]}, +{"material":"PLA","type":"CF","name":"Black","code":"14100","colours":["#000000"]}, +{"material":"PLA","type":"CF","name":"Burgundy Red","code":"14200","colours":["#951E23"]}, +{"material":"PLA","type":"CF","name":"Iris Purple","code":"14700","colours":["#69398E"]}, +{"material":"PLA","type":"CF","name":"Jeans Blue","code":"14600","colours":["#6E88BC"]}, +{"material":"PLA","type":"CF","name":"Lava Gray","code":"14101","colours":["#4D5054"]}, +{"material":"PLA","type":"CF","name":"Matcha Green","code":"14500","colours":["#5C9748"]}, +{"material":"PLA","type":"CF","name":"Royal Blue","code":"14601","colours":["#2842AD"]}, +{"material":"PLA","type":"CMYK","name":"Lithophane Bundle","code":"","colours":["#00FFFF","#FFFF00","#FF00FF","#FFFFFF"]}, +{"material":"PLA","type":"Galaxy","name":"Brown","code":"13203","colours":["#684A43"]}, +{"material":"PLA","type":"Galaxy","name":"Green","code":"13503","colours":["#3B665E"]}, +{"material":"PLA","type":"Galaxy","name":"Nebula","code":"13504","colours":["#424379"]}, +{"material":"PLA","type":"Galaxy","name":"Purple","code":"13602","colours":["#594177"]}, +{"material":"PLA","type":"Glow","name":"Glow Blue","code":"15600","colours":["#7AC0E9"]}, +{"material":"PLA","type":"Glow","name":"Glow Green","code":"15500","colours":["#A1FFAC"]}, +{"material":"PLA","type":"Glow","name":"Glow Orange","code":"15300","colours":["#FF9D5B"]}, +{"material":"PLA","type":"Glow","name":"Glow Pink","code":"15200","colours":["#F17B8F"]}, +{"material":"PLA","type":"Glow","name":"Glow Yellow","code":"15400","colours":["#F8FF80"]}, +{"material":"PLA","type":"Gradient","name":"Arctic Whisper","code":"10900","colours":["#9CDBD9","#FFFFFF"]}, +{"material":"PLA","type":"Gradient","name":"Blueberry Bubblegum","code":"10905","colours":["#6FCAEF","#8573DD"]}, +{"material":"PLA","type":"Gradient","name":"Cotton Candy Cloud","code":"10907","colours":["#E7C1D5","#8EC9E9"]}, +{"material":"PLA","type":"Gradient","name":"Dusk Glare","code":"10906","colours":["#ED6022","#FFCBA6"]}, +{"material":"PLA","type":"Gradient","name":"Mint Lime","code":"10904","colours":["#4EC83A","#D7FE9E"]}, +{"material":"PLA","type":"Gradient","name":"Ocean to Meadow","code":"10902","colours":["#307FE2","#54FF9B"]}, +{"material":"PLA","type":"Gradient","name":"Pink Citrus","code":"10903","colours":["#E4555E","#FCDAD2"]}, +{"material":"PLA","type":"Gradient","name":"Solar Breeze","code":"10901","colours":["#FC776A","#FFF7F5"]}, +{"material":"PLA","type":"Lite","name":"Beige","code":"16700","colours":["#F7E6DE"]}, +{"material":"PLA","type":"Lite","name":"Black","code":"16100","colours":["#000000"]}, +{"material":"PLA","type":"Lite","name":"Blue","code":"16601","colours":["#004EA8"]}, +{"material":"PLA","type":"Lite","name":"Cocoa Brown","code":"","colours":["#8E3C06"]}, +{"material":"PLA","type":"Lite","name":"Cyan","code":"16600","colours":["#00FFFF"]}, +{"material":"PLA","type":"Lite","name":"Dark Gray","code":"16102","colours":["#6F6E6D"]}, +{"material":"PLA","type":"Lite","name":"Gray","code":"16101","colours":["#9FA19F"]}, +{"material":"PLA","type":"Lite","name":"Green","code":"16501","colours":["#00BB31"]}, +{"material":"PLA","type":"Lite","name":"Orange","code":"16301","colours":["#FF671F"]}, +{"material":"PLA","type":"Lite","name":"Red","code":"16200","colours":["#FF0000"]}, +{"material":"PLA","type":"Lite","name":"Sunflower Yellow","code":"16401","colours":["#FFB549"]}, +{"material":"PLA","type":"Lite","name":"White","code":"16103","colours":["#FFFEF7"]}, +{"material":"PLA","type":"Lite","name":"Yellow","code":"16400","colours":["#FFD834"]}, +{"material":"PLA","type":"Marble","name":"Red Granite","code":"13201","colours":["#AD4E38"]}, +{"material":"PLA","type":"Marble","name":"White Marble","code":"13103","colours":["#F7F3F0"]}, +{"material":"PLA","type":"Matte","name":"Apple Green","code":"11502","colours":["#C2E189"]}, +{"material":"PLA","type":"Matte","name":"Ash Gray","code":"11102","colours":["#9B9EA0"]}, +{"material":"PLA","type":"Matte","name":"Bone White","code":"11103","colours":["#CBC6B8"]}, +{"material":"PLA","type":"Matte","name":"Caramel","code":"11803","colours":["#AE835B"]}, +{"material":"PLA","type":"Matte","name":"Charcoal","code":"11101","colours":["#000000"]}, +{"material":"PLA","type":"Matte","name":"Dark Blue","code":"11602","colours":["#042F56"]}, +{"material":"PLA","type":"Matte","name":"Dark Brown","code":"11801","colours":["#7D6556"]}, +{"material":"PLA","type":"Matte","name":"Dark Chocolate","code":"11802","colours":["#4D3324"]}, +{"material":"PLA","type":"Matte","name":"Dark Green","code":"11501","colours":["#68724D"]}, +{"material":"PLA","type":"Matte","name":"Dark Red","code":"11202","colours":["#BB3D43"]}, +{"material":"PLA","type":"Matte","name":"Desert Tan","code":"11401","colours":["#E8DBB7"]}, +{"material":"PLA","type":"Matte","name":"Grass Green","code":"11500","colours":["#61C680"]}, +{"material":"PLA","type":"Matte","name":"Ice Blue","code":"11601","colours":["#A3D8E1"]}, +{"material":"PLA","type":"Matte","name":"Ivory White","code":"11100","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Matte","name":"Latte Brown","code":"11800","colours":["#D3B7A7"]}, +{"material":"PLA","type":"Matte","name":"Lemon Yellow","code":"11400","colours":["#F7D959"]}, +{"material":"PLA","type":"Matte","name":"Lilac Purple","code":"11700","colours":["#AE96D4"]}, +{"material":"PLA","type":"Matte","name":"Mandarin Orange","code":"11300","colours":["#F99963"]}, +{"material":"PLA","type":"Matte","name":"Marine Blue","code":"11600","colours":["#0078BF"]}, +{"material":"PLA","type":"Matte","name":"Nardo Gray","code":"11104","colours":["#757575"]}, +{"material":"PLA","type":"Matte","name":"Plum","code":"11204","colours":["#950051"]}, +{"material":"PLA","type":"Matte","name":"Sakura Pink","code":"11201","colours":["#E8AFCF"]}, +{"material":"PLA","type":"Matte","name":"Scarlet Red","code":"11200","colours":["#DE4343"]}, +{"material":"PLA","type":"Matte","name":"Sky Blue","code":"11603","colours":["#56B7E6"]}, +{"material":"PLA","type":"Matte","name":"Terracotta","code":"11203","colours":["#B15533"]}, +{"material":"PLA","type":"Metal","name":"Cobalt Blue Metallic","code":"13600","colours":["#39699E"]}, +{"material":"PLA","type":"Metal","name":"Copper Brown Metallic","code":"13800","colours":["#AA6443"]}, +{"material":"PLA","type":"Metal","name":"Iridium Gold Metallic","code":"13400","colours":["#B39B84"]}, +{"material":"PLA","type":"Metal","name":"Iron Gray Metallic","code":"13100","colours":["#43403D"]}, +{"material":"PLA","type":"Metal","name":"Oxide Green Metallic","code":"13500","colours":["#1D7C6A"]}, +{"material":"PLA","type":"Pure","name":"Absolute Black","code":"17101","colours":["#000000"]}, +{"material":"PLA","type":"Pure","name":"Apricot","code":"17300","colours":["#FFB673"]}, +{"material":"PLA","type":"Pure","name":"Baby Blue","code":"17600","colours":["#A4DBE8"]}, +{"material":"PLA","type":"Pure","name":"Milky Pink","code":"17200","colours":["#F7CED7"]}, +{"material":"PLA","type":"Pure","name":"Pure White","code":"17100","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Silk","name":"Blue","code":"13601","colours":["#147BD1"]}, +{"material":"PLA","type":"Silk","name":"Copper","code":"13300","colours":["#5E4B3C"]}, +{"material":"PLA","type":"Silk","name":"Gold","code":"13401","colours":["#E5B03D"]}, +{"material":"PLA","type":"Silk","name":"Green","code":"13502","colours":["#4CE4A0"]}, +{"material":"PLA","type":"Silk","name":"Pink","code":"13202","colours":["#EEB1C1"]}, +{"material":"PLA","type":"Silk","name":"Purple","code":"13701","colours":["#580490"]}, +{"material":"PLA","type":"Silk","name":"Silver","code":"13104","colours":["#ACB4B5"]}, +{"material":"PLA","type":"Silk","name":"White","code":"13105","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Aurora Purple","code":"13909","colours":["#7F3696","#006EC9"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Blue Hawaii (Blue-Green)","code":"13904","colours":["#60A4E8","#4CE4A0"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Dawn Radiance","code":"13912","colours":["#EC984C","#6CD4BC","#A66EB9","#D87694"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Gilded Rose (Pink-Gold)","code":"13901","colours":["#FF9425","#FCA2BF"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Midnight Blaze (Blue-Red)","code":"13902","colours":["#0047BB","#7D1B49"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Mystic Magenta (Purple+Green)","code":"13913","colours":["#720062","#3A913F"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Neon City (Blue-Magenta)","code":"13903","colours":["#0047BB","#BB22A3"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Phantom Blue (Blue-Black)","code":"13916","colours":["#000000","#00629B"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"South Beach","code":"13906","colours":["#F772A4","#00918B"]}, +{"material":"PLA","type":"Silk Multi-Color","name":"Velvet Eclipse (Black-Red)","code":"13905","colours":["#000000","#C80000"]}, +{"material":"PLA","type":"Silk+","name":"Baby Blue","code":"13603","colours":["#A8C6EE"]}, +{"material":"PLA","type":"Silk+","name":"Blue","code":"13604","colours":["#008BDA"]}, +{"material":"PLA","type":"Silk+","name":"Candy Green","code":"13506","colours":["#018814"]}, +{"material":"PLA","type":"Silk+","name":"Candy Red","code":"13205","colours":["#D02727"]}, +{"material":"PLA","type":"Silk+","name":"Champagne","code":"13404","colours":["#F3CFB2"]}, +{"material":"PLA","type":"Silk+","name":"Gold","code":"13405","colours":["#F4A925"]}, +{"material":"PLA","type":"Silk+","name":"Mint","code":"13507","colours":["#96DCB9"]}, +{"material":"PLA","type":"Silk+","name":"Pink","code":"13207","colours":["#F7ADA6"]}, +{"material":"PLA","type":"Silk+","name":"Purple","code":"13702","colours":["#8671CB"]}, +{"material":"PLA","type":"Silk+","name":"Rose Gold","code":"13206","colours":["#BA9594"]}, +{"material":"PLA","type":"Silk+","name":"Silver","code":"13109","colours":["#C8C8C8"]}, +{"material":"PLA","type":"Silk+","name":"Titan Gray","code":"13108","colours":["#5F6367"]}, +{"material":"PLA","type":"Silk+","name":"White","code":"13110","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Sparkle","name":"Alpine Green Sparkle","code":"13501","colours":["#3F5443"]}, +{"material":"PLA","type":"Sparkle","name":"Classic Gold Sparkle","code":"13402","colours":["#CEA629"]}, +{"material":"PLA","type":"Sparkle","name":"Crimson Red Sparkle","code":"13200","colours":["#792B36"]}, +{"material":"PLA","type":"Sparkle","name":"Onyx Black Sparkle","code":"13101","colours":["#2D2B28"]}, +{"material":"PLA","type":"Sparkle","name":"Royal Purple Sparkle","code":"13700","colours":["#483D8B"]}, +{"material":"PLA","type":"Sparkle","name":"Slate Gray Sparkle","code":"13102","colours":["#8E9089"]}, +{"material":"PLA","type":"Support","name":"Support for PLA Black","code":"65101","colours":["#000000"]}, +{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65100","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65104","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Support","name":"Support for PLA/PETG Black","code":"65103","colours":["#000000"]}, +{"material":"PLA","type":"Support","name":"Support for PLA/PETG Nature","code":"65102","colours":["#FBF5E4"]}, +{"material":"PLA","type":"Tough","name":"Black","code":"","colours":["#000000"]}, +{"material":"PLA","type":"Tough","name":"Cream White","code":"","colours":["#FAFAFA"]}, +{"material":"PLA","type":"Tough","name":"Gray","code":"12102","colours":["#515A6C"]}, +{"material":"PLA","type":"Tough","name":"Lavender-Blue","code":"12700","colours":["#6667AB"]}, +{"material":"PLA","type":"Tough","name":"Light-Blue","code":"12600","colours":["#0085AD"]}, +{"material":"PLA","type":"Tough","name":"Orange","code":"12300","colours":["#FF7F41"]}, +{"material":"PLA","type":"Tough","name":"Pine Green","code":"12500","colours":["#00482B"]}, +{"material":"PLA","type":"Tough","name":"Silver","code":"12103","colours":["#9D9D9D"]}, +{"material":"PLA","type":"Tough","name":"Vermilion-Red","code":"12200","colours":["#DD3C22"]}, +{"material":"PLA","type":"Tough","name":"White","code":"12100","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Tough","name":"Yellow","code":"12400","colours":["#FEDB00"]}, +{"material":"PLA","type":"Tough+","name":"Black","code":"12104","colours":["#000000"]}, +{"material":"PLA","type":"Tough+","name":"Cyan","code":"12601","colours":["#009BD9"]}, +{"material":"PLA","type":"Tough+","name":"Gray","code":"12105","colours":["#AFB1AE"]}, +{"material":"PLA","type":"Tough+","name":"Orange","code":"12301","colours":["#DC3A27"]}, +{"material":"PLA","type":"Tough+","name":"Silver","code":"12106","colours":["#959698"]}, +{"material":"PLA","type":"Tough+","name":"White","code":"12107","colours":["#FFFFFF"]}, +{"material":"PLA","type":"Tough+","name":"Yellow","code":"12401","colours":["#F4D53F"]}, +{"material":"PLA","type":"Translucent","name":"Blue","code":"13611","colours":["#0047BB"]}, +{"material":"PLA","type":"Translucent","name":"Cherry Pink","code":"13211","colours":["#F5B6CD"]}, +{"material":"PLA","type":"Translucent","name":"Ice Blue","code":"13610","colours":["#B8CDE9"]}, +{"material":"PLA","type":"Translucent","name":"Lavender","code":"13711","colours":["#B8ACD6"]}, +{"material":"PLA","type":"Translucent","name":"Light Jade","code":"13510","colours":["#96D8AF"]}, +{"material":"PLA","type":"Translucent","name":"Mellow Yellow","code":"13410","colours":["#F5DBAB"]}, +{"material":"PLA","type":"Translucent","name":"Orange","code":"13301","colours":["#F74E02"]}, +{"material":"PLA","type":"Translucent","name":"Purple","code":"13710","colours":["#8344B0"]}, +{"material":"PLA","type":"Translucent","name":"Red","code":"13210","colours":["#B50011"]}, +{"material":"PLA","type":"Translucent","name":"Teal","code":"13612","colours":["#009FA1"]}, +{"material":"PLA","type":"Wood","name":"Black Walnut","code":"13107","colours":["#4F3F24"]}, +{"material":"PLA","type":"Wood","name":"Classic Birch","code":"13505","colours":["#918669"]}, +{"material":"PLA","type":"Wood","name":"Clay Brown","code":"13801","colours":["#995F11"]}, +{"material":"PLA","type":"Wood","name":"Ochre Yellow","code":"13403","colours":["#C98935"]}, +{"material":"PLA","type":"Wood","name":"Rosewood","code":"13204","colours":["#4C241C"]}, +{"material":"PLA","type":"Wood","name":"White Oak","code":"13106","colours":["#D6CCA3"]}, +{"material":"PPA","type":"CF","name":"Black","code":"73100","colours":["#000000"]}, +{"material":"PPS","type":"CF","name":"Black","code":"76100","colours":["#000000"]}, +{"material":"PVA","type":"Support","name":"Clear","code":"66400","colours":["#F0F1A8"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Black","code":"53101","colours":["#000000"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Blue","code":"53600","colours":["#5898DD"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Gray","code":"53102","colours":["#939393"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Neon Green","code":"53500","colours":["#90FF1A"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Red","code":"53200","colours":["#ED0000"]}, +{"material":"TPU","type":"68D (For AMS)","name":"White","code":"53100","colours":["#FFFFFF"]}, +{"material":"TPU","type":"68D (For AMS)","name":"Yellow","code":"53400","colours":["#F9EF41"]}, +{"material":"TPU","type":"85A","name":"Black","code":"51107","colours":["#000000"]}, +{"material":"TPU","type":"85A","name":"Flesh","code":"51201","colours":["#FFF2D5"]}, +{"material":"TPU","type":"85A","name":"Light Cyan","code":"51500","colours":["#C3E2D6"]}, +{"material":"TPU","type":"85A","name":"Lime Green","code":"51501","colours":["#EAF28F"]}, +{"material":"TPU","type":"85A","name":"Neon Orange","code":"51305","colours":["#F68B1B"]}, +{"material":"TPU","type":"90A","name":"Black","code":"51103","colours":["#000000"]}, +{"material":"TPU","type":"90A","name":"Blaze","code":"51901","colours":["#F1AAA8","#D21B3C"]}, +{"material":"TPU","type":"90A","name":"Cocoa Brown","code":"51800","colours":["#5C4738"]}, +{"material":"TPU","type":"90A","name":"Crystal Blue","code":"51601","colours":["#7EB4E1"]}, +{"material":"TPU","type":"90A","name":"Frozen","code":"51900","colours":["#FFFFFF","#40B6E4"]}, +{"material":"TPU","type":"90A","name":"Grape Jelly","code":"51700","colours":["#D6ABFF"]}, +{"material":"TPU","type":"90A","name":"Quicksilver","code":"51106","colours":["#9EA2A2"]}, +{"material":"TPU","type":"90A","name":"White","code":"51105","colours":["#FFFFFF"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"Black","code":"51100","colours":["#000000"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"Blue","code":"51600","colours":["#0072CE"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"Gray","code":"51101","colours":["#898D8D"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"Red","code":"51200","colours":["#C8102E"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"White","code":"51102","colours":["#FFFFFF"]}, +{"material":"TPU","type":"95A HF/High Speed","name":"Yellow","code":"51400","colours":["#F3E600"]} + ] +} diff --git a/db/schema.sql b/db/schema.sql index 4311da7..b90d866 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -277,6 +277,7 @@ CREATE TABLE IF NOT EXISTS filament_spools ( total_g INTEGER, -- spool weight when new remaining_g INTEGER, -- what's left; null = unknown ams_slot TEXT, -- "//" once AMS sync lands + code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports notes TEXT NOT NULL DEFAULT '', date_added TIMESTAMPTZ NOT NULL DEFAULT now(), date_modified TIMESTAMPTZ @@ -287,3 +288,4 @@ CREATE TABLE IF NOT EXISTS filament_spools ( -- thumbnails and fingerprints are done, because the reader shells out to Python. ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB; ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error +ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT; diff --git a/lib/store.js b/lib/store.js index d67012c..8a89f76 100644 --- a/lib/store.js +++ b/lib/store.js @@ -665,10 +665,10 @@ async function listSpools() { async function createSpool(data) { const { rows } = await db.query( - `INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes) - VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + `INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes, code) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, [String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '', - data.total_g ?? null, data.remaining_g ?? null, data.notes || '']); + data.total_g ?? null, data.remaining_g ?? null, data.notes || '', data.code || null]); return rows[0]; } @@ -683,10 +683,11 @@ async function updateSpool(id, data) { total_g = COALESCE($6, total_g), remaining_g = COALESCE($7, remaining_g), notes = COALESCE($8, notes), + code = COALESCE($9, code), date_modified = now() WHERE id = $1 RETURNING *`, [id, data.material ?? null, data.colour ?? null, data.colour_hex ?? null, data.brand ?? null, - data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null]); + data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null, data.code ?? null]); return rows[0] || null; } diff --git a/public/app.js b/public/app.js index 5ebb07a..bc977f7 100644 --- a/public/app.js +++ b/public/app.js @@ -6,6 +6,7 @@ let config = { importers: {} }; let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 }; let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id let spools = []; // filament on hand, so cards can say what's printable +let palette = []; // the maker's filament list: names, codes and colours let tagFilters = {}; // tag -> 'include' | 'exclude' let duplicateData = null; // last /api/duplicates response let duplicatePoll = null; @@ -159,6 +160,11 @@ async function loadData() { config = await configRes.json(); stats = statsRes.ok ? await statsRes.json() : stats; spools = filamentRes.ok ? (await filamentRes.json()).spools : []; + // Static and ~40KB, so fetched once rather than on every refresh. + if (!palette.length) { + palette = await fetch('/api/filament/palette') + .then((r) => (r.ok ? r.json() : { filaments: [] })).then((p) => p.filaments || []).catch(() => []); + } if (selectedScope === 'trash') await loadTrash(); if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) { selectedScope = 'all'; // the open collection was deleted @@ -287,6 +293,20 @@ function colourDistance(a, b) { const COLOUR_TOLERANCE = 50; // keeps #000000 and #161616 (~66 apart) distinct +// The maker's name for a colour, when the palette has one that close. The +// threshold is deliberately tight: this is identification, not substitution. +function paletteName(hex, material) { + let best = null; + for (const f of palette) { + if (material && filamentType(f.material) !== filamentType(material)) continue; + for (const c of f.colours || []) { + const d = colourDistance(hex, c); + if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, type: f.type }; + } + } + return best ? `${best.type} ${best.name}` : null; +} + // Which spool fills which slot — best colour matches first, one spool per slot. function printability(m) { const slots = ((m && m.filaments) || []).map((f, i) => ({ @@ -2125,7 +2145,10 @@ function renderDetailPrintable(model) {
Slot ${i + 1} · ${escapeHtml(s.material)} - ${escapeHtml(s.colour || 'any colour')} + ${escapeHtml(s.colour || 'any colour')}${(() => { + const named = s.colour ? paletteName(s.colour, s.material) : null; + return named ? ` · ${escapeHtml(named)}` : ''; + })()} ${s.filled ? `${escapeHtml(s.by)}` : `none${s.nearest @@ -2316,6 +2339,9 @@ function filamentPanelHtml() { know which filaments they ask for, so Printhive can tell you what's printable without a trip to the shelf. Colour isn't matched — a different colour still prints.

+ + @@ -2355,6 +2381,20 @@ async function renderFilamentPanel() { if (!document.getElementById('spoolForm')) { fileGrid.innerHTML = filamentPanelHtml(); document.getElementById('spoolForm').addEventListener('submit', handleAddSpool); + + // Picking a known filament fills in the rest, so nobody types hex by hand. + const pick = document.getElementById('spoolPick'); + document.getElementById('spoolPickList').innerHTML = palette + .map((f) => ``) + .join(''); + pick.addEventListener('input', () => { + const chosen = palette.find((f) => `${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}` === pick.value); + if (!chosen) return; + document.getElementById('spoolMaterial').value = chosen.material; + document.getElementById('spoolColour').value = chosen.name; + document.getElementById('spoolHex').value = (chosen.colours[0] || '#ffffff').toLowerCase(); + pick.dataset.code = chosen.code || ''; + }); } await refreshSpools(); } @@ -2397,12 +2437,14 @@ async function handleAddSpool(e) { status.textContent = ''; try { const remaining = document.getElementById('spoolRemaining').value; + const pick = document.getElementById('spoolPick'); await postJson('/api/filament', { material, colour: document.getElementById('spoolColour').value.trim(), colour_hex: document.getElementById('spoolHex').value, brand: document.getElementById('spoolBrand').value.trim(), remaining_g: remaining === '' ? null : Number(remaining), + code: (pick && pick.dataset.code) || null, // what the AMS reports, when picked from the list }); document.getElementById('spoolForm').reset(); document.getElementById('spoolMaterial').focus(); diff --git a/server.js b/server.js index 276e94c..735941f 100644 --- a/server.js +++ b/server.js @@ -29,6 +29,7 @@ const aiRuns = require('./lib/aiRuns'); const collectionWatch = require('./lib/collectionWatch'); const autotag = require('./lib/autotag'); const filaments = require('./lib/filaments'); +const filamentPalette = require('./db/bambu-filaments.json'); const embeddings = require('./lib/embeddings'); const app = express(); @@ -323,6 +324,13 @@ app.put('/api/settings', async (req, res, next) => { // ----------------------------------------------------------------- filament // The spools you have. What a model needs is read from its 3MF in the // background and kept on the model itself, so matching is a plain comparison. +// The maker's filament list, for picking a spool without typing hex codes. +// Static, so it's cached hard rather than re-read. +app.get('/api/filament/palette', (req, res) => { + res.set('Cache-Control', 'public, max-age=86400'); + res.json(filamentPalette); +}); + app.get('/api/filament', async (req, res, next) => { try { res.json({ spools: await store.listSpools(), pending: await filaments.pendingCount() });