""" Blender renderer service — FastAPI microservice. Accepts a STEP file path (on shared uploads volume) and renders a thumbnail PNG using the pipeline: STEP → STL (via cadquery) → PNG (via Blender headless). """ import asyncio import json as _json_mod import logging import os import signal import shutil import subprocess import tempfile import threading import time from pathlib import Path from fastapi import FastAPI, HTTPException from pydantic import BaseModel logger = logging.getLogger(__name__) app = FastAPI(title="Blender Renderer", version="1.0.0") # Active render subprocesses keyed by job_id for cancellation support _active_procs: dict[str, subprocess.Popen] = {} _procs_lock = threading.Lock() # Limit concurrent Blender renders to avoid memory exhaustion from parallel threads # (each thread loads cadquery/OCC, ~300-500 MB each). # Resizable at runtime via POST /configure without restart. _max_concurrent: int = 3 _render_semaphore = threading.Semaphore(_max_concurrent) _config_lock = threading.Lock() def _set_max_concurrent(n: int) -> None: """Replace the global semaphore with a new one sized to n. In-flight renders hold a reference to the old semaphore and will release it normally; new renders pick up the new one. """ global _render_semaphore, _max_concurrent with _config_lock: _max_concurrent = n _render_semaphore = threading.Semaphore(n) class RenderRequest(BaseModel): step_path: str output_path: str width: int = 512 height: int = 512 engine: str = "cycles" # "cycles" or "eevee" samples: int = 256 stl_quality: str = "low" # "low" or "high" smooth_angle: int = 30 # degrees; 0 = shade_flat, >0 = shade_smooth_by_angle cycles_device: str = "auto" # "auto", "gpu", or "cpu" transparent_bg: bool = False # render with transparent background (PNG only) part_colors: dict | None = None # optional {part_name: hex_color} template_path: str | None = None # Path to .blend template file target_collection: str = "Product" # Collection to import geometry into material_library_path: str | None = None # Path to material library .blend material_map: dict | None = None # {part_name: material_name} from Excel part_names_ordered: list | None = None # ordered STEP part names for index matching lighting_only: bool = False # use template World/HDRI only; force auto-camera shadow_catcher: bool = False # enable Shadowcatcher collection + position plane at bbox min Z rotation_x: float = 0.0 # Euler X rotation in degrees (applied to imported STL) rotation_y: float = 0.0 # Euler Y rotation in degrees rotation_z: float = 0.0 # Euler Z rotation in degrees job_id: str | None = None # Optional ID for cancellation tracking noise_threshold: str = "" # Adaptive sampling noise threshold (empty = Blender default) denoiser: str = "" # "OPTIX" | "OPENIMAGEDENOISE" (empty = auto) denoising_input_passes: str = "" # "RGB" | "RGB_ALBEDO" | "RGB_ALBEDO_NORMAL" denoising_prefilter: str = "" # "NONE" | "FAST" | "ACCURATE" denoising_quality: str = "" # "HIGH" | "BALANCED" | "FAST" (Blender 4.2+) denoising_use_gpu: str = "" # "1" = GPU, "0" = CPU, "" = auto def _find_blender() -> str: """Locate the Blender binary: prefer $BLENDER_BIN, then PATH.""" import os, shutil env_bin = os.environ.get("BLENDER_BIN", "") if env_bin and Path(env_bin).exists(): return env_bin return shutil.which("blender") or "blender" @app.get("/health") async def health(): blender_bin = _find_blender() version = "unknown" try: result = subprocess.run( [blender_bin, "--version"], capture_output=True, text=True, timeout=10 ) first_line = (result.stdout or result.stderr or "").splitlines() version = first_line[0].strip() if first_line else "unknown" except Exception: pass return { "status": "ok", "renderer": "blender", "blender_path": blender_bin, "blender_version": version, } class ConvertStlRequest(BaseModel): step_path: str quality: str = "low" # "low" or "high" @app.post("/convert-stl") async def convert_stl(req: ConvertStlRequest): """Convert a STEP file to STL and cache it — no Blender render.""" if req.quality not in ("low", "high"): raise HTTPException(400, detail="quality must be 'low' or 'high'") step_path = Path(req.step_path) if not step_path.exists(): raise HTTPException(404, detail=f"STEP file not found: {step_path}") stl_path = step_path.parent / f"{step_path.stem}_{req.quality}.stl" parts_dir = step_path.parent / f"{step_path.stem}_{req.quality}_parts" t0 = time.monotonic() try: if not stl_path.exists() or stl_path.stat().st_size == 0: await asyncio.to_thread(_convert_step_to_stl, step_path, stl_path, req.quality) logger.info("STL generated: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) else: logger.info("STL cache hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) except Exception as e: logger.error("STEP→STL conversion failed: %s", e) raise HTTPException(500, detail=f"STEP conversion failed: {e}") try: if not (parts_dir / "manifest.json").exists(): await asyncio.to_thread(_export_per_part_stls, step_path, parts_dir, req.quality) except Exception as e: logger.warning("per-part STL export failed (non-fatal): %s", e) return { "stl_path": str(stl_path), "size_bytes": stl_path.stat().st_size if stl_path.exists() else 0, "duration_s": round(time.monotonic() - t0, 2), } @app.post("/cancel/{job_id}") async def cancel_render(job_id: str): """Kill the Blender subprocess for a running job (best-effort).""" with _procs_lock: proc = _active_procs.pop(job_id, None) if proc is None: return {"status": "not_found", "job_id": job_id} try: pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGTERM) logger.info("Sent SIGTERM to process group %d for job %s", pgid, job_id) except (ProcessLookupError, OSError): pass # process already finished return {"status": "cancelled", "job_id": job_id} @app.get("/status") async def status(): """Return current render queue depth and concurrency setting.""" with _procs_lock: active = len(_active_procs) with _config_lock: current_max = _max_concurrent return {"active_jobs": active, "max_concurrent": current_max} @app.post("/configure") async def configure(max_concurrent: int): """Dynamically update the maximum number of concurrent Blender renders.""" if not (1 <= max_concurrent <= 16): from fastapi import HTTPException raise HTTPException(400, detail="max_concurrent must be between 1 and 16") _set_max_concurrent(max_concurrent) logger.info("max_concurrent_renders updated to %d", max_concurrent) return {"max_concurrent": max_concurrent} @app.post("/render") async def render(req: RenderRequest): step_path = Path(req.step_path) output_path = Path(req.output_path) if not step_path.exists(): raise HTTPException(404, detail=f"STEP file not found: {step_path}") output_path.parent.mkdir(parents=True, exist_ok=True) t_start = time.monotonic() # Acquire render slot — blocks if 3 renders are already running. # asyncio.to_thread is used so the semaphore acquire doesn't block the event loop. acquired = await asyncio.to_thread(_render_semaphore.acquire) # 1. Get/create STL cache — persistent next to STEP file so re-renders skip conversion stl_path = step_path.parent / f"{step_path.stem}_{req.stl_quality}.stl" parts_dir = step_path.parent / f"{step_path.stem}_{req.stl_quality}_parts" stl_size_bytes = 0 t_stl_start = time.monotonic() try: if not stl_path.exists() or stl_path.stat().st_size == 0: logger.info("STL cache miss — converting: %s", step_path.name) _convert_step_to_stl(step_path, stl_path, req.stl_quality) else: logger.info("STL cache hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0 except Exception as e: _render_semaphore.release() logger.error(f"STEP→STL conversion failed: {e}") raise HTTPException(500, detail=f"STEP conversion failed: {e}") # Per-part export (non-fatal — Blender falls back to combined STL) try: if not (parts_dir / "manifest.json").exists(): _export_per_part_stls(step_path, parts_dir, req.stl_quality) except Exception as e: logger.warning("per-part STL export failed (non-fatal): %s", e) stl_duration_s = round(time.monotonic() - t_stl_start, 2) # 2. Render STL → PNG via Blender render_log_lines: list[str] = [] parts_count = 0 engine_used = req.engine t_render_start = time.monotonic() try: render_log_lines, parts_count, engine_used = _render_stl_with_blender( stl_path, output_path, req.width, req.height, req.engine, req.samples, req.smooth_angle, req.cycles_device, req.transparent_bg, template_path=req.template_path, target_collection=req.target_collection, material_library_path=req.material_library_path, material_map=req.material_map, part_names_ordered=req.part_names_ordered, lighting_only=req.lighting_only, shadow_catcher=req.shadow_catcher, rotation_x=req.rotation_x, rotation_y=req.rotation_y, rotation_z=req.rotation_z, job_id=req.job_id, noise_threshold=req.noise_threshold, denoiser=req.denoiser, denoising_input_passes=req.denoising_input_passes, denoising_prefilter=req.denoising_prefilter, denoising_quality=req.denoising_quality, denoising_use_gpu=req.denoising_use_gpu, ) except Exception as e: logger.error(f"Blender render failed: {e}") raise HTTPException(500, detail=f"Blender render failed: {e}") finally: _render_semaphore.release() # STL cache is persistent — do NOT delete stl_path or parts_dir render_duration_s = round(time.monotonic() - t_render_start, 2) if not output_path.exists(): raise HTTPException(500, detail="Render produced no output file") total_duration_s = round(time.monotonic() - t_start, 2) output_size_bytes = output_path.stat().st_size return { "output_path": str(output_path), "status": "ok", "renderer": "blender", # Timing "total_duration_s": total_duration_s, "stl_duration_s": stl_duration_s, "render_duration_s": render_duration_s, # Mesh info "stl_size_bytes": stl_size_bytes, "output_size_bytes": output_size_bytes, "parts_count": parts_count, # Effective settings (engine may differ from requested if EEVEE fell back) "engine_used": engine_used, # Blender log lines (filtered to [blender_render] prefix lines) "log_lines": render_log_lines, } def _convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None: """Convert STEP file to STL using cadquery. quality="low" → tolerance=0.3, angularTolerance=0.3 (fast, coarser mesh) quality="high" → tolerance=0.01, angularTolerance=0.02 (slower, finer mesh) """ import cadquery as cq shape = cq.importers.importStep(str(step_path)) if quality == "high": cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02) else: cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3) if not stl_path.exists() or stl_path.stat().st_size == 0: raise RuntimeError("cadquery produced empty STL") def _export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list: """Export one STL per named STEP leaf shape using OCP XCAF. Creates parts_dir with individual STL files and a manifest.json. Returns the manifest list, or empty list on failure. """ tol = 0.01 if quality == "high" else 0.3 angular_tol = 0.05 if quality == "high" else 0.3 try: from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool from OCP.TDataStd import TDataStd_Name from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence from OCP.XCAFApp import XCAFApp_Application from OCP.TDocStd import TDocStd_Document from OCP.TCollection import TCollection_ExtendedString from OCP.IFSelect import IFSelect_RetDone import cadquery as cq except ImportError as e: logger.warning("per-part export skipped (import error): %s", e) return [] app = XCAFApp_Application.GetApplication_s() doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) app.InitDocument(doc) reader = STEPCAFControl_Reader() reader.SetNameMode(True) status = reader.ReadFile(str(step_path)) if status != IFSelect_RetDone: logger.warning("XCAF reader failed with status %s", status) return [] if not reader.Transfer(doc): logger.warning("XCAF transfer failed") return [] shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) name_id = TDataStd_Name.GetID_s() leaves = [] def _get_label_name(label): name_attr = TDataStd_Name() if label.FindAttribute(name_id, name_attr): return name_attr.Get().ToExtString() return "" def _collect_leaves(label): if XCAFDoc_ShapeTool.IsAssembly_s(label): components = TDF_LabelSequence() XCAFDoc_ShapeTool.GetComponents_s(label, components) for i in range(1, components.Length() + 1): comp_label = components.Value(i) if XCAFDoc_ShapeTool.IsReference_s(comp_label): ref_label = TDF_Label_cls() XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label) comp_name = _get_label_name(comp_label) ref_name = _get_label_name(ref_label) # Prefer referred shape name — matches material_map keys name = ref_name or comp_name if XCAFDoc_ShapeTool.IsAssembly_s(ref_label): _collect_leaves(ref_label) elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label): # Use comp_label shape — includes instance transform (position) shape = XCAFDoc_ShapeTool.GetShape_s(comp_label) leaves.append((name or f"unnamed_{len(leaves)}", shape)) else: _collect_leaves(comp_label) elif XCAFDoc_ShapeTool.IsSimpleShape_s(label): name = _get_label_name(label) shape = XCAFDoc_ShapeTool.GetShape_s(label) leaves.append((name or f"unnamed_{len(leaves)}", shape)) top_labels = TDF_LabelSequence() shape_tool.GetFreeShapes(top_labels) for i in range(1, top_labels.Length() + 1): _collect_leaves(top_labels.Value(i)) if not leaves: logger.warning("no leaf shapes found via XCAF") return [] parts_dir.mkdir(parents=True, exist_ok=True) manifest = [] for idx, (name, shape) in enumerate(leaves): safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_") filename = f"{idx:02d}_{safe_name}.stl" filepath = str(parts_dir / filename) try: import cadquery as cq cq_shape = cq.Shape(shape) cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol) manifest.append({"index": idx, "name": name, "file": filename}) except Exception as e: logger.warning("failed to export part '%s': %s", name, e) manifest_path = parts_dir / "manifest.json" with open(manifest_path, "w") as f: _json_mod.dump({"parts": manifest}, f, indent=2) total_size = sum( os.path.getsize(str(parts_dir / p["file"])) for p in manifest if (parts_dir / p["file"]).exists() ) logger.info("exported %d per-part STLs (%d KB) to %s", len(manifest), total_size // 1024, parts_dir) return manifest def _parse_blender_log(stdout: str) -> tuple[list[str], int]: """Extract [blender_render] lines and parts count from Blender stdout.""" lines = [] parts_count = 0 for line in (stdout or "").splitlines(): stripped = line.strip() if "[blender_render]" in stripped or "[blender_render" in stripped: lines.append(stripped) if "separated into" in stripped: try: parts_count = int(stripped.split("separated into")[1].split("part")[0].strip()) except Exception: pass elif "imported" in stripped and "named parts" in stripped: try: parts_count = int(stripped.split("imported")[1].split("named")[0].strip()) except Exception: pass elif stripped.startswith("Saved:") or stripped.startswith("Fra:"): lines.append(stripped) return lines, parts_count def _render_stl_with_blender( stl_path: Path, output_path: Path, width: int, height: int, engine: str = "cycles", samples: int = 256, smooth_angle: int = 30, cycles_device: str = "auto", transparent_bg: bool = False, template_path: str | None = None, target_collection: str = "Product", material_library_path: str | None = None, material_map: dict | None = None, part_names_ordered: list | None = None, lighting_only: bool = False, shadow_catcher: bool = False, rotation_x: float = 0.0, rotation_y: float = 0.0, rotation_z: float = 0.0, job_id: str | None = None, noise_threshold: str = "", denoiser: str = "", denoising_input_passes: str = "", denoising_prefilter: str = "", denoising_quality: str = "", denoising_use_gpu: str = "", ) -> tuple[list[str], int, str]: """Render STL to PNG using Blender in background mode. Returns (log_lines, parts_count, engine_used). Blender is launched in its own process group (start_new_session=True) so that SIGTERM from a cancel request kills the entire Blender tree. """ import json as _json blender_bin = _find_blender() script_path = Path(__file__).parent / "blender_render.py" env = dict(os.environ) if engine == "eevee": env.update({ "VK_ICD_FILENAMES": "/usr/share/vulkan/icd.d/lvp_icd.x86_64.json", "LIBGL_ALWAYS_SOFTWARE": "1", "MESA_GL_VERSION_OVERRIDE": "4.5", "EGL_PLATFORM": "surfaceless", }) else: env.update({ "EGL_PLATFORM": "surfaceless", }) def _build_cmd(eng: str) -> list: return [ blender_bin, "--background", "--python", str(script_path), "--", str(stl_path), str(output_path), str(width), str(height), eng, str(samples), str(smooth_angle), cycles_device, "1" if transparent_bg else "0", template_path or "", target_collection, material_library_path or "", _json.dumps(material_map) if material_map else "{}", _json.dumps(part_names_ordered) if part_names_ordered else "[]", "1" if lighting_only else "0", "1" if shadow_catcher else "0", str(rotation_x), str(rotation_y), str(rotation_z), noise_threshold or "", denoiser or "", denoising_input_passes or "", denoising_prefilter or "", denoising_quality or "", denoising_use_gpu or "", ] def _run_blender(eng: str) -> subprocess.CompletedProcess: """Launch Blender in an isolated process group and wait for completion.""" cmd = _build_cmd(eng) proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, start_new_session=True, # new process group → SIGTERM kills entire tree ) if job_id: with _procs_lock: _active_procs[job_id] = proc try: stdout, stderr = proc.communicate(timeout=300) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except (ProcessLookupError, OSError): pass stdout, stderr = proc.communicate() finally: if job_id: with _procs_lock: _active_procs.pop(job_id, None) return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr) result = _run_blender(engine) engine_used = engine # Log to uvicorn output if result.stdout: for line in result.stdout.splitlines(): logger.info("[blender] %s", line) if result.stderr: for line in result.stderr.splitlines(): logger.warning("[blender stderr] %s", line) # If EEVEE fails with a non-signal error, automatically retry with Cycles. # A negative returncode means the process was killed by a signal (e.g. cancel) # — do NOT retry in that case. if result.returncode > 0 and engine == "eevee": logger.warning( "EEVEE render failed (exit %d) – retrying with Cycles (CPU).", result.returncode, ) result = _run_blender("cycles") engine_used = "cycles (eevee fallback)" if result.stdout: for line in result.stdout.splitlines(): logger.info("[blender-cycles-fallback] %s", line) if result.stderr: for line in result.stderr.splitlines(): logger.warning("[blender-cycles-fallback stderr] %s", line) if result.returncode != 0: stdout_tail = result.stdout[-2000:] if result.stdout else "" stderr_tail = result.stderr[-2000:] if result.stderr else "" raise RuntimeError( f"Blender exited {result.returncode}.\n" f"STDOUT: {stdout_tail}\nSTDERR: {stderr_tail}" ) log_lines, parts_count = _parse_blender_log(result.stdout) return log_lines, parts_count, engine_used