|
#!/usr/bin/env -S uv run |
|
# /// script |
|
# requires-python = ">=3.10" |
|
# dependencies = ["f3d"] |
|
# /// |
|
""" |
|
render_stl.py - Render STL file to multiple PNG projections. |
|
|
|
Generates standard views: front, back, left, right, top, bottom, and isometric views. |
|
Output files are named: {input_basename}-{view_name}.png |
|
|
|
INSTALLATION & USAGE |
|
==================== |
|
|
|
Prerequisites: |
|
- Python 3.10+ |
|
- uv (https://docs.astral.sh/uv/) - recommended |
|
- OR: pip install f3d |
|
|
|
Method 1: Direct execution with uv (recommended, zero setup) |
|
------------------------------------------------------------ |
|
# uv auto-installs dependencies on first run |
|
chmod +x render_stl.py |
|
./render_stl.py model.stl |
|
|
|
# Or explicitly: |
|
uv run render_stl.py model.stl |
|
|
|
Method 2: Using pip |
|
------------------- |
|
pip install f3d |
|
python render_stl.py model.stl |
|
|
|
EXAMPLES |
|
======== |
|
# Generate all 10 views |
|
./render_stl.py model.stl |
|
|
|
# Generate specific views only |
|
./render_stl.py model.stl --views front,top,iso-front-right |
|
|
|
# Output to specific directory |
|
./render_stl.py model.stl -o ./renders/ |
|
|
|
# Force overwrite + verbose |
|
./render_stl.py model.stl -f -v |
|
|
|
# Custom resolution |
|
./render_stl.py model.stl -W 3840 -H 2160 |
|
|
|
AVAILABLE VIEWS |
|
=============== |
|
front, back, left, right, top, bottom, |
|
iso-front-right, iso-back-right, iso-back-left, iso-front-left |
|
|
|
LICENSE |
|
======= |
|
MIT License - https://opensource.org/licenses/MIT |
|
""" |
|
import argparse |
|
import sys |
|
from pathlib import Path |
|
|
|
import f3d |
|
|
|
# Standard projection angles: (azimuth, elevation, name) |
|
PROJECTIONS = [ |
|
(0, 0, "front"), |
|
(180, 0, "back"), |
|
(90, 0, "right"), |
|
(-90, 0, "left"), |
|
(0, 90, "top"), |
|
(0, -90, "bottom"), |
|
(45, 30, "iso-front-right"), |
|
(135, 30, "iso-back-right"), |
|
(-135, 30, "iso-back-left"), |
|
(-45, 30, "iso-front-left"), |
|
] |
|
|
|
|
|
def render_view(engine, camera, output_path: Path, azimuth: float, elevation: float, |
|
verbose: bool = False): |
|
"""Render a single view with given camera angles.""" |
|
camera.reset_to_bounds() |
|
if azimuth != 0: |
|
camera.azimuth(azimuth) |
|
if elevation != 0: |
|
camera.elevation(elevation) |
|
|
|
img = engine.window.render_to_image() |
|
img.save(str(output_path)) |
|
if verbose: |
|
print(f" Saved: {output_path}") |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser( |
|
description="Render STL file to multiple PNG projections.", |
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
|
epilog=""" |
|
Examples: |
|
%(prog)s model.stl |
|
Generate model-front.png, model-iso-front-right.png, etc. |
|
|
|
%(prog)s model.stl -o ./renders/ |
|
Output to specific directory |
|
|
|
%(prog)s model.stl --views front,top,iso-front-right |
|
Generate only specific views |
|
|
|
%(prog)s model.stl -f -v |
|
Force overwrite, verbose output |
|
|
|
Available views: |
|
front, back, left, right, top, bottom, |
|
iso-front-right, iso-back-right, iso-back-left, iso-front-left |
|
""", |
|
) |
|
parser.add_argument("stl_file", type=Path, help="Input STL file") |
|
parser.add_argument( |
|
"-o", "--output-dir", type=Path, default=None, |
|
help="Output directory (default: same as input file)" |
|
) |
|
parser.add_argument( |
|
"-W", "--width", type=int, default=1920, help="Image width (default: 1920)" |
|
) |
|
parser.add_argument( |
|
"-H", "--height", type=int, default=1080, help="Image height (default: 1080)" |
|
) |
|
parser.add_argument( |
|
"--views", type=str, default=None, |
|
help="Comma-separated list of views to render (default: all)" |
|
) |
|
parser.add_argument( |
|
"-f", "--force", action="store_true", |
|
help="Force overwrite existing files" |
|
) |
|
parser.add_argument( |
|
"-v", "--verbose", action="store_true", |
|
help="Verbose output" |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
# Validate input file |
|
if not args.stl_file.exists(): |
|
print(f"Error: Input file not found: {args.stl_file}", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
if not args.stl_file.suffix.lower() == ".stl": |
|
print(f"Warning: Input file may not be an STL: {args.stl_file}", file=sys.stderr) |
|
|
|
# Determine output directory |
|
output_dir = args.output_dir or args.stl_file.parent |
|
output_dir.mkdir(parents=True, exist_ok=True) |
|
|
|
# Base name for output files |
|
basename = args.stl_file.stem |
|
|
|
# Filter projections if --views specified |
|
if args.views: |
|
requested_views = {v.strip().lower() for v in args.views.split(",")} |
|
projections = [(az, el, name) for az, el, name in PROJECTIONS if name in requested_views] |
|
unknown = requested_views - {name for _, _, name in PROJECTIONS} |
|
if unknown: |
|
print(f"Warning: Unknown views ignored: {', '.join(unknown)}", file=sys.stderr) |
|
if not projections: |
|
print("Error: No valid views specified", file=sys.stderr) |
|
sys.exit(1) |
|
else: |
|
projections = PROJECTIONS |
|
|
|
# Check for existing files (overwrite protection) |
|
output_files = [] |
|
for _, _, view_name in projections: |
|
output_path = output_dir / f"{basename}-{view_name}.png" |
|
output_files.append(output_path) |
|
if output_path.exists() and not args.force: |
|
print(f"Error: Output file exists: {output_path}", file=sys.stderr) |
|
print("Use -f/--force to overwrite", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
if args.verbose: |
|
print(f"Input: {args.stl_file}") |
|
print(f"Output directory: {output_dir}") |
|
print(f"Resolution: {args.width}x{args.height}") |
|
print(f"Views to render: {len(projections)}") |
|
|
|
# Create offscreen engine |
|
if args.verbose: |
|
print("Initializing f3d engine...") |
|
engine = f3d.Engine.create(offscreen=True) |
|
engine.window.size = args.width, args.height |
|
|
|
# Load the STL file |
|
if args.verbose: |
|
print(f"Loading: {args.stl_file}") |
|
engine.scene.add(str(args.stl_file)) |
|
|
|
camera = engine.window.camera |
|
|
|
# Render all views |
|
if args.verbose: |
|
print("Rendering views:") |
|
|
|
for (azimuth, elevation, view_name), output_path in zip(projections, output_files): |
|
if args.verbose: |
|
print(f" {view_name} (az={azimuth}, el={elevation})...") |
|
render_view(engine, camera, output_path, azimuth, elevation, verbose=False) |
|
|
|
# Summary |
|
if args.verbose: |
|
print(f"Done. Generated {len(projections)} images.") |
|
else: |
|
for output_path in output_files: |
|
print(output_path) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |