diff --git a/stlthumb.py b/stlthumb.py old mode 100644 new mode 100755 index c757b89..c852942 --- a/stlthumb.py +++ b/stlthumb.py @@ -11,29 +11,31 @@ # "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 +- 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 - stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 + 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 - TUI: - stlthumb.py tui model.stl +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 @@ -46,7 +48,7 @@ import time from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, Literal import numpy as np @@ -67,7 +69,7 @@ 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") +app = FastAPI(title=APP_NAME, version="1.1") # ---------- Utilities ---------- @@ -104,6 +106,13 @@ class RenderParams: 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]: @@ -118,20 +127,78 @@ def _hex_to_rgb01(h: str) -> Tuple[float, float, float]: return r, g, b -def _shade_colors( - normals: np.ndarray, base_rgb: Tuple[float, float, float] +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: - """ - 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 + """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 @@ -170,14 +237,11 @@ def render_stl_to_png_bytes( # 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)) + normals = _normalize(normals) # Shading colors - base_rgb = _hex_to_rgb01("#38bdf8") # Tailwind sky-400: nice friendly default - face_colors = _shade_colors(normals, base_rgb) + 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) @@ -193,6 +257,10 @@ def render_stl_to_png_bytes( 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)) @@ -213,8 +281,6 @@ def render_stl_to_png_bytes( # 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, @@ -304,6 +370,21 @@ def cli_render( 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 @@ -316,6 +397,13 @@ def cli_render( 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'))}") @@ -371,78 +459,128 @@ def cli_serve( # ---------- Web (FastAPI + HTMX + Tailwind) ---------- HOME_HTML = """ - + - - + + STL → PNG Thumbnail - - + + - -
-

STL → PNG Thumbnail

-

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

+ +
+

STL → PNG Thumbnail

+

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

-
+
- - STL file + + w-full text-sm text-slate-200\"/>
-
+
- - + +
- - + +
- - + +
- - + +
-
+
- - + +
- - + +
-
- -
+
-
-

Tip: Default material color is sky-400 with per-face lambert shading.

+
+

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

@@ -482,6 +620,15 @@ async def api_thumbnail( 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() @@ -496,15 +643,22 @@ async def api_thumbnail( 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'
' f"

Render failed

" - f"
{typer.style(str(e), fg=typer.colors.RED)}
" + f"
{e}
" f"
", status_code=400, ) @@ -520,87 +674,20 @@ async def api_thumbnail( b64 = base64.b64encode(png).decode("ascii") html = f""" -
-
- thumbnail +
+
+ \"thumbnail\"/
-
- Download PNG (#{rec_id}) - | - Size: {len(png)} bytes +
+ 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