"""Part key generation and scene manifest building for the USD pipeline. The `resolved_material_assignments` JSONB schema written by `generate_usd_master_task`: {part_key: {"source_name": str, "prim_path": str}} The `manual_material_overrides` JSONB schema written by `PUT /cad/{id}/part-materials` (Priority 4): {part_key: material_name_str} The `source_material_assignments` JSONB schema written by the Excel importer (future): {source_part_name: material_name_str} No pxr imports — all data is read from JSONB columns, never from USD files directly. """ from __future__ import annotations import hashlib import re # ── Part key generation ─────────────────────────────────────────────────────── _AF_RE = re.compile(r'_AF\d+$', re.IGNORECASE) def generate_part_key( xcaf_label_path: str, source_name: str, existing_keys: set[str] | None = None, ) -> str: """Deterministic slug from source_name, max 64 chars, unique within assembly. - Strips `_AF\\d+` OCC suffix from source_name before slugifying. - Falls back to sha256 digest of xcaf_label_path if slug is empty. - Deduplicates by appending _2, _3, ... if existing_keys is provided. """ base = _AF_RE.sub('', source_name) if source_name else '' # Split camelCase before slugifying: "RingOuter" → "Ring_Outer" base = re.sub(r'([a-z])([A-Z])', r'\1_\2', base) slug = re.sub(r'[^a-z0-9]+', '_', base.lower()).strip('_') if not slug: slug = f"part_{hashlib.sha256(xcaf_label_path.encode()).hexdigest()[:8]}" slug = slug[:50] if existing_keys is None: return slug key = slug n = 2 while key in existing_keys: key = f"{slug}_{n}" n += 1 existing_keys.add(key) return key # ── Scene manifest building ─────────────────────────────────────────────────── def build_scene_manifest(cad_file, usd_asset=None) -> dict: """Build a scene manifest dict from CadFile ORM object. Source of part list (priority order): 1. `resolved_material_assignments` — keyed by partKey (set by generate_usd_master_task) 2. `parsed_objects["objects"]` — list of source name strings from STEP extraction 3. Empty manifest if neither is available. Material assignment priority per part: 1. `manual_material_overrides[part_key]` — provenance "manual" 2. `resolved_material_assignments[part_key]["material"]` — provenance "auto" 3. substring match in `source_material_assignments` against source_name — provenance "source" 4. None, is_unassigned=True — provenance "default" """ cad_id = str(cad_file.id) resolved = cad_file.resolved_material_assignments or {} manual = cad_file.manual_material_overrides or {} source = cad_file.source_material_assignments or {} parts: list[dict] = [] unmatched_source_rows: list[str] = [] unassigned_parts: list[str] = [] if resolved: # Build from resolved assignments (USD pipeline has run) for part_key, meta in resolved.items(): source_name = meta.get("source_name", "") if isinstance(meta, dict) else "" prim_path = meta.get("prim_path") if isinstance(meta, dict) else None effective_material, provenance = _resolve_material( part_key, source_name, manual, resolved, source ) is_unassigned = effective_material is None parts.append({ "part_key": part_key, "source_name": source_name, "prim_path": prim_path, "effective_material": effective_material, "assignment_provenance": provenance, "is_unassigned": is_unassigned, }) if is_unassigned: unassigned_parts.append(part_key) elif cad_file.parsed_objects: # Fall back to parsed_objects from STEP extraction object_names: list[str] = cad_file.parsed_objects.get("objects") or [] seen_keys: set[str] = set() for source_name in object_names: # Fallback: USD master not yet generated. Use source_name as xcaf_path proxy. # Note: slugs produced here may differ from what export_step_to_usd.py will # produce for unnamed parts (which use sha256 of the XCAF hierarchy path). # Named parts will match once USD master is generated. part_key = generate_part_key(source_name, source_name, seen_keys) effective_material, provenance = _resolve_material( part_key, source_name, manual, resolved, source ) is_unassigned = effective_material is None parts.append({ "part_key": part_key, "source_name": source_name, "prim_path": None, "effective_material": effective_material, "assignment_provenance": provenance, "is_unassigned": is_unassigned, }) if is_unassigned: unassigned_parts.append(part_key) # Find source rows not matched to any part matched_source_names = {p["source_name"].lower() for p in parts} for src_key in source: if not any( src_key.lower() in sn or sn in src_key.lower() for sn in matched_source_names ): unmatched_source_rows.append(src_key) return { "cad_file_id": cad_id, "parts": parts, "unmatched_source_rows": unmatched_source_rows, "unassigned_parts": unassigned_parts, } def _resolve_material( part_key: str, source_name: str, manual: dict, resolved: dict, source: dict, ) -> tuple[str | None, str]: """Return (material_name, provenance) for one part using priority order.""" # 1. Manual override if part_key in manual and manual[part_key]: return str(manual[part_key]), "manual" # 2. Auto-resolved from USD pipeline meta = resolved.get(part_key) if isinstance(meta, dict) and meta.get("material"): return str(meta["material"]), "auto" # 3. Substring match in source_material_assignments against source_name sn_lower = source_name.lower() for src_key, src_mat in source.items(): if src_key.lower() in sn_lower or sn_lower in src_key.lower(): if src_mat: return str(src_mat), "source" # 4. Unassigned return None, "default" # ── Effective assignments for render pipeline ───────────────────────────────── def get_effective_assignments(cad_file) -> dict[str, str]: """Return {part_key: material_name} merged from all three layers. Used by the render pipeline when building the material map (Priority 5). """ manifest = build_scene_manifest(cad_file) return { p["part_key"]: p["effective_material"] for p in manifest["parts"] if p["effective_material"] is not None }