Initial Commit for stlthumb
Make thumbnails for stl files.
This commit is contained in:
commit
bd6a19812d
5 changed files with 1231 additions and 0 deletions
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
613
.null-ls_573538_stlthumb.py
Normal file
613
.null-ls_573538_stlthumb.py
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
#!/usr/bin/env -S uv run --quiet --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# dependencies = [
|
||||||
|
# "typer",
|
||||||
|
# "fastapi",
|
||||||
|
# "uvicorn",
|
||||||
|
# "python-multipart",
|
||||||
|
# "jinja2",
|
||||||
|
# "numpy",
|
||||||
|
# "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
|
||||||
|
- SQLite: records every render (inputs, size, angles, bytes) and serves by id
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Server:
|
||||||
|
stlthumb.py serve --host 0.0.0.0 --port 8037
|
||||||
|
|
||||||
|
TUI:
|
||||||
|
stlthumb.py tui model.stl
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Use non-interactive backend for headless rendering
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, UploadFile # noqa: E402
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse # noqa: E402
|
||||||
|
from stl import mesh as npstl # noqa: E402
|
||||||
|
import typer # noqa: E402
|
||||||
|
|
||||||
|
# ---------- Configuration ----------
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Utilities ----------
|
||||||
|
def ensure_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
stl_name TEXT NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
elev REAL NOT NULL,
|
||||||
|
azim REAL NOT NULL,
|
||||||
|
bg TEXT NOT NULL,
|
||||||
|
transparent INTEGER NOT NULL,
|
||||||
|
edge_alpha REAL NOT NULL,
|
||||||
|
bytes_size INTEGER NOT NULL,
|
||||||
|
png_bytes BLOB NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderParams:
|
||||||
|
width: int = 512
|
||||||
|
height: int = 512
|
||||||
|
elev: float = 20.0
|
||||||
|
azim: float = 45.0
|
||||||
|
bg: str = "#111827" # Tailwind slate-900-ish
|
||||||
|
transparent: bool = False
|
||||||
|
edge_alpha: float = 0.12 # subtle edges help readability
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb01(h: str) -> Tuple[float, float, float]:
|
||||||
|
s = h.strip().lstrip("#")
|
||||||
|
if len(s) == 3:
|
||||||
|
s = "".join([c * 2 for c in s])
|
||||||
|
if len(s) != 6:
|
||||||
|
raise ValueError(f"Invalid hex color: {h!r}")
|
||||||
|
r = int(s[0:2], 16) / 255.0
|
||||||
|
g = int(s[2:4], 16) / 255.0
|
||||||
|
b = int(s[4:6], 16) / 255.0
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
|
def _shade_colors(
|
||||||
|
normals: np.ndarray, base_rgb: Tuple[float, float, 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
|
||||||
|
r, g, b = base_rgb
|
||||||
|
cols = np.stack(
|
||||||
|
[r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1
|
||||||
|
)
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _center_and_scale(vectors: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Center mesh at origin and scale uniformly so the largest extent fits [-0.5, 0.5].
|
||||||
|
vectors: (N,3,3) triangles
|
||||||
|
"""
|
||||||
|
all_pts = vectors.reshape(-1, 3)
|
||||||
|
center = all_pts.mean(axis=0)
|
||||||
|
centered = all_pts - center
|
||||||
|
mins = centered.min(axis=0)
|
||||||
|
maxs = centered.max(axis=0)
|
||||||
|
extents = maxs - mins
|
||||||
|
max_extent = float(extents.max()) or 1.0
|
||||||
|
scale = 1.0 / max_extent
|
||||||
|
scaled = centered * scale
|
||||||
|
return scaled.reshape(-1, 3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def render_stl_to_png_bytes(
|
||||||
|
stl_path: Path,
|
||||||
|
params: RenderParams = RenderParams(),
|
||||||
|
dpi: int = 200,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Load an STL and render a PNG thumbnail into bytes using matplotlib (headless).
|
||||||
|
"""
|
||||||
|
# Load STL (handles ASCII & binary)
|
||||||
|
stl_mesh: npstl.Mesh = npstl.Mesh.from_file(str(stl_path))
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
# Shading colors
|
||||||
|
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)
|
||||||
|
# Background
|
||||||
|
if params.transparent:
|
||||||
|
fig.patch.set_alpha(0.0)
|
||||||
|
fig.patch.set_facecolor("none")
|
||||||
|
bg_color = (0, 0, 0, 0)
|
||||||
|
else:
|
||||||
|
fig.patch.set_facecolor(params.bg)
|
||||||
|
bg_color = _hex_to_rgb01(params.bg) + (1.0,)
|
||||||
|
|
||||||
|
ax = fig.add_subplot(111, projection="3d")
|
||||||
|
ax.set_axis_off()
|
||||||
|
ax.set_facecolor(bg_color)
|
||||||
|
|
||||||
|
# Equal aspect & limits
|
||||||
|
ax.set_box_aspect((1, 1, 1))
|
||||||
|
lim = 0.6 # a bit of padding beyond [-0.5, 0.5]
|
||||||
|
ax.set_xlim3d(-lim, lim)
|
||||||
|
ax.set_ylim3d(-lim, lim)
|
||||||
|
ax.set_zlim3d(-lim, lim)
|
||||||
|
|
||||||
|
# View
|
||||||
|
ax.view_init(elev=params.elev, azim=params.azim)
|
||||||
|
|
||||||
|
# Triangles
|
||||||
|
tri_collection = Poly3DCollection(vectors, linewidths=0.2)
|
||||||
|
tri_collection.set_facecolor(face_colors)
|
||||||
|
tri_collection.set_edgecolor((0, 0, 0, float(params.edge_alpha)))
|
||||||
|
ax.add_collection3d(tri_collection)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
format="png",
|
||||||
|
dpi=dpi,
|
||||||
|
bbox_inches="tight",
|
||||||
|
pad_inches=0,
|
||||||
|
transparent=params.transparent,
|
||||||
|
facecolor=fig.get_facecolor(),
|
||||||
|
)
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_png(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
stl_name: str,
|
||||||
|
png_bytes: bytes,
|
||||||
|
params: RenderParams,
|
||||||
|
) -> int:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO thumbnails (created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size, png_bytes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||||
|
stl_name,
|
||||||
|
params.width,
|
||||||
|
params.height,
|
||||||
|
params.elev,
|
||||||
|
params.azim,
|
||||||
|
params.bg,
|
||||||
|
int(params.transparent),
|
||||||
|
float(params.edge_alpha),
|
||||||
|
len(png_bytes),
|
||||||
|
sqlite3.Binary(png_bytes),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_info(row: tuple) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"created_at": row[1],
|
||||||
|
"stl_name": row[2],
|
||||||
|
"width": row[3],
|
||||||
|
"height": row[4],
|
||||||
|
"elev": row[5],
|
||||||
|
"azim": row[6],
|
||||||
|
"bg": row[7],
|
||||||
|
"transparent": bool(row[8]),
|
||||||
|
"edge_alpha": row[9],
|
||||||
|
"bytes_size": row[10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- CLI ----------
|
||||||
|
@cli.command("render")
|
||||||
|
def cli_render(
|
||||||
|
stl: Path = typer.Argument(..., exists=True, readable=True, help="Input .stl file"),
|
||||||
|
output: Optional[Path] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
help="Output PNG path (default: <stl>.png next to input)",
|
||||||
|
),
|
||||||
|
size: int = typer.Option(
|
||||||
|
512, "--size", "-s", min=64, max=4096, help="Square size in px"
|
||||||
|
),
|
||||||
|
width: Optional[int] = typer.Option(
|
||||||
|
None, help="PNG width in px (overrides --size)"
|
||||||
|
),
|
||||||
|
height: Optional[int] = typer.Option(
|
||||||
|
None, help="PNG height in px (overrides --size)"
|
||||||
|
),
|
||||||
|
elev: float = typer.Option(20.0, help="Elevation angle"),
|
||||||
|
azim: float = typer.Option(45.0, help="Azimuth angle"),
|
||||||
|
bg: str = typer.Option("#111827", help="Background color (hex)"),
|
||||||
|
transparent: bool = typer.Option(
|
||||||
|
False, "--transparent/--opaque", help="Transparent background"
|
||||||
|
),
|
||||||
|
edge_alpha: float = typer.Option(
|
||||||
|
0.12, min=0.0, max=1.0, help="Edge alpha (0 disables edges)"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Render STL to a PNG thumbnail."""
|
||||||
|
w = width or size
|
||||||
|
h = height or size
|
||||||
|
params = RenderParams(
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
elev=elev,
|
||||||
|
azim=azim,
|
||||||
|
bg=bg,
|
||||||
|
transparent=transparent,
|
||||||
|
edge_alpha=edge_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
typer.echo(f"[{APP_NAME}] Rendering: {stl} → {output or (stl.with_suffix('.png'))}")
|
||||||
|
png = render_stl_to_png_bytes(stl, params)
|
||||||
|
out = output or stl.with_suffix(".png")
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_bytes(png)
|
||||||
|
typer.echo(f"Saved: {out} ({len(png)} bytes)")
|
||||||
|
|
||||||
|
conn = ensure_db()
|
||||||
|
rec_id = _record_png(conn, stl.name, png, params)
|
||||||
|
typer.echo(f"Recorded in DB: {DB_PATH} (id={rec_id})")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("history")
|
||||||
|
def cli_history(
|
||||||
|
limit: int = typer.Option(25, min=1, max=500),
|
||||||
|
):
|
||||||
|
"""Show recent renders stored in the SQLite database."""
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
typer.echo("No history yet.")
|
||||||
|
raise typer.Exit(code=0)
|
||||||
|
conn = ensure_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size FROM thumbnails ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
typer.echo("No history.")
|
||||||
|
return
|
||||||
|
for r in rows:
|
||||||
|
info = _row_to_info(r)
|
||||||
|
tflag = "T" if info["transparent"] else "B"
|
||||||
|
typer.echo(
|
||||||
|
f"#{info['id']:>4} {info['created_at']} {info['stl_name']:<25} "
|
||||||
|
f"{info['width']}x{info['height']} elev={info['elev']:>4.0f} azim={info['azim']:>4.0f} "
|
||||||
|
f"{tflag}G={info['bg']} α={info['edge_alpha']:.2f} {info['bytes_size']}B"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("serve")
|
||||||
|
def cli_serve(
|
||||||
|
host: str = typer.Option("127.0.0.1", help="Bind host"),
|
||||||
|
port: int = typer.Option(8037, help="Port"),
|
||||||
|
reload: bool = typer.Option(False, help="Auto-reload on code changes"),
|
||||||
|
):
|
||||||
|
"""Run the FastAPI server with the HTMX+Tailwind UI."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
ensure_db()
|
||||||
|
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Web (FastAPI + HTMX + Tailwind) ----------
|
||||||
|
HOME_HTML = """<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>STL → PNG Thumbnail</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100">
|
||||||
|
<main class="mx-auto max-w-3xl p-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">STL file</label>
|
||||||
|
<input type="file" name="stl" accept=".stl" required
|
||||||
|
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
|
||||||
|
w-full text-sm text-slate-200"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Width</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Height</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Elev</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Azim</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Background</label>
|
||||||
|
<input type="text" name="bg" value="#111827"
|
||||||
|
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">Edge α</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="transparent" class="size-5 rounded border-slate-600 bg-slate-800" />
|
||||||
|
<span class="text-sm">Transparent</span>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="result" class="mt-6"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def home() -> str:
|
||||||
|
return HOME_HTML
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", response_class=PlainTextResponse)
|
||||||
|
def healthz() -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/thumb/{thumb_id}.png")
|
||||||
|
def get_thumbnail(thumb_id: int):
|
||||||
|
conn = ensure_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT png_bytes FROM thumbnails WHERE id= ?", (thumb_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return PlainTextResponse("Not Found", status_code=404)
|
||||||
|
data = row[0]
|
||||||
|
return StreamingResponse(io.BytesIO(data), media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/thumbnail", response_class=HTMLResponse)
|
||||||
|
async def api_thumbnail(
|
||||||
|
stl: UploadFile = File(...),
|
||||||
|
width: int = Form(512),
|
||||||
|
height: int = Form(512),
|
||||||
|
elev: float = Form(20.0),
|
||||||
|
azim: float = Form(45.0),
|
||||||
|
bg: str = Form("#111827"),
|
||||||
|
edge_alpha: float = Form(0.12),
|
||||||
|
transparent: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
# Persist incoming file to temp
|
||||||
|
data = await stl.read()
|
||||||
|
tmp_path = Path(f"/tmp/{int(time.time() * 1000)}_{stl.filename}")
|
||||||
|
tmp_path.write_bytes(data)
|
||||||
|
|
||||||
|
params = RenderParams(
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
elev=elev,
|
||||||
|
azim=azim,
|
||||||
|
bg=bg,
|
||||||
|
transparent=bool(transparent),
|
||||||
|
edge_alpha=edge_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
png = render_stl_to_png_bytes(tmp_path, params)
|
||||||
|
except Exception as e:
|
||||||
|
return HTMLResponse(
|
||||||
|
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"<pre class='text-red-300 text-xs mt-2'>{typer.style(str(e), fg=typer.colors.RED)}</pre>"
|
||||||
|
f"</div>",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# cleanup temp
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn = ensure_db()
|
||||||
|
rec_id = _record_png(conn, stl.filename, png, params)
|
||||||
|
|
||||||
|
b64 = base64.b64encode(png).decode("ascii")
|
||||||
|
html = f"""
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<span class="ml-2">|</span>
|
||||||
|
<span class="ml-2">Size: {len(png)} bytes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
if os.environ.get("RUN_FROM_UVICORN", "") == "1":
|
||||||
|
return
|
||||||
|
cli()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_maybe_run_cli_or_server()
|
||||||
3
README.md
Normal file
3
README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# stlthumb
|
||||||
|
|
||||||
|
Make thumbnails for stl files.
|
||||||
2
justfile
Normal file
2
justfile
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
serve:
|
||||||
|
stlthumb.py serve --host 0.0.0.0 --port 8037
|
||||||
613
stlthumb.py
Normal file
613
stlthumb.py
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
#!/usr/bin/env -S uv run --quiet --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# dependencies = [
|
||||||
|
# "typer",
|
||||||
|
# "fastapi",
|
||||||
|
# "uvicorn",
|
||||||
|
# "python-multipart",
|
||||||
|
# "jinja2",
|
||||||
|
# "numpy",
|
||||||
|
# "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
|
||||||
|
- SQLite: records every render (inputs, size, angles, bytes) and serves by id
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Server:
|
||||||
|
stlthumb.py serve --host 0.0.0.0 --port 8037
|
||||||
|
|
||||||
|
TUI:
|
||||||
|
stlthumb.py tui model.stl
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# Use non-interactive backend for headless rendering
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt # noqa: E402
|
||||||
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, UploadFile # noqa: E402
|
||||||
|
from fastapi.responses import HTMLResponse, StreamingResponse, PlainTextResponse # noqa: E402
|
||||||
|
from stl import mesh as npstl # noqa: E402
|
||||||
|
import typer # noqa: E402
|
||||||
|
|
||||||
|
# ---------- Configuration ----------
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Utilities ----------
|
||||||
|
def ensure_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
stl_name TEXT NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
elev REAL NOT NULL,
|
||||||
|
azim REAL NOT NULL,
|
||||||
|
bg TEXT NOT NULL,
|
||||||
|
transparent INTEGER NOT NULL,
|
||||||
|
edge_alpha REAL NOT NULL,
|
||||||
|
bytes_size INTEGER NOT NULL,
|
||||||
|
png_bytes BLOB NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RenderParams:
|
||||||
|
width: int = 512
|
||||||
|
height: int = 512
|
||||||
|
elev: float = 20.0
|
||||||
|
azim: float = 45.0
|
||||||
|
bg: str = "#111827" # Tailwind slate-900-ish
|
||||||
|
transparent: bool = False
|
||||||
|
edge_alpha: float = 0.12 # subtle edges help readability
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb01(h: str) -> Tuple[float, float, float]:
|
||||||
|
s = h.strip().lstrip("#")
|
||||||
|
if len(s) == 3:
|
||||||
|
s = "".join([c * 2 for c in s])
|
||||||
|
if len(s) != 6:
|
||||||
|
raise ValueError(f"Invalid hex color: {h!r}")
|
||||||
|
r = int(s[0:2], 16) / 255.0
|
||||||
|
g = int(s[2:4], 16) / 255.0
|
||||||
|
b = int(s[4:6], 16) / 255.0
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
|
def _shade_colors(
|
||||||
|
normals: np.ndarray, base_rgb: Tuple[float, float, 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
|
||||||
|
r, g, b = base_rgb
|
||||||
|
cols = np.stack(
|
||||||
|
[r * intensity, g * intensity, b * intensity, np.ones_like(intensity)], axis=1
|
||||||
|
)
|
||||||
|
return cols
|
||||||
|
|
||||||
|
|
||||||
|
def _center_and_scale(vectors: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Center mesh at origin and scale uniformly so the largest extent fits [-0.5, 0.5].
|
||||||
|
vectors: (N,3,3) triangles
|
||||||
|
"""
|
||||||
|
all_pts = vectors.reshape(-1, 3)
|
||||||
|
center = all_pts.mean(axis=0)
|
||||||
|
centered = all_pts - center
|
||||||
|
mins = centered.min(axis=0)
|
||||||
|
maxs = centered.max(axis=0)
|
||||||
|
extents = maxs - mins
|
||||||
|
max_extent = float(extents.max()) or 1.0
|
||||||
|
scale = 1.0 / max_extent
|
||||||
|
scaled = centered * scale
|
||||||
|
return scaled.reshape(-1, 3, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def render_stl_to_png_bytes(
|
||||||
|
stl_path: Path,
|
||||||
|
params: RenderParams = RenderParams(),
|
||||||
|
dpi: int = 200,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Load an STL and render a PNG thumbnail into bytes using matplotlib (headless).
|
||||||
|
"""
|
||||||
|
# Load STL (handles ASCII & binary)
|
||||||
|
stl_mesh: npstl.Mesh = npstl.Mesh.from_file(str(stl_path))
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
# Shading colors
|
||||||
|
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)
|
||||||
|
# Background
|
||||||
|
if params.transparent:
|
||||||
|
fig.patch.set_alpha(0.0)
|
||||||
|
fig.patch.set_facecolor("none")
|
||||||
|
bg_color = (0, 0, 0, 0)
|
||||||
|
else:
|
||||||
|
fig.patch.set_facecolor(params.bg)
|
||||||
|
bg_color = _hex_to_rgb01(params.bg) + (1.0,)
|
||||||
|
|
||||||
|
ax = fig.add_subplot(111, projection="3d")
|
||||||
|
ax.set_axis_off()
|
||||||
|
ax.set_facecolor(bg_color)
|
||||||
|
|
||||||
|
# Equal aspect & limits
|
||||||
|
ax.set_box_aspect((1, 1, 1))
|
||||||
|
lim = 0.6 # a bit of padding beyond [-0.5, 0.5]
|
||||||
|
ax.set_xlim3d(-lim, lim)
|
||||||
|
ax.set_ylim3d(-lim, lim)
|
||||||
|
ax.set_zlim3d(-lim, lim)
|
||||||
|
|
||||||
|
# View
|
||||||
|
ax.view_init(elev=params.elev, azim=params.azim)
|
||||||
|
|
||||||
|
# Triangles
|
||||||
|
tri_collection = Poly3DCollection(vectors, linewidths=0.2)
|
||||||
|
tri_collection.set_facecolor(face_colors)
|
||||||
|
tri_collection.set_edgecolor((0, 0, 0, float(params.edge_alpha)))
|
||||||
|
ax.add_collection3d(tri_collection)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
format="png",
|
||||||
|
dpi=dpi,
|
||||||
|
bbox_inches="tight",
|
||||||
|
pad_inches=0,
|
||||||
|
transparent=params.transparent,
|
||||||
|
facecolor=fig.get_facecolor(),
|
||||||
|
)
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
return buf.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_png(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
stl_name: str,
|
||||||
|
png_bytes: bytes,
|
||||||
|
params: RenderParams,
|
||||||
|
) -> int:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO thumbnails (created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size, png_bytes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||||
|
stl_name,
|
||||||
|
params.width,
|
||||||
|
params.height,
|
||||||
|
params.elev,
|
||||||
|
params.azim,
|
||||||
|
params.bg,
|
||||||
|
int(params.transparent),
|
||||||
|
float(params.edge_alpha),
|
||||||
|
len(png_bytes),
|
||||||
|
sqlite3.Binary(png_bytes),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_info(row: tuple) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"created_at": row[1],
|
||||||
|
"stl_name": row[2],
|
||||||
|
"width": row[3],
|
||||||
|
"height": row[4],
|
||||||
|
"elev": row[5],
|
||||||
|
"azim": row[6],
|
||||||
|
"bg": row[7],
|
||||||
|
"transparent": bool(row[8]),
|
||||||
|
"edge_alpha": row[9],
|
||||||
|
"bytes_size": row[10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- CLI ----------
|
||||||
|
@cli.command("render")
|
||||||
|
def cli_render(
|
||||||
|
stl: Path = typer.Argument(..., exists=True, readable=True, help="Input .stl file"),
|
||||||
|
output: Optional[Path] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--output",
|
||||||
|
"-o",
|
||||||
|
help="Output PNG path (default: <stl>.png next to input)",
|
||||||
|
),
|
||||||
|
size: int = typer.Option(
|
||||||
|
512, "--size", "-s", min=64, max=4096, help="Square size in px"
|
||||||
|
),
|
||||||
|
width: Optional[int] = typer.Option(
|
||||||
|
None, help="PNG width in px (overrides --size)"
|
||||||
|
),
|
||||||
|
height: Optional[int] = typer.Option(
|
||||||
|
None, help="PNG height in px (overrides --size)"
|
||||||
|
),
|
||||||
|
elev: float = typer.Option(20.0, help="Elevation angle"),
|
||||||
|
azim: float = typer.Option(45.0, help="Azimuth angle"),
|
||||||
|
bg: str = typer.Option("#111827", help="Background color (hex)"),
|
||||||
|
transparent: bool = typer.Option(
|
||||||
|
False, "--transparent/--opaque", help="Transparent background"
|
||||||
|
),
|
||||||
|
edge_alpha: float = typer.Option(
|
||||||
|
0.12, min=0.0, max=1.0, help="Edge alpha (0 disables edges)"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"""Render STL to a PNG thumbnail."""
|
||||||
|
w = width or size
|
||||||
|
h = height or size
|
||||||
|
params = RenderParams(
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
elev=elev,
|
||||||
|
azim=azim,
|
||||||
|
bg=bg,
|
||||||
|
transparent=transparent,
|
||||||
|
edge_alpha=edge_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
typer.echo(f"[{APP_NAME}] Rendering: {stl} → {output or (stl.with_suffix('.png'))}")
|
||||||
|
png = render_stl_to_png_bytes(stl, params)
|
||||||
|
out = output or stl.with_suffix(".png")
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_bytes(png)
|
||||||
|
typer.echo(f"Saved: {out} ({len(png)} bytes)")
|
||||||
|
|
||||||
|
conn = ensure_db()
|
||||||
|
rec_id = _record_png(conn, stl.name, png, params)
|
||||||
|
typer.echo(f"Recorded in DB: {DB_PATH} (id={rec_id})")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("history")
|
||||||
|
def cli_history(
|
||||||
|
limit: int = typer.Option(25, min=1, max=500),
|
||||||
|
):
|
||||||
|
"""Show recent renders stored in the SQLite database."""
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
typer.echo("No history yet.")
|
||||||
|
raise typer.Exit(code=0)
|
||||||
|
conn = ensure_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, created_at, stl_name, width, height, elev, azim, bg, transparent, edge_alpha, bytes_size FROM thumbnails ORDER BY id DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
typer.echo("No history.")
|
||||||
|
return
|
||||||
|
for r in rows:
|
||||||
|
info = _row_to_info(r)
|
||||||
|
tflag = "T" if info["transparent"] else "B"
|
||||||
|
typer.echo(
|
||||||
|
f"#{info['id']:>4} {info['created_at']} {info['stl_name']:<25} "
|
||||||
|
f"{info['width']}x{info['height']} elev={info['elev']:>4.0f} azim={info['azim']:>4.0f} "
|
||||||
|
f"{tflag}G={info['bg']} α={info['edge_alpha']:.2f} {info['bytes_size']}B"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("serve")
|
||||||
|
def cli_serve(
|
||||||
|
host: str = typer.Option("127.0.0.1", help="Bind host"),
|
||||||
|
port: int = typer.Option(8037, help="Port"),
|
||||||
|
reload: bool = typer.Option(False, help="Auto-reload on code changes"),
|
||||||
|
):
|
||||||
|
"""Run the FastAPI server with the HTMX+Tailwind UI."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
ensure_db()
|
||||||
|
uvicorn.run(app, host=host, port=port, reload=reload)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Web (FastAPI + HTMX + Tailwind) ----------
|
||||||
|
HOME_HTML = """<!doctype html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>STL → PNG Thumbnail</title>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-950 text-slate-100">
|
||||||
|
<main class="mx-auto max-w-3xl p-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">STL file</label>
|
||||||
|
<input type="file" name="stl" accept=".stl" required
|
||||||
|
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
|
||||||
|
w-full text-sm text-slate-200"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Width</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Height</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Elev</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Azim</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1">Background</label>
|
||||||
|
<input type="text" name="bg" value="#111827"
|
||||||
|
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">Edge α</label>
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="transparent" class="size-5 rounded border-slate-600 bg-slate-800" />
|
||||||
|
<span class="text-sm">Transparent</span>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="result" class="mt-6"></div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def home() -> str:
|
||||||
|
return HOME_HTML
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz", response_class=PlainTextResponse)
|
||||||
|
def healthz() -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/thumb/{thumb_id}.png")
|
||||||
|
def get_thumbnail(thumb_id: int):
|
||||||
|
conn = ensure_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT png_bytes FROM thumbnails WHERE id= ?", (thumb_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return PlainTextResponse("Not Found", status_code=404)
|
||||||
|
data = row[0]
|
||||||
|
return StreamingResponse(io.BytesIO(data), media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/thumbnail", response_class=HTMLResponse)
|
||||||
|
async def api_thumbnail(
|
||||||
|
stl: UploadFile = File(...),
|
||||||
|
width: int = Form(512),
|
||||||
|
height: int = Form(512),
|
||||||
|
elev: float = Form(20.0),
|
||||||
|
azim: float = Form(45.0),
|
||||||
|
bg: str = Form("#111827"),
|
||||||
|
edge_alpha: float = Form(0.12),
|
||||||
|
transparent: Optional[str] = Form(None),
|
||||||
|
):
|
||||||
|
# Persist incoming file to temp
|
||||||
|
data = await stl.read()
|
||||||
|
tmp_path = Path(f"/tmp/{int(time.time() * 1000)}_{stl.filename}")
|
||||||
|
tmp_path.write_bytes(data)
|
||||||
|
|
||||||
|
params = RenderParams(
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
elev=elev,
|
||||||
|
azim=azim,
|
||||||
|
bg=bg,
|
||||||
|
transparent=bool(transparent),
|
||||||
|
edge_alpha=edge_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
png = render_stl_to_png_bytes(tmp_path, params)
|
||||||
|
except Exception as e:
|
||||||
|
return HTMLResponse(
|
||||||
|
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"<pre class='text-red-300 text-xs mt-2'>{typer.style(str(e), fg=typer.colors.RED)}</pre>"
|
||||||
|
f"</div>",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# cleanup temp
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conn = ensure_db()
|
||||||
|
rec_id = _record_png(conn, stl.filename, png, params)
|
||||||
|
|
||||||
|
b64 = base64.b64encode(png).decode("ascii")
|
||||||
|
html = f"""
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<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"/>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<span class="ml-2">|</span>
|
||||||
|
<span class="ml-2">Size: {len(png)} bytes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
if os.environ.get("RUN_FROM_UVICORN", "") == "1":
|
||||||
|
return
|
||||||
|
cli()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
_maybe_run_cli_or_server()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue