diff --git a/db/schema.sql b/db/schema.sql index 94e317d..4311da7 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, -- "//" 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 diff --git a/lib/converter.js b/lib/converter.js index 68f199e..d069ff1 100644 --- a/lib/converter.js +++ b/lib/converter.js @@ -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 }; diff --git a/lib/filaments.js b/lib/filaments.js new file mode 100644 index 0000000..f88aae5 --- /dev/null +++ b/lib/filaments.js @@ -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 }; diff --git a/lib/fingerprint.js b/lib/fingerprint.js index 9a8a56e..9c7e21d 100644 --- a/lib/fingerprint.js +++ b/lib/fingerprint.js @@ -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; } } diff --git a/lib/store.js b/lib/store.js index 516f288..d67012c 100644 --- a/lib/store.js +++ b/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, diff --git a/public/app.js b/public/app.js index 23e0add..d8523cc 100644 --- a/public/app.js +++ b/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 ` +
+

Filament on hand

+

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.

+
+ + + + + + +
+
+
+
`; +} + +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 ` +
+
+ + ${escapeHtml(s.material)} + ${escapeHtml(s.colour || '')} + ${escapeHtml(s.brand || '')}${s.ams_slot ? ` · ${escapeHtml(s.ams_slot)}` : ''} + + + + +
+
${escapeHtml(left)}${escapeHtml(of)}
+
`; +} + +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 = `

Couldn’t load your spools: ${escapeHtml(err.message)}

`; + 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 ? `

${plural(data.pending, 'model')} still to be read for filament.

` : '') + : '

No spools yet. Add what’s on the shelf above.

'; + + // 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; diff --git a/public/index.html b/public/index.html index 5540738..775206b 100644 --- a/public/index.html +++ b/public/index.html @@ -102,6 +102,10 @@ Watched 0 +
+ Filament + 0 +
Trash 0 diff --git a/public/styles.css b/public/styles.css index 0f656ac..a361c07 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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); } diff --git a/server.js b/server.js index a6f0573..276e94c 100644 --- a/server.js +++ b/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 { diff --git a/test/filaments.test.js b/test/filaments.test.js new file mode 100644 index 0000000..052dacb --- /dev/null +++ b/test/filaments.test.js @@ -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']); +});