diff --git a/LEARNINGS.md b/LEARNINGS.md index e75c55e..46e2aed 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -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 diff --git a/render-worker/scripts/export_step_to_usd.py b/render-worker/scripts/export_step_to_usd.py index 8716b15..ddbea4b 100644 --- a/render-worker/scripts/export_step_to_usd.py +++ b/render-worker/scripts/export_step_to_usd.py @@ -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 (1–2 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}"