chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
+2 -6
View File
@@ -13,6 +13,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.render_paths import result_path_to_public_url
logger = logging.getLogger(__name__)
@@ -774,12 +775,7 @@ async def _tool_find_product_renders(
renders = []
for r in rows:
path = r["result_path"] or ""
# Convert internal path to servable URL
url = None
if "/renders/" in path:
url = path[path.index("/renders/"):]
elif "/thumbnails/" in path:
url = path[path.index("/thumbnails/"):]
url = result_path_to_public_url(path, require_exists=True)
# Effective material override (line overrides output type)
material = r["line_material_override"] or r["ot_material_override"] or None
+144 -18
View File
@@ -20,6 +20,9 @@ import re
# ── Part key generation ───────────────────────────────────────────────────────
_AF_RE = re.compile(r'_AF\d+$', re.IGNORECASE)
_AF_VARIANT_RE = re.compile(r"_AF\d+(_ASM)?_?$", re.IGNORECASE)
_LEGACY_MATERIAL_PREFIX = "SCHAEFFLER_"
_CURRENT_MATERIAL_PREFIX = "HARTOMAT_"
def generate_part_key(
@@ -53,6 +56,95 @@ def generate_part_key(
return key
def normalize_material_name(material_name: str | None) -> str | None:
"""Normalize persisted legacy material names to the current HartOMat prefix."""
if not isinstance(material_name, str):
return None
value = material_name.strip()
if not value:
return None
if value.upper().startswith(_LEGACY_MATERIAL_PREFIX):
return f"{_CURRENT_MATERIAL_PREFIX}{value[len(_LEGACY_MATERIAL_PREFIX):]}"
return value
def _normalize_semantic_source_name(raw_name: str) -> str:
"""Collapse exporter-only suffixes back to their semantic OCC source name."""
name = (raw_name or "").strip()
name = re.sub(r"\.\d{3}$", "", name)
previous = None
while previous != name:
previous = name
name = _AF_VARIANT_RE.sub("", name)
return name
def _slugify_semantic_source_name(raw_name: str) -> str:
base = _normalize_semantic_source_name(raw_name)
base = re.sub(r"([a-z])([A-Z])", r"\1_\2", base)
return re.sub(r"[^a-z0-9]+", "_", base.lower()).strip("_")[:50]
def _derive_semantic_alias_key(part_key: str, source_name: str) -> str | None:
"""Return the semantic alias for deduplicated instance keys, if any."""
alias_key = _slugify_semantic_source_name(source_name)
if not alias_key or alias_key == part_key:
return None
if re.fullmatch(
rf"{re.escape(alias_key)}(?:_[2-9]\d*|_af\d+(?:_asm)?)",
part_key,
flags=re.IGNORECASE,
) is None:
return None
return alias_key
def _alias_priority(part_key: str, source_name: str) -> tuple[int, int, int]:
match = re.fullmatch(r".+_(\d+)$", part_key)
suffix_number = int(match.group(1)) if match else 1_000_000
return (suffix_number, len(source_name or ""), len(part_key))
def _iter_lookup_keys(part_key: str, fallback_part_keys: tuple[str, ...] = ()) -> tuple[str, ...]:
ordered_keys: list[str] = []
for key in (part_key, *fallback_part_keys):
if key and key not in ordered_keys:
ordered_keys.append(key)
return tuple(ordered_keys)
def _build_part_entry(
*,
part_key: str,
source_name: str,
prim_path: str | None,
manual: dict,
resolved: dict,
source: dict,
fallback_part_keys: tuple[str, ...] = (),
) -> dict:
effective_material, provenance = _resolve_material(
part_key,
source_name,
manual,
resolved,
source,
fallback_part_keys=fallback_part_keys,
)
is_unassigned = effective_material is None
return {
"part_key": part_key,
"source_name": source_name,
"prim_path": prim_path,
"effective_material": effective_material,
"assignment_provenance": provenance,
"is_unassigned": is_unassigned,
}
# ── Scene manifest building ───────────────────────────────────────────────────
def build_scene_manifest(cad_file, usd_asset=None) -> dict:
@@ -65,7 +157,8 @@ def build_scene_manifest(cad_file, usd_asset=None) -> dict:
Material assignment priority per part:
1. `manual_material_overrides[part_key]` — provenance "manual"
2. `resolved_material_assignments[part_key]["material"]` — provenance "auto"
2. `resolved_material_assignments[part_key]["canonical_material"]` (or legacy
`["material"]`) — provenance "auto"
3. substring match in `source_material_assignments` against source_name — provenance "source"
4. None, is_unassigned=True — provenance "default"
"""
@@ -80,25 +173,51 @@ def build_scene_manifest(cad_file, usd_asset=None) -> dict:
if resolved:
# Build from resolved assignments (USD pipeline has run)
alias_candidates: dict[str, tuple[tuple[int, int, int], dict]] = {}
for part_key, meta in resolved.items():
source_name = meta.get("source_name", "") if isinstance(meta, dict) else ""
prim_path = meta.get("prim_path") if isinstance(meta, dict) else None
effective_material, provenance = _resolve_material(
part_key, source_name, manual, resolved, source
part_entry = _build_part_entry(
part_key=part_key,
source_name=source_name,
prim_path=prim_path,
manual=manual,
resolved=resolved,
source=source,
)
is_unassigned = effective_material is None
parts.append(part_entry)
if part_entry["is_unassigned"]:
unassigned_parts.append(part_key)
parts.append({
"part_key": part_key,
alias_key = _derive_semantic_alias_key(part_key, source_name)
if alias_key is None or alias_key in resolved:
continue
candidate = {
"part_key": alias_key,
"source_name": source_name,
"prim_path": prim_path,
"effective_material": effective_material,
"assignment_provenance": provenance,
"is_unassigned": is_unassigned,
})
if is_unassigned:
unassigned_parts.append(part_key)
"fallback_part_keys": (part_key,),
}
candidate_priority = _alias_priority(part_key, source_name)
current = alias_candidates.get(alias_key)
if current is None or candidate_priority < current[0]:
alias_candidates[alias_key] = (candidate_priority, candidate)
for alias_key, (_, candidate) in alias_candidates.items():
alias_entry = _build_part_entry(
part_key=candidate["part_key"],
source_name=candidate["source_name"],
prim_path=candidate["prim_path"],
manual=manual,
resolved=resolved,
source=source,
fallback_part_keys=candidate["fallback_part_keys"],
)
parts.append(alias_entry)
if alias_entry["is_unassigned"]:
unassigned_parts.append(alias_key)
elif cad_file.parsed_objects:
# Fall back to parsed_objects from STEP extraction
@@ -149,23 +268,30 @@ def _resolve_material(
manual: dict,
resolved: dict,
source: dict,
fallback_part_keys: tuple[str, ...] = (),
) -> tuple[str | None, str]:
"""Return (material_name, provenance) for one part using priority order."""
lookup_keys = _iter_lookup_keys(part_key, fallback_part_keys)
# 1. Manual override
if part_key in manual and manual[part_key]:
return str(manual[part_key]), "manual"
for lookup_key in lookup_keys:
if lookup_key in manual and manual[lookup_key]:
return normalize_material_name(str(manual[lookup_key])), "manual"
# 2. Auto-resolved from USD pipeline
meta = resolved.get(part_key)
if isinstance(meta, dict) and meta.get("material"):
return str(meta["material"]), "auto"
for lookup_key in lookup_keys:
meta = resolved.get(lookup_key)
if isinstance(meta, dict):
canonical = normalize_material_name(meta.get("canonical_material") or meta.get("material"))
if canonical:
return canonical, "auto"
# 3. Substring match in source_material_assignments against source_name
sn_lower = source_name.lower()
for src_key, src_mat in source.items():
if src_key.lower() in sn_lower or sn_lower in src_key.lower():
if src_mat:
return str(src_mat), "source"
return normalize_material_name(str(src_mat)), "source"
# 4. Unassigned
return None, "default"
+298 -55
View File
@@ -4,6 +4,7 @@ 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 hashlib
import json
import logging
import os
@@ -12,16 +13,175 @@ import signal
import subprocess
from pathlib import Path
from app.core.render_paths import ensure_group_writable_dir
logger = logging.getLogger(__name__)
def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "occ") -> None:
def resolve_tessellation_settings(
profile: str = "render",
tessellation_engine: str | None = None,
) -> tuple[float, float, str]:
"""Resolve tessellation settings from system settings for a given profile."""
profile_key = "scene" if profile == "scene" else "render"
defaults = {
"scene": (0.1, 0.1),
"render": (0.03, 0.05),
}
default_linear, default_angular = defaults[profile_key]
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
linear_deflection = float(
settings.get(f"{profile_key}_linear_deflection", str(default_linear))
)
angular_deflection = float(
settings.get(f"{profile_key}_angular_deflection", str(default_angular))
)
effective_engine = (
tessellation_engine
or settings.get("tessellation_engine", "occ")
or "occ"
)
return linear_deflection, angular_deflection, effective_engine
except Exception as exc:
logger.warning(
"Could not resolve %s tessellation settings: %s; using defaults",
profile_key,
exc,
)
return default_linear, default_angular, tessellation_engine or "occ"
def build_tessellated_glb_path(
step_path: Path,
profile: str,
tessellation_engine: str,
linear_deflection: float,
angular_deflection: float,
) -> Path:
"""Build a settings-sensitive GLB path to avoid stale mesh reuse."""
signature = hashlib.sha1(
f"{profile}:{tessellation_engine}:{linear_deflection:.6f}:{angular_deflection:.6f}".encode(
"utf-8"
)
).hexdigest()[:10]
return step_path.parent / f"{step_path.stem}_{profile}_{signature}.glb"
def _stringify_optional_arg(value: object) -> str:
if value in (None, ""):
return ""
return str(value)
def _resolve_render_samples(engine: str, samples: int | None) -> int:
if samples is not None:
return int(samples)
effective_engine = (engine or "cycles").lower()
setting_key = (
"blender_eevee_samples"
if effective_engine == "eevee"
else "blender_cycles_samples"
)
try:
from app.services.step_processor import _get_all_settings
settings = _get_all_settings()
return int(settings[setting_key])
except Exception as exc:
logger.warning(
"Could not resolve Blender samples from settings for engine=%s: %s; "
"using legacy fallback",
effective_engine,
exc,
)
return 64 if effective_engine == "eevee" else 256
def build_turntable_ffmpeg_cmd(
frames_dir: Path,
output_path: Path,
*,
fps: int = 30,
bg_color: str = "",
width: int = 1920,
height: int = 1080,
ffmpeg_bin: str | None = None,
) -> list[str]:
"""Build the canonical FFmpeg command for turntable MP4 composition.
Legacy and graph/shadow paths must share this logic so template-backed
turntable outputs do not drift due to encoding differences.
"""
ffmpeg = ffmpeg_bin or shutil.which("ffmpeg") or "ffmpeg"
if any(frames_dir.glob("frame_*.png")):
frame_pattern = str(frames_dir / "frame_%04d.png")
else:
frame_pattern = str(frames_dir / "%04d.png")
if bg_color:
hex_color = bg_color.lstrip("#") or "ffffff"
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-f",
"lavfi",
"-i",
f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}",
"-filter_complex",
"[1:v][0:v]overlay=0:0:shortest=1",
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
return [
ffmpeg,
"-y",
"-framerate",
str(fps),
"-i",
frame_pattern,
"-vcodec",
"libx264",
"-pix_fmt",
"yuv420p",
"-crf",
"18",
"-movflags",
"+faststart",
str(output_path),
]
def _glb_from_step(
step_path: Path,
glb_path: Path,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
) -> None:
"""Convert STEP → GLB via OCC or GMSH (export_step_to_gltf.py, no Blender needed)."""
import subprocess
import sys as _sys
linear_deflection = 0.3
angular_deflection = 0.5
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
script_path = scripts_dir / "export_step_to_gltf.py"
@@ -32,7 +192,7 @@ def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "
"--output_path", str(glb_path),
"--linear_deflection", str(linear_deflection),
"--angular_deflection", str(angular_deflection),
"--tessellation_engine", tessellation_engine,
"--tessellation_engine", effective_engine,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
for line in result.stdout.splitlines():
@@ -44,7 +204,15 @@ def _glb_from_step(step_path: Path, glb_path: Path, tessellation_engine: str = "
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
f"STDERR: {result.stderr[-1000:]}"
)
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB converted: %s (%d KB) with %s tessellation linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
def find_blender() -> str:
@@ -67,9 +235,9 @@ def render_still(
width: int = 512,
height: int = 512,
engine: str = "cycles",
samples: int = 256,
samples: int | None = None,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
@@ -92,9 +260,12 @@ def render_still(
log_callback: "Callable[[str], None] | None" = None,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
**ignored_control_kwargs,
) -> dict:
"""Convert STEP → GLB (OCC or GMSH) → PNG (Blender subprocess).
@@ -120,8 +291,18 @@ def render_still(
t0 = time.monotonic()
if ignored_control_kwargs:
logger.debug(
"render_still ignoring unsupported control kwargs: %s",
sorted(ignored_control_kwargs.keys()),
)
if isinstance(usd_path, str) and usd_path.strip():
usd_path = Path(usd_path)
actual_samples = _resolve_render_samples(engine, samples)
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
@@ -129,15 +310,39 @@ def render_still(
logger.info("[render_blender] using USD path: %s", usd_path)
glb_size_bytes = 0
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Blender render
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
if engine == "eevee":
@@ -149,6 +354,7 @@ def render_still(
})
else:
env["EGL_PLATFORM"] = "surfaceless"
env["BLENDER_DEFAULT_SAMPLES"] = str(actual_samples)
def _build_cmd(eng: str) -> list:
# Pass "" as glb_path when using USD — blender_render.py reads --usd-path instead
@@ -161,7 +367,7 @@ def render_still(
glb_arg,
str(output_path),
str(width), str(height),
eng, str(samples), str(smooth_angle),
eng, str(actual_samples), str(smooth_angle),
cycles_device,
"1" if transparent_bg else "0",
template_path or "",
@@ -172,9 +378,9 @@ def render_still(
"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 "",
_stringify_optional_arg(noise_threshold), _stringify_optional_arg(denoiser),
_stringify_optional_arg(denoising_input_passes), _stringify_optional_arg(denoising_prefilter),
_stringify_optional_arg(denoising_quality), _stringify_optional_arg(denoising_use_gpu),
]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
@@ -188,6 +394,8 @@ def render_still(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
return cmd
def _run(eng: str) -> tuple[int, list[str], list[str]]:
@@ -305,7 +513,7 @@ def render_turntable_to_file(
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
bg_color: str = "",
turntable_axis: str = "world_z",
@@ -323,9 +531,11 @@ def render_turntable_to_file(
camera_orbit: bool = True,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
) -> dict:
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
@@ -357,25 +567,48 @@ def render_turntable_to_file(
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] turntable using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
frames_dir.mkdir(parents=True, exist_ok=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
@@ -416,6 +649,8 @@ def render_turntable_to_file(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []
@@ -458,34 +693,15 @@ def render_turntable_to_file(
# 3. Compose frames → mp4 with ffmpeg
t_ffmpeg = time.monotonic()
ffmpeg_cmd = [
ffmpeg_bin,
"-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%04d.png"),
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "18",
"-movflags", "+faststart",
str(output_path),
]
# If bg_color is set and transparent_bg is True, overlay frames on solid bg
if bg_color and transparent_bg:
hex_color = bg_color.lstrip("#")
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
ffmpeg_cmd = [
ffmpeg_bin, "-y",
"-framerate", str(fps),
"-i", str(frames_dir / "frame_%04d.png"),
"-f", "lavfi", "-i", f"color=c=0x{hex_color}:size={width}x{height}:rate={fps}",
"-filter_complex", "[1:v][0:v]overlay=0:0:shortest=1",
"-vcodec", "libx264",
"-pix_fmt", "yuv420p",
"-crf", "18",
"-movflags", "+faststart",
str(output_path),
]
ffmpeg_cmd = build_turntable_ffmpeg_cmd(
frames_dir,
output_path,
fps=fps,
bg_color=bg_color if transparent_bg else "",
width=width,
height=height,
ffmpeg_bin=ffmpeg_bin,
)
ffmpeg_proc = subprocess.run(
ffmpeg_cmd, capture_output=True, text=True, timeout=300
@@ -530,7 +746,7 @@ def render_cinematic_to_file(
engine: str = "cycles",
samples: int = 128,
smooth_angle: int = 30,
cycles_device: str = "auto",
cycles_device: str = "gpu",
transparent_bg: bool = False,
part_colors: dict | None = None,
template_path: str | None = None,
@@ -545,9 +761,11 @@ def render_cinematic_to_file(
rotation_z: float = 0.0,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
tessellation_profile: str = "render",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict | None = None,
log_callback: "Callable[[str], None] | None" = None,
) -> dict:
"""Render a cinematic highlight animation: STEP -> GLB/USD -> 480 frames @ 24fps (Blender) -> mp4 (ffmpeg).
@@ -587,25 +805,48 @@ def render_cinematic_to_file(
t0 = time.monotonic()
# 1. GLB conversion (OCC) — skipped when usd_path is provided
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
use_usd = bool(usd_path and usd_path.exists())
t_glb = time.monotonic()
if use_usd:
logger.info("[render_blender] cinematic using USD path: %s", usd_path)
else:
linear_deflection, angular_deflection, effective_engine = resolve_tessellation_settings(
tessellation_profile,
tessellation_engine,
)
glb_path = build_tessellated_glb_path(
step_path,
tessellation_profile,
effective_engine,
linear_deflection,
angular_deflection,
)
if not glb_path.exists() or glb_path.stat().st_size == 0:
_glb_from_step(step_path, glb_path, tessellation_engine)
_glb_from_step(
step_path,
glb_path,
tessellation_engine=effective_engine,
tessellation_profile=tessellation_profile,
)
else:
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
logger.info(
"GLB local hit: %s (%d KB) profile=%s linear=%s angular=%s engine=%s",
glb_path.name,
glb_path.stat().st_size // 1024,
tessellation_profile,
linear_deflection,
angular_deflection,
effective_engine,
)
glb_duration_s = round(time.monotonic() - t_glb, 2)
# 2. Render frames with Blender
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
if frames_dir.exists():
_shutil.rmtree(frames_dir, ignore_errors=True)
frames_dir.mkdir(parents=True, exist_ok=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(frames_dir)
ensure_group_writable_dir(output_path.parent)
env = dict(os.environ)
env["EGL_PLATFORM"] = "surfaceless"
@@ -645,6 +886,8 @@ def render_cinematic_to_file(
cmd += ["--sensor-width", str(sensor_width_mm)]
if material_override:
cmd += ["--material-override", material_override]
if template_inputs:
cmd += ["--template-inputs", json.dumps(template_inputs)]
log_lines: list[str] = []
+160 -30
View File
@@ -10,7 +10,9 @@ import logging
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from app.core.render_paths import ensure_group_writable_dir
if TYPE_CHECKING:
from app.models.cad_file import CadFile
@@ -18,6 +20,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class MissingCadResourceError(FileNotFoundError):
"""Terminal CAD resource error that should not be retried by Celery tasks."""
def build_part_colors(
cad_parsed_objects: list[str],
cad_part_materials: list[dict],
@@ -1023,8 +1029,12 @@ def _get_all_settings() -> dict[str, str]:
"blender_eevee_samples": "64",
"thumbnail_format": "jpg",
"blender_smooth_angle": "30",
"cycles_device": "auto",
"cycles_device": "gpu",
"tessellation_engine": "occ",
"scene_linear_deflection": "0.1",
"scene_angular_deflection": "0.1",
"render_linear_deflection": "0.03",
"render_angular_deflection": "0.05",
}
try:
from app.config import settings as app_settings
@@ -1046,6 +1056,23 @@ def _generate_thumbnail(
cad_file_id: str,
upload_dir: str,
part_colors: dict[str, str] | None = None,
*,
renderer: str | None = None,
render_engine: str | None = None,
samples: int | None = None,
width: int | None = None,
height: int | None = None,
transparent_bg: bool | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict[str, str] | None = None,
part_names_ordered: list[str] | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
usd_path: Path | None = None,
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
) -> tuple[Path | None, dict]:
"""Generate thumbnail using the configured renderer.
@@ -1054,12 +1081,20 @@ def _generate_thumbnail(
"""
import time
out_dir = Path(upload_dir) / "thumbnails"
out_dir.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out_dir)
settings = _get_all_settings()
renderer = settings["thumbnail_renderer"]
fmt = settings["thumbnail_format"] # "jpg" or "png"
requested_renderer = renderer or settings["thumbnail_renderer"]
active_renderer = requested_renderer
fmt = settings["thumbnail_format"] # "jpg" or "png"
ext = "jpg" if fmt == "jpg" else "png"
if requested_renderer == "threejs":
# The historical Three.js thumbnail renderer was removed from the backend.
# Keep the workflow node executable by falling back to the maintained Blender path
# while preserving the requested renderer in the render log for observability.
active_renderer = "blender"
fmt = "png"
ext = "png"
# Clean up any existing thumbnail for this cad_file_id (either extension)
for old_ext in ("png", "jpg"):
@@ -1073,28 +1108,39 @@ def _generate_thumbnail(
# Build the base render_log with the settings snapshot
render_log: dict = {
"renderer": renderer,
"renderer": requested_renderer,
"format": fmt,
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if renderer == "blender":
engine = settings["blender_engine"]
if active_renderer == "blender":
engine = render_engine or settings["blender_engine"]
resolved_samples = int(samples) if samples is not None else int(settings[f"blender_{engine}_samples"])
resolved_width = int(width) if width is not None else 512
resolved_height = int(height) if height is not None else 512
resolved_transparent_bg = bool(transparent_bg) if transparent_bg is not None else False
render_log.update({
"engine": engine,
"samples": int(settings[f"blender_{engine}_samples"]),
"samples": resolved_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"cycles_device": settings["cycles_device"],
"width": 512,
"height": 512,
"width": resolved_width,
"height": resolved_height,
"transparent_bg": resolved_transparent_bg,
})
logger.info(f"Thumbnail renderer={renderer}, format={fmt}")
if requested_renderer != active_renderer:
render_log["renderer_backend"] = active_renderer
render_log["renderer_fallback_reason"] = "threejs_renderer_removed_using_blender_compat"
logger.info(f"Thumbnail renderer={requested_renderer}, format={fmt}")
rendered_png: Path | None = None
service_data: dict = {}
if renderer == "blender":
engine = settings["blender_engine"]
samples = int(settings[f"blender_{engine}_samples"])
if active_renderer == "blender":
engine = render_engine or settings["blender_engine"]
resolved_samples = int(samples) if samples is not None else int(settings[f"blender_{engine}_samples"])
resolved_width = int(width) if width is not None else 512
resolved_height = int(height) if height is not None else 512
resolved_transparent_bg = bool(transparent_bg) if transparent_bg is not None else False
from app.services.render_blender import is_blender_available, render_still
if is_blender_available():
@@ -1102,11 +1148,25 @@ def _generate_thumbnail(
service_data = render_still(
step_path=step_path,
output_path=tmp_png,
width=resolved_width,
height=resolved_height,
engine=engine,
samples=samples,
samples=resolved_samples,
smooth_angle=int(settings["blender_smooth_angle"]),
cycles_device=settings["cycles_device"],
transparent_bg=resolved_transparent_bg,
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,
tessellation_engine=settings["tessellation_engine"],
usd_path=usd_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
tessellation_profile="scene",
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
@@ -1133,8 +1193,7 @@ def _generate_thumbnail(
def _finalise_image(src: Path, dst: Path) -> Path | None:
"""Move src image to dst. When dst has a .webp suffix, convert via Pillow
(quality=90, method=4) for 50-70 % smaller files. Otherwise output PNG."""
"""Move src image to dst, converting the PNG intermediate when needed."""
if dst.suffix.lower() == ".webp":
try:
from PIL import Image
@@ -1148,13 +1207,52 @@ def _finalise_image(src: Path, dst: Path) -> Path | None:
out = dst.with_suffix(".png")
src.rename(out)
return out
if dst.suffix.lower() in {".jpg", ".jpeg"}:
try:
from PIL import Image
img = Image.open(str(src))
if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info):
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
img = Image.alpha_composite(background, img.convert("RGBA")).convert("RGB")
else:
img = img.convert("RGB")
out = dst.with_suffix(".jpg")
img.save(str(out), "JPEG", quality=95, subsampling=0)
src.unlink(missing_ok=True)
return out
except Exception:
logger.warning("JPEG conversion failed — falling back to PNG")
out = dst.with_suffix(".png")
src.rename(out)
return out
out = dst.with_suffix(".png")
src.rename(out)
return out
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],
*,
renderer: str | None = None,
render_engine: str | None = None,
samples: int | None = None,
width: int | None = None,
height: int | None = None,
transparent_bg: bool | None = None,
target_collection: str = "Product",
material_library_path: str | None = None,
material_map: dict[str, str] | None = None,
part_names_ordered: list[str] | None = None,
lighting_only: bool = False,
shadow_catcher: bool = False,
usd_path: Path | None = None,
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
) -> bool:
"""
Regenerate a thumbnail with per-part colours for an existing CAD file.
@@ -1170,13 +1268,18 @@ def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> b
with Session(db_engine) as session:
cad_file = session.get(CadFile, uuid.UUID(cad_file_id))
if not cad_file:
logger.error(f"CAD file not found: {cad_file_id}")
return False
message = f"CAD file not found: {cad_file_id}"
logger.warning(message)
raise MissingCadResourceError(message)
step_path = Path(cad_file.stored_path)
if not step_path.exists():
logger.error(f"STEP file not found: {step_path}")
return False
message = f"STEP file not found: {step_path}"
logger.warning(message)
cad_file.processing_status = ProcessingStatus.failed
cad_file.error_message = message[:2000]
session.commit()
raise MissingCadResourceError(message)
# Mark as processing so the activity page shows it as active
cad_file.processing_status = ProcessingStatus.processing
@@ -1184,7 +1287,26 @@ def regenerate_cad_thumbnail(cad_file_id: str, part_colors: dict[str, str]) -> b
try:
thumb_path, render_log = _generate_thumbnail(
step_path, cad_file_id, app_settings.upload_dir, part_colors=part_colors
step_path,
cad_file_id,
app_settings.upload_dir,
part_colors=part_colors,
renderer=renderer,
render_engine=render_engine,
samples=samples,
width=width,
height=height,
transparent_bg=transparent_bg,
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,
usd_path=usd_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
)
if thumb_path:
cad_file.thumbnail_path = str(thumb_path)
@@ -1207,6 +1329,7 @@ def render_to_file(
part_colors: dict[str, str] | None = None,
width: int | None = None,
height: int | None = None,
smooth_angle: int | None = None,
transparent_bg: bool = False,
engine: str | None = None,
samples: int | None = None,
@@ -1234,6 +1357,7 @@ def render_to_file(
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
material_override: str | None = None,
template_inputs: dict[str, Any] | None = None,
) -> tuple[bool, dict]:
"""Render a STEP file to a specific output path using current system settings.
@@ -1246,6 +1370,7 @@ def render_to_file(
part_colors: Optional {part_name: hex_color} map.
width: Optional render width (overrides system default).
height: Optional render height (overrides system default).
smooth_angle: Optional auto-smooth angle override in degrees.
transparent_bg: If True and renderer=blender+PNG, render with transparent background.
engine: Optional per-OT engine override ("cycles" | "eevee"), or None for system default.
samples: Optional per-OT samples override, or None for system default.
@@ -1262,7 +1387,7 @@ def render_to_file(
step = Path(step_path)
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out.parent)
settings = _get_all_settings()
renderer = settings["thumbnail_renderer"]
@@ -1284,19 +1409,20 @@ def render_to_file(
if renderer == "blender":
actual_engine = engine or settings["blender_engine"]
actual_samples = samples or int(settings[f"blender_{actual_engine}_samples"])
actual_samples = int(samples) if samples is not None else int(settings[f"blender_{actual_engine}_samples"])
actual_cycles_device = cycles_device or settings["cycles_device"]
actual_smooth_angle = smooth_angle if smooth_angle is not None else int(settings["blender_smooth_angle"])
w = width or 512
h = height or 512
render_log.update({
"engine": actual_engine, "samples": actual_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"smooth_angle": actual_smooth_angle,
"cycles_device": actual_cycles_device,
"width": w, "height": h,
})
extra = {
"engine": actual_engine, "samples": actual_samples,
"smooth_angle": int(settings["blender_smooth_angle"]),
"smooth_angle": actual_smooth_angle,
"cycles_device": actual_cycles_device,
"width": w, "height": h,
"transparent_bg": transparent_bg,
@@ -1314,6 +1440,9 @@ def render_to_file(
render_log["lighting_only"] = True
if shadow_catcher:
render_log["shadow_catcher"] = True
if template_inputs:
extra["template_inputs"] = template_inputs
render_log["template_inputs"] = template_inputs
if material_library_path and material_map:
extra["material_library_path"] = material_library_path
extra["material_map"] = material_map
@@ -1349,7 +1478,7 @@ def render_to_file(
output_path=tmp_png,
engine=actual_engine,
samples=actual_samples,
smooth_angle=int(settings["blender_smooth_angle"]),
smooth_angle=actual_smooth_angle,
cycles_device=actual_cycles_device,
width=w, height=h,
transparent_bg=transparent_bg,
@@ -1373,6 +1502,7 @@ def render_to_file(
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=material_override,
template_inputs=template_inputs,
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
@@ -1400,7 +1530,7 @@ def render_to_file(
def _convert_to_gltf(step_path: Path, cad_file_id: str, upload_dir: str) -> Path | None:
"""Convert STEP to glTF for browser 3D viewer."""
out_dir = Path(upload_dir) / "gltf"
out_dir.mkdir(parents=True, exist_ok=True)
ensure_group_writable_dir(out_dir)
out_path = out_dir / f"{cad_file_id}.gltf"
try:
+17 -3
View File
@@ -15,6 +15,7 @@ import logging
from sqlalchemy import create_engine, select, and_, exists
from sqlalchemy.orm import Session
from app.domains.materials.library_paths import resolve_asset_library_blend_path
from app.models.render_template import RenderTemplate
from app.models.system_setting import SystemSetting
from app.domains.rendering.models import render_template_output_types
@@ -121,14 +122,27 @@ def get_material_library_path_for_session(session: Session) -> str | None:
row = session.execute(
select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) # noqa: E712
).scalar_one_or_none()
if row and row.blend_file_path:
return row.blend_file_path
if row:
resolved_path = resolve_asset_library_blend_path(
blend_file_path=row.blend_file_path,
asset_library_id=row.id,
)
if resolved_path:
if row.blend_file_path and resolved_path != row.blend_file_path:
logger.warning(
"Active asset library %s points to missing file %s; using %s instead",
row.id,
row.blend_file_path,
resolved_path,
)
return resolved_path
row = session.execute(
select(SystemSetting).where(SystemSetting.key == "material_library_path")
).scalar_one_or_none()
if row and row.value and row.value.strip():
return row.value.strip()
resolved_path = resolve_asset_library_blend_path(blend_file_path=row.value.strip())
return resolved_path or row.value.strip()
return None