feat: initial commit

This commit is contained in:
2026-03-05 22:12:38 +01:00
commit bce762a783
380 changed files with 51955 additions and 0 deletions
+581
View File
@@ -0,0 +1,581 @@
"""
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