From bd6a19812d1d2abcd5f5e21a35048d2ea5db319d Mon Sep 17 00:00:00 2001 From: "Waylon S. Walker" Date: Fri, 29 Aug 2025 20:22:35 -0500 Subject: [PATCH] Initial Commit for stlthumb Make thumbnails for stl files. --- .gitignore | 0 .null-ls_573538_stlthumb.py | 613 ++++++++++++++++++++++++++++++++++++ README.md | 3 + justfile | 2 + stlthumb.py | 613 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1231 insertions(+) create mode 100644 .gitignore create mode 100644 .null-ls_573538_stlthumb.py create mode 100644 README.md create mode 100644 justfile create mode 100644 stlthumb.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.null-ls_573538_stlthumb.py b/.null-ls_573538_stlthumb.py new file mode 100644 index 0000000..c757b89 --- /dev/null +++ b/.null-ls_573538_stlthumb.py @@ -0,0 +1,613 @@ +#!/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", +# "textual", +# ] +# /// +""" +stlthumb.py — Generate PNG thumbnails for STL files. + +Features +- CLI (Typer): render thumbnails with custom size, angles, bg/transparent +- Web (FastAPI): simple HTMX+Tailwind UI to upload STL and get a thumbnail +- TUI (Textual): preview a generated thumbnail in your terminal +- SQLite: records every render (inputs, size, angles, bytes) and serves by id + +Usage + CLI: + stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45 + stlthumb.py render model.stl --transparent + stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 + + Server: + stlthumb.py serve --host 0.0.0.0 --port 8037 + + TUI: + stlthumb.py tui model.stl +""" + +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 + +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.0") + + +# ---------- 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 + + +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 _shade_colors( + normals: np.ndarray, base_rgb: Tuple[float, float, float] +) -> np.ndarray: + """ + Simple Lambertian shading per face using a fixed light direction. + normals: (N,3) unit normals per triangle + base_rgb: tuple in 0..1 + returns (N,4) RGBA colors + """ + light_dir = np.array([0.577, 0.577, 0.577]) # approx from (1,1,1) normalized + light_dir /= np.linalg.norm(light_dir) + dots = (normals @ light_dir).clip(0, 1) # cosine term + # Add ambient term + intensity = 0.20 + 0.80 * 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() + # Recompute normals in scaled space to be safe + # (numpy-stl normals are unit vectors per triangle; re-normalize) + n_norm = np.linalg.norm(normals, axis=1, keepdims=True) + normals = np.divide(normals, np.where(n_norm == 0, 1, n_norm)) + + # Shading colors + base_rgb = _hex_to_rgb01("#38bdf8") # Tailwind sky-400: nice friendly default + face_colors = _shade_colors(normals, base_rgb) + + # 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) + + # 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) + # NOTE: For 3D axes, margins must be a single value or (x, y, z). Using plt.margins(0, 0) + # raises a TypeError. Use the Axes3D API instead: + 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)" + ), +): + """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, + ) + + 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 with per-face lambert shading.

+
+
+ + +""" + + +@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), +): + # 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, + ) + + try: + png = render_stl_to_png_bytes(tmp_path, params) + except Exception as e: + return HTMLResponse( + f'
' + f"

Render failed

" + f"
{typer.style(str(e), fg=typer.colors.RED)}
" + 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) + + +# ---------- TUI (Textual) ---------- +@cli.command("tui") +def cli_tui( + stl: Path = typer.Argument( + ..., exists=True, readable=True, help="Input .stl file to preview" + ), +): + """Render and preview a thumbnail in your terminal (Textual).""" + # Lazy import textual so basic CLI doesn't pull it in if not needed + from textual.app import App, ComposeResult + from textual.widgets import Header, Footer, Static + from textual.containers import Vertical + + try: + from textual.widgets import Image as TImage # textual >= 0.45 + + HAS_IMAGE = True + except Exception: + HAS_IMAGE = False + + class ThumbApp(App): + CSS = """ + Screen { background: #0b1220; } + #wrap { align: center middle; height: 100%; } + """ + + def __init__(self, stl_path: Path): + super().__init__() + self.stl_path = stl_path + self.png_bytes: Optional[bytes] = None + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Vertical(Static("Rendering thumbnail...", id="status"), id="wrap") + yield Footer() + + def on_mount(self) -> None: + # Render in background thread to avoid blocking UI + def _do_render(): + params = RenderParams( + width=700, + height=500, + elev=22, + azim=45, + bg="#0b1220", + transparent=False, + edge_alpha=0.08, + ) + self.png_bytes = render_stl_to_png_bytes(self.stl_path, params) + self.call_from_thread(self._show_image) + + self.run_worker(_do_render, exclusive=True) + + def _show_image(self): + self.query_one("#status", Static).update( + f"[b]{self.stl_path.name}[/b] • {len(self.png_bytes or b'')} bytes" + ) + if self.png_bytes and HAS_IMAGE: + # Save to tmp and display + tmp = Path(f"/tmp/{int(time.time() * 1000)}_{self.stl_path.stem}.png") + tmp.write_bytes(self.png_bytes) + img = TImage.from_path(tmp) + self.query_one("#wrap", Vertical).mount(img) + + ThumbApp(stl).run() + + +# ---------- 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() diff --git a/README.md b/README.md new file mode 100644 index 0000000..405a4bb --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# stlthumb + +Make thumbnails for stl files. diff --git a/justfile b/justfile new file mode 100644 index 0000000..e4e45dc --- /dev/null +++ b/justfile @@ -0,0 +1,2 @@ +serve: + stlthumb.py serve --host 0.0.0.0 --port 8037 diff --git a/stlthumb.py b/stlthumb.py new file mode 100644 index 0000000..c757b89 --- /dev/null +++ b/stlthumb.py @@ -0,0 +1,613 @@ +#!/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", +# "textual", +# ] +# /// +""" +stlthumb.py — Generate PNG thumbnails for STL files. + +Features +- CLI (Typer): render thumbnails with custom size, angles, bg/transparent +- Web (FastAPI): simple HTMX+Tailwind UI to upload STL and get a thumbnail +- TUI (Textual): preview a generated thumbnail in your terminal +- SQLite: records every render (inputs, size, angles, bytes) and serves by id + +Usage + CLI: + stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45 + stlthumb.py render model.stl --transparent + stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 + + Server: + stlthumb.py serve --host 0.0.0.0 --port 8037 + + TUI: + stlthumb.py tui model.stl +""" + +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 + +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.0") + + +# ---------- 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 + + +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 _shade_colors( + normals: np.ndarray, base_rgb: Tuple[float, float, float] +) -> np.ndarray: + """ + Simple Lambertian shading per face using a fixed light direction. + normals: (N,3) unit normals per triangle + base_rgb: tuple in 0..1 + returns (N,4) RGBA colors + """ + light_dir = np.array([0.577, 0.577, 0.577]) # approx from (1,1,1) normalized + light_dir /= np.linalg.norm(light_dir) + dots = (normals @ light_dir).clip(0, 1) # cosine term + # Add ambient term + intensity = 0.20 + 0.80 * 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() + # Recompute normals in scaled space to be safe + # (numpy-stl normals are unit vectors per triangle; re-normalize) + n_norm = np.linalg.norm(normals, axis=1, keepdims=True) + normals = np.divide(normals, np.where(n_norm == 0, 1, n_norm)) + + # Shading colors + base_rgb = _hex_to_rgb01("#38bdf8") # Tailwind sky-400: nice friendly default + face_colors = _shade_colors(normals, base_rgb) + + # 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) + + # 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) + # NOTE: For 3D axes, margins must be a single value or (x, y, z). Using plt.margins(0, 0) + # raises a TypeError. Use the Axes3D API instead: + 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)" + ), +): + """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, + ) + + 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 with per-face lambert shading.

+
+
+ + +""" + + +@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), +): + # 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, + ) + + try: + png = render_stl_to_png_bytes(tmp_path, params) + except Exception as e: + return HTMLResponse( + f'
' + f"

Render failed

" + f"
{typer.style(str(e), fg=typer.colors.RED)}
" + 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) + + +# ---------- TUI (Textual) ---------- +@cli.command("tui") +def cli_tui( + stl: Path = typer.Argument( + ..., exists=True, readable=True, help="Input .stl file to preview" + ), +): + """Render and preview a thumbnail in your terminal (Textual).""" + # Lazy import textual so basic CLI doesn't pull it in if not needed + from textual.app import App, ComposeResult + from textual.widgets import Header, Footer, Static + from textual.containers import Vertical + + try: + from textual.widgets import Image as TImage # textual >= 0.45 + + HAS_IMAGE = True + except Exception: + HAS_IMAGE = False + + class ThumbApp(App): + CSS = """ + Screen { background: #0b1220; } + #wrap { align: center middle; height: 100%; } + """ + + def __init__(self, stl_path: Path): + super().__init__() + self.stl_path = stl_path + self.png_bytes: Optional[bytes] = None + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Vertical(Static("Rendering thumbnail...", id="status"), id="wrap") + yield Footer() + + def on_mount(self) -> None: + # Render in background thread to avoid blocking UI + def _do_render(): + params = RenderParams( + width=700, + height=500, + elev=22, + azim=45, + bg="#0b1220", + transparent=False, + edge_alpha=0.08, + ) + self.png_bytes = render_stl_to_png_bytes(self.stl_path, params) + self.call_from_thread(self._show_image) + + self.run_worker(_do_render, exclusive=True) + + def _show_image(self): + self.query_one("#status", Static).update( + f"[b]{self.stl_path.name}[/b] • {len(self.png_bytes or b'')} bytes" + ) + if self.png_bytes and HAS_IMAGE: + # Save to tmp and display + tmp = Path(f"/tmp/{int(time.time() * 1000)}_{self.stl_path.stem}.png") + tmp.write_bytes(self.png_bytes) + img = TImage.from_path(tmp) + self.query_one("#wrap", Vertical).mount(img) + + ThumbApp(stl).run() + + +# ---------- 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()