printhive/tools/render_thumbnail.py
dlawler489 ea2afeac0e 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>
2026-09-15 07:54:26 +10:00

139 lines
5.3 KiB
Python

#!/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())