1974 lines
73 KiB
Python
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()
|