This commit is contained in:
Waylon Walker 2025-08-29 20:33:52 -05:00
parent 803f264a2f
commit 6dab095864

385
stlthumb.py Normal file → Executable file
View file

@ -11,29 +11,31 @@
# "numpy-stl", # "numpy-stl",
# "matplotlib", # "matplotlib",
# "pillow", # "pillow",
# "textual",
# ] # ]
# /// # ///
""" """
stlthumb.py Generate PNG thumbnails for STL files. stlthumb.py Generate PNG thumbnails for STL files.
Features Features
- CLI (Typer): render thumbnails with custom size, angles, bg/transparent - CLI (Typer): render thumbnails with size, angles, bg/transparent
- Web (FastAPI): simple HTMX+Tailwind UI to upload STL and get a thumbnail - Web (FastAPI): 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 - SQLite: records every render (inputs, size, angles, bytes) and serves by id
- Shading modes: lambert (default), unlit/flat, toon (cel), depth
Usage Usage
CLI: CLI:
stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45 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 --transparent --color "#f59e0b" --shading toon --toon-levels 4
stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 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: Server:
stlthumb.py serve --host 0.0.0.0 --port 8037 stlthumb.py serve --host 0.0.0.0 --port 8037
TUI: Notes
stlthumb.py tui model.stl * "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 from __future__ import annotations
@ -46,7 +48,7 @@ import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple, Literal
import numpy as np import numpy as np
@ -67,7 +69,7 @@ APP_NAME = "STL Thumb"
DB_PATH = Path("stlthumbs.db") DB_PATH = Path("stlthumbs.db")
cli = typer.Typer(add_completion=False, no_args_is_help=True) 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 ---------- # ---------- Utilities ----------
@ -104,6 +106,13 @@ class RenderParams:
bg: str = "#111827" # Tailwind slate-900-ish bg: str = "#111827" # Tailwind slate-900-ish
transparent: bool = False transparent: bool = False
edge_alpha: float = 0.12 # subtle edges help readability 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]: 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 return r, g, b
def _shade_colors( def _normalize(v: np.ndarray) -> np.ndarray:
normals: np.ndarray, base_rgb: Tuple[float, float, float] 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: ) -> np.ndarray:
""" """Rotate vectors by azimuth (Z) then elevation (X) to approximate camera view."""
Simple Lambertian shading per face using a fixed light direction. az = np.deg2rad(azim_deg)
normals: (N,3) unit normals per triangle el = np.deg2rad(elev_deg)
base_rgb: tuple in 0..1 Rz = np.array(
returns (N,4) RGBA colors [
""" [np.cos(az), -np.sin(az), 0],
light_dir = np.array([0.577, 0.577, 0.577]) # approx from (1,1,1) normalized [np.sin(az), np.cos(az), 0],
light_dir /= np.linalg.norm(light_dir) [0, 0, 1],
dots = (normals @ light_dir).clip(0, 1) # cosine term ]
# Add ambient term )
intensity = 0.20 + 0.80 * dots 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 r, g, b = base_rgb
cols = np.stack( cols = np.stack(
[r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1 [r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1
@ -170,14 +237,11 @@ def render_stl_to_png_bytes(
# Prepare geometry # Prepare geometry
vectors = _center_and_scale(stl_mesh.vectors.copy()) # (N,3,3) vectors = _center_and_scale(stl_mesh.vectors.copy()) # (N,3,3)
normals = stl_mesh.normals.copy() normals = stl_mesh.normals.copy()
# Recompute normals in scaled space to be safe normals = _normalize(normals)
# (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 # Shading colors
base_rgb = _hex_to_rgb01("#38bdf8") # Tailwind sky-400: nice friendly default base_rgb = _hex_to_rgb01(params.color)
face_colors = _shade_colors(normals, base_rgb) face_colors = _shade_colors(normals, base_rgb, params, vectors)
# Matplotlib figure # Matplotlib figure
fig = plt.figure(figsize=(params.width / dpi, params.height / dpi), dpi=dpi) 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 = fig.add_subplot(111, projection="3d")
ax.set_axis_off() ax.set_axis_off()
ax.set_facecolor(bg_color) 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 # Equal aspect & limits
ax.set_box_aspect((1, 1, 1)) ax.set_box_aspect((1, 1, 1))
@ -213,8 +281,6 @@ def render_stl_to_png_bytes(
# Render to bytes # Render to bytes
buf = io.BytesIO() buf = io.BytesIO()
plt.subplots_adjust(0, 0, 1, 1) 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) ax.margins(0, 0, 0)
plt.savefig( plt.savefig(
buf, buf,
@ -304,6 +370,21 @@ def cli_render(
edge_alpha: float = typer.Option( edge_alpha: float = typer.Option(
0.12, min=0.0, max=1.0, help="Edge alpha (0 disables edges)" 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.""" """Render STL to a PNG thumbnail."""
w = width or size w = width or size
@ -316,6 +397,13 @@ def cli_render(
bg=bg, bg=bg,
transparent=transparent, transparent=transparent,
edge_alpha=edge_alpha, 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'))}") typer.echo(f"[{APP_NAME}] Rendering: {stl}{output or (stl.with_suffix('.png'))}")
@ -371,78 +459,128 @@ def cli_serve(
# ---------- Web (FastAPI + HTMX + Tailwind) ---------- # ---------- Web (FastAPI + HTMX + Tailwind) ----------
HOME_HTML = """<!doctype html> HOME_HTML = """<!doctype html>
<html lang="en" class="h-full"> <html lang=\"en\" class=\"h-full\">
<head> <head>
<meta charset="utf-8" /> <meta charset=\"utf-8\" />
<meta name="viewport" content="width=device-width,initial-scale=1"/> <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>
<title>STL PNG Thumbnail</title> <title>STL PNG Thumbnail</title>
<script src="https://unpkg.com/htmx.org@1.9.12"></script> <script src=\"https://unpkg.com/htmx.org@1.9.12\"></script>
<script src="https://cdn.tailwindcss.com"></script> <script src=\"https://cdn.tailwindcss.com\"></script>
</head> </head>
<body class="h-full bg-slate-950 text-slate-100"> <body class=\"h-full bg-slate-950 text-slate-100\">
<main class="mx-auto max-w-3xl p-6"> <main class=\"mx-auto max-w-3xl p-6\">
<h1 class="text-3xl font-bold tracking-tight mb-4">STL PNG Thumbnail</h1> <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> <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" <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"> class=\"grid gap-4 bg-slate-900/60 p-4 rounded-2xl border border-slate-800\">
<div> <div>
<label class="block text-sm mb-1">STL file</label> <label class=\"block text-sm mb-1\">STL file</label>
<input type="file" name="stl" accept=".stl" required <input type=\"file\" name=\"stl\" accept=\".stl\" required
class="file:mr-4 file:py-2 file:px-4 file:rounded-xl file:border-0 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 file:text-sm file:bg-sky-500 file:text-white hover:file:bg-sky-400
w-full text-sm text-slate-200"/> w-full text-sm text-slate-200\"/>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class=\"grid grid-cols-2 md:grid-cols-4 gap-3\">
<div> <div>
<label class="block text-sm mb-1">Width</label> <label class=\"block text-sm mb-1\">Width</label>
<input type="number" name="width" value="512" min="64" max="4096" <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"/> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div> </div>
<div> <div>
<label class="block text-sm mb-1">Height</label> <label class=\"block text-sm mb-1\">Height</label>
<input type="number" name="height" value="512" min="64" max="4096" <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"/> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div> </div>
<div> <div>
<label class="block text-sm mb-1">Elev</label> <label class=\"block text-sm mb-1\">Elev</label>
<input type="number" step="1" name="elev" value="20" <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"/> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div> </div>
<div> <div>
<label class="block text-sm mb-1">Azim</label> <label class=\"block text-sm mb-1\">Azim</label>
<input type="number" step="1" name="azim" value="45" <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"/> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 items-end"> <div class=\"grid grid-cols-2 md:grid-cols-3 gap-3 items-end\">
<div> <div>
<label class="block text-sm mb-1">Background</label> <label class=\"block text-sm mb-1\">Background</label>
<input type="text" name="bg" value="#111827" <input type=\"text\" name=\"bg\" value=\"#111827\"
class="w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2" /> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div> </div>
<div> <div>
<label class="block text-sm mb-1">Edge α</label> <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" <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"/> class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\"/>
</div> </div>
<label class="inline-flex items-center gap-2"> <label class=\"inline-flex items-center gap-2\">
<input type="checkbox" name="transparent" class="size-5 rounded border-slate-600 bg-slate-800" /> <input type=\"checkbox\" name=\"transparent\" class=\"size-5 rounded border-slate-600 bg-slate-800\" />
<span class="text-sm">Transparent</span> <span class=\"text-sm\">Transparent</span>
</label> </label>
</div> </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"> <div class=\"grid grid-cols-2 md:grid-cols-4 gap-3\">
<div>
<label class=\"block text-sm mb-1\">Color</label>
<input type=\"text\" name=\"color\" value=\"#38bdf8\" 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\">Shading</label>
<select name=\"shading\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\">
<option value=\"lambert\" selected>lambert</option>
<option value=\"unlit\">unlit</option>
<option value=\"toon\">toon</option>
<option value=\"depth\">depth</option>
</select>
</div>
<div>
<label class=\"block text-sm mb-1\">Ambient</label>
<input type=\"number\" step=\"0.01\" min=\"0\" max=\"1\" name=\"ambient\" value=\"0.2\" 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\">Diffuse</label>
<input type=\"number\" step=\"0.01\" min=\"0\" max=\"1\" name=\"diffuse\" value=\"0.8\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\" />
</div>
</div>
<div class=\"grid grid-cols-3 md:grid-cols-6 gap-3\">
<div>
<label class=\"block text-sm mb-1\">Light X</label>
<input type=\"number\" step=\"0.01\" name=\"lx\" value=\"0.577\" 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\">Light Y</label>
<input type=\"number\" step=\"0.01\" name=\"ly\" value=\"0.577\" 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\">Light Z</label>
<input type=\"number\" step=\"0.01\" name=\"lz\" value=\"0.577\" 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\">Toon Levels</label>
<input type=\"number\" min=\"1\" max=\"16\" name=\"toon_levels\" value=\"4\" 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\">Projection</label>
<select name=\"proj\" class=\"w-full rounded-lg bg-slate-800/70 border border-slate-700 px-3 py-2\">
<option value=\"persp\" selected>perspective</option>
<option value=\"ortho\">orthographic</option>
</select>
</div>
</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> <span>Generate</span>
</button> </button>
</form> </form>
<div id="result" class="mt-6"></div> <div id=\"result\" class=\"mt-6\"></div>
<div class="mt-10 text-xs text-slate-500"> <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> <p>Tip: Default material color is <span class=\"text-sky-400\">sky-400</span>. Try <code>toon</code> or <code>depth</code> for stylized thumbs.</p>
</div> </div>
</main> </main>
</body> </body>
@ -482,6 +620,15 @@ async def api_thumbnail(
bg: str = Form("#111827"), bg: str = Form("#111827"),
edge_alpha: float = Form(0.12), edge_alpha: float = Form(0.12),
transparent: Optional[str] = Form(None), 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 # Persist incoming file to temp
data = await stl.read() data = await stl.read()
@ -496,15 +643,22 @@ async def api_thumbnail(
bg=bg, bg=bg,
transparent=bool(transparent), transparent=bool(transparent),
edge_alpha=edge_alpha, edge_alpha=edge_alpha,
color=color,
shading=shading,
ambient=ambient,
diffuse=diffuse,
light_dir=(lx, ly, lz),
toon_levels=toon_levels,
proj=proj,
) )
try: try:
png = render_stl_to_png_bytes(tmp_path, params) png = render_stl_to_png_bytes(tmp_path, params)
except Exception as e: except Exception as e:
return HTMLResponse( return HTMLResponse(
f'<div class="p-4 rounded-xl bg-red-900/40 border border-red-800">' f'<div class="p-4 rounded-2xl bg-red-900/40 border border-red-800">'
f"<p class='font-semibold text-red-200'>Render failed</p>" 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"<pre class='text-red-300 text-xs mt-2'>{e}</pre>"
f"</div>", f"</div>",
status_code=400, status_code=400,
) )
@ -520,87 +674,20 @@ async def api_thumbnail(
b64 = base64.b64encode(png).decode("ascii") b64 = base64.b64encode(png).decode("ascii")
html = f""" html = f"""
<div class="grid gap-3"> <div class=\"grid gap-3\">
<div class="rounded-2xl overflow-hidden ring-1 ring-slate-800 bg-slate-900/50"> <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"/> <img src=\"data:image/png;base64,{b64}\" class=\"w-full h-auto block\" alt=\"thumbnail\"/>
</div> </div>
<div class="text-xs text-slate-400"> <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> <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\">|</span>
<span class="ml-2">Size: {len(png)} bytes</span> <span class=\"ml-2\">Size: {len(png)} bytes</span>
</div> </div>
</div> </div>
""" """
return HTMLResponse(html) 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 ---------- # ---------- Entrypoint ----------
def _maybe_run_cli_or_server(): def _maybe_run_cli_or_server():
# If uvicorn is exec-ing this, avoid double-running CLI # If uvicorn is exec-ing this, avoid double-running CLI