Let a slot ask for any colour

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 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-17 13:19:47 +10:00
parent eaf1bd93d5
commit 8f3e63585c
4 changed files with 47 additions and 7 deletions

View file

@ -27,7 +27,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images. - **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. - **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. - **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). - **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. - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.

View file

@ -2180,10 +2180,15 @@ function renderDetailPrintable(model) {
// reader stops overwriting them from the project file. // reader stops overwriting them from the project file.
function editSlots(model, slots) { function editSlots(model, slots) {
const el = document.getElementById('detailPrintable'); 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 }) => `
<div class="slot-row slot-edit"> <div class="slot-row slot-edit">
<input type="text" class="slot-material" value="${escapeHtml(s.material || 'PLA')}" aria-label="Material" placeholder="PLA"> <input type="text" class="slot-material" value="${escapeHtml(s.material || 'PLA')}" aria-label="Material" placeholder="PLA">
<input type="color" class="slot-colour" value="${escapeHtml((s.colour || '#ffffff').toLowerCase())}" aria-label="Colour"> <input type="color" class="slot-colour" value="${escapeHtml((s.colour || '#ffffff').toLowerCase())}" aria-label="Colour"${s.colour ? '' : ' disabled'}>
<label class="slot-any" title="Any colour of this material will do">
<input type="checkbox" class="slot-any-box"${s.colour ? '' : ' checked'}> any colour
</label>
<button type="button" class="link-btn" data-slot-drop>remove</button> <button type="button" class="link-btn" data-slot-drop>remove</button>
</div>`; </div>`;
el.innerHTML = ` el.innerHTML = `
@ -2196,17 +2201,24 @@ function editSlots(model, slots) {
<div class="lic-note" id="slotStatus">Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.</div>`; <div class="lic-note" id="slotStatus">Colour is matched to your spools; a slot left at a colour you don’t own shows as missing.</div>`;
const list = document.getElementById('slotEditList'); const list = document.getElementById('slotEditList');
const bindDrop = (row) => row.querySelector('[data-slot-drop]').addEventListener('click', () => row.remove()); const bindRow = (row) => {
[...list.children].forEach(bindDrop); 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', () => { document.getElementById('slotAdd').addEventListener('click', () => {
list.insertAdjacentHTML('beforeend', rowHtml()); list.insertAdjacentHTML('beforeend', rowHtml());
bindDrop(list.lastElementChild); bindRow(list.lastElementChild);
}); });
document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model)); document.getElementById('slotCancel').addEventListener('click', () => renderDetailPrintable(findModel(model.id) || model));
document.getElementById('slotSave').addEventListener('click', async () => { document.getElementById('slotSave').addEventListener('click', async () => {
const filaments = [...list.children].map((row) => ({ const filaments = [...list.children].map((row) => ({
type: row.querySelector('.slot-material').value.trim(), 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); })).filter((f) => f.type);
const status = document.getElementById('slotStatus'); const status = document.getElementById('slotStatus');
try { try {

View file

@ -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-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-material { width: 110px; padding: 5px 8px; font-size: 13px; }
.slot-edit .slot-colour { width: 42px; padding: 2px; } .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; } .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; } .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 { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }

View file

@ -105,3 +105,28 @@ test('anything that isn’t a list of slots is refused outright', () => {
assert.equal(filaments.cleanSlots({ type: 'PLA' }), null); assert.equal(filaments.cleanSlots({ type: 'PLA' }), null);
assert.deepEqual(filaments.cleanSlots([]), []); 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);
});