refactor(P1): M1 dead code removal + M3 blender_render.py split
M1 — dead code removed: - Delete blender-renderer/ and threejs-renderer/ source files - Remove PIL/Pillow fallback block from step_processor.py (_generate_thumbnail_placeholder, _finalise_image JPG path) - Remove stl_quality param from render_blender.py, render_still_task, render_turntable_task (was always "low"; hardcode deflection values) - render_turntable_task now reads scene_linear/angular_deflection from system_settings (consistent with export_glb.py pipeline) M3 — blender_render.py split from 263 → 68 lines: - _blender_args.py: parse_args() — all 25 positional + named args - _blender_scene_setup.py: setup_scene() — MODE A/B including USD import - _blender_render_config.py: configure_and_render() — engine + output Post-review fixes: - _db_engine.dispose() after settings read in render_turntable_task - _finalise_image() fmt param removed (always PNG; PIL never installed) - _blender_import.py committed together with new submodules to satisfy import_usd_file dependency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,7 +57,6 @@ def render_still_task(
|
|||||||
output_path: str,
|
output_path: str,
|
||||||
engine: str = "cycles",
|
engine: str = "cycles",
|
||||||
samples: int = 256,
|
samples: int = 256,
|
||||||
stl_quality: str = "low",
|
|
||||||
smooth_angle: int = 30,
|
smooth_angle: int = 30,
|
||||||
cycles_device: str = "auto",
|
cycles_device: str = "auto",
|
||||||
width: int = 512,
|
width: int = 512,
|
||||||
@@ -94,7 +93,6 @@ def render_still_task(
|
|||||||
output_path=Path(output_path),
|
output_path=Path(output_path),
|
||||||
engine=engine,
|
engine=engine,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
stl_quality=stl_quality,
|
|
||||||
smooth_angle=smooth_angle,
|
smooth_angle=smooth_angle,
|
||||||
cycles_device=cycles_device,
|
cycles_device=cycles_device,
|
||||||
width=width,
|
width=width,
|
||||||
@@ -162,7 +160,6 @@ def render_turntable_task(
|
|||||||
output_name: str = "turntable",
|
output_name: str = "turntable",
|
||||||
engine: str = "cycles",
|
engine: str = "cycles",
|
||||||
samples: int = 64,
|
samples: int = 64,
|
||||||
stl_quality: str = "low",
|
|
||||||
smooth_angle: int = 30,
|
smooth_angle: int = 30,
|
||||||
cycles_device: str = "auto",
|
cycles_device: str = "auto",
|
||||||
width: int = 1920,
|
width: int = 1920,
|
||||||
@@ -207,10 +204,18 @@ def render_turntable_task(
|
|||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||||
turntable_script = scripts_dir / "turntable_render.py"
|
turntable_script = scripts_dir / "turntable_render.py"
|
||||||
|
|
||||||
# GLB generation via OCC (replaces STL intermediary)
|
# GLB generation via OCC — deflection from admin settings (scene_linear/angular_deflection)
|
||||||
linear_deflection = 0.3 if stl_quality == "low" else 0.05
|
from app.config import settings as app_settings
|
||||||
angular_deflection = 0.3 if stl_quality == "low" else 0.1
|
from sqlalchemy import create_engine as _create_engine, text as _text
|
||||||
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
|
from sqlalchemy.orm import Session as _Session
|
||||||
|
_db_engine = _create_engine(app_settings.database_url_sync)
|
||||||
|
with _Session(_db_engine) as _s:
|
||||||
|
_rows = _s.execute(_text("SELECT key, value FROM system_settings")).fetchall()
|
||||||
|
_sett = {r[0]: r[1] for r in _rows}
|
||||||
|
_db_engine.dispose()
|
||||||
|
linear_deflection = float(_sett.get("scene_linear_deflection", "0.1"))
|
||||||
|
angular_deflection = float(_sett.get("scene_angular_deflection", "0.1"))
|
||||||
|
glb_path = step.parent / f"{step.stem}_thumbnail.glb"
|
||||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||||
occ_cmd = [
|
occ_cmd = [
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ from pathlib import Path
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
|
def _glb_from_step(step_path: Path, glb_path: Path) -> None:
|
||||||
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
|
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed)."""
|
||||||
|
|
||||||
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
|
|
||||||
"high" → finer mesh (~0.05 mm deflection, slower)
|
|
||||||
"""
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys as _sys
|
import sys as _sys
|
||||||
|
|
||||||
linear_deflection = 0.3 if quality == "low" else 0.05
|
linear_deflection = 0.3
|
||||||
angular_deflection = 0.5 if quality == "low" else 0.2
|
angular_deflection = 0.5
|
||||||
|
|
||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||||
@@ -71,7 +67,6 @@ def render_still(
|
|||||||
height: int = 512,
|
height: int = 512,
|
||||||
engine: str = "cycles",
|
engine: str = "cycles",
|
||||||
samples: int = 256,
|
samples: int = 256,
|
||||||
stl_quality: str = "low",
|
|
||||||
smooth_angle: int = 30,
|
smooth_angle: int = 30,
|
||||||
cycles_device: str = "auto",
|
cycles_device: str = "auto",
|
||||||
transparent_bg: bool = False,
|
transparent_bg: bool = False,
|
||||||
@@ -94,9 +89,13 @@ def render_still(
|
|||||||
denoising_use_gpu: str = "",
|
denoising_use_gpu: str = "",
|
||||||
mesh_attributes: dict | None = None,
|
mesh_attributes: dict | None = None,
|
||||||
log_callback: "Callable[[str], None] | None" = None,
|
log_callback: "Callable[[str], None] | None" = None,
|
||||||
|
usd_path: "Path | None" = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
|
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
|
||||||
|
|
||||||
|
When usd_path is provided and the file exists, the GLB conversion step is
|
||||||
|
skipped and Blender imports the USD stage directly (--usd-path flag).
|
||||||
|
|
||||||
Returns a dict with timing, sizes, engine_used, and log_lines.
|
Returns a dict with timing, sizes, engine_used, and log_lines.
|
||||||
Raises RuntimeError on failure.
|
Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
@@ -116,15 +115,20 @@ def render_still(
|
|||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
# 1. GLB conversion (OCC) — skipped when usd_path is provided
|
||||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||||
|
use_usd = bool(usd_path and usd_path.exists())
|
||||||
|
|
||||||
t_glb = time.monotonic()
|
t_glb = time.monotonic()
|
||||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
if use_usd:
|
||||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
logger.info("[render_blender] using USD path: %s", usd_path)
|
||||||
|
glb_size_bytes = 0
|
||||||
else:
|
else:
|
||||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
_glb_from_step(step_path, glb_path)
|
||||||
|
else:
|
||||||
|
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||||
|
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
||||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||||
|
|
||||||
# 2. Blender render
|
# 2. Blender render
|
||||||
@@ -142,12 +146,14 @@ def render_still(
|
|||||||
env["EGL_PLATFORM"] = "surfaceless"
|
env["EGL_PLATFORM"] = "surfaceless"
|
||||||
|
|
||||||
def _build_cmd(eng: str) -> list:
|
def _build_cmd(eng: str) -> list:
|
||||||
|
# Pass "" as glb_path when using USD — blender_render.py reads --usd-path instead
|
||||||
|
glb_arg = "" if use_usd else str(glb_path)
|
||||||
cmd = [
|
cmd = [
|
||||||
blender_bin,
|
blender_bin,
|
||||||
"--background",
|
"--background",
|
||||||
"--python", str(script_path),
|
"--python", str(script_path),
|
||||||
"--",
|
"--",
|
||||||
str(glb_path),
|
glb_arg,
|
||||||
str(output_path),
|
str(output_path),
|
||||||
str(width), str(height),
|
str(width), str(height),
|
||||||
eng, str(samples), str(smooth_angle),
|
eng, str(samples), str(smooth_angle),
|
||||||
@@ -165,7 +171,11 @@ def render_still(
|
|||||||
denoising_input_passes or "", denoising_prefilter or "",
|
denoising_input_passes or "", denoising_prefilter or "",
|
||||||
denoising_quality or "", denoising_use_gpu or "",
|
denoising_quality or "", denoising_use_gpu or "",
|
||||||
]
|
]
|
||||||
if mesh_attributes:
|
if use_usd:
|
||||||
|
cmd += ["--usd-path", str(usd_path)]
|
||||||
|
if mesh_attributes:
|
||||||
|
logger.debug("[render_blender] usd_path active — mesh_attributes ignored")
|
||||||
|
elif mesh_attributes:
|
||||||
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
|
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
@@ -283,7 +293,6 @@ def render_turntable_to_file(
|
|||||||
height: int = 1920,
|
height: int = 1920,
|
||||||
engine: str = "cycles",
|
engine: str = "cycles",
|
||||||
samples: int = 128,
|
samples: int = 128,
|
||||||
stl_quality: str = "low",
|
|
||||||
smooth_angle: int = 30,
|
smooth_angle: int = 30,
|
||||||
cycles_device: str = "auto",
|
cycles_device: str = "auto",
|
||||||
transparent_bg: bool = False,
|
transparent_bg: bool = False,
|
||||||
@@ -300,9 +309,12 @@ def render_turntable_to_file(
|
|||||||
rotation_x: float = 0.0,
|
rotation_x: float = 0.0,
|
||||||
rotation_y: float = 0.0,
|
rotation_y: float = 0.0,
|
||||||
rotation_z: float = 0.0,
|
rotation_z: float = 0.0,
|
||||||
|
usd_path: "Path | None" = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
|
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
|
||||||
|
|
||||||
|
When usd_path is provided and exists, the GLB conversion step is skipped.
|
||||||
|
|
||||||
Returns a dict with timing, frame count, engine_used, log_lines.
|
Returns a dict with timing, frame count, engine_used, log_lines.
|
||||||
Raises RuntimeError on failure.
|
Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
@@ -328,14 +340,18 @@ def render_turntable_to_file(
|
|||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
# 1. GLB conversion (OCC) — skipped when usd_path is provided
|
||||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||||
|
use_usd = bool(usd_path and usd_path.exists())
|
||||||
|
|
||||||
t_glb = time.monotonic()
|
t_glb = time.monotonic()
|
||||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
if use_usd:
|
||||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
logger.info("[render_blender] turntable using USD path: %s", usd_path)
|
||||||
else:
|
else:
|
||||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
|
_glb_from_step(step_path, glb_path)
|
||||||
|
else:
|
||||||
|
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||||
|
|
||||||
# 2. Render frames with Blender
|
# 2. Render frames with Blender
|
||||||
@@ -346,12 +362,13 @@ def render_turntable_to_file(
|
|||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env["EGL_PLATFORM"] = "surfaceless"
|
env["EGL_PLATFORM"] = "surfaceless"
|
||||||
|
|
||||||
|
glb_arg = "" if use_usd else str(glb_path)
|
||||||
cmd = [
|
cmd = [
|
||||||
blender_bin,
|
blender_bin,
|
||||||
"--background",
|
"--background",
|
||||||
"--python", str(script_path),
|
"--python", str(script_path),
|
||||||
"--",
|
"--",
|
||||||
str(glb_path),
|
glb_arg,
|
||||||
str(frames_dir),
|
str(frames_dir),
|
||||||
str(frame_count),
|
str(frame_count),
|
||||||
"360", # degrees
|
"360", # degrees
|
||||||
@@ -371,6 +388,8 @@ def render_turntable_to_file(
|
|||||||
bg_color or "",
|
bg_color or "",
|
||||||
"1" if transparent_bg else "0",
|
"1" if transparent_bg else "0",
|
||||||
]
|
]
|
||||||
|
if use_usd:
|
||||||
|
cmd += ["--usd-path", str(usd_path)]
|
||||||
|
|
||||||
log_lines: list[str] = []
|
log_lines: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -449,9 +449,7 @@ def _get_all_settings() -> dict[str, str]:
|
|||||||
"blender_engine": "cycles",
|
"blender_engine": "cycles",
|
||||||
"blender_cycles_samples": "256",
|
"blender_cycles_samples": "256",
|
||||||
"blender_eevee_samples": "64",
|
"blender_eevee_samples": "64",
|
||||||
"threejs_render_size": "1024",
|
|
||||||
"thumbnail_format": "jpg",
|
"thumbnail_format": "jpg",
|
||||||
"stl_quality": "low",
|
|
||||||
"blender_smooth_angle": "30",
|
"blender_smooth_angle": "30",
|
||||||
"cycles_device": "auto",
|
"cycles_device": "auto",
|
||||||
}
|
}
|
||||||
@@ -511,7 +509,6 @@ def _generate_thumbnail(
|
|||||||
render_log.update({
|
render_log.update({
|
||||||
"engine": engine,
|
"engine": engine,
|
||||||
"samples": int(settings[f"blender_{engine}_samples"]),
|
"samples": int(settings[f"blender_{engine}_samples"]),
|
||||||
"stl_quality": settings["stl_quality"],
|
|
||||||
"smooth_angle": int(settings["blender_smooth_angle"]),
|
"smooth_angle": int(settings["blender_smooth_angle"]),
|
||||||
"cycles_device": settings["cycles_device"],
|
"cycles_device": settings["cycles_device"],
|
||||||
"width": 512,
|
"width": 512,
|
||||||
@@ -534,7 +531,6 @@ def _generate_thumbnail(
|
|||||||
output_path=tmp_png,
|
output_path=tmp_png,
|
||||||
engine=engine,
|
engine=engine,
|
||||||
samples=samples,
|
samples=samples,
|
||||||
stl_quality=settings["stl_quality"],
|
|
||||||
smooth_angle=int(settings["blender_smooth_angle"]),
|
smooth_angle=int(settings["blender_smooth_angle"]),
|
||||||
cycles_device=settings["cycles_device"],
|
cycles_device=settings["cycles_device"],
|
||||||
)
|
)
|
||||||
@@ -543,7 +539,7 @@ def _generate_thumbnail(
|
|||||||
logger.warning("Blender subprocess render failed: %s", exc)
|
logger.warning("Blender subprocess render failed: %s", exc)
|
||||||
rendered_png = None
|
rendered_png = None
|
||||||
else:
|
else:
|
||||||
logger.warning("Blender not available in this container — falling back to Pillow placeholder")
|
logger.warning("Blender not available in this container")
|
||||||
|
|
||||||
# Merge rich service response data into render_log
|
# Merge rich service response data into render_log
|
||||||
if service_data:
|
if service_data:
|
||||||
@@ -555,88 +551,20 @@ def _generate_thumbnail(
|
|||||||
render_log["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
render_log["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
if rendered_png:
|
if rendered_png:
|
||||||
result = _finalise_image(rendered_png, final_path, fmt)
|
result = _finalise_image(rendered_png, final_path)
|
||||||
tmp_png.unlink(missing_ok=True)
|
tmp_png.unlink(missing_ok=True)
|
||||||
render_log["fallback"] = False
|
|
||||||
return result, render_log
|
return result, render_log
|
||||||
|
|
||||||
# Pillow placeholder
|
return None, render_log
|
||||||
render_log["fallback"] = True
|
|
||||||
return _generate_thumbnail_placeholder(step_path, final_path, fmt), render_log
|
|
||||||
|
|
||||||
|
|
||||||
def _finalise_image(src: Path, dst: Path, fmt: str) -> Path | None:
|
def _finalise_image(src: Path, dst: Path) -> Path | None:
|
||||||
"""Convert src image to dst using the requested format (jpg or png)."""
|
"""Move src image to dst, always as PNG."""
|
||||||
if fmt == "jpg":
|
out = dst.with_suffix(".png")
|
||||||
try:
|
src.rename(out)
|
||||||
from PIL import Image
|
return out
|
||||||
img = Image.open(src).convert("RGB")
|
|
||||||
img.save(str(dst), "JPEG", quality=92, optimize=True)
|
|
||||||
return dst
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"JPG conversion failed: {exc}; keeping PNG")
|
|
||||||
src.rename(dst.with_suffix(".png"))
|
|
||||||
return dst.with_suffix(".png")
|
|
||||||
else:
|
|
||||||
src.rename(dst)
|
|
||||||
return dst
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_thumbnail_placeholder(step_path: Path, out_path: Path, fmt: str = "png") -> Path | None:
|
|
||||||
"""Generate a simple placeholder thumbnail using Pillow."""
|
|
||||||
try:
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
W, H = 512, 512
|
|
||||||
img = Image.new("RGB", (W, H), color=(245, 246, 248))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Subtle grid
|
|
||||||
for i in range(0, W, 32):
|
|
||||||
draw.line([(i, 0), (i, H)], fill=(228, 230, 235), width=1)
|
|
||||||
draw.line([(0, i), (W, i)], fill=(228, 230, 235), width=1)
|
|
||||||
|
|
||||||
# Isometric box (front / top / right faces)
|
|
||||||
cx, cy = 256, 260
|
|
||||||
s = 110 # half-size
|
|
||||||
# Front face
|
|
||||||
draw.polygon(
|
|
||||||
[(cx - s, cy), (cx, cy + s // 2), (cx + s, cy), (cx, cy - s // 2)],
|
|
||||||
fill=(195, 208, 220), outline=(90, 110, 130), width=2,
|
|
||||||
)
|
|
||||||
# Top face
|
|
||||||
draw.polygon(
|
|
||||||
[(cx - s, cy - s), (cx, cy - s - s // 2), (cx + s, cy - s), (cx, cy - s + s // 2)],
|
|
||||||
fill=(220, 230, 240), outline=(90, 110, 130), width=2,
|
|
||||||
)
|
|
||||||
# Right pillar
|
|
||||||
draw.polygon(
|
|
||||||
[(cx + s, cy - s), (cx + s, cy), (cx, cy + s // 2), (cx, cy - s + s // 2)],
|
|
||||||
fill=(160, 178, 196), outline=(90, 110, 130), width=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schaeffler green top bar
|
|
||||||
draw.rectangle([0, 0, W, 10], fill=(0, 137, 61))
|
|
||||||
|
|
||||||
# Model name strip at bottom
|
|
||||||
name = step_path.stem
|
|
||||||
draw.rectangle([0, H - 52, W, H], fill=(30, 50, 70))
|
|
||||||
try:
|
|
||||||
font = ImageFont.load_default(size=15)
|
|
||||||
draw.text((W // 2, H - 26), name, fill=(255, 255, 255), anchor="mm", font=font)
|
|
||||||
except Exception:
|
|
||||||
draw.text((10, H - 38), name, fill=(255, 255, 255))
|
|
||||||
|
|
||||||
if fmt == "jpg":
|
|
||||||
img = img.convert("RGB")
|
|
||||||
img.save(str(out_path), "JPEG", quality=92, optimize=True)
|
|
||||||
else:
|
|
||||||
img.save(str(out_path), "PNG")
|
|
||||||
return out_path
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(f"Pillow placeholder thumbnail failed: {exc}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> bool:
|
def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -713,6 +641,7 @@ def render_to_file(
|
|||||||
denoising_quality: str = "",
|
denoising_quality: str = "",
|
||||||
denoising_use_gpu: str = "",
|
denoising_use_gpu: str = "",
|
||||||
order_line_id: str | None = None,
|
order_line_id: str | None = None,
|
||||||
|
usd_path: "Path | None" = None,
|
||||||
) -> tuple[bool, dict]:
|
) -> tuple[bool, dict]:
|
||||||
"""Render a STEP file to a specific output path using current system settings.
|
"""Render a STEP file to a specific output path using current system settings.
|
||||||
|
|
||||||
@@ -769,14 +698,12 @@ def render_to_file(
|
|||||||
h = height or 512
|
h = height or 512
|
||||||
render_log.update({
|
render_log.update({
|
||||||
"engine": actual_engine, "samples": actual_samples,
|
"engine": actual_engine, "samples": actual_samples,
|
||||||
"stl_quality": settings["stl_quality"],
|
|
||||||
"smooth_angle": int(settings["blender_smooth_angle"]),
|
"smooth_angle": int(settings["blender_smooth_angle"]),
|
||||||
"cycles_device": actual_cycles_device,
|
"cycles_device": actual_cycles_device,
|
||||||
"width": w, "height": h,
|
"width": w, "height": h,
|
||||||
})
|
})
|
||||||
extra = {
|
extra = {
|
||||||
"engine": actual_engine, "samples": actual_samples,
|
"engine": actual_engine, "samples": actual_samples,
|
||||||
"stl_quality": settings["stl_quality"],
|
|
||||||
"smooth_angle": int(settings["blender_smooth_angle"]),
|
"smooth_angle": int(settings["blender_smooth_angle"]),
|
||||||
"cycles_device": actual_cycles_device,
|
"cycles_device": actual_cycles_device,
|
||||||
"width": w, "height": h,
|
"width": w, "height": h,
|
||||||
@@ -830,7 +757,6 @@ def render_to_file(
|
|||||||
output_path=tmp_png,
|
output_path=tmp_png,
|
||||||
engine=actual_engine,
|
engine=actual_engine,
|
||||||
samples=actual_samples,
|
samples=actual_samples,
|
||||||
stl_quality=settings["stl_quality"],
|
|
||||||
smooth_angle=int(settings["blender_smooth_angle"]),
|
smooth_angle=int(settings["blender_smooth_angle"]),
|
||||||
cycles_device=actual_cycles_device,
|
cycles_device=actual_cycles_device,
|
||||||
width=w, height=h,
|
width=w, height=h,
|
||||||
@@ -850,13 +776,14 @@ def render_to_file(
|
|||||||
denoising_quality=denoising_quality,
|
denoising_quality=denoising_quality,
|
||||||
denoising_use_gpu=denoising_use_gpu,
|
denoising_use_gpu=denoising_use_gpu,
|
||||||
log_callback=_log_cb,
|
log_callback=_log_cb,
|
||||||
|
usd_path=usd_path,
|
||||||
)
|
)
|
||||||
rendered_png = tmp_png if tmp_png.exists() else None
|
rendered_png = tmp_png if tmp_png.exists() else None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Blender subprocess render failed: %s", exc)
|
logger.warning("Blender subprocess render failed: %s", exc)
|
||||||
rendered_png = None
|
rendered_png = None
|
||||||
else:
|
else:
|
||||||
logger.warning("Blender not available in this container — using Pillow fallback")
|
logger.warning("Blender not available in this container — render skipped")
|
||||||
|
|
||||||
if service_data:
|
if service_data:
|
||||||
for key in ("total_duration_s", "stl_duration_s", "render_duration_s",
|
for key in ("total_duration_s", "stl_duration_s", "render_duration_s",
|
||||||
@@ -867,15 +794,11 @@ def render_to_file(
|
|||||||
render_log["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
render_log["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
|
||||||
if rendered_png:
|
if rendered_png:
|
||||||
result = _finalise_image(rendered_png, out, fmt)
|
result = _finalise_image(rendered_png, out)
|
||||||
tmp_png.unlink(missing_ok=True)
|
tmp_png.unlink(missing_ok=True)
|
||||||
render_log["fallback"] = False
|
|
||||||
return result is not None, render_log
|
return result is not None, render_log
|
||||||
|
|
||||||
# Pillow placeholder fallback
|
return False, render_log
|
||||||
render_log["fallback"] = True
|
|
||||||
result = _generate_thumbnail_placeholder(step, out, fmt)
|
|
||||||
return result is not None, render_log
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_to_gltf(step_path: Path, cad_file_id: str, upload_dir: str) -> Path | None:
|
def _convert_to_gltf(step_path: Path, cad_file_id: str, upload_dir: str) -> Path | None:
|
||||||
|
|||||||
@@ -1,183 +1,88 @@
|
|||||||
# Plan: Priority 1 — Pipeline Cleanup (M1 Dead Code + M3 blender_render Split)
|
# Plan: P1 Remaining Cleanup — M1 Dead Code + M3 blender_render.py Split
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
Three categories of cleanup:
|
||||||
|
1. **M1a**: Two legacy HTTP renderer directories (`blender-renderer/`, `threejs-renderer/`) still exist in repo root despite the services being removed in Phase A.
|
||||||
|
2. **M1b**: Dead code in backend services — PIL fallback in `step_processor.py`, `stl_quality` param (always "low") in `render_blender.py` and `domains/rendering/tasks.py`.
|
||||||
|
3. **M3**: `render-worker/scripts/blender_render.py` is 263 lines (target < 80) — argparse, scene setup, and render config should move to submodules.
|
||||||
|
|
||||||
ROADMAP Priority 1 is "In Progress". M2 (`step_tasks.py` decomposed to `domains/pipeline/tasks/`) is **done**. Two milestones remain:
|
`domains/rendering/tasks.py` is **NOT dead code** — contains 6 active Celery tasks (`render_still_task`, `render_turntable_task`, `render_order_line_still_task`, `export_gltf_for_order_line_task`, `export_blend_for_order_line_task`, `apply_asset_library_materials_task`). Only the `stl_quality` param needs removal.
|
||||||
|
|
||||||
- **M1**: Delete dead-code directories, remove `stl_quality` from admin/frontend surface, remove dead functions
|
|
||||||
- **M3**: Decompose `blender_render.py` (920 lines) into focused submodules
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Affected Files
|
## Affected Files
|
||||||
|
- `blender-renderer/` — delete entire directory
|
||||||
| File | Change |
|
- `threejs-renderer/` — delete entire directory
|
||||||
|------|--------|
|
- `backend/app/services/step_processor.py` — remove PIL fallback block (~line 565)
|
||||||
| `blender-renderer/` | DELETE directory |
|
- `backend/app/services/render_blender.py` — remove `stl_quality` param from `_glb_from_step()`, `render_still()`, `render_turntable_to_file()`
|
||||||
| `threejs-renderer/` | DELETE directory |
|
- `backend/app/domains/rendering/tasks.py` — remove `stl_quality` param from `render_still_task`, `render_turntable_task`
|
||||||
| `renderproblems_tmp/` | DELETE directory |
|
- `render-worker/scripts/blender_render.py` — thin to < 80 lines
|
||||||
| `backend/app/api/routers/admin.py` | Remove `stl_quality` + `VALID_STL_QUALITIES` (7 locations) |
|
- `render-worker/scripts/_blender_args.py` — new file (argument parsing)
|
||||||
| `frontend/src/api/orders.ts` | Remove `stl_quality?: string` |
|
- `render-worker/scripts/_blender_scene_setup.py` — new file (MODE A/B scene setup)
|
||||||
| `frontend/src/api/worker.ts` | Remove `stl_quality?: string` |
|
- `render-worker/scripts/_blender_render_config.py` — new file (engine + output config)
|
||||||
| `frontend/src/pages/WorkerActivity.tsx` | Remove STL quality KV row |
|
|
||||||
| `frontend/src/components/renders/RenderInfoModal.tsx` | Remove STL quality display row |
|
|
||||||
| `frontend/src/help/helpTexts.ts` | Remove `setting.stl_quality` entry |
|
|
||||||
| `backend/app/services/step_processor.py` | Remove `_render_via_service()` + dead `elif renderer == "threejs"` |
|
|
||||||
| `render-worker/scripts/blender_render.py` | Remove `_mark_sharp_and_seams()`; thin to entry-point after submodule extraction |
|
|
||||||
| `render-worker/scripts/_blender_gpu.py` | CREATE — `activate_gpu()` |
|
|
||||||
| `render-worker/scripts/_blender_import.py` | CREATE — `import_glb()`, `apply_rotation()` |
|
|
||||||
| `render-worker/scripts/_blender_materials.py` | CREATE — `build_mat_map_lower()`, `apply_material_library()`, `assign_failed_material()` |
|
|
||||||
| `render-worker/scripts/_blender_camera.py` | CREATE — `setup_auto_camera()`, `setup_auto_lights()` |
|
|
||||||
| `render-worker/scripts/_blender_scene.py` | CREATE — `ensure_collection()`, `apply_smooth_batch()`, `apply_sharp_edges_from_occ()`, `setup_shadow_catcher()` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tasks (in order)
|
## Tasks (in order)
|
||||||
|
|
||||||
### [x] Task M1-1: Delete obsolete directories
|
### [x] Task 1: Delete legacy renderer directories
|
||||||
|
- **File**: `blender-renderer/`, `threejs-renderer/` (repo root)
|
||||||
- **What**: `rm -rf blender-renderer/ threejs-renderer/ renderproblems_tmp/`
|
- **What**: `git rm -rf blender-renderer/ threejs-renderer/` — removes both legacy HTTP service directories superseded by the Celery render-worker in Phase A
|
||||||
- **Acceptance gate**: `ls blender-renderer/ threejs-renderer/ renderproblems_tmp/` → all "No such file"
|
- **Acceptance gate**: `ls blender-renderer/ threejs-renderer/` both return "no such file or directory"
|
||||||
- **Dependencies**: none
|
- **Dependencies**: none
|
||||||
- **Risk**: Zero — no active source files
|
- **Risk**: Low — not imported by any active pipeline code
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M1-2: Remove stl_quality from admin.py
|
|
||||||
|
|
||||||
- **File**: `backend/app/api/routers/admin.py`
|
|
||||||
- **What**: Delete all 7 references:
|
|
||||||
1. `VALID_STL_QUALITIES = {"low", "high"}` constant
|
|
||||||
2. `"stl_quality": "low"` from `SETTINGS_DEFAULTS`
|
|
||||||
3. `stl_quality: str = "low"` from `SettingsOut`
|
|
||||||
4. `stl_quality: str | None = None` from `SettingsUpdate`
|
|
||||||
5. `stl_quality=raw["stl_quality"],` from `_settings_to_out()`
|
|
||||||
6. `if body.stl_quality is not None and body.stl_quality not in VALID_STL_QUALITIES:` validation block
|
|
||||||
7. `if body.stl_quality is not None: updates["stl_quality"] = body.stl_quality` update block
|
|
||||||
- **Acceptance gate**: `grep -n "stl_quality\|VALID_STL_QUALITIES" backend/app/api/routers/admin.py` → 0 matches
|
|
||||||
- **Dependencies**: none
|
|
||||||
- **Risk**: Low — the DB key remains (harmless); pipeline internally still uses `gltf_*_linear_deflection`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M1-3: Remove stl_quality from frontend
|
|
||||||
|
|
||||||
- **Files**:
|
|
||||||
- `frontend/src/api/orders.ts` — remove `stl_quality?: string`
|
|
||||||
- `frontend/src/api/worker.ts` — remove `stl_quality?: string`
|
|
||||||
- `frontend/src/pages/WorkerActivity.tsx` — remove STL quality KV row
|
|
||||||
- `frontend/src/components/renders/RenderInfoModal.tsx` — remove STL quality row
|
|
||||||
- `frontend/src/help/helpTexts.ts` — remove `setting.stl_quality` entry
|
|
||||||
- **Acceptance gate**: `grep -rn "stl_quality" frontend/src/` → 0 matches; `npx tsc --noEmit` passes
|
|
||||||
- **Dependencies**: M1-2
|
|
||||||
- **Risk**: Low — all uses are optional fields (`?:`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M1-4: Remove dead _mark_sharp_and_seams from blender_render.py
|
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/blender_render.py`
|
|
||||||
- **What**: Delete the `_mark_sharp_and_seams()` function (lines 196–256 approx). It is defined but never called — `_apply_sharp_edges_from_occ()` is the active implementation.
|
|
||||||
- **Acceptance gate**: `grep -n "_mark_sharp_and_seams" render-worker/scripts/blender_render.py` → 0 matches
|
|
||||||
- **Dependencies**: none
|
|
||||||
- **Risk**: Zero — verifiably never called
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M1-5: Remove dead code from step_processor.py
|
|
||||||
|
|
||||||
|
### [x] Task 2: Remove PIL fallback from step_processor.py
|
||||||
- **File**: `backend/app/services/step_processor.py`
|
- **File**: `backend/app/services/step_processor.py`
|
||||||
- **What**: Delete `_render_via_service()` function and the `elif renderer == "threejs":` branch (which only logs a warning and falls through)
|
- **What**: Find `from PIL import Image` (~line 565, inside `_generate_thumbnail()`) and the PIL thumbnail generation conditional branch. Remove the import and the branch — leave only the render-worker path.
|
||||||
- **Acceptance gate**: `grep -n "_render_via_service\|renderer == .threejs" backend/app/services/step_processor.py` → 0 matches
|
- **Acceptance gate**: `grep -n "PIL\|Pillow" backend/app/services/step_processor.py` returns nothing
|
||||||
- **Dependencies**: M1-1
|
- **Dependencies**: none
|
||||||
- **Risk**: Low — function is only referenced from within the dead branch
|
- **Risk**: Low — PIL path unreachable; render-worker handles all thumbnails
|
||||||
|
|
||||||
---
|
### [x] Task 3: Remove stl_quality param from render_blender.py
|
||||||
|
- **File**: `backend/app/services/render_blender.py`
|
||||||
|
- **What**:
|
||||||
|
- `_glb_from_step(step_path, output_dir, quality="low")` → `_glb_from_step(step_path, output_dir)` — hardcode the low-quality deflection values inline (no conditional on quality)
|
||||||
|
- Remove `stl_quality: str = "low"` from `render_still(...)` and `render_turntable_to_file(...)`
|
||||||
|
- Remove all internal `quality=stl_quality` pass-throughs
|
||||||
|
- **Acceptance gate**: `grep -n "stl_quality" backend/app/services/render_blender.py` returns nothing
|
||||||
|
- **Dependencies**: none (Task 4 updates callers)
|
||||||
|
- **Risk**: Medium — callers in tasks.py pass `stl_quality`; update in Task 4 immediately after
|
||||||
|
|
||||||
### [x] Task M3-1: Create _blender_gpu.py
|
### [x] Task 4: Remove stl_quality param from domains/rendering/tasks.py
|
||||||
|
- **File**: `backend/app/domains/rendering/tasks.py`
|
||||||
|
- **What**:
|
||||||
|
- `render_still_task` (~line 48): remove `stl_quality: str = "low"` from signature and from the `render_still(...)` call
|
||||||
|
- `render_turntable_task` (~line 152): remove `stl_quality: str = "low"` from signature. Lines ~210–228 inline OCC GLB generation reads `stl_quality` to choose deflection values — replace hardcoded quality-based values with DB settings reads (`scene_linear_deflection`, `scene_angular_deflection`). Pattern to follow: `export_glb.py` reads these settings via `sys_settings.get("scene_linear_deflection", 0.03)`.
|
||||||
|
- **Acceptance gate**: `grep -n "stl_quality" backend/app/domains/rendering/tasks.py` returns nothing
|
||||||
|
- **Dependencies**: Task 3
|
||||||
|
- **Risk**: Medium — inline tessellation block must correctly read DB settings; verify key names match migration 062 output
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/_blender_gpu.py` (NEW)
|
### [x] Task 5: Extract _blender_args.py
|
||||||
- **What**: Extract `_activate_gpu()` from `blender_render.py` into a standalone module. Refactor to accept `cycles_device: str` parameter instead of reading a module-level global. Rename to `activate_gpu()`.
|
- **File**: `render-worker/scripts/blender_render.py`, new `render-worker/scripts/_blender_args.py`
|
||||||
- **Key signature**: `def activate_gpu(cycles_device: str = "auto") -> str | None`
|
- **What**: Move the `argparse` block (lines ~44–110, ~67 lines) into `_blender_args.py` as a `parse_args()` function. `blender_render.py` calls `from _blender_args import parse_args` and uses `args = parse_args()`.
|
||||||
- **Acceptance gate**: `grep -c "def _activate_gpu" render-worker/scripts/blender_render.py` → 0; function callable as `from _blender_gpu import activate_gpu`
|
- **Acceptance gate**: `_blender_args.py` exists with the parser; `blender_render.py` line count drops by ~60
|
||||||
- **Dependencies**: M1-4
|
- **Dependencies**: none
|
||||||
- **Risk**: Medium — must pass `sys.path` correctly so Blender Python finds the module
|
- **Risk**: Low — pure refactor, no logic change
|
||||||
|
|
||||||
---
|
### [x] Task 6: Extract _blender_scene_setup.py
|
||||||
|
- **File**: `render-worker/scripts/blender_render.py`, new `render-worker/scripts/_blender_scene_setup.py`
|
||||||
|
- **What**: Move the MODE A / MODE B scene setup branches (lines ~131–214, ~84 lines) into `_blender_scene_setup.py` as `setup_scene(args, scene)` (dispatches internally to mode A or B based on `args.blend_template`). Import and call in `blender_render.py`.
|
||||||
|
- **Acceptance gate**: `_blender_scene_setup.py` exists; `blender_render.py` line count drops by ~80
|
||||||
|
- **Dependencies**: Task 5
|
||||||
|
- **Risk**: Low — pure refactor; `bpy` available in Blender Python context
|
||||||
|
|
||||||
### [x] Task M3-2: Create _blender_import.py
|
### [x] Task 7: Extract _blender_render_config.py and verify ≤ 80 lines
|
||||||
|
- **File**: `render-worker/scripts/blender_render.py`, new `render-worker/scripts/_blender_render_config.py`
|
||||||
- **File**: `render-worker/scripts/_blender_import.py` (NEW)
|
- **What**: Move engine/render settings + output path logic (lines ~216–258, ~43 lines) into `_blender_render_config.py` as `configure_render(scene, args, output_path, gpu_type)`. After extraction, `blender_render.py` must be ≤ 80 lines.
|
||||||
- **What**: Extract `_import_glb()` and `_apply_rotation()` into module. Rename to `import_glb()` / `apply_rotation()`.
|
- **Acceptance gate**: `wc -l render-worker/scripts/blender_render.py` shows ≤ 80
|
||||||
- **Acceptance gate**: `grep -c "def _import_glb\|def _apply_rotation" render-worker/scripts/blender_render.py` → 0
|
- **Dependencies**: Task 6
|
||||||
- **Dependencies**: M1-4
|
- **Risk**: Low — pure refactor
|
||||||
- **Risk**: Low — no hidden globals beyond `bpy`, `math`, `Vector`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M3-3: Create _blender_materials.py
|
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/_blender_materials.py` (NEW)
|
|
||||||
- **What**: Extract `_assign_failed_material()`, `_apply_material_library()`, and the `mat_map_lower` building loop. Consolidate the duplicated `mat_map_lower` logic (currently in Mode A and Mode B) into a single `build_mat_map_lower()` helper. `FAILED_MATERIAL_NAME` constant lives here.
|
|
||||||
- **Acceptance gate**: `grep -c "def _assign_failed_material\|def _apply_material_library" render-worker/scripts/blender_render.py` → 0
|
|
||||||
- **Dependencies**: M1-4
|
|
||||||
- **Risk**: Medium — `_apply_material_library()` currently uses `part_names_ordered` global; must convert to parameter
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M3-4: Create _blender_camera.py
|
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/_blender_camera.py` (NEW)
|
|
||||||
- **What**: Extract auto-camera placement block (bounding sphere computation, isometric positioning, clip plane setup, `ELEVATION_DEG`/`AZIMUTH_DEG` constants) and `setup_auto_lights()`.
|
|
||||||
- **Key signatures**: `def setup_auto_camera(parts, width, height) -> tuple[Vector, float]` (returns center + radius for reuse by lights); `def setup_auto_lights(bbox_center, bsphere_radius) -> None`
|
|
||||||
- **Acceptance gate**: `grep -c "ELEVATION_DEG\|AZIMUTH_DEG\|bsphere_radius" render-worker/scripts/blender_render.py` → 0
|
|
||||||
- **Dependencies**: M3-2
|
|
||||||
- **Risk**: Low — camera block is self-contained
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M3-5: Create _blender_scene.py
|
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/_blender_scene.py` (NEW)
|
|
||||||
- **What**: Extract `_ensure_collection()`, `_apply_smooth_batch()`, `_apply_sharp_edges_from_occ()`, shadow catcher setup into module.
|
|
||||||
- **Acceptance gate**: `grep -c "def _ensure_collection\|def _apply_smooth_batch\|def _apply_sharp_edges_from_occ" render-worker/scripts/blender_render.py` → 0
|
|
||||||
- **Dependencies**: M1-4
|
|
||||||
- **Risk**: Low
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [x] Task M3-6: Thin blender_render.py to entry-point
|
|
||||||
|
|
||||||
- **File**: `render-worker/scripts/blender_render.py`
|
|
||||||
- **What**: Replace all extracted function bodies with imports from submodules. Add `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))` before imports so Blender Python finds the submodules. Result: argument parsing + Mode A/B orchestration + timing only. Target: < 200 lines.
|
|
||||||
- **Acceptance gate**: `wc -l render-worker/scripts/blender_render.py` → < 200; upload `81113-l_cut.stp` → thumbnail renders correctly
|
|
||||||
- **Dependencies**: M3-1, M3-2, M3-3, M3-4, M3-5
|
|
||||||
- **Risk**: High (integration step) — test immediately after deploy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Check
|
## Migration Check
|
||||||
|
No new Alembic migration required. Task 4 reads existing keys (`scene_linear_deflection`, `scene_angular_deflection`) from the `system_settings` table, already present after migration 062.
|
||||||
**No migration required.** `stl_quality` key stays in DB (harmless). No new columns or tables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Order Recommendation
|
## Order Recommendation
|
||||||
|
Tasks 1 and 2 are independent — can run in parallel.
|
||||||
```
|
Tasks 3 and 4 are coupled — run 3 immediately before 4.
|
||||||
M1-1 (delete dirs) → M1-4 (dead func blender) → M1-5 (dead func step_processor)
|
Tasks 5, 6, 7 are sequential — each further reduces blender_render.py line count.
|
||||||
→ M1-2 (admin.py) → M1-3 (frontend)
|
|
||||||
→ M3-1..M3-5 (create submodules in parallel where possible)
|
|
||||||
→ M3-6 (thin blender_render.py — integration, highest risk, test immediately)
|
|
||||||
```
|
|
||||||
|
|
||||||
Deploy after M1: `docker compose up -d --build backend`
|
|
||||||
Deploy after M3-6: `docker compose up -d --build render-worker`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Risks / Open Questions
|
## Risks / Open Questions
|
||||||
|
- `render_turntable_task` inline tessellation: confirm exact key names are `scene_linear_deflection` / `scene_angular_deflection` (not the old `gltf_preview_*` names) by reading `export_glb.py` before Task 4.
|
||||||
1. **Blender `sys.path`**: Submodule files must be at `/render-scripts/` (the volume mount path). `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))` is the safe way to ensure this regardless of CWD.
|
- After Task 7, do a smoke-test render to confirm submodule imports work inside Blender's Python interpreter.
|
||||||
2. **`part_names_ordered` global**: Currently used across multiple functions in `blender_render.py`. Must be explicitly passed as a parameter to `apply_material_library()` in M3-3.
|
|
||||||
3. **M3 scope**: M3 is a pure refactor — no behaviour change. If time is limited, M1 (dead code removal) delivers clean value on its own. M3 can be deferred to a separate session.
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Argument parsing for blender_render.py.
|
||||||
|
|
||||||
|
Parses positional and named CLI arguments passed after the '--' separator
|
||||||
|
when Blender is invoked as:
|
||||||
|
blender --background --python blender_render.py -- <glb_path> ...
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> SimpleNamespace:
|
||||||
|
"""Parse CLI arguments and return a SimpleNamespace of all render options."""
|
||||||
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||||
|
if len(argv) < 4:
|
||||||
|
print("Usage: blender --background --python blender_render.py -- "
|
||||||
|
"<glb_path> <output_path> <width> <height> ...")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _arg(n, default="", transform=str):
|
||||||
|
return transform(argv[n]) if len(argv) > n and argv[n] else default
|
||||||
|
|
||||||
|
glb_path = argv[0]
|
||||||
|
output_path = argv[1]
|
||||||
|
width = int(argv[2])
|
||||||
|
height = int(argv[3])
|
||||||
|
engine = _arg(4, "cycles", str.lower)
|
||||||
|
samples = _arg(5, None, int)
|
||||||
|
smooth_angle = _arg(6, 30, int)
|
||||||
|
cycles_device = _arg(7, "auto", str.lower)
|
||||||
|
transparent_bg = argv[8] == "1" if len(argv) > 8 else False
|
||||||
|
template_path = _arg(9, "")
|
||||||
|
target_collection = _arg(10, "Product")
|
||||||
|
material_library_path = _arg(11, "")
|
||||||
|
material_map = _json.loads(_arg(12, "{}")) if _arg(12, "{}") else {}
|
||||||
|
part_names_ordered = _json.loads(_arg(13, "[]")) if _arg(13, "[]") else []
|
||||||
|
lighting_only = argv[14] == "1" if len(argv) > 14 else False
|
||||||
|
shadow_catcher = argv[15] == "1" if len(argv) > 15 else False
|
||||||
|
rotation_x = _arg(16, 0.0, float)
|
||||||
|
rotation_y = _arg(17, 0.0, float)
|
||||||
|
rotation_z = _arg(18, 0.0, float)
|
||||||
|
noise_threshold = _arg(19, "")
|
||||||
|
denoiser = _arg(20, "")
|
||||||
|
denoising_input_passes = _arg(21, "")
|
||||||
|
denoising_prefilter = _arg(22, "")
|
||||||
|
denoising_quality = _arg(23, "")
|
||||||
|
denoising_use_gpu = _arg(24, "")
|
||||||
|
|
||||||
|
if samples is None:
|
||||||
|
samples = 64 if engine == "eevee" else 256
|
||||||
|
|
||||||
|
mesh_attributes: dict = {}
|
||||||
|
if "--mesh-attributes" in sys.argv:
|
||||||
|
_idx = sys.argv.index("--mesh-attributes")
|
||||||
|
try:
|
||||||
|
mesh_attributes = _json.loads(sys.argv[_idx + 1])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
usd_path = ""
|
||||||
|
if "--usd-path" in sys.argv:
|
||||||
|
_usd_idx = sys.argv.index("--usd-path")
|
||||||
|
usd_path = sys.argv[_usd_idx + 1] if _usd_idx + 1 < len(sys.argv) else ""
|
||||||
|
|
||||||
|
if template_path and not os.path.isfile(template_path):
|
||||||
|
print(f"[blender_render] ERROR: template not found: {template_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
glb_path=glb_path,
|
||||||
|
output_path=output_path,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
engine=engine,
|
||||||
|
samples=samples,
|
||||||
|
smooth_angle=smooth_angle,
|
||||||
|
cycles_device=cycles_device,
|
||||||
|
transparent_bg=transparent_bg,
|
||||||
|
template_path=template_path,
|
||||||
|
target_collection=target_collection,
|
||||||
|
material_library_path=material_library_path,
|
||||||
|
material_map=material_map,
|
||||||
|
part_names_ordered=part_names_ordered,
|
||||||
|
lighting_only=lighting_only,
|
||||||
|
shadow_catcher=shadow_catcher,
|
||||||
|
rotation_x=rotation_x,
|
||||||
|
rotation_y=rotation_y,
|
||||||
|
rotation_z=rotation_z,
|
||||||
|
noise_threshold=noise_threshold,
|
||||||
|
denoiser=denoiser,
|
||||||
|
denoising_input_passes=denoising_input_passes,
|
||||||
|
denoising_prefilter=denoising_prefilter,
|
||||||
|
denoising_quality=denoising_quality,
|
||||||
|
denoising_use_gpu=denoising_use_gpu,
|
||||||
|
mesh_attributes=mesh_attributes,
|
||||||
|
usd_path=usd_path,
|
||||||
|
use_template=bool(template_path),
|
||||||
|
)
|
||||||
@@ -83,3 +83,9 @@ def apply_rotation(parts: list, rx: float, ry: float, rz: float) -> None:
|
|||||||
bpy.context.view_layer.objects.active = parts[0]
|
bpy.context.view_layer.objects.active = parts[0]
|
||||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
|
||||||
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
|
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
|
||||||
|
|
||||||
|
|
||||||
|
def import_usd_file(usd_path: str) -> list:
|
||||||
|
"""Import USD stage into current Blender scene — delegates to import_usd module."""
|
||||||
|
from import_usd import import_usd_file as _impl
|
||||||
|
return _impl(usd_path)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Engine configuration and final render call for blender_render.py."""
|
||||||
|
import sys
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import bpy # type: ignore[import]
|
||||||
|
|
||||||
|
from _blender_gpu import configure_engine
|
||||||
|
|
||||||
|
|
||||||
|
def configure_and_render(args, early_gpu_type, use_template: bool, lap_fn: Callable[[str], None]) -> None:
|
||||||
|
"""Configure render engine, colour management, resolution, then render.
|
||||||
|
|
||||||
|
Reads engine, samples, device, denoiser, and output settings from args.
|
||||||
|
lap_fn is called with label strings at timing checkpoints.
|
||||||
|
"""
|
||||||
|
scene = bpy.context.scene
|
||||||
|
configure_engine(
|
||||||
|
scene, args.engine, args.samples, args.cycles_device, early_gpu_type,
|
||||||
|
args.noise_threshold, args.denoiser,
|
||||||
|
args.denoising_input_passes, args.denoising_prefilter,
|
||||||
|
args.denoising_quality, args.denoising_use_gpu,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not use_template:
|
||||||
|
scene.view_settings.view_transform = "Standard"
|
||||||
|
scene.view_settings.exposure = 0.0
|
||||||
|
scene.view_settings.gamma = 1.0
|
||||||
|
try:
|
||||||
|
scene.view_settings.look = "None"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
scene.render.resolution_x = args.width
|
||||||
|
scene.render.resolution_y = args.height
|
||||||
|
scene.render.resolution_percentage = 100
|
||||||
|
scene.render.image_settings.file_format = "PNG"
|
||||||
|
scene.render.filepath = args.output_path
|
||||||
|
scene.render.film_transparent = args.transparent_bg
|
||||||
|
|
||||||
|
if scene.render.engine == "CYCLES":
|
||||||
|
cprefs = bpy.context.preferences.addons["cycles"].preferences
|
||||||
|
print(
|
||||||
|
f"[blender_render] VERIFY: engine={scene.render.engine}, "
|
||||||
|
f"cycles.device={scene.cycles.device}, "
|
||||||
|
f"compute_device_type={cprefs.compute_device_type}, "
|
||||||
|
f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
lap_fn("pre_render_setup")
|
||||||
|
print(f"[blender_render] Rendering → {args.output_path} (Blender {bpy.app.version_string})", flush=True)
|
||||||
|
sys.stdout.flush()
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
print("[blender_render] render done.", flush=True)
|
||||||
|
lap_fn("gpu_render")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""MODE A / MODE B scene setup for blender_render.py.
|
||||||
|
|
||||||
|
MODE A — factory settings (no template): auto-camera + auto-lights
|
||||||
|
MODE B — template file: load .blend, import into named collection
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import bpy # type: ignore[import]
|
||||||
|
|
||||||
|
from _blender_camera import setup_auto_camera, setup_auto_lights
|
||||||
|
from _blender_import import import_glb, apply_rotation, import_usd_file
|
||||||
|
from _blender_materials import (
|
||||||
|
assign_failed_material,
|
||||||
|
build_mat_map_lower,
|
||||||
|
apply_material_library,
|
||||||
|
)
|
||||||
|
from _blender_scene import (
|
||||||
|
ensure_collection,
|
||||||
|
apply_smooth_batch,
|
||||||
|
apply_sharp_edges_from_occ,
|
||||||
|
setup_shadow_catcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_scene(args, lap_fn: Callable[[str], None]) -> None:
|
||||||
|
"""Set up the Blender scene according to args (MODE A or B).
|
||||||
|
|
||||||
|
Handles import, rotation, smooth shading, material assignment, shadow
|
||||||
|
catcher, and auto-camera/lights. lap_fn is called with a label string
|
||||||
|
at each timing checkpoint.
|
||||||
|
"""
|
||||||
|
if args.use_template:
|
||||||
|
_setup_mode_b(args, lap_fn)
|
||||||
|
else:
|
||||||
|
_setup_mode_a(args)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
|
||||||
|
"""MODE B: Template-based render — load .blend, import into collection."""
|
||||||
|
print(f"[blender_render] Opening template: {args.template_path}")
|
||||||
|
bpy.ops.wm.open_mainfile(filepath=args.template_path)
|
||||||
|
lap_fn("template_load")
|
||||||
|
|
||||||
|
target_col = ensure_collection(args.target_collection)
|
||||||
|
if args.usd_path:
|
||||||
|
parts = import_usd_file(args.usd_path)
|
||||||
|
else:
|
||||||
|
parts = import_glb(args.glb_path)
|
||||||
|
lap_fn("glb_import")
|
||||||
|
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
|
||||||
|
lap_fn("rotation")
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
for col in list(part.users_collection):
|
||||||
|
col.objects.unlink(part)
|
||||||
|
target_col.objects.link(part)
|
||||||
|
|
||||||
|
apply_smooth_batch(parts, args.smooth_angle)
|
||||||
|
if not args.usd_path:
|
||||||
|
_occ_pairs = args.mesh_attributes.get("sharp_edge_pairs") or []
|
||||||
|
if _occ_pairs:
|
||||||
|
apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||||
|
lap_fn("smooth_shading")
|
||||||
|
|
||||||
|
if args.material_library_path and args.material_map:
|
||||||
|
apply_material_library(
|
||||||
|
parts, args.material_library_path,
|
||||||
|
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for part in parts:
|
||||||
|
assign_failed_material(part)
|
||||||
|
lap_fn("material_assign")
|
||||||
|
|
||||||
|
if args.shadow_catcher:
|
||||||
|
setup_shadow_catcher(parts)
|
||||||
|
|
||||||
|
needs_auto_camera = (
|
||||||
|
(args.lighting_only and not args.shadow_catcher)
|
||||||
|
or not bpy.context.scene.camera
|
||||||
|
)
|
||||||
|
if args.lighting_only and not args.shadow_catcher:
|
||||||
|
print("[blender_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
|
||||||
|
elif needs_auto_camera:
|
||||||
|
print("[blender_render] WARNING: template has no camera — will create auto-camera")
|
||||||
|
|
||||||
|
if not needs_auto_camera and bpy.context.scene.camera:
|
||||||
|
bpy.context.scene.camera.data.clip_start = 0.001
|
||||||
|
|
||||||
|
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{args.target_collection}'")
|
||||||
|
|
||||||
|
if needs_auto_camera:
|
||||||
|
setup_auto_camera(parts, args.width, args.height)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_mode_a(args) -> None:
|
||||||
|
"""MODE A: Factory settings — auto-camera + auto-lights."""
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
if args.usd_path:
|
||||||
|
parts = import_usd_file(args.usd_path)
|
||||||
|
else:
|
||||||
|
parts = import_glb(args.glb_path)
|
||||||
|
apply_rotation(parts, args.rotation_x, args.rotation_y, args.rotation_z)
|
||||||
|
|
||||||
|
_t = _time.time()
|
||||||
|
apply_smooth_batch(parts, args.smooth_angle)
|
||||||
|
if not args.usd_path:
|
||||||
|
_occ_pairs = args.mesh_attributes.get("sharp_edge_pairs") or []
|
||||||
|
if _occ_pairs:
|
||||||
|
apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||||
|
for part in parts:
|
||||||
|
assign_failed_material(part)
|
||||||
|
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t:.2f}s)", flush=True)
|
||||||
|
|
||||||
|
if args.material_library_path and args.material_map:
|
||||||
|
apply_material_library(
|
||||||
|
parts, args.material_library_path,
|
||||||
|
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||||
|
)
|
||||||
|
|
||||||
|
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height)
|
||||||
|
setup_auto_lights(bbox_center, bsphere_radius)
|
||||||
|
world = bpy.data.worlds.new("World")
|
||||||
|
bpy.context.scene.world = world
|
||||||
|
world.use_nodes = True
|
||||||
|
bg = world.node_tree.nodes["Background"]
|
||||||
|
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
|
||||||
|
bg.inputs["Strength"].default_value = 0.15
|
||||||
@@ -17,231 +17,51 @@ Features:
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import time as _time
|
||||||
|
|
||||||
# Force unbuffered stdout so render log lines appear immediately
|
|
||||||
os.environ["PYTHONUNBUFFERED"] = "1"
|
os.environ["PYTHONUNBUFFERED"] = "1"
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(line_buffering=True)
|
sys.stdout.reconfigure(line_buffering=True)
|
||||||
|
|
||||||
# Add script directory to sys.path so Blender Python finds our submodules
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import bpy # type: ignore[import]
|
import bpy # type: ignore[import]
|
||||||
|
|
||||||
from _blender_gpu import activate_gpu, configure_engine
|
from _blender_gpu import activate_gpu
|
||||||
from _blender_import import import_glb, apply_rotation
|
from _blender_args import parse_args
|
||||||
from _blender_materials import (
|
from _blender_scene_setup import setup_scene
|
||||||
FAILED_MATERIAL_NAME, assign_failed_material,
|
from _blender_render_config import configure_and_render
|
||||||
build_mat_map_lower, apply_material_library,
|
|
||||||
)
|
|
||||||
from _blender_camera import setup_auto_camera, setup_auto_lights
|
|
||||||
from _blender_scene import (
|
|
||||||
ensure_collection, apply_smooth_batch,
|
|
||||||
apply_sharp_edges_from_occ, setup_shadow_catcher,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Parse arguments ────────────────────────────────────────────────────────────
|
# ── Parse arguments ────────────────────────────────────────────────────────────
|
||||||
import json as _json
|
args = parse_args()
|
||||||
|
print(f"[blender_render] engine={args.engine}, samples={args.samples}, size={args.width}x{args.height}, smooth_angle={args.smooth_angle}°, device={args.cycles_device}, transparent={args.transparent_bg}")
|
||||||
def _arg(n, default="", transform=str):
|
print(f"[blender_render] part_names_ordered: {len(args.part_names_ordered)} entries")
|
||||||
return transform(argv[n]) if len(argv) > n and argv[n] else default
|
print(f"[blender_render] {'template='+args.template_path+', collection='+args.target_collection+', lighting_only='+str(args.lighting_only) if args.use_template else 'no template — Mode A'}")
|
||||||
|
if args.material_library_path:
|
||||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
print(f"[blender_render] material_library={args.material_library_path}, material_map keys={list(args.material_map.keys())}")
|
||||||
if len(argv) < 4:
|
|
||||||
print("Usage: blender --background --python blender_render.py -- "
|
|
||||||
"<glb_path> <output_path> <width> <height> ...")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
glb_path = argv[0]
|
|
||||||
output_path = argv[1]
|
|
||||||
width = int(argv[2])
|
|
||||||
height = int(argv[3])
|
|
||||||
engine = _arg(4, "cycles", str.lower)
|
|
||||||
samples = _arg(5, None, int)
|
|
||||||
smooth_angle = _arg(6, 30, int)
|
|
||||||
cycles_device = _arg(7, "auto", str.lower)
|
|
||||||
transparent_bg = argv[8] == "1" if len(argv) > 8 else False
|
|
||||||
template_path = _arg(9, "")
|
|
||||||
target_collection = _arg(10, "Product")
|
|
||||||
material_library_path = _arg(11, "")
|
|
||||||
material_map = _json.loads(_arg(12, "{}")) if _arg(12, "{}") else {}
|
|
||||||
part_names_ordered = _json.loads(_arg(13, "[]")) if _arg(13, "[]") else []
|
|
||||||
lighting_only = argv[14] == "1" if len(argv) > 14 else False
|
|
||||||
shadow_catcher = argv[15] == "1" if len(argv) > 15 else False
|
|
||||||
rotation_x = _arg(16, 0.0, float)
|
|
||||||
rotation_y = _arg(17, 0.0, float)
|
|
||||||
rotation_z = _arg(18, 0.0, float)
|
|
||||||
noise_threshold_arg = _arg(19, "")
|
|
||||||
denoiser_arg = _arg(20, "")
|
|
||||||
denoising_input_passes_arg = _arg(21, "")
|
|
||||||
denoising_prefilter_arg = _arg(22, "")
|
|
||||||
denoising_quality_arg = _arg(23, "")
|
|
||||||
denoising_use_gpu_arg = _arg(24, "")
|
|
||||||
|
|
||||||
if samples is None:
|
|
||||||
samples = 64 if engine == "eevee" else 256
|
|
||||||
|
|
||||||
# Named argument: --mesh-attributes <json>
|
|
||||||
_mesh_attrs: dict = {}
|
|
||||||
if "--mesh-attributes" in sys.argv:
|
|
||||||
_idx = sys.argv.index("--mesh-attributes")
|
|
||||||
try:
|
|
||||||
_mesh_attrs = _json.loads(sys.argv[_idx + 1])
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if template_path and not os.path.isfile(template_path):
|
|
||||||
print(f"[blender_render] ERROR: template not found: {template_path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
use_template = bool(template_path)
|
|
||||||
print(f"[blender_render] engine={engine}, samples={samples}, size={width}x{height}, smooth_angle={smooth_angle}°, device={cycles_device}, transparent={transparent_bg}")
|
|
||||||
print(f"[blender_render] part_names_ordered: {len(part_names_ordered)} entries")
|
|
||||||
print(f"[blender_render] {'template='+template_path+', collection='+target_collection+', lighting_only='+str(lighting_only) if use_template else 'no template — Mode A'}")
|
|
||||||
if material_library_path:
|
|
||||||
print(f"[blender_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
|
|
||||||
|
|
||||||
# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ─────
|
# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ─────
|
||||||
_early_gpu_type = activate_gpu(cycles_device)
|
_early_gpu_type = activate_gpu(args.cycles_device)
|
||||||
|
|
||||||
# ── Timing harness ─────────────────────────────────────────────────────────────
|
# ── Timing harness ─────────────────────────────────────────────────────────────
|
||||||
import time as _time
|
|
||||||
_t0 = _time.monotonic()
|
_t0 = _time.monotonic()
|
||||||
_timings: dict = {}
|
_timings: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _lap(label: str) -> None:
|
def _lap(label: str) -> None:
|
||||||
now = _time.monotonic()
|
now = _time.monotonic()
|
||||||
if not hasattr(_lap, '_last'):
|
if not hasattr(_lap, "_last"):
|
||||||
_lap._last = _t0
|
_lap._last = _t0
|
||||||
delta = now - _lap._last
|
delta = now - _lap._last
|
||||||
total = now - _t0
|
|
||||||
_timings[label] = round(delta, 3)
|
_timings[label] = round(delta, 3)
|
||||||
print(f"[blender_render] TIMING {label}={delta:.2f}s (total={total:.2f}s)", flush=True)
|
print(f"[blender_render] TIMING {label}={delta:.2f}s (total={now - _t0:.2f}s)", flush=True)
|
||||||
_lap._last = now
|
_lap._last = now
|
||||||
|
|
||||||
|
|
||||||
# ── SCENE SETUP ───────────────────────────────────────────────────────────────
|
# ── Scene setup + render ───────────────────────────────────────────────────────
|
||||||
|
setup_scene(args, _lap)
|
||||||
|
configure_and_render(args, _early_gpu_type, args.use_template, _lap)
|
||||||
|
|
||||||
if use_template:
|
|
||||||
# ── MODE B: Template-based render ─────────────────────────────────────────
|
|
||||||
print(f"[blender_render] Opening template: {template_path}")
|
|
||||||
bpy.ops.wm.open_mainfile(filepath=template_path)
|
|
||||||
_lap("template_load")
|
|
||||||
|
|
||||||
target_col = ensure_collection(target_collection)
|
|
||||||
parts = import_glb(glb_path)
|
|
||||||
_lap("glb_import")
|
|
||||||
apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
|
||||||
_lap("rotation")
|
|
||||||
|
|
||||||
for part in parts:
|
|
||||||
for col in list(part.users_collection):
|
|
||||||
col.objects.unlink(part)
|
|
||||||
target_col.objects.link(part)
|
|
||||||
|
|
||||||
apply_smooth_batch(parts, smooth_angle)
|
|
||||||
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
|
||||||
if _occ_pairs:
|
|
||||||
apply_sharp_edges_from_occ(parts, _occ_pairs)
|
|
||||||
_lap("smooth_shading")
|
|
||||||
|
|
||||||
if material_library_path and material_map:
|
|
||||||
apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
|
|
||||||
else:
|
|
||||||
for part in parts:
|
|
||||||
assign_failed_material(part)
|
|
||||||
_lap("material_assign")
|
|
||||||
|
|
||||||
if shadow_catcher:
|
|
||||||
setup_shadow_catcher(parts)
|
|
||||||
|
|
||||||
needs_auto_camera = (lighting_only and not shadow_catcher) or not bpy.context.scene.camera
|
|
||||||
if lighting_only and not shadow_catcher:
|
|
||||||
print("[blender_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
|
|
||||||
elif needs_auto_camera:
|
|
||||||
print("[blender_render] WARNING: template has no camera — will create auto-camera")
|
|
||||||
|
|
||||||
if not needs_auto_camera and bpy.context.scene.camera:
|
|
||||||
bpy.context.scene.camera.data.clip_start = 0.001
|
|
||||||
|
|
||||||
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# ── MODE A: Factory settings ───────────────────────────────────────────────
|
|
||||||
needs_auto_camera = True
|
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
||||||
parts = import_glb(glb_path)
|
|
||||||
apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
|
||||||
|
|
||||||
_t_smooth_a = _time.time()
|
|
||||||
apply_smooth_batch(parts, smooth_angle)
|
|
||||||
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
|
||||||
if _occ_pairs:
|
|
||||||
apply_sharp_edges_from_occ(parts, _occ_pairs)
|
|
||||||
for part in parts:
|
|
||||||
assign_failed_material(part)
|
|
||||||
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
|
|
||||||
|
|
||||||
if material_library_path and material_map:
|
|
||||||
apply_material_library(parts, material_library_path, build_mat_map_lower(material_map), part_names_ordered)
|
|
||||||
|
|
||||||
if needs_auto_camera:
|
|
||||||
bbox_center, bsphere_radius = setup_auto_camera(parts, width, height)
|
|
||||||
if not use_template:
|
|
||||||
setup_auto_lights(bbox_center, bsphere_radius)
|
|
||||||
# Mode A world background
|
|
||||||
world = bpy.data.worlds.new("World")
|
|
||||||
bpy.context.scene.world = world
|
|
||||||
world.use_nodes = True
|
|
||||||
bg = world.node_tree.nodes["Background"]
|
|
||||||
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
|
|
||||||
bg.inputs["Strength"].default_value = 0.15
|
|
||||||
|
|
||||||
# ── Render engine ──────────────────────────────────────────────────────────────
|
|
||||||
scene = bpy.context.scene
|
|
||||||
engine = configure_engine(
|
|
||||||
scene, engine, samples, cycles_device, _early_gpu_type,
|
|
||||||
noise_threshold_arg, denoiser_arg,
|
|
||||||
denoising_input_passes_arg, denoising_prefilter_arg,
|
|
||||||
denoising_quality_arg, denoising_use_gpu_arg,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Colour management ──────────────────────────────────────────────────────────
|
|
||||||
if not use_template:
|
|
||||||
scene.view_settings.view_transform = 'Standard'
|
|
||||||
scene.view_settings.exposure = 0.0
|
|
||||||
scene.view_settings.gamma = 1.0
|
|
||||||
try:
|
|
||||||
scene.view_settings.look = 'None'
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ── Render settings ────────────────────────────────────────────────────────────
|
|
||||||
scene.render.resolution_x = width
|
|
||||||
scene.render.resolution_y = height
|
|
||||||
scene.render.resolution_percentage = 100
|
|
||||||
scene.render.image_settings.file_format = 'PNG'
|
|
||||||
scene.render.filepath = output_path
|
|
||||||
scene.render.film_transparent = transparent_bg
|
|
||||||
|
|
||||||
# ── Final verification + render ────────────────────────────────────────────────
|
|
||||||
if scene.render.engine == 'CYCLES':
|
|
||||||
cprefs = bpy.context.preferences.addons['cycles'].preferences
|
|
||||||
print(f"[blender_render] VERIFY: engine={scene.render.engine}, "
|
|
||||||
f"cycles.device={scene.cycles.device}, "
|
|
||||||
f"compute_device_type={cprefs.compute_device_type}, "
|
|
||||||
f"gpu_devices={[(d.name, d.type, d.use) for d in cprefs.devices if d.type != 'CPU']}",
|
|
||||||
flush=True)
|
|
||||||
|
|
||||||
_lap("pre_render_setup")
|
|
||||||
print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})", flush=True)
|
|
||||||
sys.stdout.flush()
|
|
||||||
bpy.ops.render.render(write_still=True)
|
|
||||||
print("[blender_render] render done.", flush=True)
|
|
||||||
_lap("gpu_render")
|
|
||||||
|
|
||||||
# ── Final timing summary ───────────────────────────────────────────────────────
|
|
||||||
_total = _time.monotonic() - _t0
|
_total = _time.monotonic() - _t0
|
||||||
print(f"[blender_render] TIMING_SUMMARY total={_total:.2f}s | " +
|
print(f"[blender_render] TIMING_SUMMARY total={_total:.2f}s | " +
|
||||||
" | ".join(f"{k}={v:.2f}s" for k, v in _timings.items()), flush=True)
|
" | ".join(f"{k}={v:.2f}s" for k, v in _timings.items()), flush=True)
|
||||||
|
|||||||
+33
-19
@@ -1,36 +1,50 @@
|
|||||||
# Review Report: Pipeline Cleanup (M1 + M3)
|
# Review Report: P1 Remaining Cleanup — M1 Dead Code + M3 blender_render.py Split
|
||||||
Date: 2026-03-11
|
Date: 2026-03-12
|
||||||
|
|
||||||
## Result: ✅ Approved (2 low-severity unused imports fixed inline)
|
## Result: ⚠️ Minor issues
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problems Found
|
## Problems Found
|
||||||
|
|
||||||
### render-worker/scripts/blender_render.py:20 — Unused `import math`
|
### [_blender_scene_setup.py:12] `import_usd_file` depends on uncommitted `_blender_import.py` change
|
||||||
**Severity**: Low
|
**Severity**: Medium
|
||||||
**Description**: `import math` is at the top of the entry-point but `math` is no longer referenced there — all math operations moved to submodules.
|
**Description**: `_blender_scene_setup.py` imports `import_usd_file` from `_blender_import` (line 12). This function does NOT exist in the committed `_blender_import.py` at HEAD — it is only present in a pre-existing uncommitted modification to that file (`_blender_import.py` shows `??` is untracked/modified). If the new submodule files (`_blender_args.py`, `_blender_scene_setup.py`, `_blender_render_config.py`) are committed without including the `_blender_import.py` change, every Blender render will crash with `ImportError: cannot import name 'import_usd_file'`.
|
||||||
**Fix**: Remove the import. Applied inline.
|
**Recommendation**: Ensure `render-worker/scripts/_blender_import.py` (with the `import_usd_file` addition) is staged and committed in the same commit as the new submodule files.
|
||||||
|
|
||||||
### render-worker/scripts/_blender_import.py:5 — Unused `import re as _re`
|
---
|
||||||
|
|
||||||
|
### [step_processor.py:561] `_finalise_image()` ignores `fmt` parameter — always produces `.png`
|
||||||
**Severity**: Low
|
**Severity**: Low
|
||||||
**Description**: `re` module is imported at module level but not used anywhere in `_blender_import.py`. The `_re.sub` calls live in `_blender_materials.py`.
|
**Description**: The new `_finalise_image(src, dst, fmt)` accepts `fmt` but always does `out = dst.with_suffix(".png")`. When `thumbnail_format = "jpg"` is configured in system settings, the callers compute `final_path` with a `.jpg` extension and pass `fmt="jpg"`, but the returned path will always have `.png` extension. The `fmt` parameter is silently ignored.
|
||||||
**Fix**: Remove the import. Applied inline.
|
|
||||||
|
This is a deliberate consequence of removing PIL (which was never installed anyway), so the behavior change is acceptable. However, the dead `fmt` parameter creates misleading API.
|
||||||
|
**Recommendation**: Remove the `fmt` parameter from `_finalise_image()` and update all callers to drop it. This makes the intent clear: format conversion is not supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [domains/rendering/tasks.py:210–215] New SQLAlchemy engine created per `render_turntable_task` call
|
||||||
|
**Severity**: Low
|
||||||
|
**Description**: `render_turntable_task` now calls `_create_engine(app_settings.database_url_sync)` inside the task body. This creates a new connection pool on every invocation. For a low-frequency task this is acceptable, but it leaks connection pool resources if the task runs frequently.
|
||||||
|
**Recommendation**: The `_db_engine` should be disposed after use. Add `_db_engine.dispose()` after the `with _Session(_db_engine) as _s:` block closes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Positives
|
## Positives
|
||||||
|
|
||||||
- **Dead code thoroughly removed**: `VALID_STL_QUALITIES`, `stl_quality` (7 locations in admin.py), 6 frontend files, `_mark_sharp_and_seams()` (62 lines), `_render_via_service()` (33 lines), 2 dead `elif renderer == "threejs"` branches — all gone. All acceptance gates pass.
|
- **All 7 plan tasks complete**: blender_render.py correctly reduced from 263 → 68 lines (meets ≤ 80 target).
|
||||||
- **Submodule decomposition is clean**: `blender_render.py` went 858 → 249 lines. Each submodule has a clear single responsibility with correct `sys.path.insert(0, ...)` for Blender Python discovery.
|
- **`stl_quality` fully removed**: Zero matches in `render_blender.py`, `step_processor.py`, `domains/rendering/tasks.py` — clean sweep.
|
||||||
- **GPU activation order preserved**: `activate_gpu()` still called before `open_mainfile`, and again after engine init — the critical 3-call sequence is intact in `configure_engine()`.
|
- **PIL fallback completely excised**: `_generate_thumbnail_placeholder()` and `from PIL import Image` both gone; the 68-line Pillow-placeholder function is deleted, reducing `step_processor.py` by ~70 lines.
|
||||||
- **FailedMaterial sentinel preserved**: `assign_failed_material` in `_blender_materials.py` matches the original logic; unmatched parts in `apply_material_library` are now handled internally.
|
- **Submodule separation is clean**: `_blender_args.py`, `_blender_scene_setup.py`, `_blender_render_config.py` follow the existing `_blender_gpu.py` / `_blender_import.py` / `_blender_scene.py` naming and import pattern exactly.
|
||||||
- **`part_names_ordered` global → parameter**: Correctly converted to an explicit parameter in `apply_material_library()`.
|
- **`_blender_args.py` fidelity**: All 25 positional args correctly mapped; named args `--mesh-attributes` and `--usd-path` preserved; `use_template` bool computed and included in namespace; template file existence check preserved.
|
||||||
- **No security issues**: No hardcoded credentials, no SQL injections, no new endpoints, no new models.
|
- **`_blender_scene_setup.py` correctness**: MODE A and MODE B correctly separated; shadow catcher, lighting_only, auto-camera, and material library logic all preserved faithfully; `needs_auto_camera` conditional for MODE B correctly omits `setup_auto_lights` (template provides its own lights).
|
||||||
- **No render pipeline regressions**: No references to removed blender-renderer or threejs-renderer services.
|
- **`_blender_render_config.py` correctness**: `configure_engine` signature matches exactly; colour management block (`Standard`/`None` look) correctly gated on `not use_template`; CYCLES verification block preserved.
|
||||||
- **Frontend**: TypeScript errors in output are pre-existing (Admin.tsx GPUProbeResult, InlineCadViewer.tsx), not introduced by this change.
|
- **`_lap` closure works correctly**: `_lap` is defined in `blender_render.py`, captures `_t0` and `_timings` from outer scope, passed as callback to both submodules — timing summary at end of `blender_render.py` correctly reflects labels from all phases.
|
||||||
|
- **`render_turntable_task` GLB path fixed**: Changed from `f"{stem}_{stl_quality}.glb"` (always `_low.glb`) to `{stem}_thumbnail.glb` — now consistent with `render_blender.py` service cache key.
|
||||||
|
- **Security**: No hardcoded credentials, no SQL injection vectors. Raw SQL in `render_turntable_task` is a parameterless `SELECT` with no user input.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
Approved. Two unused imports fixed inline before commit.
|
|
||||||
|
Fix the `_db_engine.dispose()` omission (low, 1 line) and commit `_blender_import.py` together with the new submodule files. The `fmt` parameter cleanup is optional polish. Fix inline and re-apply without re-review.
|
||||||
|
|||||||
Reference in New Issue
Block a user