700 lines
24 KiB
Python
Executable file
700 lines
24 KiB
Python
Executable file
#!/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",
|
||
# ]
|
||
# ///
|
||
"""
|
||
stlthumb.py — Generate PNG thumbnails for STL files.
|
||
|
||
Features
|
||
- CLI (Typer): render thumbnails with size, angles, bg/transparent
|
||
- Web (FastAPI): HTMX+Tailwind UI to upload STL and get a thumbnail
|
||
- SQLite: records every render (inputs, size, angles, bytes) and serves by id
|
||
- Shading modes: lambert (default), unlit/flat, toon (cel), depth
|
||
|
||
Usage
|
||
CLI:
|
||
stlthumb.py render model.stl --output thumb.png --size 512 --elev 20 --azim 45
|
||
stlthumb.py render model.stl --transparent --color "#f59e0b" --shading toon --toon-levels 4
|
||
stlthumb.py render model.stl --bg '#0f172a' --edge-alpha 0.15 --proj ortho
|
||
stlthumb.py render model.stl --light 1 1 1 --ambient 0.2 --diffuse 0.8
|
||
|
||
Server:
|
||
stlthumb.py serve --host 0.0.0.0 --port 8037
|
||
|
||
Notes
|
||
* "Lambert" is a diffuse-only shading model: brightness ∝ cos(θ) between face normal and light direction.
|
||
* For toon, intensity is quantized into N bands.
|
||
* For depth, brightness comes from camera-space Z (near = bright, far = dark).
|
||
"""
|
||
|
||
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, Literal
|
||
|
||
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.1")
|
||
|
||
|
||
# ---------- 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
|
||
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]:
|
||
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 _normalize(v: np.ndarray) -> np.ndarray:
|
||
n = np.linalg.norm(v, axis=-1, keepdims=True)
|
||
return np.divide(v, np.where(n == 0, 1, n))
|
||
|
||
|
||
def _rotate_vectors(
|
||
vectors: np.ndarray, elev_deg: float, azim_deg: float
|
||
) -> np.ndarray:
|
||
"""Rotate vectors by azimuth (Z) then elevation (X) to approximate camera view."""
|
||
az = np.deg2rad(azim_deg)
|
||
el = np.deg2rad(elev_deg)
|
||
Rz = np.array(
|
||
[
|
||
[np.cos(az), -np.sin(az), 0],
|
||
[np.sin(az), np.cos(az), 0],
|
||
[0, 0, 1],
|
||
]
|
||
)
|
||
Rx = np.array(
|
||
[
|
||
[1, 0, 0],
|
||
[0, np.cos(el), -np.sin(el)],
|
||
[0, np.sin(el), np.cos(el)],
|
||
]
|
||
)
|
||
R = Rx @ Rz
|
||
v = vectors.reshape(-1, 3)
|
||
v_rot = (R @ v.T).T
|
||
return v_rot.reshape(-1, 3, 3)
|
||
|
||
|
||
def _shade_colors(
|
||
normals: np.ndarray,
|
||
base_rgb: Tuple[float, float, float],
|
||
params: RenderParams,
|
||
vectors: np.ndarray,
|
||
) -> 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
|
||
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()
|
||
normals = _normalize(normals)
|
||
|
||
# Shading colors
|
||
base_rgb = _hex_to_rgb01(params.color)
|
||
face_colors = _shade_colors(normals, base_rgb, params, vectors)
|
||
|
||
# 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)
|
||
try:
|
||
ax.set_proj_type("ortho" if params.proj == "ortho" else "persp")
|
||
except Exception:
|
||
pass # older Matplotlib
|
||
|
||
# Equal aspect & limits
|
||
ax.set_box_aspect((1, 1, 1))
|
||
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)
|
||
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)"
|
||
),
|
||
color: str = typer.Option("#38bdf8", help="Base material color (hex)"),
|
||
shading: str = typer.Option(
|
||
"lambert", case_sensitive=False, help="Shading: lambert|unlit|toon|depth"
|
||
),
|
||
ambient: float = typer.Option(
|
||
0.20, min=0.0, max=1.0, help="Ambient term for lambert/toon"
|
||
),
|
||
diffuse: float = typer.Option(
|
||
0.80, min=0.0, max=1.0, help="Diffuse term for lambert/toon"
|
||
),
|
||
light: Tuple[float, float, float] = typer.Option(
|
||
(0.577, 0.577, 0.577), help="Light direction x y z"
|
||
),
|
||
toon_levels: int = typer.Option(4, min=1, max=16, help="Toon levels (bands)"),
|
||
proj: str = typer.Option("persp", help="Projection: persp|ortho"),
|
||
):
|
||
"""Render STL to a PNG thumbnail."""
|
||
w = width or size
|
||
h = height or size
|
||
params = RenderParams(
|
||
width=w,
|
||
height=h,
|
||
elev=elev,
|
||
azim=azim,
|
||
bg=bg,
|
||
transparent=transparent,
|
||
edge_alpha=edge_alpha,
|
||
color=color,
|
||
shading=shading.lower(),
|
||
ambient=ambient,
|
||
diffuse=diffuse,
|
||
light_dir=light,
|
||
toon_levels=toon_levels,
|
||
proj=proj.lower(),
|
||
)
|
||
|
||
typer.echo(f"[{APP_NAME}] Rendering: {stl} → {output or (stl.with_suffix('.png'))}")
|
||
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>
|
||
|
||
<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>
|
||
</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>. Try <code>toon</code> or <code>depth</code> for stylized thumbs.</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),
|
||
color: str = Form("#38bdf8"),
|
||
shading: str = Form("lambert"),
|
||
ambient: float = Form(0.20),
|
||
diffuse: float = Form(0.80),
|
||
lx: float = Form(0.577),
|
||
ly: float = Form(0.577),
|
||
lz: float = Form(0.577),
|
||
toon_levels: int = Form(4),
|
||
proj: str = Form("persp"),
|
||
):
|
||
# Persist incoming file to temp
|
||
data = await stl.read()
|
||
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,
|
||
color=color,
|
||
shading=shading,
|
||
ambient=ambient,
|
||
diffuse=diffuse,
|
||
light_dir=(lx, ly, lz),
|
||
toon_levels=toon_levels,
|
||
proj=proj,
|
||
)
|
||
|
||
try:
|
||
png = render_stl_to_png_bytes(tmp_path, params)
|
||
except Exception as e:
|
||
return HTMLResponse(
|
||
f'<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"<pre class='text-red-300 text-xs mt-2'>{e}</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)
|
||
|
||
|
||
# ---------- 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()
|