#!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.12" # dependencies = [ # "typer", # "fastapi", # "uvicorn", # "python-multipart", # "jinja2", # "numpy", # "numpy-stl", # "matplotlib", # "pillow", # ] # /// """ stlthumb.py — Generate PNG thumbnails for STL files. Features - CLI (Typer): render thumbnails with size, angles, bg/transparent - Web (FastAPI): HTMX+Tailwind UI to upload STL and get a thumbnail - SQLite: records every render (inputs, size, angles, bytes) and serves by id - Shading modes: lambert (default), unlit/flat, toon (cel), depth Usage CLI: stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45 stlthumb.py render model.stl --transparent --color "#f59e0b" --shading toon --toon-levels 4 stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 --proj ortho stlthumb.py render model.stl --light 1 1 1 --ambient 0.2 --diffuse 0.8 Server: stlthumb.py serve --host 0.0.0.0 --port 8037 Notes * "Lambert" is a diffuse-only shading model: brightness ∝ cos(θ) between face normal and light direction. * For toon, intensity is quantized into N bands. * For depth, brightness comes from camera-space Z (near = bright, far = dark). """ from __future__ import annotations import base64 import io import os import sqlite3 import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Optional, Tuple, Literal import numpy as np # Use non-interactive backend for headless rendering import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402 from fastapi import FastAPI, File, Form, UploadFile # noqa: E402 from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse # noqa: E402 from stl import mesh as npstl # noqa: E402 import typer # noqa: E402 # ---------- Configuration ---------- APP_NAME = "STL Thumb" DB_PATH = Path("stlthumbs.db") cli = typer.Typer(add_completion=False, no_args_is_help=True) app = FastAPI(title=APP_NAME, version="1.1") # ---------- Utilities ---------- def ensure_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.execute( """ CREATE TABLE IF NOT EXISTS thumbnails ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, stl_name TEXT NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, elev REAL NOT NULL, azim REAL NOT NULL, bg TEXT NOT NULL, transparent INTEGER NOT NULL, edge_alpha REAL NOT NULL, bytes_size INTEGER NOT NULL, png_bytes BLOB NOT NULL ) """ ) conn.commit() return conn @dataclass class RenderParams: width: int = 512 height: int = 512 elev: float = 20.0 azim: float = 45.0 bg: str = "#111827" # Tailwind slate-900-ish transparent: bool = False edge_alpha: float = 0.12 # subtle edges help readability color: str = "#38bdf8" # base material color shading: Literal["lambert", "unlit", "toon", "depth"] = "lambert" ambient: float = 0.20 diffuse: float = 0.80 light_dir: Tuple[float, float, float] = (0.577, 0.577, 0.577) toon_levels: int = 4 proj: Literal["persp", "ortho"] = "persp" def _hex_to_rgb01(h: str) -> Tuple[float, float, float]: s = h.strip().lstrip("#") if len(s) == 3: s = "".join([c * 2 for c in s]) if len(s) != 6: raise ValueError(f"Invalid hex color: {h!r}") r = int(s[0:2], 16) / 255.0 g = int(s[2:4], 16) / 255.0 b = int(s[4:6], 16) / 255.0 return r, g, b def _normalize(v: np.ndarray) -> np.ndarray: n = np.linalg.norm(v, axis=-1, keepdims=True) return np.divide(v, np.where(n == 0, 1, n)) def _rotate_vectors( vectors: np.ndarray, elev_deg: float, azim_deg: float ) -> np.ndarray: """Rotate vectors by azimuth (Z) then elevation (X) to approximate camera view.""" az = np.deg2rad(azim_deg) el = np.deg2rad(elev_deg) Rz = np.array( [ [np.cos(az), -np.sin(az), 0], [np.sin(az), np.cos(az), 0], [0, 0, 1], ] ) Rx = np.array( [ [1, 0, 0], [0, np.cos(el), -np.sin(el)], [0, np.sin(el), np.cos(el)], ] ) R = Rx @ Rz v = vectors.reshape(-1, 3) v_rot = (R @ v.T).T return v_rot.reshape(-1, 3, 3) def _shade_colors( normals: np.ndarray, base_rgb: Tuple[float, float, float], params: RenderParams, vectors: np.ndarray, ) -> np.ndarray: mode = params.shading if mode == "unlit": N = normals.shape[0] return np.tile(np.array([*base_rgb, 1.0])[None, :], (N, 1)) if mode == "toon": L = _normalize(np.array(params.light_dir, dtype=float)) dots = (normals @ L).clip(0, 1) intensity = params.ambient + params.diffuse * dots # quantize levels = max(1, int(params.toon_levels)) q = np.floor(intensity * levels) / max(1, (levels - 1)) q = q.clip(0, 1) r, g, b = base_rgb return np.stack([r * q, g * q, b * q, np.ones_like(q)], axis=1) if mode == "depth": # rotate to camera space and use triangle centroid Z for intensity v_cam = _rotate_vectors(vectors, params.elev, params.azim) centroids = v_cam.mean(axis=1) z = centroids[:, 2] # map z from [zmin,zmax] -> [1,0] (near bright) zmin, zmax = float(z.min()), float(z.max()) if zmax - zmin == 0: t = np.ones_like(z) else: t = 1.0 - (z - zmin) / (zmax - zmin) t = (params.ambient + (1 - params.ambient) * t).clip(0, 1) r, g, b = base_rgb return np.stack([r * t, g * t, b * t, np.ones_like(t)], axis=1) # default: lambert L = _normalize(np.array(params.light_dir, dtype=float)) dots = (normals @ L).clip(0, 1) intensity = params.ambient + params.diffuse * dots r, g, b = base_rgb cols = np.stack( [r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1 ) return cols def _center_and_scale(vectors: np.ndarray) -> np.ndarray: """ Center mesh at origin and scale uniformly so the largest extent fits [-0.5, 0.5]. vectors: (N,3,3) triangles """ all_pts = vectors.reshape(-1, 3) center = all_pts.mean(axis=0) centered = all_pts - center mins = centered.min(axis=0) maxs = centered.max(axis=0) extents = maxs - mins max_extent = float(extents.max()) or 1.0 scale = 1.0 / max_extent scaled = centered * scale return scaled.reshape(-1, 3, 3) def render_stl_to_png_bytes( stl_path: Path, params: RenderParams = RenderParams(), dpi: int = 200, ) -> bytes: """ Load an STL and render a PNG thumbnail into bytes using matplotlib (headless). """ # Load STL (handles ASCII & binary) stl_mesh: npstl.Mesh = npstl.Mesh.from_file(str(stl_path)) # Prepare geometry vectors = _center_and_scale(stl_mesh.vectors.copy()) # (N,3,3) normals = stl_mesh.normals.copy() normals = _normalize(normals) # Shading colors base_rgb = _hex_to_rgb01(params.color) face_colors = _shade_colors(normals, base_rgb, params, vectors) # Matplotlib figure fig = plt.figure(figsize=(params.width / dpi, params.height / dpi), dpi=dpi) # Background if params.transparent: fig.patch.set_alpha(0.0) fig.patch.set_facecolor("none") bg_color = (0, 0, 0, 0) else: fig.patch.set_facecolor(params.bg) bg_color = _hex_to_rgb01(params.bg) + (1.0,) ax = fig.add_subplot(111, projection="3d") ax.set_axis_off() ax.set_facecolor(bg_color) try: ax.set_proj_type("ortho" if params.proj == "ortho" else "persp") except Exception: pass # older Matplotlib # Equal aspect & limits ax.set_box_aspect((1, 1, 1)) lim = 0.6 # a bit of padding beyond [-0.5, 0.5] ax.set_xlim3d(-lim, lim) ax.set_ylim3d(-lim, lim) ax.set_zlim3d(-lim, lim) # View ax.view_init(elev=params.elev, azim=params.azim) # Triangles tri_collection = Poly3DCollection(vectors, linewidths=0.2) tri_collection.set_facecolor(face_colors) tri_collection.set_edgecolor((0, 0, 0, float(params.edge_alpha))) ax.add_collection3d(tri_collection) # Render to bytes buf = io.BytesIO() plt.subplots_adjust(0, 0, 1, 1) ax.margins(0, 0, 0) plt.savefig( buf, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0, transparent=params.transparent, facecolor=fig.get_facecolor(), ) plt.close(fig) buf.seek(0) return buf.read() def _record_png( conn: sqlite3.Connection, stl_name: str, png_bytes: bytes, params: RenderParams, ) -> int: cur = conn.cursor() cur.execute( """ INSERT INTO thumbnails (created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size, png_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( datetime.utcnow().isoformat(timespec="seconds") + "Z", stl_name, params.width, params.height, params.elev, params.azim, params.bg, int(params.transparent), float(params.edge_alpha), len(png_bytes), sqlite3.Binary(png_bytes), ), ) conn.commit() return int(cur.lastrowid) def _row_to_info(row: tuple) -> dict: return { "id": row[0], "created_at": row[1], "stl_name": row[2], "width": row[3], "height": row[4], "elev": row[5], "azim": row[6], "bg": row[7], "transparent": bool(row[8]), "edge_alpha": row[9], "bytes_size": row[10], } # ---------- CLI ---------- @cli.command("render") def cli_render( stl: Path = typer.Argument(..., exists=True, readable=True, help="Input .stl file"), output: Optional[Path] = typer.Option( None, "--output", "-o", help="Output PNG path (default: .png next to input)", ), size: int = typer.Option( 512, "--size", "-s", min=64, max=4096, help="Square size in px" ), width: Optional[int] = typer.Option( None, help="PNG width in px (overrides --size)" ), height: Optional[int] = typer.Option( None, help="PNG height in px (overrides --size)" ), elev: float = typer.Option(20.0, help="Elevation angle"), azim: float = typer.Option(45.0, help="Azimuth angle"), bg: str = typer.Option("#111827", help="Background color (hex)"), transparent: bool = typer.Option( False, "--transparent/--opaque", help="Transparent background" ), edge_alpha: float = typer.Option( 0.12, min=0.0, max=1.0, help="Edge alpha (0 disables edges)" ), color: str = typer.Option("#38bdf8", help="Base material color (hex)"), shading: str = typer.Option( "lambert", case_sensitive=False, help="Shading: lambert|unlit|toon|depth" ), ambient: float = typer.Option( 0.20, min=0.0, max=1.0, help="Ambient term for lambert/toon" ), diffuse: float = typer.Option( 0.80, min=0.0, max=1.0, help="Diffuse term for lambert/toon" ), light: Tuple[float, float, float] = typer.Option( (0.577, 0.577, 0.577), help="Light direction x y z" ), toon_levels: int = typer.Option(4, min=1, max=16, help="Toon levels (bands)"), proj: str = typer.Option("persp", help="Projection: persp|ortho"), ): """Render STL to a PNG thumbnail.""" w = width or size h = height or size params = RenderParams( width=w, height=h, elev=elev, azim=azim, bg=bg, transparent=transparent, edge_alpha=edge_alpha, color=color, shading=shading.lower(), ambient=ambient, diffuse=diffuse, light_dir=light, toon_levels=toon_levels, proj=proj.lower(), ) typer.echo(f"[{APP_NAME}] Rendering: {stl} → {output or (stl.with_suffix('.png'))}") png = render_stl_to_png_bytes(stl, params) out = output or stl.with_suffix(".png") out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(png) typer.echo(f"Saved: {out} ({len(png)} bytes)") conn = ensure_db() rec_id = _record_png(conn, stl.name, png, params) typer.echo(f"Recorded in DB: {DB_PATH} (id={rec_id})") @cli.command("history") def cli_history( limit: int = typer.Option(25, min=1, max=500), ): """Show recent renders stored in the SQLite database.""" if not DB_PATH.exists(): typer.echo("No history yet.") raise typer.Exit(code=0) conn = ensure_db() rows = conn.execute( "SELECT id, created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size FROM thumbnails ORDER BY id DESC LIMIT ?", (limit,), ).fetchall() if not rows: typer.echo("No history.") return for r in rows: info = _row_to_info(r) tflag = "T" if info["transparent"] else "B" typer.echo( f"#{info['id']:>4} {info['created_at']} {info['stl_name']:<25} " f"{info['width']}x{info['height']} elev={info['elev']:>4.0f} azim={info['azim']:>4.0f} " f"{tflag}G={info['bg']} α={info['edge_alpha']:.2f} {info['bytes_size']}B" ) @cli.command("serve") def cli_serve( host: str = typer.Option("127.0.0.1", help="Bind host"), port: int = typer.Option(8037, help="Port"), reload: bool = typer.Option(False, help="Auto-reload on code changes"), ): """Run the FastAPI server with the HTMX+Tailwind UI.""" import uvicorn ensure_db() uvicorn.run(app, host=host, port=port, reload=reload) # ---------- Web (FastAPI + HTMX + Tailwind) ---------- HOME_HTML = """ STL → PNG Thumbnail

STL → PNG Thumbnail

Upload an .stl and get a shaded PNG thumbnail. Everything renders locally.

Tip: Default material color is sky-400. Try toon or depth for stylized thumbs.

""" @app.get("/", response_class=HTMLResponse) def home() -> str: return HOME_HTML @app.get("/healthz", response_class=PlainTextResponse) def healthz() -> str: return "ok" @app.get("/thumb/{thumb_id}.png") def get_thumbnail(thumb_id: int): conn = ensure_db() row = conn.execute( "SELECT png_bytes FROM thumbnails WHERE id= ?", (thumb_id,) ).fetchone() if not row: return PlainTextResponse("Not Found", status_code=404) data = row[0] return StreamingResponse(io.BytesIO(data), media_type="image/png") @app.post("/api/thumbnail", response_class=HTMLResponse) async def api_thumbnail( stl: UploadFile = File(...), width: int = Form(512), height: int = Form(512), elev: float = Form(20.0), azim: float = Form(45.0), bg: str = Form("#111827"), edge_alpha: float = Form(0.12), transparent: Optional[str] = Form(None), color: str = Form("#38bdf8"), shading: str = Form("lambert"), ambient: float = Form(0.20), diffuse: float = Form(0.80), lx: float = Form(0.577), ly: float = Form(0.577), lz: float = Form(0.577), toon_levels: int = Form(4), proj: str = Form("persp"), ): # Persist incoming file to temp data = await stl.read() tmp_path = Path(f"/tmp/{int(time.time() * 1000)}_{stl.filename}") tmp_path.write_bytes(data) params = RenderParams( width=width, height=height, elev=elev, azim=azim, bg=bg, transparent=bool(transparent), edge_alpha=edge_alpha, color=color, shading=shading, ambient=ambient, diffuse=diffuse, light_dir=(lx, ly, lz), toon_levels=toon_levels, proj=proj, ) try: png = render_stl_to_png_bytes(tmp_path, params) except Exception as e: return HTMLResponse( f'
' f"

Render failed

" f"
{e}
" f"
", status_code=400, ) finally: # cleanup temp try: tmp_path.unlink(missing_ok=True) except Exception: pass conn = ensure_db() rec_id = _record_png(conn, stl.filename, png, params) b64 = base64.b64encode(png).decode("ascii") html = f"""
\"thumbnail\"/
Download PNG (#{rec_id}) | Size: {len(png)} bytes
""" return HTMLResponse(html) # ---------- Entrypoint ---------- def _maybe_run_cli_or_server(): # If uvicorn is exec-ing this, avoid double-running CLI if os.environ.get("RUN_FROM_UVICORN", "") == "1": return cli() if __name__ == "__main__": _maybe_run_cli_or_server()