716451ff76
Migration 039: cad_files.mesh_attributes JSONB column. domains/products/tasks.py: extract_mesh_attributes Celery task using pythonOCC. still_render.py + turntable_render.py: _apply_mesh_attributes() sets auto-smooth based on curved_ratio and topology threshold from OCC analysis. render_blender.py: passes --mesh-attributes JSON arg to Blender subprocess. render_still_task: loads mesh_attributes from DB and passes to renderer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Direct Blender rendering service — runs Blender as a subprocess.
|
|
|
|
Used by the render-worker Celery container (which has BLENDER_BIN set and
|
|
cadquery installed). The backend and standard workers fall back to the Pillow
|
|
placeholder when this service is unavailable.
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MIN_BLENDER_VERSION = (5, 0, 1)
|
|
|
|
|
|
def find_blender() -> str:
|
|
"""Locate the Blender binary via $BLENDER_BIN or PATH."""
|
|
env_bin = os.environ.get("BLENDER_BIN", "")
|
|
if env_bin and Path(env_bin).exists():
|
|
return env_bin
|
|
found = shutil.which("blender")
|
|
return found or ""
|
|
|
|
|
|
def is_blender_available() -> bool:
|
|
"""Return True if a Blender binary is reachable from this process."""
|
|
return bool(find_blender())
|
|
|
|
|
|
def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
|
|
"""Convert a STEP file to STL using cadquery.
|
|
|
|
Raises ImportError if cadquery is not installed (not available in backend
|
|
container — only in render-worker container).
|
|
"""
|
|
import cadquery as cq # only available in render-worker
|
|
|
|
if quality == "high":
|
|
shape = cq.importers.importStep(str(step_path))
|
|
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
|
|
else:
|
|
shape = cq.importers.importStep(str(step_path))
|
|
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.
|
|
|
|
Returns the manifest list (may be empty on failure — non-fatal).
|
|
"""
|
|
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)
|
|
name = ref_name or comp_name
|
|
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
|
|
_collect_leaves(ref_label)
|
|
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
|
|
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:
|
|
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.dump({"parts": manifest}, f, indent=2)
|
|
|
|
return manifest
|
|
|
|
|
|
def render_still(
|
|
step_path: Path,
|
|
output_path: Path,
|
|
width: int = 512,
|
|
height: int = 512,
|
|
engine: str = "cycles",
|
|
samples: int = 256,
|
|
stl_quality: str = "low",
|
|
smooth_angle: int = 30,
|
|
cycles_device: str = "auto",
|
|
transparent_bg: bool = False,
|
|
part_colors: dict | None = None,
|
|
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,
|
|
noise_threshold: str = "",
|
|
denoiser: str = "",
|
|
denoising_input_passes: str = "",
|
|
denoising_prefilter: str = "",
|
|
denoising_quality: str = "",
|
|
denoising_use_gpu: str = "",
|
|
mesh_attributes: dict | None = None,
|
|
) -> dict:
|
|
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
|
|
|
Returns a dict with timing, sizes, engine_used, and log_lines.
|
|
Raises RuntimeError on failure.
|
|
"""
|
|
import time
|
|
|
|
blender_bin = find_blender()
|
|
if not blender_bin:
|
|
raise RuntimeError("Blender binary not found — check BLENDER_BIN env or PATH")
|
|
|
|
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
|
|
if not script_path.exists():
|
|
# Fallback: look next to this file (development mode)
|
|
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
|
|
if alt.exists():
|
|
script_path = alt
|
|
else:
|
|
raise RuntimeError(f"blender_render.py not found at {script_path}")
|
|
|
|
t0 = time.monotonic()
|
|
|
|
# 1. STL conversion (cadquery)
|
|
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
|
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
|
|
|
t_stl = time.monotonic()
|
|
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, 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
|
|
|
|
if not (parts_dir / "manifest.json").exists():
|
|
try:
|
|
export_per_part_stls(step_path, parts_dir, stl_quality)
|
|
except Exception as exc:
|
|
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
|
|
|
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
|
|
|
# 2. Blender render
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
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["EGL_PLATFORM"] = "surfaceless"
|
|
|
|
def _build_cmd(eng: str) -> list:
|
|
cmd = [
|
|
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 "",
|
|
]
|
|
if mesh_attributes:
|
|
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
|
|
return cmd
|
|
|
|
def _run(eng: str) -> subprocess.CompletedProcess:
|
|
proc = subprocess.Popen(
|
|
_build_cmd(eng),
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=True, env=env, start_new_session=True,
|
|
)
|
|
try:
|
|
stdout, stderr = proc.communicate(timeout=600)
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, OSError):
|
|
pass
|
|
stdout, stderr = proc.communicate()
|
|
return subprocess.CompletedProcess(_build_cmd(eng), proc.returncode, stdout, stderr)
|
|
|
|
t_render = time.monotonic()
|
|
result = _run(engine)
|
|
engine_used = engine
|
|
|
|
log_lines = []
|
|
for line in (result.stdout or "").splitlines():
|
|
logger.info("[blender] %s", line)
|
|
if "[blender_render]" in line:
|
|
log_lines.append(line)
|
|
for line in (result.stderr or "").splitlines():
|
|
logger.warning("[blender stderr] %s", line)
|
|
|
|
# EEVEE fallback to Cycles on non-signal error
|
|
if result.returncode > 0 and engine == "eevee":
|
|
logger.warning("EEVEE failed (exit %d) — retrying with Cycles", result.returncode)
|
|
result = _run("cycles")
|
|
engine_used = "cycles (eevee fallback)"
|
|
for line in (result.stdout or "").splitlines():
|
|
logger.info("[blender-fallback] %s", line)
|
|
if "[blender_render]" in line:
|
|
log_lines.append(line)
|
|
|
|
if result.returncode != 0:
|
|
raise RuntimeError(
|
|
f"Blender exited with code {result.returncode}.\n"
|
|
f"stdout: {(result.stdout or '')[-2000:]}\n"
|
|
f"stderr: {(result.stderr or '')[-500:]}"
|
|
)
|
|
|
|
render_duration_s = round(time.monotonic() - t_render, 2)
|
|
|
|
parts_count = 0
|
|
manifest_file = parts_dir / "manifest.json"
|
|
if manifest_file.exists():
|
|
try:
|
|
data = json.loads(manifest_file.read_text())
|
|
parts_count = len(data.get("parts", []))
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"total_duration_s": round(time.monotonic() - t0, 2),
|
|
"stl_duration_s": stl_duration_s,
|
|
"render_duration_s": render_duration_s,
|
|
"stl_size_bytes": stl_size_bytes,
|
|
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
|
"parts_count": parts_count,
|
|
"engine_used": engine_used,
|
|
"log_lines": log_lines,
|
|
}
|