from __future__ import annotations import logging import re import shutil import uuid from dataclasses import dataclass, field from datetime import datetime 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 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 merge_output_type_invocation_overrides from app.domains.rendering.models import ( GlobalRenderPosition, ProductRenderPosition, RenderTemplate, WorkflowRun, ) 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 @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 category_key: str | None output_type_id: str | None @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 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, } 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, } 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, } 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, "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_refresh_reason(cad_file: CadFile) -> 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") 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" 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" return MediaAssetType.turntable if extension in ("mp4", "webm") else 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_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, 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 = ( merge_output_type_invocation_overrides( output_type.render_settings, getattr(output_type, "invocation_overrides", None), ) 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 "auto") if output_type is not None else "auto" 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.template.target_collection if template_context and template_context.template and template_context.template.target_collection else "Product" ), material_library_path=( template_context.material_library if template_context and use_materials else None ), material_map=material_map, lighting_only=bool(template_context.template.lighting_only) if template_context and template_context.template else False, shadow_catcher=( bool(template_context.template.shadow_catcher_enabled) if template_context and template_context.template else False ), camera_orbit=bool(template_context.template.camera_orbit) if template_context and template_context.template 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, ) 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 _materialize_public_output(line: OrderLine, output_path: str) -> str: canonical_path = Path(_canonical_public_output_path(line, output_path)) source_path = Path(output_path) canonical_path.parent.mkdir(parents=True, exist_ok=True) 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: 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 datetime.utcnow() persisted_output_path = output_path line.render_status = status line.render_completed_at = completed_at line.render_log = render_log if success: 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 = datetime.utcnow() 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: refresh_reason = _usd_master_refresh_reason(cad_file) 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 = _resolve_asset_path(usd_asset.storage_key) 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, ) -> 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 = resolve_template_for_session( session, category_key=category_key, output_type_id=output_type_id, ) material_library = get_material_library_path_for_session(session) material_resolution = resolve_order_line_material_map( line, cad_file, materials_source, material_library=material_library, template=template, emit=emit, ) 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 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, category_key=category_key, output_type_id=output_type_id, ) 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, ) -> MaterialResolutionResult: """Resolve the effective order-line material map with legacy precedence rules.""" material_map = None raw_material_count = 0 use_materials = bool(material_library and materials_source) if template and not template.material_replace_enabled: use_materials = False if use_materials: material_map = { material["part_name"]: material["material"] for material in materials_source if material.get("part_name") and material.get("material") } raw_material_count = len(material_map) material_map = resolve_material_map(material_map) line_override = getattr(line, "material_override", None) output_override = line.output_type.material_override if line.output_type else None override_material = line_override or output_override if override_material: override_keys = set(material_map.keys()) if material_map else set() 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 auto_populate_materials_for_cad( session: Session, cad_file_id: str, *, enqueue_thumbnail: QueueThumbnailFn = None, persist_updates: bool = True, ) -> 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 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()