Compare commits

..

No commits in common. "5b559326e38b5624a3981439bcb40d0f2c2cdcbc" and "c0c5e3300a36f245a98c9670bd5f7f091e3ff2e1" have entirely different histories.

10 changed files with 3 additions and 391 deletions

View file

@ -263,27 +263,3 @@ 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

View file

@ -34,14 +34,4 @@ async function readProjectInfo(inputPath, imagesDir) {
'reading this 3MF project');
}
// 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 };
module.exports = { convert3mfToGlb, readProjectInfo };

View file

@ -1,85 +0,0 @@
// 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 };

View file

@ -213,11 +213,8 @@ 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 filaments.run()];
await thumbnails.generate(), await autotag.run(), await embeddings.run()];
if (!worked.some(Boolean)) return;
}
}

View file

@ -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.filaments, m.filaments_status,
m.notes, m.deleted_at, m.ai_tagged_at, m.original_name,
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,45 +656,6 @@ 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,
@ -705,7 +666,6 @@ 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,

View file

@ -333,7 +333,6 @@ 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';
}
@ -357,7 +356,6 @@ 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();
@ -2174,144 +2172,6 @@ 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;

View file

@ -102,10 +102,6 @@
<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>

View file

@ -464,9 +464,6 @@ 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); }

View file

@ -28,7 +28,6 @@ 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();
@ -320,38 +319,6 @@ 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 {

View file

@ -1,46 +0,0 @@
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']);
});