chore: snapshot workflow migration progress
This commit is contained in:
@@ -5,7 +5,7 @@ import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
@@ -13,7 +13,11 @@ 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.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
|
||||
@@ -37,6 +41,199 @@ 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)
|
||||
@@ -75,8 +272,14 @@ class TemplateResolutionResult:
|
||||
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)
|
||||
@@ -159,6 +362,7 @@ class OrderLineRenderInvocation:
|
||||
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] = {
|
||||
@@ -196,9 +400,10 @@ class OrderLineRenderInvocation:
|
||||
"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, ""):
|
||||
if value not in (None, "", {}, [], ()):
|
||||
payload[key] = value
|
||||
return payload
|
||||
|
||||
@@ -242,6 +447,7 @@ class OrderLineRenderInvocation:
|
||||
"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(
|
||||
@@ -285,6 +491,7 @@ class OrderLineRenderInvocation:
|
||||
"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(
|
||||
@@ -324,6 +531,7 @@ class OrderLineRenderInvocation:
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -341,7 +549,61 @@ 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:
|
||||
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"
|
||||
@@ -350,7 +612,7 @@ def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
|
||||
for meta in resolved.values():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
canonical = meta.get("canonical_material")
|
||||
canonical = meta.get("canonical_material") or meta.get("material")
|
||||
if isinstance(canonical, str) and canonical.strip():
|
||||
canonical_materials.append(canonical.strip())
|
||||
|
||||
@@ -360,6 +622,14 @@ def _usd_master_refresh_reason(cad_file: CadFile) -> str | None:
|
||||
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
|
||||
|
||||
|
||||
@@ -502,6 +772,27 @@ def _coerce_bool(value: Any) -> bool:
|
||||
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"
|
||||
@@ -582,7 +873,7 @@ def build_order_line_render_invocation(
|
||||
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"
|
||||
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):
|
||||
@@ -682,22 +973,14 @@ def build_order_line_render_invocation(
|
||||
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"
|
||||
),
|
||||
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=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,
|
||||
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,
|
||||
@@ -705,6 +988,7 @@ def build_order_line_render_invocation(
|
||||
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 {},
|
||||
)
|
||||
|
||||
|
||||
@@ -727,10 +1011,49 @@ def _canonical_public_output_path(line: OrderLine, output_path: str) -> str:
|
||||
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)
|
||||
canonical_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ensure_group_writable_dir(canonical_path.parent)
|
||||
if source_path != canonical_path:
|
||||
shutil.copy2(source_path, canonical_path)
|
||||
return str(canonical_path)
|
||||
@@ -765,6 +1088,7 @@ def persist_order_line_media_asset(
|
||||
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(
|
||||
@@ -906,13 +1230,14 @@ def persist_order_line_output(
|
||||
) -> 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()
|
||||
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()
|
||||
@@ -1084,7 +1409,7 @@ def prepare_order_line_render_context(
|
||||
reason="missing_cad_file",
|
||||
)
|
||||
|
||||
render_start = datetime.utcnow() if persist_state else None
|
||||
render_start = _utcnow_naive() if persist_state else None
|
||||
if persist_state:
|
||||
session.execute(
|
||||
sql_update(OrderLine)
|
||||
@@ -1111,7 +1436,12 @@ def prepare_order_line_render_context(
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if usd_asset:
|
||||
refresh_reason = _usd_master_refresh_reason(cad_file)
|
||||
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)",
|
||||
@@ -1127,7 +1457,7 @@ def prepare_order_line_render_context(
|
||||
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)
|
||||
usd_render_path = usd_candidate_path
|
||||
if usd_render_path:
|
||||
logger.info(
|
||||
"render_order_line: using usd_master %s for cad %s",
|
||||
@@ -1203,6 +1533,12 @@ def resolve_order_line_template_context(
|
||||
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:
|
||||
@@ -1242,6 +1578,7 @@ def resolve_order_line_template_context(
|
||||
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,
|
||||
@@ -1250,8 +1587,36 @@ def resolve_order_line_template_context(
|
||||
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,
|
||||
@@ -1267,6 +1632,8 @@ def resolve_order_line_template_context(
|
||||
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(
|
||||
@@ -1281,8 +1648,14 @@ def resolve_order_line_template_context(
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -1296,6 +1669,7 @@ def resolve_order_line_material_map(
|
||||
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:
|
||||
@@ -1311,11 +1685,15 @@ def resolve_order_line_material_map(
|
||||
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 template and not template.material_replace_enabled:
|
||||
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
|
||||
@@ -1344,21 +1722,55 @@ def resolve_order_line_material_map(
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Authoritative scene-manifest assignments win when present, but we emit both
|
||||
source-name and part-key keys so USD and GLB/STEP fallback paths resolve the
|
||||
same effective material map.
|
||||
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
|
||||
@@ -1372,10 +1784,16 @@ def _build_effective_material_lookup(
|
||||
continue
|
||||
source_name = part.get("source_name")
|
||||
part_key = part.get("part_key")
|
||||
if source_name:
|
||||
raw_material_map[str(source_name)] = str(effective_material)
|
||||
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[str(part_key)] = str(effective_material)
|
||||
raw_material_map.setdefault(str(part_key), merged_material)
|
||||
|
||||
return raw_material_map
|
||||
|
||||
|
||||
Reference in New Issue
Block a user