# Plan: FailedMaterial Sentinel for Unmatched Mesh Objects ## Context 23/25 mesh objects in production GLB exports receive correct Schaeffler library materials. 2 ISO8734 dowel pins carry an empty material string → `mat_map_lower.get()` returns `None` → they fall through the entire matching block and keep their OCC palette color in the exported GLB. The old single-material fallback in `export_gltf.py` (lines ~275–291) fires **only when exactly 1 material is appended** — it never fires for multi-material assemblies. `blender_render.py` logs unmatched parts but assigns nothing. Fix: append `SCHAEFFLER_059999_FailedMaterial` unconditionally as a sentinel, then assign it to every unmatched mesh object in both scripts. Also remove 2 temporary `[DEBUG]` print lines left from investigation. --- ## Affected Files | File | Change | |------|--------| | `render-worker/scripts/export_gltf.py` | Remove 2 DEBUG prints; add `FAILED_MATERIAL_NAME` constant; replace single-material fallback with universal sentinel | | `render-worker/scripts/blender_render.py` | Add FailedMaterial assignment loop at end of `_apply_material_library()` | --- ## Tasks (in order) ### [x] Task 1: export_gltf.py — Remove DEBUG prints + add universal FailedMaterial sentinel **File**: `render-worker/scripts/export_gltf.py` **Step 1a** — Add `FAILED_MATERIAL_NAME` constant after the imports (near top of file, after `import traceback`): ```python FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial" ``` **Step 1b** — In the material assignment loop, remove the two `[DEBUG]` print lines and replace with a `pass` comment: ```python # BEFORE: assigned += 1 assigned_names.add(obj.name) print(f"[DEBUG] assigned '{mat_name}' → '{obj.name}' (lookup_key='{lower_base}')") else: print(f"[DEBUG] NO MATCH for obj='{obj.name}' lower_base='{lower_base}' mat_name={mat_name!r} in_appended={mat_name in appended if mat_name else False}") # AFTER: assigned += 1 assigned_names.add(obj.name) else: pass # unmatched → will receive FailedMaterial sentinel below ``` **Step 1c** — Replace the single-material fallback block (after `print(f"Material substitution: ...")`) with the universal sentinel: ```python # BEFORE (single-material fallback, only fires when len(appended)==1): # Single-material fallback: if only one library material was loaded, ... if len(appended) == 1: default_mat_name, default_mat = next(iter(appended.items())) if default_mat: fallback = 0 for obj in mesh_objects: if obj.name not in assigned_names: if obj.data.users > 1: obj.data = obj.data.copy() obj.data.materials.clear() obj.data.materials.append(default_mat) fallback += 1 if fallback: print(f"Single-material fallback: applied '{default_mat_name}' to {fallback} unmatched objects") # AFTER (universal sentinel — fires regardless of how many materials were appended): # Universal FailedMaterial sentinel: assign SCHAEFFLER_059999_FailedMaterial # to every mesh object that was not matched by name-based lookup above. failed_mat = None try: bpy.ops.wm.append( filepath=f"{args.asset_library_blend}/Material/{FAILED_MATERIAL_NAME}", directory=f"{args.asset_library_blend}/Material/", filename=FAILED_MATERIAL_NAME, link=False, ) if FAILED_MATERIAL_NAME in bpy.data.materials: failed_mat = bpy.data.materials[FAILED_MATERIAL_NAME] print(f"Appended sentinel material: {FAILED_MATERIAL_NAME}") else: print(f"WARNING: sentinel '{FAILED_MATERIAL_NAME}' not in library — " f"creating in-memory magenta fallback", file=sys.stderr) except Exception as exc: print(f"WARNING: failed to append sentinel '{FAILED_MATERIAL_NAME}': {exc}", file=sys.stderr) if failed_mat is None: # Library append failed: create in-memory magenta so export is never silently wrong failed_mat = bpy.data.materials.new(name=FAILED_MATERIAL_NAME) failed_mat.use_nodes = True bsdf = failed_mat.node_tree.nodes.get("Principled BSDF") if bsdf: bsdf.inputs["Base Color"].default_value = (1.0, 0.0, 1.0, 1.0) # magenta fallback_count = 0 for obj in mesh_objects: if obj.name not in assigned_names: if obj.data.users > 1: obj.data = obj.data.copy() obj.data.materials.clear() obj.data.materials.append(failed_mat) fallback_count += 1 if fallback_count: print(f"FailedMaterial sentinel: assigned '{FAILED_MATERIAL_NAME}' " f"to {fallback_count} unmatched objects") ``` **Acceptance gate**: ```bash grep -n "\[DEBUG\]" render-worker/scripts/export_gltf.py # must return nothing grep -n "FAILED_MATERIAL_NAME" render-worker/scripts/export_gltf.py # must show constant + usage ``` After deploying and running a production GLB export: - Log shows `FailedMaterial sentinel: assigned 'SCHAEFFLER_059999_FailedMaterial' to 2 unmatched objects` - No `[DEBUG]` lines in logs **Dependencies**: none --- ### [x] Task 2: blender_render.py — Add FailedMaterial fallback inside `_apply_material_library()` **File**: `render-worker/scripts/blender_render.py` At the end of `_apply_material_library()`, replace the logging-only unmatched block with one that also calls `_assign_failed_material()`: ```python # BEFORE (end of _apply_material_library, lines ~483-485): print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched", flush=True) if unmatched_names: print(f"[blender_render] unmatched parts (palette fallback): {unmatched_names[:10]}", flush=True) # AFTER: print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched", flush=True) if unmatched_names: print(f"[blender_render] unmatched parts → assigning {FAILED_MATERIAL_NAME}: {unmatched_names[:10]}", flush=True) unmatched_set = set(unmatched_names) for part in parts: if part.name in unmatched_set: if part.data.users > 1: part.data = part.data.copy() _assign_failed_material(part) ``` Note: `_assign_failed_material()` and `FAILED_MATERIAL_NAME` already exist in `blender_render.py` (line 31 and lines 151–166). No new imports needed. **Acceptance gate**: Trigger a thumbnail render with a material_map that leaves one or more parts unmatched. Render log must include: ``` [blender_render] unmatched parts → assigning SCHAEFFLER_059999_FailedMaterial: [...] ``` **Dependencies**: none (independent of Task 1) --- ## Migration Check **No migration required.** Two render-worker scripts only. No DB, no backend, no frontend. --- ## Order Recommendation Tasks 1 and 2 are independent. Implement both in the same session, then: ``` docker compose cp render-worker/scripts/export_gltf.py render-worker:/render-scripts/export_gltf.py docker compose cp render-worker/scripts/blender_render.py render-worker:/render-scripts/blender_render.py → trigger production GLB re-generation → verify sentinel fires for ISO8734 parts ``` --- ## Risks / Open Questions 1. `assigned_names` uses `obj.name` (Blender-deduplicated, may include `.001` suffix) — the sentinel loop iterates the same `mesh_objects` list and checks `obj.name not in assigned_names`, so the comparison is consistent. ✓ 2. `_assign_failed_material()` in `blender_render.py` does not include a `users > 1` copy guard — adding it in Task 2 is correct and consistent with the existing assignment branch. 3. If `FAILED_MATERIAL_NAME` was already appended as part of `needed` in `export_gltf.py` (e.g., if a part explicitly has `SCHAEFFLER_059999_FailedMaterial` in its material map), the `wm.append` call deduplicates automatically. ✓