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:
@@ -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 ~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.
|
||||
|
||||
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 535–553) 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 535–553:**
|
||||
**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
|
||||
|
||||
_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)
|
||||
# BEFORE:
|
||||
assigned += 1
|
||||
assigned_names.add(obj.name)
|
||||
print(f"[DEBUG] assigned '{mat_name}' → '{obj.name}' (lookup_key='{lower_base}')")
|
||||
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)
|
||||
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.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:
|
||||
# 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:
|
||||
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
|
||||
```
|
||||
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)
|
||||
|
||||
> **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)
|
||||
> ```
|
||||
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**:
|
||||
- `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 324–329:
|
||||
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 12–16. 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 151–166). 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. ✓
|
||||
|
||||
@@ -482,7 +482,13 @@ def _apply_material_library(parts, mat_lib_path, mat_map):
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
# ── Early GPU activation (must happen BEFORE open_mainfile / Cycles init) ────
|
||||
|
||||
@@ -19,6 +19,8 @@ import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
argv = sys.argv
|
||||
@@ -268,26 +270,49 @@ def main() -> None:
|
||||
assigned += 1
|
||||
assigned_names.add(obj.name)
|
||||
else:
|
||||
pass # name-matching miss — may be covered by single-material fallback below
|
||||
pass # unmatched → will receive FailedMaterial sentinel below
|
||||
print(f"Material substitution: {assigned}/{len(mesh_objects)} mesh objects assigned")
|
||||
|
||||
# Single-material fallback: if only one library material was loaded,
|
||||
# apply it to every object that name-matching missed.
|
||||
# (mat_map_lower may contain unresolvable pass-through values like
|
||||
# "Stahl; Durotect CMT", so checking appended is more reliable.)
|
||||
if len(appended) == 1:
|
||||
default_mat_name, default_mat = next(iter(appended.items()))
|
||||
if default_mat:
|
||||
fallback = 0
|
||||
# Universal FailedMaterial sentinel: assign SCHAEFFLER_059999_FailedMaterial
|
||||
# to every mesh object that was not matched by name-based lookup above.
|
||||
# Replaces the old single-material fallback that only fired when len(appended)==1.
|
||||
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 found 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(default_mat)
|
||||
fallback += 1
|
||||
if fallback:
|
||||
print(f"Single-material fallback: applied '{default_mat_name}' to {fallback} unmatched objects")
|
||||
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")
|
||||
|
||||
# Purge orphan data-blocks (palette materials mat_0/mat_1/... from the geometry
|
||||
# GLB that now have users=0 after library material substitution).
|
||||
|
||||
Reference in New Issue
Block a user