From dbc032ec74ef4c3f86ea1af0f7effb214fba8987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 11 Mar 2026 20:45:27 +0100 Subject: [PATCH] feat(gmsh): GMSH Frontal-Delaunay tessellation for clean cylinder seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-solid iteration prevents OOM on multi-part assemblies (25-part bearing: 2.3GB RAM when processing compound → ~100MB per solid with per-solid approach) - Fix CharacteristicLengthMax multiplier 5× → 15× and cap MinimumCirclePoints at 20 (prevents 63-pts/circle on angular_deflection=0.1rad → 231MB → 21MB) - Geometry task timeout 120s → 600s for large assemblies - Production task: reuse _geometry.glb when GMSH enabled (no re-tessellation) and cache _production_geom.glb for OCC (mtime vs STEP check) - Viewer now prefers production GLB when available (shows correct GMSH mesh) - GMSH OpenMP multithreading (min(cpu_count,16)) for 4.4× speedup Co-Authored-By: Claude Sonnet 4.6 --- LEARNINGS.md | 15 +++ .../app/domains/pipeline/tasks/export_glb.py | 105 ++++++++++++------ .../src/components/cad/InlineCadViewer.tsx | 5 +- render-worker/scripts/export_step_to_gltf.py | 57 ++++++++-- 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/LEARNINGS.md b/LEARNINGS.md index a2d9a4a..bfa6db5 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -73,6 +73,21 @@ Nach `cd frontend` im Bash-Tool blieb CWD in `frontend/` → Hook-Pfad nicht gef `current_setting('app.current_tenant_id')` wirft Exception wenn nicht gesetzt. **Lösung:** `current_setting('app.current_tenant_id', true)` — zweites Argument macht Funktion Null-safe. Admin-Bypass: separate Policy mit `SET LOCAL app.current_tenant_id = 'bypass'`. +### 2026-03-11 | Tessellation | GMSH CharacteristicLength ≠ OCC linear_deflection +OCC `linear_deflection` ist ein **Oberflächenabweichungs-Toleranzwert** (max. Abstand Mesh→echte Fläche). GMSH `CharacteristicLengthMax` ist eine **Kantenlängenvorgabe**. Gleicher Wert (0.1) erzeugt bei GMSH 50× mehr Dreiecke → 231MB statt 3MB. +**Lösung:** `CharacteristicLengthMax = linear_deflection * 15.0` (15× Faktor). `MinimumCirclePoints = min(20, ceil(2π/angular_deflection))` — ohne Cap liefert `angular_deflection=0.1rad` → 63 Punkte/Kreis (10× zu dicht). Mit Cap 20: ~20MB statt 231MB, OCC-ähnliche Dichte bei 0 Fan-Dreiecken. + +### 2026-03-11 | Tessellation | BRep_Builder.UpdateFace — richtige Signatur +OCP Python API: `BRep_Builder.UpdateFace(face, triangulation)` — 2-Argumente-Form, NICHT `(face, tri, loc, tolerance)` wie in C++-Doku. Falsche Signatur führt zu Silent-Exception, alle Faces fallen auf BRepMesh zurück. + +### 2026-03-11 | Tessellation | GMSH OOM bei Assembly-Compound +GMSH verarbeitet ganzen Compound auf einmal → 25-teilige Lager-Baugruppe: 2.3GB RAM → OOM-Kill (exit -9). +**Lösung:** Per-Solid-Iteration via `TopExp_Explorer(root_shape, TopAbs_SOLID)`. `BRep_Builder.UpdateFace` aktualisiert Face-Objekte in-place; Parent-Compound sieht Updates automatisch. + +### 2026-03-11 | Celery | Timeout in Worker-Code ≠ Running Worker liest neue Version +`export_glb.py` mit 600s-Timeout in Container-Datei — aber Celery-Worker hatte Code beim Start geladen. Fehler zeigt `timeout=120` obwohl Datei 600 enthält. +**Lösung:** `docker compose restart render-worker` nach Datei-Update. Celery lädt Module beim Start, nicht bei Task-Ausführung. + ### 2026-03-06 | Refactor | Domain-Driven Migration: Compat-Shims statt Big-Bang Vollständige Migration in einem Schritt bricht alle Imports. **Lösung:** Alte Dateien werden Re-Export-Shims: `from app.domains.auth.models import User; __all__ = ["User"]`. Erst nach vollständiger Import-Migration Shims entfernen. diff --git a/backend/app/domains/pipeline/tasks/export_glb.py b/backend/app/domains/pipeline/tasks/export_glb.py index bcea740..173563b 100644 --- a/backend/app/domains/pipeline/tasks/export_glb.py +++ b/backend/app/domains/pipeline/tasks/export_glb.py @@ -107,7 +107,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str): ) try: - result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = _subprocess.run(cmd, capture_output=True, text=True, timeout=600) for line in result.stdout.splitlines(): logger.info("[occ-gltf] %s", line) for line in result.stderr.splitlines(): @@ -243,34 +243,75 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb" python_bin = _sys.executable sharp_threshold = float(sys_settings.get("sharp_edge_threshold", "20.0")) - occ_cmd = [ - python_bin, str(occ_script), - "--step_path", str(step_path), - "--output_path", str(prod_geom_glb), - "--linear_deflection", str(prod_linear), - "--angular_deflection", str(prod_angular), - "--sharp_threshold", str(sharp_threshold), - "--tessellation_engine", tessellation_engine, - ] - log_task_event( - self.request.id, - f"Re-exporting STEP at production quality (linear={prod_linear}mm, angular={prod_angular}rad)", - "info", - ) - try: - occ_result = _subprocess.run(occ_cmd, capture_output=True, text=True, timeout=180) - for line in occ_result.stdout.splitlines(): - logger.info("[occ-prod] %s", line) - if occ_result.returncode != 0 or not prod_geom_glb.exists() or prod_geom_glb.stat().st_size == 0: - raise RuntimeError( - f"OCC export failed (exit {occ_result.returncode}): {occ_result.stderr[-500:]}" - ) - except Exception as exc: - log_task_event(self.request.id, f"OCC re-export failed: {exc}", "error") - pl.step_error("export_glb_production", f"OCC re-export failed: {exc}", exc) - raise self.retry(exc=exc, countdown=30) - geom_glb_path = prod_geom_glb + # --- Geometry GLB selection strategy --- + # When GMSH is enabled, the geometry GLB (_geometry.glb) is already a conforming + # mesh with correct seam topology — GMSH quality comes from the algorithm, not density. + # Re-tessellating at finer production settings only wastes time and RAM on large assemblies. + # → For GMSH: reuse the existing _geometry.glb if it is newer than the STEP file. + # → For OCC: generate a separate _production_geom.glb at finer settings (density matters). + + step_mtime = step_path.stat().st_mtime if step_path.exists() else 0 + preview_glb = step_path.parent / f"{step_path.stem}_geometry.glb" + + preview_glb_valid = ( + preview_glb.exists() + and preview_glb.stat().st_size > 0 + and preview_glb.stat().st_mtime >= step_mtime + ) + prod_geom_cache_valid = ( + prod_geom_glb.exists() + and prod_geom_glb.stat().st_size > 0 + and prod_geom_glb.stat().st_mtime >= step_mtime + ) + + if tessellation_engine == "gmsh" and preview_glb_valid: + # Fast path: reuse geometry GLB — GMSH topology is already correct at preview quality + geom_glb_path = preview_glb + log_task_event( + self.request.id, + f"GMSH: reusing geometry GLB as Blender input ({preview_glb.stat().st_size // 1024}KB, " + f"no re-tessellation needed)", + "info", + ) + elif prod_geom_cache_valid: + # Cache hit: production_geom.glb exists and is up-to-date + geom_glb_path = prod_geom_glb + log_task_event( + self.request.id, + f"Cache hit: reusing production geometry GLB ({prod_geom_glb.stat().st_size // 1024}KB)", + "info", + ) + else: + # No usable cache: run tessellation from STEP + occ_cmd = [ + python_bin, str(occ_script), + "--step_path", str(step_path), + "--output_path", str(prod_geom_glb), + "--linear_deflection", str(prod_linear), + "--angular_deflection", str(prod_angular), + "--sharp_threshold", str(sharp_threshold), + "--tessellation_engine", tessellation_engine, + ] + log_task_event( + self.request.id, + f"Tessellating STEP at production quality ({tessellation_engine}, " + f"linear={prod_linear}mm, angular={prod_angular}rad)", + "info", + ) + try: + occ_result = _subprocess.run(occ_cmd, capture_output=True, text=True, timeout=600) + for line in occ_result.stdout.splitlines(): + logger.info("[occ-prod] %s", line) + if occ_result.returncode != 0 or not prod_geom_glb.exists() or prod_geom_glb.stat().st_size == 0: + raise RuntimeError( + f"OCC export failed (exit {occ_result.returncode}): {occ_result.stderr[-500:]}" + ) + except Exception as exc: + log_task_event(self.request.id, f"OCC re-export failed: {exc}", "error") + pl.step_error("export_glb_production", f"OCC re-export failed: {exc}", exc) + raise self.retry(exc=exc, countdown=30) + geom_glb_path = prod_geom_glb # --- 2. Resolve material map from Product.cad_part_materials (SCHAEFFLER library names) --- # cad_part_materials lives on Product (list[dict]), NOT on CadFile. @@ -344,12 +385,8 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None pl.step_error("export_glb_production", f"Blender production GLB failed: {exc}", exc) logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc) raise self.retry(exc=exc, countdown=30) - finally: - # Clean up the high-quality temp geometry GLB (not needed after Blender export) - try: - prod_geom_glb.unlink(missing_ok=True) - except Exception: - pass + # Note: _production_geom.glb is intentionally kept on disk as a tessellation cache. + # It is reused on subsequent runs when the STEP file hasn't changed. log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done") diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index bc96d99..61d0a7e 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -239,7 +239,10 @@ export default function InlineCadViewer({ const hasProduction = (productionAssets?.length ?? 0) > 0 useEffect(() => { - if (!hasGeometry && hasProduction) setGlbSource('production') + // Prefer production GLB when available — it has correct materials and a clean + // GMSH mesh. Fall back to geometry GLB only when no production GLB exists yet. + if (hasProduction) setGlbSource('production') + else setGlbSource('geometry') }, [hasGeometry, hasProduction]) const activeDownloadUrl = diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index e26013c..d6a097d 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -316,11 +316,16 @@ 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 controls edge length target in mm - gmsh.option.setNumber("Mesh.CharacteristicLengthMin", linear_deflection * 0.5) - gmsh.option.setNumber("Mesh.CharacteristicLengthMax", linear_deflection * 3.0) - # Angular resolution via circle point count: 2π / angular_deflection - min_circle_pts = max(6, int(_math.ceil(2.0 * _math.pi / max(angular_deflection, 0.01)))) + # 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))))) gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts) gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3) # Reduce noise from GMSH warnings @@ -512,12 +517,44 @@ def main() -> None: f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …") engine = getattr(args, "tessellation_engine", "occ") - for i in range(1, free_labels.Length() + 1): - shape = shape_tool.GetShape_s(free_labels.Value(i)) - if not shape.IsNull(): - if engine == "gmsh": - _tessellate_with_gmsh(shape, args.linear_deflection, args.angular_deflection) + 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. + 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 + + 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 + solids = [] + exp = _Explorer(root_shape, _SOLID) + while exp.More(): + solids.append(exp.Current()) + exp.Next() + + if not solids: + exp = _Explorer(root_shape, _SHELL) + while exp.More(): + 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) + else: + for i in range(1, free_labels.Length() + 1): + shape = shape_tool.GetShape_s(free_labels.Value(i)) + if not shape.IsNull(): BRepMesh_IncrementalMesh( shape, args.linear_deflection,