Compare commits

..

No commits in common. "78c225f232536eeb0c41671d6a9e0c160ba6bed3" and "e141d800c28579ee2ff76df7f1bf2ee890e10d98" have entirely different histories.

42 changed files with 307 additions and 3900 deletions

View file

@ -14,4 +14,3 @@ npm-debug.log*
Dockerfile Dockerfile
.dockerignore .dockerignore
docker-compose.yml docker-compose.yml
extracted

2
.gitignore vendored
View file

@ -8,5 +8,3 @@ thumbnails/
.env .env
glb/ glb/
tools/venv/ tools/venv/
__pycache__/
extracted/

View file

@ -1,6 +1,6 @@
# Printhive # Printhive
A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX, STEP, 3DM, GCODE — loose or in ZIP/7Z/RAR/TAR archives) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**. A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP, GCODE, ZIP) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**.
## Features ## Features
@ -10,36 +10,27 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models). - **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token. - **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually. - **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects. - **In-browser 3D viewer** (Three.js) for STL/OBJ, and for 3MF via a server-side **trimesh GLB conversion** (handles Bambu/MakerWorld production-extension files the JS loader can't).
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden. - **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
- **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection.
- **Pictures, PDFs & readmes** — keep them with a model and view them in the app.
- **Open in slicer** — pick your slicer once per browser (Settings → Slicer). Bambu Studio and OrcaSlicer open the file straight from a 10-minute download link (`bambustudioopen://…`, `orcaslicer://open?file=…`), so the server must be reachable at the address the browser uses; Bambu Studio asks before opening files that aren't from MakerWorld. PrusaSlicer (which only accepts Printables/Thingiverse links) and Cura get a normal download, and any other slicer with an open-link can be added as a template. Opening a 3MF project in a different slicer from the one that saved it asks first. Works for files inside archives too.
- **Drag out** — drag a model card (its file, or a zip of all its files) or any file row, including files inside archives, onto your desktop or a folder to save it. Uses Chromium's DownloadURL drag, so it needs Chrome, Edge, Brave or similar.
- **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones.
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling). - **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. - **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. - **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. - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.
## Tech stack ## Tech stack
- Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL. - Node.js + Express, vanilla-JS frontend (no build step), PostgreSQL.
- Three.js (viewer) via CDN import map. - Three.js (viewer) via CDN import map.
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion, volume, and geometry fingerprints. - Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion and volume.
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources. - Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
## Setup ## Setup
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup. 1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
2. **Python sidecar** (for 3MF previews, plates, server-side thumbnails and shape-based duplicate detection — without it, duplicates are still found by content hash): 2. **Python sidecar** (for 3MF previews/volume):
```bash ```bash
python3 -m venv tools/venv python3 -m venv tools/venv
tools/venv/bin/pip install -r tools/requirements.txt tools/venv/bin/pip install trimesh numpy networkx lxml
``` ```
3. **Install deps and run**: 3. **Install deps and run**:
```bash ```bash
@ -49,8 +40,6 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
``` ```
4. Open http://localhost:3000 4. Open http://localhost:3000
Run the unit tests with `npm test`.
## Configuration ## Configuration
Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables: Settings live in the database (editable in the app's **Settings** menu) and fall back to environment variables:

View file

@ -107,50 +107,3 @@ 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_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_model_tags_t ON model_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_models_source ON models(source_site, source_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);
-- Archive files (zip/7z/rar/tar) are indexed in the background:
-- null = not yet, ok | error (with archive_error).
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_status TEXT;
ALTER TABLE files ADD COLUMN IF NOT EXISTS archive_error TEXT;
-- Files inside archives, so they can be browsed, previewed and matched as duplicates.
CREATE TABLE IF NOT EXISTS archive_entries (
id SERIAL PRIMARY KEY,
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- path inside the archive, as the archive stores it
format TEXT NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
sha256 TEXT,
geometry_hash TEXT,
geometry_status TEXT, -- same states as files.geometry_status
fingerprinted_at TIMESTAMPTZ,
UNIQUE (file_id, path)
);
CREATE INDEX IF NOT EXISTS idx_entries_sha256 ON archive_entries(sha256);
CREATE INDEX IF NOT EXISTS idx_entries_geometry ON archive_entries(geometry_hash);
-- Background thumbnails: null = not tried yet, none = nothing to make one from
-- (left to the browser), error. Cleared when files are added.
ALTER TABLE models ADD COLUMN IF NOT EXISTS thumbnail_status TEXT;
-- 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()
);

View file

@ -36,7 +36,6 @@ services:
- uploads:/app/uploads - uploads:/app/uploads
- thumbnails:/app/thumbnails - thumbnails:/app/thumbnails
- glb:/app/glb - glb:/app/glb
- extracted:/app/extracted # cache of files unpacked from archives
# No host port published — Traefik auto-discovers the app on the shared network. # No host port published — Traefik auto-discovers the app on the shared network.
networks: networks:
- default # talk to the db - default # talk to the db
@ -57,7 +56,6 @@ volumes:
uploads: uploads:
thumbnails: thumbnails:
glb: glb:
extracted:
networks: networks:
default: default:

View file

@ -1,82 +0,0 @@
// Worker thread running 7-Zip (compiled to WASM) for ZIP/7Z/RAR archives.
// 7-Zip calls are synchronous, so they run here to keep the server responsive.
const { parentPort } = require('worker_threads');
const SevenZip = require('7z-wasm');
let sevenZip = null;
const output = [];
// The host filesystem is mounted at /host inside the WASM filesystem.
const host = (p) => `/host${p}`;
async function instance() {
if (!sevenZip) {
sevenZip = await SevenZip({ print: (l) => output.push(l), printErr: (l) => output.push(l) });
sevenZip.FS.mkdir('/host');
sevenZip.FS.mount(sevenZip.NODEFS, { root: '/' }, '/host');
}
return sevenZip;
}
async function run(args) {
const sz = await instance();
output.length = 0;
let code;
try {
code = sz.callMain(args);
} catch (err) {
code = err && typeof err.status === 'number' ? err.status : 2;
}
return { code, lines: output.slice() };
}
function errorText(lines) {
const line = lines.find((l) => /error|wrong password|can ?not open|unsupported|headers/i.test(l));
return line ? line.trim() : null;
}
// Parses `7zz l -slt` into [{ path, size }] for files (folders are dropped).
function parseListing(lines) {
const start = lines.findIndex((l) => l.startsWith('----------'));
if (start === -1) return null;
const entries = [];
let cur = {};
const flush = () => {
const isFolder = cur.Folder === '+' || (cur.Attributes || '').startsWith('D');
if (cur.Path !== undefined && !isFolder) entries.push({ path: cur.Path, size: Number(cur.Size) || 0 });
cur = {};
};
for (const line of lines.slice(start + 1)) {
if (!line.trim()) { flush(); continue; }
const i = line.indexOf(' = ');
if (i > 0) cur[line.slice(0, i)] = line.slice(i + 3);
}
flush();
return entries;
}
async function list(archive) {
// A dummy password makes encrypted archives fail fast instead of prompting.
const { code, lines } = await run(['l', '-slt', '-pprinthive', host(archive)]);
const entries = parseListing(lines);
if (code !== 0 || !entries) throw new Error(errorText(lines) || `7-Zip could not read the archive (exit ${code})`);
return entries;
}
// Extracts the named entries (keeping their folders) under destDir. Callers
// check which files actually arrived.
async function extract(archive, entries, destDir) {
const { code, lines } = await run(
['x', host(archive), `-o${host(destDir)}`, '-y', '-spd', '-bsp0', '-pprinthive', '--', ...entries]);
return { code, error: code === 0 ? null : errorText(lines) };
}
parentPort.on('message', async ({ id, op, archive, entries, destDir }) => {
try {
const result = op === 'list' ? await list(archive) : await extract(archive, entries, destDir);
parentPort.postMessage({ id, result });
} catch (err) {
parentPort.postMessage({ id, error: err.message });
}
});

View file

@ -1,133 +0,0 @@
// Lists and extracts files inside archives. ZIP/7Z/RAR go through 7-Zip (WASM,
// in a worker thread); TAR and .tar.gz go through node-tar. Extracted entries
// are cached under EXTRACT_DIR/<file id>/ and can always be re-extracted.
const fs = require('fs');
const path = require('path');
const { Worker } = require('worker_threads');
const tar = require('tar');
const EXTRACT_DIR = path.resolve(process.env.EXTRACT_DIR || './extracted');
const TAR_FILE_TYPES = ['File', 'OldFile', 'ContiguousFile'];
let worker = null;
let nextId = 1;
const pending = new Map();
function failPending(err) {
for (const p of pending.values()) p.reject(err);
pending.clear();
worker = null;
}
// The worker keeps the process alive only while a request is outstanding, so
// an idle worker never stops Node from exiting.
function sevenZip(message) {
if (!worker) {
worker = new Worker(path.join(__dirname, 'archiveWorker.js'));
worker.on('message', ({ id, result, error }) => {
const p = pending.get(id);
pending.delete(id);
if (pending.size === 0 && worker) worker.unref();
if (p) error ? p.reject(new Error(error)) : p.resolve(result);
});
worker.on('error', failPending);
worker.on('exit', () => failPending(new Error('Archive worker stopped')));
}
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.ref();
worker.postMessage({ id, ...message });
});
}
const isTar = (format) => format === 'tar' || format === 'tgz';
const clean = (p) => p.replace(/^\.\//, '');
// Clutter archivers add (macOS resource forks, Finder/Explorer files).
function isJunk(entryPath) {
const parts = clean(entryPath).split('/');
const name = parts[parts.length - 1];
return parts.includes('__MACOSX') || name.startsWith('._') || name === '.DS_Store' || name === 'Thumbs.db';
}
// Paths that could land outside the extraction folder are never used.
function isSafe(entryPath) {
return Boolean(entryPath) && !path.isAbsolute(entryPath) && !entryPath.split(/[\\/]/).includes('..');
}
// file: { file_path, format }. Returns [{ path, size }] of real files.
async function listEntries(file) {
const abs = path.resolve(file.file_path);
let raw;
if (isTar(file.format)) {
raw = [];
await tar.t({
file: abs,
onReadEntry: (e) => { if (TAR_FILE_TYPES.includes(e.type)) raw.push({ path: e.path, size: e.size }); },
});
} else {
raw = await sevenZip({ op: 'list', archive: abs });
}
return raw.filter((e) => isSafe(e.path) && !isJunk(e.path));
}
function tempDir() {
fs.mkdirSync(EXTRACT_DIR, { recursive: true });
return fs.mkdtempSync(path.join(EXTRACT_DIR, '.tmp-'));
}
// Extracts entries into destDir. Returns Map(entry path -> absolute path) for
// the ones that came out; archive-level failures throw.
async function extractMany(file, entryPaths, destDir) {
const abs = path.resolve(file.file_path);
const wanted = new Set(entryPaths.filter(isSafe));
if (isTar(file.format)) {
await tar.x({ file: abs, cwd: destDir, filter: (p) => wanted.has(p) });
} else {
const { code, error } = await sevenZip({ op: 'extract', archive: abs, entries: [...wanted], destDir });
if (code !== 0 && error) console.warn(`7-Zip reported a problem extracting from ${abs}: ${error}`);
}
const out = new Map();
for (const p of wanted) {
const target = path.resolve(destDir, p);
if (target.startsWith(destDir + path.sep) && fs.existsSync(target)) out.set(p, target);
}
return out;
}
function entryCachePath(fileId, entry) {
return path.join(EXTRACT_DIR, String(fileId), `${entry.id}${path.extname(entry.path).toLowerCase()}`);
}
const inflight = new Map();
// file: { id, file_path, format }; entry: { id, path }. Returns the absolute
// path of a cached copy, extracting it on first use.
async function extractEntry(file, entry) {
const dest = entryCachePath(file.id, entry);
if (fs.existsSync(dest)) return dest;
if (!inflight.has(dest)) {
const job = (async () => {
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = tempDir();
try {
const got = (await extractMany(file, [entry.path], tmp)).get(entry.path);
if (!got) throw new Error(`Could not extract ${clean(entry.path)} from the archive`);
fs.renameSync(got, dest);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
})().finally(() => inflight.delete(dest));
inflight.set(dest, job);
}
await inflight.get(dest);
return dest;
}
function removeExtracted(fileId) {
fs.rmSync(path.join(EXTRACT_DIR, String(fileId)), { recursive: true, force: true });
}
module.exports = { listEntries, extractMany, extractEntry, removeExtracted, tempDir, isJunk };

View file

@ -73,6 +73,6 @@ function hashResetToken(token) {
} }
module.exports = { module.exports = {
COOKIE, getSecret, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie, COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
currentUser, requireAuth, makeResetToken, hashResetToken, currentUser, requireAuth, makeResetToken, hashResetToken,
}; };

View file

@ -1,37 +1,32 @@
// Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can // Converts 3MF files to GLB via the Python/trimesh sidecar, so the browser can
// render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't), // render Bambu/MakerWorld production-extension 3MFs (which the JS 3MFLoader can't).
// and reads their slicer project details (plates, filaments).
const { runScript } = require('./python'); const path = require('path');
const { execFile } = require('child_process');
// Parses a sidecar script's last JSON line, turning its error / failure into a throw. const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
function parseResult({ err, stdout, stderr }, what) { const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
let result = null;
// Returns { glb, volume_cm3, bbox, faces } or throws.
function convert3mfToGlb(inputPath, outputPath) {
return new Promise((resolve, reject) => {
execFile(
PYTHON,
[SCRIPT, inputPath, outputPath],
{ maxBuffer: 16 * 1024 * 1024, timeout: 120000 },
(err, stdout, stderr) => {
if (err) return reject(new Error(stderr ? stderr.split('\n').slice(-3).join(' ') : err.message));
let result;
try { try {
result = JSON.parse(stdout.trim().split('\n').pop()); result = JSON.parse(stdout.trim().split('\n').pop());
} catch { /* handled below */ } } catch {
if (result && result.error) throw new Error(result.error); return reject(new Error('Converter returned unparseable output.'));
if (err) {
if (err.killed) throw new Error(`Timed out ${what}.`);
throw new Error(stderr ? stderr.trim().split('\n').slice(-3).join(' ') : err.message);
} }
if (!result) throw new Error(`Unparseable output ${what}.`); if (result.error) return reject(new Error(result.error));
return result; resolve(result);
}
);
});
} }
// Returns { glb, plate, volume_cm3, bbox, faces } or throws. `plate` limits the module.exports = { convert3mfToGlb };
// output to one plate of a slicer project.
async function convert3mfToGlb(inputPath, outputPath, { plate = null } = {}) {
const args = [inputPath, outputPath];
if (plate) args.push('--plate', String(plate));
return parseResult(await runScript('convert_3mf.py', args, { timeout: 120000 }), 'converting this 3MF for preview');
}
// Returns { plates: [{ index, name, objectCount, thumbnail }], filaments: [{ colour, type }] }.
// Plate previews are written into imagesDir as <n>.png / <n>-small.png.
async function readProjectInfo(inputPath, imagesDir) {
return parseResult(await runScript('threemf_info.py', [inputPath, '--images', imagesDir], { timeout: 60000 }),
'reading this 3MF project');
}
module.exports = { convert3mfToGlb, readProjectInfo };

View file

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

View file

@ -5,25 +5,18 @@ const AdmZip = require('adm-zip');
const THUMBNAIL_DIR = './thumbnails'; const THUMBNAIL_DIR = './thumbnails';
const MODEL_FORMATS = ['stl', '3mf', 'obj', 'fbx', 'step', 'stp', '3dm', 'gcode', 'gco']; const MODEL_FORMATS = ['stl', '3mf', 'obj', 'step', 'stp', 'gcode', 'gco', 'zip'];
const ARCHIVE_FORMATS = ['zip', '7z', 'rar', 'tar', 'tgz'];
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
// Kept with a model and viewable, but never matched as duplicates.
const COMPANION_FORMATS = [...IMAGE_FORMATS, 'pdf', 'txt', 'md', 'mtl'];
const ALLOWED_FORMATS = [...MODEL_FORMATS, ...ARCHIVE_FORMATS, ...COMPANION_FORMATS];
function ensureThumbnailDir() { function ensureThumbnailDir() {
if (!fs.existsSync(THUMBNAIL_DIR)) fs.mkdirSync(THUMBNAIL_DIR, { recursive: true }); if (!fs.existsSync(THUMBNAIL_DIR)) fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
} }
function formatOf(filename) { function formatOf(filename) {
const lower = filename.toLowerCase(); return path.extname(filename).slice(1).toLowerCase();
if (lower.endsWith('.tar.gz')) return 'tgz';
return path.extname(lower).slice(1);
} }
function isAllowed(filename) { function isAllowed(filename) {
return ALLOWED_FORMATS.includes(formatOf(filename)); return MODEL_FORMATS.includes(formatOf(filename));
} }
// Geometry metadata. Only STL is computed natively today; others return nulls // Geometry metadata. Only STL is computed natively today; others return nulls
@ -44,28 +37,16 @@ function extractMetadata(filePath, format) {
} }
} }
// Which embedded PNG makes the best card image: the project thumbnail, then the
// first plate render. Slicer pick maps, top views and small/unlit variants last.
function thumbnailRank(name) {
if (name === 'metadata/thumbnail.png') return 0;
if (/metadata\/plate_1\.png$/.test(name)) return 1;
if (/metadata\/plate_\d+\.png$/.test(name)) return 2;
if (/(pick_|top_|no_light|_small)/.test(name)) return 9;
return 5;
}
// A .3mf is a zip; slicers embed a PNG preview under /Metadata/. If present, // A .3mf is a zip; slicers embed a PNG preview under /Metadata/. If present,
// pull it out as the model thumbnail. Returns a path under thumbnails/ or null. // pull it out as the model thumbnail. Returns a path under thumbnails/ or null.
function extract3mfThumbnail(filePath, baseName) { function extract3mfThumbnail(filePath, baseName) {
try { try {
ensureThumbnailDir(); ensureThumbnailDir();
const zip = new AdmZip(filePath); const zip = new AdmZip(filePath);
const entry = zip.getEntries() const entry = zip.getEntries().find((e) => {
.filter((e) => {
const n = e.entryName.toLowerCase(); const n = e.entryName.toLowerCase();
return n.endsWith('.png') && (n.includes('thumbnail') || n.includes('metadata/')); return n.endsWith('.png') && (n.includes('thumbnail') || n.includes('/metadata/'));
}) });
.sort((a, b) => thumbnailRank(a.entryName.toLowerCase()) - thumbnailRank(b.entryName.toLowerCase()))[0];
if (!entry) return null; if (!entry) return null;
const out = path.join(THUMBNAIL_DIR, `${baseName}.png`); const out = path.join(THUMBNAIL_DIR, `${baseName}.png`);
fs.writeFileSync(out, entry.getData()); fs.writeFileSync(out, entry.getData());
@ -76,21 +57,4 @@ function extract3mfThumbnail(filePath, baseName) {
} }
} }
// Copy a picture that came with a model into thumbnails/ (a copy, so replacing module.exports = { MODEL_FORMATS, formatOf, isAllowed, extractMetadata, extract3mfThumbnail };
// the thumbnail later never deletes the model's own file).
function copyAsThumbnail(filePath, baseName) {
try {
ensureThumbnailDir();
const out = path.join(THUMBNAIL_DIR, `${baseName}-image${path.extname(filePath).toLowerCase()}`);
fs.copyFileSync(filePath, out);
return out;
} catch (err) {
console.error('Image thumbnail copy failed:', err.message);
return null;
}
}
module.exports = {
MODEL_FORMATS, ARCHIVE_FORMATS, IMAGE_FORMATS, COMPANION_FORMATS, ALLOWED_FORMATS,
formatOf, isAllowed, extractMetadata, extract3mfThumbnail, copyAsThumbnail,
};

View file

@ -1,240 +0,0 @@
// Background worker that indexes and fingerprints stored files for the
// duplicate finder: a SHA-256 of the bytes, 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). Archives are listed, and the
// models inside them are fingerprinted too.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
const store = require('./store');
const archives = require('./archives');
const thumbnails = require('./thumbnails');
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
const { PYTHON, runScript } = require('./python');
const GEOMETRY_FORMATS = ['stl', 'obj', '3mf'];
const BATCH = 10;
const TABLES = ['files', 'archive_entries'];
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.
async function measure(paths) {
const { err, stdout } = await runScript('fingerprint.py', paths,
{ timeout: 60000 * (paths.length + 1), isBackground: true });
if (err && err.code === 'ENOENT') throw err;
const results = new Map();
for (const line of stdout.split('\n')) {
if (!line.trim()) continue;
try {
const r = JSON.parse(line);
results.set(r.path, r);
} catch { /* ignore non-JSON noise */ }
}
return results;
}
// rows: [{ id, format, abs }] from `table` ('files' or 'archive_entries').
async function fingerprintGeometry(rows, table) {
if (!TABLES.includes(table)) throw new Error(`Unknown table ${table}`);
// 3MF projects can be big, so each gets its own run; that keeps a queued
// preview from waiting behind a whole batch of them.
const chunks = [rows.filter((r) => r.format !== '3mf'), ...rows.filter((r) => r.format === '3mf').map((r) => [r])];
for (const chunk of chunks.filter((c) => c.length)) {
const results = await measure(chunk.map((r) => r.abs));
for (const row of chunk) {
let r = results.get(row.abs);
// A batch that died part-way: retry the missing file on its own.
if (!r && chunk.length > 1) r = (await measure([row.abs])).get(row.abs);
const key = r && r.fp ? geometryKey(r.fp) : null;
await db.query(`UPDATE ${table} SET geometry_hash = $2, geometry_status = $3 WHERE id = $1`,
[row.id, key, key ? 'ok' : 'error']);
}
}
}
// Runs fn; if Python turns out to be missing, geometry fingerprints are switched
// off for this process (content hashes still run).
async function withPython(fn) {
try {
await fn();
} catch (err) {
if (err.code !== 'ENOENT') throw err;
pythonAvailable = false;
console.warn(`Geometry fingerprints disabled: no Python at ${PYTHON} (content hashes still run).`);
}
}
async function fingerprintFiles() {
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 false;
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({ id: row.id, format: row.format, abs });
}
} 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) await withPython(() => fingerprintGeometry(geometry, 'files'));
return true;
}
async function indexArchives() {
const { rows } = await db.query(
`SELECT id, file_path, format FROM files
WHERE format = ANY($1) AND archive_status IS NULL ORDER BY id LIMIT 5`, [ARCHIVE_FORMATS]);
for (const file of rows) {
try {
const entries = await archives.listEntries(file);
await store.replaceArchiveEntries(file.id,
entries.map((e) => ({ path: e.path, size: e.size, format: formatOf(e.path) })));
await db.query(`UPDATE files SET archive_status = 'ok', archive_error = NULL WHERE id = $1`, [file.id]);
} catch (err) {
console.warn(`Could not read archive (file ${file.id}):`, err.message);
await db.query(`UPDATE files SET archive_status = 'error', archive_error = $2 WHERE id = $1`,
[file.id, err.message.slice(0, 300)]);
}
}
return rows.length > 0;
}
const ENTRY_PENDING = 'e.fingerprinted_at IS NULL OR (e.geometry_status IS NULL AND $1::boolean)';
// Fingerprints the pending entries of one archive, extracting its models in a
// single pass (cheaper than one extraction per entry, especially for solid 7z).
async function fingerprintEntries() {
const { rows: [file] } = await db.query(
`SELECT f.id, f.file_path, f.format FROM files f
WHERE EXISTS (SELECT 1 FROM archive_entries e WHERE e.file_id = f.id AND (${ENTRY_PENDING}))
ORDER BY f.id LIMIT 1`, [pythonAvailable]);
if (!file) return false;
const { rows: entries } = await db.query(
`SELECT e.id, e.path, e.format, e.fingerprinted_at FROM archive_entries e
WHERE e.file_id = $2 AND (${ENTRY_PENDING}) ORDER BY e.id LIMIT 500`, [pythonAvailable, file.id]);
// Pictures, documents and nested archives are listed but not matched as duplicates.
const others = entries.filter((e) => !MODEL_FORMATS.includes(e.format));
if (others.length) {
await db.query(`UPDATE archive_entries SET fingerprinted_at = now(), geometry_status = 'na' WHERE id = ANY($1::int[])`,
[others.map((e) => e.id)]);
}
const models = entries.filter((e) => MODEL_FORMATS.includes(e.format));
if (!models.length) return true;
const tmp = archives.tempDir();
try {
let extracted = new Map();
try {
extracted = await archives.extractMany(file, models.map((e) => e.path), tmp);
} catch (err) {
console.warn(`Could not extract from archive (file ${file.id}):`, err.message);
}
const geometry = [];
for (const entry of models) {
const abs = extracted.get(entry.path);
try {
if (!abs) {
await db.query(`UPDATE archive_entries SET fingerprinted_at = now(), geometry_status = 'error' WHERE id = $1`, [entry.id]);
continue;
}
if (!entry.fingerprinted_at) {
await db.query('UPDATE archive_entries SET sha256 = $2, fingerprinted_at = now() WHERE id = $1',
[entry.id, await sha256File(abs)]);
}
if (!GEOMETRY_FORMATS.includes(entry.format)) {
await db.query(`UPDATE archive_entries SET geometry_status = 'na' WHERE id = $1`, [entry.id]);
} else if (pythonAvailable) {
geometry.push({ id: entry.id, format: entry.format, abs });
}
} catch (err) {
console.warn(`Fingerprint failed for archive entry ${entry.id}:`, err.message);
await db.query(`UPDATE archive_entries SET fingerprinted_at = now(), geometry_status = 'error' WHERE id = $1`, [entry.id]);
}
}
if (geometry.length) await withPython(() => fingerprintGeometry(geometry, 'archive_entries'));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
return true;
}
async function run() {
for (;;) {
// Thumbnails go last so they can use what's inside newly indexed archives.
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()];
if (!worked.some(Boolean)) return;
}
}
// 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 (SELECT COUNT(*) FROM files
WHERE fingerprinted_at IS NULL OR (geometry_status IS NULL AND $1::boolean))
+ (SELECT COUNT(*) FROM files WHERE format = ANY($2) AND archive_status IS NULL)
+ (SELECT COUNT(*) FROM archive_entries e WHERE ${ENTRY_PENDING}) AS n`,
[pythonAvailable, ARCHIVE_FORMATS]);
return Number(rows[0].n);
}
module.exports = { kick, pendingCount, geometryKey, sha256File };

View file

@ -1,46 +0,0 @@
// Links for "Open in slicer". The slicer app downloads the file itself, outside
// the browser and without the session cookie, so it gets a short-lived link that
// grants just that one file.
const crypto = require('crypto');
const path = require('path');
const jwt = require('jsonwebtoken');
const TTL_SECONDS = 10 * 60;
const AUDIENCE = 'printhive-handoff';
const KINDS = ['file', 'entry'];
// What slicers open: meshes, projects and STEP (Bambu Studio / OrcaSlicer import STEP).
const SLICEABLE = ['3mf', 'stl', 'obj', 'step', 'stp'];
// Keyed off the session secret but never equal to it, so a hand-off link can't
// pass as a login and a login can't pass as a hand-off link.
const keyFor = (secret) => crypto.createHmac('sha256', secret).update('handoff').digest('hex');
function createToken(secret, { kind, id }, ttl = TTL_SECONDS) {
if (!KINDS.includes(kind)) throw new Error(`Unknown hand-off kind: ${kind}`);
return jwt.sign({ k: kind, id: Number(id) }, keyFor(secret), { audience: AUDIENCE, expiresIn: ttl, algorithm: 'HS256' });
}
// { kind, id }, or null for a link that's expired, altered or not a hand-off link.
function readToken(secret, token) {
try {
const p = jwt.verify(String(token), keyFor(secret), { audience: AUDIENCE, algorithms: ['HS256'] });
return KINDS.includes(p.k) && Number.isInteger(p.id) ? { kind: p.k, id: p.id } : null;
} catch {
return null;
}
}
// Slicers name the download after the link's last path segment and decode the
// link in different ways, so that segment is kept to plain ASCII.
function urlName(name) {
const ext = path.extname(String(name)).toLowerCase().replace(/[^\w.]/g, '');
const base = path.basename(String(name), path.extname(String(name)))
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '') // accents split off by NFKD: "é" -> "e"
.replace(/[^\w.-]+/g, '_')
.replace(/^[_.-]+|[_.-]+$/g, '')
.slice(0, 80);
return `${base || 'model'}${ext}`;
}
module.exports = { TTL_SECONDS, SLICEABLE, createToken, readToken, urlName };

View file

@ -1,42 +0,0 @@
// Runs the Python/trimesh sidecar scripts one at a time. Loading a large mesh
// can take hundreds of MB, so a preview conversion and background
// fingerprinting never run at once. Jobs someone is waiting on (previews) go
// ahead of queued background work.
const path = require('path');
const { execFile } = require('child_process');
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
const TOOLS = path.join(__dirname, '..', 'tools');
const urgent = [];
const background = [];
let busy = false;
// Resolves { err, stdout, stderr } — never rejects, so callers can use partial
// output from a run that failed part-way.
function runScript(script, args, { timeout = 120000, isBackground = false } = {}) {
return new Promise((resolve) => {
(isBackground ? background : urgent).push({ script, args, timeout, resolve });
next();
});
}
function next() {
if (busy) return;
const job = urgent.shift() || background.shift();
if (!job) return;
busy = true;
execFile(
PYTHON,
[path.join(TOOLS, job.script), ...job.args],
{ maxBuffer: 16 * 1024 * 1024, timeout: job.timeout },
(err, stdout, stderr) => {
busy = false;
job.resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
next();
}
);
}
module.exports = { PYTHON, runScript };

View file

@ -1,11 +1,9 @@
const db = require('./db'); const db = require('./db');
const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
// ---- shared SELECT that hydrates a model with tags, projects, files, designer ---- // ---- shared SELECT that hydrates a model with tags, projects, files, designer ----
const MODEL_SELECT = ` const MODEL_SELECT = `
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id, 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.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, d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
COALESCE(t.tags, '[]'::json) AS tags, COALESCE(t.tags, '[]'::json) AS tags,
COALESCE(p.projects, '[]'::json) AS projects, COALESCE(p.projects, '[]'::json) AS projects,
@ -26,8 +24,7 @@ const MODEL_SELECT = `
SELECT json_agg(json_build_object( SELECT json_agg(json_build_object(
'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name, 'id', fl.id, 'originalName', fl.original_name, 'fileName', fl.file_name,
'format', fl.format, 'size', fl.size, 'volume', fl.volume, 'format', fl.format, 'size', fl.size, 'volume', fl.volume,
'bbox', fl.bbox, 'facets', fl.facets, 'archiveStatus', fl.archive_status, 'bbox', fl.bbox, 'facets', fl.facets) ORDER BY fl.id) AS files
'entryCount', (SELECT COUNT(*)::int FROM archive_entries ae WHERE ae.file_id = fl.id)) ORDER BY fl.id) AS files
FROM files fl WHERE fl.model_id = m.id FROM files fl WHERE fl.model_id = m.id
) f ON true ) f ON true
`; `;
@ -42,11 +39,8 @@ function shapeModel(row) {
} }
// ---------------------------------------------------------------- models // ---------------------------------------------------------------- models
// The library, or with { trash: true } the models in the trash. async function getAllModels() {
async function getAllModels({ trash = false } = {}) { const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
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); return rows.map(shapeModel);
} }
@ -55,15 +49,9 @@ async function getModel(id) {
return shapeModel(rows[0]); 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) { async function findModelBySource(site, sourceId) {
const { rows } = await db.query( const { rows } = await db.query(
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2 AND m.deleted_at IS NULL`, [site, sourceId]); `${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2`, [site, sourceId]);
return shapeModel(rows[0]); return shapeModel(rows[0]);
} }
@ -99,7 +87,7 @@ async function updateModel(id, updates) {
const fields = []; const fields = [];
const values = []; const values = [];
let i = 1; let i = 1;
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) { for (const col of ['name', 'description', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); } if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
} }
if (updates.print_settings !== undefined) { if (updates.print_settings !== undefined) {
@ -118,159 +106,20 @@ async function updateModel(id, updates) {
// Returns the on-disk file rows so the caller can unlink them. // Returns the on-disk file rows so the caller can unlink them.
async function deleteModel(id) { async function deleteModel(id) {
const { rows } = await db.query('SELECT id, file_path, glb_path FROM files WHERE model_id = $1', [id]); const { rows } = await db.query('SELECT file_path, glb_path FROM files WHERE model_id = $1', [id]);
const entries = await db.query(
'SELECT e.id FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = $1', [id]);
const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]); const model = await db.query('SELECT thumbnail_path FROM models WHERE id = $1', [id]);
const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]); const result = await db.query('DELETE FROM models WHERE id = $1 RETURNING id', [id]);
if (result.rowCount === 0) return null; if (result.rowCount === 0) return null;
return { return {
fileIds: rows.map((r) => r.id),
entryIds: entries.rows.map((e) => e.id),
filePaths: rows.map((r) => r.file_path), filePaths: rows.map((r) => r.file_path),
glbPaths: [ glbPaths: rows.map((r) => r.glb_path).filter(Boolean),
...rows.map((r) => r.glb_path).filter(Boolean),
...entries.rows.map((e) => `glb/entry-${e.id}.glb`),
],
thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path, thumbnailPath: model.rows[0] && model.rows[0].thumbnail_path,
}; };
} }
// ---------------------------------------------------------------- files / archive entries
async function getFile(id) {
const { rows } = await db.query('SELECT * FROM files WHERE id = $1', [id]);
return rows[0] || null;
}
async function getArchiveEntries(fileId) {
const { rows } = await db.query(
'SELECT id, path, format, size FROM archive_entries WHERE file_id = $1 ORDER BY path', [fileId]);
return rows.map((r) => ({ ...r, size: Number(r.size) }));
}
// An entry plus the archive it lives in ({ id, file_path, format }).
async function getArchiveEntry(id) {
const { rows } = await db.query(
`SELECT e.id, e.file_id, e.path, e.format, e.size, f.file_path, f.format AS archive_format
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE e.id = $1`, [id]);
const r = rows[0];
if (!r) return null;
return {
id: r.id, file_id: r.file_id, path: r.path, format: r.format, size: Number(r.size),
archive: { id: r.file_id, file_path: r.file_path, format: r.archive_format },
};
}
async function replaceArchiveEntries(fileId, entries) {
await db.transaction(async (client) => {
await client.query('DELETE FROM archive_entries WHERE file_id = $1', [fileId]);
if (!entries.length) return;
await client.query(
`INSERT INTO archive_entries (file_id, path, format, size)
SELECT $1::int, * FROM unnest($2::text[], $3::text[], $4::bigint[])
ON CONFLICT (file_id, path) DO NOTHING`,
[fileId, entries.map((e) => e.path), entries.map((e) => e.format), entries.map((e) => e.size)]);
});
}
// ---------------------------------------------------------------- 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.
// Ids are prefixed ('f12' for a file, 'e7' for an archive entry) so both kinds
// can be matched together. Pictures and documents never count.
async function duplicateCandidateFiles() {
const { rows } = await db.query(`
WITH live AS (
SELECT 'f' || f.id AS 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 AND f.format = ANY($1)
UNION ALL
SELECT 'e' || e.id, f.model_id, e.sha256, e.geometry_hash
FROM archive_entries e JOIN files f ON f.id = e.file_id JOIN models m ON m.id = f.model_id
WHERE m.deleted_at IS NULL AND e.size > 0 AND e.format = ANY($2)
)
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)`,
[[...MODEL_FORMATS, ...ARCHIVE_FORMATS], MODEL_FORMATS]);
return rows;
}
// How many duplicate-matchable items (model files, archives, models inside
// archives) each model holds: { modelId: n }.
async function matchableCounts(modelIds) {
if (!modelIds.length) return {};
const { rows } = await db.query(`
SELECT m.id,
(SELECT COUNT(*) FROM files f WHERE f.model_id = m.id AND f.format = ANY($2))
+ (SELECT COUNT(*) FROM archive_entries e JOIN files f ON f.id = e.file_id
WHERE f.model_id = m.id AND e.format = ANY($3)) AS n
FROM models m WHERE m.id = ANY($1::int[])`,
[modelIds, [...MODEL_FORMATS, ...ARCHIVE_FORMATS], MODEL_FORMATS]);
return Object.fromEntries(rows.map((r) => [r.id, Number(r.n)]));
}
// 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) { async function addFiles(modelId, files) {
await db.transaction(async (client) => { await db.transaction(async (client) => {
for (const file of files) await insertFile(client, modelId, file); for (const file of files) await insertFile(client, modelId, file);
// New files may give the background worker something to make a thumbnail from.
await client.query('UPDATE models SET thumbnail_status = NULL WHERE id = $1', [modelId]);
}); });
return getModel(modelId); return getModel(modelId);
} }
@ -327,10 +176,9 @@ async function setProjects(client, modelId, projectIds) {
async function getAllProjects() { async function getAllProjects() {
const { rows } = await db.query(` const { rows } = await db.query(`
SELECT p.id, p.name, p.description, p.date_created, p.date_modified, SELECT p.id, p.name, p.description, p.date_created, p.date_modified,
COUNT(m.id)::int AS model_count COUNT(mp.model_id)::int AS model_count
FROM projects p FROM projects p
LEFT JOIN model_projects mp ON mp.project_id = p.id 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`); GROUP BY p.id ORDER BY p.name`);
return rows; return rows;
} }
@ -473,11 +321,8 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
} }
module.exports = { module.exports = {
getAllModels, getModel, getModelsByIds, findModelBySource, createModel, updateModel, deleteModel, addFiles, getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
updateFileConversion, updateFileConversion,
trashModels, restoreModel, trashedModelIds, countTrashed,
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
getAllTags, getAllTags,
getAllProjects, getProject, createProject, updateProject, deleteProject, getAllProjects, getProject, createProject, updateProject, deleteProject,
getSetting, setSetting, getSetting, setSetting,

View file

@ -1,104 +0,0 @@
// Background thumbnails for models that don't have one. Tries, in order: an
// embedded 3MF preview, a picture uploaded with the model, a 3MF preview or
// picture inside one of its archives, then a server-side render of an
// STL/OBJ/3MF (or one inside an archive). Models whose only 3D files are
// FBX/STEP/3DM are left to the browser, which can render those.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const archives = require('./archives');
const fileProcessor = require('./fileProcessor');
const { runScript } = require('./python');
const THUMBNAIL_DIR = './thumbnails';
const PICTURES = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
const RENDERABLE = ['3mf', 'stl', 'obj']; // 3MF first: slicer projects usually embed a preview
const MAX_ATTEMPTS = 3; // per kind of source, so a huge pack doesn't stall the queue
const BATCH = 5;
// Models still waiting: no thumbnail, not given up on, and archives already
// indexed (so what's inside them can be considered).
const PENDING = `
m.thumbnail_path IS NULL AND m.thumbnail_status IS NULL AND m.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.model_id = m.id AND f.format = ANY($1) AND f.archive_status IS NULL)`;
async function render(input, base) {
const out = path.join(THUMBNAIL_DIR, `${base}.png`);
fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
const { err } = await runScript('render_thumbnail.py', [input, path.resolve(out)],
{ timeout: 180000, isBackground: true });
if (err && err.code === 'ENOENT') throw err;
return !err && fs.existsSync(out) ? out : null;
}
// Returns a path under thumbnails/ or null.
async function thumbnailFor(modelId) {
const { rows: files } = await db.query(
'SELECT id, file_path, format FROM files WHERE model_id = $1 ORDER BY id', [modelId]);
const base = `model-${modelId}-${Date.now()}`;
for (const f of files.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(path.resolve(f.file_path), base);
if (out) return out;
}
const picture = files.find((f) => PICTURES.includes(f.format));
if (picture) return fileProcessor.copyAsThumbnail(path.resolve(picture.file_path), base);
const archiveFiles = files.filter((f) => fileProcessor.ARCHIVE_FORMATS.includes(f.format));
const { rows: entries } = archiveFiles.length
? await db.query('SELECT id, file_id, path, format FROM archive_entries WHERE file_id = ANY($1::int[]) ORDER BY path',
[archiveFiles.map((f) => f.id)])
: { rows: [] };
const extract = (entry) => archives.extractEntry(archiveFiles.find((f) => f.id === entry.file_id), entry);
for (const e of entries.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(await extract(e), base);
if (out) return out;
}
const entryPicture = entries.find((e) => PICTURES.includes(e.format));
if (entryPicture) return fileProcessor.copyAsThumbnail(await extract(entryPicture), base);
const sources = [
...RENDERABLE.flatMap((fmt) => files.filter((f) => f.format === fmt).map((f) => () => path.resolve(f.file_path))),
...RENDERABLE.flatMap((fmt) => entries.filter((e) => e.format === fmt).map((e) => () => extract(e))),
];
for (const source of sources.slice(0, MAX_ATTEMPTS)) {
const out = await render(await source(), base);
if (out) return out;
}
return null;
}
// One batch; returns whether there was anything to do.
async function generate() {
const { rows } = await db.query(
`SELECT m.id FROM models m WHERE ${PENDING} ORDER BY m.id LIMIT $2`, [fileProcessor.ARCHIVE_FORMATS, BATCH]);
for (const { id } of rows) {
let out = null;
let status = 'none';
try {
out = await thumbnailFor(id);
} catch (err) {
console.warn(`Thumbnail failed for model ${id}:`, err.message);
status = 'error';
}
if (out) {
// Don't overwrite a thumbnail set meanwhile (e.g. rendered by a browser).
const { rowCount } = await db.query(
'UPDATE models SET thumbnail_path = $2 WHERE id = $1 AND thumbnail_path IS NULL', [id, out]);
if (rowCount === 0) fs.rmSync(out, { force: true });
} else {
await db.query('UPDATE models SET thumbnail_status = $2 WHERE id = $1', [id, status]);
}
}
return rows.length > 0;
}
async function pendingCount() {
const { rows } = await db.query(`SELECT COUNT(*)::int AS n FROM models m WHERE ${PENDING}`,
[fileProcessor.ARCHIVE_FORMATS]);
return rows[0].n;
}
module.exports = { generate, pendingCount };

109
package-lock.json generated
View file

@ -9,7 +9,6 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7z-wasm": "^1.2.0",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -19,42 +18,12 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
"occt-import-js": "^0.0.23", "pg": "^8.22.0"
"pg": "^8.22.0",
"rhino3dm": "^8.32.2",
"tar": "^7.5.22"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
} }
}, },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/7z-wasm": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/7z-wasm/-/7z-wasm-1.2.0.tgz",
"integrity": "sha512-AZjtWXFleAe5nQ27JYISs70cICjO0zZr4LU45I9pXKcrhG0qiEWgq3Vjwr9JHHihFLuqgc7uSHYVn/z3vXCjdw==",
"license": "SEE LICENSE IN License.txt",
"dependencies": {
"readline-sync": "^1.4.10"
},
"bin": {
"7z-wasm": "bin/cli"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -271,15 +240,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@ -985,27 +945,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp": { "node_modules/mkdirp": {
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@ -1152,12 +1091,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/occt-import-js": {
"version": "0.0.23",
"resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz",
"integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==",
"license": "LGPL-2.1"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1425,21 +1358,6 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/readline-sync": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz",
"integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==",
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/rhino3dm": {
"version": "8.32.2",
"resolved": "https://registry.npmjs.org/rhino3dm/-/rhino3dm-8.32.2.tgz",
"integrity": "sha512-Q3/5iHIKJeoE5doXrRy7C1vJer4F8uMYe8K6B8VQHqhGj5BCS1L3EkqNpKyUTERoHyKh7rMmiiZpw9iEUkCnPQ==",
"license": "MIT"
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -1668,22 +1586,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -1783,15 +1685,6 @@
"engines": { "engines": {
"node": ">=0.4" "node": ">=0.4"
} }
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
} }
} }
} }

View file

@ -5,8 +5,7 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js"
"test": "node --test test/*.test.js"
}, },
"keywords": [ "keywords": [
"stl", "stl",
@ -17,7 +16,6 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7z-wasm": "^1.2.0",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@ -27,10 +25,7 @@
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-stl": "^0.7.0", "node-stl": "^0.7.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
"occt-import-js": "^0.0.23", "pg": "^8.22.0"
"pg": "^8.22.0",
"rhino3dm": "^8.32.2",
"tar": "^7.5.22"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

File diff suppressed because it is too large Load diff

View file

@ -84,82 +84,34 @@
<div class="main-content"> <div class="main-content">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-section"> <div class="sidebar-section">
<h3>Library</h3> <h3>Collections</h3>
<div id="libraryList" class="projects-list"> <div id="projectsList" class="projects-list">
<div class="project-item active" data-scope="all"> <div class="project-item active" data-project-id="all">
<span>All Models</span> <span>All Models</span>
<span class="count" id="allFilesCount">0</span> <span class="count" id="allFilesCount">0</span>
</div> </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> </div>
<div class="sidebar-section"> <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> <h3>Tags</h3>
<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>
</div>
<div id="tagsList" class="tags-list"></div> <div id="tagsList" class="tags-list"></div>
<p class="form-hint">Click a tag to include it, again to exclude it.</p> </div>
<div class="sidebar-section">
<h3>Search</h3>
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
</div> </div>
</aside> </aside>
<main class="content"> <main class="content">
<div class="content-header"> <div class="content-header">
<h2 id="contentTitle">All Models</h2> <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"> <div class="view-options">
<button class="view-btn active" data-view="grid">Grid</button> <button class="view-btn active" data-view="grid">Grid</button>
<button class="view-btn" data-view="list">List</button> <button class="view-btn" data-view="list">List</button>
</div> </div>
</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> <div id="fileGrid" class="file-grid"></div>
</main> </main>
@ -219,15 +171,6 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="settingsForm"> <form id="settingsForm">
<h3 class="settings-group-title">Slicer</h3>
<div class="form-group">
<p class="form-hint">
“Open in slicer” uses <strong id="settingsSlicerName">no slicer yet</strong>. This choice is
remembered in this browser only, since each computer has its own slicers installed.
</p>
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
</div>
<h3 class="settings-group-title">Import sources</h3> <h3 class="settings-group-title">Import sources</h3>
<div class="form-group"> <div class="form-group">
@ -338,8 +281,8 @@
<div class="modal-body"> <div class="modal-body">
<form id="uploadForm"> <form id="uploadForm">
<div class="form-group"> <div class="form-group">
<label for="fileInput">Files * <span class="form-hint-inline">(models, archives, pictures, PDFs &amp; readmes — multiple allowed)</span></label> <label for="fileInput">Files * <span class="form-hint-inline">(stl, 3mf, obj, step, gcode, zip — multiple allowed)</span></label>
<input type="file" id="fileInput" accept=".stl,.3mf,.obj,.fbx,.step,.stp,.3dm,.gcode,.gco,.zip,.7z,.rar,.tar,.tgz,.gz,.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg,.pdf,.txt,.md,.mtl" multiple required> <input type="file" id="fileInput" accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip" multiple required>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -418,18 +361,9 @@
<div class="file-detail-content"> <div class="file-detail-content">
<div class="file-detail-preview"> <div class="file-detail-preview">
<div id="detailViewer" class="model-viewer"></div> <div id="detailViewer" class="model-viewer"></div>
<div id="detailPlates" class="plate-chips" hidden></div>
<div id="detailFileTabs" class="file-tabs"></div> <div id="detailFileTabs" class="file-tabs"></div>
<div class="preview-actions">
<span id="slicerBar" class="slicer-bar" hidden>
<button type="button" class="btn btn-primary btn-sm" id="openInSlicerBtn">Open in slicer</button>
<button type="button" class="link-btn slicer-change" id="changeSlicerBtn">change slicer</button>
</span>
<button type="button" class="btn btn-secondary btn-sm drag-out-btn" id="dragOutBtn" draggable="true" hidden>⠿ Drag out</button>
<button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button> <button type="button" class="btn btn-secondary btn-sm" id="regenThumbBtn">Regenerate thumbnail</button>
</div> </div>
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
</div>
<div class="file-detail-info"> <div class="file-detail-info">
<div class="info-section"> <div class="info-section">
<h3>Source</h3> <h3>Source</h3>
@ -444,10 +378,6 @@
<h3>Description</h3> <h3>Description</h3>
<p id="detailDescription"></p> <p id="detailDescription"></p>
</div> </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"> <div class="info-section">
<h3>Tags</h3> <h3>Tags</h3>
<div id="detailTags" class="tags"></div> <div id="detailTags" class="tags"></div>
@ -460,60 +390,23 @@
<h3>Print Settings</h3> <h3>Print Settings</h3>
<div id="detailPrintSettings"></div> <div id="detailPrintSettings"></div>
</div> </div>
<div class="info-section" id="detailFilamentsSection" hidden>
<h3>Filaments</h3>
<div id="detailFilaments" class="filament-list"></div>
</div>
<div class="info-section"> <div class="info-section">
<h3>Files</h3> <h3>Files</h3>
<div id="detailFileInfo"></div> <div id="detailFileInfo"></div>
<button type="button" class="btn btn-secondary btn-sm" id="downloadFilesBtn" style="margin-top:10px; display:none"></button> <button type="button" class="btn btn-secondary btn-sm" id="downloadFilesBtn" style="margin-top:10px; display:none"></button>
</div> </div>
<input type="file" id="addFilesInput" multiple style="display:none" <input type="file" id="addFilesInput" multiple style="display:none"
accept=".stl,.3mf,.obj,.fbx,.step,.stp,.3dm,.gcode,.gco,.zip,.7z,.rar,.tar,.tgz,.gz,.jpg,.jpeg,.png,.gif,.webp,.bmp,.svg,.pdf,.txt,.md,.mtl"> accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip">
<div class="form-actions" id="detailActions"> <div class="form-actions">
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button> <button class="btn btn-danger" id="deleteFileBtn">Delete</button>
<button class="btn btn-secondary" id="addFilesBtn">Add files</button> <button class="btn btn-secondary" id="addFilesBtn">Add files</button>
<button class="btn btn-primary" id="editModelBtn">Edit</button> <button class="btn btn-primary" id="editModelBtn">Edit</button>
</div> </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>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Slicer choice (per browser) -->
<div id="slicerModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Choose your slicer</h2>
<button class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form id="slicerForm">
<p class="form-hint slicer-intro">Your browser can't tell which apps are installed, so pick the slicer this computer uses.</p>
<div id="slicerOptions" class="slicer-options"></div>
<div class="form-group" id="slicerTemplateGroup" hidden>
<label for="slicerTemplate">Open-link template</label>
<input type="text" id="slicerTemplate" placeholder="myslicer://open?file={url}" autocomplete="off" spellcheck="false">
<p class="form-hint">
<code>{url}</code> becomes the file's download link, URL-encoded (<code>{rawurl}</code> leaves it as is).
The link works for 10 minutes.
</p>
</div>
<div id="slicerFormError" class="slicer-error"></div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancelSlicer">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Model Modal --> <!-- Edit Model Modal -->
<div id="editModal" class="modal"> <div id="editModal" class="modal">

View file

@ -110,11 +110,10 @@ header {
transition: all 0.12s; transition: all 0.12s;
} }
.tag:hover { border-color: var(--border-strong); color: var(--text); } .tag:hover { border-color: var(--border-strong); color: var(--text); }
.tag.include { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); } .tag.active { 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 ---- */ /* ---- form fields ---- */
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select { .search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
font-size: 14px; font-size: 14px;
@ -205,8 +204,7 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; } .form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
/* ---- model detail ---- */ /* ---- model detail ---- */
/* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */ .file-detail-content { display: grid; grid-template-columns: 1.1fr 1fr; gap: 26px; }
.file-detail-content { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 26px; }
.file-detail-preview { display: flex; flex-direction: column; gap: 10px; } .file-detail-preview { display: flex; flex-direction: column; gap: 10px; }
.model-viewer { width: 100%; height: 380px; background: radial-gradient(circle at 50% 40%, #232730 0%, #131519 100%); border: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; } .model-viewer { width: 100%; height: 380px; background: radial-gradient(circle at 50% 40%, #232730 0%, #131519 100%); border: 1px solid var(--border); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; }
.model-viewer canvas { display: block; border-radius: var(--radius); } .model-viewer canvas { display: block; border-radius: var(--radius); }
@ -215,34 +213,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.viewer-fallback img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: var(--radius-sm); } .viewer-fallback img { max-width: 100%; max-height: 320px; object-fit: contain; border-radius: var(--radius-sm); }
.viewer-fallback span { color: var(--text-dim); font-size: 12px; text-align: center; } .viewer-fallback span { color: var(--text-dim); font-size: 12px; text-align: center; }
.viewer-image { max-width: 100%; max-height: 100%; object-fit: contain; }
.viewer-pdf, .viewer-text { align-self: stretch; width: 100%; height: 100%; border: 0; border-radius: var(--radius); }
.viewer-pdf { background: #fff; }
.viewer-text { margin: 0; padding: 14px 16px; overflow: auto; font-family: var(--mono); font-size: 12px; line-height: 1.55; color: var(--text); white-space: pre-wrap; word-break: break-word; text-align: left; }
.info-section .entry-list { margin: 2px 0 8px 10px; padding-left: 12px; border-left: 1px solid var(--border); max-height: 260px; overflow-y: auto; }
.info-section .entry-head { font-size: 12px; color: var(--text-dim); margin: 6px 0 2px; }
.info-section .entry-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 0; font-size: 13px; color: var(--text-muted); }
.entry-row > span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.entry-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 11px; margin-left: 4px; }
.entry-row .entry-folder { color: var(--text-dim); margin-left: 4px; }
.row-actions { display: flex; gap: 12px; flex-shrink: 0; align-items: center; }
.link-btn { background: none; border: none; color: var(--accent); font: inherit; cursor: pointer; padding: 0; }
.link-btn:hover { text-decoration: underline; }
.plate-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.plate-chip { display: flex; align-items: center; gap: 6px; min-height: 30px; border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 2px 10px 2px 3px; border-radius: var(--radius-sm); font-family: inherit; font-size: 12px; cursor: pointer; }
.plate-chip:not(:has(img)) { padding-left: 10px; }
.plate-chip img { width: 28px; height: 28px; object-fit: cover; border-radius: 4px; background: var(--surface-3); }
.plate-chip:hover { color: var(--text); border-color: var(--border-strong); }
.plate-chip.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.info-section .filament-list { display: flex; flex-direction: column; gap: 6px; }
.info-section .filament { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.filament em { font-style: normal; font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.swatch { width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.25); flex-shrink: 0; }
.file-tabs { display: flex; flex-wrap: wrap; gap: 6px; } .file-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .file-tab { border: 1px solid var(--border); background: var(--surface-2); color: var(--text-muted); padding: 5px 11px; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; font-family: inherit; }
.file-tab:hover { color: var(--text); } .file-tab:hover { color: var(--text); }
.file-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } .file-tab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
@ -255,28 +227,6 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); } .file-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
.file-row:last-child { border-bottom: none; } .file-row:last-child { border-bottom: none; }
.file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; } .file-row em { color: var(--text-dim); font-style: normal; font-family: var(--mono); font-size: 12px; }
.file-row > span:first-child { min-width: 0; overflow-wrap: anywhere; }
.file-row[draggable="true"], .entry-row[draggable="true"], .drag-out-btn { cursor: grab; }
.drag-out-btn:active { cursor: grabbing; }
/* ---- slicer hand-off ---- */
.preview-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.preview-actions .btn-sm { align-self: auto; }
.slicer-bar { display: inline-flex; align-items: center; gap: 10px; }
.slicer-change { font-size: 12px; color: var(--text-dim); }
.slicer-notice { font-size: 13px; line-height: 1.5; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; }
.slicer-notice.is-warning { border-color: var(--accent); color: var(--text); }
.slicer-notice-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.slicer-notice-actions .btn-sm { align-self: auto; }
.slicer-intro { margin: 0 0 14px; }
.slicer-options { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.slicer-option { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: border-color 0.12s, background 0.12s; }
.slicer-option:hover { border-color: var(--border-strong); }
.slicer-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.slicer-option input { width: auto; margin-top: 3px; accent-color: var(--accent); flex-shrink: 0; }
.slicer-option strong { display: block; font-size: 14px; font-weight: 500; color: var(--text); }
.slicer-option em { display: block; font-style: normal; font-size: 12px; color: var(--text-dim); }
.slicer-error { color: var(--danger); font-size: 13px; min-height: 1em; }
/* ---- import / settings ---- */ /* ---- import / settings ---- */
.import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; } .import-status { margin-top: 14px; color: var(--text-muted); font-size: 14px; min-height: 20px; }
@ -329,49 +279,3 @@ 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 { 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); } .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); }

View file

@ -1,27 +1,16 @@
// In-browser preview for 3D models (STL, OBJ + MTL, 3MF via GLB, FBX, STEP, 3DM) // In-browser 3D preview for STL / 3MF / OBJ files, loaded as an ES module.
// and for the pictures, PDFs and text files that come with them.
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js'; import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Rhino3dmLoader } from 'three/addons/loaders/3DMLoader.js';
// Formats the 3D renderer draws directly (3MF arrives as a server-converted GLB).
const MODEL_FORMATS = ['stl', 'obj', 'glb', 'fbx', '3dm', 'step', 'stp'];
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
const TEXT_FORMATS = ['txt', 'md', 'mtl'];
const DOCUMENT_FORMATS = [...IMAGE_FORMATS, 'pdf', ...TEXT_FORMATS];
// What the app offers a preview for.
const RENDERABLE = ['stl', '3mf', 'obj', 'fbx', '3dm', 'step', 'stp'];
const PREVIEWABLE = [...RENDERABLE, ...DOCUMENT_FORMATS];
// Formats authored Z-up; turned to sit upright in three.js's Y-up world.
const Z_UP = ['stl', 'glb', '3dm', 'step', 'stp'];
// Formats the renderer can draw directly. 3MF is rendered via server-converted
// GLB (see RENDERABLE for what the app treats as previewable input).
const SUPPORTED = ['stl', 'obj', 'glb'];
const RENDERABLE = ['stl', '3mf', 'obj'];
let active = null; // { renderer, controls, rafId } let active = null; // { renderer, controls, rafId }
let renderToken = 0; // bumps on every render, so a slow earlier load can't draw over a newer one
function dispose() { function dispose() {
if (!active) return; if (!active) return;
@ -37,7 +26,8 @@ function message(container, text) {
container.innerHTML = `<div class="viewer-msg">${text}</div>`; container.innerHTML = `<div class="viewer-msg">${text}</div>`;
} }
// When 3D rendering isn't possible, show the model's preview image instead of an error. // When 3D rendering isn't possible (e.g. Bambu/MakerWorld 3MF project files that
// the loader can't parse), show the model's preview image instead of an error.
function showFallback(container, image, text) { function showFallback(container, image, text) {
if (image) { if (image) {
container.innerHTML = container.innerHTML =
@ -47,172 +37,33 @@ function showFallback(container, image, text) {
} }
} }
async function fetchOk(url) { async function loadObject(url, format) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`);
return res;
}
const basename = (p) => String(p).split(/[\\/]/).pop();
function defaultMaterial(color) {
return new THREE.MeshStandardMaterial({ color: color || 0xc4cad6, metalness: 0.15, roughness: 0.65 });
}
// A loading manager that points material and texture references ("wood.png",
// "../tex/wood.png") at the files shipped alongside the model. `own` is the
// URL being loaded itself, which is left alone.
function managerFor(resolve, own) {
const manager = new THREE.LoadingManager();
if (resolve) {
manager.setURLModifier((url) => {
if (url === own || url.startsWith('blob:') || url.startsWith('data:')) return url;
return resolve(decodeURIComponent(basename(url.split('?')[0]))) || url;
});
}
return manager;
}
async function loadObj(url, resolve) {
const text = await (await fetchOk(url)).text();
const loader = new OBJLoader();
const mtllib = /^mtllib\s+(.+)$/m.exec(text);
const mtlUrl = mtllib && resolve ? resolve(basename(mtllib[1].trim())) : null;
let textured = false;
if (mtlUrl) {
try {
const materials = await new MTLLoader(managerFor(resolve, mtlUrl)).loadAsync(mtlUrl);
materials.preload();
loader.setMaterials(materials);
textured = true;
} catch { /* fall back to plain grey */ }
}
const object = loader.parse(text);
if (!textured) object.traverse((c) => { if (c.isMesh) c.material = defaultMaterial(); });
return object;
}
// OpenCascade (WASM) is large, so it's fetched the first time a STEP file is opened.
let occtPromise = null;
function occt() {
if (!occtPromise) {
occtPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = '/vendor/occt/occt-import-js.js';
script.onload = () => resolve(window.occtimportjs({ locateFile: (name) => `/vendor/occt/${name}` }));
script.onerror = () => { occtPromise = null; reject(new Error('Could not load the STEP reader')); };
document.head.appendChild(script);
});
}
return occtPromise;
}
async function loadStep(url) {
const [reader, buffer] = await Promise.all([occt(), fetchOk(url).then((r) => r.arrayBuffer())]);
const result = reader.ReadStepFile(new Uint8Array(buffer), null);
if (!result.success || !result.meshes.length) throw new Error('No geometry in this STEP file');
const group = new THREE.Group();
for (const m of result.meshes) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(m.attributes.position.array, 3));
if (m.attributes.normal) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(m.attributes.normal.array, 3));
geometry.setIndex(Array.from(m.index.array));
if (!m.attributes.normal) geometry.computeVertexNormals();
const color = m.color ? new THREE.Color(m.color[0], m.color[1], m.color[2]) : null;
group.add(new THREE.Mesh(geometry, defaultMaterial(color)));
}
return group;
}
async function load3dm(url) {
const loader = new Rhino3dmLoader().setLibraryPath('/vendor/rhino3dm/');
try {
return await loader.loadAsync(url);
} finally {
loader.dispose();
}
}
async function loadObject(url, format, opts = {}) {
let object;
if (format === 'stl') { if (format === 'stl') {
const geometry = await new STLLoader().loadAsync(url); const geometry = await new STLLoader().loadAsync(url);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
object = new THREE.Mesh(geometry, defaultMaterial()); const material = new THREE.MeshStandardMaterial({ color: 0xc4cad6, metalness: 0.15, roughness: 0.65 });
} else if (format === 'obj') { const mesh = new THREE.Mesh(geometry, material);
object = await loadObj(url, opts.resolve); mesh.rotation.x = -Math.PI / 2; // STL is typically Z-up; sit it flat
} else if (format === 'fbx') { return mesh;
object = await new FBXLoader(managerFor(opts.resolve, url)).loadAsync(url); }
} else if (format === 'glb') { if (format === 'obj') return new OBJLoader().loadAsync(url);
object = (await new GLTFLoader().loadAsync(url)).scene; if (format === '3mf') return new ThreeMFLoader().loadAsync(url);
} else if (format === '3dm') { if (format === 'glb') {
object = await load3dm(url); const gltf = await new GLTFLoader().loadAsync(url);
} else if (format === 'step' || format === 'stp') { gltf.scene.rotation.x = -Math.PI / 2; // 3MF/trimesh is Z-up; sit it upright
object = await loadStep(url); return gltf.scene;
} else { }
throw new Error(`Unsupported format ${format}`); throw new Error(`Unsupported format ${format}`);
}
if (Z_UP.includes(format)) object.rotation.x = -Math.PI / 2;
return object;
}
async function renderDocument(container, url, format, token) {
if (IMAGE_FORMATS.includes(format)) {
const img = document.createElement('img');
img.className = 'viewer-image';
img.alt = '';
img.src = url;
container.appendChild(img);
} else if (format === 'pdf') {
const frame = document.createElement('iframe');
frame.className = 'viewer-pdf';
frame.title = 'PDF preview';
frame.src = url;
container.appendChild(frame);
} else {
let text;
try {
text = await (await fetchOk(url)).text();
} catch {
text = 'Could not load this file.';
}
if (token !== renderToken) return;
const pre = document.createElement('pre');
pre.className = 'viewer-text';
pre.textContent = text.length > 200000 ? `${text.slice(0, 200000)}\n…` : text;
container.appendChild(pre);
}
} }
async function render(container, url, format, opts = {}) { async function render(container, url, format, opts = {}) {
dispose(); dispose();
const token = ++renderToken;
container.innerHTML = ''; container.innerHTML = '';
if (DOCUMENT_FORMATS.includes(format)) { if (!SUPPORTED.includes(format)) {
await renderDocument(container, url, format, token); showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`);
return;
}
if (!MODEL_FORMATS.includes(format)) {
showFallback(container, opts.fallbackImage, opts.message || `No 3D preview for .${format} files.`);
return; return;
} }
message(container, 'Loading preview…');
let object;
try {
object = await loadObject(url, format, opts);
} catch (err) {
console.warn('Preview failed:', err);
if (token !== renderToken) return;
const note = format === 'glb'
? 'Interactive 3D isn’t available for this 3MF.'
: 'This file couldn’t be rendered in 3D.';
showFallback(container, opts.fallbackImage, note);
return;
}
if (token !== renderToken) return; // another file was opened meanwhile
container.innerHTML = '';
const width = container.clientWidth || 600; const width = container.clientWidth || 600;
const height = container.clientHeight || 420; const height = container.clientHeight || 420;
@ -230,6 +81,20 @@ async function render(container, url, format, opts = {}) {
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
let object;
try {
object = await loadObject(url, format);
} catch (err) {
renderer.dispose();
renderer.forceContextLoss();
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
const note = format === '3mf'
? 'Interactive 3D isn’t supported for this 3MF (Bambu/MakerWorld project file).'
: 'This file couldn’t be rendered in 3D.';
showFallback(container, opts.fallbackImage, note);
return;
}
// Center the object and frame the camera around it. // Center the object and frame the camera around it.
const box = new THREE.Box3().setFromObject(object); const box = new THREE.Box3().setFromObject(object);
const size = box.getSize(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3());
@ -258,8 +123,8 @@ async function render(container, url, format, opts = {}) {
// Render a model once to an offscreen canvas and return a PNG data URL. // Render a model once to an offscreen canvas and return a PNG data URL.
// Used to generate card thumbnails. Transparent background so it composites // Used to generate card thumbnails. Transparent background so it composites
// over the card surface. // over the card surface.
async function snapshot(url, format, size = 512, opts = {}) { async function snapshot(url, format, size = 512) {
if (!MODEL_FORMATS.includes(format)) return null; if (!SUPPORTED.includes(format)) return null;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
renderer.setSize(size, size); renderer.setSize(size, size);
@ -272,7 +137,7 @@ async function snapshot(url, format, size = 512, opts = {}) {
const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill); const fill = new THREE.DirectionalLight(0xffffff, 0.35); fill.position.set(-1, 0.5, -1); scene.add(fill);
try { try {
const object = await loadObject(url, format, opts); const object = await loadObject(url, format);
const box = new THREE.Box3().setFromObject(object); const box = new THREE.Box3().setFromObject(object);
const dim = box.getSize(new THREE.Vector3()); const dim = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3());
@ -294,4 +159,4 @@ async function snapshot(url, format, size = 512, opts = {}) {
} }
} }
window.ModelViewer = { render, dispose, snapshot, MODEL_FORMATS, DOCUMENT_FORMATS, RENDERABLE, PREVIEWABLE }; window.ModelViewer = { render, dispose, snapshot, SUPPORTED, RENDERABLE };

374
server.js
View file

@ -19,11 +19,6 @@ const licenses = require('./lib/licenses');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const auth = require('./lib/auth'); const auth = require('./lib/auth');
const mailer = require('./lib/mailer'); const mailer = require('./lib/mailer');
const fingerprint = require('./lib/fingerprint');
const duplicates = require('./lib/duplicates');
const archives = require('./lib/archives');
const thumbnails = require('./lib/thumbnails');
const handoff = require('./lib/handoff');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@ -34,9 +29,6 @@ app.use(express.json({ limit: '8mb' })); // generous for base64 thumbnail PNGs
app.use(cookieParser()); app.use(cookieParser());
app.use(express.static('public')); app.use(express.static('public'));
app.use('/thumbnails', express.static('thumbnails')); app.use('/thumbnails', express.static('thumbnails'));
// In-browser readers for STEP (OpenCascade) and Rhino 3DM, served from node_modules.
app.use('/vendor/occt', express.static(path.join(__dirname, 'node_modules/occt-import-js/dist')));
app.use('/vendor/rhino3dm', express.static(path.join(__dirname, 'node_modules/rhino3dm')));
// Gate the API: everything under /api requires auth except the bootstrap config // Gate the API: everything under /api requires auth except the bootstrap config
// and the auth endpoints themselves. // and the auth endpoints themselves.
@ -63,14 +55,11 @@ function describeFile(filePath, fileName, originalName) {
return { fileName, filePath, originalName, format, size, ...meta }; return { fileName, filePath, originalName, format, size, ...meta };
} }
// Choose a thumbnail for a model from its files: an embedded 3MF preview, else // Choose a thumbnail for a model from its files (embedded 3MF preview, if any).
// a picture that came with the model.
function pickThumbnail(files) { function pickThumbnail(files) {
const threeMf = files.find((f) => f.format === '3mf'); const threeMf = files.find((f) => f.format === '3mf');
const embedded = threeMf && fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name); if (!threeMf) return null;
if (embedded) return embedded; return fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name);
const image = files.find((f) => ['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(f.format));
return image ? fileProcessor.copyAsThumbnail(image.filePath, path.parse(image.fileName).name) : null;
} }
// Transform a stored model into the public shape (thumbnail path -> URL). // Transform a stored model into the public shape (thumbnail path -> URL).
@ -91,7 +80,7 @@ const upload = multer({
storage, storage,
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (fileProcessor.isAllowed(file.originalname)) cb(null, true); if (fileProcessor.isAllowed(file.originalname)) cb(null, true);
else cb(new Error(`Unsupported file type. Allowed: ${fileProcessor.ALLOWED_FORMATS.join(', ')}`)); else cb(new Error(`Unsupported file type. Allowed: ${fileProcessor.MODEL_FORMATS.join(', ')}`));
}, },
}); });
@ -303,7 +292,7 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- models // ----------------------------------------------------------------- models
app.get('/api/models', async (req, res, next) => { app.get('/api/models', async (req, res, next) => {
try { try {
const models = await store.getAllModels({ trash: req.query.view === 'trash' }); const models = await store.getAllModels();
res.json(models.map(publicModel)); res.json(models.map(publicModel));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -332,7 +321,6 @@ app.post('/api/models', upload.array('files'), async (req, res, next) => {
thumbnail_path: pickThumbnail(files), thumbnail_path: pickThumbnail(files),
files, files,
}); });
fingerprint.kick();
res.status(201).json(publicModel(model)); res.status(201).json(publicModel(model));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -344,7 +332,6 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
if (!existing) return res.status(404).json({ error: 'Model not found' }); 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 files = (req.files || []).map((f) => describeFile(f.path, f.filename, f.originalname));
const model = await store.addFiles(req.params.id, files); const model = await store.addFiles(req.params.id, files);
fingerprint.kick();
res.json(publicModel(model)); res.json(publicModel(model));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -416,7 +403,6 @@ app.post('/api/models/:id/download-files', async (req, res, next) => {
return res.status(200).json({ ...publicModel(model), added: 0 }); return res.status(200).json({ ...publicModel(model), added: 0 });
} }
const updated = await store.addFiles(model.id, newFiles); const updated = await store.addFiles(model.id, newFiles);
fingerprint.kick();
res.json({ ...publicModel(updated), added: newFiles.length }); res.json({ ...publicModel(updated), added: newFiles.length });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -443,56 +429,17 @@ async function fetchSourceFiles(model, skipNames) {
throw e; 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) => { app.delete('/api/models/:id', async (req, res, next) => {
try { try {
const model = await store.getModel(req.params.id); const removed = await store.deleteModel(req.params.id);
if (!model) return res.status(404).json({ error: 'Model not found' }); if (!removed) return res.status(404).json({ error: 'Model not found' });
if (req.query.permanent === 'true') { for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
if (!model.deleted_at) { for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' }); if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
} res.json({ message: 'Model deleted' });
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); } } 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;
removed.fileIds.forEach(archives.removeExtracted);
removeDerivedFiles(removed.fileIds, removed.entryIds);
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. // Store a client-rendered thumbnail (PNG data URL) for a model.
app.post('/api/models/:id/thumbnail', async (req, res, next) => { app.post('/api/models/:id/thumbnail', async (req, res, next) => {
try { try {
@ -545,227 +492,45 @@ app.get('/api/models/:id/download', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// Send a stored file as a download, or with ?inline=1 for display in the page // Serve a raw model file (used by the in-browser 3D viewer).
// (images, PDFs, text). SVGs are sandboxed so scripts inside them can't run.
function sendStored(req, res, absPath, name) {
res.set('X-Content-Type-Options', 'nosniff');
const ext = path.extname(name).toLowerCase();
if (ext === '.svg') res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
if (req.query.inline !== '1') return res.download(absPath, name);
if (['.md', '.mtl', '.txt'].includes(ext)) res.type('text/plain');
res.sendFile(absPath);
}
// Serve a stored file (downloads, and the in-browser viewers).
app.get('/api/files/:id/raw', async (req, res, next) => { app.get('/api/files/:id/raw', async (req, res, next) => {
try { try {
const file = await store.getFile(req.params.id); const { rows } = await db.query('SELECT file_path, original_name FROM files WHERE id = $1', [req.params.id]);
if (!file) return res.status(404).json({ error: 'File not found' }); if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
const abs = path.resolve(file.file_path); const abs = path.resolve(rows[0].file_path);
if (!fs.existsSync(abs)) return res.status(404).json({ error: 'File missing on disk' }); if (!fs.existsSync(abs)) return res.status(404).json({ error: 'File missing on disk' });
sendStored(req, res, abs, file.original_name); res.download(abs, rows[0].original_name);
} catch (e) { next(e); }
});
// What's inside an archive file (indexed in the background).
app.get('/api/files/:id/entries', async (req, res, next) => {
try {
const file = await store.getFile(req.params.id);
if (!file) return res.status(404).json({ error: 'File not found' });
if (!fileProcessor.ARCHIVE_FORMATS.includes(file.format)) return res.status(400).json({ error: 'Not an archive' });
res.json({
status: file.archive_status || 'pending',
error: file.archive_error,
entries: await store.getArchiveEntries(file.id),
});
} catch (e) { next(e); }
});
// Serve a file from inside an archive (extracted on first use, then cached).
app.get('/api/entries/:id/raw', async (req, res, next) => {
try {
const entry = await store.getArchiveEntry(req.params.id);
if (!entry) return res.status(404).json({ error: 'Entry not found' });
const abs = await archives.extractEntry(entry.archive, entry);
sendStored(req, res, abs, path.posix.basename(entry.path));
} catch (e) { next(e); }
});
// ----------------------------------------------------------------- slicer hand-off
// "Open in slicer" launches the slicer with a link it downloads itself. The slicer
// has no session cookie, so the link carries a short-lived grant for one file.
async function handoffTarget(kind, id) {
if (!Number.isInteger(Number(id))) return null;
if (kind === 'file') {
const file = await store.getFile(Number(id));
return file && { name: file.original_name, format: file.format, resolve: async () => path.resolve(file.file_path) };
}
if (kind === 'entry') {
const entry = await store.getArchiveEntry(Number(id));
return entry && {
name: path.posix.basename(entry.path), format: entry.format,
resolve: () => archives.extractEntry(entry.archive, entry),
};
}
return null;
}
// -> { path: "/dl/<token>/<name>", name, expiresIn }. The browser adds its own
// origin, which is the address this machine already reaches the server on.
app.post('/api/handoff', async (req, res, next) => {
try {
const { kind, id } = req.body || {};
const target = await handoffTarget(kind, id);
if (!target) return res.status(404).json({ error: 'File not found' });
if (!handoff.SLICEABLE.includes(target.format)) {
return res.status(415).json({ error: `.${target.format} files can't be opened in a slicer` });
}
const token = handoff.createToken(await auth.getSecret(), { kind, id: Number(id) });
res.json({ path: `/dl/${token}/${handoff.urlName(target.name)}`, name: target.name, expiresIn: handoff.TTL_SECONDS });
} catch (e) { next(e); }
});
// Outside /api on purpose: the token is the only credential.
app.get('/dl/:token/:name', async (req, res, next) => {
try {
const grant = handoff.readToken(await auth.getSecret(), req.params.token);
if (!grant) return res.status(403).type('text/plain').send('This download link has expired. Open the model in Printhive again.');
const target = await handoffTarget(grant.kind, grant.id);
if (!target) return res.status(404).type('text/plain').send('File not found');
const abs = await target.resolve();
if (!fs.existsSync(abs)) return res.status(404).type('text/plain').send('File missing on disk');
res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' });
res.download(abs, target.name);
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// Serve a 3MF as GLB, converting on first request (cached thereafter). This lets // Serve a 3MF as GLB, converting on first request (cached thereafter). This lets
// the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox. // the browser render Bambu/MakerWorld 3MFs and backfills volume/bbox.
// ?plate=N converts just that plate of a slicer project. const glbConversions = new Map(); // fileId -> in-flight Promise
const glbConversions = new Map(); // output path -> in-flight Promise
// Converts into a temporary file and renames it into place, so a half-written
// GLB is never served.
function convertOnce(inputPath, outPath, plate = null) {
if (!glbConversions.has(outPath)) {
fs.mkdirSync(path.dirname(outPath), { recursive: true });
const tmp = `${outPath}.part`;
glbConversions.set(outPath, converter.convert3mfToGlb(inputPath, tmp, { plate })
.then((result) => { fs.renameSync(tmp, outPath); return result; })
.catch((err) => {
if (/no plate \d+/.test(err.message)) err.status = 404;
throw err;
})
.finally(() => { fs.rmSync(tmp, { force: true }); glbConversions.delete(outPath); }));
}
return glbConversions.get(outPath);
}
// Bump when the converter's output changes (e.g. filament colours), so previews
// cached by an older version get rebuilt instead of served.
const GLB_VERSION = 2;
const glbName = (base, plate) => `${base}${plate ? `-plate${plate}` : ''}-v${GLB_VERSION}.glb`;
function plateParam(req) {
const n = Number(req.query.plate);
return Number.isInteger(n) && n > 0 ? n : null;
}
app.get('/api/files/:id/glb', async (req, res, next) => { app.get('/api/files/:id/glb', async (req, res, next) => {
try { try {
const file = await store.getFile(req.params.id); const { rows } = await db.query('SELECT id, file_path, format, glb_path FROM files WHERE id = $1', [req.params.id]);
if (!file) return res.status(404).json({ error: 'File not found' }); if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
const file = rows[0];
if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); if (file.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' });
const plate = plateParam(req);
if (plate) {
const platePath = path.resolve('./glb', glbName(file.id, plate));
if (!fs.existsSync(platePath)) await convertOnce(path.resolve(file.file_path), platePath, plate);
return res.sendFile(platePath);
}
let glbPath = file.glb_path; let glbPath = file.glb_path;
if (!glbPath || path.basename(glbPath) !== glbName(file.id) || !fs.existsSync(glbPath)) { if (!glbPath || !fs.existsSync(glbPath)) {
if (glbPath) fs.rmSync(glbPath, { force: true }); // made by an older converter if (!glbConversions.has(file.id)) {
glbPath = path.join('./glb', glbName(file.id)); glbConversions.set(file.id, convertFileToGlb(file).finally(() => glbConversions.delete(file.id)));
const result = await convertOnce(path.resolve(file.file_path), path.resolve(glbPath)); }
await store.updateFileConversion(file.id, { glb_path: glbPath, volume: result.volume_cm3, bbox: result.bbox }); glbPath = await glbConversions.get(file.id);
} }
res.sendFile(path.resolve(glbPath)); res.sendFile(path.resolve(glbPath));
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
app.get('/api/entries/:id/glb', async (req, res, next) => { async function convertFileToGlb(file) {
try { if (!fs.existsSync('./glb')) fs.mkdirSync('./glb', { recursive: true });
const entry = await store.getArchiveEntry(req.params.id); const out = path.join('./glb', `${file.id}.glb`);
if (!entry) return res.status(404).json({ error: 'Entry not found' }); const result = await converter.convert3mfToGlb(path.resolve(file.file_path), path.resolve(out));
if (entry.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' }); await store.updateFileConversion(file.id, { glb_path: out, volume: result.volume_cm3, bbox: result.bbox });
const plate = plateParam(req); return out;
const glbPath = path.resolve('./glb', glbName(`entry-${entry.id}`, plate));
if (!fs.existsSync(glbPath)) await convertOnce(await archives.extractEntry(entry.archive, entry), glbPath, plate);
res.sendFile(glbPath);
} catch (e) { next(e); }
});
// Plates and filaments of a 3MF slicer project (Bambu Studio / OrcaSlicer),
// read once per server run. Plate previews land in thumbnails/plates/.
const PLATE_IMAGE_DIR = './thumbnails/plates';
const projectInfoCache = new Map(); // "<kind>-<id>" -> Promise
async function threeMfPath(kind, id) {
const unsupported = () => Object.assign(new Error('Only 3MF files have plates'), { status: 415 });
if (kind === 'files') {
const file = await store.getFile(id);
if (!file) return null;
if (file.format !== '3mf') throw unsupported();
return path.resolve(file.file_path);
}
const entry = await store.getArchiveEntry(id);
if (!entry) return null;
if (entry.format !== '3mf') throw unsupported();
return archives.extractEntry(entry.archive, entry);
}
for (const kind of ['files', 'entries']) {
app.get(`/api/${kind}/:id/project`, async (req, res, next) => {
try {
const key = `${kind}-${Number(req.params.id)}`;
if (!projectInfoCache.has(key)) {
const abs = await threeMfPath(kind, req.params.id);
if (!abs) return res.status(404).json({ error: 'Not found' });
const imagesDir = path.resolve(PLATE_IMAGE_DIR, key);
const job = converter.readProjectInfo(abs, imagesDir).then((info) => ({
...info,
plates: info.plates.map((p) => {
const image = [`${p.index}-small.png`, `${p.index}.png`].find((n) => fs.existsSync(path.join(imagesDir, n)));
return { ...p, thumbnailUrl: image ? `/thumbnails/plates/${key}/${image}` : null };
}),
}));
projectInfoCache.set(key, job);
job.catch(() => projectInfoCache.delete(key));
}
res.json(await projectInfoCache.get(key));
} catch (e) { next(e); }
});
}
// Removes cached previews (GLBs incl. per-plate ones) and plate images for
// deleted files and archive entries.
function removeDerivedFiles(fileIds, entryIds) {
const owned = new Set([...fileIds.map(String), ...entryIds.map((id) => `entry-${id}`)]);
let names = [];
try { names = fs.readdirSync('./glb'); } catch { /* no previews yet */ }
for (const name of names) {
const m = /^(.+?)(?:-plate\d+)?(?:-v\d+)?\.glb$/.exec(name);
if (m && owned.has(m[1])) fs.rmSync(path.join('./glb', name), { force: true });
}
for (const id of fileIds) {
fs.rmSync(path.join(PLATE_IMAGE_DIR, `files-${id}`), { recursive: true, force: true });
projectInfoCache.delete(`files-${id}`);
}
for (const id of entryIds) {
fs.rmSync(path.join(PLATE_IMAGE_DIR, `entries-${id}`), { recursive: true, force: true });
projectInfoCache.delete(`entries-${id}`);
}
} }
// ----------------------------------------------------------------- import // ----------------------------------------------------------------- import
@ -849,7 +614,6 @@ app.post('/api/import', async (req, res, next) => {
projects, projects,
files, files,
}); });
fingerprint.kick();
res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched }); res.status(201).json({ ...publicModel(model), metadataOnly: files.length === 0, metadataFetched });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@ -862,9 +626,7 @@ async function importThingiverse(url, projects) {
const existing = await store.findModelBySource('thingiverse', String(thingId)); const existing = await store.findModelBySource('thingiverse', String(thingId));
if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; } if (existing) { const e = new Error('Already imported'); e.status = 409; throw e; }
const meta = await thingiverse.fetchModel(thingId, token); const meta = await thingiverse.fetchModel(thingId, token);
const model = await downloadAndCreateModel(meta, projects); return publicModel(await downloadAndCreateModel(meta, projects));
fingerprint.kick();
return publicModel(model);
} }
// Fetch a page's HTML. Tries a direct request first; if the response is a // Fetch a page's HTML. Tries a direct request first; if the response is a
@ -1047,73 +809,6 @@ app.get('/api/tags', async (req, res, next) => {
try { res.json(await store.getAllTags()); } catch (e) { next(e); } 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, thumbnailsPending] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount()]);
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending });
} catch (e) { next(e); }
});
app.get('/api/duplicates', async (req, res, next) => {
try {
const [groups, pending] = await Promise.all([findDuplicateGroups(), fingerprint.pendingCount()]);
const ids = groups.flatMap((g) => g.modelIds);
const [found, counts] = await Promise.all([store.getModelsByIds(ids), store.matchableCounts(ids)]);
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],
matchableFiles: counts[m.id] ?? m.files.length,
})),
}));
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 // ----------------------------------------------------------------- misc
function parseJson(value, fallback) { function parseJson(value, fallback) {
if (value === undefined || value === null || value === '') return fallback; if (value === undefined || value === null || value === '') return fallback;
@ -1127,8 +822,5 @@ app.use((err, req, res, next) => {
}); });
db.init() db.init()
.then(() => { .then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
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); }); .catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });

View file

@ -1,47 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
process.env.EXTRACT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-extract-'));
const archives = require('../lib/archives');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const clean = (p) => p.replace(/^\.\//, '');
// Each pack holds models/cube.stl, models/inner.3mf and readme.txt, plus macOS
// junk (._ files, __MACOSX, .DS_Store); the zip also has a ../ path.
const EXPECTED = ['models/cube.stl', 'models/inner.3mf', 'readme.txt'];
test.after(() => fs.rmSync(process.env.EXTRACT_DIR, { recursive: true, force: true }));
for (const [name, format] of [['pack.zip', 'zip'], ['pack.7z', '7z'], ['pack.tar.gz', 'tgz']]) {
const file = { id: `test-${format}`, file_path: fixture(name), format };
test(`${name}: lists real files, skipping folders, junk and unsafe paths`, async () => {
const entries = await archives.listEntries(file);
assert.deepEqual(entries.map((e) => clean(e.path)).sort(), EXPECTED);
const cube = entries.find((e) => clean(e.path) === 'models/cube.stl');
assert.equal(cube.size, fs.statSync(fixture('cube.stl')).size);
});
test(`${name}: extracts one entry byte-for-byte, and caches it`, async () => {
const entries = await archives.listEntries(file);
const cube = { ...entries.find((e) => clean(e.path) === 'models/cube.stl'), id: 1 };
const abs = await archives.extractEntry(file, cube);
assert.equal(sha(abs), sha(fixture('cube.stl')));
assert.equal(await archives.extractEntry(file, cube), abs);
});
}
test('a missing entry fails clearly instead of returning a path', async () => {
const file = { id: 'test-missing', file_path: fixture('pack.zip'), format: 'zip' };
await assert.rejects(archives.extractEntry(file, { id: 2, path: 'models/nope.stl' }), /Could not extract/);
});
test('an unreadable archive rejects', async () => {
await assert.rejects(archives.listEntries({ file_path: fixture('cube.stl'), format: 'zip' }));
});

View file

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

Binary file not shown.

BIN
test/fixtures/cube.stl vendored

Binary file not shown.

Binary file not shown.

BIN
test/fixtures/pack.7z vendored

Binary file not shown.

Binary file not shown.

BIN
test/fixtures/pack.zip vendored

Binary file not shown.

Binary file not shown.

View file

@ -1,55 +0,0 @@
// Exercises the Python sidecar scripts against small fixtures. Skipped when the
// Python venv isn't installed.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const { geometryKey } = require('../lib/fingerprint');
const TOOLS = path.join(__dirname, '..', 'tools');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
function fingerprints(...names) {
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), ...names.map(fixture)], { encoding: 'utf8' });
return out.trim().split('\n').map((line) => JSON.parse(line));
}
test('a cube matches across binary STL and a rotated, moved 3MF', { skip }, () => {
const [stl, threemf] = fingerprints('cube.stl', 'cube_rotated.3mf');
assert.ok(stl.fp && threemf.fp, JSON.stringify([stl, threemf]));
assert.equal(threemf.fp.faces, 12);
assert.equal(geometryKey(stl.fp), geometryKey(threemf.fp));
});
// components.3mf is laid out like a Bambu project: the root model's objects are
// components pointing (via p:path) at object 1 of a separate part file, which
// also holds a decoy object 2. One placement is mirrored.
test('3MF components resolve to the one object they reference, once per placement', { skip }, () => {
const [r] = fingerprints('components.3mf');
assert.ok(r.fp, JSON.stringify(r));
assert.equal(r.fp.faces, 24);
assert.ok(Math.abs(r.fp.volume - 16000) < 1e-6, `volume ${r.fp.volume}`);
assert.deepEqual(r.fp.extents.map(Math.round).sort((a, b) => a - b), [10, 40, 120]);
});
test('3MF converts to GLB with shared geometry and a combined bounding box', { skip }, () => {
const out = path.join(os.tmpdir(), `printhive-test-${process.pid}.glb`);
try {
const stdout = execFileSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), fixture('components.3mf'), out], { encoding: 'utf8' });
const summary = JSON.parse(stdout.trim().split('\n').pop());
assert.equal(summary.faces, 24);
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [120, 40, 10]);
assert.ok(Math.abs(summary.volume_cm3 - 16) < 1e-6);
const glb = fs.readFileSync(out);
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
assert.equal(gltf.meshes.length, 1, 'the object is stored once');
assert.equal(gltf.nodes.filter((n) => n.mesh !== undefined).length, 2, 'and placed twice');
} finally {
fs.rmSync(out, { force: true });
}
});

View file

@ -1,42 +0,0 @@
// Short-lived "Open in slicer" download links (lib/handoff.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const jwt = require('jsonwebtoken');
const handoff = require('../lib/handoff');
const SECRET = 'a'.repeat(96);
test('a link grants the file it was made for', () => {
const token = handoff.createToken(SECRET, { kind: 'entry', id: '42' });
assert.deepEqual(handoff.readToken(SECRET, token), { kind: 'entry', id: 42 });
});
test('expired, altered or foreign links grant nothing', () => {
const expired = handoff.createToken(SECRET, { kind: 'file', id: 1 }, -5);
assert.equal(handoff.readToken(SECRET, expired), null);
const token = handoff.createToken(SECRET, { kind: 'file', id: 1 });
const [head, , sig] = token.split('.');
const otherBody = Buffer.from(JSON.stringify({ k: 'file', id: 2, aud: 'printhive-handoff', exp: 9999999999 })).toString('base64url');
assert.equal(handoff.readToken(SECRET, `${head}.${otherBody}.${sig}`), null);
assert.equal(handoff.readToken('b'.repeat(96), token), null);
assert.equal(handoff.readToken(SECRET, 'not-a-token'), null);
});
test('a login token is not a hand-off link', () => {
const session = jwt.sign({ sub: 1, email: 'x@example.com' }, SECRET, { expiresIn: '30d' });
assert.equal(handoff.readToken(SECRET, session), null);
// …and a hand-off link doesn't verify as a login.
assert.throws(() => jwt.verify(handoff.createToken(SECRET, { kind: 'file', id: 1 }), SECRET));
});
test('only files and archive entries can be handed off', () => {
assert.throws(() => handoff.createToken(SECRET, { kind: 'model', id: 1 }), /Unknown hand-off kind/);
});
test('link file names stay plain ASCII and keep their extension', () => {
assert.equal(handoff.urlName('Toy Keychain (v2).3mf'), 'Toy_Keychain_v2.3mf');
assert.equal(handoff.urlName('Café déco.STL'), 'Cafe_deco.stl');
assert.equal(handoff.urlName('日本.stl'), 'model.stl');
assert.equal(handoff.urlName('../../etc/passwd.obj'), 'passwd.obj');
});

View file

@ -1,97 +0,0 @@
// Slicer-project handling of 3MF files: plates, filament colours and part types.
// plates.3mf is laid out like a Bambu Studio project:
// plate 1 "Left": object 10 = a 20 mm cube (part 1, filament slot 2)
// + a negative part (part 2) that must never be drawn
// plate 2 "Right": object 20 = a 10x10x40 tower (part 3, no slot of its own:
// uses its object's slot 3), placed at x = 300
// Filaments: #FF0000 PLA, #00FF00 PETG, #0000FF TPU. Only plate 1 has a preview image.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const AdmZip = require('adm-zip');
const { PYTHON } = require('../lib/python');
const TOOLS = path.join(__dirname, '..', 'tools');
const FIXTURE = path.join(__dirname, 'fixtures', 'plates.3mf');
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-project-'));
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
// Converts (optionally one plate) and returns { summary, colours } where colours
// are the base colours of the meshes drawn, one per placed node.
function convert(plate) {
const out = path.join(tmp, `plate-${plate || 'all'}.glb`);
const args = [path.join(TOOLS, 'convert_3mf.py'), FIXTURE, out, ...(plate ? ['--plate', String(plate)] : [])];
const summary = lastJson(execFileSync(PYTHON, args, { encoding: 'utf8' }));
const glb = fs.readFileSync(out);
const gltf = JSON.parse(glb.subarray(20, 20 + glb.readUInt32LE(12)).toString('utf8'));
const colours = gltf.nodes.filter((n) => n.mesh !== undefined).map((n) => {
const material = gltf.materials[gltf.meshes[n.mesh].primitives[0].material];
return material.pbrMetallicRoughness.baseColorFactor.slice(0, 3).map((c) => Math.round(c * 255));
});
return { summary, colours };
}
test('project info lists plates, their previews and the filaments', { skip }, () => {
const images = path.join(tmp, 'images');
const info = lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), FIXTURE, '--images', images], { encoding: 'utf8' }));
assert.deepEqual(info.plates, [
{ index: 1, name: 'Left', objectCount: 1, thumbnail: true },
{ index: 2, name: 'Right', objectCount: 1, thumbnail: false },
]);
assert.deepEqual(info.filaments.map((f) => [f.colour, f.type]), [['#FF0000', 'PLA'], ['#00FF00', 'PETG'], ['#0000FF', 'TPU']]);
assert.ok(fs.existsSync(path.join(images, '1.png')));
assert.ok(!fs.existsSync(path.join(images, '2.png')));
});
test('project info names the app that saved the 3MF', { skip }, () => {
const info = (file) => lastJson(execFileSync(PYTHON, [path.join(TOOLS, 'threemf_info.py'), file], { encoding: 'utf8' }));
const zip = new AdmZip(FIXTURE);
const model = zip.readAsText('3D/3dmodel.model').replace(/\s*<metadata name="Application">[^<]*<\/metadata>/, '');
const plain = path.join(tmp, 'plain.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model));
zip.writeZip(plain);
assert.equal(info(plain).application, null);
const saved = path.join(tmp, 'saved.3mf');
zip.updateFile('3D/3dmodel.model', Buffer.from(model.replace(/<model\b[^>]*>/, (m) => `${m}\n <metadata name="Application">BambuStudio-02.01.01.52</metadata>`)));
zip.writeZip(saved);
assert.equal(info(saved).application, 'BambuStudio-02.01.01.52');
});
test('a plate converts alone, coloured by its part’s filament, without negative parts', { skip }, () => {
const { summary, colours } = convert(1);
assert.equal(summary.faces, 12);
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [20, 20, 20]);
assert.deepEqual(colours, [[0, 255, 0]]);
});
test('a part without its own filament uses its object’s', { skip }, () => {
const { summary, colours } = convert(2);
assert.equal(summary.faces, 12);
assert.deepEqual(colours, [[0, 0, 255]]);
});
test('all plates convert together', { skip }, () => {
const { summary, colours } = convert(null);
assert.equal(summary.faces, 24);
assert.deepEqual([summary.bbox.x, summary.bbox.y, summary.bbox.z].map(Math.round), [315, 20, 40]);
assert.deepEqual(colours.sort(), [[0, 0, 255], [0, 255, 0]]);
});
test('asking for a plate that does not exist fails with a clear error', { skip }, () => {
const run = spawnSync(PYTHON, [path.join(TOOLS, 'convert_3mf.py'), FIXTURE, path.join(tmp, 'none.glb'), '--plate', '5'], { encoding: 'utf8' });
assert.equal(run.status, 1);
assert.match(lastJson(run.stdout).error, /no plate 5/);
});
test('fingerprints leave out negative parts', { skip }, () => {
const out = execFileSync(PYTHON, [path.join(TOOLS, 'fingerprint.py'), FIXTURE], { encoding: 'utf8' });
assert.equal(lastJson(out).fp.faces, 24);
});

View file

@ -1,79 +0,0 @@
// Server-side thumbnail rendering (tools/render_thumbnail.py).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const RENDER = path.join(__dirname, '..', 'tools', 'render_thumbnail.py');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-thumb-'));
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
// Size, how much of the image is covered, where, and how green/blue the model is.
function pixelStats(png) {
const script = `
import json, sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("RGBA")
px = [p for p in im.getdata() if p[3] > 0]
opaque = Image.new("L", im.size); opaque.putdata([255 if p[3] > 0 else 0 for p in im.getdata()])
share = lambda test: sum(1 for p in px if test(p)) / max(len(px), 1)
from collections import Counter
top3 = sum(n for _, n in Counter(p[:3] for p in px if p[3] == 255).most_common(3)) / max(len(px), 1)
print(json.dumps({"size": im.size, "coverage": len(px) / (im.size[0] * im.size[1]), "bbox": opaque.getbbox(), "top3": top3,
"green": share(lambda p: p[1] > p[0] + 40 and p[1] > p[2] + 40),
"blue": share(lambda p: p[2] > p[0] + 40 and p[2] > p[1] + 40)}))`;
return JSON.parse(execFileSync(PYTHON, ['-c', script, png], { encoding: 'utf8' }));
}
test('an STL renders to a 512px PNG, fully in frame', { skip }, () => {
const out = path.join(tmp, 'cube.png');
const result = lastJson(execFileSync(PYTHON, [RENDER, fixture('cube.stl'), out], { encoding: 'utf8' }));
assert.equal(result.faces, 12);
const stats = pixelStats(out);
assert.deepEqual(stats.size, [512, 512]);
assert.ok(stats.coverage > 0.1 && stats.coverage < 0.9, `coverage ${stats.coverage}`);
const [left, top, right, bottom] = stats.bbox;
assert.ok(left > 2 && top > 2 && right < 510 && bottom < 510, `bbox ${stats.bbox}`);
});
// A cube shows three faces, each one flat shade; hidden faces painted over
// visible ones (or inside-out winding) would add more shades.
for (const inverted of [false, true]) {
test(`a cube${inverted ? ' with inside-out faces' : ''} shows just its three visible faces`, { skip }, () => {
let input = fixture('cube.stl');
if (inverted) {
input = path.join(tmp, 'inverted.stl');
execFileSync(PYTHON, ['-c', `
import sys, trimesh
m = trimesh.load(sys.argv[1], force="mesh"); m.invert(); m.export(sys.argv[2])`, fixture('cube.stl'), input]);
}
const out = path.join(tmp, `cube-${inverted}.png`);
execFileSync(PYTHON, [RENDER, input, out], { encoding: 'utf8' });
const { top3 } = pixelStats(out);
assert.ok(top3 > 0.9, `three most common colours cover ${top3}`);
});
}
test('a multi-plate 3MF renders its first plate in filament colours', { skip }, () => {
const out = path.join(tmp, 'plates.png');
lastJson(execFileSync(PYTHON, [RENDER, fixture('plates.3mf'), out], { encoding: 'utf8' }));
const stats = pixelStats(out);
assert.ok(stats.green > 0.5, `green share ${stats.green}`); // plate 1's cube prints in #00FF00
assert.ok(stats.blue < 0.01, `blue share ${stats.blue}`); // plate 2's blue tower isn't drawn
});
test('a file with no geometry fails with a JSON error', { skip }, () => {
const broken = path.join(tmp, 'broken.stl');
fs.writeFileSync(broken, 'this is not a mesh');
const run = spawnSync(PYTHON, [RENDER, broken, path.join(tmp, 'x.png')], { encoding: 'utf8' });
assert.equal(run.status, 1);
assert.match(lastJson(run.stdout).error, /could not render thumbnail/);
});

View file

@ -1,77 +1,44 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB. """Convert a 3MF (incl. Bambu/MakerWorld production-extension files) to GLB.
Usage: convert_3mf.py <input.3mf> <output.glb> [--plate N] Usage: convert_3mf.py <input.3mf> <output.glb>
Prints a JSON summary {glb, plate, volume_cm3, bbox, faces} to stdout. Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
Negative/modifier/support parts are left out, parts are coloured by their
filament when the project says so, and --plate keeps just that plate.
""" """
import argparse import sys
import json import json
import trimesh import trimesh
from trimesh.visual import TextureVisuals
from trimesh.visual.material import PBRMaterial
import threemf_stream
def mesh_in(vertices, faces, colour):
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
if colour:
rgb = [int(colour[i:i + 2], 16) for i in (1, 3, 5)]
mesh.visual = TextureVisuals(material=PBRMaterial(
baseColorFactor=rgb + [255], metallicFactor=0.1, roughnessFactor=0.7))
return mesh
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) if len(sys.argv) != 3:
parser.add_argument("input") print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
parser.add_argument("output") return 2
parser.add_argument("--plate", type=int)
args = parser.parse_args()
try: in_path, out_path = sys.argv[1], sys.argv[2]
meshes, placements = threemf_stream.load(args.input) scene = trimesh.load(in_path, force="scene")
project = threemf_stream.read_project(args.input) geoms = list(scene.geometry.values())
placements = threemf_stream.printable(project, placements) if not geoms:
if args.plate is not None:
placements = threemf_stream.on_plate(project, placements, args.plate)
except Exception as exc:
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
return 1
if not placements:
print(json.dumps({"error": "no geometry found in 3MF"})) print(json.dumps({"error": "no geometry found in 3MF"}))
return 1 return 1
# Each (object, colour) is stored once; repeated placements become instances of it. scene.export(out_path, file_type="glb")
scene = trimesh.Scene()
added = set()
for i, p in enumerate(placements):
colour = threemf_stream.filament_colour(project, p)
geom = f"{p.key}|{colour or ''}"
node = f"part{i}"
if geom in added:
scene.graph.update(frame_from=scene.graph.base_frame, frame_to=node, matrix=p.transform, geometry=geom)
else:
v, f = meshes[p.key]
scene.add_geometry(mesh_in(v, f, colour), node_name=node, geom_name=geom, transform=p.transform)
added.add(geom)
scene.export(args.output, file_type="glb")
stats = threemf_stream.measure(meshes, placements) combined = trimesh.util.concatenate(geoms)
ext = stats["extents"] ext = combined.bounds[1] - combined.bounds[0]
print(json.dumps({ summary = {
"glb": args.output, "glb": out_path,
"plate": args.plate, "bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)}, "faces": int(sum(len(g.faces) for g in geoms)),
"faces": stats["faces"], }
# mm^3 -> cm^3 to match the rest of the app try:
"volume_cm3": round(stats["volume"] / 1000.0, 3), # trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
})) summary["volume_cm3"] = round(float(combined.volume) / 1000.0, 3)
except Exception:
summary["volume_cm3"] = None
print(json.dumps(summary))
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) sys.exit(main())

View file

@ -1,53 +0,0 @@
#!/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
import threemf_stream
def measure(path):
if path.lower().endswith(".3mf"):
# Leave out negative/modifier/support parts, so a project matches the
# model it prints (e.g. its STL export).
meshes, placements = threemf_stream.load(path)
placements = threemf_stream.printable(threemf_stream.read_project(path), placements)
stats = threemf_stream.measure(meshes, placements)
if not stats["faces"]:
raise ValueError("no geometry found")
return stats
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())

View file

@ -1,139 +0,0 @@
#!/usr/bin/env python3
"""Render a model's card thumbnail without a GPU.
Usage: render_thumbnail.py <input.stl|.obj|.3mf> <output.png> [--size 512]
Prints {png, faces} (or {error}) as JSON.
3MF projects show their first plate with parts in their filament colours. Big
meshes are simplified first, then triangles are painted back to front with
Pillow, which is plenty for a card image.
"""
import argparse
import json
import fast_simplification
import numpy as np
import trimesh
from PIL import Image, ImageDraw
import threemf_stream
MAX_FACES = 150_000
GREY = (196, 202, 214)
SUPERSAMPLE = 2
def simplify(vertices, faces, ratio):
if ratio >= 1.0 or len(faces) <= 400:
return vertices, faces
v, f = fast_simplification.simplify(
np.asarray(vertices, dtype=np.float32), np.asarray(faces, dtype=np.int32), target_reduction=1 - ratio)
return np.asarray(v, dtype=np.float64), np.asarray(f)
def hex_rgb(colour):
return tuple(int(colour[i:i + 2], 16) for i in (1, 3, 5)) if colour else GREY
def load_3mf(path):
meshes, placements = threemf_stream.load(path)
project = threemf_stream.read_project(path)
placements = threemf_stream.printable(project, placements)
if len(project["plates"]) > 1:
placements = threemf_stream.on_plate(project, placements, project["plates"][0]["index"]) or placements
if not placements:
raise ValueError("no geometry found")
# Simplify each distinct part once, by the same ratio, so instances stay cheap.
total = sum(len(meshes[p.key][1]) for p in placements)
ratio = min(1.0, MAX_FACES / total)
small = {}
vs, fs, cs, offset = [], [], [], 0
for p in placements:
if p.key not in small:
small[p.key] = simplify(*meshes[p.key], ratio)
v, f = small[p.key]
t = p.transform
if np.linalg.det(t[:3, :3]) < 0:
f = f[:, ::-1] # a mirrored placement flips winding; flip it back so faces point outward
vs.append(v @ t[:3, :3].T + t[:3, 3])
fs.append(f + offset)
cs.append(np.tile(hex_rgb(threemf_stream.filament_colour(project, p)), (len(f), 1)))
offset += len(v)
return np.vstack(vs), np.vstack(fs), np.vstack(cs)
def load_mesh(path):
mesh = trimesh.load(path, force="mesh")
if mesh is None or len(mesh.faces) == 0:
raise ValueError("no geometry found")
v, f = simplify(np.asarray(mesh.vertices), np.asarray(mesh.faces), min(1.0, MAX_FACES / len(mesh.faces)))
if path.lower().endswith(".obj"):
v = np.column_stack([v[:, 0], -v[:, 2], v[:, 1]]) # OBJ is Y-up; everything else here is Z-up
return v, f, np.tile(GREY, (len(f), 1))
def render(vertices, faces, colours, size):
# Three-quarter view from the front right, a little above.
az, el = np.radians(-40), np.radians(30)
forward = -np.array([np.cos(el) * np.sin(az), -np.cos(el) * np.cos(az), np.sin(el)])
right = np.cross(forward, [0, 0, 1])
right /= np.linalg.norm(right)
up = np.cross(right, forward)
a, b, c = vertices[faces[:, 0]], vertices[faces[:, 1]], vertices[faces[:, 2]]
normals = np.cross(b - a, c - a)
# A mesh exported inside-out has negative volume: turn its normals around.
if np.einsum("ij,ij->i", a, np.cross(b, c)).sum() < 0:
normals = -normals
normals /= np.linalg.norm(normals, axis=1, keepdims=True) + 1e-12
# Only faces pointing at the camera are drawn. Painting back faces too lets a
# large far triangle land on top of a near one when sorted by centre depth.
facing = normals @ forward < 0
if facing.any():
a, b, c, normals, colours = a[facing], b[facing], c[facing], normals[facing], colours[facing]
light = -forward + 0.6 * up - 0.4 * right
light /= np.linalg.norm(light)
shade = 0.32 + 0.68 * np.clip(normals @ light, 0, 1)
fills = np.clip(colours * shade[:, None], 0, 255).astype(np.uint8)
xs = np.stack([a @ right, b @ right, c @ right], axis=1)
ys = np.stack([a @ up, b @ up, c @ up], axis=1)
lo = np.array([xs.min(), ys.min()])
span = np.array([xs.max(), ys.max()]) - lo
width = size * SUPERSAMPLE
scale = width * 0.86 / max(span.max(), 1e-9)
offset = (width - span * scale) / 2
px = (xs - lo[0]) * scale + offset[0]
py = width - ((ys - lo[1]) * scale + offset[1])
image = Image.new("RGBA", (width, width), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
depth = ((a + b + c) / 3) @ forward
for i in np.argsort(-depth): # far to near
fill = tuple(int(x) for x in fills[i]) + (255,)
draw.polygon([(px[i, 0], py[i, 0]), (px[i, 1], py[i, 1]), (px[i, 2], py[i, 2])], fill=fill, outline=fill)
return image.resize((size, size), Image.LANCZOS)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input")
parser.add_argument("output")
parser.add_argument("--size", type=int, default=512)
args = parser.parse_args()
try:
load = load_3mf if args.input.lower().endswith(".3mf") else load_mesh
vertices, faces, colours = load(args.input)
render(vertices, faces, colours, args.size).save(args.output, "PNG")
except Exception as exc:
print(json.dumps({"error": f"could not render thumbnail: {str(exc)[:200]}"}))
return 1
print(json.dumps({"png": args.output, "faces": int(len(faces))}))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,5 +2,3 @@ trimesh
numpy numpy
networkx networkx
lxml lxml
pillow
fast-simplification

View file

@ -1,45 +0,0 @@
#!/usr/bin/env python3
"""Print a 3MF project's plates and filaments as JSON, without loading geometry.
Usage: threemf_info.py <file.3mf> [--images DIR]
With --images, each plate's preview is written to DIR/<n>.png and DIR/<n>-small.png.
"""
import argparse
import json
import os
import zipfile
import threemf_stream
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input")
parser.add_argument("--images")
args = parser.parse_args()
try:
project = threemf_stream.read_project(args.input)
if args.images and project["plates"]:
os.makedirs(args.images, exist_ok=True)
with zipfile.ZipFile(args.input) as z:
for plate in project["plates"]:
for name, suffix in ((plate["thumbnail"], ""), (plate["thumbnail_small"], "-small")):
if name:
with open(os.path.join(args.images, f"{plate['index']}{suffix}.png"), "wb") as out:
out.write(z.read(name))
except Exception as exc:
print(json.dumps({"error": f"could not read 3MF: {str(exc)[:200]}"}))
return 1
print(json.dumps({
"application": project["application"],
"plates": [{"index": p["index"], "name": p["name"], "objectCount": len(p["objects"]),
"thumbnail": bool(p["thumbnail"] or p["thumbnail_small"])} for p in project["plates"]],
"filaments": project["filaments"],
}))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,294 +0,0 @@
"""Streaming 3MF reader shared by convert_3mf.py, fingerprint.py and threemf_info.py.
trimesh's 3MF loader re-reads a whole production-extension part file (every
mesh in it) for each component that points into that file. Bambu Studio
projects are hundreds of components pointing into a few large part files, so
the geometry multiplies until the process runs out of memory.
This reader parses each part file once, element by element, resolves each
component to the one object it names, and returns every object's mesh once
plus the transforms where it is placed — so parts reused across plates share
their geometry. It also reads Bambu Studio / OrcaSlicer project metadata
(plates, filaments, part types) when the file has it.
"""
import json
import re
import zipfile
from array import array
from collections import namedtuple
import numpy as np
from lxml import etree
UNIT_MM = {"micron": 0.001, "millimeter": 1.0, "centimeter": 10.0, "inch": 25.4, "foot": 304.8, "meter": 1000.0}
PARSER = dict(huge_tree=True, resolve_entities=False, no_network=True)
MAX_DEPTH = 32
# Slicer part types that shape or steer printing but aren't printed themselves.
HIDDEN_SUBTYPES = {"negative_part", "modifier_part", "support_blocker", "support_enforcer"}
# One placed copy of an object's mesh. `item` is the object id of the build item
# it belongs to and `instance` which occurrence of that id it is; `part` is the
# component directly under that object (how slicer metadata names parts).
Placement = namedtuple("Placement", "key transform item instance part")
def _local(name):
return name.rpartition("}")[2]
def _path_attr(el):
# The production extension's p:path, whatever prefix the file bound it to.
for key, value in el.attrib.items():
if _local(key) == "path":
return value
return None
def _int(value):
try:
return int(value)
except (TypeError, ValueError):
return None
def _matrix(text):
m = np.eye(4)
if text:
v = [float(x) for x in text.split()]
if len(v) == 12:
# 3MF lists "m00 m01 m02 m10 ... m32" for row vectors; store column form.
m[:3, :3] = np.array(v[:9]).reshape(3, 3).T
m[:3, 3] = v[9:12]
return m
def _parse_part(fh):
"""Returns (objects, build items, unit) for one model part.
objects: {id: (vertices (n,3) float64, faces (m,3) int32, [(path|None, objectid, 4x4)])}
"""
objects, items, unit = {}, [], None
oid = verts = tris = comps = None
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name == "model":
unit = el.get("unit")
elif name == "object":
oid, verts, tris, comps = el.get("id"), array("d"), array("i"), []
continue
if name == "vertex":
verts.extend((float(el.get("x")), float(el.get("y")), float(el.get("z"))))
elif name == "triangle":
tris.extend((int(el.get("v1")), int(el.get("v2")), int(el.get("v3"))))
elif name == "component":
comps.append((_path_attr(el), el.get("objectid"), _matrix(el.get("transform"))))
elif name == "item":
items.append((el.get("objectid"), _matrix(el.get("transform"))))
elif name == "object":
v = np.frombuffer(verts, dtype=np.float64).reshape(-1, 3)
f = np.frombuffer(tris, dtype=np.int32).reshape(-1, 3)
if len(f) and (f.min() < 0 or f.max() >= len(v)):
f = f[:0] # corrupt indices: drop the mesh rather than crash later
objects[oid] = (v, f, comps)
# Free what's been read so memory stays flat on huge parts.
el.clear()
parent = el.getparent()
while parent is not None and el.getprevious() is not None:
del parent[0]
return objects, items, unit
def _root_part(z):
try:
rels = etree.fromstring(z.read("_rels/.rels"), etree.XMLParser(resolve_entities=False, no_network=True))
for rel in rels:
if rel.get("Type", "").endswith("/3dmodel"):
return rel.get("Target", "").lstrip("/")
except (KeyError, etree.XMLSyntaxError):
pass
return "3D/3dmodel.model"
def load(path):
"""Returns (meshes, placements).
meshes: {key: (vertices, faces)} — each object once, in file units.
placements: [Placement] — one per placed instance; transforms map to millimetres.
"""
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
parts = {}
def part(name):
name = name.lstrip("/")
if name not in parts:
if name not in names:
raise ValueError(f"3MF references a missing part: {name}")
with z.open(name) as fh:
parts[name] = _parse_part(fh)
return parts[name]
root = _root_part(z)
_, items, unit = part(root)
scale = np.diag([UNIT_MM.get(unit or "millimeter", 1.0)] * 3 + [1.0])
meshes, placements = {}, []
def walk(name, oid, transform, depth, item, instance, part_id):
if depth > MAX_DEPTH:
raise ValueError("3MF components nest too deeply")
obj = part(name)[0].get(oid)
if obj is None:
return # undefined reference; slicers skip these too
v, f, comps = obj
if len(f):
key = f"{name}#{oid}"
meshes.setdefault(key, (v, f))
placements.append(Placement(key, transform, item, instance, part_id))
for cpath, coid, m in comps:
walk(cpath.lstrip("/") if cpath else name, coid, transform @ m, depth + 1,
item, instance, part_id if depth else coid)
seen = {}
for oid, m in items:
instance = seen.get(oid, 0)
seen[oid] = instance + 1
walk(root, oid, scale @ m, 0, oid, instance, None)
return meshes, placements
def _application(z, names):
"""The app that wrote the file, from the root model's <metadata name="Application">.
Metadata comes before the geometry, so reading stops at <resources>."""
root = _root_part(z)
if root not in names:
return None
try:
with z.open(root) as fh:
for event, el in etree.iterparse(fh, events=("start", "end"), **PARSER):
name = _local(el.tag)
if event == "start":
if name in ("resources", "build"):
break
elif name == "metadata" and el.get("name") == "Application":
return (el.text or "").strip() or None
except etree.XMLSyntaxError:
pass
return None
def read_project(path):
"""Slicer project metadata (Bambu Studio / OrcaSlicer), empty when absent:
{application: "BambuStudio-02.01.01.52" | None,
plates: [{index, name, objects: [(object_id, instance)], thumbnail, thumbnail_small}],
filaments: [{colour, type}],
objects: {object_id: {name, extruder, parts: {part_id: {extruder, subtype}}}}}
"""
project = {"application": None, "plates": [], "filaments": [], "objects": {}}
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
project["application"] = _application(z, names)
if "Metadata/project_settings.config" in names:
try:
settings = json.loads(z.read("Metadata/project_settings.config"))
colours = settings.get("filament_colour") or []
types = settings.get("filament_type") or []
project["filaments"] = [
{"colour": c, "type": types[i] if i < len(types) else None} for i, c in enumerate(colours)]
except (ValueError, UnicodeDecodeError, AttributeError):
pass
if "Metadata/model_settings.config" in names:
try:
root = etree.fromstring(z.read("Metadata/model_settings.config"), etree.XMLParser(**PARSER))
except etree.XMLSyntaxError:
return project
def meta(el):
return {m.get("key"): m.get("value") for m in el.findall("metadata") if m.get("key")}
for obj in root.findall("object"):
info = meta(obj)
project["objects"][obj.get("id")] = {
"name": info.get("name"),
"extruder": _int(info.get("extruder")),
"parts": {
p.get("id"): {"extruder": _int(meta(p).get("extruder")), "subtype": p.get("subtype") or "normal_part"}
for p in obj.findall("part")
},
}
for plate in root.findall("plate"):
info = meta(plate)
index = _int(info.get("plater_id")) or len(project["plates"]) + 1
large = info.get("thumbnail_file") or f"Metadata/plate_{index}.png"
small = f"Metadata/plate_{index}_small.png"
project["plates"].append({
"index": index,
"name": info.get("plater_name") or "",
"objects": [(meta(i).get("object_id"), _int(meta(i).get("instance_id")) or 0)
for i in plate.findall("model_instance")],
"thumbnail": large if large in names else None,
"thumbnail_small": small if small in names else None,
})
return project
def printable(project, placements):
"""Drops negative, modifier and support parts (known from slicer metadata)."""
def visible(p):
obj = project["objects"].get(p.item)
part = obj and obj["parts"].get(p.part)
return not part or part["subtype"] not in HIDDEN_SUBTYPES
return [p for p in placements if visible(p)]
def on_plate(project, placements, plate_index):
plate = next((pl for pl in project["plates"] if pl["index"] == plate_index), None)
if plate is None:
raise ValueError(f"no plate {plate_index} in this project")
wanted = set(plate["objects"])
return [p for p in placements if (p.item, p.instance) in wanted]
def filament_colour(project, placement):
"""The '#RRGGBB' colour a placed part prints in, or None if unknown.
A part's extruder of 0 (or none) means it uses its object's extruder."""
obj = project["objects"].get(placement.item)
if not obj:
return None
part = obj["parts"].get(placement.part) or {}
extruder = part.get("extruder") or obj["extruder"]
filaments = project["filaments"]
if extruder and 1 <= extruder <= len(filaments):
colour = filaments[extruder - 1]["colour"]
if isinstance(colour, str) and re.fullmatch(r"#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", colour):
return colour[:7]
return None
def measure(meshes, placements):
"""Faces, surface area (mm^2), volume (mm^3) and extents (mm) of everything
placed, as if concatenated into one mesh — computed per instance to keep
memory flat."""
faces, area, volume = 0, 0.0, 0.0
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
for p in placements:
v, f = meshes[p.key]
t = p.transform
w = v @ t[:3, :3].T + t[:3, 3]
a, b, c = w[f[:, 0]], w[f[:, 1]], w[f[:, 2]]
cross = np.cross(b - a, c - a)
faces += len(f)
area += 0.5 * float(np.linalg.norm(cross, axis=1).sum())
# A mirroring transform flips winding; count its volume as positive
# (matching trimesh, which re-winds faces when applying such a transform).
signed = float(np.einsum("ij,ij->i", a, np.cross(b, c)).sum()) / 6.0
volume += -signed if np.linalg.det(t[:3, :3]) < 0 else signed
if len(w):
lo, hi = np.minimum(lo, w.min(axis=0)), np.maximum(hi, w.max(axis=0))
extents = (hi - lo) if faces else np.zeros(3)
return {"faces": faces, "area": area, "volume": abs(volume), "extents": [float(x) for x in extents]}