Files
HartOMat/backend/app/domains/rendering/workflow_runtime_services.py
T

1974 lines
73 KiB
Python

from __future__ import annotations
import logging
import re
import shutil
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Literal
from sqlalchemy import select, update as sql_update
from sqlalchemy.orm import Session, joinedload
from app.config import settings as app_settings
from app.core.render_paths import (
ensure_group_writable_dir,
resolve_result_path,
result_path_to_storage_key,
)
from app.domains.media.models import MediaAsset, MediaAssetType
from app.domains.orders.models import Order, OrderLine, OrderStatus
from app.domains.products.models import CadFile, Product
from app.domains.rendering.output_type_contracts import resolve_output_type_invocation_overrides
from app.domains.rendering.models import (
GlobalRenderPosition,
ProductRenderPosition,
RenderTemplate,
WorkflowRun,
)
from app.services.part_key_service import build_scene_manifest
from app.services.material_service import resolve_material_map
from app.services.step_processor import build_part_colors
from app.services.template_service import (
get_material_library_path_for_session,
resolve_template_for_session,
)
logger = logging.getLogger(__name__)
EmitFn = Callable[..., None] | None
SetupStatus = Literal["ready", "skip", "failed", "missing"]
QueueThumbnailFn = Callable[[str, dict[str, str]], None] | None
TEMPLATE_INPUT_PARAM_PREFIX = "template_input__"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_VOLATILE_PNG_CHUNK_TYPES = {b"tEXt", b"zTXt", b"iTXt", b"tIME"}
def _slugify_material_lookup_key(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "_", value).strip("_")
def _build_authoritative_material_lookup(materials_source: list[dict[str, Any]]) -> dict[str, str]:
lookup: dict[str, str] = {}
for material in materials_source:
raw_part_name = material.get("part_name")
raw_material_name = material.get("material")
if not raw_part_name or not raw_material_name:
continue
part_name = str(raw_part_name).lower().strip()
material_name = str(raw_material_name)
if not part_name:
continue
lookup.setdefault(part_name, material_name)
slug_key = _slugify_material_lookup_key(part_name)
if slug_key:
lookup.setdefault(slug_key, material_name)
stripped = re.sub(r"(_af\d+(_\d+)?)+$", "", part_name, flags=re.IGNORECASE)
if stripped != part_name:
lookup.setdefault(stripped, material_name)
slug_stripped = _slugify_material_lookup_key(stripped)
if slug_stripped:
lookup.setdefault(slug_stripped, material_name)
return lookup
def _common_prefix_length(left: str, right: str) -> int:
limit = min(len(left), len(right))
idx = 0
while idx < limit and left[idx] == right[idx]:
idx += 1
return idx
def _lookup_material_by_prefix(query: str, material_lookup: dict[str, str]) -> str | None:
if not query or not material_lookup:
return None
contenders: list[tuple[int, str]] = []
for key, material_name in material_lookup.items():
if len(key) >= 5 and len(query) >= 5 and (query.startswith(key) or key.startswith(query)):
contenders.append((len(key), material_name))
if not contenders:
return None
contenders.sort(reverse=True)
top_length = contenders[0][0]
close_materials = {
material_name
for key_length, material_name in contenders
if key_length >= top_length - 2
}
return contenders[0][1] if len(close_materials) == 1 else None
def _lookup_material_by_common_prefix(query: str, material_lookup: dict[str, str]) -> str | None:
if not query or not material_lookup:
return None
scored: list[tuple[float, int, int, str]] = []
for key, material_name in material_lookup.items():
prefix_length = _common_prefix_length(query, key)
if prefix_length < 12:
continue
ratio = prefix_length / max(len(query), len(key))
if ratio < 0.68:
continue
scored.append((ratio, prefix_length, len(key), material_name))
if not scored:
return None
scored.sort(reverse=True)
top_ratio, top_prefix_length, _, top_material_name = scored[0]
close_materials = {
material_name
for ratio, prefix_length, _, material_name in scored
if ratio >= top_ratio - 0.02 and prefix_length >= top_prefix_length - 2
}
return top_material_name if len(close_materials) == 1 else None
def _resolve_authoritative_material_name(
raw_name: str | None,
material_lookup: dict[str, str],
*fallback_names: str | None,
) -> str | None:
candidates = [raw_name, *fallback_names]
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
normalized = str(candidate).lower().strip()
variants = [normalized]
stripped = re.sub(r"(_af\d+(_\d+)?)+$", "", normalized, flags=re.IGNORECASE)
if stripped != normalized:
variants.append(stripped)
no_instance = re.sub(r"_\d+$", "", stripped)
if no_instance and no_instance not in variants:
variants.append(no_instance)
for variant in list(variants):
slug_variant = _slugify_material_lookup_key(variant)
if slug_variant and slug_variant not in variants:
variants.append(slug_variant)
deduped_variants = [variant for variant in variants if variant and not (variant in seen or seen.add(variant))]
for variant in deduped_variants:
material_name = material_lookup.get(variant)
if material_name:
return material_name
for variant in deduped_variants:
material_name = _lookup_material_by_prefix(variant, material_lookup)
if material_name:
return material_name
for variant in deduped_variants:
material_name = _lookup_material_by_common_prefix(variant, material_lookup)
if material_name:
return material_name
return None
def _utcnow_naive() -> datetime:
"""Return UTC as a naive datetime for legacy TIMESTAMP WITHOUT TIME ZONE columns."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def extract_template_input_overrides(params: dict[str, Any] | None) -> dict[str, Any]:
if not params:
return {}
overrides: dict[str, Any] = {}
for key, value in params.items():
if not isinstance(key, str) or not key.startswith(TEMPLATE_INPUT_PARAM_PREFIX):
continue
input_key = key[len(TEMPLATE_INPUT_PARAM_PREFIX):].strip()
if input_key:
overrides[input_key] = value
return overrides
def _normalize_template_input_schema(template: RenderTemplate | None) -> list[dict[str, Any]]:
raw_schema = getattr(template, "workflow_input_schema", None) if template is not None else None
if not isinstance(raw_schema, list):
return []
normalized: list[dict[str, Any]] = []
for raw_field in raw_schema:
if not isinstance(raw_field, dict):
continue
key = str(raw_field.get("key") or "").strip()
if not key:
continue
normalized.append(dict(raw_field))
return normalized
def _resolve_template_input_values(
schema: list[dict[str, Any]],
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
raw_overrides = overrides or {}
resolved: dict[str, Any] = {}
for field in schema:
key = str(field.get("key") or "").strip()
if not key:
continue
if key in raw_overrides:
resolved[key] = raw_overrides[key]
continue
if "default" in field:
resolved[key] = field.get("default")
return resolved
@dataclass(slots=True)
class OrderLineRenderSetupResult:
status: SetupStatus
order_line: OrderLine | None = None
order: Order | None = None
cad_file: CadFile | None = None
materials_source: list[dict[str, Any]] = field(default_factory=list)
usd_render_path: Path | None = None
glb_reuse_path: Path | None = None
part_colors: dict[str, str] = field(default_factory=dict)
render_start: datetime | None = None
reason: str | None = None
@property
def is_ready(self) -> bool:
return self.status == "ready" and self.order_line is not None and self.cad_file is not None
@dataclass(slots=True)
class RenderPositionContext:
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
source_name: str | None = None
source_kind: Literal["product", "global", "default"] = "default"
@dataclass(slots=True)
class TemplateResolutionResult:
template: RenderTemplate | None
material_library: str | None
material_map: dict[str, str] | None
use_materials: bool
override_material: str | None
target_collection: str
lighting_only: bool
shadow_catcher: bool
camera_orbit: bool
category_key: str | None
output_type_id: str | None
workflow_input_schema: list[dict[str, Any]] = field(default_factory=list)
template_inputs: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class MaterialResolutionResult:
material_map: dict[str, str] | None
use_materials: bool
override_material: str | None
source_material_count: int = 0
resolved_material_count: int = 0
@dataclass(slots=True)
class AutoPopulateMaterialsResult:
cad_file_id: str
updated_product_ids: list[str] = field(default_factory=list)
queued_thumbnail_regeneration: bool = False
part_colors: dict[str, str] | None = None
cad_parts: list[str] = field(default_factory=list)
@dataclass(slots=True)
class BBoxResolutionResult:
bbox_data: dict[str, dict[str, float]] | None
source_kind: Literal["glb", "step", "none"]
step_path: str
glb_path: str | None = None
@property
def has_bbox(self) -> bool:
return self.bbox_data is not None
@dataclass(slots=True)
class OutputSaveResult:
status: Literal["completed", "failed"]
result_path: str | None
asset_id: str | None = None
storage_key: str | None = None
asset_type: MediaAssetType | None = None
@dataclass(slots=True)
class OrderLineRenderInvocation:
product_name: str
output_type_name: str
output_extension: str
output_filename: str
output_path: str
is_animation: bool
is_cinematic: bool
width: int | None = None
height: int | None = None
engine: str | None = None
samples: int | None = None
frame_count: int = 24
fps: int = 25
bg_color: str = ""
turntable_axis: str = "world_z"
noise_threshold: str = ""
denoiser: str = ""
denoising_input_passes: str = ""
denoising_prefilter: str = ""
denoising_quality: str = ""
denoising_use_gpu: str = ""
transparent_bg: bool = False
cycles_device: str = "auto"
part_colors: dict[str, str] = field(default_factory=dict)
part_names_ordered: list[str] | None = None
template_path: str | None = None
target_collection: str = "Product"
material_library_path: str | None = None
material_map: dict[str, str] | None = None
lighting_only: bool = False
shadow_catcher: bool = False
camera_orbit: bool = True
rotation_x: float = 0.0
rotation_y: float = 0.0
rotation_z: float = 0.0
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
usd_path: str | None = None
material_override: str | None = None
template_inputs: dict[str, Any] = field(default_factory=dict)
def task_defaults(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"transparent_bg": self.transparent_bg,
"cycles_device": self.cycles_device,
"part_colors": self.part_colors,
"target_collection": self.target_collection,
"lighting_only": self.lighting_only,
"shadow_catcher": self.shadow_catcher,
"camera_orbit": self.camera_orbit,
"rotation_x": self.rotation_x,
"rotation_y": self.rotation_y,
"rotation_z": self.rotation_z,
"frame_count": self.frame_count,
"fps": self.fps,
"bg_color": self.bg_color,
"turntable_axis": self.turntable_axis,
"noise_threshold": self.noise_threshold,
"denoiser": self.denoiser,
"denoising_input_passes": self.denoising_input_passes,
"denoising_prefilter": self.denoising_prefilter,
"denoising_quality": self.denoising_quality,
"denoising_use_gpu": self.denoising_use_gpu,
}
optional_values = {
"width": self.width,
"height": self.height,
"engine": self.engine,
"samples": self.samples,
"template_path": self.template_path,
"material_library_path": self.material_library_path,
"material_map": self.material_map,
"part_names_ordered": self.part_names_ordered,
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"usd_path": self.usd_path,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
for key, value in optional_values.items():
if value not in (None, "", {}, [], ()):
payload[key] = value
return payload
def as_still_renderer_kwargs(
self,
*,
step_path: str,
output_path: str,
job_id: str | None = None,
order_line_id: str | None = None,
) -> dict[str, Any]:
return {
"step_path": step_path,
"output_path": output_path,
"part_colors": self.part_colors or None,
"width": self.width,
"height": self.height,
"transparent_bg": self.transparent_bg,
"engine": self.engine,
"samples": self.samples,
"template_path": self.template_path,
"target_collection": self.target_collection,
"material_library_path": self.material_library_path,
"material_map": self.material_map,
"part_names_ordered": self.part_names_ordered,
"lighting_only": self.lighting_only,
"shadow_catcher": self.shadow_catcher,
"cycles_device": self.cycles_device,
"rotation_x": self.rotation_x,
"rotation_y": self.rotation_y,
"rotation_z": self.rotation_z,
"job_id": job_id,
"noise_threshold": self.noise_threshold,
"denoiser": self.denoiser,
"denoising_input_passes": self.denoising_input_passes,
"denoising_prefilter": self.denoising_prefilter,
"denoising_quality": self.denoising_quality,
"denoising_use_gpu": self.denoising_use_gpu,
"order_line_id": order_line_id,
"usd_path": self.usd_path,
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
def as_turntable_renderer_kwargs(
self,
*,
step_path: Path,
output_path: Path,
smooth_angle: int,
default_width: int,
default_height: int,
default_engine: str,
default_samples: int,
) -> dict[str, Any]:
return {
"step_path": step_path,
"output_path": output_path,
"frame_count": self.frame_count,
"fps": self.fps,
"width": self.width or default_width,
"height": self.height or default_height,
"engine": self.engine or default_engine,
"samples": self.samples or default_samples,
"smooth_angle": smooth_angle,
"cycles_device": self.cycles_device,
"transparent_bg": self.transparent_bg,
"bg_color": self.bg_color,
"turntable_axis": self.turntable_axis,
"part_colors": self.part_colors or None,
"template_path": self.template_path,
"target_collection": self.target_collection,
"material_library_path": self.material_library_path,
"material_map": self.material_map,
"part_names_ordered": self.part_names_ordered,
"lighting_only": self.lighting_only,
"shadow_catcher": self.shadow_catcher,
"rotation_x": self.rotation_x,
"rotation_y": self.rotation_y,
"rotation_z": self.rotation_z,
"camera_orbit": self.camera_orbit,
"usd_path": self.usd_path,
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
}
def as_cinematic_renderer_kwargs(
self,
*,
step_path: Path,
output_path: Path,
smooth_angle: int,
default_width: int,
default_height: int,
default_engine: str,
default_samples: int,
log_callback: Callable[[str], None] | None = None,
) -> dict[str, Any]:
return {
"step_path": step_path,
"output_path": output_path,
"width": self.width or default_width,
"height": self.height or default_height,
"engine": self.engine or default_engine,
"samples": self.samples or default_samples,
"smooth_angle": smooth_angle,
"cycles_device": self.cycles_device,
"transparent_bg": self.transparent_bg,
"part_colors": self.part_colors or None,
"template_path": self.template_path,
"target_collection": self.target_collection,
"material_library_path": self.material_library_path,
"material_map": self.material_map,
"part_names_ordered": self.part_names_ordered,
"lighting_only": self.lighting_only,
"shadow_catcher": self.shadow_catcher,
"rotation_x": self.rotation_x,
"rotation_y": self.rotation_y,
"rotation_z": self.rotation_z,
"usd_path": self.usd_path,
"focal_length_mm": self.focal_length_mm,
"sensor_width_mm": self.sensor_width_mm,
"material_override": self.material_override,
"template_inputs": self.template_inputs,
"log_callback": log_callback,
}
def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = None) -> None:
if emit is None:
return
if level is None:
emit(order_line_id, message)
else:
emit(order_line_id, message, level)
def _resolve_asset_path(storage_key: str | None) -> Path | None:
return resolve_result_path(storage_key)
def _usd_master_file_refresh_reason(usd_render_path: Path | None) -> str | None:
if usd_render_path is None:
return "missing USD master file"
if not usd_render_path.exists():
return "missing USD master file"
try:
usd_bytes = usd_render_path.read_bytes()
except OSError:
logger.exception("render_order_line: failed to inspect usd_master %s", usd_render_path)
return "unreadable USD master file"
usd_bytes_lower = usd_bytes.lower()
if b"schaeffler:" in usd_bytes_lower:
return "legacy Schaeffler USD primvars"
if b"hartomat:" in usd_bytes_lower:
return None
# Binary USD (`PXR-USDC`) stores HartOMat customData in a form that is not
# reliably discoverable via a raw byte grep. For those files we rely on the
# cache fingerprint plus the upstream resolved material metadata checks.
if usd_bytes.startswith(b"PXR-USDC") or b"\x00" in usd_bytes[:256]:
return None
# Textual USD payloads without any HartOMat markers are legacy/stale in the
# current pipeline and should be refreshed before they are reused.
try:
usd_bytes.decode("utf-8")
except UnicodeDecodeError:
return None
return "missing HartOMat USD markers"
def _usd_master_cache_refresh_reason(usd_asset: MediaAsset | None) -> str | None:
if usd_asset is None:
return None
render_config = usd_asset.render_config if isinstance(usd_asset.render_config, dict) else {}
cache_key = render_config.get("cache_key")
if not isinstance(cache_key, str) or not cache_key.strip():
return "missing USD cache fingerprint"
# New-format keys append the render-script fingerprint as a sixth colon-delimited segment.
if len(cache_key.split(":")) < 6:
return "legacy USD cache fingerprint"
return None
def _usd_master_refresh_reason(
cad_file: CadFile,
*,
usd_asset: MediaAsset | None = None,
usd_render_path: Path | None = None,
) -> str | None:
resolved = cad_file.resolved_material_assignments
if not isinstance(resolved, dict) or not resolved:
return "missing resolved material assignments"
canonical_materials: list[str] = []
for meta in resolved.values():
if not isinstance(meta, dict):
continue
canonical = meta.get("canonical_material") or meta.get("material")
if isinstance(canonical, str) and canonical.strip():
canonical_materials.append(canonical.strip())
if not canonical_materials:
return "missing canonical material metadata"
if any(material.upper().startswith("SCHAEFFLER_") for material in canonical_materials):
return "legacy Schaeffler material metadata"
cache_reason = _usd_master_cache_refresh_reason(usd_asset)
if cache_reason is not None:
return cache_reason
file_reason = _usd_master_file_refresh_reason(usd_render_path)
if file_reason is not None:
return file_reason
return None
def _queue_usd_master_refresh(cad_file_id: str) -> bool:
try:
from app.tasks.step_tasks import generate_usd_master_task
generate_usd_master_task.delay(cad_file_id)
return True
except Exception:
logger.exception("render_order_line: failed to queue usd_master refresh for cad %s", cad_file_id)
return False
def extract_bbox_from_glb(glb_path: str) -> dict[str, dict[str, float]] | None:
"""Extract a bounding box from a GLB file in meters and convert to mm."""
try:
import trimesh
path = Path(glb_path)
if not path.exists():
return None
scene = trimesh.load(str(path), force="scene")
bounds = getattr(scene, "bounds", None)
if bounds is None:
return None
mins, maxs = bounds
dims = maxs - mins
return {
"dimensions_mm": {
"x": round(float(dims[0]) * 1000, 2),
"y": round(float(dims[1]) * 1000, 2),
"z": round(float(dims[2]) * 1000, 2),
},
"bbox_center_mm": {
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
},
}
except Exception as exc:
logger.debug("extract_bbox_from_glb failed for %s: %s", glb_path, exc)
return None
def extract_bbox_from_step_cadquery(step_path: str) -> dict[str, dict[str, float]] | None:
"""Fallback: extract a bounding box by re-parsing the STEP file via cadquery."""
try:
import cadquery as cq
bb = cq.importers.importStep(step_path).val().BoundingBox()
return {
"dimensions_mm": {
"x": round(bb.xlen, 2),
"y": round(bb.ylen, 2),
"z": round(bb.zlen, 2),
},
"bbox_center_mm": {
"x": round((bb.xmin + bb.xmax) / 2, 2),
"y": round((bb.ymin + bb.ymax) / 2, 2),
"z": round((bb.zmin + bb.zmax) / 2, 2),
},
}
except Exception as exc:
logger.debug("extract_bbox_from_step_cadquery failed for %s: %s", step_path, exc)
return None
def resolve_cad_bbox(
step_path: str,
*,
glb_path: str | None = None,
) -> BBoxResolutionResult:
"""Resolve CAD bounding-box data with the legacy GLB-first fallback order."""
bbox_data = None
source_kind: Literal["glb", "step", "none"] = "none"
if glb_path:
bbox_data = extract_bbox_from_glb(glb_path)
if bbox_data:
source_kind = "glb"
if bbox_data is None:
bbox_data = extract_bbox_from_step_cadquery(step_path)
if bbox_data:
source_kind = "step"
return BBoxResolutionResult(
bbox_data=bbox_data,
source_kind=source_kind,
step_path=step_path,
glb_path=glb_path,
)
def _normalize_storage_key(output_path: str) -> str:
return result_path_to_storage_key(output_path) or output_path
def _resolve_output_asset_type(output_path: str) -> MediaAssetType:
extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin"
if extension == "blend":
return MediaAssetType.blend_production
if extension in ("mp4", "webm"):
return MediaAssetType.turntable
return MediaAssetType.still
def _resolve_output_mime_type(output_path: str) -> str:
extension = output_path.rsplit(".", 1)[-1].lower() if "." in output_path else "bin"
if extension == "blend":
return "application/x-blender"
if extension in ("mp4", "webm"):
return "video/mp4"
if extension == "webp":
return "image/webp"
if extension in ("jpg", "jpeg"):
return "image/jpeg"
return "image/png"
def _sanitize_public_output_name(value: str) -> str:
sanitized = re.sub(r"[^\w\-.]", "_", value.strip())
return sanitized[:100] or "output"
def _coerce_int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _resolve_tristate_mode(
value: Any,
*,
field_name: str,
fallback: bool | None = None,
) -> bool | None:
if value in (None, "", "inherit"):
return fallback
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"enabled", "true", "1", "yes", "on"}:
return True
if normalized in {"disabled", "false", "0", "no", "off"}:
return False
raise ValueError(
f"{field_name} must be one of: inherit, enabled, disabled"
)
def _resolve_render_output_extension(line: OrderLine) -> str:
output_type = line.output_type
output_extension = "jpg"
if output_type is not None and output_type.output_format:
fmt = str(output_type.output_format).lower()
if fmt == "mp4":
output_extension = "mp4"
elif fmt == "webp":
output_extension = "webp"
elif fmt in {"png", "jpg", "jpeg"}:
output_extension = "png" if fmt == "png" else "jpg"
render_overrides = getattr(line, "render_overrides", None)
if isinstance(render_overrides, dict) and render_overrides.get("output_format") not in (None, ""):
override = str(render_overrides["output_format"]).lower()
if override == "mp4":
return "mp4"
if override == "webp":
return "webp"
if override in {"png", "jpg", "jpeg"}:
return "png" if override == "png" else "jpg"
return output_extension
def _scale_render_samples_for_resolution(
samples: int | None,
width: int | None,
height: int | None,
) -> int | None:
if samples is None or width is None or height is None:
return samples
max_dim = max(width, height)
if max_dim > 1024:
return samples
scaled = max(32, int(samples * max_dim / 2048))
return scaled if scaled < samples else samples
def build_order_line_render_invocation(
setup: OrderLineRenderSetupResult,
*,
template_context: TemplateResolutionResult | None = None,
position_context: RenderPositionContext | None = None,
material_context: MaterialResolutionResult | None = None,
artifact_kind_override: str | None = None,
emit: EmitFn = None,
) -> OrderLineRenderInvocation:
if not setup.is_ready or setup.order_line is None or setup.cad_file is None:
raise ValueError("build_order_line_render_invocation requires a ready order-line setup")
line = setup.order_line
cad_file = setup.cad_file
output_type = line.output_type
position = position_context or RenderPositionContext()
render_settings = (
resolve_output_type_invocation_overrides(
output_type.render_settings,
getattr(output_type, "invocation_overrides", None),
artifact_kind=artifact_kind_override or output_type.artifact_kind,
is_animation=output_type.is_animation,
)
if output_type is not None
else {}
)
width = _coerce_int(render_settings.get("width"))
height = _coerce_int(render_settings.get("height"))
samples = _coerce_int(render_settings.get("samples"))
frame_count = _coerce_int(render_settings.get("frame_count")) or 24
fps = _coerce_int(render_settings.get("fps")) or 25
engine = render_settings.get("engine")
bg_color = str(render_settings.get("bg_color", ""))
turntable_axis = str(render_settings.get("turntable_axis", "world_z"))
noise_threshold = str(render_settings.get("noise_threshold", ""))
denoiser = str(render_settings.get("denoiser", ""))
denoising_input_passes = str(render_settings.get("denoising_input_passes", ""))
denoising_prefilter = str(render_settings.get("denoising_prefilter", ""))
denoising_quality = str(render_settings.get("denoising_quality", ""))
denoising_use_gpu = str(render_settings.get("denoising_use_gpu", ""))
transparent_bg = bool(output_type and output_type.transparent_bg)
cycles_device = (output_type.cycles_device or "gpu") if output_type is not None else "gpu"
render_overrides = getattr(line, "render_overrides", None)
if isinstance(render_overrides, dict):
width = _coerce_int(render_overrides.get("width")) or width
height = _coerce_int(render_overrides.get("height")) or height
samples = _coerce_int(render_overrides.get("samples")) or samples
frame_count = _coerce_int(render_overrides.get("frame_count")) or frame_count
fps = _coerce_int(render_overrides.get("fps")) or fps
engine = render_overrides.get("engine") or engine
if render_overrides.get("bg_color") not in (None, ""):
bg_color = str(render_overrides["bg_color"])
if render_overrides.get("turntable_axis") not in (None, ""):
turntable_axis = str(render_overrides["turntable_axis"])
if render_overrides.get("noise_threshold") not in (None, ""):
noise_threshold = str(render_overrides["noise_threshold"])
if render_overrides.get("denoiser") not in (None, ""):
denoiser = str(render_overrides["denoiser"])
if render_overrides.get("denoising_input_passes") not in (None, ""):
denoising_input_passes = str(render_overrides["denoising_input_passes"])
if render_overrides.get("denoising_prefilter") not in (None, ""):
denoising_prefilter = str(render_overrides["denoising_prefilter"])
if render_overrides.get("denoising_quality") not in (None, ""):
denoising_quality = str(render_overrides["denoising_quality"])
if render_overrides.get("denoising_use_gpu") not in (None, ""):
denoising_use_gpu = str(render_overrides["denoising_use_gpu"])
if "transparent_bg" in render_overrides:
transparent_bg = _coerce_bool(render_overrides["transparent_bg"])
if render_overrides.get("cycles_device") not in (None, ""):
cycles_device = str(render_overrides["cycles_device"])
_emit(emit, str(line.id), f"Render overrides active: {render_overrides}")
scaled_samples = _scale_render_samples_for_resolution(samples, width, height)
if (
samples is not None
and scaled_samples is not None
and scaled_samples < samples
and width is not None
and height is not None
):
_emit(
emit,
str(line.id),
f"Auto-scaled samples {samples} -> {scaled_samples} for {width}x{height}",
)
samples = scaled_samples
part_names_ordered = None
if cad_file.parsed_objects:
part_names = cad_file.parsed_objects.get("objects", [])
part_names_ordered = part_names or None
product_name = line.product.name or line.product.pim_id or "product"
output_type_name = output_type.name if output_type is not None else "render"
output_extension = _resolve_render_output_extension(line)
output_filename = (
f"{_sanitize_public_output_name(product_name)}_"
f"{_sanitize_public_output_name(output_type_name)}.{output_extension}"
)
output_dir = Path(app_settings.upload_dir) / "renders" / str(line.id)
material_map = None
use_materials = False
material_override = None
if template_context is not None:
material_map = template_context.material_map
use_materials = template_context.use_materials
material_override = template_context.override_material
if material_context is not None:
material_map = material_context.material_map
use_materials = material_context.use_materials
material_override = material_context.override_material
return OrderLineRenderInvocation(
product_name=product_name,
output_type_name=output_type_name,
output_extension=output_extension,
output_filename=output_filename,
output_path=str(output_dir / output_filename),
is_animation=bool(output_type and output_type.is_animation),
is_cinematic=bool(output_type and render_settings.get("cinematic")),
width=width,
height=height,
engine=str(engine) if engine not in (None, "") else None,
samples=samples,
frame_count=frame_count,
fps=fps,
bg_color=bg_color,
turntable_axis=turntable_axis,
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,
transparent_bg=transparent_bg,
cycles_device=cycles_device,
part_colors=dict(setup.part_colors or {}),
part_names_ordered=part_names_ordered,
template_path=template_context.template.blend_file_path if template_context and template_context.template else None,
target_collection=template_context.target_collection if template_context else "Product",
material_library_path=(
template_context.material_library if template_context and use_materials else None
),
material_map=material_map,
lighting_only=template_context.lighting_only if template_context else False,
shadow_catcher=template_context.shadow_catcher if template_context else False,
camera_orbit=template_context.camera_orbit if template_context else True,
rotation_x=position.rotation_x,
rotation_y=position.rotation_y,
rotation_z=position.rotation_z,
focal_length_mm=position.focal_length_mm,
sensor_width_mm=position.sensor_width_mm,
usd_path=str(setup.usd_render_path) if setup.usd_render_path is not None else None,
material_override=material_override,
template_inputs=dict(template_context.template_inputs) if template_context is not None else {},
)
def _canonical_public_output_path(line: OrderLine, output_path: str) -> str:
source_path = Path(output_path)
upload_root = Path(app_settings.upload_dir)
try:
source_path.relative_to(upload_root / "renders")
return str(source_path)
except ValueError:
pass
extension = source_path.suffix or ".bin"
product_name = None
if line.product is not None:
product_name = getattr(line.product, "name", None) or getattr(line.product, "pim_id", None)
output_type_name = getattr(line.output_type, "name", None) if line.output_type is not None else None
filename = f"{_sanitize_public_output_name(product_name or 'product')}_{_sanitize_public_output_name(output_type_name or 'render')}{extension}"
return str(upload_root / "renders" / str(line.id) / filename)
def _strip_volatile_png_metadata(output_path: Path) -> None:
if output_path.suffix.lower() != ".png" or not output_path.is_file():
return
raw_bytes = output_path.read_bytes()
if not raw_bytes.startswith(_PNG_SIGNATURE):
return
cursor = len(_PNG_SIGNATURE)
kept_chunks: list[bytes] = []
changed = False
while cursor + 12 <= len(raw_bytes):
chunk_length = int.from_bytes(raw_bytes[cursor : cursor + 4], "big")
chunk_end = cursor + 12 + chunk_length
if chunk_end > len(raw_bytes):
return
chunk_type = raw_bytes[cursor + 4 : cursor + 8]
chunk_bytes = raw_bytes[cursor:chunk_end]
if chunk_type in _VOLATILE_PNG_CHUNK_TYPES:
changed = True
else:
kept_chunks.append(chunk_bytes)
cursor = chunk_end
if chunk_type == b"IEND":
break
if not changed:
return
output_path.write_bytes(_PNG_SIGNATURE + b"".join(kept_chunks))
def _normalize_output_artifact(output_path: str) -> None:
_strip_volatile_png_metadata(Path(output_path))
def _materialize_public_output(line: OrderLine, output_path: str) -> str:
canonical_path = Path(_canonical_public_output_path(line, output_path))
source_path = Path(output_path)
ensure_group_writable_dir(canonical_path.parent)
if source_path != canonical_path:
shutil.copy2(source_path, canonical_path)
return str(canonical_path)
def _resolve_existing_workflow_run_id(session: Session, workflow_run_id: str | None) -> uuid.UUID | None:
if workflow_run_id in (None, ""):
return None
try:
candidate = uuid.UUID(str(workflow_run_id))
except (TypeError, ValueError):
return None
existing = session.get(WorkflowRun, candidate)
return existing.id if existing is not None else None
def persist_order_line_media_asset(
session: Session,
line: OrderLine,
*,
success: bool,
output_path: str,
asset_type: MediaAssetType,
render_log: dict[str, Any] | None = None,
workflow_run_id: str | None = None,
) -> OutputSaveResult:
"""Persist a non-primary workflow artifact as a MediaAsset without mutating order-line result fields."""
status: Literal["completed", "failed"] = "completed" if success else "failed"
asset_id: str | None = None
storage_key: str | None = None
resolved_workflow_run_id = _resolve_existing_workflow_run_id(session, workflow_run_id)
if success:
_normalize_output_artifact(output_path)
storage_key = _normalize_storage_key(output_path)
output_file = Path(output_path)
existing_asset = session.execute(
select(MediaAsset).where(MediaAsset.storage_key == storage_key).limit(1)
).scalar_one_or_none()
if existing_asset is None:
asset = MediaAsset(
tenant_id=line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None,
product_id=line.product_id,
cad_file_id=line.product.cad_file_id if line.product is not None else None,
order_line_id=line.id,
workflow_run_id=resolved_workflow_run_id,
asset_type=asset_type,
storage_key=storage_key,
mime_type=_resolve_output_mime_type(output_path),
file_size_bytes=output_file.stat().st_size if output_file.exists() else None,
render_config=render_log if isinstance(render_log, dict) else None,
)
session.add(asset)
session.flush()
asset_id = str(asset.id)
else:
existing_asset.asset_type = asset_type
existing_asset.order_line_id = line.id
existing_asset.product_id = line.product_id
existing_asset.cad_file_id = line.product.cad_file_id if line.product is not None else None
existing_asset.mime_type = _resolve_output_mime_type(output_path)
existing_asset.file_size_bytes = output_file.stat().st_size if output_file.exists() else None
if isinstance(render_log, dict):
existing_asset.render_config = render_log
if resolved_workflow_run_id is not None:
existing_asset.workflow_run_id = resolved_workflow_run_id
session.flush()
asset_id = str(existing_asset.id)
session.commit()
return OutputSaveResult(
status=status,
result_path=output_path if success else None,
asset_id=asset_id,
storage_key=storage_key,
asset_type=asset_type if success else None,
)
def _extract_render_error(render_log: dict[str, Any] | None) -> str | None:
if not isinstance(render_log, dict):
return None
error_value = render_log.get("error") or render_log.get("stderr", "")
if not error_value:
return None
return str(error_value)[:300]
def emit_order_line_render_notifications(
*,
success: bool,
order_line_id: str,
order_id: str | None = None,
order_number: str | None = None,
order_creator_id: str | None = None,
tenant_id: str | None = None,
product_name: str,
output_type_name: str,
render_log: dict[str, Any] | None = None,
session: Session | None = None,
line: OrderLine | None = None,
emit_websocket: bool = True,
emit_activity: bool = True,
activity_entity_id: str | None = None,
) -> None:
"""Emit the legacy websocket and activity notifications for an order-line render."""
resolved_order_id = order_id or (str(line.order_id) if line is not None else None)
resolved_entity_id = activity_entity_id if activity_entity_id is not None else resolved_order_id
if session is not None and resolved_order_id and (order_creator_id is None or order_number is None):
order_row = session.execute(
select(Order.created_by, Order.order_number).where(Order.id == resolved_order_id)
).one_or_none()
if order_row:
if order_creator_id is None:
order_creator_id = str(order_row[0])
if order_number is None:
order_number = order_row[1]
if emit_websocket and tenant_id:
try:
from app.core.websocket import publish_event_sync
publish_event_sync(
tenant_id,
{
"type": "render_complete" if success else "render_failed",
"order_line_id": order_line_id,
"order_id": resolved_order_id,
"status": "completed" if success else "failed",
},
)
except Exception:
logger.debug("WebSocket publish skipped (non-fatal)")
if not emit_activity or not order_creator_id:
return
try:
from app.services.notification_service import CHANNEL_ACTIVITY, emit_notification_sync
details: dict[str, Any] = {
"order_number": order_number,
"product_name": product_name,
"output_type": output_type_name,
}
error_message = _extract_render_error(render_log)
if not success and error_message:
details["error"] = error_message
emit_notification_sync(
actor_user_id=None,
target_user_id=order_creator_id,
action="render.completed" if success else "render.failed",
entity_type="order",
entity_id=resolved_entity_id,
details=details,
channel=CHANNEL_ACTIVITY,
)
except Exception:
logger.exception("Failed to emit render activity event")
def persist_order_line_output(
session: Session,
line: OrderLine,
*,
success: bool,
output_path: str,
render_log: dict[str, Any] | None,
render_completed_at: datetime | None = None,
workflow_run_id: str | None = None,
) -> OutputSaveResult:
"""Persist the render result for an order line and publish the media asset if needed."""
status: Literal["completed", "failed"] = "completed" if success else "failed"
completed_at = render_completed_at or _utcnow_naive()
persisted_output_path = output_path
line.render_status = status
line.render_completed_at = completed_at
line.render_log = render_log
if success:
_normalize_output_artifact(output_path)
persisted_output_path = _materialize_public_output(line, output_path)
line.result_path = persisted_output_path if success else None
session.flush()
asset_id: str | None = None
storage_key: str | None = None
asset_type: MediaAssetType | None = None
resolved_workflow_run_id = _resolve_existing_workflow_run_id(session, workflow_run_id)
if success:
storage_key = _normalize_storage_key(persisted_output_path)
asset_type = _resolve_output_asset_type(persisted_output_path)
output_file = Path(persisted_output_path)
existing_asset = session.execute(
select(MediaAsset).where(MediaAsset.storage_key == storage_key).limit(1)
).scalar_one_or_none()
if existing_asset is None:
existing_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.order_line_id == line.id,
MediaAsset.asset_type == asset_type,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
if existing_asset is None:
render_config = None
if isinstance(render_log, dict):
render_config = {
key: render_log[key]
for key in (
"renderer",
"engine_used",
"engine",
"samples",
"device_used",
"compute_type",
"total_duration_s",
)
if key in render_log
}
asset = MediaAsset(
tenant_id=line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None,
order_line_id=line.id,
product_id=line.product_id,
workflow_run_id=resolved_workflow_run_id,
asset_type=asset_type,
storage_key=storage_key,
mime_type=_resolve_output_mime_type(persisted_output_path),
file_size_bytes=output_file.stat().st_size if output_file.exists() else None,
width=None,
height=None,
render_config=render_config,
)
session.add(asset)
session.flush()
asset_id = str(asset.id)
else:
existing_asset.order_line_id = line.id
existing_asset.product_id = line.product_id
existing_asset.asset_type = asset_type
existing_asset.storage_key = storage_key
existing_asset.mime_type = _resolve_output_mime_type(persisted_output_path)
existing_asset.file_size_bytes = output_file.stat().st_size if output_file.exists() else None
if line.product is not None:
existing_asset.cad_file_id = line.product.cad_file_id
if isinstance(render_log, dict):
existing_asset.render_config = {
key: render_log[key]
for key in (
"renderer",
"engine_used",
"engine",
"samples",
"device_used",
"compute_type",
"total_duration_s",
)
if key in render_log
}
if resolved_workflow_run_id is not None:
existing_asset.workflow_run_id = resolved_workflow_run_id
session.flush()
asset_id = str(existing_asset.id)
session.commit()
if line.order_id is not None:
try:
from app.domains.orders.service import check_order_completion
check_order_completion(str(line.order_id))
except Exception:
logger.exception("Failed to check order completion for order_line %s", line.id)
return OutputSaveResult(
status=status,
result_path=line.result_path,
asset_id=asset_id,
storage_key=storage_key,
asset_type=asset_type,
)
def prepare_order_line_render_context(
session: Session,
order_line_id: str,
*,
emit: EmitFn = None,
persist_state: bool = True,
) -> OrderLineRenderSetupResult:
"""Load and validate the order line, then prepare reusable render inputs."""
_emit(emit, order_line_id, "Loading order line from database")
line = session.execute(
select(OrderLine)
.where(OrderLine.id == order_line_id)
.options(
joinedload(OrderLine.product).joinedload(Product.cad_file),
joinedload(OrderLine.output_type),
)
).scalar_one_or_none()
if line is None:
_emit(emit, order_line_id, "Order line not found in database", "error")
logger.error("OrderLine %s not found", order_line_id)
return OrderLineRenderSetupResult(status="missing", reason="order_line_not_found")
if line.render_status == "cancelled":
_emit(emit, order_line_id, "Order line already cancelled — skipping render")
logger.info("OrderLine %s cancelled — skipping", order_line_id)
return OrderLineRenderSetupResult(
status="skip",
order_line=line,
reason="line_cancelled",
)
order = session.execute(
select(Order).where(Order.id == line.order_id)
).scalar_one_or_none()
if order and order.status in (OrderStatus.rejected, OrderStatus.completed):
_emit(emit, order_line_id, f"Order {order.status.value} — skipping render")
logger.info("OrderLine %s: order %s — skipping", order_line_id, order.status.value)
if persist_state and line.render_status in ("pending", "processing"):
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="cancelled")
)
session.commit()
return OrderLineRenderSetupResult(
status="skip",
order_line=line,
order=order,
reason="order_closed",
)
if line.product is None or line.product.cad_file_id is None or line.product.cad_file is None:
_emit(emit, order_line_id, "Product has no CAD file — marking as failed", "error")
logger.warning("OrderLine %s: product has no CAD file", order_line_id)
if persist_state:
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="failed")
)
session.commit()
return OrderLineRenderSetupResult(
status="failed",
order_line=line,
order=order,
reason="missing_cad_file",
)
render_start = _utcnow_naive() if persist_state else None
if persist_state:
session.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(
render_status="processing",
render_backend_used="celery",
render_started_at=render_start,
)
)
session.commit()
cad_file = line.product.cad_file
materials_source = line.product.cad_part_materials or []
usd_render_path = None
usd_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.cad_file_id == cad_file.id,
MediaAsset.asset_type == MediaAssetType.usd_master,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
if usd_asset:
usd_candidate_path = _resolve_asset_path(usd_asset.storage_key)
refresh_reason = _usd_master_refresh_reason(
cad_file,
usd_asset=usd_asset,
usd_render_path=usd_candidate_path,
)
if refresh_reason is not None:
logger.warning(
"render_order_line: ignoring stale usd_master for cad %s (%s)",
cad_file.id,
refresh_reason,
)
_emit(
emit,
order_line_id,
f"Existing USD master is stale ({refresh_reason}) — falling back to GLB/STEP",
"warning",
)
if _queue_usd_master_refresh(str(cad_file.id)):
_emit(emit, order_line_id, "Queued USD master regeneration in background")
else:
usd_render_path = usd_candidate_path
if usd_render_path:
logger.info(
"render_order_line: using usd_master %s for cad %s",
usd_render_path.name,
cad_file.id,
)
glb_reuse_path = None
if not usd_render_path:
glb_asset = session.execute(
select(MediaAsset)
.where(
MediaAsset.cad_file_id == cad_file.id,
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
)
.order_by(MediaAsset.created_at.desc())
.limit(1)
).scalar_one_or_none()
glb_candidate = _resolve_asset_path(glb_asset.storage_key if glb_asset else None)
if glb_candidate and glb_candidate.stat().st_size > 0:
step_path = Path(cad_file.stored_path)
expected_glb = step_path.parent / f"{step_path.stem}_thumbnail.glb"
if not expected_glb.exists() or expected_glb.stat().st_size == 0:
try:
shutil.copy2(str(glb_candidate), str(expected_glb))
logger.info(
"render_order_line: reused gltf_geometry asset %s -> %s",
glb_candidate.name,
expected_glb.name,
)
glb_reuse_path = expected_glb
except Exception as copy_exc:
logger.warning("render_order_line: failed to copy GLB asset: %s", copy_exc)
else:
glb_reuse_path = expected_glb
if usd_render_path:
_emit(emit, order_line_id, "Using USD master for render (skipping GLB tessellation)")
elif glb_reuse_path:
_emit(
emit,
order_line_id,
f"Reusing cached GLB geometry ({glb_reuse_path.name}) — skipping re-tessellation",
)
else:
_emit(emit, order_line_id, "No USD master or cached GLB — will tessellate STEP -> GLB")
part_colors: dict[str, str] = {}
if cad_file.parsed_objects:
parsed_names = cad_file.parsed_objects.get("objects", [])
if materials_source:
part_colors = build_part_colors(parsed_names, materials_source)
return OrderLineRenderSetupResult(
status="ready",
order_line=line,
order=order,
cad_file=cad_file,
materials_source=materials_source,
usd_render_path=usd_render_path,
glb_reuse_path=glb_reuse_path,
part_colors=part_colors,
render_start=render_start,
)
def resolve_order_line_template_context(
session: Session,
setup: OrderLineRenderSetupResult,
*,
emit: EmitFn = None,
template_id_override: str | None = None,
material_library_path_override: str | None = None,
require_template: bool = False,
disable_materials: bool = False,
target_collection_override: str | None = None,
material_replace_mode: str | None = None,
lighting_only_mode: str | None = None,
shadow_catcher_mode: str | None = None,
camera_orbit_mode: str | None = None,
template_input_overrides: dict[str, Any] | None = None,
) -> TemplateResolutionResult:
"""Resolve render template, material library, and material map for a prepared order line."""
if not setup.is_ready:
raise ValueError("resolve_order_line_template_context requires a ready setup result")
line = setup.order_line
cad_file = setup.cad_file
assert line is not None
assert cad_file is not None
materials_source = setup.materials_source
category_key = line.product.category_key if line.product else None
output_type_id = str(line.output_type_id) if line.output_type_id else None
template = None
if template_id_override:
try:
template_uuid = uuid.UUID(str(template_id_override))
except (TypeError, ValueError) as exc:
raise ValueError(f"template_id_override is not a valid UUID: {template_id_override}") from exc
template = session.get(RenderTemplate, template_uuid)
if template is None:
raise ValueError(f"render template not found: {template_id_override}")
if not template.is_active:
raise ValueError(f"render template is inactive: {template_id_override}")
else:
template = resolve_template_for_session(
session,
category_key=category_key,
output_type_id=output_type_id,
)
if require_template and template is None:
raise ValueError("resolve_order_line_template_context requires a matching render template")
material_library = (
material_library_path_override.strip()
if isinstance(material_library_path_override, str) and material_library_path_override.strip()
else get_material_library_path_for_session(session)
)
material_replace_override = _resolve_tristate_mode(material_replace_mode, field_name="material_replace_mode")
material_resolution = resolve_order_line_material_map(
line,
cad_file,
materials_source,
material_library=material_library,
template=template,
emit=emit,
disable_materials=disable_materials,
material_replace_enabled_override=material_replace_override,
)
resolved_target_collection = (
target_collection_override.strip()
if isinstance(target_collection_override, str) and target_collection_override.strip()
else (
template.target_collection
if template is not None and template.target_collection
else "Product"
)
)
resolved_lighting_only = _resolve_tristate_mode(
lighting_only_mode,
field_name="lighting_only_mode",
fallback=bool(template.lighting_only) if template is not None else False,
)
resolved_shadow_catcher = _resolve_tristate_mode(
shadow_catcher_mode,
field_name="shadow_catcher_mode",
fallback=bool(template.shadow_catcher_enabled) if template is not None else False,
)
resolved_camera_orbit = _resolve_tristate_mode(
camera_orbit_mode,
field_name="camera_orbit_mode",
fallback=bool(template.camera_orbit) if template is not None else True,
)
workflow_input_schema = _normalize_template_input_schema(template)
template_inputs = _resolve_template_input_values(workflow_input_schema, template_input_overrides)
if template:
_emit(
emit,
str(line.id),
"Using render template: "
f"{template.name} (collection={template.target_collection}, "
f"material_replace={template.material_replace_enabled}, "
f"lighting_only={template.lighting_only})",
)
logger.info(
"Render template resolved: '%s' path=%s, lighting_only=%s",
template.name,
template.blend_file_path,
template.lighting_only,
)
if template_inputs:
logger.info("Render template inputs resolved for '%s': %s", template.name, sorted(template_inputs))
if not template:
_emit(emit, str(line.id), "No render template found — using factory settings (Mode A)")
logger.info(
"No render template for category_key=%r, output_type_id=%r",
category_key,
output_type_id,
)
return TemplateResolutionResult(
template=template,
material_library=material_library,
material_map=material_resolution.material_map,
use_materials=material_resolution.use_materials,
override_material=material_resolution.override_material,
target_collection=resolved_target_collection,
lighting_only=resolved_lighting_only,
shadow_catcher=resolved_shadow_catcher,
camera_orbit=resolved_camera_orbit,
category_key=category_key,
output_type_id=output_type_id,
workflow_input_schema=workflow_input_schema,
template_inputs=template_inputs,
)
def resolve_order_line_material_map(
line: OrderLine,
cad_file: CadFile | None,
materials_source: list[dict[str, Any]],
*,
material_library: str | None,
template: RenderTemplate | None,
emit: EmitFn = None,
material_override: str | None = None,
disable_materials: bool = False,
material_replace_enabled_override: bool | None = None,
) -> MaterialResolutionResult:
"""Resolve the effective order-line material map with legacy precedence rules."""
if disable_materials:
return MaterialResolutionResult(
material_map=None,
use_materials=False,
override_material=material_override,
source_material_count=0,
resolved_material_count=0,
)
material_map = None
raw_material_count = 0
raw_material_map = _build_effective_material_lookup(cad_file, materials_source)
use_materials = bool(material_library and raw_material_map)
if material_replace_enabled_override is not None:
use_materials = bool(material_replace_enabled_override and material_library and raw_material_map)
elif template and not template.material_replace_enabled:
use_materials = False
if use_materials:
raw_material_count = len(raw_material_map)
material_map = resolve_material_map(raw_material_map)
if cad_file:
material_map = _overlay_scene_manifest_material_map(cad_file, material_map)
line_override = getattr(line, "material_override", None)
output_override = line.output_type.material_override if line.output_type else None
override_material = material_override or line_override or output_override
if override_material:
override_keys = set(material_map.keys()) if material_map else set()
if cad_file:
override_keys.update(_collect_cad_material_keys(cad_file))
if cad_file and cad_file.parsed_objects:
for part_name in cad_file.parsed_objects.get("objects", []):
override_keys.add(part_name)
material_map = {key: override_material for key in override_keys}
use_materials = True
_emit(
emit,
str(line.id),
f"Material override active: {len(material_map)} parts → {override_material}",
)
return MaterialResolutionResult(
material_map=material_map,
use_materials=use_materials,
override_material=override_material,
source_material_count=raw_material_count,
resolved_material_count=len(material_map or {}),
)
def _overlay_scene_manifest_material_map(
cad_file: CadFile,
material_map: dict[str, str],
) -> dict[str, str]:
"""Overlay authoritative scene-manifest materials onto a resolved material map.
Low-level lookups still retain legacy/product source assignments so older
fallback paths keep working. The final order-line material map, however,
must prefer the scene manifest's effective assignments wherever the USD/CAD
pipeline has already established authoritative part identity.
"""
if not material_map:
return material_map
merged = dict(material_map)
manifest = build_scene_manifest(cad_file)
for part in manifest.get("parts", []):
if not isinstance(part, dict):
continue
effective_material = part.get("effective_material")
if not isinstance(effective_material, str) or not effective_material.strip():
continue
source_name = part.get("source_name")
part_key = part.get("part_key")
if isinstance(source_name, str) and source_name.strip():
merged[source_name] = effective_material
if isinstance(part_key, str) and part_key.strip():
merged[part_key] = effective_material
return merged
def _build_effective_material_lookup(
cad_file: CadFile | None,
materials_source: list[dict[str, Any]],
) -> dict[str, str]:
"""Build a renderer-compatible material lookup from all available layers.
Product/Excel CAD assignments stay authoritative for overlapping source-name
keys so legacy renders, thumbnails, and viewer previews keep parity with the
pre-USD pipeline. Scene-manifest assignments still fill gaps and emit part-key
aliases so USD and GLB/STEP fallback paths resolve the same effective map.
"""
raw_material_map: dict[str, str] = {
str(material["part_name"]): str(material["material"])
for material in materials_source
if material.get("part_name") and material.get("material")
}
authoritative_lookup = _build_authoritative_material_lookup(materials_source)
if not cad_file:
return raw_material_map
manifest = build_scene_manifest(cad_file)
for part in manifest.get("parts", []):
if not isinstance(part, dict):
continue
effective_material = part.get("effective_material")
if not effective_material:
continue
source_name = part.get("source_name")
part_key = part.get("part_key")
authoritative_material = _resolve_authoritative_material_name(
str(source_name) if source_name else None,
authoritative_lookup,
str(part_key) if part_key else None,
)
merged_material = authoritative_material or str(effective_material)
if source_name and str(source_name) not in raw_material_map:
raw_material_map[str(source_name)] = merged_material
if part_key:
raw_material_map.setdefault(str(part_key), merged_material)
return raw_material_map
def _collect_cad_material_keys(cad_file: CadFile) -> set[str]:
if not (
cad_file.resolved_material_assignments
or cad_file.manual_material_overrides
or cad_file.source_material_assignments
):
return set()
keys: set[str] = set()
manifest = build_scene_manifest(cad_file)
for part in manifest.get("parts", []):
if not isinstance(part, dict):
continue
source_name = part.get("source_name")
part_key = part.get("part_key")
if source_name:
keys.add(str(source_name))
if part_key:
keys.add(str(part_key))
return keys
def auto_populate_materials_for_cad(
session: Session,
cad_file_id: str,
*,
enqueue_thumbnail: QueueThumbnailFn = None,
persist_updates: bool = True,
include_populated_products: bool = False,
) -> AutoPopulateMaterialsResult:
"""Auto-fill empty CAD material mappings from Excel component data.
This preserves the legacy rules:
- only products with empty/all-blank `cad_part_materials` are updated
- thumbnail regeneration is queued at most once per CAD file
- the queued part-color map comes from the last updated product
"""
from app.api.routers.products import build_materials_from_excel
cad_file = session.execute(
select(CadFile).where(CadFile.id == cad_file_id)
).scalar_one_or_none()
if cad_file is None:
return AutoPopulateMaterialsResult(cad_file_id=str(cad_file_id))
parsed_objects = cad_file.parsed_objects or {}
cad_parts: list[str] = parsed_objects.get("objects", [])
if not cad_parts:
return AutoPopulateMaterialsResult(
cad_file_id=str(cad_file_id),
cad_parts=[],
)
products = session.execute(
select(Product).where(
Product.cad_file_id == cad_file.id,
Product.is_active.is_(True),
)
).scalars().all()
updated_product_ids: list[str] = []
final_part_colors: dict[str, str] | None = None
for product in products:
excel_components: list[dict[str, Any]] = product.components or []
if not excel_components:
continue
existing = product.cad_part_materials or []
if not include_populated_products and existing and any((entry.get("material") or "").strip() for entry in existing):
continue
new_materials = build_materials_from_excel(cad_parts, excel_components)
updated_product_ids.append(str(product.id))
if persist_updates:
session.execute(
sql_update(Product)
.where(Product.id == product.id)
.values(cad_part_materials=new_materials)
)
session.flush()
try:
final_part_colors = build_part_colors(cad_parts, new_materials)
except Exception:
logger.exception("Part colors build failed for product %s", product.id)
logger.info(
"Auto-populated %d materials for product %s from %d Excel components",
len(new_materials),
product.id,
len(excel_components),
)
if persist_updates:
session.commit()
queued_thumbnail_regeneration = False
if persist_updates and final_part_colors is not None:
if enqueue_thumbnail is None:
from app.domains.pipeline.tasks.render_thumbnail import regenerate_thumbnail
enqueue_thumbnail = lambda current_cad_file_id, part_colors: regenerate_thumbnail.delay( # noqa: E731
current_cad_file_id,
part_colors,
)
enqueue_thumbnail(str(cad_file_id), final_part_colors)
queued_thumbnail_regeneration = True
return AutoPopulateMaterialsResult(
cad_file_id=str(cad_file_id),
updated_product_ids=updated_product_ids,
queued_thumbnail_regeneration=queued_thumbnail_regeneration,
part_colors=final_part_colors,
cad_parts=cad_parts,
)
def resolve_render_position_context(
session: Session,
line: OrderLine,
*,
emit: EmitFn = None,
) -> RenderPositionContext:
"""Resolve per-line render position values with product/global fallback."""
if line.render_position_id:
render_position = session.get(ProductRenderPosition, line.render_position_id)
if render_position:
_emit(
emit,
str(line.id),
f"Render position: '{render_position.name}' "
f"({render_position.rotation_x}°, {render_position.rotation_y}°, {render_position.rotation_z}°)"
+ (
f" focal_length={render_position.focal_length_mm}mm"
if render_position.focal_length_mm
else ""
),
)
return RenderPositionContext(
rotation_x=render_position.rotation_x,
rotation_y=render_position.rotation_y,
rotation_z=render_position.rotation_z,
focal_length_mm=render_position.focal_length_mm,
sensor_width_mm=render_position.sensor_width_mm,
source_name=render_position.name,
source_kind="product",
)
if line.global_render_position_id:
global_position = session.get(GlobalRenderPosition, line.global_render_position_id)
if global_position:
_emit(
emit,
str(line.id),
f"Global render position: '{global_position.name}' "
f"({global_position.rotation_x}°, {global_position.rotation_y}°, {global_position.rotation_z}°)"
+ (
f" focal_length={global_position.focal_length_mm}mm"
if global_position.focal_length_mm
else ""
),
)
return RenderPositionContext(
rotation_x=global_position.rotation_x,
rotation_y=global_position.rotation_y,
rotation_z=global_position.rotation_z,
focal_length_mm=global_position.focal_length_mm,
sensor_width_mm=global_position.sensor_width_mm,
source_name=global_position.name,
source_kind="global",
)
return RenderPositionContext()