Track the filament you have, and what each model asks for
A Bambu Studio or OrcaSlicer 3MF names the filaments its plates use, but reading that means shelling out to Python, so it can't be done per page load. A background pass — in the same loop as thumbnails and fingerprints — reads it once per model into models.filaments, which turns "what can I print now" into a comparison of two lists. Adds a Filament page for the spools on hand: material, colour, brand and grams left, edited in place. Matching is on material only; a project's exact colours would make almost nothing printable, and a different colour still prints. Measured on the dev library: 10 models read with no errors, the two Bambu projects resolving to four PLA colours each, and no plate images written because the reader skips --images and queues behind anything interactive. Not done yet: nothing in the library view uses this — no "printable now" badge or filter — and the Filament page itself has not been seen rendered, because the local preview browser has no session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c0c5e3300a
commit
ba3bb415bb
10 changed files with 391 additions and 3 deletions
|
|
@ -263,3 +263,27 @@ CREATE TABLE IF NOT EXISTS watched_collection_items (
|
|||
);
|
||||
|
||||
ALTER TABLE watched_collections ADD COLUMN IF NOT EXISTS fetch_files BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- ------------------------------------------------------------------ filament
|
||||
-- The spools you own. Weights in grams; remaining is what's left on the spool,
|
||||
-- null when you haven't said. ams_slot is filled in later by AMS sync, and
|
||||
-- stays null for spools logged by hand.
|
||||
CREATE TABLE IF NOT EXISTS filament_spools (
|
||||
id SERIAL PRIMARY KEY,
|
||||
material TEXT NOT NULL, -- PLA, PETG, ABS, TPU…
|
||||
colour TEXT NOT NULL DEFAULT '', -- what you call it, e.g. "Bambu Matte Ivory"
|
||||
colour_hex TEXT, -- #RRGGBB when known, to match a project's colours
|
||||
brand TEXT NOT NULL DEFAULT '',
|
||||
total_g INTEGER, -- spool weight when new
|
||||
remaining_g INTEGER, -- what's left; null = unknown
|
||||
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
date_modified TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- What a model asks for, read once from its 3MF project rather than on every
|
||||
-- page: [{ type, colour }]. A background pass fills these in, the way
|
||||
-- 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
|
||||
|
|
|
|||
|
|
@ -34,4 +34,14 @@ async function readProjectInfo(inputPath, imagesDir) {
|
|||
'reading this 3MF project');
|
||||
}
|
||||
|
||||
module.exports = { convert3mfToGlb, readProjectInfo };
|
||||
// Just the filaments a project asks for: no --images, so no plate previews are
|
||||
// written, and queued behind anything someone is waiting on — this runs over the
|
||||
// whole library in the background.
|
||||
async function readFilaments(inputPath) {
|
||||
const info = await parseResult(
|
||||
await runScript('threemf_info.py', [inputPath], { timeout: 60000, isBackground: true }),
|
||||
'reading this 3MF project');
|
||||
return Array.isArray(info.filaments) ? info.filaments : [];
|
||||
}
|
||||
|
||||
module.exports = { convert3mfToGlb, readProjectInfo, readFilaments };
|
||||
|
|
|
|||
85
lib/filaments.js
Normal file
85
lib/filaments.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// What each model asks for, and what you have on hand.
|
||||
//
|
||||
// A Bambu Studio / OrcaSlicer 3MF names the filaments its plates use. Reading
|
||||
// that means shelling out to Python, so it is done once per model in the
|
||||
// background — like thumbnails and fingerprints — and kept in models.filaments.
|
||||
// With that stored, "what can I print right now" is a plain query rather than
|
||||
// hundreds of subprocesses.
|
||||
const db = require('./db');
|
||||
const converter = require('./converter');
|
||||
|
||||
const BATCH = 25;
|
||||
|
||||
// Models that have never been looked at, paired with their first loose 3MF.
|
||||
// A model whose only 3MF sits inside an archive is left for a later pass rather
|
||||
// than paying an extraction for each one.
|
||||
const PENDING = `
|
||||
FROM models m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT f.file_path FROM files f
|
||||
WHERE f.model_id = m.id AND f.format = '3mf'
|
||||
ORDER BY f.id LIMIT 1
|
||||
) f3 ON true
|
||||
WHERE m.deleted_at IS NULL AND m.filaments_status IS NULL`;
|
||||
|
||||
// "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));
|
||||
}
|
||||
|
||||
// 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.
|
||||
function isPrintable(filaments, spools) {
|
||||
if (!filaments || !filaments.length) return false;
|
||||
return missingMaterials(filaments, spools).length === 0;
|
||||
}
|
||||
|
||||
// One pass. Returns whether it did any work, so it fits the background worker
|
||||
// loop in fingerprint.js.
|
||||
async function run() {
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, f3.file_path ${PENDING} ORDER BY m.id LIMIT ${BATCH}`);
|
||||
if (!rows.length) return false;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.file_path) {
|
||||
await mark(row.id, 'none', null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const filaments = await converter.readFilaments(row.file_path);
|
||||
// A plain 3MF mesh names no filaments — that is an answer, not a failure.
|
||||
await mark(row.id, filaments.length ? 'done' : 'none', filaments.length ? filaments : null);
|
||||
} catch (err) {
|
||||
console.warn(`Could not read filaments for model ${row.id}:`, err.message);
|
||||
await mark(row.id, 'error', null);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function mark(modelId, status, filaments) {
|
||||
await db.query('UPDATE models SET filaments = $2, filaments_status = $3 WHERE id = $1',
|
||||
[modelId, filaments ? JSON.stringify(filaments) : null, status]);
|
||||
}
|
||||
|
||||
async function pendingCount() {
|
||||
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`);
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
// Look at every model again — after adding files, say.
|
||||
async function reread() {
|
||||
await db.query('UPDATE models SET filaments_status = NULL');
|
||||
}
|
||||
|
||||
module.exports = { run, pendingCount, reread, missingMaterials, isPrintable, normalise };
|
||||
|
|
@ -213,8 +213,11 @@ async function run() {
|
|||
for (;;) {
|
||||
// Thumbnails, tags and search come after indexing so they can use what's inside
|
||||
// newly indexed archives.
|
||||
// require here rather than at the top: filaments.js pulls in the converter,
|
||||
// and this keeps the module graph one-directional.
|
||||
const filaments = require('./filaments');
|
||||
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(),
|
||||
await thumbnails.generate(), await autotag.run(), await embeddings.run()];
|
||||
await thumbnails.generate(), await autotag.run(), await embeddings.run(), await filaments.run()];
|
||||
if (!worked.some(Boolean)) return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
lib/store.js
42
lib/store.js
|
|
@ -5,7 +5,7 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
|
|||
const MODEL_SELECT = `
|
||||
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
||||
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
||||
m.notes, m.deleted_at, m.ai_tagged_at, m.original_name,
|
||||
m.notes, m.deleted_at, m.ai_tagged_at, m.original_name, m.filaments, m.filaments_status,
|
||||
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
||||
COALESCE(t.tags, '[]'::json) AS tags,
|
||||
COALESCE(t.tag_sources, '{}'::json) AS tag_sources,
|
||||
|
|
@ -656,6 +656,45 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
|
|||
return rows[0].id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filament
|
||||
async function listSpools() {
|
||||
const { rows } = await db.query(
|
||||
`SELECT * FROM filament_spools ORDER BY material, colour, id`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
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 *`,
|
||||
[String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '',
|
||||
data.total_g ?? null, data.remaining_g ?? null, data.notes || '']);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Only the fields given are changed, so a weight edit can't wipe the notes.
|
||||
async function updateSpool(id, data) {
|
||||
const { rows } = await db.query(
|
||||
`UPDATE filament_spools SET
|
||||
material = COALESCE($2, material),
|
||||
colour = COALESCE($3, colour),
|
||||
colour_hex = COALESCE($4, colour_hex),
|
||||
brand = COALESCE($5, brand),
|
||||
total_g = COALESCE($6, total_g),
|
||||
remaining_g = COALESCE($7, remaining_g),
|
||||
notes = COALESCE($8, notes),
|
||||
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]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function deleteSpool(id) {
|
||||
const { rowCount } = await db.query('DELETE FROM filament_spools WHERE id = $1', [id]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
||||
updateFileConversion,
|
||||
|
|
@ -666,6 +705,7 @@ module.exports = {
|
|||
suggestCollections, collectionNameHints, listCollectionSuggestions, acceptCollectionSuggestion, dismissCollectionSuggestion,
|
||||
getAllProjects, getProject, createProject, updateProject, deleteProject, findOrCreateProject, addModelToProject,
|
||||
getSetting, setSetting,
|
||||
listSpools, createSpool, updateSpool, deleteSpool,
|
||||
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
||||
createPasswordReset, getValidReset, markResetUsed,
|
||||
getLicenseRule, upsertLicenseRule, listLicenseRules, distinctModelLicenses, applyLicenseRuleToModels,
|
||||
|
|
|
|||
140
public/app.js
140
public/app.js
|
|
@ -333,6 +333,7 @@ function scopeTitle() {
|
|||
if (selectedScope === 'trash') return 'Trash';
|
||||
if (selectedScope === 'ai') return 'AI & automatic tags';
|
||||
if (selectedScope === 'watch') return 'Watched collections';
|
||||
if (selectedScope === 'filament') return 'Filament';
|
||||
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
||||
return p ? p.name : 'Collection';
|
||||
}
|
||||
|
|
@ -356,6 +357,7 @@ function renderModels() {
|
|||
if (selectedScope === 'ai') { clearTimeout(watchPoll); renderAiPanel(); return; }
|
||||
clearTimeout(aiPoll);
|
||||
if (selectedScope === 'watch') { renderWatchPanel(); return; }
|
||||
if (selectedScope === 'filament') { renderFilamentPanel(); return; }
|
||||
clearTimeout(watchPoll);
|
||||
|
||||
const list = getFilteredModels();
|
||||
|
|
@ -2172,6 +2174,144 @@ async function retryModelDownload(id) {
|
|||
return { ok: true, message: data.added ? `Downloaded ${plural(data.added, 'file')}.` : 'Token saved. No new files were found.' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- filament
|
||||
// The spools you have. What a model needs is read from its 3MF in the
|
||||
// background, so "what can I print now" compares two lists rather than opening
|
||||
// files. Everything here binds straight after rendering — no delegation — so a
|
||||
// button that appears always has its handler.
|
||||
|
||||
function filamentPanelHtml() {
|
||||
return `
|
||||
<section class="ai-card">
|
||||
<div class="ai-card-head"><h3>Filament on hand</h3></div>
|
||||
<p class="form-hint">Add the spools you have. Models that came from a Bambu Studio or OrcaSlicer project
|
||||
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.</p>
|
||||
<form id="spoolForm" class="watch-form">
|
||||
<input type="text" id="spoolMaterial" placeholder="Material (PLA, PETG…)" aria-label="Material" required>
|
||||
<input type="text" id="spoolColour" placeholder="Colour name" aria-label="Colour">
|
||||
<input type="color" id="spoolHex" value="#22c55e" aria-label="Colour swatch" title="Colour">
|
||||
<input type="text" id="spoolBrand" placeholder="Brand" aria-label="Brand">
|
||||
<input type="number" id="spoolRemaining" placeholder="Grams left" aria-label="Grams left" min="0" step="10">
|
||||
<button type="submit" class="btn btn-primary" id="spoolSubmit">Add spool</button>
|
||||
</form>
|
||||
<div id="spoolStatus" class="form-hint"></div>
|
||||
<div id="spoolList" class="watch-list"></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function spoolHtml(s) {
|
||||
const left = s.remaining_g == null ? 'amount unknown' : `${count(s.remaining_g)} g left`;
|
||||
const of = s.total_g ? ` of ${count(s.total_g)} g` : '';
|
||||
return `
|
||||
<div class="watch spool" data-spool-id="${s.id}">
|
||||
<div class="watch-head">
|
||||
<span class="spool-dot" style="background:${escapeHtml(s.colour_hex || '#888')}"></span>
|
||||
<strong>${escapeHtml(s.material)}</strong>
|
||||
<span>${escapeHtml(s.colour || '')}</span>
|
||||
<span class="form-hint">${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(s.ams_slot)}` : ''}</span>
|
||||
<span class="watch-actions">
|
||||
<label class="form-hint">grams left
|
||||
<input type="number" class="spool-remaining" value="${s.remaining_g ?? ''}" min="0" step="10" aria-label="Grams left">
|
||||
</label>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-spool="remove">Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="watch-meta">${escapeHtml(left)}${escapeHtml(of)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function renderFilamentPanel() {
|
||||
fileGrid.className = 'ai-panel';
|
||||
resultCount.textContent = '';
|
||||
if (!document.getElementById('spoolForm')) {
|
||||
fileGrid.innerHTML = filamentPanelHtml();
|
||||
document.getElementById('spoolForm').addEventListener('submit', handleAddSpool);
|
||||
}
|
||||
await refreshSpools();
|
||||
}
|
||||
|
||||
async function refreshSpools() {
|
||||
if (selectedScope !== 'filament') return;
|
||||
const list = document.getElementById('spoolList');
|
||||
if (!list) return;
|
||||
let data;
|
||||
try {
|
||||
data = await getJson('/api/filament');
|
||||
} catch (err) {
|
||||
list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
const spoolCount = document.getElementById('spoolCount');
|
||||
if (spoolCount) spoolCount.textContent = data.spools.length;
|
||||
list.innerHTML = data.spools.length
|
||||
? data.spools.map(spoolHtml).join('')
|
||||
+ (data.pending ? `<p class="form-hint">${plural(data.pending, 'model')} still to be read for filament.</p>` : '')
|
||||
: '<p class="form-hint">No spools yet. Add what’s on the shelf above.</p>';
|
||||
|
||||
// Bound here, on the elements just drawn, so nothing depends on delegation.
|
||||
for (const row of list.querySelectorAll('[data-spool-id]')) {
|
||||
const id = Number(row.dataset.spoolId);
|
||||
row.querySelector('[data-spool="remove"]').addEventListener('click', () => removeSpool(id, row));
|
||||
row.querySelector('.spool-remaining').addEventListener('change', (e) => setSpoolRemaining(id, e.target.value));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSpool(e) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('spoolStatus');
|
||||
const submit = document.getElementById('spoolSubmit');
|
||||
const material = document.getElementById('spoolMaterial').value.trim();
|
||||
if (!material) return;
|
||||
submit.disabled = true;
|
||||
status.classList.remove('is-error');
|
||||
status.textContent = '';
|
||||
try {
|
||||
const remaining = document.getElementById('spoolRemaining').value;
|
||||
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),
|
||||
});
|
||||
document.getElementById('spoolForm').reset();
|
||||
document.getElementById('spoolMaterial').focus();
|
||||
await refreshSpools();
|
||||
} catch (err) {
|
||||
status.classList.add('is-error');
|
||||
status.textContent = err.message;
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSpool(id, row) {
|
||||
if (!confirm('Remove this spool?')) return;
|
||||
try {
|
||||
await getJson(`/api/filament/${id}`, { method: 'DELETE' });
|
||||
await refreshSpools();
|
||||
} catch (err) {
|
||||
const status = document.getElementById('spoolStatus');
|
||||
status.classList.add('is-error');
|
||||
status.textContent = err.message;
|
||||
if (row) row.classList.remove('is-busy');
|
||||
}
|
||||
}
|
||||
|
||||
async function setSpoolRemaining(id, value) {
|
||||
const status = document.getElementById('spoolStatus');
|
||||
try {
|
||||
await postJson(`/api/filament/${id}`, { remaining_g: value === '' ? null : Number(value) }, 'PUT');
|
||||
status.classList.remove('is-error');
|
||||
status.textContent = 'Saved.';
|
||||
await refreshSpools();
|
||||
} catch (err) {
|
||||
status.classList.add('is-error');
|
||||
status.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- watched collections
|
||||
let watchPoll = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@
|
|||
<span>Watched</span>
|
||||
<span class="count" id="watchCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="filament">
|
||||
<span>Filament</span>
|
||||
<span class="count" id="spoolCount">0</span>
|
||||
</div>
|
||||
<div class="project-item" data-scope="trash">
|
||||
<span>Trash</span>
|
||||
<span class="count" id="trashCount">0</span>
|
||||
|
|
|
|||
|
|
@ -464,6 +464,9 @@ 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; }
|
||||
.spool-dot { width: 14px; height: 14px; border-radius: 50%; border: 1px solid var(--border); display: inline-block; }
|
||||
.spool .watch-actions label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.spool .spool-remaining { width: 90px; padding: 5px 8px; font-size: 13px; }
|
||||
.ai-failures summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
|
||||
.ai-failures summary:hover { color: var(--text); }
|
||||
.watch-pending summary { cursor: pointer; font-size: 13px; color: var(--text-muted); }
|
||||
|
|
|
|||
33
server.js
33
server.js
|
|
@ -28,6 +28,7 @@ const ai = require('./lib/ai');
|
|||
const aiRuns = require('./lib/aiRuns');
|
||||
const collectionWatch = require('./lib/collectionWatch');
|
||||
const autotag = require('./lib/autotag');
|
||||
const filaments = require('./lib/filaments');
|
||||
const embeddings = require('./lib/embeddings');
|
||||
|
||||
const app = express();
|
||||
|
|
@ -319,6 +320,38 @@ app.put('/api/settings', async (req, res, next) => {
|
|||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- 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.
|
||||
app.get('/api/filament', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ spools: await store.listSpools(), pending: await filaments.pendingCount() });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.post('/api/filament', async (req, res, next) => {
|
||||
try {
|
||||
const material = String((req.body || {}).material || '').trim();
|
||||
if (!material) return res.status(400).json({ error: 'A spool needs a material (PLA, PETG, …).' });
|
||||
res.status(201).json(await store.createSpool({ ...req.body, material }));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.put('/api/filament/:id', async (req, res, next) => {
|
||||
try {
|
||||
const spool = await store.updateSpool(Number(req.params.id), req.body || {});
|
||||
if (!spool) return res.status(404).json({ error: 'No such spool' });
|
||||
res.json(spool);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
app.delete('/api/filament/:id', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await store.deleteSpool(Number(req.params.id)))) return res.status(404).json({ error: 'No such spool' });
|
||||
res.json({ message: 'Spool removed' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------- models
|
||||
app.get('/api/models', async (req, res, next) => {
|
||||
try {
|
||||
|
|
|
|||
46
test/filaments.test.js
Normal file
46
test/filaments.test.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 }));
|
||||
|
||||
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('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('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('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')), []);
|
||||
});
|
||||
|
||||
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' }]), []);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
Loading…
Reference in a new issue