chore: snapshot workflow migration progress
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user