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.
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 = """ - +
- - + +Upload an .stl and get a shaded PNG thumbnail. Everything renders locally.
Upload an .stl and get a shaded PNG thumbnail. Everything renders locally.