- 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>
139 lines
5.3 KiB
Python
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())
|