Let the required colours be edited on the model page

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 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-17 13:15:48 +10:00
parent c81dc68c0b
commit eaf1bd93d5
6 changed files with 149 additions and 6 deletions

View file

@ -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,
};

View file

@ -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,

View file

@ -2146,6 +2146,7 @@ function renderDetailPrintable(model) {
el.innerHTML = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
const yours = model.filaments_status === 'edited';
const rows = p.slots.map((s, i) => `
<div class="slot-row">
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
@ -2161,9 +2162,72 @@ function renderDetailPrintable(model) {
</div>`).join('');
el.innerHTML = `<div>${p.ok
? '<span class="print-badge ok">✔ printable now</span>'
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}</div>
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}
${yours ? '<span class="print-badge yours" title="You set these slots; the project’s own list is different">your list</span>' : ''}</div>
<div class="slot-list">${rows}</div>
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>`;
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>
<div class="slot-actions">
<button type="button" class="btn btn-secondary btn-sm" data-slots="edit">Edit slots</button>
${yours ? '<button type="button" class="btn btn-secondary btn-sm" data-slots="revert">Use the project’s list</button>' : ''}
</div>`;
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' }) => `
<div class="slot-row slot-edit">
<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">
<button type="button" class="link-btn" data-slot-drop>remove</button>
</div>`;
el.innerHTML = `
<div class="slot-list" id="slotEditList">${slots.map(rowHtml).join('')}</div>
<div class="slot-actions">
<button type="button" class="btn btn-secondary btn-sm" id="slotAdd">Add a slot</button>
<button type="button" class="btn btn-primary btn-sm" id="slotSave">Save</button>
<button type="button" class="btn btn-secondary btn-sm" id="slotCancel">Cancel</button>
</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 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) {

View file

@ -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; }

View file

@ -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);

View file

@ -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([]), []);
});