From af320bcdc8a474bc8be88d39013c3be88b4ef851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 11 Mar 2026 19:17:26 +0100 Subject: [PATCH] feat(P3): add GMSH Frontal-Delaunay tessellation engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces GMSH as an alternative to OCC BRepMesh for STEP→GLB tessellation. GMSH produces conforming meshes that eliminate fan triangles at cylinder seam edges — a structural limitation of OCC BRepMesh that cannot be fixed via deflection parameters. Changes: - render-worker/Dockerfile: install gmsh>=4.15.0 + libglu1-mesa + libxft2 - export_step_to_gltf.py: --tessellation_engine occ|gmsh CLI arg + _tessellate_with_gmsh() using BRep→GMSH→Poly_Triangulation write-back - admin.py: tessellation_engine setting (SETTINGS_DEFAULTS, SettingsOut, SettingsUpdate, validation) - export_glb.py: pass tessellation_engine to export_step_to_gltf.py CLI in both geometry and production GLB tasks - Admin.tsx: radio button UI for OCC vs GMSH selection Tested: 121 faces meshed, 0 BRepMesh fallback, 649K triangles on sample part. Clean seam edges for UV unwrap — GMSH respects B-rep periodic face boundaries. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/routers/admin.py | 10 +- .../app/domains/pipeline/tasks/export_glb.py | 6 +- frontend/src/pages/Admin.tsx | 29 +++ plan.md | 12 +- render-worker/Dockerfile | 6 + render-worker/scripts/export_step_to_gltf.py | 190 +++++++++++++++++- 6 files changed, 236 insertions(+), 17 deletions(-) diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index f22d712..619e1db 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -41,7 +41,8 @@ SETTINGS_DEFAULTS: dict[str, str] = { "smtp_user": "", "smtp_password": "", "smtp_from_address": "", - # glTF tessellation quality (OCC BRepMesh) + # glTF tessellation quality + "tessellation_engine": "occ", # "occ" | "gmsh" — tessellation backend "gltf_preview_linear_deflection": "0.1", # mm — geometry GLB for viewer "gltf_preview_angular_deflection": "0.1", # rad — Standard preset "gltf_production_linear_deflection": "0.03", # mm — production GLB @@ -87,6 +88,7 @@ class SettingsOut(BaseModel): gltf_material_quality: str = "pbr_colors" gltf_pbr_roughness: float = 0.4 gltf_pbr_metallic: float = 0.6 + tessellation_engine: str = "occ" class SettingsUpdate(BaseModel): @@ -119,6 +121,7 @@ class SettingsUpdate(BaseModel): gltf_material_quality: str | None = None gltf_pbr_roughness: float | None = None gltf_pbr_metallic: float | None = None + tessellation_engine: str | None = None @router.get("/users", response_model=list[UserOut]) @@ -237,6 +240,7 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut: gltf_material_quality=raw.get("gltf_material_quality", "pbr_colors"), gltf_pbr_roughness=float(raw.get("gltf_pbr_roughness", "0.4")), gltf_pbr_metallic=float(raw.get("gltf_pbr_metallic", "0.6")), + tessellation_engine=raw.get("tessellation_engine", "occ"), ) @@ -361,6 +365,10 @@ async def update_settings( if not (0.05 <= body.gltf_production_angular_deflection <= 1.5): raise HTTPException(400, detail="gltf_production_angular_deflection must be 0.05–1.5 rad") updates["gltf_production_angular_deflection"] = str(body.gltf_production_angular_deflection) + if body.tessellation_engine is not None: + if body.tessellation_engine not in {"occ", "gmsh"}: + raise HTTPException(400, detail="tessellation_engine must be 'occ' or 'gmsh'") + updates["tessellation_engine"] = body.tessellation_engine for k, v in updates.items(): await _save_setting(db, k, v) diff --git a/backend/app/domains/pipeline/tasks/export_glb.py b/backend/app/domains/pipeline/tasks/export_glb.py index 519faeb..bcea740 100644 --- a/backend/app/domains/pipeline/tasks/export_glb.py +++ b/backend/app/domains/pipeline/tasks/export_glb.py @@ -70,6 +70,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str): linear_deflection = float(sys_settings.get("gltf_preview_linear_deflection", "0.1")) angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.1")) + tessellation_engine = sys_settings.get("tessellation_engine", "occ") step = _Path(step_path_str) @@ -97,10 +98,11 @@ def generate_gltf_geometry_task(self, cad_file_id: str): "--color_map", _json.dumps(color_map), "--linear_deflection", str(linear_deflection), "--angular_deflection", str(angular_deflection), + "--tessellation_engine", tessellation_engine, ] log_task_event( self.request.id, - f"OCC tessellation: linear={linear_deflection}mm, angular={angular_deflection}rad", + f"Tessellation ({tessellation_engine}): linear={linear_deflection}mm, angular={angular_deflection}rad", "info", ) @@ -231,6 +233,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None smooth_angle = float(sys_settings.get("blender_smooth_angle", "30")) prod_linear = float(sys_settings.get("gltf_production_linear_deflection", "0.03")) prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.05")) + tessellation_engine = sys_settings.get("tessellation_engine", "occ") scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) occ_script = scripts_dir / "export_step_to_gltf.py" @@ -247,6 +250,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None "--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, diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 25fe57a..1850f52 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -112,6 +112,7 @@ export default function AdminPage() { gltf_preview_angular_deflection: number gltf_production_linear_deflection: number gltf_production_angular_deflection: number + tessellation_engine: string } const { data: settings } = useQuery({ @@ -1459,6 +1460,34 @@ export default function AdminPage() { ) })()} + {/* Tessellation engine selector */} +
+

Tessellation Engine

+
+
+ {[ + { value: 'occ', label: 'OCC BRepMesh', description: 'Default engine. Fast, but produces fan triangles at cylinder seam edges.' }, + { value: 'gmsh', label: 'GMSH Frontal-Delaunay', description: 'Conforming mesh — no fan triangles on cylinders. +10–30% export time. Recommended for cylindrical parts.' }, + ].map(opt => ( + + ))} +
+
+
+ {/* Manual inputs */}
diff --git a/plan.md b/plan.md index ceb5c40..feba739 100644 --- a/plan.md +++ b/plan.md @@ -33,7 +33,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch ## Tasks (in Reihenfolge) -### Task 1: Dockerfile — `gmsh` installieren +### [x] Task 1: Dockerfile — `gmsh` installieren - **Datei**: `render-worker/Dockerfile` - **Was**: Nach der `trimesh`-Zeile einfügen: @@ -44,7 +44,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch - **Akzeptanzkriterium**: `docker compose exec render-worker python3 -c "import gmsh; print(gmsh.__version__)"` gibt `4.15.x`. - **Abhängigkeiten**: keine -### Task 2: `export_step_to_gltf.py` — CLI-Argument `--tessellation_engine` +### [x] Task 2: `export_step_to_gltf.py` — CLI-Argument `--tessellation_engine` - **Datei**: `render-worker/scripts/export_step_to_gltf.py` - **Was**: In `parse_args()` ein neues Argument: @@ -57,7 +57,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch - **Akzeptanzkriterium**: `--help` listet `--tessellation_engine`. - **Abhängigkeiten**: keine -### Task 3: `export_step_to_gltf.py` — Funktion `_tessellate_with_gmsh()` +### [x] Task 3: `export_step_to_gltf.py` — Funktion `_tessellate_with_gmsh()` - **Datei**: `render-worker/scripts/export_step_to_gltf.py` - **Was**: Neue Funktion vor `main()`. Nimmt den XCAF-Compound und Deflection-Parameter. Strategie: @@ -120,7 +120,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch - **Abhängigkeiten**: Task 1, Task 2 -### Task 4: Admin-Setting `tessellation_engine` +### [x] Task 4: Admin-Setting `tessellation_engine` - **Datei**: `backend/app/api/routers/admin.py` - **Was**: In `SETTINGS_DEFAULTS` eintragen: @@ -135,7 +135,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch - **Akzeptanzkriterium**: `GET /api/admin/settings` gibt `tessellation_engine: "occ"` zurück. - **Abhängigkeiten**: keine -### Task 5: `export_glb.py` — Setting durchreichen +### [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): @@ -150,7 +150,7 @@ Diese Fehler können **nicht** mit Deflection-Parametern behoben werden — auch - **Akzeptanzkriterium**: Admin stellt `tessellation_engine` auf `gmsh` → nächster GLB-Export nutzt GMSH. - **Abhängigkeiten**: Task 2, Task 4 -### Task 6: Frontend — Dropdown in Admin-Settings +### [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`: diff --git a/render-worker/Dockerfile b/render-worker/Dockerfile index c0e567b..af3ed26 100644 --- a/render-worker/Dockerfile +++ b/render-worker/Dockerfile @@ -64,6 +64,12 @@ RUN pip3 install --no-cache-dir "cadquery>=2.4.0" # Install trimesh for STL→GLB geometry export (separate layer to avoid cache invalidation) RUN pip3 install --no-cache-dir "trimesh>=4.2.0" +# libGLU + libXft required by gmsh's shared library loader +RUN apt-get update && apt-get install -y --no-install-recommends libglu1-mesa libxft2 && rm -rf /var/lib/apt/lists/* + +# GMSH for Frontal-Delaunay tessellation (alternative to OCC BRepMesh) +RUN pip3 install --no-cache-dir "gmsh>=4.15.0" + # Copy render scripts COPY render-worker/scripts/ /render-scripts/ diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index edd42da..e8fdd15 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -1,8 +1,8 @@ """OCC-native STEP → GLB export script. Reads a STEP file via OCP/XCAF (preserving part names and embedded colors), -tessellates with BRepMesh, optionally applies per-part hex colors, and writes -a binary GLB in meters (Y-up, glTF convention). +tessellates with BRepMesh or GMSH Frontal-Delaunay, optionally applies +per-part hex colors, and writes a binary GLB in meters (Y-up, glTF convention). No Blender required. Uses the same OCP bindings that cadquery ships with. @@ -12,6 +12,7 @@ Usage: --output_path /path/to/output.glb \ [--linear_deflection 0.1] \ [--angular_deflection 0.5] \ + [--tessellation_engine occ|gmsh] \ [--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}'] Exit 0 on success, exit 1 on failure. @@ -50,6 +51,10 @@ def parse_args() -> argparse.Namespace: "--sharp_threshold", type=float, default=20.0, help="Dihedral angle threshold (degrees) for sharp B-rep edge detection. Default 20.0", ) + parser.add_argument( + "--tessellation_engine", choices=["occ", "gmsh"], default="occ", + help="Tessellation backend: 'occ' = BRepMesh (default), 'gmsh' = Frontal-Delaunay", + ) return parser.parse_args() @@ -259,6 +264,169 @@ def _extract_sharp_edge_pairs(shape, sharp_threshold_deg: float = 20.0) -> list: return sharp_pairs +def _tessellate_with_gmsh(shape, linear_deflection: float, angular_deflection: float) -> None: + """Tessellate an OCC TopoDS_Shape using GMSH Frontal-Delaunay mesher. + + Writes the resulting Poly_Triangulation back to each TopoDS_Face via + BRep_Builder.UpdateFace(), so the shape's tessellation data is available + to RWGltf_CafWriter exactly like BRepMesh output. + + GMSH surface tags correspond 1:1 to faces in TopExp_Explorer(FACE) order + after importShapes() from a .brep file — no coordinate-based matching needed. + + Falls back silently to BRepMesh for any face that GMSH cannot mesh (degenerate + geometry, e.g. degenerate poles). + + Args: + shape: tessellated in-place (OCC TopoDS_Shape) + linear_deflection: controls CharacteristicLengthMax (mm) + angular_deflection: controls minimum circle subdivision points (rad) + """ + import math as _math + import tempfile + import gmsh + + from OCP.BRep import BRep_Builder + from OCP.BRepTools import BRepTools + from OCP.BRepMesh import BRepMesh_IncrementalMesh + from OCP.Poly import Poly_Triangulation, Poly_Array1OfTriangle, Poly_Triangle + from OCP.TColgp import TColgp_Array1OfPnt + from OCP.TopExp import TopExp_Explorer + from OCP.TopAbs import TopAbs_FACE + from OCP.TopoDS import TopoDS as _TopoDS + from OCP.gp import gp_Pnt + + # Write shape to temporary .brep for GMSH import + with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp: + brep_path = tmp.name + + n_faces_gmsh = 0 + n_faces_fallback = 0 + n_triangles_total = 0 + + try: + BRepTools.Write_s(shape, brep_path) + + gmsh.initialize() + gmsh.option.setNumber("General.Terminal", 0) # suppress console output + 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)))) + gmsh.option.setNumber("Mesh.MinimumCirclePoints", min_circle_pts) + gmsh.option.setNumber("Mesh.MinimumCurvePoints", 3) + # Reduce noise from GMSH warnings + gmsh.option.setNumber("General.Verbosity", 1) + + gmsh.model.add("shape") + gmsh.model.occ.importShapes(brep_path) + gmsh.model.occ.synchronize() + gmsh.model.mesh.generate(2) + + # Build lookup: surface tag → list of (node_coords, triangle_node_tags) + # GMSH surface tags from importShapes correspond 1:1 to TopExp_Explorer(FACE) order + surface_tags = [tag for (_, tag) in gmsh.model.getEntities(2)] + + # Collect per-surface mesh data + surface_mesh: dict[int, tuple] = {} # tag → (nodes_xyz, triangles) + for stag in surface_tags: + try: + node_tags, coords, _ = gmsh.model.mesh.getNodes(dim=2, tag=stag, includeBoundary=True) + if len(node_tags) == 0: + continue + # coords is flat [x0,y0,z0, x1,y1,z1, ...] + node_map = {} # gmsh tag → 1-based local index + pts_xyz = [] + for i, ntag in enumerate(node_tags): + x, y, z = coords[3*i], coords[3*i+1], coords[3*i+2] + node_map[ntag] = i + 1 # 1-based + pts_xyz.append((x, y, z)) + + elem_types, elem_tags, elem_node_tags = gmsh.model.mesh.getElements(dim=2, tag=stag) + tris = [] + for etype, etags, entags in zip(elem_types, elem_tags, elem_node_tags): + if etype != 2: # type 2 = triangle + continue + n_elems = len(etags) + for k in range(n_elems): + a = node_map.get(entags[3*k]) + b = node_map.get(entags[3*k+1]) + c = node_map.get(entags[3*k+2]) + if a and b and c: + tris.append((a, b, c)) + + if pts_xyz and tris: + surface_mesh[stag] = (pts_xyz, tris) + except Exception: + continue + + except Exception as _gmsh_err: + print(f"WARNING: GMSH failed ({_gmsh_err}), falling back to BRepMesh", file=sys.stderr) + # Fallback: full BRepMesh on the whole shape + BRepMesh_IncrementalMesh(shape, linear_deflection, False, angular_deflection, True) + return + finally: + try: + gmsh.finalize() + except Exception: + pass + try: + Path(brep_path).unlink(missing_ok=True) + except Exception: + pass + + # Map GMSH surface data back to OCC faces via TopExp_Explorer (same order as importShapes) + builder = BRep_Builder() + explorer = TopExp_Explorer(shape, TopAbs_FACE) + face_index = 0 # 0-based → maps to surface_tags[face_index] + + while explorer.More(): + face = _TopoDS.Face_s(explorer.Current()) + if face_index < len(surface_tags): + stag = surface_tags[face_index] + mesh_data = surface_mesh.get(stag) + if mesh_data: + pts_xyz, tris = mesh_data + n_nodes = len(pts_xyz) + n_tris = len(tris) + try: + arr_pts = TColgp_Array1OfPnt(1, n_nodes) + for idx, (x, y, z) in enumerate(pts_xyz, 1): + arr_pts.SetValue(idx, gp_Pnt(x, y, z)) + + arr_tris = Poly_Array1OfTriangle(1, n_tris) + for idx, (a, b, c) in enumerate(tris, 1): + arr_tris.SetValue(idx, Poly_Triangle(a, b, c)) + + tri = Poly_Triangulation(arr_pts, arr_tris) + builder.UpdateFace(face, tri) + n_faces_gmsh += 1 + n_triangles_total += n_tris + except Exception as _e: + # Fallback for this face only + BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False) + n_faces_fallback += 1 + else: + # No GMSH mesh for this surface → BRepMesh fallback for this face + BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False) + n_faces_fallback += 1 + else: + BRepMesh_IncrementalMesh(face, linear_deflection, False, angular_deflection, False) + n_faces_fallback += 1 + + face_index += 1 + explorer.Next() + + print( + f"GMSH tessellation: {n_faces_gmsh} faces meshed, " + f"{n_faces_fallback} BRepMesh fallback, " + f"{n_triangles_total} triangles total" + ) + + def _inject_glb_extras(glb_path: Path, extras: dict) -> None: """Patch a GLB binary to add/update scenes[0].extras JSON field. @@ -337,16 +505,20 @@ def main() -> None: print(f"Found {free_labels.Length()} root shape(s), tessellating " 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(): - BRepMesh_IncrementalMesh( - shape, - args.linear_deflection, - False, # isRelative - args.angular_deflection, - True, # isInParallel - ) + if engine == "gmsh": + _tessellate_with_gmsh(shape, args.linear_deflection, args.angular_deflection) + else: + BRepMesh_IncrementalMesh( + shape, + args.linear_deflection, + False, # isRelative + args.angular_deflection, + True, # isInParallel + ) # --- Extract sharp B-rep edge pairs (before mm→m scaling so coords are in mm) --- # Collect all free shapes into one list for the extraction function.