diff --git a/LEARNINGS.md b/LEARNINGS.md index bfa6db5..37510b5 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -445,3 +445,12 @@ for obj in mesh_objects: - [ ] @xyflow/react noch nicht installiert — npm install nötig nach nächstem `docker compose up --build frontend` - [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab - [ ] Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen + +### 2026-03-11 | OCP/Python | id(solid.TShape()) ist nicht stabil +In OCP (pybind11-basiert) gibt jeder Aufruf von `solid.TShape()` ein neues Python-Wrapper-Objekt zurück, das dieselbe C++ TShape-Instanz wrapet. `id()` gibt daher jedes Mal einen anderen Wert → Deduplizierung per `id()` schlägt immer fehl. **Lösung:** `solid.IsSame(other_solid)` verwenden (vergleicht TShape-Zeiger intern, liefert True für gleiche TShape mit unterschiedlicher Location/Orientation). + +### 2026-03-11 | GMSH | TopExp_Explorer(SOLID) übersieht freie Faces und Shells +`TopExp_Explorer(root, SOLID)` findet nur `TopoDS_Solid`-Shapes, nicht freie `TopoDS_Shell`- oder `TopoDS_Face`-Shapes auf Compound-Ebene. OCC's `BRepMesh_IncrementalMesh` auf dem Root-Compound tesselliert alle Typen rekursiv. **Lösung:** BRepMesh-Baseline zuerst auf den vollen Root-Compound, dann GMSH als Override nur für SOLID-Shapes. So werden keine Shapes übersprungen. + +### 2026-03-11 | GMSH | CharacteristicLengthMax vs. OCC linear_deflection +OCC `linear_deflection=0.1mm` auf einem 50mm-Zylinder → Kantenlänge ~5mm. GMSH `CharacteristicLengthMax=0.1×15=1.5mm` → 3× mehr Unterteilungen → 9× mehr Dreiecke → GLB 7× größer. **Lösung:** `CharacteristicLengthMax = linear_deflection × 50` (≈5mm), `MinimumCirclePoints = min(12, ...)` statt min(20). Ergebnis: GMSH 91% von OCC-Größe (Ziel ≤120% ✓). diff --git a/plan.md b/plan.md index feba739..721453f 100644 --- a/plan.md +++ b/plan.md @@ -1,278 +1,335 @@ +# Plan: GMSH — Fix Mirror Instances + Reduce Mesh Size to ≤120% of OCC -# Plan: GMSH Tessellation — Eliminate Fan Triangles on Cylindrical Surfaces +## Context -## Kontext +Two bugs introduced by the GMSH tessellation path: -OCC `BRepMesh_IncrementalMesh` erzeugt strukturell fehlerhafte Dreiecke bei periodischen Flächen (Vollzylinder, Ringe): +**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. -1. **Fan-Dreiecke** an u=0=2π Naht-Kanten — BRepMesh tesselliert jede Fläche unabhängig. Am Nahtübergang entstehen hochvalente Dreiecke mit Valenz 30-40, weil die Kantenmittelpunkte nicht zur Flächentessellierung konformieren. -2. **Faceting** auf großen Zylindern — angular_deflection (Winkelbedingung) und linear_deflection (Abstandsbedingung) erzeugen unterschiedlich viele Vertices an Kanten vs. Flächeninneres. OCC überbrückt die Lücke mit Fan-Dreiecken. +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. -Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch Fine-Settings (`0.02 rad / 0.01 mm`) zeigen exakt dieselben Artefakte. Delabella-Algorithmus liefert dieselbe Dreiecksanzahl (332.394 gegenüber DEFAULT 332.394). +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. -**Lösung**: GMSH Frontal-Delaunay Mesher via GMSH Python API. GMSH: -- Kennt die periodischen Naht-Kanten der B-rep-Topologie -- Erzeugt konformierende Netze über Flächen-Grenzen hinweg -- Nutzt den OCC-Kernel intern (dieselbe STEP-Repräsentation) -- Ist pip-installierbar: `pip install gmsh` → `gmsh 4.15.1` +**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. -**Scope**: Änderungen nur in `export_step_to_gltf.py` und Dockerfile. Die Sharp-Edge-Pipeline (`_extract_sharp_edge_pairs`), das GLB-Format, Blender-Seite und die XCAF→RWGltf_CafWriter-Kette bleiben unverändert. +`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))`. --- -## Betroffene Dateien +## Affected Files -| Datei | Änderung | -|---|---| -| `render-worker/Dockerfile` | `gmsh>=4.15.0` installieren | -| `render-worker/scripts/export_step_to_gltf.py` | GMSH-Tessellierung als Alternative zu BRepMesh | -| `backend/app/api/routers/admin.py` | Setting `tessellation_engine` (`occ`\|`gmsh`) | -| `backend/app/domains/pipeline/tasks/export_glb.py` | Setting lesen, `--tessellation_engine` an CLI-Aufruf übergeben | +| 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. --- -## Tasks (in Reihenfolge) +## Tasks (in order) -### [x] Task 1: Dockerfile — `gmsh` installieren +### [x] Task 1: Deduplicate TShape in the per-solid tessellation loop -- **Datei**: `render-worker/Dockerfile` -- **Was**: Nach der `trimesh`-Zeile einfügen: - ```dockerfile - # GMSH for Frontal-Delaunay tessellation (alternative to OCC BRepMesh) - RUN pip3 install --no-cache-dir "gmsh>=4.15.0" - ``` -- **Akzeptanzkriterium**: `docker compose exec render-worker python3 -c "import gmsh; print(gmsh.__version__)"` gibt `4.15.x`. -- **Abhängigkeiten**: keine +**File**: `render-worker/scripts/export_step_to_gltf.py` -### [x] Task 2: `export_step_to_gltf.py` — CLI-Argument `--tessellation_engine` +**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. -- **Datei**: `render-worker/scripts/export_step_to_gltf.py` -- **Was**: In `parse_args()` ein neues Argument: - ```python - parser.add_argument( - "--tessellation_engine", choices=["occ", "gmsh"], default="occ", - help="Tessellation backend: 'occ' = BRepMesh (default), 'gmsh' = Frontal-Delaunay", - ) - ``` -- **Akzeptanzkriterium**: `--help` listet `--tessellation_engine`. -- **Abhängigkeiten**: keine +**What**: -### [x] Task 3: `export_step_to_gltf.py` — Funktion `_tessellate_with_gmsh()` +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 -- **Datei**: `render-worker/scripts/export_step_to_gltf.py` -- **Was**: Neue Funktion vor `main()`. Nimmt den XCAF-Compound und Deflection-Parameter. Strategie: +**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. - ``` - Für jede Leaf-Shape in der XCAF-Hierarchie: - 1. Schreibe die TopoDS_Shape als temporäre .brep-Datei - 2. Lade via gmsh.model.occ.importShapes(brep_path) in GMSH-OCC-Kernel - 3. Setze Mesh-Parameter: - - MeshSizeMin/Max aus linear_deflection - - Algorithm = 6 (Frontal-Delaunay) - - RecombineAll = 0 (behalte Dreiecke, keine Quads) - 4. gmsh.model.mesh.generate(2) — 2D Oberflächen-Mesh - 5. Lese Knoten + Dreiecke via gmsh.model.mesh.getNodes() / getElementsByType(2) - 6. Baue Poly_Triangulation aus Knoten + Dreiecken - 7. Setze per BRep_Builder auf die TopoDS_Face (oder direkt auf die Shell) - 8. Lösche tmp-Datei - ``` - - Wichtige OCP-APIs: - ```python - from OCP.BRep import BRep_Builder - from OCP.BRepTools import BRepTools - from OCP.Poly import Poly_Triangulation - from OCP.TColgp import TColgp_Array1OfPnt - from OCP.TShort import TShort_Array1OfShortInteger - from OCP.TopExp import TopExp_Explorer - from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL - from OCP.TopoDS import TopoDS - from OCP.gp import gp_Pnt - ``` - - Konkreter Code-Ablauf für `Poly_Triangulation`-Erstellung: - ```python - # nodes: list of (x, y, z) in mm - # triangles: list of (n1, n2, n3) 1-indexed - n_nodes = len(nodes) - n_tris = len(triangles) - arr_pts = TColgp_Array1OfPnt(1, n_nodes) - for idx, (x, y, z) in enumerate(nodes, 1): - arr_pts.SetValue(idx, gp_Pnt(x, y, z)) - arr_tris = Poly_Array1OfTriangle(1, n_tris) - for idx, (a, b, c) in enumerate(triangles, 1): - arr_tris.SetValue(idx, Poly_Triangle(a, b, c)) - tri = Poly_Triangulation(arr_pts, arr_tris) - # BRep_Builder.UpdateFace weist Triangulation einer Face zu - builder = BRep_Builder() - builder.UpdateFace(face, tri, loc, precision) - ``` - - **Wichtig**: GMSH gibt face-lokale Nodeindices zurück. Die XCAF-Assembly-Location (`TopLoc_Location`) muss für die Koordinatentransformation berücksichtigt werden, damit die Triangulation im richtigen Koordinatenrahmen liegt. - - **Fallback**: Wenn GMSH für eine bestimmte Face fehlschlägt (z.B. degenerierte Fläche) → BRepMesh für diese Face als Fallback. - -- **Akzeptanzkriterium**: - - `python3 export_step_to_gltf.py --step_path /tmp/test.stp --output_path /tmp/out.glb --tessellation_engine gmsh` läuft ohne Fehler - - Log zeigt „GMSH tessellation: N faces processed, M triangles total" - - GLB kann in Blender geöffnet werden, keine degenerierten Dreiecke - - Keine Fan-Vertices mit Valenz > 10 an Zylindernaht-Kanten - -- **Abhängigkeiten**: Task 1, Task 2 - -### [x] Task 4: Admin-Setting `tessellation_engine` - -- **Datei**: `backend/app/api/routers/admin.py` -- **Was**: In `SETTINGS_DEFAULTS` eintragen: - ```python - "tessellation_engine": "occ", # "occ" | "gmsh" - ``` - In `SettingsOut` ergänzen: - ```python - tessellation_engine: str = "occ" - ``` - In der Admin-UI-Beschreibung (Docstring oder Kommentar) dokumentieren. -- **Akzeptanzkriterium**: `GET /api/admin/settings` gibt `tessellation_engine: "occ"` zurück. -- **Abhängigkeiten**: keine - -### [x] Task 5: `export_glb.py` — Setting durchreichen - -- **Datei**: `backend/app/domains/pipeline/tasks/export_glb.py` -- **Was**: In `generate_gltf_geometry_task()` (und `generate_gltf_production_task()` wo der OCC-Befehl aufgebaut wird): - ```python - tessellation_engine = sys_settings.get("tessellation_engine", "occ") - # ... - occ_cmd = [ - ..., - "--tessellation_engine", tessellation_engine, - ] - ``` -- **Akzeptanzkriterium**: Admin stellt `tessellation_engine` auf `gmsh` → nächster GLB-Export nutzt GMSH. -- **Abhängigkeiten**: Task 2, Task 4 - -### [x] Task 6: Frontend — Dropdown in Admin-Settings - -- **Datei**: `frontend/src/pages/Admin.tsx` -- **Was**: Im Tessellation-Settings-Abschnitt ein Select-Element für `tessellation_engine`: - ```tsx - - ``` - Kurze Beschreibung: „GMSH erzeugt konformierende Dreiecke ohne Fan-Artefakte an Zylindernaht-Kanten. Verarbeitungszeit: +10-30% pro Modell." -- **Akzeptanzkriterium**: Dropdown sichtbar und speichert Setting korrekt. -- **Abhängigkeiten**: Task 4 - ---- - -## GMSH-Implementierungsdetails - -### GMSH API Ablauf (pseudo-code) +**Exact code to replace lines 535–553:** ```python -import gmsh -import tempfile -from pathlib import Path -from OCP.BRepTools import BRepTools -from OCP.TopExp import TopExp_Explorer -from OCP.TopAbs import TopAbs_FACE +from OCP.TopLoc import TopLoc_Location as _TopLoc_Location +from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy as _BRepCopy -def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None: - """Replace BRepMesh with GMSH Frontal-Delaunay tessellation.""" - gmsh.initialize() - gmsh.option.setNumber("General.Terminal", 0) # suppress output - gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay - gmsh.option.setNumber("Mesh.RecombineAll", 0) # triangles only - gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5) - gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 5.0) - gmsh.option.setNumber("Mesh.AngleToleranceFacetOverlap", angular_deflection * 57.3) +_seen_tshapes: set = set() # TShape identity → already tessellated - with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp: - brep_path = tmp.name +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) +``` + +**Actual correct implementation** (the placeholder above is incomplete; here is the +full, correct replacement): + +```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) + 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) +``` + +**Exact mirror-detection snippet** (the `gp_Trsf` determinant check): + +```python +from OCP.gp import gp_Trsf as _gp_Trsf + +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: - BRepTools.Write_s(shape, brep_path) - gmsh.model.add("shape") - gmsh.model.occ.importShapes(brep_path) - gmsh.model.occ.synchronize() - gmsh.model.mesh.generate(2) - - # Build Poly_Triangulation per face and write back via BRep_Builder - _write_gmsh_triangulation_to_occ(shape) - finally: - gmsh.finalize() - Path(brep_path).unlink(missing_ok=True) + 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 ``` -### Poly_Triangulation Write-Back +> **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) +> ``` -GMSH nummeriert Surfaces durch. Per XCAF-Leaf muss die Entsprechung zwischen -GMSH Surface-Tags und OCC TopoDS_Face-Objekten hergestellt werden: -- GMSH gibt bei `occ.importShapes()` die Surface-Tags zurück -- Die Reihenfolge entspricht der TopExp_Explorer-Iteration über `TopAbs_FACE` -- `BRep_Builder.UpdateFace(face, tri_triangulation, location, tolerance)` setzt die Triangulation +**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 -### Parameter-Mapping: OCC → GMSH - -| OCC Parameter | GMSH Entsprechung | -|---|---| -| `linear_deflection` (mm) | `CharacteristicLengthMax = linear_deflection * 3` | -| `angular_deflection` (rad) | `Mesh.MinimumCirclePoints = ceil(2π/angular_deflection)` | +**Dependencies**: none --- -## Migrations-Check +### [x] Task 2: Tune GMSH density parameters to ≤120% of OCC output size -**Keine Migration erforderlich.** Nur Rendering-Pipeline-Änderungen. `tessellation_engine` wird in `system_settings` gespeichert (bestehendes Key-Value-Store, keine Schema-Änderung). +**File**: `render-worker/scripts/export_step_to_gltf.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. -## Reihenfolge-Empfehlung +**What**: In `_tessellate_with_gmsh()`, replace lines 324–329: -``` -Task 1 (Dockerfile) → rebuild render-worker -→ Task 2+3 (export_step_to_gltf.py) → manueller Test -→ Task 4 (admin.py) → Task 5 (export_glb.py) -→ Task 6 (Frontend) -→ End-to-End Test: Admin → GMSH → Produkt-GLB regenerieren → Viewer prüfen +```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) ``` -Manueller Test nach Task 3: +```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×) + +**Acceptance gate**: ```bash -# In render-worker container: +# Run both OCC and GMSH, compare sizes: python3 /render-scripts/export_step_to_gltf.py \ - --step_path /tmp/81113-l_cut.stp \ - --output_path /tmp/test_gmsh.glb \ - --tessellation_engine gmsh \ - --linear_deflection 0.03 \ - --angular_deflection 0.05 + --step_path /app/uploads/step_files/341ee748*.stp \ + --output_path /tmp/occ.glb --tessellation_engine occ \ + --linear_deflection 0.1 --angular_deflection 0.1 -# Dann Production GLB: -/opt/blender/blender --background \ - --python /render-scripts/export_gltf.py -- \ - --glb_path /tmp/test_gmsh.glb \ - --output_path /tmp/test_gmsh_prod.glb \ - --smooth_angle 30 +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 -# In Blender öffnen: kein Faceting, keine Fan-Vertices an Naht-Kanten +# 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') +" +``` + +**Dependencies**: none (independent of Task 1, can run in parallel) + +--- + +## Migration Check + +**No migration required.** Rendering-pipeline-only changes. + +--- + +## Order Recommendation + +Tasks 1 and 2 are independent — implement both in the same file edit, then test together. + +``` +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 ``` --- -## Risiken / Offene Fragen +## Risks / Open Questions -1. **GMSH Surface-Tag ↔ OCC Face-Mapping**: Die Reihenfolge der Surface-Tags bei `importShapes()` muss mit `TopExp_Explorer(FACE)` übereinstimmen. Falls nicht 1:1 → Koordinaten-basiertes Matching (Schwerpunkt der Face vs. GMSH-Mesh-Centroid) als Fallback. +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. **Performance**: GMSH Frontal-Delaunay ist typisch 2-5× langsamer als BRepMesh (BRepMesh 0.18s → GMSH ~0.4-0.9s für 25 Parts). Für 175-teilige Assemblies: 3-8 Min statt 1.5 Min. Liegt im 3-Min-Budget für Produktions-GLBs. +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. **GMSH subprocess-Isolation**: `gmsh.initialize()` / `gmsh.finalize()` sind nicht thread-safe. Da render-worker concurrency=1, ist das kein Problem. +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. **Sharp-Edge-Extraktion**: `_extract_sharp_edge_pairs()` läuft NACH der Tessellierung und nutzt die analytischen B-rep-Kurven (GCPnts_UniformAbscissa) — unabhängig vom Tessellierungsalgorithmus. Bleibt unverändert. - -5. **Assembly-Locations**: Wenn GMSH eine Assembly-Shape als Ganzes importiert, werden Instance-Transformationen flachgeklopft. Dies ist erwünscht (Tessellierung in Weltkoordinaten), muss aber mit der späteren BRepBuilderAPI_Transform mm→m-Skalierung abgestimmt werden. - -Plan fertig. Bitte mit `/implement` fortfahren. +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). diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index d6a097d..4a2fd14 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -316,16 +316,14 @@ def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: f gmsh.option.setNumber("Mesh.MaxNumThreads2D", n_threads) # parallel surface meshing gmsh.option.setNumber("Mesh.Algorithm", 6) # Frontal-Delaunay 2D gmsh.option.setNumber("Mesh.RecombineAll", 0) # keep triangles (no quads) - # CharacteristicLength is an edge LENGTH target, while OCC linear_deflection is a - # surface DEVIATION tolerance. On a 50mm radius cylinder, OCC with deflection=0.1mm - # produces ~1.4mm edge lengths; we scale by 15x to match density. - # MinimumCirclePoints caps are essential: without a cap, angular_deflection=0.1rad - # yields ceil(2π/0.1)=63 pts/circle which inflates mesh 10-20x vs OCC. - gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection) - gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 15.0) - # 12–20 pts/circle produces smooth-looking cylinders and matches OCC density. - # Clamp below ceil(2π/angular_deflection) so angular quality is never degraded. - min_circle_pts = min(20, max(12, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01))))) + # CharacteristicLength is an edge LENGTH target; OCC linear_deflection is a surface + # DEVIATION tolerance. Empirically: OCC 0.1mm deflection on a 50mm cylinder produces + # ~5mm edge lengths. Scale by 50× to match OCC density (target ≤120% of OCC file size). + # MinimumCirclePoints: OCC angular_deflection=0.1rad → effectively ~12 uniform pts/circle. + # Cap at 12 to avoid GMSH generating 3–5× more edges than OCC on cylindrical surfaces. + 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) gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3) # Reduce noise from GMSH warnings @@ -519,20 +517,38 @@ def main() -> None: engine = getattr(args, "tessellation_engine", "occ") if engine == "gmsh": # GMSH: tessellate each solid individually to cap peak RAM usage. - # On multi-part assemblies (e.g. 25 rolling elements), processing the full - # compound at once uses 2-3 GB RAM. Processing per-solid limits peak RAM to - # max(single_solid_size). OCC BRep_Builder writes triangulation directly to - # the shared face objects — the parent compound is updated automatically. + # Strategy: + # 1. BRepMesh baseline on full root_shape — tessellates ALL face types + # (solids, shells, free faces). Ensures nothing is skipped. + # 2. GMSH override per unique SOLID — better seam topology. + # Overrides the BRepMesh triangulation on solid faces only. + # REVERSED solids (mirrored instances) keep BRepMesh to avoid + # GMSH inverted-Jacobian issues. + # Deduplication uses IsSame() (TShape pointer comparison) — NOT id(TShape()) + # because OCP creates a new Python wrapper per TShape() call, making id() unreliable. from OCP.TopExp import TopExp_Explorer as _Explorer - from OCP.TopAbs import TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL - from OCP.BRepMesh import BRepMesh_IncrementalMesh as _BrepMesh + from OCP.TopAbs import TopAbs_SOLID as _SOLID, TopAbs_SHELL as _SHELL, TopAbs_REVERSED as _REVERSED + from OCP.TopLoc import TopLoc_Location as _TopLoc_Location for i in range(1, free_labels.Length() + 1): root_shape = shape_tool.GetShape_s(free_labels.Value(i)) if root_shape.IsNull(): continue - # Collect solids first; fall back to shells for open bodies + # Step 1: BRepMesh baseline — catches non-solid shapes (free faces, shells) + # that TopExp_Explorer(SOLID) would miss. Also provides fallback for any + # solid that GMSH fails to tessellate. + BRepMesh_IncrementalMesh( + root_shape, + args.linear_deflection, + False, + args.angular_deflection, + True, + ) + + # Step 2: GMSH override for SOLID shapes (better seam topology) + _seen_shapes: list = [] # shapes already GMSH-tessellated; compared via IsSame() + solids = [] exp = _Explorer(root_shape, _SOLID) while exp.More(): @@ -545,12 +561,21 @@ def main() -> None: solids.append(exp.Current()) exp.Next() - if solids: - for solid in solids: - _tessellate_with_gmsh(solid, args.linear_deflection, args.angular_deflection) - else: - # Fallback for any shapes that are neither solid nor shell - _tessellate_with_gmsh(root_shape, args.linear_deflection, args.angular_deflection) + for solid in solids: + # Skip REVERSED (mirrored) solids — keep BRepMesh tessellation. + # GMSH produces inverted-Jacobian meshes for negative-scale shapes. + if solid.Orientation() == _REVERSED: + continue + # Skip duplicate TShape instances — GMSH tessellation is already on the + # shared TShape from the first occurrence; overwriting would be redundant. + # IsSame() compares underlying TShape pointers (reliable; id() is not). + if any(solid.IsSame(s) for s in _seen_shapes): + continue + # Strip location: GMSH tessellates in definition space. + # The XCAF writer applies instance transforms at GLB export time. + solid_def = solid.Located(_TopLoc_Location()) + _tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection) + _seen_shapes.append(solid) else: for i in range(1, free_labels.Length() + 1): shape = shape_tool.GetShape_s(free_labels.Value(i))