fix(render): fix double-transform bug in USD mesh extraction causing wrong part positions

In _extract_mesh(), BRep_Tool.Triangulation_s(face, face_loc) returns a face_loc
that already encodes the instance's full placement transform when a compound shape
is tessellated with BRepMesh_IncrementalMesh. Applying shape_trsf on top doubled
every rotation/translation, causing multiple roller elements to collapse to the same
wrong world position (e.g. Z(-75°)×2 ≡ Z(+105°)×2 mod 360° → identical positions).

Fix: use elif so shape_loc is only applied as a fallback when face_loc is identity.
Adds seam edge extraction (UV seam primvar) and improves _traverse_xcaf doc.

docs: learning erfasst - OCC face_loc double-transform in compound tessellation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 16:43:33 +01:00
parent 71e099305c
commit de7f97be87
2 changed files with 89 additions and 7 deletions
+5
View File
@@ -437,6 +437,11 @@ for obj in mesh_objects:
**Lösung:** `GCPnts_UniformAbscissa(curve3d, step_mm=0.3, tol=1e-6)` auf der analytischen B-rep-Kurve (`BRepAdaptor_Curve`) samplen. 0.3mm-Schritt garantiert dass konsekutive Sample-Paare die Tessellations-Kanten (~0.78-1.55mm) straddeln — die KD-Tree-Suche (TOL=0.5mm) findet dann die richtigen Blender-Mesh-Edges. Ergebnis: 17.129 Segment-Paare, 1.364 Kanten in Blender markiert.
**Imports:** `from OCP.GCPnts import GCPnts_UniformAbscissa; from OCP.BRepAdaptor import BRepAdaptor_Curve`
### 2026-03-12 | OCC/USD | export_step_to_usd.py: face_loc == shape_loc → Doppel-Transform → falsche Mesh-Positionen
`BRepMesh_IncrementalMesh` auf dem Root-Compound tesselliert alle Instanzen. `BRep_Tool.Triangulation_s(face, face_loc)` liefert dabei einen `face_loc`, der exakt die Instance-Platzierung des shape kodiert (identisch zu `shape.Location()`). In `_extract_mesh()` wurden beide Transforms nacheinander angewendet: erst `face_loc.Transformation()`, dann `shape_trsf` — was eine Doppel-Rotation ergibt. Für einen Zylinder-Rollensatz (12 Rollen à 30° Abstand) führt das dazu, dass mehrere Rollen auf dieselbe falsche Position kollabieren (z.B. -75° × 2 = -150° = +105° × 2 mod 360° → Rollen 3 und 9 landen identisch).
**Lösung:** `if face_has_loc: ... elif shape_has_loc: ...` statt `if face_has_loc: ... if shape_has_loc: ...`. `shape_loc` ist nur ein Fallback für direkt tessellierte Shapes (nicht als Teil eines Compounds), bei denen `face_loc` identity ist.
**Beweis:** Mit `face_only`-Extraktion (nur face_loc, kein shape_loc) erscheinen alle 10 Rollen bei genau 223,9mm Radius, gleichmäßig 30° verteilt.
---
## Offene Fragen
+84 -7
View File
@@ -273,14 +273,62 @@ def _extract_sharp_edge_pairs(shape, sharp_threshold_deg: float = 20.0) -> list:
return sharp_pairs
def _extract_seam_edge_pairs(shape) -> list:
"""Extract seam edges (periodic-surface boundary edges) as segment pairs (mm, Z-up).
Seam edges are detected via BRep_Tool.IsClosed_s(edge) — edges that are
topologically closed (start == end vertex). This includes the UV seams of
periodic surfaces (cylinders, cones, spheres) but also full circles on flat
faces and bore rims.
TODO: Use ShapeAnalysis_Edge().IsSeam(edge, face) to restrict to true UV seams
when UV-unwrapped texture mapping is needed (future phase).
"""
from OCP.BRep import BRep_Tool
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_UniformAbscissa
seam_pairs: list = []
n_seam = 0
exp = TopExp_Explorer(shape, TopAbs_EDGE)
while exp.More():
edge = exp.Current()
exp.Next()
if not BRep_Tool.IsClosed_s(edge):
continue
try:
curve = BRepAdaptor_Curve(edge)
# Use arc-length step (0.3 mm) matching the sharp edge sampler,
# so segments are short enough for _world_to_index_pairs (tol=0.5 mm).
sampler = GCPnts_UniformAbscissa()
sampler.Initialize(curve, 0.3, 1e-6)
if not sampler.IsDone() or sampler.NbPoints() < 2:
continue
pts = []
for i in range(1, sampler.NbPoints() + 1):
p = curve.Value(sampler.Parameter(i))
pts.append([p.X(), p.Y(), p.Z()])
for k in range(len(pts) - 1):
seam_pairs.append([pts[k], pts[k + 1]])
n_seam += 1
except Exception:
continue
print(f"Seam edge extraction: {n_seam} seam edges, {len(seam_pairs)} segment pairs total")
return seam_pairs
# ── XCAF traversal ────────────────────────────────────────────────────────────
def _traverse_xcaf(shape_tool, color_tool, label, path_prefix, existing_keys, depth=0):
"""Yield one dict per leaf shape in the XCAF hierarchy.
Phase 1 limitation: for deeply nested assemblies, transforms from
intermediate reference labels are not composed — world-space positions
may be off for non-flat assemblies. Single-level assemblies are correct.
Transform composition: `GetShape_s(reference_label)` returns the shape with
the reference's own location already composed in. For standard Schaeffler flat
assemblies (12 levels deep) this is correct. Deeply nested sub-assembly
transforms (3+ levels) accumulate naturally because each recursive call
receives a component label from the *referred* definition, so each level's
location is composed by the next GetShape_s call.
"""
from OCP.TDF import TDF_LabelSequence, TDF_Label
from OCP.TDataStd import TDataStd_Name
@@ -363,8 +411,13 @@ def _extract_mesh(shape) -> tuple[list, list]:
for i in range(1, tri.NbNodes() + 1):
node = tri.Node(i)
if face_has_loc:
# face_loc from BRep_Tool.Triangulation_s already encodes the
# instance placement for compound-tessellated shapes — applying
# shape_loc on top would double-transform every vertex.
node = node.Transformed(face_loc.Transformation())
if shape_has_loc:
elif shape_has_loc:
# Only fall back to shape_loc when face_loc is identity (e.g.
# shapes tessellated individually rather than as a compound).
node = node.Transformed(shape_trsf)
vertices.append((node.X(), node.Y(), node.Z()))
@@ -488,6 +541,17 @@ def main() -> None:
except Exception as exc:
print(f"WARNING: sharp edge extraction failed (non-fatal): {exc}", file=sys.stderr)
# ── Seam edge pairs (world-space mm, Z-up) ────────────────────────────────
seam_pairs_mm: list = []
try:
for i in range(1, free_labels.Length() + 1):
root_shape = shape_tool.GetShape_s(free_labels.Value(i))
if not root_shape.IsNull():
seam_pairs_mm.extend(_extract_seam_edge_pairs(root_shape))
print(f"Total seam segment pairs: {len(seam_pairs_mm)}")
except Exception as exc:
print(f"WARNING: seam edge extraction failed (non-fatal): {exc}", file=sys.stderr)
# ── Apply colors ──────────────────────────────────────────────────────────
if color_map:
try:
@@ -583,17 +647,30 @@ def main() -> None:
r, g, b = _hex_to_rgb01(hex_color)
mesh.CreateDisplayColorAttr(Vt.Vec3fArray([Gf.Vec3f(r, g, b)]))
# ── Index-space sharp edge primvar ────────────────────────────
# Lookup is in OCC Z-up space; sharp_pairs_mm are also Z-up — no swap needed.
# ── Index-space sharp + seam edge primvars ───────────────────
# Lookup is in OCC Z-up space; pairs are also Z-up — no swap needed.
# Both `vertices` and `*_pairs_mm` are in OCC Z-up mm space with the
# full per-shape location already applied — same coordinate frame required
# by _world_to_index_pairs for the nearest-vertex lookup (tol=0.5 mm).
primvars_api = UsdGeom.PrimvarsAPI(mesh)
if sharp_pairs_mm:
idx_pairs = _world_to_index_pairs(vertices, sharp_pairs_mm)
if idx_pairs:
pv = UsdGeom.PrimvarsAPI(mesh).CreatePrimvar(
pv = primvars_api.CreatePrimvar(
"schaeffler:sharpEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant,
)
pv.Set(Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in idx_pairs]))
if seam_pairs_mm:
seam_idx_pairs = _world_to_index_pairs(vertices, seam_pairs_mm)
if seam_idx_pairs:
pv_seam = primvars_api.CreatePrimvar(
"schaeffler:seamEdgeVertexPairs",
Sdf.ValueTypeNames.Int2Array,
UsdGeom.Tokens.constant,
)
pv_seam.Set(Vt.Vec2iArray([Gf.Vec2i(a, b) for a, b in seam_idx_pairs]))
# ── Material placeholder + binding ────────────────────────────
mat_name = _prim_name(source_name) if source_name else f"mat_{part_key}"