Files
HartOMat/plan.md
T
Hartmut d938c4db1b fix(materials): universal FailedMaterial sentinel for unmatched mesh objects
- export_gltf.py: replace single-material fallback (only fired when
  len(appended)==1) with a universal sentinel that appends
  SCHAEFFLER_059999_FailedMaterial unconditionally and assigns it to
  every mesh object not matched by name-based lookup.
  Also adds in-memory magenta fallback if library append fails.
  Removes 2 temporary [DEBUG] print lines from investigation.

- blender_render.py: add FailedMaterial assignment inside
  _apply_material_library() for unmatched parts (was log-only before).
  Includes copy-on-write guard (users > 1) matching existing pattern.

Also added alias 'Stahl; Durotect CMT' (semicolon) → Durotect-Blue
to cover STEP files using semicolon separator instead of comma.

Verified: 23/25 objects matched correctly, 2 ISO8734 dowel pins
(empty material) receive SCHAEFFLER_059999_FailedMaterial as sentinel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:49:37 +01:00

8.4 KiB
Raw Blame History

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 ~275291) 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):

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:

# 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:

# 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:

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():

# 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 151166). 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. ✓