From 5f708e24f5af226b6b138fc6c6c057bf12ce7d7e Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Thu, 17 Sep 2026 11:46:54 +1000 Subject: [PATCH] Show what's printable with the filament you have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three placements: a pill beside the sellable badge on each card, a "Printable now" section between Source and License on the model page, and a toolbar filter for "printable now" / "missing filament". Only the 101 models whose slicer project names its filaments can answer this, so the rest say so plainly rather than being reported as unprintable. Colour is not matched — a different colour still prints. Also fixes a bug from the filament page itself: 'filament' was missing from SPECIAL_SCOPES, so any data refresh while that page was open (after an AI run, say) decided the collection had vanished and dropped you back to All Models. Caveat: the client's printability() mirrors lib/filaments.js by hand. The server side has tests; this copy cannot easily get them, because app.js touches DOM globals at load — so the two can drift. Co-Authored-By: Claude Opus 5 --- public/app.js | 70 +++++++++++++++++++++++++++++++++++++++++++---- public/index.html | 9 ++++++ public/styles.css | 3 ++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/public/app.js b/public/app.js index d8523cc..298efad 100644 --- a/public/app.js +++ b/public/app.js @@ -5,14 +5,17 @@ let projects = []; let config = { importers: {} }; let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 }; let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id +let spools = []; // filament on hand, so cards can say what's printable let tagFilters = {}; // tag -> 'include' | 'exclude' let duplicateData = null; // last /api/duplicates response let duplicatePoll = null; // View preferences, remembered per browser. -const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; +const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', printable: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; const PREFS_KEY = 'printhive.prefs'; -const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch']; +// Scopes that aren't a collection id. Leaving one out means loadData() decides +// the collection vanished and drops you back to All Models on the next refresh. +const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai', 'watch', 'filament']; // DOM const fileGrid = document.getElementById('fileGrid'); @@ -144,16 +147,18 @@ async function handleLogout() { async function loadData() { try { - const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([ + const [modelsRes, projectsRes, configRes, statsRes, filamentRes] = await Promise.all([ fetch('/api/models'), fetch('/api/projects'), fetch('/api/config'), fetch('/api/library/stats'), + fetch('/api/filament'), ]); models = await modelsRes.json(); projects = await projectsRes.json(); config = await configRes.json(); stats = statsRes.ok ? await statsRes.json() : stats; + spools = filamentRes.ok ? (await filamentRes.json()).spools : []; if (selectedScope === 'trash') await loadTrash(); if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) { selectedScope = 'all'; // the open collection was deleted @@ -253,6 +258,30 @@ const SOURCE_LABELS = { const sourceKey = (m) => m.source_site || 'upload'; const sourceLabel = (key) => SOURCE_LABELS[key] || key; const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial_use === false ? 'noncommercial' : 'unknown'); + +// Can you print it with what's on the shelf? Mirrors lib/filaments.js on the +// server: material only (a different colour still prints), an empty spool +// doesn't count, and a model whose filaments were never read is "unknown" +// rather than "no" — only slicer projects name their filaments. +const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' '); + +function printability(m) { + const needed = [...new Set(((m && m.filaments) || []).map((f) => filamentType(f.type)).filter(Boolean))]; + if (!needed.length) return { known: false, needed: [], missing: [], ok: false }; + const onHand = new Set(spools + .filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0) + .map((s) => filamentType(s.material))); + const missing = needed.filter((t) => !onHand.has(t)); + return { known: true, needed, missing, ok: missing.length === 0 }; +} + +function printBadgeHtml(m) { + const p = printability(m); + if (!p.known) return ''; + return p.ok + ? '✔ printable' + : `needs ${escapeHtml(p.missing.join(', '))}`; +} const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0); const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`; @@ -416,6 +445,7 @@ function cardHtml(m) {
${escapeHtml(m.name)}
${m.source_site ? `${escapeHtml(m.source_site)}` : ''} ${m.commercial_use === true ? '$ sellable' : m.commercial_use === false ? 'non-commercial' : ''} + ${printBadgeHtml(m)} ${m.description ? `
${escapeHtml(m.description)}
` : ''} ${(m.tags && m.tags.length) ? `
${m.tags.map((t) => `${escapeHtml(t)}`).join('')}
` : ''}
${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}
@@ -456,6 +486,13 @@ function getFilteredModels() { if (prefs.format) result = result.filter((m) => (m.files || []).some((f) => f.format === prefs.format)); if (prefs.source) result = result.filter((m) => sourceKey(m) === prefs.source); if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license); + // Only models we've actually read filaments for can answer this either way. + if (prefs.printable) { + result = result.filter((m) => { + const p = printability(m); + return p.known && (prefs.printable === 'yes' ? p.ok : !p.ok); + }); + } const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean); if (terms.length) { @@ -699,10 +736,11 @@ function setupEventListeners() { }); searchInput.addEventListener('input', renderModels); - [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], ['sortBy', 'sort'], ['groupBy', 'group']] + [['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], + ['filterPrintable', 'printable'], ['sortBy', 'sort'], ['groupBy', 'group']] .forEach(([id, key]) => { const el = document.getElementById(id); - el.value = prefs[key]; + el.value = prefs[key] || ''; // a preference added later starts undefined el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); }); }); @@ -2011,7 +2049,28 @@ function modelDragTarget(model) { return { url: `/api/models/${model.id}/download`, name: `${(model.name || 'model').replace(/[^\w.-]+/g, '_')}.zip` }; } +// What the shelf says about this model, shown between Source and License. The +// Filaments section further down lists what the project uses; this one answers +// whether you can print it today. +function renderDetailPrintable(model) { + const section = document.getElementById('detailPrintableSection'); + const el = document.getElementById('detailPrintable'); + if (!section || !el) return; + const p = printability(model); + if (!p.known) { + el.innerHTML = '
This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.
'; + return; + } + el.innerHTML = p.ok + ? `
✔ printable now
+
Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.
` + : `
not right now
+
Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}. + Colour isn’t matched — a different colour still prints.
`; +} + function renderDetailLicense(model) { + renderDetailPrintable(model); const com = model.commercial_use; // true / false / null const badge = com === true ? 'Commercial use allowed' @@ -2242,6 +2301,7 @@ async function refreshSpools() { list.innerHTML = `

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

`; return; } + spools = data.spools; // cards and the filter judge against this const spoolCount = document.getElementById('spoolCount'); if (spoolCount) spoolCount.textContent = data.spools.length; list.innerHTML = data.spools.length diff --git a/public/index.html b/public/index.html index 775206b..d3b8705 100644 --- a/public/index.html +++ b/public/index.html @@ -162,6 +162,11 @@ +