feat(gmsh): GMSH Frontal-Delaunay tessellation for clean cylinder seams

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 20:45:27 +01:00
parent 9703aec497
commit dbc032ec74
4 changed files with 137 additions and 45 deletions
@@ -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")