Compare commits

..

No commits in common. "6dab09586461773be03f8ae1cb8b381e9e1b711d" and "bd6a19812d1d2abcd5f5e21a35048d2ea5db319d" have entirely different histories.

2 changed files with 149 additions and 236 deletions

View file

@ -1,2 +1,2 @@
serve: serve:
./stlthumb.py serve --host 0.0.0.0 --port 8037 stlthumb.py serve --host 0.0.0.0 --port 8037

383
stlthumb.py Executable file → Normal file
View file

@ -11,31 +11,29 @@
# "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 size, angles, bg/transparent - CLI (Typer): render thumbnails with custom size, angles, bg/transparent
- Web (FastAPI): HTMX+Tailwind UI to upload STL and get a thumbnail - 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 - 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 --color "#f59e0b" --shading toon --toon-levels 4 stlthumb.py render model.stl --transparent
stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 --proj ortho stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15
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
Notes TUI:
* "Lambert" is a diffuse-only shading model: brightness cos(θ) between face normal and light direction. stlthumb.py tui model.stl
* 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
@ -48,7 +46,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, Literal from typing import Optional, Tuple
import numpy as np import numpy as np
@ -69,7 +67,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.1") app = FastAPI(title=APP_NAME, version="1.0")
# ---------- Utilities ---------- # ---------- Utilities ----------
@ -106,13 +104,6 @@ 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]:
@ -127,78 +118,20 @@ def _hex_to_rgb01(h: str) -> Tuple[float, float, float]:
return r, g, b 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( def _shade_colors(
normals: np.ndarray, normals: np.ndarray, base_rgb: Tuple[float, float, float]
base_rgb: Tuple[float, float, float],
params: RenderParams,
vectors: np.ndarray,
) -> np.ndarray: ) -> np.ndarray:
mode = params.shading """
if mode == "unlit": Simple Lambertian shading per face using a fixed light direction.
N = normals.shape[0] normals: (N,3) unit normals per triangle
return np.tile(np.array([*base_rgb, 1.0])[None, :], (N, 1)) base_rgb: tuple in 0..1
returns (N,4) RGBA colors
if mode == "toon": """
L = _normalize(np.array(params.light_dir, dtype=float)) light_dir = np.array([0.577, 0.577, 0.577]) # approx from (1,1,1) normalized
dots = (normals @ L).clip(0, 1) light_dir /= np.linalg.norm(light_dir)
intensity = params.ambient + params.diffuse * dots dots = (normals @ light_dir).clip(0, 1) # cosine term
# quantize # Add ambient term
levels = max(1, int(params.toon_levels)) intensity = 0.20 + 0.80 * dots
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
@ -237,11 +170,14 @@ 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()
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 # Shading colors
base_rgb = _hex_to_rgb01(params.color) base_rgb = _hex_to_rgb01("#38bdf8") # Tailwind sky-400: nice friendly default
face_colors = _shade_colors(normals, base_rgb, params, vectors) face_colors = _shade_colors(normals, base_rgb)
# 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)
@ -257,10 +193,6 @@ 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))
@ -281,6 +213,8 @@ 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,
@ -370,21 +304,6 @@ 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
@ -397,13 +316,6 @@ 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'))}")
@ -459,128 +371,78 @@ 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>
<div class=\"grid grid-cols-2 md:grid-cols-4 gap-3\"> <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>
<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>. Try <code>toon</code> or <code>depth</code> for stylized thumbs.</p> <p>Tip: Default material color is <span class="text-sky-400">sky-400</span> with per-face lambert shading.</p>
</div> </div>
</main> </main>
</body> </body>
@ -620,15 +482,6 @@ 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()
@ -643,22 +496,15 @@ 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-2xl bg-red-900/40 border border-red-800">' 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"<p class='font-semibold text-red-200'>Render failed</p>"
f"<pre class='text-red-300 text-xs mt-2'>{e}</pre>" f"<pre class='text-red-300 text-xs mt-2'>{typer.style(str(e), fg=typer.colors.RED)}</pre>"
f"</div>", f"</div>",
status_code=400, status_code=400,
) )
@ -674,20 +520,87 @@ 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