Show what's printable with the filament you have

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 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-17 11:46:54 +10:00
parent 5b559326e3
commit 5f708e24f5
3 changed files with 77 additions and 5 deletions

View file

@ -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
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>'
: `<span class="print-badge no" title="Not on the shelf: ${escapeHtml(p.missing.join(', '))}">needs ${escapeHtml(p.missing.join(', '))}</span>`;
}
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) {
<div class="file-name">${escapeHtml(m.name)}</div>
${m.source_site ? `<span class="source-badge">${escapeHtml(m.source_site)}</span>` : ''}
${m.commercial_use === true ? '<span class="com-badge ok" title="Commercial use allowed">$ sellable</span>' : m.commercial_use === false ? '<span class="com-badge no" title="Non-commercial only">non-commercial</span>' : ''}
${printBadgeHtml(m)}
${m.description ? `<div class="file-description">${escapeHtml(m.description)}</div>` : ''}
${(m.tags && m.tags.length) ? `<div class="file-tags">${m.tags.map((t) => `<span class="file-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
@ -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 = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
el.innerHTML = p.ok
? `<div><span class="print-badge ok">✔ printable now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.</div>`
: `<div><span class="print-badge no">not right now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}.
Colour isn’t matched — a different colour still prints.</div>`;
}
function renderDetailLicense(model) {
renderDetailPrintable(model);
const com = model.commercial_use; // true / false / null
const badge = com === true
? '<span class="lic-badge ok">Commercial use allowed</span>'
@ -2242,6 +2301,7 @@ async function refreshSpools() {
list.innerHTML = `<p class="form-hint">Couldn’t load your spools: ${escapeHtml(err.message)}</p>`;
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

View file

@ -162,6 +162,11 @@
<option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option>
</select>
<select id="filterPrintable" aria-label="Filter by what you can print">
<option value="">Any filament</option>
<option value="yes">Printable now</option>
<option value="no">Missing filament</option>
</select>
<select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
@ -505,6 +510,10 @@
<h3>Source</h3>
<div id="detailSource"></div>
</div>
<div class="info-section" id="detailPrintableSection">
<h3>Printable now</h3>
<div id="detailPrintable"></div>
</div>
<div class="info-section">
<h3>License</h3>
<div id="detailLicense"></div>

View file

@ -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; }
.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; }
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }
.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; }