diff --git a/justfile b/justfile index f400322..e4e45dc 100644 --- a/justfile +++ b/justfile @@ -1,2 +1,2 @@ serve: - ./stlthumb.py serve --host 0.0.0.0 --port 8037 + stlthumb.py serve --host 0.0.0.0 --port 8037 diff --git a/stlthumb.py b/stlthumb.py old mode 100755 new mode 100644 index c852942..c757b89 --- a/stlthumb.py +++ b/stlthumb.py @@ -11,31 +11,29 @@ # "numpy-stl", # "matplotlib", # "pillow", +# "textual", # ] # /// """ 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 +- 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 -- 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 + 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 -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). + TUI: + stlthumb.py tui model.stl """ from __future__ import annotations @@ -48,7 +46,7 @@ import time from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Optional, Tuple, Literal +from typing import Optional, Tuple import numpy as np @@ -69,7 +67,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.1") +app = FastAPI(title=APP_NAME, version="1.0") # ---------- Utilities ---------- @@ -106,13 +104,6 @@ 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]: @@ -127,78 +118,20 @@ def _hex_to_rgb01(h: str) -> Tuple[float, float, float]: 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, + normals: np.ndarray, base_rgb: Tuple[float, float, float] ) -> 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 + """ + 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 @@ -237,11 +170,14 @@ def render_stl_to_png_bytes( # Prepare geometry vectors = _center_and_scale(stl_mesh.vectors.copy()) # (N,3,3) normals = stl_mesh.normals.copy() - normals = _normalize(normals) + # 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(params.color) - face_colors = _shade_colors(normals, base_rgb, params, vectors) + 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) @@ -257,10 +193,6 @@ 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)) @@ -281,6 +213,8 @@ 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, @@ -370,21 +304,6 @@ 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 @@ -397,13 +316,6 @@ 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'))}") @@ -459,128 +371,78 @@ 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. Try toon or depth for stylized thumbs.

+
+

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

@@ -620,15 +482,6 @@ 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() @@ -643,22 +496,15 @@ 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"
{e}
" + f"
{typer.style(str(e), fg=typer.colors.RED)}
" f"
", status_code=400, ) @@ -674,20 +520,87 @@ 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