diff --git a/README.md b/README.md index 2d87e40..7f7c104 100644 --- a/README.md +++ b/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 diff --git a/db/schema.sql b/db/schema.sql index db464c4..62451b5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml index 1d27f68..1ae9204 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lib/fingerprint.js b/lib/fingerprint.js index 3f936db..949a8d0 100644 --- a/lib/fingerprint.js +++ b/lib/fingerprint.js @@ -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; } } diff --git a/lib/store.js b/lib/store.js index fdfbd31..9fab0e4 100644 --- a/lib/store.js +++ b/lib/store.js @@ -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); } diff --git a/lib/thumbnails.js b/lib/thumbnails.js new file mode 100644 index 0000000..d980ab1 --- /dev/null +++ b/lib/thumbnails.js @@ -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 }; diff --git a/public/app.js b/public/app.js index e845f97..4675d2d 100644 --- a/public/app.js +++ b/public/app.js @@ -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 { diff --git a/server.js b/server.js index 80ae980..d110fca 100644 --- a/server.js +++ b/server.js @@ -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); } }); diff --git a/test/thumbnail.test.js b/test/thumbnail.test.js new file mode 100644 index 0000000..f3d4e89 --- /dev/null +++ b/test/thumbnail.test.js @@ -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/); +}); diff --git a/tools/render_thumbnail.py b/tools/render_thumbnail.py new file mode 100644 index 0000000..d2eb5fb --- /dev/null +++ b/tools/render_thumbnail.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Render a model's card thumbnail without a GPU. + +Usage: render_thumbnail.py [--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()) diff --git a/tools/requirements.txt b/tools/requirements.txt index 5af82ec..2369c07 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -2,3 +2,5 @@ trimesh numpy networkx lxml +pillow +fast-simplification