stlthumb/stlthumb.py
Waylon S. Walker bd6a19812d Initial Commit for stlthumb
Make thumbnails for stl files.
2025-08-29 20:22:35 -05:00

613 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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: <stl>.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 = """<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>STL → PNG Thumbnail</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="h-full bg-slate-950 text-slate-100">
<main class="mx-auto max-w-3xl p-6">
<h1 class="text-3xl font-bold tracking-tight mb-4">STL → PNG Thumbnail</h1>
<p class="text-slate-300 mb-6">Upload an <code>.stl</code> and get a shaded PNG thumbnail. Everything renders locally.</p>
<form hx-post="/api/thumbnail" hx-target="#result" hx-swap="innerHTML" enctype="multipart/form-data"
class="grid gap-4 bg-slate-900/60 p-4 rounded-2xl border border-slate-800">
<div>
<label class="block text-sm mb-1">STL file</label>
<input type="file" name="stl" accept=".stl" required
class="file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0
file:text-sm file:bg-sky-500 file:text-white hover:file:bg-sky-400
w-full text-sm text-slate-200"/>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label class="block text-sm mb-1">Width</label>
<input type="number" name="width" value="512" min="64" max="4096"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Height</label>
<input type="number" name="height" value="512" min="64" max="4096"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Elev</label>
<input type="number" step="1" name="elev" value="20"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2"/>
</div>
<div>
<label class="block text-sm mb-1">Azim</label>
<input type="number" step="1" name="azim" value="45"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2"/>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
<div>
<label class="block text-sm mb-1">Background</label>
<input type="text" name="bg" value="#111827"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2" />
</div>
<div>
<label class="block text-sm mb-1">Edge α</label>
<input type="number" name="edge_alpha" value="0.12" step="0.01" min="0" max="1"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2"/>
</div>
<label class="inline-flex items-center gap-2">
<input type="checkbox" name="transparent" class="size-5 rounded border-slate-600 bg-slate-800" />
<span class="text-sm">Transparent</span>
</label>
</div>
<button class="inline-flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white px-4 py-2 rounded-xl text-sm font-semibold">
<span>Generate</span>
</button>
</form>
<div id="result" class="mt-6"></div>
<div class="mt-10 text-xs text-slate-500">
<p>Tip: Default material color is <span class="text-sky-400">sky-400</span> with per-face lambert shading.</p>
</div>
</main>
</body>
</html>
"""
@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'<div class="p-4 rounded-xl bg-red-900/40 border border-red-800">'
f"<p class='font-semibold text-red-200'>Render failed</p>"
f"<pre class='text-red-300 text-xs mt-2'>{typer.style(str(e), fg=typer.colors.RED)}</pre>"
f"</div>",
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"""
<div class="grid gap-3">
<div class="rounded-2xl overflow-hidden ring-1 ring-slate-800 bg-slate-900/50">
<img src="data:image/png;base64,{b64}" class="w-full h-auto block" alt="thumbnail"/>
</div>
<div class="text-xs text-slate-400">
<a class="underline hover:text-slate-200" href="/thumb/{rec_id}.png" download>Download PNG (#{rec_id})</a>
<span class="ml-2">|</span>
<span class="ml-2">Size: {len(png)} bytes</span>
</div>
</div>
"""
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()