From eaf1bd93d523ec128b706245ae57fd319940bd8b Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 13:15:48 +1000 Subject: [PATCH 1/2] Let the required colours be edited on the model page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slot list comes from the project file, which is sometimes wrong and sometimes not what you intend to print. "Edit slots" turns it into fields: material and colour per slot, with slots you can add or drop. Saved slots are marked as yours and shown with a "your list" badge, and "Use the project's list" throws the override away and re-reads the 3MF. Edits have to outlive the background reader, which is the part that would fail silently: the reader only picks up models with no status, and reread() now skips 'edited' rather than nulling everything. Verified on a real model — the edit survived a reread plus a full worker pass, and the revert restored the project's own four slots exactly. Input cleaning moved out of the route into lib/filaments.js so it can be tested: a slot with no material is dropped, and a colour that isn't a proper hex becomes "any colour of that material" rather than being rejected. Not verified: none of this has been seen rendered — the local preview browser has no session. Co-Authored-By: Claude Opus 5 --- lib/filaments.js | 22 ++++++++++++-- lib/store.js | 12 +++++++- public/app.js | 68 ++++++++++++++++++++++++++++++++++++++++-- public/styles.css | 4 +++ server.js | 28 +++++++++++++++++ test/filaments.test.js | 21 +++++++++++++ 6 files changed, 149 insertions(+), 6 deletions(-) diff --git a/lib/filaments.js b/lib/filaments.js index 7e8f280..ce429e4 100644 --- a/lib/filaments.js +++ b/lib/filaments.js @@ -144,12 +144,28 @@ async function pendingCount() { return rows[0].n; } -// Look at every model again — after adding files, say. +// Look at every model again — after adding files, say. Slots you set by hand +// are left alone: they aren't the reader's to overwrite. async function reread() { - await db.query('UPDATE models SET filaments_status = NULL'); + await db.query("UPDATE models SET filaments_status = NULL WHERE filaments_status IS DISTINCT FROM 'edited'"); +} + +// Slots as sent from the page. Keeps the material, accepts only a proper hex as +// a colour, and drops anything with no material — a slot that asks for nothing +// would quietly count as satisfied. +function cleanSlots(list) { + if (!Array.isArray(list)) return null; + return list + .map((f) => ({ + type: String((f && f.type) || '').trim(), + colour: /^#[0-9a-f]{6}$/i.test(String((f && f.colour) || '').trim()) + ? String(f.colour).trim().toUpperCase() + : null, + })) + .filter((f) => f.type); } module.exports = { - run, pendingCount, reread, + run, pendingCount, reread, cleanSlots, assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE, }; diff --git a/lib/store.js b/lib/store.js index 8a89f76..4860950 100644 --- a/lib/store.js +++ b/lib/store.js @@ -696,6 +696,16 @@ async function deleteSpool(id) { return rowCount > 0; } +// The filament slots a model needs, set by hand. Marked 'edited' so the +// background reader treats them as yours and leaves them alone. +async function setModelFilaments(modelId, filaments) { + const { rowCount } = await db.query( + `UPDATE models SET filaments = $2, filaments_status = 'edited', date_modified = now() + WHERE id = $1 AND deleted_at IS NULL`, + [modelId, JSON.stringify(filaments)]); + return rowCount > 0; +} + module.exports = { getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles, updateFileConversion, @@ -706,7 +716,7 @@ module.exports = { suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion, getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject, getSetting, setSetting, - listSpools, createSpool, updateSpool, deleteSpool, + listSpools, createSpool, updateSpool, deleteSpool, setModelFilaments, countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, createPasswordReset, getValidReset, markResetUsed, getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels, diff --git a/public/app.js b/public/app.js index d798cca..aee4ab0 100644 --- a/public/app.js +++ b/public/app.js @@ -2146,6 +2146,7 @@ function renderDetailPrintable(model) { el.innerHTML = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
'; return; } + const yours = model.filaments_status === 'edited'; const rows = p.slots.map((s, i) => `
@@ -2161,9 +2162,72 @@ function renderDetailPrintable(model) {
`).join(''); el.innerHTML = `
${p.ok ? '✔ printable now' - : `needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}`}
+ : `needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}`} + ${yours ? 'your list' : ''}
${rows}
-
A spool fills one slot, and its colour has to be close to what the slot asks for.
`; +
A spool fills one slot, and its colour has to be close to what the slot asks for.
+
+ + ${yours ? '' : ''} +
`; + el.querySelector('[data-slots="edit"]').addEventListener('click', () => editSlots(model, p.slots)); + const revert = el.querySelector('[data-slots="revert"]'); + if (revert) revert.addEventListener('click', () => revertSlots(model.id)); +} + +// Editing turns the list into fields: material and colour per slot, with slots +// you can add or drop. Saved slots are marked as yours, so the background +// reader stops overwriting them from the project file. +function editSlots(model, slots) { + const el = document.getElementById('detailPrintable'); + const rowHtml = (s = { material: 'PLA', colour: '#FFFFFF' }) => ` +
+ + + +
`; + el.innerHTML = ` +
${slots.map(rowHtml).join('')}
+
+ + + +
+
Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.
`; + + const list = document.getElementById('slotEditList'); + const bindDrop = (row) => row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove()); + [...list.children].forEach(bindDrop); + document.getElementById('slotAdd').addEventListener('click', () => { + list.insertAdjacentHTML('beforeend', rowHtml()); + bindDrop(list.lastElementChild); + }); + document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model)); + document.getElementById('slotSave').addEventListener('click', async () => { + const filaments = [...list.children].map((row) => ({ + type: row.querySelector('.slot-material').value.trim(), + colour: row.querySelector('.slot-colour').value, + })).filter((f) => f.type); + const status = document.getElementById('slotStatus'); + try { + await postJson(`/api/models/${model.id}/filaments`, { filaments }, 'PUT'); + await loadData(); + openModelDetail(model.id); + } catch (err) { + status.classList.add('is-error'); + status.textContent = err.message; + } + }); +} + +async function revertSlots(id) { + try { + await getJson(`/api/models/${id}/filaments`, { method: 'DELETE' }); + await loadData(); + openModelDetail(id); + } catch (err) { + alert(err.message); + } } function renderDetailLicense(model) { diff --git a/public/styles.css b/public/styles.css index 0a57e02..37810f2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -465,6 +465,10 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .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-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; } +.slot-edit .slot-colour { width: 42px; padding: 2px; } +.print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; } .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; } diff --git a/server.js b/server.js index 735941f..110846f 100644 --- a/server.js +++ b/server.js @@ -464,6 +464,34 @@ async function refreshMetadata(model) { // Re-download files from the source into an existing model (recovers imports // that came back empty/partial, e.g. when FlareSolverr was flaky). Only adds // files not already present. +// The filament slots a model needs — for when the project's own list is wrong, +// or you plan to print it in something else. Saved as yours, so the background +// reader won't overwrite it. +app.put('/api/models/:id/filaments', async (req, res, next) => { + try { + const sent = (req.body || {}).filaments; + const cleaned = filaments.cleanSlots(sent); + if (!cleaned) return res.status(400).json({ error: 'Send { filaments: [{ type, colour }] }' }); + if (sent.length > 32) return res.status(400).json({ error: 'That is more slots than any printer has.' }); + if (!(await store.setModelFilaments(Number(req.params.id), cleaned))) { + return res.status(404).json({ error: 'Model not found' }); + } + res.json(publicModel(await store.getModel(req.params.id))); + } catch (e) { next(e); } +}); + +// Throw away your edits and read the project's own list again. +app.delete('/api/models/:id/filaments', async (req, res, next) => { + try { + const { rowCount } = await db.query( + `UPDATE models SET filaments = NULL, filaments_status = NULL WHERE id = $1 AND deleted_at IS NULL`, + [Number(req.params.id)]); + if (!rowCount) return res.status(404).json({ error: 'Model not found' }); + fingerprint.kick(); // the reader picks it up again + res.json({ message: 'Back to the project’s own filament list' }); + } catch (e) { next(e); } +}); + app.post('/api/models/:id/download-files', async (req, res, next) => { try { const model = await store.getModel(req.params.id); diff --git a/test/filaments.test.js b/test/filaments.test.js index fa20e00..c57e078 100644 --- a/test/filaments.test.js +++ b/test/filaments.test.js @@ -84,3 +84,24 @@ test('a model we know nothing about is not claimed to be printable', () => { assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false); assert.deepEqual(filaments.assignSpools(null, []), []); }); + +test('slots sent from the page are cleaned before they reach the database', () => { + const cleaned = filaments.cleanSlots([ + { type: ' pla ', colour: '#ffffff' }, // trimmed, hex upper-cased + { type: 'PETG', colour: 'not a colour' }, // bad colour becomes "any colour" + { type: '', colour: '#000000' }, // no material: dropped entirely + { type: 'PLA', colour: '#8e9089' }, + ]); + assert.deepEqual(cleaned, [ + { type: 'pla', colour: '#FFFFFF' }, + { type: 'PETG', colour: null }, + { type: 'PLA', colour: '#8E9089' }, + ]); +}); + +test('anything that isn’t a list of slots is refused outright', () => { + assert.equal(filaments.cleanSlots(null), null); + assert.equal(filaments.cleanSlots('PLA'), null); + assert.equal(filaments.cleanSlots({ type: 'PLA' }), null); + assert.deepEqual(filaments.cleanSlots([]), []); +}); From 8f3e63585ce64aac2e5063d0e46ff4451253cc96 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 13:19:47 +1000 Subject: [PATCH 2/2] Let a slot ask for any colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some prints don't care about the shade, and some change with the order. Each slot in the editor now has an "any colour" box: ticking it greys out the swatch and saves the slot without a colour, which the matcher fills from any spool of that material. A slot added by hand starts this way, since adding one usually means you care about the material rather than the shade. An unparseable colour already became "any colour" rather than being refused; that stays, and is now covered by tests along with the shapes the checkbox actually sends — null, empty string, and no colour key at all. Worth knowing when reading the printable count: ticking "any colour" makes a model easier to satisfy, so the count now partly reflects your decisions rather than only what the project files ask for. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- public/app.js | 24 ++++++++++++++++++------ public/styles.css | 3 +++ test/filaments.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5414270..67bb16d 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. 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. +- **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. The slot list can be edited on the model page when the project's own list is wrong or you plan to print it differently — set a slot to **any colour** when the shade doesn't matter, and it's filled by any spool of that material. Edited lists are marked as yours and left alone by the background reader; "Use the project's list" puts it back. 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/public/app.js b/public/app.js index aee4ab0..5e2e352 100644 --- a/public/app.js +++ b/public/app.js @@ -2180,10 +2180,15 @@ function renderDetailPrintable(model) { // reader stops overwriting them from the project file. function editSlots(model, slots) { const el = document.getElementById('detailPrintable'); - const rowHtml = (s = { material: 'PLA', colour: '#FFFFFF' }) => ` + // A new slot starts as "any colour": adding one by hand usually means you + // care about the material, not the shade. + const rowHtml = (s = { material: 'PLA', colour: null }) => `
- + +
`; el.innerHTML = ` @@ -2196,17 +2201,24 @@ function editSlots(model, slots) {
Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.
`; const list = document.getElementById('slotEditList'); - const bindDrop = (row) => row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove()); - [...list.children].forEach(bindDrop); + const bindRow = (row) => { + row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove()); + const any = row.querySelector('.slot-any-box'); + const colour = row.querySelector('.slot-colour'); + any.addEventListener('change', () => { colour.disabled = any.checked; }); + }; + [...list.children].forEach(bindRow); document.getElementById('slotAdd').addEventListener('click', () => { list.insertAdjacentHTML('beforeend', rowHtml()); - bindDrop(list.lastElementChild); + bindRow(list.lastElementChild); }); document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model)); document.getElementById('slotSave').addEventListener('click', async () => { const filaments = [...list.children].map((row) => ({ type: row.querySelector('.slot-material').value.trim(), - colour: row.querySelector('.slot-colour').value, + // "any colour" sends nothing, which the matcher reads as any spool of + // that material. + colour: row.querySelector('.slot-any-box').checked ? null : row.querySelector('.slot-colour').value, })).filter((f) => f.type); const status = document.getElementById('slotStatus'); try { diff --git a/public/styles.css b/public/styles.css index 37810f2..0517591 100644 --- a/public/styles.css +++ b/public/styles.css @@ -468,6 +468,9 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut .slot-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } .slot-edit .slot-material { width: 110px; padding: 5px 8px; font-size: 13px; } .slot-edit .slot-colour { width: 42px; padding: 2px; } +.slot-edit .slot-colour:disabled { opacity: 0.35; } +.slot-any { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); } +.slot-any input { width: auto; accent-color: var(--accent); } .print-badge.yours { background: color-mix(in srgb, #6366f1 20%, transparent); color: #a5b4fc; } .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; } diff --git a/test/filaments.test.js b/test/filaments.test.js index c57e078..d2e26b5 100644 --- a/test/filaments.test.js +++ b/test/filaments.test.js @@ -105,3 +105,28 @@ test('anything that isn’t a list of slots is refused outright', () => { assert.equal(filaments.cleanSlots({ type: 'PLA' }), null); assert.deepEqual(filaments.cleanSlots([]), []); }); + +test('"any colour" survives the trip from the page to the database', () => { + // What the editor sends when the box is ticked, plus the two shapes that can + // arrive from older clients or a hand-made request. + assert.deepEqual(filaments.cleanSlots([ + { type: 'PLA', colour: null }, + { type: 'PETG', colour: '' }, + { type: 'ABS' }, + ]), [ + { type: 'PLA', colour: null }, + { type: 'PETG', colour: null }, + { type: 'ABS', colour: null }, + ]); +}); + +test('a slot with no colour is filled by any spool of that material', () => { + const anyWhite = [{ type: 'PLA', colour: null }]; + assert.equal(filaments.isPrintable(anyWhite, + [{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), true); + // …but still not by the wrong material, and still one spool per slot. + assert.equal(filaments.isPrintable(anyWhite, + [{ id: 1, material: 'PETG', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false); + assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }, { type: 'PLA', colour: null }], + [{ id: 1, material: 'PLA', colour_hex: '#123456', colour: 'Anything', remaining_g: 200 }]), false); +});