Add duplicate finder, trash, notes, and library search/filters

- Fingerprint every file in the background: SHA-256 for all formats plus a
  trimesh geometry fingerprint for STL/OBJ/3MF, so renamed, re-exported or
  converted copies are still matched. Backfills existing files on startup.
- Duplicates view groups models that share files (exact copies vs same
  shape), recommends which to keep, merges tags/collections/notes into the
  kept model and trashes the rest; groups can be dismissed.
- Deleting now moves models to a restorable trash; files are only removed
  from disk on permanent delete or when the trash is emptied.
- Per-model notes with autosave.
- Search across names, notes, tags, files and designers; filter by format,
  source and license; include/exclude tags with any/all matching; sort and
  group by source or format. Preferences persist per browser.
- Add node:test unit tests for duplicate grouping and fingerprints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-14 23:06:09 +10:00
parent e141d800c2
commit 00a4b4ba07
12 changed files with 1203 additions and 128 deletions

View file

@ -15,19 +15,23 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
- **Collections** — group models (a model can be in many); rename/delete without losing models.
- **Editing** — rename/retag, edit print settings, override commercial-use, refresh metadata from source, re-download files, download all files as a zip.
- **Notes** — free-form notes per model, saved as you type.
- **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press <kbd>/</kbd> to search.
- **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates".
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
## Tech stack
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
- Three.js (viewer) via CDN import map.
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion and volume.
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion, volume, and geometry fingerprints.
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
## Setup
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
2. **Python sidecar** (for 3MF previews/volume):
2. **Python sidecar** (for 3MF previews/volume and shape-based duplicate detection — without it, duplicates are still found by content hash):
```bash
python3 -m venv tools/venv
tools/venv/bin/pip install trimesh numpy networkx lxml
@ -40,6 +44,8 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
```
4. Open http://localhost:3000
Run the unit tests with `npm test`.
## Configuration
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:

View file

@ -107,3 +107,25 @@ CREATE INDEX IF NOT EXISTS idx_files_model ON files(model_id);
CREATE INDEX IF NOT EXISTS idx_model_projects_p ON model_projects(project_id);
CREATE INDEX IF NOT EXISTS idx_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_id);
-- Content + geometry fingerprints for the duplicate finder (filled in the
-- background by lib/fingerprint.js).
ALTER TABLE files ADD COLUMN IF NOT EXISTS sha256 TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_hash TEXT;
-- null = not checked yet; ok | error | missing | na (format has no geometry fingerprint)
ALTER TABLE files ADD COLUMN IF NOT EXISTS geometry_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS fingerprinted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_files_sha256 ON files(sha256);
CREATE INDEX IF NOT EXISTS idx_files_geometry ON files(geometry_hash);
-- Free-form notes per model.
ALTER TABLE models ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
-- Soft delete: set when a model is moved to the trash.
ALTER TABLE models ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_models_deleted ON models(deleted_at);
-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
CREATE TABLE IF NOT EXISTS duplicate_dismissals (
signature TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

86
lib/duplicates.js Normal file
View file

@ -0,0 +1,86 @@
// Groups models that hold the same files. Two models are linked when they share
// a file with the same content hash (exact copy) or the same geometry
// fingerprint (same shape: renamed, re-exported, converted — or mirrored, so
// those groups are flagged for review). Links are transitive, so a pack
// downloaded twice collapses into one group.
// files: [{ id, model_id, sha256, geometry_hash }]
// Returns [{ modelIds: [sorted], exact: boolean, matchedFiles: { modelId: count } }]
function groupDuplicates(files) {
const parent = new Map();
const find = (x) => {
while (parent.get(x) !== x) {
parent.set(x, parent.get(parent.get(x)));
x = parent.get(x);
}
return x;
};
for (const f of files) parent.set(f.model_id, f.model_id);
const matched = new Map(); // modelId -> Set(fileId)
const inexact = []; // a model in each shape-only link
const buckets = (key) => {
const map = new Map();
for (const f of files) {
if (!f[key]) continue;
if (!map.has(f[key])) map.set(f[key], []);
map.get(f[key]).push(f);
}
return map.values();
};
const link = (bucket, exact) => {
const modelIds = [...new Set(bucket.map((f) => f.model_id))];
if (modelIds.length < 2) return;
for (const f of bucket) {
if (!matched.has(f.model_id)) matched.set(f.model_id, new Set());
matched.get(f.model_id).add(f.id);
}
for (const id of modelIds.slice(1)) parent.set(find(id), find(modelIds[0]));
if (!exact) inexact.push(modelIds[0]);
};
for (const bucket of buckets('sha256')) link(bucket, true);
for (const bucket of buckets('geometry_hash')) {
// Same shape but different bytes somewhere in the bucket = not a plain copy.
link(bucket, new Set(bucket.map((f) => f.sha256)).size === 1);
}
const groups = new Map();
for (const id of matched.keys()) {
const root = find(id);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(id);
}
const inexactRoots = new Set(inexact.map(find));
return [...groups.entries()]
.filter(([, ids]) => ids.length > 1)
.map(([root, ids]) => ({
modelIds: ids.sort((a, b) => a - b),
exact: !inexactRoots.has(root),
matchedFiles: Object.fromEntries(ids.map((id) => [id, matched.get(id).size])),
}));
}
// Stable id for a group of models (used to remember "not duplicates").
function signature(modelIds) {
return [...new Set(modelIds.map(Number))].sort((a, b) => a - b).join(',');
}
function keepScore(m) {
return (m.source_url ? 4 : 0) + (m.license ? 2 : 0) + (m.notes ? 2 : 0) + (m.description ? 1 : 0)
+ Math.min((m.tags || []).length, 3) + Math.min((m.projects || []).length, 3) + (m.files || []).length;
}
// Which model of a group to keep: the most complete one (source, license, notes,
// tags, collections, most files), then the one added first.
function recommendKeep(models) {
return [...models].sort((a, b) =>
keepScore(b) - keepScore(a)
|| new Date(a.date_added) - new Date(b.date_added)
|| a.id - b.id)[0].id;
}
module.exports = { groupDuplicates, signature, recommendKeep };

145
lib/fingerprint.js Normal file
View file

@ -0,0 +1,145 @@
// Background worker that fingerprints stored files for the duplicate finder: a
// SHA-256 of the bytes for every file, plus a geometry fingerprint for mesh
// formats so the same model still matches after being renamed, re-exported
// (ASCII/binary STL) or converted (STL -> 3MF).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const db = require('./db');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const SCRIPT = path.join(__dirname, '..', 'tools', 'fingerprint.py');
const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
const BATCH = 10;
let pythonAvailable = true;
let running = false;
let rerun = false;
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
fs.createReadStream(filePath)
.on('error', reject)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')));
});
}
// n rounded to `digits` significant figures, as a stable string.
function sig(n, digits = 4) {
if (!Number.isFinite(n) || n === 0) return '0';
return String(Number(n.toPrecision(digits)));
}
// Hash of rounded shape measurements. Position and axis-aligned rotation don't
// change it, and rounding absorbs float noise between export formats.
function geometryKey(fp) {
if (!fp || !fp.faces) return null;
const extents = (fp.extents || []).map((e) => Math.abs(e)).sort((a, b) => a - b).map((e) => e.toFixed(1));
const raw = `${fp.faces}|${sig(fp.area)}|${sig(Math.abs(fp.volume || 0))}|${extents.join('x')}`;
return crypto.createHash('sha1').update(raw).digest('hex');
}
// Runs the trimesh sidecar over some files. Resolves Map(absPath -> {fp}|{error});
// output from a run that timed out or crashed part-way is still returned.
function measure(paths) {
return new Promise((resolve, reject) => {
execFile(
PYTHON,
[SCRIPT, ...paths],
{ maxBuffer: 4 * 1024 * 1024, timeout: 60000 * (paths.length + 1) },
(err, stdout) => {
if (err && err.code === 'ENOENT') return reject(err);
const results = new Map();
for (const line of String(stdout || '').split('\n')) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line);
results.set(r.path, r);
} catch { /* ignore non-JSON noise */ }
}
resolve(results);
}
);
});
}
async function fingerprintGeometry(rows) {
const abs = (row) => path.resolve(row.file_path);
const results = await measure(rows.map(abs));
for (const row of rows) {
let r = results.get(abs(row));
// A batch that died part-way: retry the missing file on its own.
if (!r && rows.length > 1) r = (await measure([abs(row)])).get(abs(row));
const key = r && r.fp ? geometryKey(r.fp) : null;
await db.query('UPDATE files SET geometry_hash = $2, geometry_status = $3 WHERE id = $1',
[row.id, key, key ? 'ok' : 'error']);
}
}
async function run() {
for (;;) {
const { rows } = await db.query(
`SELECT id, file_path, format, fingerprinted_at FROM files
WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)
ORDER BY id LIMIT $2`, [pythonAvailable, BATCH]);
if (rows.length === 0) return;
const geometry = [];
for (const row of rows) {
const abs = path.resolve(row.file_path);
try {
if (!fs.existsSync(abs)) {
await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'missing' WHERE id = $1`, [row.id]);
continue;
}
if (!row.fingerprinted_at) {
await db.query('UPDATE files SET sha256 = $2, fingerprinted_at = now() WHERE id = $1',
[row.id, await sha256File(abs)]);
}
if (!GEOMETRY_FORMATS.includes(row.format)) {
await db.query(`UPDATE files SET geometry_status = 'na' WHERE id = $1`, [row.id]);
} else if (pythonAvailable) {
geometry.push(row);
}
} catch (err) {
console.warn(`Fingerprint failed for file ${row.id}:`, err.message);
await db.query(`UPDATE files SET fingerprinted_at = now(), geometry_status = 'error' WHERE id = $1`, [row.id]);
}
}
if (geometry.length) {
try {
await fingerprintGeometry(geometry);
} catch (err) {
if (err.code !== 'ENOENT') throw err;
pythonAvailable = false;
console.warn(`Geometry fingerprints disabled: no Python at ${PYTHON} (content hashes still run).`);
}
}
}
}
// Start (or queue another pass of) the worker. Safe to call often.
function kick() {
if (running) { rerun = true; return; }
running = true;
run()
.catch((err) => console.error('Fingerprinting stopped:', err.message))
.finally(() => {
running = false;
if (rerun) { rerun = false; kick(); }
});
}
async function pendingCount() {
const { rows } = await db.query(
`SELECT COUNT(*)::int AS n FROM files
WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean)`, [pythonAvailable]);
return rows[0].n;
}
module.exports = { kick, pendingCount, geometryKey, sha256File };

View file

@ -4,6 +4,7 @@ const db = require('./db');
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,
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
COALESCE(t.tags, '[]'::json) AS tags,
COALESCE(p.projects, '[]'::json) AS projects,
@ -39,8 +40,11 @@ function shapeModel(row) {
}
// ---------------------------------------------------------------- models
async function getAllModels() {
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
// The library, or with { trash: true } the models in the trash.
async function getAllModels({ trash = false } = {}) {
const { rows } = await db.query(trash
? `${MODEL_SELECT} WHERE m.deleted_at IS NOT NULL ORDER BY m.deleted_at DESC`
: `${MODEL_SELECT} WHERE m.deleted_at IS NULL ORDER BY m.date_added DESC`);
return rows.map(shapeModel);
}
@ -49,9 +53,15 @@ async function getModel(id) {
return shapeModel(rows[0]);
}
async function getModelsByIds(ids) {
if (!ids.length) return [];
const { rows } = await db.query(`${MODEL_SELECT} WHERE m.id = ANY($1::int[])`, [ids]);
return rows.map(shapeModel);
}
async function findModelBySource(site, sourceId) {
const { rows } = await db.query(
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2 AND m.deleted_at IS NULL`, [site, sourceId]);
return shapeModel(rows[0]);
}
@ -87,7 +97,7 @@ async function updateModel(id, updates) {
const fields = [];
const values = [];
let i = 1;
for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
}
if (updates.print_settings !== undefined) {
@ -117,6 +127,78 @@ async function deleteModel(id) {
};
}
// ---------------------------------------------------------------- trash
async function trashModels(ids) {
const { rowCount } = await db.query(
'UPDATE models SET deleted_at = now() WHERE id = ANY($1::int[]) AND deleted_at IS NULL', [ids]);
return rowCount;
}
async function restoreModel(id) {
const { rowCount } = await db.query(
'UPDATE models SET deleted_at = NULL WHERE id = $1 AND deleted_at IS NOT NULL', [id]);
return rowCount > 0;
}
async function trashedModelIds() {
const { rows } = await db.query('SELECT id FROM models WHERE deleted_at IS NOT NULL');
return rows.map((r) => r.id);
}
async function countTrashed() {
const { rows } = await db.query('SELECT COUNT(*)::int AS n FROM models WHERE deleted_at IS NOT NULL');
return rows[0].n;
}
// ---------------------------------------------------------------- duplicates
// Files of non-trashed models whose content hash or geometry fingerprint also
// appears in another non-trashed model — the input to the duplicate finder.
async function duplicateCandidateFiles() {
const { rows } = await db.query(`
WITH live AS (
SELECT f.id, f.model_id, f.sha256, f.geometry_hash
FROM files f JOIN models m ON m.id = f.model_id
WHERE m.deleted_at IS NULL AND f.size > 0
)
SELECT * FROM live
WHERE sha256 IN (SELECT sha256 FROM live WHERE sha256 IS NOT NULL
GROUP BY sha256 HAVING COUNT(DISTINCT model_id) > 1)
OR geometry_hash IN (SELECT geometry_hash FROM live WHERE geometry_hash IS NOT NULL
GROUP BY geometry_hash HAVING COUNT(DISTINCT model_id) > 1)`);
return rows;
}
// Keep one model of a duplicate group: fold the others' tags, collections and
// notes into it, then move the others to the trash.
async function mergeAndTrash(keepId, otherIds) {
await db.transaction(async (client) => {
await client.query(
`INSERT INTO model_tags (model_id, tag_id)
SELECT $1::int, tag_id FROM model_tags WHERE model_id = ANY($2::int[])
ON CONFLICT DO NOTHING`, [keepId, otherIds]);
await client.query(
`INSERT INTO model_projects (model_id, project_id)
SELECT $1::int, project_id FROM model_projects WHERE model_id = ANY($2::int[])
ON CONFLICT DO NOTHING`, [keepId, otherIds]);
const { rows } = await client.query(
'SELECT notes FROM models WHERE id = $1 OR id = ANY($2::int[]) ORDER BY id = $1 DESC, id',
[keepId, otherIds]);
const notes = [...new Set(rows.map((r) => r.notes.trim()).filter(Boolean))].join('\n\n');
await client.query('UPDATE models SET notes = $2, date_modified = now() WHERE id = $1', [keepId, notes]);
await client.query('UPDATE models SET deleted_at = now() WHERE id = ANY($1::int[])', [otherIds]);
});
}
async function dismissDuplicateGroup(signature) {
await db.query(
'INSERT INTO duplicate_dismissals (signature) VALUES ($1) ON CONFLICT DO NOTHING', [signature]);
}
async function dismissedDuplicateSignatures() {
const { rows } = await db.query('SELECT signature FROM duplicate_dismissals');
return rows.map((r) => r.signature);
}
async function addFiles(modelId, files) {
await db.transaction(async (client) => {
for (const file of files) await insertFile(client, modelId, file);
@ -176,9 +258,10 @@ async function setProjects(client, modelId, projectIds) {
async function getAllProjects() {
const { rows } = await db.query(`
SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
COUNT(mp.model_id)::int AS model_count
COUNT(m.id)::int AS model_count
FROM projects p
LEFT JOIN model_projects mp ON mp.project_id = p.id
LEFT JOIN models m ON m.id = mp.model_id AND m.deleted_at IS NULL
GROUP BY p.id ORDER BY p.name`);
return rows;
}
@ -321,8 +404,10 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
}
module.exports = {
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles,
updateFileConversion,
trashModels, restoreModel, trashedModelIds, countTrashed,
duplicateCandidateFiles, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
getAllTags,
getAllProjects, getProject, createProject, updateProject, deleteProject,
getSetting, setSetting,

View file

@ -5,7 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "node --test test/*.test.js"
},
"keywords": [
"stl",

View file

@ -1,16 +1,27 @@
// State
let models = [];
let models = []; // the library (not in the trash)
let trashModels = []; // loaded while the Trash view is open
let projects = [];
let config = { importers: {} };
let selectedProject = 'all';
let selectedTags = [];
let currentView = 'grid';
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
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: '' };
const PREFS_KEY = 'printhive.prefs';
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash'];
// DOM
const fileGrid = document.getElementById('fileGrid');
const projectsList = document.getElementById('projectsList');
const tagsList = document.getElementById('tagsList');
const searchInput = document.getElementById('searchInput');
const contentTitle = document.getElementById('contentTitle');
const resultCount = document.getElementById('resultCount');
const emptyTrashBtn = document.getElementById('emptyTrashBtn');
const uploadModal = document.getElementById('uploadModal');
const projectModal = document.getElementById('projectModal');
const importModal = document.getElementById('importModal');
@ -20,10 +31,19 @@ const editModal = document.getElementById('editModal');
const fileDetailModal = document.getElementById('fileDetailModal');
document.addEventListener('DOMContentLoaded', () => {
loadPrefs();
setupAuthListeners();
bootstrap();
});
function loadPrefs() {
try { Object.assign(prefs, JSON.parse(localStorage.getItem(PREFS_KEY)) || {}); } catch { /* storage unavailable */ }
}
function savePrefs() {
try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* storage unavailable */ }
}
// Decide what to show: the app (if signed in), the reset form (if ?reset=...),
// or the sign-in / create-account screen.
async function bootstrap() {
@ -122,17 +142,24 @@ async function handleLogout() {
async function loadData() {
try {
const [modelsRes, projectsRes, configRes] = await Promise.all([
const [modelsRes, projectsRes, configRes, statsRes] = await Promise.all([
fetch('/api/models'),
fetch('/api/projects'),
fetch('/api/config'),
fetch('/api/library/stats'),
]);
models = await modelsRes.json();
projects = await projectsRes.json();
config = await configRes.json();
stats = statsRes.ok ? await statsRes.json() : stats;
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
}
renderProjects();
renderTags();
renderFilterOptions();
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
@ -146,6 +173,11 @@ async function loadData() {
}
}
async function loadTrash() {
const res = await fetch('/api/models?view=trash');
trashModels = res.ok ? await res.json() : [];
}
// Render thumbnails in the browser for models that don't have one yet, then
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
const thumbAttempted = new Set();
@ -167,93 +199,383 @@ async function generateMissingThumbnails() {
});
if (!res.ok) continue;
const updated = await res.json();
m.thumbnailUrl = updated.thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${m.id}"] .file-thumbnail`);
if (thumb && updated.thumbnailUrl) {
thumb.innerHTML = `<img src="${updated.thumbnailUrl}" alt="" loading="lazy">`;
}
updateCardThumbnail(m.id, updated.thumbnailUrl);
} catch (err) {
console.warn('Thumbnail generation failed for model', m.id, err);
}
}
}
// ---------------------------------------------------------------- lookups
function findModel(id) {
return models.find((m) => String(m.id) === String(id))
|| trashModels.find((m) => String(m.id) === String(id));
}
function replaceModel(updated) {
for (const list of [models, trashModels]) {
const i = list.findIndex((m) => m.id === updated.id);
if (i !== -1) list[i] = updated;
}
}
const SOURCE_LABELS = {
upload: 'Manual upload', thingiverse: 'Thingiverse', printables: 'Printables',
makerworld: 'MakerWorld', cubee3d: 'Cubee3D', web: 'Web',
};
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');
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'}`;
// Everything a search term can match, lower-cased. Cached per model object
// (objects are replaced whenever data reloads, which invalidates the cache).
const searchCache = new WeakMap();
function searchText(m) {
if (!searchCache.has(m)) {
searchCache.set(m, [
m.name, m.description, m.notes, m.license, sourceLabel(sourceKey(m)),
m.designer && m.designer.name,
...(m.tags || []),
...(m.projects || []).map((p) => p.name),
...(m.files || []).map((f) => f.originalName),
].filter(Boolean).join('\n').toLowerCase());
}
return searchCache.get(m);
}
// ---------------------------------------------------------------- rendering
function renderProjects() {
const items = projects.map((p) => `
<div class="project-item${String(selectedProject) === String(p.id) ? ' active' : ''}" data-project-id="${p.id}">
projectsList.innerHTML = projects.length
? projects.map((p) => `
<div class="project-item" data-scope="${p.id}">
<span class="project-name">${escapeHtml(p.name)}</span>
<span class="project-meta">
<button class="collection-action collection-rename" title="Rename" aria-label="Rename collection">&#9998;</button>
<button class="collection-action collection-delete" title="Delete" aria-label="Delete collection">&times;</button>
<span class="count" data-project-count="${p.id}">${p.model_count}</span>
<span class="count">${p.model_count}</span>
</span>
</div>`).join('');
const allItem = projectsList.querySelector('[data-project-id="all"]');
allItem.classList.toggle('active', selectedProject === 'all');
projectsList.innerHTML = '';
projectsList.appendChild(allItem);
projectsList.insertAdjacentHTML('beforeend', items);
</div>`).join('')
: '<p class="form-hint">No collections yet.</p>';
}
function renderTags() {
const all = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => all.add(t)));
tagsList.innerHTML = Array.from(all).sort().map((tag) => `
<span class="tag${selectedTags.includes(tag) ? ' active' : ''}" data-tag="${escapeHtml(tag)}">${escapeHtml(tag)}</span>`).join('');
const all = knownTags();
for (const t of Object.keys(tagFilters)) if (!all.includes(t)) delete tagFilters[t];
const titles = {
include: 'Showing models with this tag — click to exclude it',
exclude: 'Hiding models with this tag — click to clear',
};
tagsList.innerHTML = all.length
? all.map((tag) => {
const state = tagFilters[tag];
return `<span class="tag${state ? ` ${state}` : ''}" data-tag="${escapeHtml(tag)}"
title="${titles[state] || 'Click to show only models with this tag'}">${escapeHtml(tag)}</span>`;
}).join('')
: '<p class="form-hint">No tags yet.</p>';
document.getElementById('clearTags').hidden = Object.keys(tagFilters).length === 0;
document.querySelectorAll('#tagMatch button').forEach((b) => {
b.classList.toggle('active', b.dataset.match === prefs.tagMatch);
});
}
// Format/source dropdowns list only what's in the library. A remembered choice
// that no longer matches anything is reset rather than silently hiding everything.
function renderFilterOptions() {
const formats = [...new Set(models.flatMap((m) => (m.files || []).map((f) => f.format)))].sort();
const sources = [...new Set(models.map(sourceKey))].sort();
fillSelect('filterFormat', 'Any format', formats.map((f) => [f, `.${f}`]), 'format');
fillSelect('filterSource', 'Any source', sources.map((s) => [s, sourceLabel(s)]), 'source');
}
function fillSelect(id, anyLabel, options, prefKey) {
if (prefs[prefKey] && !options.some(([value]) => value === prefs[prefKey])) {
prefs[prefKey] = '';
savePrefs();
}
const el = document.getElementById(id);
el.innerHTML = `<option value="">${anyLabel}</option>`
+ options.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('');
el.value = prefs[prefKey];
}
function scopeTitle() {
if (selectedScope === 'all') return 'All Models';
if (selectedScope === 'duplicates') return 'Duplicates';
if (selectedScope === 'trash') return 'Trash';
const p = projects.find((x) => String(x.id) === String(selectedScope));
return p ? p.name : 'Collection';
}
function updateScopeChrome() {
contentTitle.textContent = scopeTitle();
const dupes = selectedScope === 'duplicates';
document.getElementById('filterBar').hidden = dupes;
document.querySelector('.view-options').hidden = dupes;
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
el.classList.toggle('active', el.dataset.scope === String(selectedScope));
});
document.querySelectorAll('.view-btn').forEach((b) => b.classList.toggle('active', b.dataset.view === prefs.view));
}
function renderModels() {
const filtered = getFilteredModels();
if (filtered.length === 0) {
fileGrid.innerHTML = `
<div class="empty-state">
<h3>No models found</h3>
<p>Upload files or import from a URL to get started</p>
</div>`;
updateScopeChrome();
if (selectedScope === 'duplicates') { renderDuplicates(); return; }
clearTimeout(duplicatePoll);
const list = getFilteredModels();
resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}`;
fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
if (list.length === 0) {
fileGrid.innerHTML = emptyStateHtml();
return;
}
if (!prefs.group) {
fileGrid.innerHTML = list.map(cardHtml).join('');
return;
}
const groups = new Map();
for (const m of list) {
const key = groupLabel(m);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(m);
}
fileGrid.innerHTML = [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([label, ms]) => `<div class="group-header">${escapeHtml(label)} <span>${ms.length}</span></div>`
+ ms.map(cardHtml).join(''))
.join('');
}
fileGrid.className = currentView === 'grid' ? 'file-grid' : 'file-grid list-view';
fileGrid.innerHTML = filtered.map((m) => {
const totalSize = (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const thumb = m.thumbnailUrl
? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">`
: '<span>📦</span>';
function groupLabel(m) {
if (prefs.group === 'source') return sourceLabel(sourceKey(m));
const formats = [...new Set((m.files || []).map((f) => `.${f.format}`))].sort();
return formats.length ? formats.join(' + ') : 'No files';
}
function emptyStateHtml() {
const state = (title, text) => `<div class="empty-state"><h3>${title}</h3><p>${text}</p></div>`;
if (selectedScope === 'trash' && trashModels.length === 0) {
return state('Trash is empty', 'Models you delete wait here until you restore them or empty the trash.');
}
if (selectedScope === 'all' && models.length === 0) {
return state('No models yet', 'Upload files or import from a URL to get started');
}
return state('No models match', 'Try clearing the search, filters or tag selection.');
}
function thumbHtml(m) {
return m.thumbnailUrl ? `<img src="${m.thumbnailUrl}" alt="" loading="lazy">` : '<span>📦</span>';
}
function cardHtml(m) {
const trashed = Boolean(m.deleted_at);
const when = trashed ? `trashed ${formatDate(m.deleted_at)}` : formatDate(m.date_added);
return `
<div class="file-card" data-model-id="${m.id}">
<div class="file-thumbnail">${thumb}</div>
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
<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>' : ''}
${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">${m.files.length} file${m.files.length === 1 ? '' : 's'} • ${formatFileSize(totalSize)} • ${formatDate(m.date_added)}</div>
<div class="file-meta">${fileCountLabel(m)} • ${formatFileSize(totalSize(m))} • ${when}</div>
${trashed ? `
<div class="card-actions">
<button class="btn btn-secondary btn-sm" data-action="restore">Restore</button>
<button class="btn btn-danger btn-sm" data-action="purge">Delete forever</button>
</div>` : ''}
</div>
</div>`;
}).join('');
}
const SORTS = {
newest: (a, b) => new Date(b.date_added) - new Date(a.date_added),
oldest: (a, b) => new Date(a.date_added) - new Date(b.date_added),
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
'name-desc': (a, b) => b.name.localeCompare(a.name, undefined, { numeric: true, sensitivity: 'base' }),
size: (a, b) => totalSize(b) - totalSize(a),
modified: (a, b) => new Date(b.date_modified || b.date_added) - new Date(a.date_modified || a.date_added),
};
function getFilteredModels() {
let result = models;
if (selectedProject !== 'all') {
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedProject)));
let result = selectedScope === 'trash' ? trashModels : models;
if (!SPECIAL_SCOPES.includes(selectedScope)) {
result = result.filter((m) => (m.projects || []).some((p) => String(p.id) === String(selectedScope)));
}
if (selectedTags.length > 0) {
result = result.filter((m) => selectedTags.some((t) => (m.tags || []).includes(t)));
const include = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'include');
const exclude = Object.keys(tagFilters).filter((t) => tagFilters[t] === 'exclude');
if (include.length) {
result = result.filter((m) => {
const tags = m.tags || [];
return prefs.tagMatch === 'all' ? include.every((t) => tags.includes(t)) : include.some((t) => tags.includes(t));
});
}
const term = searchInput.value.toLowerCase().trim();
if (term) {
result = result.filter((m) =>
m.name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)));
}
return result;
if (exclude.length) result = result.filter((m) => !exclude.some((t) => (m.tags || []).includes(t)));
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);
const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length) result = result.filter((m) => terms.every((t) => searchText(m).includes(t)));
return [...result].sort(SORTS[prefs.sort] || SORTS.newest);
}
function updateCounts() {
document.getElementById('allFilesCount').textContent = models.length;
document.getElementById('trashCount').textContent = stats.trash ?? 0;
document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0;
}
// ---------------------------------------------------------------- duplicates
async function renderDuplicates() {
fileGrid.className = 'dup-list';
resultCount.textContent = '';
if (!duplicateData) fileGrid.innerHTML = '<div class="empty-state"><p>Looking for duplicates…</p></div>';
try {
const res = await fetch('/api/duplicates');
if (!res.ok) throw new Error((await res.json()).error);
duplicateData = await res.json();
} catch (err) {
fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn't check for duplicates</h3><p>${escapeHtml(err.message)}</p></div>`;
return;
}
if (selectedScope !== 'duplicates') return; // navigated away while loading
const { groups, pending } = duplicateData;
stats.duplicateGroups = groups.length;
updateCounts();
resultCount.textContent = `${groups.length} group${groups.length === 1 ? '' : 's'}`;
const pendingNote = pending
? `<div class="dup-pending">Fingerprinting ${pending} file${pending === 1 ? '' : 's'}… this list updates automatically.</div>`
: '';
fileGrid.innerHTML = pendingNote + (groups.length
? groups.map(duplicateGroupHtml).join('')
: '<div class="empty-state"><h3>No duplicates found</h3><p>Files are compared by content and by shape, so renamed or re-exported copies show up here too.</p></div>');
clearTimeout(duplicatePoll);
if (pending) duplicatePoll = setTimeout(() => { if (selectedScope === 'duplicates') renderDuplicates(); }, 4000);
}
function duplicateGroupHtml(g) {
const title = g.exact ? 'Exact copies' : 'Same shape';
const hint = g.exact
? 'These models share byte-for-byte identical files.'
: 'Geometry matches but the files differ (re-exported, converted, or possibly mirrored) — compare before trashing.';
return `
<section class="dup-group" data-signature="${escapeHtml(g.signature)}">
<header class="dup-head">
<div>
<h3>${title} · ${g.models.length} models</h3>
<p class="form-hint">${hint}</p>
</div>
<button class="btn btn-secondary btn-sm" data-action="dismiss">Not duplicates</button>
</header>
<div class="dup-cards">${g.models.map((m) => duplicateCardHtml(g, m)).join('')}</div>
</section>`;
}
function duplicateCardHtml(g, m) {
const recommended = m.id === g.recommendedId;
const unique = m.files.length - m.matchedFiles;
return `
<div class="dup-card${recommended ? ' recommended' : ''}" data-model-id="${m.id}">
<div class="file-thumbnail">${thumbHtml(m)}</div>
<div class="file-info">
${recommended ? '<span class="rec-badge">Recommended keep</span>' : ''}
<div class="file-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</div>
<div class="file-meta">${escapeHtml(sourceLabel(sourceKey(m)))} • ${formatDate(m.date_added)}</div>
<div class="file-meta">${m.matchedFiles} of ${m.files.length} files match${unique ? ` • <span class="warn">${unique} unique</span>` : ''}</div>
<button class="btn btn-primary btn-sm" data-action="keep">Keep this one</button>
</div>
</div>`;
}
async function handleKeepDuplicate(signature, keepId) {
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
if (!group) return;
const keep = group.models.find((m) => m.id === keepId);
const others = group.models.filter((m) => m.id !== keepId);
let message = `Keep "${keep.name}" and move ${others.length} other model${others.length === 1 ? '' : 's'} to the trash?\n\n`
+ 'Their tags, collections and notes are merged into the one you keep.';
const withUnique = others.filter((m) => m.files.length > m.matchedFiles);
if (withUnique.length) {
message += `\n\nHeads up: ${withUnique.map((m) => `"${m.name}"`).join(', ')} also contain files that aren't duplicated — they go to the trash too.`;
}
if (!confirm(message)) return;
try {
const res = await fetch('/api/duplicates/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepId, trashIds: others.map((m) => m.id) }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Could not resolve duplicates: ' + err.message);
}
}
async function handleDismissDuplicates(signature) {
const group = duplicateData && duplicateData.groups.find((g) => g.signature === signature);
if (!group) return;
try {
const res = await fetch('/api/duplicates/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modelIds: group.models.map((m) => m.id) }),
});
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Could not dismiss: ' + err.message);
}
}
// ---------------------------------------------------------------- trash
async function restoreModel(id) {
try {
const res = await fetch(`/api/models/${id}/restore`, { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
return true;
} catch (err) {
alert('Restore failed: ' + err.message);
return false;
}
}
async function purgeModel(id) {
const model = findModel(id);
if (!confirm(`Permanently delete "${model ? model.name : 'this model'}" and its files?\n\nThis can't be undone.`)) return false;
try {
const res = await fetch(`/api/models/${id}?permanent=true`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
return true;
} catch (err) {
alert('Delete failed: ' + err.message);
return false;
}
}
async function handleEmptyTrash() {
const n = trashModels.length;
if (!n || !confirm(`Permanently delete ${n} model${n === 1 ? '' : 's'} in the trash and all their files?\n\nThis can't be undone.`)) return;
try {
const res = await fetch('/api/trash', { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
await loadData();
} catch (err) {
alert('Emptying the trash failed: ' + err.message);
}
}
// ---------------------------------------------------------------- events
@ -293,48 +615,92 @@ function setupEventListeners() {
if (f && !nameField.value) nameField.value = f.name.replace(/\.[^.]+$/, '');
});
projectsList.addEventListener('click', (e) => {
const item = e.target.closest('.project-item');
// Library views (All / Duplicates / Trash) and collections share one scope.
document.querySelector('.sidebar').addEventListener('click', (e) => {
const item = e.target.closest('.project-item[data-scope]');
if (!item) return;
const id = item.dataset.projectId;
const name = item.querySelector('.project-name') ? item.querySelector('.project-name').textContent : 'All Models';
if (e.target.closest('.collection-rename')) { handleRenameCollection(id, name); return; }
if (e.target.closest('.collection-delete')) { handleDeleteCollection(id, name); return; }
document.querySelectorAll('.project-item').forEach((i) => i.classList.remove('active'));
item.classList.add('active');
selectedProject = id;
document.getElementById('contentTitle').textContent = id === 'all' ? 'All Models' : name;
renderModels();
const scope = item.dataset.scope;
if (e.target.closest('.collection-rename')) { handleRenameCollection(scope, item.querySelector('.project-name').textContent); return; }
if (e.target.closest('.collection-delete')) { handleDeleteCollection(scope, item.querySelector('.project-name').textContent); return; }
selectScope(scope);
});
tagsList.addEventListener('click', (e) => {
if (!e.target.classList.contains('tag')) return;
const tag = e.target.dataset.tag;
e.target.classList.toggle('active');
if (selectedTags.includes(tag)) selectedTags = selectedTags.filter((t) => t !== tag);
else selectedTags.push(tag);
const el = e.target.closest('.tag');
if (!el) return;
const tag = el.dataset.tag;
if (!tagFilters[tag]) tagFilters[tag] = 'include';
else if (tagFilters[tag] === 'include') tagFilters[tag] = 'exclude';
else delete tagFilters[tag];
renderTags();
renderModels();
});
document.getElementById('tagMatch').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-match]');
if (!btn) return;
prefs.tagMatch = btn.dataset.match;
savePrefs();
renderTags();
renderModels();
});
document.getElementById('clearTags').addEventListener('click', () => {
tagFilters = {};
renderTags();
renderModels();
});
searchInput.addEventListener('input', renderModels);
[['filterFormat', 'format'], ['filterSource', 'source'], ['filterLicense', 'license'], ['sortBy', 'sort'], ['groupBy', 'group']]
.forEach(([id, key]) => {
const el = document.getElementById(id);
el.value = prefs[key];
el.addEventListener('change', () => { prefs[key] = el.value; savePrefs(); renderModels(); });
});
// "/" jumps to search.
document.addEventListener('keydown', (e) => {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.closest && e.target.closest('input, textarea, select, [contenteditable]')) return;
if (document.querySelector('.modal.active') || document.getElementById('filterBar').hidden) return;
e.preventDefault();
searchInput.focus();
});
document.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.view-btn').forEach((b) => b.classList.remove('active'));
e.target.classList.add('active');
currentView = e.target.dataset.view;
prefs.view = e.target.dataset.view;
savePrefs();
renderModels();
});
});
fileGrid.addEventListener('click', (e) => {
const card = e.target.closest('.file-card');
const card = e.target.closest('[data-model-id]');
const actionBtn = e.target.closest('[data-action]');
if (actionBtn) {
const group = actionBtn.closest('.dup-group');
switch (actionBtn.dataset.action) {
case 'restore': restoreModel(card.dataset.modelId); break;
case 'purge': purgeModel(card.dataset.modelId); break;
case 'keep': handleKeepDuplicate(group.dataset.signature, Number(card.dataset.modelId)); break;
case 'dismiss': handleDismissDuplicates(group.dataset.signature); break;
}
return;
}
if (card) openModelDetail(card.dataset.modelId);
});
emptyTrashBtn.addEventListener('click', handleEmptyTrash);
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
document.getElementById('restoreModelBtn').addEventListener('click', async () => {
if (await restoreModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
});
document.getElementById('purgeModelBtn').addEventListener('click', async () => {
if (await purgeModel(fileDetailModal.dataset.currentModelId)) closeModal(fileDetailModal);
});
document.getElementById('detailNotes').addEventListener('input', queueNotesSave);
document.getElementById('detailNotes').addEventListener('blur', flushNotes);
setupTagAutocomplete(document.getElementById('fileTags'));
setupTagAutocomplete(document.getElementById('editTags'));
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
@ -347,13 +713,61 @@ function setupEventListeners() {
document.getElementById('addFilesInput').addEventListener('change', handleAddFiles);
}
async function selectScope(scope) {
selectedScope = scope;
if (scope === 'trash') await loadTrash();
renderModels();
}
// Tracks the file currently shown in the detail viewer (for thumbnail regen).
let currentDetailFile = null;
let currentDetailThumb = null; // model image, used as a viewer fallback
function closeModal(modal) {
modal.classList.remove('active');
if (modal === fileDetailModal && window.ModelViewer) window.ModelViewer.dispose();
if (modal === fileDetailModal) {
flushNotes();
if (window.ModelViewer) window.ModelViewer.dispose();
}
}
// ---------------------------------------------------------------- notes
// Notes autosave shortly after typing stops (and on blur / close). The pending
// edit remembers its model, so switching models mid-edit can't misfile it.
let pendingNotes = null;
let notesTimer = null;
function setNotesStatus(text) {
document.getElementById('notesStatus').textContent = text;
}
function queueNotesSave() {
pendingNotes = { id: fileDetailModal.dataset.currentModelId, value: document.getElementById('detailNotes').value };
setNotesStatus('editing…');
clearTimeout(notesTimer);
notesTimer = setTimeout(flushNotes, 800);
}
async function flushNotes() {
clearTimeout(notesTimer);
const pending = pendingNotes;
pendingNotes = null;
if (!pending) return;
const model = findModel(pending.id);
if (model && (model.notes || '') === pending.value) { setNotesStatus(''); return; }
setNotesStatus('saving…');
try {
const res = await fetch(`/api/models/${pending.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes: pending.value }),
});
if (!res.ok) throw new Error((await res.json()).error);
replaceModel(await res.json());
if (fileDetailModal.dataset.currentModelId === String(pending.id)) setNotesStatus('saved');
} catch (err) {
setNotesStatus('not saved — ' + err.message);
}
}
// ---------------------------------------------------------------- modals
@ -443,13 +857,20 @@ function openImportModal() {
}
function openModelDetail(modelId) {
const model = models.find((m) => String(m.id) === String(modelId));
const model = findModel(modelId);
if (!model) return;
flushNotes(); // save any edit still pending for the previously open model
fileDetailModal.dataset.currentModelId = modelId;
currentDetailThumb = model.thumbnailUrl || null;
document.getElementById('detailFileName').textContent = model.name;
document.getElementById('detailDescription').textContent = model.description || 'No description';
document.getElementById('detailNotes').value = model.notes || '';
setNotesStatus('');
const trashed = Boolean(model.deleted_at);
document.getElementById('detailActions').hidden = trashed;
document.getElementById('trashActions').hidden = !trashed;
// Source
const src = document.getElementById('detailSource');
@ -551,7 +972,7 @@ function renderDetailLicense(model) {
function openEditModal() {
const id = fileDetailModal.dataset.currentModelId;
const model = models.find((m) => String(m.id) === String(id));
const model = findModel(id);
if (!model) return;
editModal.dataset.editId = id;
@ -633,8 +1054,7 @@ async function handleRefreshMetadata() {
if (!res.ok) throw new Error((await res.json()).error);
const updated = await res.json();
// update in-memory state, the license panel, and the card badge
const idx = models.findIndex((m) => String(m.id) === String(modelId));
if (idx !== -1) models[idx] = updated;
replaceModel(updated);
renderDetailLicense(updated);
renderModels();
btn.textContent = 'Updated ✓';
@ -656,9 +1076,9 @@ function loadViewer(fileId, format) {
}
function updateCardThumbnail(modelId, thumbnailUrl) {
const m = models.find((x) => String(x.id) === String(modelId));
const m = findModel(modelId);
if (m) m.thumbnailUrl = thumbnailUrl;
const thumb = fileGrid.querySelector(`.file-card[data-model-id="${modelId}"] .file-thumbnail`);
const thumb = fileGrid.querySelector(`[data-model-id="${modelId}"] .file-thumbnail`);
if (thumb && thumbnailUrl) thumb.innerHTML = `<img src="${thumbnailUrl}" alt="" loading="lazy">`;
}
@ -870,9 +1290,6 @@ async function handleRenameCollection(id, currentName) {
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedProject) === String(id)) {
document.getElementById('contentTitle').textContent = trimmed;
}
await loadData();
} catch (err) {
alert('Failed to rename collection: ' + err.message);
@ -884,10 +1301,7 @@ async function handleDeleteCollection(id, name) {
try {
const res = await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error((await res.json()).error);
if (String(selectedProject) === String(id)) {
selectedProject = 'all';
document.getElementById('contentTitle').textContent = 'All Models';
}
if (String(selectedScope) === String(id)) selectedScope = 'all';
await loadData();
} catch (err) {
alert('Failed to delete collection: ' + err.message);
@ -919,17 +1333,17 @@ async function handleCreateProject(e) {
async function handleDeleteModel() {
const id = fileDetailModal.dataset.currentModelId;
if (!id || !confirm('Delete this model and all its files?')) return;
if (!id || !confirm('Move this model to the trash?\n\nYou can restore it from Trash until the trash is emptied.')) return;
try {
const res = await fetch(`/api/models/${id}`, { method: 'DELETE' });
if (res.ok) {
closeModal(fileDetailModal);
await loadData();
} else {
alert('Failed to delete: ' + (await res.json()).error);
alert('Failed to move to trash: ' + (await res.json()).error);
}
} catch (err) {
alert('Failed to delete: ' + err.message);
alert('Failed to move to trash: ' + err.message);
}
}

View file

@ -84,34 +84,82 @@
<div class="main-content">
<aside class="sidebar">
<div class="sidebar-section">
<h3>Collections</h3>
<div id="projectsList" class="projects-list">
<div class="project-item active" data-project-id="all">
<h3>Library</h3>
<div id="libraryList" class="projects-list">
<div class="project-item active" data-scope="all">
<span>All Models</span>
<span class="count" id="allFilesCount">0</span>
</div>
<div class="project-item" data-scope="duplicates">
<span>Duplicates</span>
<span class="count" id="duplicatesCount">0</span>
</div>
<div class="project-item" data-scope="trash">
<span>Trash</span>
<span class="count" id="trashCount">0</span>
</div>
</div>
</div>
<div class="sidebar-section">
<h3>Collections</h3>
<div id="projectsList" class="projects-list"></div>
</div>
<div class="sidebar-section">
<div class="section-head">
<h3>Tags</h3>
<div id="tagsList" class="tags-list"></div>
<div class="tag-tools">
<button type="button" class="btn-link" id="clearTags" hidden>Clear</button>
<div class="seg" id="tagMatch" title="When several tags are included, match any or all of them">
<button type="button" data-match="any">Any</button>
<button type="button" data-match="all">All</button>
</div>
<div class="sidebar-section">
<h3>Search</h3>
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
</div>
</div>
<div id="tagsList" class="tags-list"></div>
<p class="form-hint">Click a tag to include it, again to exclude it.</p>
</div>
</aside>
<main class="content">
<div class="content-header">
<h2 id="contentTitle">All Models</h2>
<div class="content-actions">
<span id="resultCount" class="result-count"></span>
<button type="button" id="emptyTrashBtn" class="btn btn-danger btn-sm" hidden>Empty trash</button>
<div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button>
</div>
</div>
</div>
<div id="filterBar" class="filter-bar">
<input type="search" id="searchInput" class="search-input"
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
<select id="filterLicense" aria-label="Filter by license">
<option value="">Any license</option>
<option value="sellable">Sellable</option>
<option value="noncommercial">Non-commercial</option>
<option value="unknown">License unclear</option>
</select>
<select id="sortBy" aria-label="Sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">Name A–Z</option>
<option value="name-desc">Name Z–A</option>
<option value="size">Largest first</option>
<option value="modified">Recently edited</option>
</select>
<select id="groupBy" aria-label="Group">
<option value="">No grouping</option>
<option value="source">Group by source</option>
<option value="format">Group by format</option>
</select>
</div>
<div id="fileGrid" class="file-grid"></div>
</main>
@ -378,6 +426,10 @@
<h3>Description</h3>
<p id="detailDescription"></p>
</div>
<div class="info-section">
<h3>Notes <span id="notesStatus" class="notes-status"></span></h3>
<textarea id="detailNotes" rows="3" placeholder="Your notes — settings that worked, tweaks, who it's for…"></textarea>
</div>
<div class="info-section">
<h3>Tags</h3>
<div id="detailTags" class="tags"></div>
@ -397,11 +449,15 @@
</div>
<input type="file" id="addFilesInput" multiple style="display:none"
accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
<div class="form-actions">
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
<div class="form-actions" id="detailActions">
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button>
</div>
<div class="form-actions" id="trashActions" hidden>
<button class="btn btn-danger" id="purgeModelBtn">Delete forever</button>
<button class="btn btn-primary" id="restoreModelBtn">Restore</button>
</div>
</div>
</div>
</div>

View file

@ -110,10 +110,11 @@ header {
transition: all 0.12s;
}
.tag:hover { border-color: var(--border-strong); color: var(--text); }
.tag.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.tag.include { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
/* ---- form fields ---- */
.search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
width: 100%;
font-family: inherit;
font-size: 14px;
@ -279,3 +280,49 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
}
.tag-suggest-item { padding: 7px 11px; font-size: 13px; cursor: pointer; color: var(--text-muted); }
.tag-suggest-item:hover, .tag-suggest-item.active { background: var(--accent-soft); color: var(--accent); }
[hidden] { display: none !important; }
/* ---- sidebar tag tools ---- */
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
.sidebar-section .section-head h3 { margin-bottom: 0; }
.tag-tools { display: flex; align-items: center; gap: 8px; }
.tag-tools .btn-link { padding: 0; font-size: 12px; color: var(--text-muted); }
.seg { display: flex; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 2px; }
.seg button { font-family: inherit; font-size: 11px; border: none; background: transparent; color: var(--text-muted); padding: 2px 8px; border-radius: 4px; cursor: pointer; }
.seg button.active { background: var(--surface-3); color: var(--text); }
/* ---- filter bar ---- */
.content-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.result-count { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.filter-bar .search-input { flex: 1 1 260px; width: auto; }
.filter-bar select { width: auto; flex: 0 0 auto; padding: 9px 10px; font-size: 13px; }
.group-header {
grid-column: 1 / -1; display: flex; align-items: baseline; gap: 8px;
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim);
padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.group-header:not(:first-child) { margin-top: 10px; }
.group-header span { font-family: var(--mono); font-weight: 400; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.notes-status { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--text-dim); margin-left: 6px; }
/* ---- duplicates ---- */
.dup-list { display: flex; flex-direction: column; gap: 18px; }
.dup-pending { font-size: 13px; color: var(--text-muted); background: var(--surface); border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); padding: 10px 14px; }
.dup-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
.dup-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; }
.dup-head h3 { font-size: 15px; font-weight: 600; }
.dup-head .form-hint { margin-top: 2px; }
.dup-head .btn-sm { flex-shrink: 0; }
.dup-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.dup-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
.dup-card:hover { border-color: var(--border-strong); }
.dup-card.recommended { border-color: var(--success); }
.dup-card .file-thumbnail { height: 130px; font-size: 34px; }
.dup-card .file-info { padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
.dup-card .file-name { margin-bottom: 0; font-size: 14px; }
.dup-card .btn { margin-top: 6px; }
.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
.warn { color: var(--accent); }

128
server.js
View file

@ -19,6 +19,8 @@ const licenses = require('./lib/licenses');
const cookieParser = require('cookie-parser');
const auth = require('./lib/auth');
const mailer = require('./lib/mailer');
const fingerprint = require('./lib/fingerprint');
const duplicates = require('./lib/duplicates');
const app = express();
const PORT = process.env.PORT || 3000;
@ -292,7 +294,7 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- models
app.get('/api/models', async (req, res, next) => {
try {
const models = await store.getAllModels();
const models = await store.getAllModels({ trash: req.query.view === 'trash' });
res.json(models.map(publicModel));
} catch (e) { next(e); }
});
@ -321,6 +323,7 @@ app.post('/api/models', upload.array('files'), async (req, res, next) => {
thumbnail_path: pickThumbnail(files),
files,
});
fingerprint.kick();
res.status(201).json(publicModel(model));
} catch (e) { next(e); }
});
@ -332,6 +335,7 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
if (!existing) return res.status(404).json({ error: 'Model not found' });
const files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname));
const model = await store.addFiles(req.params.id, files);
fingerprint.kick();
res.json(publicModel(model));
} catch (e) { next(e); }
});
@ -403,6 +407,7 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
return res.status(200).json({ ...publicModel(model), added: 0 });
}
const updated = await store.addFiles(model.id, newFiles);
fingerprint.kick();
res.json({ ...publicModel(updated), added: newFiles.length });
} catch (e) { next(e); }
});
@ -429,17 +434,54 @@ async function fetchSourceFiles(model, skipNames) {
throw e;
}
// Moves a model to the trash (restorable). ?permanent=true deletes it and its
// files from disk — only for a model that's already in the trash.
app.delete('/api/models/:id', async (req, res, next) => {
try {
const removed = await store.deleteModel(req.params.id);
if (!removed) return res.status(404).json({ error: 'Model not found' });
for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
res.json({ message: 'Model deleted' });
const model = await store.getModel(req.params.id);
if (!model) return res.status(404).json({ error: 'Model not found' });
if (req.query.permanent === 'true') {
if (!model.deleted_at) {
return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' });
}
await purgeModel(model.id);
return res.json({ message: 'Model permanently deleted' });
}
await store.trashModels([model.id]);
res.json({ message: 'Model moved to trash' });
} catch (e) { next(e); }
});
app.post('/api/models/:id/restore', async (req, res, next) => {
try {
if (!(await store.restoreModel(req.params.id))) {
return res.status(404).json({ error: 'Model not found in trash' });
}
res.json(publicModel(await store.getModel(req.params.id)));
} catch (e) { next(e); }
});
// Empty the trash: permanently delete every trashed model and its files.
app.delete('/api/trash', async (req, res, next) => {
try {
const ids = await store.trashedModelIds();
for (const id of ids) await purgeModel(id);
res.json({ deleted: ids.length });
} catch (e) { next(e); }
});
// Delete a model's rows, then its files, GLB conversions and thumbnail on disk.
async function purgeModel(id) {
const removed = await store.deleteModel(id);
if (!removed) return;
for (const p of [...removed.filePaths, ...removed.glbPaths, removed.thumbnailPath]) {
if (!p) continue;
try { fs.unlinkSync(p); } catch (err) {
if (err.code !== 'ENOENT') console.warn(`Could not delete ${p}:`, err.message);
}
}
}
// Store a client-rendered thumbnail (PNG data URL) for a model.
app.post('/api/models/:id/thumbnail', async (req, res, next) => {
try {
@ -614,6 +656,7 @@ app.post('/api/import', async (req, res, next) => {
projects,
files,
});
fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
} catch (e) { next(e); }
});
@ -626,7 +669,9 @@ async function importThingiverse(url, projects) {
const existing = await store.findModelBySource('thingiverse', String(thingId));
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
const meta = await thingiverse.fetchModel(thingId, token);
return publicModel(await downloadAndCreateModel(meta, projects));
const model = await downloadAndCreateModel(meta, projects);
fingerprint.kick();
return publicModel(model);
}
// Fetch a page's HTML. Tries a direct request first; if the response is a
@ -809,6 +854,68 @@ app.get('/api/tags', async (req, res, next) => {
try { res.json(await store.getAllTags()); } catch (e) { next(e); }
});
// ----------------------------------------------------------------- library / duplicates
// Duplicate groups among non-trashed models, minus ones marked "not duplicates".
async function findDuplicateGroups() {
const [files, dismissed] = await Promise.all([
store.duplicateCandidateFiles(), store.dismissedDuplicateSignatures()]);
const skip = new Set(dismissed);
return duplicates.groupDuplicates(files).filter((g) => !skip.has(duplicates.signature(g.modelIds)));
}
app.get('/api/library/stats', async (req, res, next) => {
try {
const [trash, groups, pending] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount()]);
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending });
} catch (e) { next(e); }
});
app.get('/api/duplicates', async (req, res, next) => {
try {
const [groups, pending] = await Promise.all([findDuplicateGroups(), fingerprint.pendingCount()]);
const found = await store.getModelsByIds(groups.flatMap((g) => g.modelIds));
const byId = new Map(found.map((m) => [m.id, m]));
const result = groups
.map((g) => ({ ...g, models: g.modelIds.map((id) => byId.get(id)).filter(Boolean) }))
.filter((g) => g.models.length > 1)
.map((g) => ({
signature: duplicates.signature(g.modelIds),
exact: g.exact,
recommendedId: duplicates.recommendKeep(g.models),
models: g.models.map((m) => ({ ...publicModel(m), matchedFiles: g.matchedFiles[m.id] })),
}));
res.json({ pending, groups: result });
} catch (e) { next(e); }
});
// Keep one model of a duplicate group; the rest are merged into it and trashed.
app.post('/api/duplicates/resolve', async (req, res, next) => {
try {
const keepId = Number(req.body.keepId);
const trashIds = [...new Set((req.body.trashIds || []).map(Number))];
if (!Number.isInteger(keepId) || trashIds.length === 0
|| trashIds.some((id) => !Number.isInteger(id) || id === keepId)) {
return res.status(400).json({ error: 'Pass keepId and a non-empty trashIds list that excludes it.' });
}
const found = await store.getModelsByIds([keepId, ...trashIds]);
if (found.length !== trashIds.length + 1 || found.some((m) => m.deleted_at)) {
return res.status(409).json({ error: 'Some of those models are gone or already in the trash — refresh and try again.' });
}
await store.mergeAndTrash(keepId, trashIds);
res.json(publicModel(await store.getModel(keepId)));
} catch (e) { next(e); }
});
app.post('/api/duplicates/dismiss', async (req, res, next) => {
try {
const ids = (req.body.modelIds || []).map(Number).filter(Number.isInteger);
if (new Set(ids).size < 2) return res.status(400).json({ error: 'Pass at least two modelIds.' });
await store.dismissDuplicateGroup(duplicates.signature(ids));
res.json({ ok: true });
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- misc
function parseJson(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
@ -822,5 +929,8 @@ app.use((err, req, res, next) => {
});
db.init()
.then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
.then(() => {
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
fingerprint.kick(); // backfill fingerprints for files stored before this ran
})
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });

63
test/duplicates.test.js Normal file
View file

@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { groupDuplicates, signature, recommendKeep } = require('../lib/duplicates');
const { geometryKey } = require('../lib/fingerprint');
const file = (id, model_id, sha256, geometry_hash = null) => ({ id, model_id, sha256, geometry_hash });
test('identical content in two models is an exact group', () => {
const groups = groupDuplicates([file(1, 10, 'aaa'), file(2, 20, 'aaa'), file(3, 30, 'zzz')]);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].modelIds, [10, 20]);
assert.equal(groups[0].exact, true);
assert.deepEqual(groups[0].matchedFiles, { 10: 1, 20: 1 });
});
test('same geometry with different bytes is flagged for review', () => {
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'bbb', 'g1')]);
assert.deepEqual(group.modelIds, [10, 20]);
assert.equal(group.exact, false);
});
test('same geometry and same bytes stays exact', () => {
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'aaa', 'g1')]);
assert.equal(group.exact, true);
});
test('a file repeated inside one model is not a duplicate group', () => {
assert.deepEqual(groupDuplicates([file(1, 10, 'aaa'), file(2, 10, 'aaa')]), []);
});
test('links are transitive, so packs collapse into one group', () => {
const groups = groupDuplicates([
file(1, 10, 'a'), file(2, 10, 'b'),
file(3, 20, 'a'), file(4, 20, 'b', 'g'),
file(5, 30, 'c', 'g'),
]);
assert.equal(groups.length, 1);
assert.deepEqual(groups[0].modelIds, [10, 20, 30]);
assert.equal(groups[0].exact, false);
assert.deepEqual(groups[0].matchedFiles, { 10: 2, 20: 2, 30: 1 });
});
test('signature ignores order and repeats', () => {
assert.equal(signature([3, 1, 2, 1]), '1,2,3');
assert.equal(signature(['20', '10']), '10,20');
});
test('recommendKeep prefers the more complete model, then the older one', () => {
const bare = { id: 1, date_added: '2026-01-01', files: [{}], tags: [], projects: [] };
const sourced = { id: 2, date_added: '2026-02-01', source_url: 'https://x', license: 'CC-BY', files: [{}], tags: ['a'], projects: [] };
assert.equal(recommendKeep([bare, sourced]), 2);
const older = { ...bare, id: 3, date_added: '2025-12-01' };
assert.equal(recommendKeep([bare, older]), 3);
});
test('geometryKey ignores axis order, position and float noise', () => {
const a = { faces: 1200, area: 5321.4567, volume: -18342.12, extents: [20, 40.02, 10] };
const b = { faces: 1200, area: 5321.4571, volume: 18342.09, extents: [40.019, 10.001, 20] };
assert.equal(geometryKey(a), geometryKey(b));
assert.notEqual(geometryKey(a), geometryKey({ ...a, faces: 1202 }));
assert.notEqual(geometryKey(a), geometryKey({ ...a, extents: [20, 40.02, 12] }));
assert.equal(geometryKey({ faces: 0 }), null);
});

40
tools/fingerprint.py Normal file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Measure mesh files for geometry fingerprints (duplicate detection).
Usage: fingerprint.py <file> [<file> ...]
Prints one JSON line per file: {path, fp: {faces, area, volume, extents}} or {path, error}.
Multi-object files (e.g. 3MF projects) are measured as one combined mesh.
"""
import sys
import json
import trimesh
def measure(path):
mesh = trimesh.load(path, force="mesh")
if mesh is None or len(mesh.faces) == 0:
raise ValueError("no geometry found")
try:
volume = abs(float(mesh.volume))
except Exception:
volume = 0.0
return {
"faces": int(len(mesh.faces)),
"area": float(mesh.area),
"volume": volume,
"extents": [float(x) for x in mesh.extents],
}
def main():
for path in sys.argv[1:]:
try:
out = {"path": path, "fp": measure(path)}
except Exception as exc:
out = {"path": path, "error": str(exc)[:200]}
print(json.dumps(out), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())