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>
This commit is contained in:
2026-03-11 21:49:37 +01:00
parent 638b93bb1e
commit d938c4db1b
3 changed files with 166 additions and 291 deletions
+116 -272
View File
@@ -1,36 +1,12 @@
# Plan: GMSH — Fix Mirror Instances + Reduce Mesh Size to ≤120% of OCC
# Plan: FailedMaterial Sentinel for Unmatched Mesh Objects
## Context
Two bugs introduced by the GMSH tessellation path:
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.
**Bug 1 — Missing parts (mirror instances)**
`TopExp_Explorer(root_shape, SOLID)` visits every *occurrence* of a solid, including
mirrored instances. In a typical STEP bearing assembly the inner ring is defined once
but instanced twice: normal + Y=-1 mirror. Both occurrences share the exact same
underlying `TShape` pointer.
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.
Tessellation loop calls `_tessellate_with_gmsh(solidA)` then
`_tessellate_with_gmsh(solidB)`. Both reach `BRep_Builder.UpdateFace(face, tri)` on
the same `BRep_TFace` objects. The second call **overwrites** the triangulation written
by the first — with coordinates from the mirrored geometry. The XCAF writer then tries
to apply the instance Location on top of already-mirrored coordinates → part appears
at the wrong position or vanishes entirely.
Fix: deduplicate by TShape. Each unique `TShape` must be tessellated exactly **once**,
in its definition-space geometry (location stripped). The XCAF writer handles instance
transforms at write time — it does not need the triangulation to be pre-transformed.
**Bug 2 — GLB 7× too large (21 MB vs OCC 3 MB; target ≤3.6 MB)**
`CharacteristicLengthMax = linear_deflection × 15 = 1.5 mm` is much smaller than the
effective OCC edge length. OCC with `angular_deflection=0.1 rad` on a 50 mm radius
cylinder produces edges ≈ `2 × 50 × sin(0.05) ≈ 5 mm`. The 15× multiplier only
reaches 1.5 mm → 3× more edge subdivisions along every cylinder → ~9× more triangles.
`MinimumCirclePoints = min(20, ceil(2π/0.1)) = 20` adds further density.
Fix: `CharacteristicLengthMax = linear_deflection × 50` (≈5 mm for default 0.1 mm),
`MinimumCirclePoints = min(12, ceil(2π/angular_deflection))`.
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.
---
@@ -38,298 +14,166 @@ Fix: `CharacteristicLengthMax = linear_deflection × 50` (≈5 mm for default 0.
| File | Change |
|------|--------|
| `render-worker/scripts/export_step_to_gltf.py` | Fix 1: deduplicate TShape; Fix 2: new mesh-density formula |
Only one file changes. No DB migration, no frontend change, no backend task 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: Deduplicate TShape in the per-solid tessellation loop
### [x] Task 1: export_gltf.py — Remove DEBUG prints + add universal FailedMaterial sentinel
**File**: `render-worker/scripts/export_step_to_gltf.py`
**Root cause**: `TopExp_Explorer(root_shape, SOLID)` returns every *occurrence* (instance)
of a solid. Mirrored instances share the same TShape. The second `UpdateFace` call on the
same TShape overwrites the first tessellation.
**What**:
Replace the current per-solid loop (lines 535553) with a version that:
1. Extracts `TShape` identity for each solid via `solid.TShape()`
2. Tracks already-processed TShapes in a `set` (using Python `id()` on the TShape object)
3. For a solid whose TShape was already processed → skip (the triangulation is already set)
4. For a solid with a **mirror transform** (negative determinant) → use BRepMesh fallback
instead of GMSH, to avoid inverted-Jacobian issues
5. For new, non-mirrored solids → strip location before calling GMSH, then restore
**Why strip location?**
`BRepTools.Write_s(solid_with_location, brep_path)` writes the solid in world coordinates
(location applied). GMSH then tessellates in world coordinates. `UpdateFace` stores the
world-coordinate triangulation on the TShape, which the XCAF writer then double-transforms
(applies instance location again) → geometry is wrong.
With location stripped (`solid.Located(TopLoc_Location())`) the BRep file contains the
definition-space geometry, GMSH tessellates in definition space, and the XCAF writer
applies the instance transforms correctly at write time.
**Exact code to replace lines 535553:**
**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
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy as _BRepCopy
_seen_tshapes: set = set() # TShape identity → already tessellated
for solid in solids:
tshape_id = id(solid.TShape())
if tshape_id in _seen_tshapes:
# Shared TShape already tessellated — skip duplicate instance
continue
# Detect mirror transform: determinant of rotation part < 0
loc = solid.Location()
trsf = loc.IsIdentity() and None or loc.IsIdentity() # placeholder — see below
_is_mirror = False
if not loc.IsIdentity():
from OCP.gp import gp_Trsf as _gp_Trsf
m = loc.IsIdentity() # placeholder
try:
t = loc.IsIdentity() # will be replaced below
pass
except Exception:
pass
_tessellate_with_gmsh(solid, args.linear_deflection, args.angular_deflection)
_seen_tshapes.add(tshape_id)
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
```
**Actual correct implementation** (the placeholder above is incomplete; here is the
full, correct replacement):
**Step 1b** — In the material assignment loop, remove the two `[DEBUG]` print lines and replace with a `pass` comment:
```python
from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
# 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}")
_seen_tshapes: set = set() # set of id(TShape) already tessellated
for solid in solids:
tshape_id = id(solid.TShape())
# Skip duplicate instances — same TShape, different location (e.g. mirrored copy)
if tshape_id in _seen_tshapes:
continue
# Detect mirror transform (negative determinant → inverted Jacobian in GMSH)
loc = solid.Location()
_is_mirror = False
if not loc.IsIdentity():
t = loc.IsIdentity() # placeholder — actual det check below
try:
trsf = loc.IsIdentity() and None # will be overridden
# Real OCP API: loc.IsIdentity() returns bool; the transform is:
# trsf = gp_Trsf(); loc gives access via loc.IsIdentity() (no)
# Correct: the 3×3 rotation matrix determinant via VectorForm
pass
except Exception:
pass
if _is_mirror:
# Mirrored solid — GMSH produces inverted Jacobian; use BRepMesh fallback
_BrepMesh(solid, args.linear_deflection, False, args.angular_deflection, True)
else:
# Strip location: tessellate in definition space so XCAF writer can apply
# the instance transform correctly at GLB export time
solid_def = solid.Located(_TopLoc_Location())
_tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection)
_seen_tshapes.add(tshape_id)
# AFTER:
assigned += 1
assigned_names.add(obj.name)
else:
pass # unmatched → will receive FailedMaterial sentinel below
```
**Exact mirror-detection snippet** (the `gp_Trsf` determinant check):
**Step 1c** — Replace the single-material fallback block (after `print(f"Material substitution: ...")`) with the universal sentinel:
```python
from OCP.gp import gp_Trsf as _gp_Trsf
# 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")
def _is_mirror_transform(location) -> bool:
"""Return True if the TopLoc_Location has a negative-determinant (mirror) transform."""
if location.IsIdentity():
return False
trsf = location.IsIdentity() # placeholder — real API below
# OCP: TopLoc_Location has no direct Transformation() Python binding in all versions.
# Reliable alternative: check IsIdentity first; then use gp_Trsf from the location's
# IsIdentity() — actually TopLoc_Location.IsIdentity() returns bool.
# The correct OCP call:
try:
from OCP.gp import gp_GTrsf as _gp_GTrsf
# TopLoc_Location stores a gp_Trsf — access via:
trsf: _gp_Trsf = location.IsIdentity() and _gp_Trsf() or location.IsIdentity()
except Exception:
return False
# det = trsf.Value(1,1)*(trsf.Value(2,2)*trsf.Value(3,3) - trsf.Value(2,3)*trsf.Value(3,2))
# ...
return False # expand when OCP binding is confirmed
# 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")
```
> **Note for implementer**: The OCP Python binding for `TopLoc_Location` does expose
> `.IsIdentity()` (bool). The transform matrix is accessible via:
> ```python
> from OCP.gp import gp_Trsf
> trsf = gp_Trsf()
> location.IsIdentity() # bool
> # The actual matrix getter is not directly .Transformation() in all OCP builds.
> # Safest approach: use BRep_Tool or directly check the shape's TShape flags.
> # Alternative: use shape.Orientation() — mirrored solids in OCC have REVERSED orientation.
> ```
> **Recommended simpler check**: `solid.Orientation() == TopAbs_REVERSED` (from
> `OCP.TopAbs import TopAbs_REVERSED`). In OCC, a mirrored instance is stored as the
> same solid with `REVERSED` orientation. This is the correct, idiomatic OCC check.
>
> Full deduplication + mirror-detection loop (final version):
>
> ```python
> from OCP.TopLoc import TopLoc_Location as _TopLoc_Location
> from OCP.TopAbs import TopAbs_REVERSED as _REVERSED
>
> _seen_tshapes: set = set()
>
> for solid in solids:
> tshape_id = id(solid.TShape())
> if tshape_id in _seen_tshapes:
> continue # duplicate instance — triangulation already set on the shared TShape
>
> if solid.Orientation() == _REVERSED:
> # Mirrored/reversed solid → GMSH produces inverted-Jacobian mesh; BRepMesh fallback
> _BrepMesh(solid, args.linear_deflection, False, args.angular_deflection, True)
> else:
> # Strip location so GMSH sees definition-space geometry
> solid_def = solid.Located(_TopLoc_Location())
> _tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection)
>
> _seen_tshapes.add(tshape_id)
> ```
**Acceptance gate**:
- `docker compose exec render-worker python3 /render-scripts/export_step_to_gltf.py --step_path /app/uploads/step_files/341ee748-3f04-4c4e-b358-5f2dcd18f848.stp --output_path /tmp/test_mirror.glb --tessellation_engine gmsh` completes without error
- Log shows no "skipped node without triangulation data" for any mirrored-instance part that previously showed geometry
- GLB loaded in Blender shows all parts (including mirrored halves) at correct positions
```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: Tune GMSH density parameters to ≤120% of OCC output size
### [x] Task 2: blender_render.py — Add FailedMaterial fallback inside `_apply_material_library()`
**File**: `render-worker/scripts/export_step_to_gltf.py`
**File**: `render-worker/scripts/blender_render.py`
**Root cause**: `CharacteristicLengthMax = linear_deflection × 15 = 1.5 mm` → 3× more
edge subdivisions than OCC on cylindrical surfaces → ~9× more triangles.
`MinimumCirclePoints = 20` adds further overhead.
**What**: In `_tessellate_with_gmsh()`, replace lines 324329:
At the end of `_apply_material_library()`, replace the logging-only unmatched block with one that also calls `_assign_failed_material()`:
```python
# BEFORE
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 15.0)
min_circle_pts = min(20, max(12, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))))
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
# 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)
```
```python
# AFTER
# OCC linear_deflection (mm) is a surface-deviation tolerance.
# Empirically: OCC with 0.1mm deflection on a 50mm cylinder produces ~5mm edge lengths.
# Match that with CharacteristicLengthMax = deflection × 50.
# MinimumCirclePoints: OCC angular_deflection=0.1rad → ceil(2π/0.1)=63 pts/circle but
# spread unevenly; effective uniform subdivision is closer to 1216. Cap at 12.
gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 50.0)
min_circle_pts = min(12, max(6, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))))
gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts)
```
**Expected result** for `linear_deflection=0.1, angular_deflection=0.1`:
- `CharacteristicLengthMax = 5 mm` (vs 1.5 mm before)
- `MinimumCirclePoints = 12` (vs 20 before)
- Triangle count: ~(1.5/5)² × (12/20) × previous = ~0.054× → 21 MB × 0.054 ≈ 1.1 MB
(this estimate is rough; target is ≤ 3.6 MB which is 120% of OCC's ~3 MB)
- If result is still too large, increase multiplier further (60×, 70×)
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**:
```bash
# Run both OCC and GMSH, compare sizes:
python3 /render-scripts/export_step_to_gltf.py \
--step_path /app/uploads/step_files/341ee748*.stp \
--output_path /tmp/occ.glb --tessellation_engine occ \
--linear_deflection 0.1 --angular_deflection 0.1
python3 /render-scripts/export_step_to_gltf.py \
--step_path /app/uploads/step_files/341ee748*.stp \
--output_path /tmp/gmsh.glb --tessellation_engine gmsh \
--linear_deflection 0.1 --angular_deflection 0.1
# gmsh.glb must be ≤ 120% of occ.glb
python3 -c "
import os; occ=os.path.getsize('/tmp/occ.glb'); gmsh=os.path.getsize('/tmp/gmsh.glb')
print(f'OCC: {occ//1024}KB, GMSH: {gmsh//1024}KB, ratio: {gmsh/occ:.2f}')
assert gmsh <= occ * 1.20, f'GMSH {gmsh//1024}KB > 120% of OCC {occ//1024}KB'
print('PASS')
"
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, can run in parallel)
**Dependencies**: none (independent of Task 1)
---
## Migration Check
**No migration required.** Rendering-pipeline-only changes.
**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 file edit, then test together.
Tasks 1 and 2 are independent. Implement both in the same session, then:
```
Task 1 (deduplicate TShape + orientation check)
Task 2 (CharacteristicLengthMax ×50, MinimumCirclePoints ≤12)
docker compose cp updated script into render-worker
→ run benchmark (both OCC and GMSH on rolling bearing)
→ verify size ≤120% and no missing mirror parts
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. **`solid.Located(_TopLoc_Location())` strips transform correctly?**
Yes — `TopoDS_Shape.Located(loc)` returns a new shape reference with the given
location applied. `TopLoc_Location()` (default constructor) is identity.
The underlying TShape geometry is unchanged; only the Shape wrapper's location changes.
`BRepTools.Write_s` will then write the definition-space geometry.
2. **`solid.Orientation() == TopAbs_REVERSED` for ALL mirrored instances?**
In XCAF assemblies loaded from STEP, mirrored instances are typically stored with
`REVERSED` orientation. However, some STEP exporters encode mirrors as a proper
negative-scale transform in the Location rather than using REVERSED orientation.
Safeguard: also check `loc.IsIdentity() == False` and compute `det(trsf_rotation)`:
```python
# Fallback determinant check if orientation check misses some cases
from OCP.gp import gp_Trsf
# trsf available via: shape._ptr ... (no direct Python binding in all OCP versions)
# Use BRepBuilderAPI_Transform trick: transform shape by identity and check inversion
```
In practice, the `TopAbs_REVERSED` check handles the majority of STEP mirror instances.
The BRepMesh fallback for reversed solids is safe (no visual difference vs before GMSH).
3. **Does `CharacteristicLengthMax × 50` produce fan-free meshes?**
Yes — GMSH Frontal-Delaunay at any density produces conforming meshes without fan
triangles. The density reduction does NOT affect the seam topology quality; only the
triangle count changes. The UV-unwrap seam advantage of GMSH is preserved at any
`CharacteristicLengthMax`.
4. **Multiplier tuning**: If 50× still produces GLB > 120% of OCC, try 70× or 100×.
The goal is seam-correctness, not mesh fidelity — larger triangles are fine for the
viewer and for UV unwrapping (seams are topological, not density-dependent).
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. ✓