Skip to main content
Glama
quellant

OpenSCAD MCP Server

by quellant
scad_renderer.py15 kB
#!/usr/bin/env python3 """ SCAD File Renderer Renders OpenSCAD files to images from multiple perspectives using OpenSCAD CLI. """ import subprocess import os import sys import json import tempfile from pathlib import Path from typing import List, Tuple, Dict, Optional import argparse from datetime import datetime class ScadRenderer: """Renders SCAD files to images from various camera angles.""" def __init__(self, scad_file: str, output_dir: str = None): """ Initialize the renderer. Args: scad_file: Path to the SCAD file to render output_dir: Directory to save rendered images (defaults to ./renders relative to scad file) """ self.scad_file = Path(scad_file) if not self.scad_file.exists(): raise FileNotFoundError(f"SCAD file not found: {scad_file}") # Default output dir is sibling to the scad file's directory if output_dir is None: self.output_dir = self.scad_file.parent / "renders" else: self.output_dir = Path(output_dir) self.output_dir.mkdir(exist_ok=True) # Check if OpenSCAD is installed self.openscad_cmd = self._find_openscad() if not self.openscad_cmd: raise RuntimeError("OpenSCAD not found. Please install OpenSCAD first.") def _find_openscad(self) -> Optional[str]: """Find OpenSCAD executable.""" # Common OpenSCAD executable names candidates = ['openscad', 'OpenSCAD', 'openscad.exe'] for cmd in candidates: try: subprocess.run([cmd, '--version'], capture_output=True, check=False) return cmd except FileNotFoundError: continue # Check common installation paths common_paths = [ '/usr/bin/openscad', '/usr/local/bin/openscad', '/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD', 'C:\\Program Files\\OpenSCAD\\openscad.exe', 'C:\\Program Files (x86)\\OpenSCAD\\openscad.exe' ] for path in common_paths: if os.path.exists(path): return path return None def render_single(self, output_file: str, camera_pos: Tuple[float, float, float] = (100, 100, 100), camera_look_at: Tuple[float, float, float] = (0, 0, 0), camera_up: Tuple[float, float, float] = (0, 0, 1), img_size: Tuple[int, int] = (1024, 768), colorscheme: str = "Tomorrow Night", variables: Dict[str, any] = None, auto_center: bool = True) -> bool: """ Render a single image from specified camera position. Args: output_file: Output image filename (PNG) camera_pos: Camera position (x, y, z) camera_look_at: Point camera looks at (x, y, z) camera_up: Camera up vector (x, y, z) img_size: Image size (width, height) colorscheme: OpenSCAD color scheme variables: SCAD variables to override auto_center: Auto-center the model Returns: True if successful, False otherwise """ output_path = self.output_dir / output_file # Build OpenSCAD command cmd = [ self.openscad_cmd, '-o', str(output_path), '--imgsize', f'{img_size[0]},{img_size[1]}', '--colorscheme', colorscheme, ] # Add camera parameters # Calculate distance for OpenSCAD camera import math dx = camera_pos[0] - camera_look_at[0] dy = camera_pos[1] - camera_look_at[1] dz = camera_pos[2] - camera_look_at[2] distance = math.sqrt(dx*dx + dy*dy + dz*dz) # OpenSCAD uses 7 parameters: eye_x,eye_y,eye_z,center_x,center_y,center_z,distance camera_str = (f'--camera=' f'{camera_pos[0]},{camera_pos[1]},{camera_pos[2]},' f'{camera_look_at[0]},{camera_look_at[1]},{camera_look_at[2]},' f'{distance}') cmd.append(camera_str) if auto_center: cmd.append('--autocenter') cmd.append('--viewall') # Add variable overrides if variables: for key, value in variables.items(): # Format value based on type if isinstance(value, str): val_str = f'"{value}"' elif isinstance(value, bool): val_str = 'true' if value else 'false' else: val_str = str(value) cmd.extend(['-D', f'{key}={val_str}']) # Add the SCAD file cmd.append(str(self.scad_file)) # Run OpenSCAD print(f"Rendering: {output_file}") print(f"Camera: pos={camera_pos}, target={camera_look_at}") try: result = subprocess.run(cmd, capture_output=True, text=True, check=False) if result.returncode != 0: print(f"Error rendering {output_file}:") print(result.stderr) return False print(f"✓ Saved to: {output_path}") return True except Exception as e: print(f"Error: {e}") return False def render_turntable(self, base_name: str = "turntable", num_frames: int = 36, radius: float = 200, height: float = 100, look_at: Tuple[float, float, float] = (0, 0, 30), **kwargs) -> List[str]: """ Render turntable animation frames. Args: base_name: Base name for output files num_frames: Number of frames (360/num_frames degrees per frame) radius: Distance from center height: Camera height look_at: Point to look at **kwargs: Additional arguments for render_single Returns: List of rendered filenames """ import math rendered_files = [] for i in range(num_frames): angle = (360 / num_frames) * i angle_rad = math.radians(angle) # Calculate camera position x = radius * math.cos(angle_rad) y = radius * math.sin(angle_rad) z = height filename = f"{base_name}_{i:03d}.png" success = self.render_single( filename, camera_pos=(x, y, z), camera_look_at=look_at, **kwargs ) if success: rendered_files.append(filename) return rendered_files def render_perspectives(self, base_name: str = "view", distance: float = 200, **kwargs) -> Dict[str, str]: """ Render standard orthographic-like perspectives. Args: base_name: Base name for output files distance: Distance from origin **kwargs: Additional arguments for render_single Returns: Dictionary of perspective names to filenames """ perspectives = { 'front': ((0, -distance, 0), (0, 0, 0), (0, 0, 1)), 'back': ((0, distance, 0), (0, 0, 0), (0, 0, 1)), 'left': ((-distance, 0, 0), (0, 0, 0), (0, 0, 1)), 'right': ((distance, 0, 0), (0, 0, 0), (0, 0, 1)), 'top': ((0, 0, distance), (0, 0, 0), (0, 1, 0)), 'bottom': ((0, 0, -distance), (0, 0, 0), (0, -1, 0)), 'isometric': ((distance, distance, distance), (0, 0, 0), (0, 0, 1)), 'dimetric': ((distance, distance*0.5, distance), (0, 0, 0), (0, 0, 1)), } rendered = {} for name, (pos, look_at, up) in perspectives.items(): filename = f"{base_name}_{name}.png" success = self.render_single( filename, camera_pos=pos, camera_look_at=look_at, camera_up=up, **kwargs ) if success: rendered[name] = filename return rendered def render_custom_views(self, views: List[Dict]) -> List[str]: """ Render custom views from a list of view specifications. Args: views: List of view dictionaries with camera parameters Returns: List of rendered filenames """ rendered = [] for i, view in enumerate(views): filename = view.get('filename', f'custom_view_{i:03d}.png') success = self.render_single( filename, camera_pos=view.get('camera_pos', (100, 100, 100)), camera_look_at=view.get('camera_look_at', (0, 0, 0)), camera_up=view.get('camera_up', (0, 0, 1)), img_size=view.get('img_size', (1024, 768)), colorscheme=view.get('colorscheme', 'Tomorrow Night'), variables=view.get('variables', None), auto_center=view.get('auto_center', True) ) if success: rendered.append(filename) return rendered def create_animation_gif(self, frames: List[str], output_name: str = "animation.gif", delay: int = 10) -> bool: """ Create animated GIF from rendered frames using ImageMagick. Args: frames: List of frame filenames output_name: Output GIF filename delay: Delay between frames (1/100 seconds) Returns: True if successful """ try: # Check if ImageMagick is installed subprocess.run(['convert', '--version'], capture_output=True, check=True) except: print("ImageMagick not found. Install it to create GIFs.") print("Ubuntu/Debian: sudo apt-get install imagemagick") print("macOS: brew install imagemagick") print("Windows: Download from https://imagemagick.org") return False # Build frame paths frame_paths = [str(self.output_dir / frame) for frame in frames] # Create GIF output_path = self.output_dir / output_name cmd = ['convert', '-delay', str(delay), '-loop', '0'] + frame_paths + [str(output_path)] try: subprocess.run(cmd, check=True) print(f"✓ Animation saved to: {output_path}") return True except subprocess.CalledProcessError as e: print(f"Error creating GIF: {e}") return False def main(): """Main entry point with CLI.""" parser = argparse.ArgumentParser(description='Render OpenSCAD files to images') parser.add_argument('scad_file', help='Path to SCAD file') parser.add_argument('-o', '--output-dir', default=None, help='Output directory (default: ./renders relative to scad file)') parser.add_argument('-m', '--mode', choices=['single', 'turntable', 'perspectives', 'all'], default='all', help='Rendering mode') parser.add_argument('-n', '--num-frames', type=int, default=36, help='Number of frames for turntable (default: 36)') parser.add_argument('-s', '--size', default='1024,768', help='Image size as width,height (default: 1024,768)') parser.add_argument('-d', '--distance', type=float, default=200, help='Camera distance (default: 200)') parser.add_argument('--gif', action='store_true', help='Create animated GIF from turntable') parser.add_argument('-v', '--variables', action='append', help='Set SCAD variables (e.g., -v view_mode=exploded)') args = parser.parse_args() # Parse image size try: width, height = map(int, args.size.split(',')) img_size = (width, height) except: print(f"Invalid size format: {args.size}") sys.exit(1) # Parse variables variables = {} if args.variables: for var in args.variables: try: key, value = var.split('=', 1) # Try to parse value as JSON for proper type try: value = json.loads(value) except: pass # Keep as string variables[key] = value except: print(f"Invalid variable format: {var}") sys.exit(1) # Create renderer try: renderer = ScadRenderer(args.scad_file, args.output_dir) except Exception as e: print(f"Error: {e}") sys.exit(1) # Render based on mode if args.mode == 'single' or args.mode == 'all': print("\n=== Rendering single view ===") renderer.render_single( 'single_view.png', camera_pos=(150, 150, 100), camera_look_at=(0, 0, 30), img_size=img_size, variables=variables ) if args.mode == 'perspectives' or args.mode == 'all': print("\n=== Rendering standard perspectives ===") renderer.render_perspectives( distance=args.distance, img_size=img_size, variables=variables ) if args.mode == 'turntable' or args.mode == 'all': print(f"\n=== Rendering {args.num_frames} frame turntable ===") frames = renderer.render_turntable( num_frames=args.num_frames, radius=args.distance, height=args.distance * 0.5, img_size=img_size, variables=variables ) if args.gif and frames: print("\n=== Creating animated GIF ===") renderer.create_animation_gif(frames, delay=5) print("\n✓ Rendering complete!") if __name__ == '__main__': main()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/quellant/openscad-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server