Compare commits
No commits in common. "6dab09586461773be03f8ae1cb8b381e9e1b711d" and "bd6a19812d1d2abcd5f5e21a35048d2ea5db319d" have entirely different histories.
6dab095864
...
bd6a19812d
2 changed files with 149 additions and 236 deletions
2
justfile
2
justfile
|
|
@ -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
383
stlthumb.py
Executable file → Normal 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue