Compare commits
11 commits
e141d800c2
...
78c225f232
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c225f232 | ||
|
|
01844b4941 | ||
|
|
f401eb4ca4 | ||
|
|
be1facdec3 | ||
|
|
f3a69c82ec | ||
|
|
fbf1b706bf | ||
|
|
ea2afeac0e | ||
|
|
fcb418f119 | ||
|
|
d4b7e29348 | ||
|
|
502eefdec2 | ||
|
|
00a4b4ba07 |
42 changed files with 3898 additions and 305 deletions
|
|
@ -14,3 +14,4 @@ npm-debug.log*
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
extracted
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ thumbnails/
|
||||||
.env
|
.env
|
||||||
glb/
|
glb/
|
||||||
tools/venv/
|
tools/venv/
|
||||||
|
__pycache__/
|
||||||
|
extracted/
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Printhive
|
# Printhive
|
||||||
|
|
||||||
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**.
|
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**.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -10,27 +10,36 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
|
||||||
- **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, and for 3MF via a server-side **trimesh GLB conversion** (handles Bambu/MakerWorld production-extension files the JS loader can't).
|
- **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.
|
||||||
- **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
|
- **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.
|
||||||
|
- **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 and volume.
|
- Python + [trimesh](https://github.com/mikedh/trimesh) sidecar for 3MF→GLB conversion, volume, and geometry fingerprints.
|
||||||
- Optional [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) for Cloudflare-protected sources.
|
- 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/volume):
|
2. **Python sidecar** (for 3MF previews, plates, server-side thumbnails and shape-based duplicate detection — without it, duplicates are still found by content hash):
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv tools/venv
|
python3 -m venv tools/venv
|
||||||
tools/venv/bin/pip install trimesh numpy networkx lxml
|
tools/venv/bin/pip install -r tools/requirements.txt
|
||||||
```
|
```
|
||||||
3. **Install deps and run**:
|
3. **Install deps and run**:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -40,6 +49,8 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
|
||||||
```
|
```
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -107,3 +107,50 @@ 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()
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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
|
||||||
|
|
@ -56,6 +57,7 @@ volumes:
|
||||||
uploads:
|
uploads:
|
||||||
thumbnails:
|
thumbnails:
|
||||||
glb:
|
glb:
|
||||||
|
extracted:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
82
lib/archiveWorker.js
Normal file
82
lib/archiveWorker.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
133
lib/archives.js
Normal file
133
lib/archives.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// 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 };
|
||||||
|
|
@ -73,6 +73,6 @@ function hashResetToken(token) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
COOKIE, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
COOKIE, getSecret, hashPassword, verifyPassword, issueToken, setAuthCookie, clearAuthCookie,
|
||||||
currentUser, requireAuth, makeResetToken, hashResetToken,
|
currentUser, requireAuth, makeResetToken, hashResetToken,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,37 @@
|
||||||
// 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 path = require('path');
|
const { runScript } = require('./python');
|
||||||
const { execFile } = require('child_process');
|
|
||||||
|
|
||||||
const PYTHON = process.env.PYTHON_BIN || path.join(__dirname, '..', 'tools', 'venv', 'bin', 'python');
|
// Parses a sidecar script's last JSON line, turning its error / failure into a throw.
|
||||||
const SCRIPT = path.join(__dirname, '..', 'tools', 'convert_3mf.py');
|
function parseResult({ err, stdout, stderr }, what) {
|
||||||
|
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 {
|
} catch { /* handled below */ }
|
||||||
return reject(new Error('Converter returned unparseable output.'));
|
if (result && result.error) throw new Error(result.error);
|
||||||
|
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.error) return reject(new Error(result.error));
|
if (!result) throw new Error(`Unparseable output ${what}.`);
|
||||||
resolve(result);
|
return result;
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { convert3mfToGlb };
|
// Returns { glb, plate, volume_cm3, bbox, faces } or throws. `plate` limits the
|
||||||
|
// 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 };
|
||||||
|
|
|
||||||
86
lib/duplicates.js
Normal file
86
lib/duplicates.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Groups models that hold the same files. Two models are linked when they share
|
||||||
|
// a file with the same content hash (exact copy) or the same geometry
|
||||||
|
// fingerprint (same shape: renamed, re-exported, converted — or mirrored, so
|
||||||
|
// those groups are flagged for review). Links are transitive, so a pack
|
||||||
|
// downloaded twice collapses into one group.
|
||||||
|
|
||||||
|
// files: [{ id, model_id, sha256, geometry_hash }]
|
||||||
|
// Returns [{ modelIds: [sorted], exact: boolean, matchedFiles: { modelId: count } }]
|
||||||
|
function groupDuplicates(files) {
|
||||||
|
const parent = new Map();
|
||||||
|
const find = (x) => {
|
||||||
|
while (parent.get(x) !== x) {
|
||||||
|
parent.set(x, parent.get(parent.get(x)));
|
||||||
|
x = parent.get(x);
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
for (const f of files) parent.set(f.model_id, f.model_id);
|
||||||
|
|
||||||
|
const matched = new Map(); // modelId -> Set(fileId)
|
||||||
|
const inexact = []; // a model in each shape-only link
|
||||||
|
|
||||||
|
const buckets = (key) => {
|
||||||
|
const map = new Map();
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f[key]) continue;
|
||||||
|
if (!map.has(f[key])) map.set(f[key], []);
|
||||||
|
map.get(f[key]).push(f);
|
||||||
|
}
|
||||||
|
return map.values();
|
||||||
|
};
|
||||||
|
|
||||||
|
const link = (bucket, exact) => {
|
||||||
|
const modelIds = [...new Set(bucket.map((f) => f.model_id))];
|
||||||
|
if (modelIds.length < 2) return;
|
||||||
|
for (const f of bucket) {
|
||||||
|
if (!matched.has(f.model_id)) matched.set(f.model_id, new Set());
|
||||||
|
matched.get(f.model_id).add(f.id);
|
||||||
|
}
|
||||||
|
for (const id of modelIds.slice(1)) parent.set(find(id), find(modelIds[0]));
|
||||||
|
if (!exact) inexact.push(modelIds[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const bucket of buckets('sha256')) link(bucket, true);
|
||||||
|
for (const bucket of buckets('geometry_hash')) {
|
||||||
|
// Same shape but different bytes somewhere in the bucket = not a plain copy.
|
||||||
|
link(bucket, new Set(bucket.map((f) => f.sha256)).size === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map();
|
||||||
|
for (const id of matched.keys()) {
|
||||||
|
const root = find(id);
|
||||||
|
if (!groups.has(root)) groups.set(root, []);
|
||||||
|
groups.get(root).push(id);
|
||||||
|
}
|
||||||
|
const inexactRoots = new Set(inexact.map(find));
|
||||||
|
|
||||||
|
return [...groups.entries()]
|
||||||
|
.filter(([, ids]) => ids.length > 1)
|
||||||
|
.map(([root, ids]) => ({
|
||||||
|
modelIds: ids.sort((a, b) => a - b),
|
||||||
|
exact: !inexactRoots.has(root),
|
||||||
|
matchedFiles: Object.fromEntries(ids.map((id) => [id, matched.get(id).size])),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable id for a group of models (used to remember "not duplicates").
|
||||||
|
function signature(modelIds) {
|
||||||
|
return [...new Set(modelIds.map(Number))].sort((a, b) => a - b).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepScore(m) {
|
||||||
|
return (m.source_url ? 4 : 0) + (m.license ? 2 : 0) + (m.notes ? 2 : 0) + (m.description ? 1 : 0)
|
||||||
|
+ Math.min((m.tags || []).length, 3) + Math.min((m.projects || []).length, 3) + (m.files || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which model of a group to keep: the most complete one (source, license, notes,
|
||||||
|
// tags, collections, most files), then the one added first.
|
||||||
|
function recommendKeep(models) {
|
||||||
|
return [...models].sort((a, b) =>
|
||||||
|
keepScore(b) - keepScore(a)
|
||||||
|
|| new Date(a.date_added) - new Date(b.date_added)
|
||||||
|
|| a.id - b.id)[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { groupDuplicates, signature, recommendKeep };
|
||||||
|
|
@ -5,18 +5,25 @@ const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
const THUMBNAIL_DIR = './thumbnails';
|
const THUMBNAIL_DIR = './thumbnails';
|
||||||
|
|
||||||
const MODEL_FORMATS = ['stl', '3mf', 'obj', 'step', 'stp', 'gcode', 'gco', 'zip'];
|
const MODEL_FORMATS = ['stl', '3mf', 'obj', 'fbx', 'step', 'stp', '3dm', 'gcode', 'gco'];
|
||||||
|
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) {
|
||||||
return path.extname(filename).slice(1).toLowerCase();
|
const lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.tar.gz')) return 'tgz';
|
||||||
|
return path.extname(lower).slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAllowed(filename) {
|
function isAllowed(filename) {
|
||||||
return MODEL_FORMATS.includes(formatOf(filename));
|
return ALLOWED_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
|
||||||
|
|
@ -37,16 +44,28 @@ 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().find((e) => {
|
const entry = zip.getEntries()
|
||||||
|
.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());
|
||||||
|
|
@ -57,4 +76,21 @@ function extract3mfThumbnail(filePath, baseName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { MODEL_FORMATS, formatOf, isAllowed, extractMetadata, extract3mfThumbnail };
|
// Copy a picture that came with a model into thumbnails/ (a copy, so replacing
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
|
||||||
240
lib/fingerprint.js
Normal file
240
lib/fingerprint.js
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
// 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 };
|
||||||
46
lib/handoff.js
Normal file
46
lib/handoff.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// 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 };
|
||||||
42
lib/python.js
Normal file
42
lib/python.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// 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 };
|
||||||
173
lib/store.js
173
lib/store.js
|
|
@ -1,9 +1,11 @@
|
||||||
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,
|
||||||
|
|
@ -24,7 +26,8 @@ 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) ORDER BY fl.id) AS files
|
'bbox', fl.bbox, 'facets', fl.facets, 'archiveStatus', fl.archive_status,
|
||||||
|
'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
|
||||||
`;
|
`;
|
||||||
|
|
@ -39,8 +42,11 @@ function shapeModel(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- models
|
// ---------------------------------------------------------------- models
|
||||||
async function getAllModels() {
|
// The library, or with { trash: true } the models in the trash.
|
||||||
const { rows } = await db.query(`${MODEL_SELECT} ORDER BY m.date_added DESC`);
|
async function getAllModels({ trash = false } = {}) {
|
||||||
|
const { rows } = await db.query(trash
|
||||||
|
? `${MODEL_SELECT} WHERE m.deleted_at IS NOT NULL ORDER BY m.deleted_at DESC`
|
||||||
|
: `${MODEL_SELECT} WHERE m.deleted_at IS NULL ORDER BY m.date_added DESC`);
|
||||||
return rows.map(shapeModel);
|
return rows.map(shapeModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,9 +55,15 @@ 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`, [site, sourceId]);
|
`${MODEL_SELECT} WHERE m.source_site = $1 AND m.source_id = $2 AND m.deleted_at IS NULL`, [site, sourceId]);
|
||||||
return shapeModel(rows[0]);
|
return shapeModel(rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +99,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', 'license', 'commercial_use', 'thumbnail_path']) {
|
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
|
||||||
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
|
||||||
}
|
}
|
||||||
if (updates.print_settings !== undefined) {
|
if (updates.print_settings !== undefined) {
|
||||||
|
|
@ -106,20 +118,159 @@ 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 file_path, glb_path FROM files WHERE model_id = $1', [id]);
|
const { rows } = await db.query('SELECT id, 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: rows.map((r) => r.glb_path).filter(Boolean),
|
glbPaths: [
|
||||||
|
...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);
|
||||||
}
|
}
|
||||||
|
|
@ -176,9 +327,10 @@ 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(mp.model_id)::int AS model_count
|
COUNT(m.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;
|
||||||
}
|
}
|
||||||
|
|
@ -321,8 +473,11 @@ async function upsertDesigner(client, { name, source_site, profile_url }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAllModels, getModel, findModelBySource, createModel, updateModel, deleteModel, addFiles,
|
getAllModels, getModel, getModelsByIds, 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,
|
||||||
|
|
|
||||||
104
lib/thumbnails.js
Normal file
104
lib/thumbnails.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// 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
109
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"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",
|
||||||
|
|
@ -18,12 +19,42 @@
|
||||||
"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",
|
||||||
"pg": "^8.22.0"
|
"occt-import-js": "^0.0.23",
|
||||||
|
"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",
|
||||||
|
|
@ -240,6 +271,15 @@
|
||||||
"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",
|
||||||
|
|
@ -945,6 +985,27 @@
|
||||||
"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",
|
||||||
|
|
@ -1091,6 +1152,12 @@
|
||||||
"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",
|
||||||
|
|
@ -1358,6 +1425,21 @@
|
||||||
"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",
|
||||||
|
|
@ -1586,6 +1668,22 @@
|
||||||
"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",
|
||||||
|
|
@ -1685,6 +1783,15 @@
|
||||||
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
"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",
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
"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",
|
||||||
|
|
@ -25,7 +27,10 @@
|
||||||
"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",
|
||||||
"pg": "^8.22.0"
|
"occt-import-js": "^0.0.23",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"rhino3dm": "^8.32.2",
|
||||||
|
"tar": "^7.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|
|
||||||
1146
public/app.js
1146
public/app.js
File diff suppressed because it is too large
Load diff
|
|
@ -84,34 +84,82 @@
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Collections</h3>
|
<h3>Library</h3>
|
||||||
<div id="projectsList" class="projects-list">
|
<div id="libraryList" class="projects-list">
|
||||||
<div class="project-item active" data-project-id="all">
|
<div class="project-item active" data-scope="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 id="tagsList" class="tags-list"></div>
|
<div class="tag-tools">
|
||||||
|
<button type="button" class="btn-link" id="clearTags" hidden>Clear</button>
|
||||||
|
<div class="seg" id="tagMatch" title="When several tags are included, match any or all of them">
|
||||||
|
<button type="button" data-match="any">Any</button>
|
||||||
|
<button type="button" data-match="all">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="sidebar-section">
|
</div>
|
||||||
<h3>Search</h3>
|
<div id="tagsList" class="tags-list"></div>
|
||||||
<input type="text" id="searchInput" placeholder="Search models..." class="search-input">
|
<p class="form-hint">Click a tag to include it, again to exclude it.</p>
|
||||||
</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>
|
||||||
|
|
@ -171,6 +219,15 @@
|
||||||
</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">
|
||||||
|
|
@ -281,8 +338,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">(stl, 3mf, obj, step, gcode, zip — multiple allowed)</span></label>
|
<label for="fileInput">Files * <span class="form-hint-inline">(models, archives, pictures, PDFs & readmes — multiple allowed)</span></label>
|
||||||
<input type="file" id="fileInput" accept=".stl,.3mf,.obj,.step,.stp,.gcode,.gco,.zip" multiple required>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -361,9 +418,18 @@
|
||||||
<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>
|
||||||
|
|
@ -378,6 +444,10 @@
|
||||||
<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>
|
||||||
|
|
@ -390,23 +460,60 @@
|
||||||
<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,.step,.stp,.gcode,.gco,.zip">
|
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">
|
||||||
<div class="form-actions">
|
<div class="form-actions" id="detailActions">
|
||||||
<button class="btn btn-danger" id="deleteFileBtn">Delete</button>
|
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
|
||||||
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
|
<button class="btn btn-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">×</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">
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,11 @@ 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.active { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
.tag.include { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
|
||||||
|
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
|
||||||
|
|
||||||
/* ---- form fields ---- */
|
/* ---- form fields ---- */
|
||||||
.search-input, input[type="text"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
|
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -204,7 +205,8 @@ 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 ---- */
|
||||||
.file-detail-content { display: grid; grid-template-columns: 1.1fr 1fr; gap: 26px; }
|
/* minmax(0, …) keeps a long file name from widening its column and crushing the viewer */
|
||||||
|
.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); }
|
||||||
|
|
@ -213,8 +215,34 @@ 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; }
|
.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: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); }
|
||||||
|
|
||||||
|
|
@ -227,6 +255,28 @@ 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; }
|
||||||
|
|
@ -279,3 +329,49 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
||||||
}
|
}
|
||||||
.tag-suggest-item { padding: 7px 11px; font-size: 13px; cursor: pointer; color: var(--text-muted); }
|
.tag-suggest-item { 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); }
|
||||||
|
|
|
||||||
217
public/viewer.js
217
public/viewer.js
|
|
@ -1,16 +1,27 @@
|
||||||
// In-browser 3D preview for STL / 3MF / OBJ files, loaded as an ES module.
|
// In-browser preview for 3D models (STL, OBJ + MTL, 3MF via GLB, FBX, STEP, 3DM)
|
||||||
|
// 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 { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
import { MTLLoader } from 'three/addons/loaders/MTLLoader.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;
|
||||||
|
|
@ -26,8 +37,7 @@ 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 (e.g. Bambu/MakerWorld 3MF project files that
|
// When 3D rendering isn't possible, show the model's preview image instead of an error.
|
||||||
// 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 =
|
||||||
|
|
@ -37,33 +47,172 @@ function showFallback(container, image, text) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadObject(url, format) {
|
async function fetchOk(url) {
|
||||||
|
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();
|
||||||
const material = new THREE.MeshStandardMaterial({ color: 0xc4cad6, metalness: 0.15, roughness: 0.65 });
|
object = new THREE.Mesh(geometry, defaultMaterial());
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
} else if (format === 'obj') {
|
||||||
mesh.rotation.x = -Math.PI / 2; // STL is typically Z-up; sit it flat
|
object = await loadObj(url, opts.resolve);
|
||||||
return mesh;
|
} else if (format === 'fbx') {
|
||||||
}
|
object = await new FBXLoader(managerFor(opts.resolve, url)).loadAsync(url);
|
||||||
if (format === 'obj') return new OBJLoader().loadAsync(url);
|
} else if (format === 'glb') {
|
||||||
if (format === '3mf') return new ThreeMFLoader().loadAsync(url);
|
object = (await new GLTFLoader().loadAsync(url)).scene;
|
||||||
if (format === 'glb') {
|
} else if (format === '3dm') {
|
||||||
const gltf = await new GLTFLoader().loadAsync(url);
|
object = await load3dm(url);
|
||||||
gltf.scene.rotation.x = -Math.PI / 2; // 3MF/trimesh is Z-up; sit it upright
|
} else if (format === 'step' || format === 'stp') {
|
||||||
return gltf.scene;
|
object = await loadStep(url);
|
||||||
}
|
} 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 (!SUPPORTED.includes(format)) {
|
if (DOCUMENT_FORMATS.includes(format)) {
|
||||||
showFallback(container, opts.fallbackImage, `No 3D preview for .${format} files.`);
|
await renderDocument(container, url, format, token);
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -81,20 +230,6 @@ 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());
|
||||||
|
|
@ -123,8 +258,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) {
|
async function snapshot(url, format, size = 512, opts = {}) {
|
||||||
if (!SUPPORTED.includes(format)) return null;
|
if (!MODEL_FORMATS.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);
|
||||||
|
|
@ -137,7 +272,7 @@ async function snapshot(url, format, size = 512) {
|
||||||
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);
|
const object = await loadObject(url, format, opts);
|
||||||
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());
|
||||||
|
|
@ -159,4 +294,4 @@ async function snapshot(url, format, size = 512) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.ModelViewer = { render, dispose, snapshot, SUPPORTED, RENDERABLE };
|
window.ModelViewer = { render, dispose, snapshot, MODEL_FORMATS, DOCUMENT_FORMATS, RENDERABLE, PREVIEWABLE };
|
||||||
|
|
|
||||||
374
server.js
374
server.js
|
|
@ -19,6 +19,11 @@ 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;
|
||||||
|
|
@ -29,6 +34,9 @@ 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.
|
||||||
|
|
@ -55,11 +63,14 @@ 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 (embedded 3MF preview, if any).
|
// Choose a thumbnail for a model from its files: an embedded 3MF preview, else
|
||||||
|
// 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');
|
||||||
if (!threeMf) return null;
|
const embedded = threeMf && fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name);
|
||||||
return fileProcessor.extract3mfThumbnail(threeMf.filePath, path.parse(threeMf.fileName).name);
|
if (embedded) return embedded;
|
||||||
|
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).
|
||||||
|
|
@ -80,7 +91,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.MODEL_FORMATS.join(', ')}`));
|
else cb(new Error(`Unsupported file type. Allowed: ${fileProcessor.ALLOWED_FORMATS.join(', ')}`));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -292,7 +303,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();
|
const models = await store.getAllModels({ trash: req.query.view === 'trash' });
|
||||||
res.json(models.map(publicModel));
|
res.json(models.map(publicModel));
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
@ -321,6 +332,7 @@ 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); }
|
||||||
});
|
});
|
||||||
|
|
@ -332,6 +344,7 @@ app.post('/api/models/:id/files', upload.array('files'), async (req, res, next)
|
||||||
if (!existing) return res.status(404).json({ error: 'Model not found' });
|
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); }
|
||||||
});
|
});
|
||||||
|
|
@ -403,6 +416,7 @@ 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); }
|
||||||
});
|
});
|
||||||
|
|
@ -429,17 +443,56 @@ 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 removed = await store.deleteModel(req.params.id);
|
const model = await store.getModel(req.params.id);
|
||||||
if (!removed) return res.status(404).json({ error: 'Model not found' });
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
for (const p of removed.filePaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
if (req.query.permanent === 'true') {
|
||||||
for (const p of removed.glbPaths) { if (p && fs.existsSync(p)) fs.unlinkSync(p); }
|
if (!model.deleted_at) {
|
||||||
if (removed.thumbnailPath && fs.existsSync(removed.thumbnailPath)) fs.unlinkSync(removed.thumbnailPath);
|
return res.status(400).json({ error: 'Move the model to the trash before deleting it permanently.' });
|
||||||
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 {
|
||||||
|
|
@ -492,45 +545,227 @@ app.get('/api/models/:id/download', async (req, res, next) => {
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve a raw model file (used by the in-browser 3D viewer).
|
// Send a stored file as a download, or with ?inline=1 for display in the page
|
||||||
|
// (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 { rows } = await db.query('SELECT file_path, original_name FROM files WHERE id = $1', [req.params.id]);
|
const file = await store.getFile(req.params.id);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
const abs = path.resolve(rows[0].file_path);
|
const abs = path.resolve(file.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' });
|
||||||
res.download(abs, rows[0].original_name);
|
sendStored(req, res, abs, file.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.
|
||||||
const glbConversions = new Map(); // fileId -> in-flight Promise
|
// ?plate=N converts just that plate of a slicer project.
|
||||||
|
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 { rows } = await db.query('SELECT id, file_path, format, glb_path FROM files WHERE id = $1', [req.params.id]);
|
const file = await store.getFile(req.params.id);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'File not found' });
|
if (!file) 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' });
|
||||||
|
|
||||||
let glbPath = file.glb_path;
|
const plate = plateParam(req);
|
||||||
if (!glbPath || !fs.existsSync(glbPath)) {
|
if (plate) {
|
||||||
if (!glbConversions.has(file.id)) {
|
const platePath = path.resolve('./glb', glbName(file.id, plate));
|
||||||
glbConversions.set(file.id, convertFileToGlb(file).finally(() => glbConversions.delete(file.id)));
|
if (!fs.existsSync(platePath)) await convertOnce(path.resolve(file.file_path), platePath, plate);
|
||||||
|
return res.sendFile(platePath);
|
||||||
}
|
}
|
||||||
glbPath = await glbConversions.get(file.id);
|
let glbPath = file.glb_path;
|
||||||
|
if (!glbPath || path.basename(glbPath) !== glbName(file.id) || !fs.existsSync(glbPath)) {
|
||||||
|
if (glbPath) fs.rmSync(glbPath, { force: true }); // made by an older converter
|
||||||
|
glbPath = path.join('./glb', glbName(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 });
|
||||||
}
|
}
|
||||||
res.sendFile(path.resolve(glbPath));
|
res.sendFile(path.resolve(glbPath));
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
async function convertFileToGlb(file) {
|
app.get('/api/entries/:id/glb', async (req, res, next) => {
|
||||||
if (!fs.existsSync('./glb')) fs.mkdirSync('./glb', { recursive: true });
|
try {
|
||||||
const out = path.join('./glb', `${file.id}.glb`);
|
const entry = await store.getArchiveEntry(req.params.id);
|
||||||
const result = await converter.convert3mfToGlb(path.resolve(file.file_path), path.resolve(out));
|
if (!entry) return res.status(404).json({ error: 'Entry not found' });
|
||||||
await store.updateFileConversion(file.id, { glb_path: out, volume: result.volume_cm3, bbox: result.bbox });
|
if (entry.format !== '3mf') return res.status(415).json({ error: 'GLB conversion is only for 3MF files' });
|
||||||
return out;
|
const plate = plateParam(req);
|
||||||
|
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
|
||||||
|
|
@ -614,6 +849,7 @@ 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); }
|
||||||
});
|
});
|
||||||
|
|
@ -626,7 +862,9 @@ 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);
|
||||||
return publicModel(await downloadAndCreateModel(meta, projects));
|
const model = await downloadAndCreateModel(meta, projects);
|
||||||
|
fingerprint.kick();
|
||||||
|
return publicModel(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch a page's HTML. Tries a direct request first; if the response is a
|
// Fetch a page's HTML. Tries a direct request first; if the response is a
|
||||||
|
|
@ -809,6 +1047,73 @@ 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;
|
||||||
|
|
@ -822,5 +1127,8 @@ app.use((err, req, res, next) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
db.init()
|
db.init()
|
||||||
.then(() => app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`)))
|
.then(() => {
|
||||||
|
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
|
||||||
|
fingerprint.kick(); // backfill fingerprints for files stored before this ran
|
||||||
|
})
|
||||||
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
||||||
|
|
|
||||||
47
test/archives.test.js
Normal file
47
test/archives.test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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' }));
|
||||||
|
});
|
||||||
63
test/duplicates.test.js
Normal file
63
test/duplicates.test.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { groupDuplicates, signature, recommendKeep } = require('../lib/duplicates');
|
||||||
|
const { geometryKey } = require('../lib/fingerprint');
|
||||||
|
|
||||||
|
const file = (id, model_id, sha256, geometry_hash = null) => ({ id, model_id, sha256, geometry_hash });
|
||||||
|
|
||||||
|
test('identical content in two models is an exact group', () => {
|
||||||
|
const groups = groupDuplicates([file(1, 10, 'aaa'), file(2, 20, 'aaa'), file(3, 30, 'zzz')]);
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.deepEqual(groups[0].modelIds, [10, 20]);
|
||||||
|
assert.equal(groups[0].exact, true);
|
||||||
|
assert.deepEqual(groups[0].matchedFiles, { 10: 1, 20: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same geometry with different bytes is flagged for review', () => {
|
||||||
|
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'bbb', 'g1')]);
|
||||||
|
assert.deepEqual(group.modelIds, [10, 20]);
|
||||||
|
assert.equal(group.exact, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same geometry and same bytes stays exact', () => {
|
||||||
|
const [group] = groupDuplicates([file(1, 10, 'aaa', 'g1'), file(2, 20, 'aaa', 'g1')]);
|
||||||
|
assert.equal(group.exact, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file repeated inside one model is not a duplicate group', () => {
|
||||||
|
assert.deepEqual(groupDuplicates([file(1, 10, 'aaa'), file(2, 10, 'aaa')]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links are transitive, so packs collapse into one group', () => {
|
||||||
|
const groups = groupDuplicates([
|
||||||
|
file(1, 10, 'a'), file(2, 10, 'b'),
|
||||||
|
file(3, 20, 'a'), file(4, 20, 'b', 'g'),
|
||||||
|
file(5, 30, 'c', 'g'),
|
||||||
|
]);
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.deepEqual(groups[0].modelIds, [10, 20, 30]);
|
||||||
|
assert.equal(groups[0].exact, false);
|
||||||
|
assert.deepEqual(groups[0].matchedFiles, { 10: 2, 20: 2, 30: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signature ignores order and repeats', () => {
|
||||||
|
assert.equal(signature([3, 1, 2, 1]), '1,2,3');
|
||||||
|
assert.equal(signature(['20', '10']), '10,20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendKeep prefers the more complete model, then the older one', () => {
|
||||||
|
const bare = { id: 1, date_added: '2026-01-01', files: [{}], tags: [], projects: [] };
|
||||||
|
const sourced = { id: 2, date_added: '2026-02-01', source_url: 'https://x', license: 'CC-BY', files: [{}], tags: ['a'], projects: [] };
|
||||||
|
assert.equal(recommendKeep([bare, sourced]), 2);
|
||||||
|
const older = { ...bare, id: 3, date_added: '2025-12-01' };
|
||||||
|
assert.equal(recommendKeep([bare, older]), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geometryKey ignores axis order, position and float noise', () => {
|
||||||
|
const a = { faces: 1200, area: 5321.4567, volume: -18342.12, extents: [20, 40.02, 10] };
|
||||||
|
const b = { faces: 1200, area: 5321.4571, volume: 18342.09, extents: [40.019, 10.001, 20] };
|
||||||
|
assert.equal(geometryKey(a), geometryKey(b));
|
||||||
|
assert.notEqual(geometryKey(a), geometryKey({ ...a, faces: 1202 }));
|
||||||
|
assert.notEqual(geometryKey(a), geometryKey({ ...a, extents: [20, 40.02, 12] }));
|
||||||
|
assert.equal(geometryKey({ faces: 0 }), null);
|
||||||
|
});
|
||||||
BIN
test/fixtures/components.3mf
vendored
Normal file
BIN
test/fixtures/components.3mf
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube.stl
vendored
Normal file
BIN
test/fixtures/cube.stl
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
BIN
test/fixtures/cube_rotated.3mf
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/pack.7z
vendored
Normal file
BIN
test/fixtures/pack.7z
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/pack.tar.gz
vendored
Normal file
BIN
test/fixtures/pack.tar.gz
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/pack.zip
vendored
Normal file
BIN
test/fixtures/pack.zip
vendored
Normal file
Binary file not shown.
BIN
test/fixtures/plates.3mf
vendored
Normal file
BIN
test/fixtures/plates.3mf
vendored
Normal file
Binary file not shown.
55
test/geometry.test.js
Normal file
55
test/geometry.test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
42
test/handoff.test.js
Normal file
42
test/handoff.test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
97
test/project.test.js
Normal file
97
test/project.test.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
79
test/thumbnail.test.js
Normal file
79
test/thumbnail.test.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// 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/);
|
||||||
|
});
|
||||||
|
|
@ -1,44 +1,77 @@
|
||||||
#!/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>
|
Usage: convert_3mf.py <input.3mf> <output.glb> [--plate N]
|
||||||
Prints a JSON summary {glb, volume_cm3, bbox, faces} to stdout.
|
Prints a JSON summary {glb, plate, 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 sys
|
import argparse
|
||||||
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():
|
||||||
if len(sys.argv) != 3:
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
print(json.dumps({"error": "usage: convert_3mf.py <in.3mf> <out.glb>"}))
|
parser.add_argument("input")
|
||||||
return 2
|
parser.add_argument("output")
|
||||||
|
parser.add_argument("--plate", type=int)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
in_path, out_path = sys.argv[1], sys.argv[2]
|
try:
|
||||||
scene = trimesh.load(in_path, force="scene")
|
meshes, placements = threemf_stream.load(args.input)
|
||||||
geoms = list(scene.geometry.values())
|
project = threemf_stream.read_project(args.input)
|
||||||
if not geoms:
|
placements = threemf_stream.printable(project, placements)
|
||||||
|
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
|
||||||
|
|
||||||
scene.export(out_path, file_type="glb")
|
# Each (object, colour) is stored once; repeated placements become instances of it.
|
||||||
|
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")
|
||||||
|
|
||||||
combined = trimesh.util.concatenate(geoms)
|
stats = threemf_stream.measure(meshes, placements)
|
||||||
ext = combined.bounds[1] - combined.bounds[0]
|
ext = stats["extents"]
|
||||||
summary = {
|
print(json.dumps({
|
||||||
"glb": out_path,
|
"glb": args.output,
|
||||||
"bbox": {"x": round(float(ext[0]), 3), "y": round(float(ext[1]), 3), "z": round(float(ext[2]), 3)},
|
"plate": args.plate,
|
||||||
"faces": int(sum(len(g.faces) for g in geoms)),
|
"bbox": {"x": round(ext[0], 3), "y": round(ext[1], 3), "z": round(ext[2], 3)},
|
||||||
}
|
"faces": stats["faces"],
|
||||||
try:
|
# mm^3 -> cm^3 to match the rest of the app
|
||||||
# trimesh volume is in model units (mm) -> cm^3 to match the rest of the app
|
"volume_cm3": round(stats["volume"] / 1000.0, 3),
|
||||||
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__":
|
||||||
sys.exit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
53
tools/fingerprint.py
Normal file
53
tools/fingerprint.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/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())
|
||||||
139
tools/render_thumbnail.py
Normal file
139
tools/render_thumbnail.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
#!/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())
|
||||||
|
|
@ -2,3 +2,5 @@ trimesh
|
||||||
numpy
|
numpy
|
||||||
networkx
|
networkx
|
||||||
lxml
|
lxml
|
||||||
|
pillow
|
||||||
|
fast-simplification
|
||||||
|
|
|
||||||
45
tools/threemf_info.py
Normal file
45
tools/threemf_info.py
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
#!/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())
|
||||||
294
tools/threemf_stream.py
Normal file
294
tools/threemf_stream.py
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
"""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]}
|
||||||
Loading…
Reference in a new issue