Make thumbnails on the server in the background
- The background worker now also gives models without a thumbnail one: an embedded 3MF/plate preview, else a picture uploaded with the model, else a 3MF preview or picture inside one of its archives, else a render of an STL/OBJ/3MF (loose or inside an archive). - tools/render_thumbnail.py renders without a GPU: meshes are simplified per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB), 3MF projects show their first plate in filament colours, and only camera-facing faces are painted (inside-out meshes and mirrored parts are re-oriented first). - models.thumbnail_status stops retries when there's nothing to use; adding files clears it. The browser now only renders FBX/STEP/3DM thumbnails. - The library checks back while fingerprints or thumbnails are pending and refreshes when they land. - Python sidecar needs pillow and fast-simplification; compose keeps the archive extraction cache in a volume. README covers the phase 2 features. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
fcb418f119
commit
ea2afeac0e
11 changed files with 375 additions and 12 deletions
13
README.md
13
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -10,8 +10,11 @@ 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).
|
||||
- **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.
|
||||
- **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).
|
||||
- **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
- **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.
|
||||
|
|
@ -31,10 +34,10 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
|
|||
## Setup
|
||||
|
||||
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
|
||||
2. **Python sidecar** (for 3MF previews/volume and shape-based duplicate detection — without it, duplicates are still found by content hash):
|
||||
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
|
||||
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**:
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ CREATE TABLE IF NOT EXISTS archive_entries (
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ services:
|
|||
- uploads:/app/uploads
|
||||
- thumbnails:/app/thumbnails
|
||||
- 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.
|
||||
networks:
|
||||
- default # talk to the db
|
||||
|
|
@ -56,6 +57,7 @@ volumes:
|
|||
uploads:
|
||||
thumbnails:
|
||||
glb:
|
||||
extracted:
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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');
|
||||
|
||||
|
|
@ -208,7 +209,8 @@ async function fingerprintEntries() {
|
|||
|
||||
async function run() {
|
||||
for (;;) {
|
||||
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries()];
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,8 @@ async function dismissedDuplicateSignatures() {
|
|||
async function addFiles(modelId, files) {
|
||||
await db.transaction(async (client) => {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
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 };
|
||||
|
|
@ -163,6 +163,7 @@ async function loadData() {
|
|||
renderModels();
|
||||
updateCounts();
|
||||
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
|
||||
watchBackgroundWork();
|
||||
|
||||
// First-run onboarding: prompt for import sources once.
|
||||
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
|
||||
|
|
@ -173,19 +174,43 @@ async function loadData() {
|
|||
}
|
||||
}
|
||||
|
||||
// While the server is still fingerprinting or making thumbnails, check back
|
||||
// every few seconds and reload once its progress changes, so new thumbnails
|
||||
// and duplicates show up without a manual refresh.
|
||||
let backgroundPoll = null;
|
||||
function watchBackgroundWork() {
|
||||
clearTimeout(backgroundPoll);
|
||||
const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0);
|
||||
const pending = pendingOf(stats);
|
||||
if (!pending) return;
|
||||
backgroundPoll = setTimeout(async () => {
|
||||
try {
|
||||
const next = await (await fetch('/api/library/stats')).json();
|
||||
if (pendingOf(next) !== pending || next.duplicateGroups !== stats.duplicateGroups) {
|
||||
await loadData(); // re-arms the watch
|
||||
return;
|
||||
}
|
||||
stats = next;
|
||||
watchBackgroundWork();
|
||||
} catch { /* offline: try again on the next reload */ }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
const res = await fetch('/api/models?view=trash');
|
||||
trashModels = res.ok ? await res.json() : [];
|
||||
}
|
||||
|
||||
// Render thumbnails in the browser for models that don't have one yet, then
|
||||
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
|
||||
// The server makes STL/OBJ/3MF thumbnails in the background; the browser
|
||||
// renders the formats only it can read, then saves them. One at a time, to
|
||||
// avoid thrashing the GPU / WebGL contexts.
|
||||
const BROWSER_THUMBNAIL_FORMATS = ['fbx', '3dm', 'step', 'stp'];
|
||||
const thumbAttempted = new Set();
|
||||
async function generateMissingThumbnails() {
|
||||
if (!window.ModelViewer) return;
|
||||
for (const m of models) {
|
||||
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
|
||||
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
|
||||
const file = (m.files || []).find((f) => BROWSER_THUMBNAIL_FORMATS.includes(f.format));
|
||||
if (!file) continue;
|
||||
thumbAttempted.add(m.id);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ 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 app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
|
@ -1008,9 +1009,9 @@ async function findDuplicateGroups() {
|
|||
|
||||
app.get('/api/library/stats', async (req, res, next) => {
|
||||
try {
|
||||
const [trash, groups, pending] = await Promise.all([
|
||||
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount()]);
|
||||
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending });
|
||||
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); }
|
||||
});
|
||||
|
||||
|
|
|
|||
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/);
|
||||
});
|
||||
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
|
||||
networkx
|
||||
lxml
|
||||
pillow
|
||||
fast-simplification
|
||||
|
|
|
|||
Loading…
Reference in a new issue