"""Gmsh meshing integration for yapCAD analysis.
This module provides meshing capabilities using Gmsh's OCC integration,
enabling direct geometry transfer from yapCAD's OCC-based BREP representation.
The key advantage is that both yapCAD and Gmsh use the OpenCASCADE kernel,
so geometry can be passed directly without lossy STEP/IGES conversions.
Usage:
from yapcad.package.analysis.gmsh_mesher import GmshMesher, MeshHints
mesher = GmshMesher()
mesh = mesher.mesh_from_solid(solid, hints=MeshHints(element_size=2.0))
mesher.export_mesh(workspace / "model.msh")
Copyright (c) 2025 yapCAD contributors
MIT License
"""
from __future__ import annotations
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# Gmsh import with graceful fallback
try:
import gmsh
_GMSH_AVAILABLE = True
except ImportError:
gmsh = None # type: ignore
_GMSH_AVAILABLE = False
# OCC imports for geometry conversion
try:
from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Solid
from OCC.Core.BRepTools import breptools
_OCC_AVAILABLE = True
except ImportError:
TopoDS_Shape = TopoDS_Solid = Any
breptools = None
_OCC_AVAILABLE = False
[docs]
def gmsh_available() -> bool:
"""Return True if Gmsh Python API is available."""
return _GMSH_AVAILABLE
[docs]
def require_gmsh() -> None:
"""Raise error if Gmsh is not available."""
if not _GMSH_AVAILABLE:
raise RuntimeError(
"Gmsh Python API is not available. Install via conda: "
"conda install -c conda-forge gmsh"
)
[docs]
@dataclass
class MeshHints:
"""Mesh generation hints for Gmsh.
Attributes:
element_size: Target element size (mesh density)
min_element_size: Minimum element size
max_element_size: Maximum element size
algorithm_2d: 2D meshing algorithm (1=MeshAdapt, 2=Auto, 5=Delaunay, 6=Frontal-Delaunay)
algorithm_3d: 3D meshing algorithm (1=Delaunay, 4=Frontal, 10=HXT)
element_order: Element polynomial order (1=linear, 2=quadratic)
optimize: Whether to optimize mesh quality
optimize_netgen: Use Netgen optimizer for 3D meshes
refinement_fields: List of refinement field specifications
geometry_tolerance: Tolerance for geometry healing (default 1e-4)
recover_3d: If False, skip 3D mesh generation and do 2D only
stl_fallback: If True, use STL intermediate format when BREP fails
"""
element_size: float = 5.0
min_element_size: Optional[float] = None
max_element_size: Optional[float] = None
algorithm_2d: int = 6 # Frontal-Delaunay
algorithm_3d: int = 1 # Delaunay
element_order: int = 1
optimize: bool = True
optimize_netgen: bool = False
refinement_fields: List[Dict[str, Any]] = field(default_factory=list)
geometry_tolerance: float = 1e-4
recover_3d: bool = True
use_stl: bool = False # Use STL-based meshing (robust but loses exact geometry)
scale_factor: float = 1.0 # Scale geometry for better precision (results scaled back)
[docs]
@dataclass
class PhysicalGroup:
"""A named group of mesh entities (for boundary conditions).
Attributes:
name: Human-readable name for the group
dim: Dimension (0=point, 1=edge, 2=face, 3=volume)
tags: Gmsh entity tags in this group
"""
name: str
dim: int
tags: List[int]
[docs]
class GmshMesher:
"""Primary meshing interface using Gmsh's OCC integration.
This class provides meshing capabilities for yapCAD solids using Gmsh.
It leverages the shared OCC kernel between yapCAD and Gmsh for direct
geometry transfer without intermediate file formats.
Example:
mesher = GmshMesher()
mesher.initialize()
mesher.import_solid(solid)
mesher.set_physical_groups({"fixed_face": [1, 2], "load_face": [3]})
mesher.generate_mesh(hints)
mesher.export_mesh(Path("output.msh"))
mesher.finalize()
"""
def __init__(self, model_name: str = "yapCAD_model"):
"""Initialize the mesher.
Args:
model_name: Name for the Gmsh model
"""
require_gmsh()
self._model_name = model_name
self._initialized = False
self._physical_groups: Dict[str, PhysicalGroup] = {}
self._face_map: Dict[int, str] = {} # Gmsh tag -> name
[docs]
def initialize(self, verbose: bool = True, geometry_tolerance: float = 0.1) -> None:
"""Initialize Gmsh (must be called before other operations).
Args:
verbose: If True, enable terminal output for progress monitoring
geometry_tolerance: Tolerance for geometry repair operations
"""
if self._initialized:
return
gmsh.initialize()
gmsh.model.add(self._model_name)
# Enable terminal output for progress monitoring
gmsh.option.setNumber("General.Terminal", 1 if verbose else 0)
gmsh.option.setNumber("General.Verbosity", 5 if verbose else 0) # Max verbosity
# Set geometry healing options BEFORE importing geometry
gmsh.option.setNumber("Geometry.Tolerance", geometry_tolerance)
gmsh.option.setNumber("Geometry.ToleranceBoolean", geometry_tolerance)
gmsh.option.setNumber("Geometry.OCCFixDegenerated", 1)
gmsh.option.setNumber("Geometry.OCCFixSmallEdges", 1)
gmsh.option.setNumber("Geometry.OCCFixSmallFaces", 1)
gmsh.option.setNumber("Geometry.OCCSewFaces", 1)
gmsh.option.setNumber("Geometry.OCCMakeSolids", 1)
self._initialized = True
self._verbose = verbose
self._geometry_tolerance = geometry_tolerance
[docs]
def finalize(self) -> None:
"""Finalize Gmsh and release resources."""
if self._initialized:
gmsh.finalize()
self._initialized = False
def __enter__(self) -> "GmshMesher":
"""Context manager entry."""
self.initialize(geometry_tolerance=getattr(self, '_geometry_tolerance', 0.1))
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit."""
self.finalize()
[docs]
def import_solid(self, solid: Any, face_names: Optional[Dict[str, List[int]]] = None, use_stl: bool = False) -> List[Tuple[int, int]]:
"""Import a yapCAD solid into Gmsh.
This uses Gmsh's OCC integration to import the geometry directly
from the OCC representation, avoiding STEP/IGES conversion losses.
Args:
solid: yapCAD solid (must have OCC BREP representation)
face_names: Optional mapping of face names to face indices
use_stl: If True, use STL (tessellated) representation which may
be more robust for complex geometries with topology issues
Returns:
List of (dim, tag) tuples for imported entities
"""
if not self._initialized:
raise RuntimeError("GmshMesher not initialized. Call initialize() first.")
# Get OCC shape from yapCAD solid
occ_shape = self._get_occ_shape(solid)
if use_stl or getattr(self, '_use_stl', False):
return self._import_via_stl(occ_shape)
# Import via temporary BREP file (Gmsh's importShapes needs a file)
# TODO: Investigate direct OCC shape passing when gmsh Python API supports it
with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
# Apply OCC healing before export only if tolerance is large enough
tol = getattr(self, '_geometry_tolerance', 0.1)
if tol >= 0.01: # Only heal if tolerance >= 0.01mm
if getattr(self, '_verbose', False):
print(f" Meshing: healing OCC shape (tol={tol})...", flush=True)
try:
from OCC.Core.ShapeFix import ShapeFix_Shape, ShapeFix_Solid
# Fix the shape
fixer = ShapeFix_Shape(occ_shape)
fixer.SetPrecision(tol)
fixer.SetMaxTolerance(tol * 10)
fixer.SetMinTolerance(tol / 10)
fixer.Perform()
fixed_shape = fixer.Shape()
# Additional solid-specific fixing if applicable
from OCC.Core.TopAbs import TopAbs_SOLID
if fixed_shape.ShapeType() == TopAbs_SOLID:
try:
solid_fixer = ShapeFix_Solid(fixed_shape)
solid_fixer.SetPrecision(tol)
solid_fixer.Perform()
fixed_shape = solid_fixer.Solid()
except Exception:
pass # Skip if solid fixing fails
occ_shape = fixed_shape
if getattr(self, '_verbose', False):
print(" Meshing: OCC shape healed", flush=True)
except Exception as occ_heal_err:
if getattr(self, '_verbose', False):
print(f" Meshing: OCC healing skipped: {occ_heal_err}", flush=True)
else:
if getattr(self, '_verbose', False):
print(f" Meshing: skipping OCC healing (tol={tol} < 0.01)", flush=True)
# Optionally scale geometry for better numerical precision
scale_factor = getattr(self, '_scale_factor', 1.0)
if scale_factor != 1.0:
if getattr(self, '_verbose', False):
print(f" Meshing: scaling geometry by {scale_factor}x...", flush=True)
from OCC.Core.gp import gp_Trsf, gp_Pnt
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
trsf = gp_Trsf()
trsf.SetScale(gp_Pnt(0, 0, 0), scale_factor)
transformer = BRepBuilderAPI_Transform(occ_shape, trsf, True)
transformer.Build()
occ_shape = transformer.Shape()
# If shape is a COMPOUND with multiple SOLIDs, fuse them into a single solid
# This ensures Gmsh imports a proper volume instead of loose surfaces
try:
from OCC.Core.TopAbs import TopAbs_COMPOUND, TopAbs_SOLID
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.TopoDS import topods
if occ_shape.ShapeType() == TopAbs_COMPOUND:
# Count and collect solids
solids = []
exp = TopExp_Explorer(occ_shape, TopAbs_SOLID)
while exp.More():
solids.append(topods.Solid(exp.Current()))
exp.Next()
if len(solids) > 1:
if getattr(self, '_verbose', False):
print(f" Meshing: fusing {len(solids)} solids into single body...", flush=True)
# Fuse all solids together
result = solids[0]
for i, s in enumerate(solids[1:], 1):
try:
fuser = BRepAlgoAPI_Fuse(result, s)
fuser.SetFuzzyValue(tol)
fuser.Build()
if fuser.IsDone():
result = fuser.Shape()
except Exception as fuse_err:
if getattr(self, '_verbose', False):
print(f" Meshing: fuse {i} warning: {fuse_err}", flush=True)
occ_shape = result
if getattr(self, '_verbose', False):
print(" Meshing: solids fused successfully", flush=True)
except Exception as fuse_err:
if getattr(self, '_verbose', False):
print(f" Meshing: solid fusion skipped: {fuse_err}", flush=True)
# Write OCC shape to BREP file
if getattr(self, '_verbose', False):
print(" Meshing: exporting geometry to BREP...", flush=True)
breptools.Write(occ_shape, str(tmp_path))
# Import into Gmsh via OCC kernel
if getattr(self, '_verbose', False):
print(" Meshing: importing geometry into Gmsh...", flush=True)
entities = gmsh.model.occ.importShapes(str(tmp_path))
# Heal and repair geometry topology only if tolerance is large enough
if tol >= 0.01:
if getattr(self, '_verbose', False):
print(" Meshing: healing geometry in Gmsh...", flush=True)
try:
# Remove small edges and faces that cause meshing issues
gmsh.model.occ.healShapes(
dimTags=[],
tolerance=tol,
fixDegenerated=True,
fixSmallEdges=True,
fixSmallFaces=True,
sewFaces=True,
makeSolids=True
)
except Exception as heal_err:
if getattr(self, '_verbose', False):
print(f" Meshing: heal warning: {heal_err}", flush=True)
else:
if getattr(self, '_verbose', False):
print(" Meshing: skipping Gmsh healing (small tolerance)", flush=True)
gmsh.model.occ.synchronize()
# Remove duplicate entities (internal faces from fused solids)
try:
gmsh.model.occ.removeAllDuplicates()
gmsh.model.occ.synchronize()
except Exception as dup_err:
if getattr(self, '_verbose', False):
print(f" Meshing: removeAllDuplicates warning: {dup_err}", flush=True)
if getattr(self, '_verbose', False):
print(f" Meshing: imported {len(entities)} entities", flush=True)
# Check if we have volumes - if not, try to create them from surfaces
# This handles cases where healShapes "Could not make solid" during BREP import
volumes = gmsh.model.getEntities(dim=3)
if not volumes:
surfaces = gmsh.model.getEntities(dim=2)
if surfaces:
if getattr(self, '_verbose', False):
print(f" Meshing: no volumes found, creating from {len(surfaces)} surfaces...", flush=True)
# Use healShapes with makeSolids to try creating volumes from the surface shells
try:
surface_tags = [(2, s[1]) for s in surfaces]
gmsh.model.occ.healShapes(
dimTags=surface_tags,
tolerance=max(tol, 0.1), # Use at least 0.1mm tolerance for solid creation
fixDegenerated=True,
fixSmallEdges=True,
fixSmallFaces=True,
sewFaces=True,
makeSolids=True
)
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
if volumes:
if getattr(self, '_verbose', False):
print(f" Meshing: healShapes created {len(volumes)} volume(s)", flush=True)
except Exception as heal_err:
if getattr(self, '_verbose', False):
print(f" Meshing: healShapes for volumes failed: {heal_err}", flush=True)
# If healShapes didn't create volumes, try single surface loop approach
if not volumes:
try:
surfaces = gmsh.model.getEntities(dim=2)
surface_tags = [s[1] for s in surfaces]
sl = gmsh.model.occ.addSurfaceLoop(surface_tags)
vol = gmsh.model.occ.addVolume([sl])
gmsh.model.occ.synchronize()
volumes = gmsh.model.getEntities(dim=3)
if getattr(self, '_verbose', False):
print(f" Meshing: created {len(volumes)} volume(s) from surface loop", flush=True)
except Exception as vol_err:
if getattr(self, '_verbose', False):
print(f" Meshing: surface loop volume creation failed: {vol_err}", flush=True)
finally:
tmp_path.unlink(missing_ok=True)
# Store face mapping if provided
if face_names:
# Get all faces from the model
faces = gmsh.model.getEntities(dim=2)
for name, indices in face_names.items():
for idx in indices:
if idx < len(faces):
self._face_map[faces[idx][1]] = name
return entities
def _import_via_stl(self, occ_shape: "TopoDS_Shape") -> List[Tuple[int, int]]:
"""Import geometry via STL tessellation (bypasses BREP topology issues).
Uses Gmsh's discrete model capabilities to mesh from triangulated surfaces.
"""
from OCC.Core.StlAPI import stlapi
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
if getattr(self, '_verbose', False):
print(" Meshing: tessellating geometry to STL...", flush=True)
# Tessellate the shape with finer resolution for FEA
tol = getattr(self, '_geometry_tolerance', 0.1)
linear_deflection = tol # Linear deviation tolerance
angular_deflection = 0.25 # Angular deviation in radians (~14 degrees)
mesh = BRepMesh_IncrementalMesh(occ_shape, linear_deflection, False, angular_deflection, True)
mesh.Perform()
# Export to STL
with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as tmp:
stl_path = Path(tmp.name)
try:
stlapi.Write(occ_shape, str(stl_path))
if getattr(self, '_verbose', False):
print(" Meshing: importing STL into Gmsh...", flush=True)
# Import STL using merge which creates surface triangulation
gmsh.merge(str(stl_path))
# Create topology from the imported mesh
if getattr(self, '_verbose', False):
print(" Meshing: creating volume from STL surface...", flush=True)
# Create edges and surfaces from the triangulation
angle_deg = 40 # Feature angle threshold in degrees
gmsh.model.mesh.classifySurfaces(
angle_deg * 3.14159265 / 180.0, # angle
True, # includeBoundary
False, # forceParametrizablePatches
180 * 3.14159265 / 180.0 # curveAngle (don't split based on curves)
)
# Create discrete model geometry from the classified mesh
gmsh.model.mesh.createGeometry()
# Get the surfaces from the discrete model
surfaces = gmsh.model.getEntities(dim=2)
if getattr(self, '_verbose', False):
print(f" Meshing: classified into {len(surfaces)} discrete surfaces", flush=True)
if surfaces:
# Create a surface filling to get a closed volume
surface_tags = [s[1] for s in surfaces]
# Add a discrete surface loop
sl = gmsh.model.geo.addSurfaceLoop(surface_tags)
vol = gmsh.model.geo.addVolume([sl])
gmsh.model.geo.synchronize()
if getattr(self, '_verbose', False):
print(f" Meshing: created volume entity", flush=True)
entities = gmsh.model.getEntities()
if getattr(self, '_verbose', False):
dim_counts = {}
for dim, _ in entities:
dim_counts[dim] = dim_counts.get(dim, 0) + 1
print(f" Meshing: final entities: {dim_counts}", flush=True)
return entities
finally:
stl_path.unlink(missing_ok=True)
[docs]
def import_step(self, step_path: Path) -> List[Tuple[int, int]]:
"""Import geometry from a STEP file.
Args:
step_path: Path to the STEP file
Returns:
List of (dim, tag) tuples for imported entities
"""
if not self._initialized:
raise RuntimeError("GmshMesher not initialized.")
entities = gmsh.model.occ.importShapes(str(step_path))
gmsh.model.occ.synchronize()
return entities
[docs]
def set_physical_groups(self, groups: Dict[str, List[int]], dim: int = 2) -> None:
"""Define physical groups for boundary conditions.
Physical groups associate mesh entities with names that can be
used to apply boundary conditions in the solver.
Args:
groups: Mapping of group names to entity tags
dim: Dimension of entities (2 for faces, 3 for volumes)
"""
for name, tags in groups.items():
if tags:
pg_tag = gmsh.model.addPhysicalGroup(dim, tags)
gmsh.model.setPhysicalName(dim, pg_tag, name)
self._physical_groups[name] = PhysicalGroup(name=name, dim=dim, tags=tags)
[docs]
def set_physical_groups_by_normal(
self,
groups: Dict[str, Tuple[float, float, float]],
tolerance_deg: float = 5.0
) -> None:
"""Define physical groups by face normal direction.
This is useful for automatically identifying faces like "top", "bottom",
"front", etc. based on their orientation.
Args:
groups: Mapping of group names to normal vectors (x, y, z)
tolerance_deg: Angular tolerance in degrees
"""
import math
faces = gmsh.model.getEntities(dim=2)
tol_rad = math.radians(tolerance_deg)
for name, target_normal in groups.items():
# Normalize target
mag = math.sqrt(sum(n*n for n in target_normal))
if mag < 1e-10:
continue
target = tuple(n/mag for n in target_normal)
matching_tags = []
for dim, tag in faces:
# Get face center and normal via Gmsh
try:
# Get parametric center
bounds = gmsh.model.getParametrizationBounds(dim, tag)
u_mid = (bounds[0][0] + bounds[1][0]) / 2
v_mid = (bounds[0][1] + bounds[1][1]) / 2
# Get normal at center
normal = gmsh.model.getNormal(tag, [u_mid, v_mid])
# Check angle
dot = sum(a*b for a, b in zip(target, normal))
angle = math.acos(max(-1, min(1, dot)))
if angle < tol_rad:
matching_tags.append(tag)
except Exception:
continue
if matching_tags:
pg_tag = gmsh.model.addPhysicalGroup(2, matching_tags)
gmsh.model.setPhysicalName(2, pg_tag, name)
self._physical_groups[name] = PhysicalGroup(name=name, dim=2, tags=matching_tags)
[docs]
def generate_mesh(self, hints: Optional[MeshHints] = None, dim: int = 3) -> None:
"""Generate the mesh.
Args:
hints: Mesh generation hints
dim: Mesh dimension (2 for surface, 3 for volume)
"""
if not self._initialized:
raise RuntimeError("GmshMesher not initialized.")
hints = hints or MeshHints()
# Apply geometry tolerance for healing problematic geometry
gmsh.option.setNumber("Geometry.Tolerance", hints.geometry_tolerance)
gmsh.option.setNumber("Geometry.ToleranceBoolean", hints.geometry_tolerance)
gmsh.option.setNumber("Geometry.OCCFixDegenerated", 1)
gmsh.option.setNumber("Geometry.OCCFixSmallEdges", 1)
gmsh.option.setNumber("Geometry.OCCFixSmallFaces", 1)
gmsh.option.setNumber("Geometry.OCCSewFaces", 1)
gmsh.option.setNumber("Geometry.OCCMakeSolids", 1)
# More tolerant curve recovery during meshing
gmsh.option.setNumber("Mesh.ToleranceEdgeLength", hints.geometry_tolerance * 10)
gmsh.option.setNumber("Mesh.ToleranceInitialDelaunay", hints.geometry_tolerance * 100)
# Allow meshing to proceed even with small topology issues
gmsh.option.setNumber("Mesh.AngleToleranceFacetOverlap", 0.5)
gmsh.option.setNumber("Mesh.AllowSwapAngle", 90)
# Apply mesh size options
gmsh.option.setNumber("Mesh.CharacteristicLengthMin",
hints.min_element_size or hints.element_size * 0.1)
gmsh.option.setNumber("Mesh.CharacteristicLengthMax",
hints.max_element_size or hints.element_size * 10)
gmsh.option.setNumber("Mesh.CharacteristicLengthFactor", 1.0)
# Set default element size
gmsh.option.setNumber("Mesh.MeshSizeMin",
hints.min_element_size or hints.element_size * 0.1)
gmsh.option.setNumber("Mesh.MeshSizeMax",
hints.max_element_size or hints.element_size * 10)
# Set meshing algorithms
gmsh.option.setNumber("Mesh.Algorithm", hints.algorithm_2d)
gmsh.option.setNumber("Mesh.Algorithm3D", hints.algorithm_3d)
# Element order
gmsh.option.setNumber("Mesh.ElementOrder", hints.element_order)
# Optimization
gmsh.option.setNumber("Mesh.Optimize", 1 if hints.optimize else 0)
gmsh.option.setNumber("Mesh.OptimizeNetgen", 1 if hints.optimize_netgen else 0)
# Apply refinement fields if specified
for i, field_spec in enumerate(hints.refinement_fields):
self._apply_refinement_field(i + 1, field_spec)
# Set uniform mesh size on all entities
entities = gmsh.model.getEntities(0) # vertices
gmsh.model.mesh.setSize(entities, hints.element_size)
# Determine actual dimension to mesh
actual_dim = dim
if not hints.recover_3d and dim == 3:
if getattr(self, '_verbose', False):
print(" Meshing: recover_3d=False, generating 2D surface mesh only", flush=True)
actual_dim = 2
# Generate mesh with progress indication
if getattr(self, '_verbose', False):
print(f" Meshing: generating {actual_dim}D mesh (element size={hints.element_size}mm)...", flush=True)
gmsh.model.mesh.generate(actual_dim)
if getattr(self, '_verbose', False):
print(f" Meshing: {actual_dim}D mesh generation complete", flush=True)
# Optimize if requested
if hints.optimize:
if getattr(self, '_verbose', False):
print(" Meshing: optimizing 2D mesh...", flush=True)
gmsh.model.mesh.optimize("Relocate2D")
if actual_dim == 3:
if getattr(self, '_verbose', False):
print(" Meshing: optimizing 3D mesh...", flush=True)
# Use Netgen if requested, otherwise basic 3D relocation
if hints.optimize_netgen:
gmsh.model.mesh.optimize("Netgen")
else:
gmsh.model.mesh.optimize("Relocate3D")
if getattr(self, '_verbose', False):
print(" Meshing: optimization complete", flush=True)
def _apply_refinement_field(self, field_id: int, spec: Dict[str, Any]) -> None:
"""Apply a mesh refinement field."""
field_type = spec.get("type", "Box")
gmsh.model.mesh.field.add(field_type, field_id)
for key, value in spec.items():
if key == "type":
continue
if isinstance(value, (int, float)):
gmsh.model.mesh.field.setNumber(field_id, key, value)
elif isinstance(value, list):
gmsh.model.mesh.field.setNumbers(field_id, key, value)
elif isinstance(value, str):
gmsh.model.mesh.field.setString(field_id, key, value)
[docs]
def export_mesh(self, path: Path, format: Optional[str] = None) -> Path:
"""Export the mesh to a file.
Args:
path: Output path
format: Optional format override (msh, vtk, xdmf, su2)
Returns:
Path to the exported file
"""
if not self._initialized:
raise RuntimeError("GmshMesher not initialized.")
path = Path(path)
# Determine format from extension if not specified
if format is None:
format = path.suffix.lstrip(".").lower()
# Handle special formats
if format == "xdmf":
# XDMF for FEniCSx - export as MSH2 then convert
msh_path = path.with_suffix(".msh")
gmsh.option.setNumber("Mesh.MshFileVersion", 2.2)
gmsh.write(str(msh_path))
# Note: Actual XDMF conversion requires meshio or dolfinx
return msh_path
elif format == "su2":
gmsh.write(str(path))
else:
gmsh.write(str(path))
return path
[docs]
def get_mesh_stats(self) -> Dict[str, Any]:
"""Get mesh statistics.
Returns:
Dictionary with node count, element counts by type, quality metrics
"""
if not self._initialized:
raise RuntimeError("GmshMesher not initialized.")
stats = {
"nodes": 0,
"elements": {},
"physical_groups": list(self._physical_groups.keys()),
}
# Count nodes
node_tags, coords, _ = gmsh.model.mesh.getNodes()
stats["nodes"] = len(node_tags)
# Count elements by type
element_types, element_tags, _ = gmsh.model.mesh.getElements()
for elem_type, tags in zip(element_types, element_tags):
elem_name = gmsh.model.mesh.getElementProperties(elem_type)[0]
stats["elements"][elem_name] = len(tags)
return stats
def _get_occ_shape(self, solid: Any) -> "TopoDS_Shape":
"""Extract OCC shape from yapCAD solid.
Args:
solid: yapCAD solid representation
Returns:
OCC TopoDS_Shape
"""
# Check if solid already has OCC representation
if hasattr(solid, '_occ_shape'):
return solid._occ_shape
# Try to get from BREP cache or directly if it's a BrepSolid
from yapcad.brep import BrepSolid, occ_available, brep_from_solid
if occ_available():
if isinstance(solid, BrepSolid):
return solid.shape
# For list-based solids, try to get cached BrepSolid
# This handles deserialized geometry that has BREP data in metadata
brep = brep_from_solid(solid)
if brep is not None:
return brep.shape
# Convert via native BREP (TopologyGraph)
from yapcad.occ_native_convert import native_brep_to_occ, occ_available as occ_convert_available
from yapcad.native_brep import TopologyGraph, has_native_brep
if occ_convert_available():
# Check if solid has native BREP attached (TopologyGraph)
if has_native_brep(solid):
from yapcad.native_brep import native_brep_from_solid
graph = native_brep_from_solid(solid)
occ_shape = native_brep_to_occ(graph)
if occ_shape is not None:
return occ_shape
raise ValueError(
"Cannot extract OCC shape from solid. Ensure the solid has "
"a valid BREP representation (created with OCC-enabled yapCAD)."
)
[docs]
def mesh_solid(
solid: Any,
output_path: Path,
hints: Optional[MeshHints] = None,
physical_groups: Optional[Dict[str, List[int]]] = None,
dim: int = 3
) -> Dict[str, Any]:
"""Convenience function to mesh a solid and export.
Args:
solid: yapCAD solid to mesh
output_path: Path for output mesh file
hints: Mesh generation hints
physical_groups: Optional face groups for BCs
dim: Mesh dimension
Returns:
Mesh statistics dictionary
"""
with GmshMesher() as mesher:
mesher.import_solid(solid)
if physical_groups:
mesher.set_physical_groups(physical_groups)
mesher.generate_mesh(hints, dim)
mesher.export_mesh(output_path)
return mesher.get_mesh_stats()
__all__ = [
"GmshMesher",
"MeshHints",
"PhysicalGroup",
"gmsh_available",
"require_gmsh",
"mesh_solid",
]