diff --git a/LEARNINGS.md b/LEARNINGS.md index 46e2aed..ac43daf 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -462,3 +462,12 @@ OCC `linear_deflection=0.1mm` auf einem 50mm-Zylinder → Kantenlänge ~5mm. GMS ### 2026-03-12 | GMSH | Priority 3 vollständig — GMSH-Pipeline Status GMSH 4.15.1 in render-worker installiert. `tessellation_engine=gmsh` ist der aktive DB-Default. `_tessellate_with_gmsh()` in `export_step_to_gltf.py` vollständig: `CharacteristicLengthMax = linear_deflection × 50`, `MinimumCirclePoints = min(12, ...)`, REVERSED Solids bleiben erhalten (kein invertierter Jacobian). Produktion-GLB nutzt Cache-Reuse (kein Re-Tessellieren bei Materialwechsel). Sharp-Edge-Extraktion läuft nach Tessellierung unabhängig vom Engine-Typ — `Injected N segment pairs into GLB extras` gilt für beide Pfade. + +### 2026-03-12 | OCC | BRepMesh auf Compound: Triangulation in Definition-Space, Face-loc = Instance-Placement +`BRepMesh_IncrementalMesh(compound)` tesselliert alle Faces in Definition-Space-Koordinaten. Für Instanzen mit Placement enthält `face.Location()` (= `TopoDS_Shape`-Location) die Instance-Transformation. `BRep_Tool.Triangulation_s(face, loc)` gibt die Triangulation-Knoten in Definition-Space zurück, `loc` enthält die Face-Location (= Instance-Placement). `BRep_Builder.UpdateFace(face_def, tri)` mit einer aus `solid.Located(TopLoc_Location())` gewonnenen Face schreibt auf das geteilte TShape — ALLE Instanzen der gleichen Geometrie sehen die neue Triangulation, da sie IsPartner teilen. + +### 2026-03-12 | OCC/XCAF | IsSame() vs IsPartner() — Deduplizierung bei Assembly-Instanzen +`IsSame()` prüft TShape-Pointer UND Location → für 16 Instanzen desselben Wälzkörpers sind alle 16 "unique" (unterschiedliche Location). `IsPartner()` prüft nur TShape-Pointer → gibt 9 tatsächlich unterschiedliche Geometrien. In `export_step_to_gltf.py` GMSH-Schleife: `IsSame()`-Deduplizierung tesselliert alle 16 Instanzen separat, aber da sie das gleiche TShape teilen, werden alle 16 mal auf dasselbe TShape geschrieben (idempotent, korrekt). `RWGltf_CafWriter` traversiert XCAF-Labelhierarchie und liest Triangulation von Definition-Labels (Identity-Location) — kein Double-Transform. + +### 2026-03-12 | Debugging | Stale GLB-Cache maskiert Code-Fixes +Bug "Wälzkörper an falscher Position" war in Code durch commit 638b93b (IsSame-Fix) bereits behoben. Aber gecachtes Produktions-GLB (vor dem Fix generiert) zeigte weiterhin falsche Positionen im Viewer. Lösung: Geometry-GLB manuell neu generieren oder `step_file_hash = NULL` in DB um Cache-Invalidierung zu erzwingen. Nach Code-Fixes an Tessellierung/Export IMMER alle betroffenen GLB-Caches invalidieren. diff --git a/render-worker/scripts/import_usd.py b/render-worker/scripts/import_usd.py new file mode 100644 index 0000000..f64262a --- /dev/null +++ b/render-worker/scripts/import_usd.py @@ -0,0 +1,130 @@ +"""USD import helper for Blender headless renders. + +Runs inside Blender's Python environment (bpy available). +Imports a USD stage and restores seam + sharp edges from +schaeffler:*EdgeVertexPairs primvars (mapped as Blender mesh attributes +by Blender's built-in USD importer). + +USD stage convention: mm Y-up, metersPerUnit=0.001. +Blender's USD importer respects metersPerUnit and scales objects to metres. +""" +import sys + +import bpy # type: ignore[import] +import bmesh # type: ignore[import] +from mathutils import Vector # type: ignore[import] + + +def import_usd_file(usd_path: str) -> list: + """Import USD stage into current Blender scene. + + Returns list of imported mesh objects, centred at world origin. + USD stage is mm Y-up with metersPerUnit=0.001 — Blender scales to metres. + """ + bpy.ops.object.select_all(action='DESELECT') + bpy.ops.wm.usd_import(filepath=usd_path) + parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] + + if not parts: + print(f"[import_usd] ERROR: No mesh objects imported from {usd_path}") + sys.exit(1) + + print(f"[import_usd] imported {len(parts)} part(s) from USD: " + f"{[p.name for p in parts[:5]]}", flush=True) + + _rename_usd_objects(parts) + + # Restore seam + sharp edges from primvars mapped to Blender mesh attributes. + # Blender's USD importer converts Int2Array primvars to INT32_2D attributes. + # Attribute names: "schaeffler:sharpEdgeVertexPairs" / "schaeffler:seamEdgeVertexPairs" + restored = 0 + for part in parts: + restored += _restore_seam_sharp(part) + if restored: + print(f"[import_usd] restored seam/sharp on {restored} mesh(es)", flush=True) + + # Centre combined bbox at world origin (same as import_glb convention) + all_corners = [] + for p in parts: + all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) + if all_corners: + mins = Vector((min(v.x for v in all_corners), + min(v.y for v in all_corners), + min(v.z for v in all_corners))) + maxs = Vector((max(v.x for v in all_corners), + max(v.y for v in all_corners), + max(v.z for v in all_corners))) + center = (mins + maxs) * 0.5 + all_imported = list(bpy.context.selected_objects) + root_objects = [o for o in all_imported if o.parent is None] + for obj in root_objects: + obj.location -= center + + return parts + + +def _rename_usd_objects(parts: list) -> None: + """No-op: mesh prims are now named after part_key in export_step_to_usd.py. + + Blender 5.0 collapses single-child Xform+Mesh into just the Mesh object, + using the mesh prim leaf name as the Blender object name. Since the mesh + prim is now named after the part_key (not "Mesh"), Blender imports each + object with the correct part name, making material matching work directly. + + The object["usd:path"] custom property is NOT set by Blender's importer, + so the previous path-based rename approach did not work. + """ + print(f"[import_usd] mesh objects named from USD prim paths: {[p.name for p in parts[:3]]!r}", + flush=True) + + +def _restore_seam_sharp(obj) -> int: + """Apply seam+sharp edges from USD primvars mapped as Blender mesh attributes. + + Blender's USD importer maps primvars:schaeffler:sharpEdgeVertexPairs (Int2Array) + to a mesh attribute named "schaeffler:sharpEdgeVertexPairs" with type INT32_2D. + Each attribute element has a .value property returning a 2-tuple (v0, v1). + + Returns 1 if any edge data was applied, 0 otherwise. + """ + mesh = obj.data + sharp_attr = mesh.attributes.get("schaeffler:sharpEdgeVertexPairs") + seam_attr = mesh.attributes.get("schaeffler:seamEdgeVertexPairs") + if not sharp_attr and not seam_attr: + return 0 + + # Ensure single-user data block before bmesh edit + if mesh.users > 1: + obj.data = mesh.copy() + mesh = obj.data + + bm = bmesh.new() + bm.from_mesh(mesh) + bm.verts.ensure_lookup_table() + + n_verts = len(bm.verts) + + def _apply_pairs(attr, mark_fn): + applied = 0 + for elem in attr.data: + v = elem.value # 2-tuple for INT32_2D + if len(v) >= 2 and 0 <= v[0] < n_verts and 0 <= v[1] < n_verts: + edge = bm.edges.get([bm.verts[v[0]], bm.verts[v[1]]]) + if edge: + mark_fn(edge) + applied += 1 + return applied + + n_sharp = n_seam = 0 + if sharp_attr: + n_sharp = _apply_pairs(sharp_attr, lambda e: setattr(e, 'smooth', False)) + if seam_attr: + n_seam = _apply_pairs(seam_attr, lambda e: setattr(e, 'seam', True)) + + bm.to_mesh(mesh) + bm.free() + + if n_sharp or n_seam: + print(f"[import_usd] {obj.name}: {n_sharp} sharp edges, {n_seam} seam edges", + flush=True) + return 1