Source code for cad_to_dagmc.core

from contextlib import contextmanager
from pathlib import Path
from typing import Iterable
import functools
import importlib.util
import cadquery as cq
import gmsh
import numpy as np
from cadquery import importers
from cadquery.occ_impl.importers.assembly import importStep as importStepAssembly
from cadquery.occ_impl.shapes import setThreads
from OCP.OSD import OSD_ThreadPool
import tempfile
import warnings
from typing import Iterable
from cad_to_dagmc import __version__


[docs] class PyMoabNotFoundError(ImportError): """Raised when pymoab is not installed but the pymoab backend is requested.""" def __init__(self, message=None): if message is None: message = ( "pymoab is not installed. pymoab/MOAB is not available on PyPI so it " "cannot be included as a dependency of cad-to-dagmc.\n\n" "You can install pymoab via one of these methods:\n" " 1. From conda-forge: conda install -c conda-forge moab\n" " 2. From extra index: pip install --extra-index-url https://shimwell.github.io/wheels moab\n" " 3. From source: https://bitbucket.org/fathomteam/moab\n\n" "Alternatively, use the h5py backend (the default) which does not require pymoab:\n" " export_dagmc_h5m_file(..., h5m_backend='h5py')" ) super().__init__(message)
[docs] class CadToDagmcMesherNotFoundError(ImportError): """Raised when cad-to-dagmc-mesher is not installed but its backend is requested.""" def __init__(self, message=None): if message is None: message = ( "cad-to-dagmc-mesher is not installed. It is not available on " "conda-forge so it cannot be included as a dependency of the " "cad-to-dagmc conda package.\n\n" "Install it with pip, which works alongside a conda installation:\n" " pip install cad-to-dagmc-mesher\n\n" "Alternatively, use a meshing backend that is always available:\n" " export_dagmc_h5m_file(..., meshing_backend='cadquery')\n" " export_dagmc_h5m_file(..., meshing_backend='gmsh')" ) super().__init__(message)
def _cad_to_dagmc_mesher_is_available() -> bool: """Return True when the cad-to-dagmc-mesher package can be imported.""" return importlib.util.find_spec("cad_to_dagmc_mesher") is not None def write_vtk(filename, vertices, tetrahedra): """Write a tetrahedral mesh to an ASCII VTK legacy file. The output is a pure tetrahedron UNSTRUCTURED_GRID in the same legacy format that gmsh writes today, so it can be read back with openmc.UnstructuredMesh(filename, library="moab"). The MOAB reader does not require the GLOBAL_ID POINT_DATA/CELL_DATA blocks that MOAB itself writes when it exports a mesh, so they are intentionally omitted. This was confirmed with a round trip transport test (see tests/test_write_vtk.py::test_write_vtk_openmc_moab_round_trip): a mesh written without GLOBAL_ID loads in MOAB and tallies identically to one written with it. Args: filename: Output file path. vertices: Sequence of [x, y, z] coordinates (list or numpy array). tetrahedra: Sequence of [v0, v1, v2, v3] zero-based vertex indices (list or numpy array). """ n_tets = len(tetrahedra) # Stream the point/cell blocks with writelines() over generators. This # keeps memory bounded (nothing bigger than one line is materialised at a # time, matching the old per-line writes) while letting the C-level # writelines do the looping, which matters for the large meshes the mesher # can produce. with open(filename, "w") as f: f.write("# vtk DataFile Version 2.0\n") f.write("Unstructured mesh\n") f.write("ASCII\n") f.write("DATASET UNSTRUCTURED_GRID\n") f.write(f"POINTS {len(vertices)} double\n") f.writelines(f"{v[0]} {v[1]} {v[2]}\n" for v in vertices) f.write(f"CELLS {n_tets} {n_tets * 5}\n") f.writelines(f"4 {t[0]} {t[1]} {t[2]} {t[3]}\n" for t in tetrahedra) f.write(f"CELL_TYPES {n_tets}\n") f.writelines("10\n" for _ in range(n_tets)) def combine_tet_meshes(tet_data): """Combine per-solid tetrahedral meshes into a single mesh. ``cad_to_dagmc_mesher.cad.mesh_assembly`` returns a ``tet_data`` dict mapping ``solid_id`` to ``{"vertices": (n, 3) array, "tetrahedra": (m, 4) array, ...}`` where each solid's tetrahedra index into that solid's own vertex list. To write a single unstructured grid the vertex arrays are concatenated and each solid's tetrahedra are offset by the running vertex count so they index into the combined vertex array. Args: tet_data: Mapping of solid_id -> dict with "vertices" and "tetrahedra" entries, as returned by mesh_assembly. Returns: (vertices, tetrahedra): a single (N, 3) float array of vertex coordinates and a single (M, 4) int array of zero-based tetrahedron vertex indices. """ all_vertices = [] all_tetrahedra = [] offset = 0 for solid_id in tet_data: verts = np.asarray(tet_data[solid_id]["vertices"], dtype=float).reshape(-1, 3) tets = np.asarray(tet_data[solid_id]["tetrahedra"], dtype=np.int64).reshape(-1, 4) all_vertices.append(verts) all_tetrahedra.append(tets + offset) offset += len(verts) if not all_vertices: return np.empty((0, 3), dtype=float), np.empty((0, 4), dtype=np.int64) return np.vstack(all_vertices), np.vstack(all_tetrahedra) def resolve_imprint(imprint: bool | int) -> tuple[bool, int | None]: """Split the imprint argument into a flag and a thread limit. The imprint argument of the export methods accepts either a bool or an int. True imprints with however many threads the OpenCASCADE thread pool is set to (all cores unless the caller has already limited it), False skips imprinting, and a positive int imprints with that many threads. Imprinting runs in parallel and its peak RAM scales with the number of threads, so a large model that runs out of memory can often be imprinted by lowering the thread count. Args: imprint: the imprint argument as given by the user. Returns: (do_imprint, threads) where threads is None when the thread count is to be left as the caller set it. Raises: ValueError: if an int less than 1 is given. TypeError: if something other than a bool or an int is given. """ # bool is a subclass of int so it has to be tested for first. It also # means an int cannot express "do not imprint": imprint=0 would be # indistinguishable from imprint=False, so 0 is rejected rather than # guessed at. if isinstance(imprint, bool): return imprint, None if isinstance(imprint, int): if imprint < 1: raise ValueError( f"imprint={imprint} is not a valid number of threads. Use " "imprint=False to skip imprinting, imprint=True to imprint " "with all available cores, or a positive int to imprint with " "that many threads." ) return True, imprint raise TypeError( f"imprint must be a bool or an int, got {type(imprint).__name__}. Use " "imprint=True or imprint=False to turn imprinting on or off, or a " "positive int to imprint with that many threads." ) @contextmanager def thread_limit(threads: int | None): """Limit the threads OpenCASCADE uses, restoring the limit afterwards. cadquery's setThreads sets the size of the OpenCASCADE thread pool that the boolean operations behind imprinting run on. The pool is process wide, so the previous size is put back on the way out and the cadquery operations that follow are left running on as many threads as before. Args: threads: the number of threads to allow, or None to leave the pool alone. """ if threads is None: yield return previous = OSD_ThreadPool.DefaultPool_s().NbThreads() setThreads(threads) try: yield finally: setThreads(previous) @contextmanager def imprint_thread_limit(threads: int | None): """Limit the threads used by imprinting and by nothing else. The gmsh backend imprints through imprint_assembly, so there the imprint can simply be wrapped in thread_limit. The cadquery plugin and cad-to-dagmc-mesher instead imprint part way through their own meshing call, so wrapping that call would limit the meshing too, and the meshing is not what runs out of memory. Both of them reach the imprint through cq.occ_impl.assembly.imprint and look it up when they call it, so swapping in a wrapper that shrinks the pool around the real imprint keeps the limit on the imprint and off the meshing either side of it. The original function is put back on the way out, including when meshing raises part way through. Args: threads: the number of threads to imprint with, or None to leave the pool alone. """ if threads is None: yield return real_imprint = cq.occ_impl.assembly.imprint # functools.wraps keeps the signature intact: imprint_assembly and # cad-to-dagmc-mesher both inspect it for the glue argument. @functools.wraps(real_imprint) def limited_imprint(*args, **kwargs): with thread_limit(threads): return real_imprint(*args, **kwargs) cq.occ_impl.assembly.imprint = limited_imprint try: yield finally: cq.occ_impl.assembly.imprint = real_imprint def imprint_assembly(assembly, threads: int | None = None): """Imprint a CadQuery assembly into a connected compound. Uses the BOPAlgo_Builder based imprint with glue="partial" when the installed cadquery supports it (CadQuery/cadquery#2069, faster and lower RAM than the older BOPAlgo_MakeConnected based imprint, with the same result for touching, non-overlapping solids). Older cadquery versions fall back to the original single-argument imprint. Args: assembly: the cadquery assembly to imprint. threads: the number of threads to imprint with. Fewer threads lowers the peak RAM of the imprint at the cost of speed. Defaults to None which leaves the thread count as it is. Returns: (imprinted_shape, imprinted_solids_with_original_ids) """ import inspect # Imprinting needs at least two solids to do anything. Skipping it for a # single solid is not just an optimization: the BOPAlgo_Builder based # imprint returns a Null shape when given fewer than two arguments. id_map = {} for obj, name, loc, _ in assembly: for solid in obj.moved(loc).Solids(): id_map[solid] = name if len(id_map) < 2: solids = list(id_map) compound = cq.occ_impl.shapes.Compound.makeCompound(solids) return compound, {s: (id_map[s],) for s in solids} with thread_limit(threads): imprint = cq.occ_impl.assembly.imprint if "glue" in inspect.signature(imprint).parameters: return imprint(assembly, glue="partial") return imprint(assembly) def share_coincident_face_ids(triangles_by_solid_by_face): """Give the face two touching solids share a single id in both of them. Imprinting leaves one face between two touching solids, but cadquery_direct_mesh_plugin numbers faces per solid, so depending on the installed version each solid can contribute its own id for that one face. Writing both produces two coincident one sided DAGMC surfaces instead of one surface carrying a sense for each volume, which does not transport correctly: particles crossing the interface are not handed to the neighbouring volume, and the flux tallied there comes out low with nothing reported. The plugin welds vertices across the whole assembly, so both copies of the interface index the same vertices and differ only in winding. Keying on the triangle set with each triangle sorted is therefore orientation insensitive and identifies the copies. Only the ids are rewritten. Each solid keeps its own winding under the shared id, which is what vertices_to_h5m expects: it writes the surface once from the first solid that refers to it and reads the second solid off the shared id to build GEOM_SENSE_2. Ids are handed out from 1 in order of first appearance rather than the plugin's original ids being kept. Merging without renumbering would leave the ids of the dropped copies unused, so the highest surface id would stay as high as the unmerged count. Those ids become DAGMC surface ids, and a DAGMC universe embedded in CSG shares an id space with the CSG surfaces, so an inflated range collides with them ("Surface ID 21 exists in both Universe 3 and the CSG geometry"). Renumbering also matches what gmsh and cad-to-dagmc-mesher produce. This reproduces what the plugin does when it shares imprinted face ids itself (jmwright/cadquery-direct-mesh-plugin#10), including the numbering. It is idempotent, so it is a no-op against a plugin that already shares them. Once that pull request is released AND the cadquery_direct_mesh_plugin floor in pyproject.toml is raised to that release, this function and its call can be removed. Removing it before the floor is raised would reintroduce the bug for anyone on an older plugin. Args: triangles_by_solid_by_face: Dict mapping solid_id -> face_id -> list of triangles, each triangle a list of vertex indices. Returns: The same mapping with coincident faces sharing one face id, and ids renumbered contiguously from 1. Raises: ValueError: if a face is shared by more than two solids, or if one solid carries the same face twice. Neither is representable as DAGMC geometry, and vertices_to_h5m would silently write a wrong sense rather than fail. """ def canonical(triangles): return frozenset(tuple(sorted(int(vertex) for vertex in triangle)) for triangle in triangles) id_by_key = {} solid_ids_by_key = {} remapped = {} for solid_id, faces in triangles_by_solid_by_face.items(): shared_faces = {} for triangles in faces.values(): key = canonical(triangles) solid_ids_by_key.setdefault(key, []).append(solid_id) if key not in id_by_key: id_by_key[key] = len(id_by_key) + 1 shared_faces[id_by_key[key]] = triangles remapped[solid_id] = shared_faces for key, solid_ids in solid_ids_by_key.items(): if len(solid_ids) != len(set(solid_ids)): msg = ( f"Solid {solid_ids[0]} has the same face twice, so it cannot be " "written as DAGMC geometry. This points at a degenerate or zero " "thickness feature in the CAD." ) raise ValueError(msg) if len(solid_ids) > 2: msg = ( f"The face with id {id_by_key[key]} is shared by solids " f"{sorted(solid_ids)}. A DAGMC surface separates at most two " "volumes, so this points at overlapping solids in the CAD." ) raise ValueError(msg) return remapped def define_moab_core_and_tags(): """Creates a MOAB Core instance which can be built up by adding sets of triangles to the instance Returns: (pymoab Core): A pymoab.core.Core() instance (pymoab tag_handle): A pymoab.core.tag_get_handle() instance """ try: from pymoab import core, types except ImportError as e: raise PyMoabNotFoundError() from e # create pymoab instance moab_core = core.Core() tags = dict() sense_tag_name = "GEOM_SENSE_2" sense_tag_size = 2 tags["surf_sense"] = moab_core.tag_get_handle( sense_tag_name, sense_tag_size, types.MB_TYPE_HANDLE, types.MB_TAG_SPARSE, create_if_missing=True, ) tags["category"] = moab_core.tag_get_handle( types.CATEGORY_TAG_NAME, types.CATEGORY_TAG_SIZE, types.MB_TYPE_OPAQUE, types.MB_TAG_SPARSE, create_if_missing=True, ) tags["name"] = moab_core.tag_get_handle( types.NAME_TAG_NAME, types.NAME_TAG_SIZE, types.MB_TYPE_OPAQUE, types.MB_TAG_SPARSE, create_if_missing=True, ) tags["geom_dimension"] = moab_core.tag_get_handle( types.GEOM_DIMENSION_TAG_NAME, 1, types.MB_TYPE_INTEGER, types.MB_TAG_DENSE, create_if_missing=True, ) # Global ID is a default tag, just need the name to retrieve tags["global_id"] = moab_core.tag_get_handle(types.GLOBAL_ID_TAG_NAME) return moab_core, tags
[docs] def vertices_to_h5m( vertices: list[tuple[float, float, float]] | list["cadquery.occ_impl.geom.Vector"], triangles_by_solid_by_face: dict[int, dict[int, list[list[int]]]], material_tags: list[str], h5m_filename: str = "dagmc.h5m", implicit_complement_material_tag: str | None = None, method: str = "h5py", ): """Converts vertices and triangle sets into a tagged h5m file compatible with DAGMC enabled neutronics simulations Args: vertices: List of vertex coordinates as (x, y, z) tuples or CadQuery vectors triangles_by_solid_by_face: Dict mapping solid_id -> face_id -> list of triangles material_tags: List of material tag names, one per solid h5m_filename: Output filename for the h5m file implicit_complement_material_tag: Optional material tag for implicit complement method: Backend to use for writing h5m file ('pymoab' or 'h5py') """ if method == "pymoab": return _vertices_to_h5m_pymoab( vertices=vertices, triangles_by_solid_by_face=triangles_by_solid_by_face, material_tags=material_tags, h5m_filename=h5m_filename, implicit_complement_material_tag=implicit_complement_material_tag, ) elif method == "h5py": return _vertices_to_h5m_h5py( vertices=vertices, triangles_by_solid_by_face=triangles_by_solid_by_face, material_tags=material_tags, h5m_filename=h5m_filename, implicit_complement_material_tag=implicit_complement_material_tag, ) else: raise ValueError(f"method must be 'pymoab' or 'h5py', not '{method}'")
def _vertices_to_h5m_pymoab( vertices: list[tuple[float, float, float]] | list["cadquery.occ_impl.geom.Vector"], triangles_by_solid_by_face: dict[int, dict[int, list[list[int]]]], material_tags: list[str], h5m_filename: str = "dagmc.h5m", implicit_complement_material_tag: str | None = None, ): """PyMOAB backend for vertices_to_h5m.""" try: from pymoab import types except ImportError as e: raise PyMoabNotFoundError() from e if len(material_tags) != len(triangles_by_solid_by_face): msg = f"The number of material_tags provided is {len(material_tags)} and the number of sets of triangles is {len(triangles_by_solid_by_face)}. You must provide one material_tag for every triangle set" raise ValueError(msg) # limited attribute checking to see if user passed in a list of CadQuery vectors if ( hasattr(vertices[0], "x") and hasattr(vertices[0], "y") and hasattr(vertices[0], "z") ): vertices_floats = [] for vert in vertices: vertices_floats.append((vert.x, vert.y, vert.z)) else: vertices_floats = vertices face_ids_with_solid_ids = {} for solid_id, triangles_on_each_face in triangles_by_solid_by_face.items(): for face_id, triangles_on_face in triangles_on_each_face.items(): if face_id in face_ids_with_solid_ids.keys(): face_ids_with_solid_ids[face_id].append(solid_id) else: face_ids_with_solid_ids[face_id] = [solid_id] moab_core, tags = define_moab_core_and_tags() # Add the vertices once at the start all_moab_verts = moab_core.create_vertices(vertices) volume_sets_by_solid_id = {} for material_tag, (solid_id, triangles_on_each_face) in zip( material_tags, triangles_by_solid_by_face.items() ): volume_set = moab_core.create_meshset() volume_sets_by_solid_id[solid_id] = volume_set added_surfaces_ids = {} for material_tag, (solid_id, triangles_on_each_face) in zip( material_tags, triangles_by_solid_by_face.items() ): volume_set = volume_sets_by_solid_id[solid_id] moab_core.tag_set_data(tags["global_id"], volume_set, solid_id) moab_core.tag_set_data(tags["geom_dimension"], volume_set, 3) moab_core.tag_set_data(tags["category"], volume_set, "Volume") group_set = moab_core.create_meshset() moab_core.tag_set_data(tags["category"], group_set, "Group") moab_core.tag_set_data(tags["name"], group_set, f"mat:{material_tag}") moab_core.tag_set_data(tags["global_id"], group_set, solid_id) # moab_core.tag_set_data(tags["geom_dimension"], group_set, 4) for face_id, triangles_on_face in triangles_on_each_face.items(): if face_id not in added_surfaces_ids.keys(): face_set = moab_core.create_meshset() moab_core.tag_set_data(tags["global_id"], face_set, face_id) moab_core.tag_set_data(tags["geom_dimension"], face_set, 2) moab_core.tag_set_data(tags["category"], face_set, "Surface") if len(face_ids_with_solid_ids[face_id]) == 2: other_solid_id = face_ids_with_solid_ids[face_id][1] other_volume_set = volume_sets_by_solid_id[other_solid_id] sense_data = np.array( [other_volume_set, volume_set], dtype="uint64" ) else: sense_data = np.array([volume_set, 0], dtype="uint64") moab_core.tag_set_data(tags["surf_sense"], face_set, sense_data) # Collect only the vertices that lie on triangles on this face face_vertices_set = set() for triangle in triangles_on_face: face_vertices_set.update(triangle) face_vertices_list = sorted(face_vertices_set) # Only add these to the MOAB face moab_verts = [all_moab_verts[ii] for ii in face_vertices_list] moab_core.add_entity(face_set, moab_verts) for triangle in triangles_on_face: tri = ( all_moab_verts[int(triangle[0])], all_moab_verts[int(triangle[1])], all_moab_verts[int(triangle[2])], ) moab_triangle = moab_core.create_element(types.MBTRI, tri) moab_core.add_entity(face_set, moab_triangle) added_surfaces_ids[face_id] = face_set else: face_set = added_surfaces_ids[face_id] other_solid_id = face_ids_with_solid_ids[face_id][0] other_volume_set = volume_sets_by_solid_id[other_solid_id] sense_data = np.array([other_volume_set, volume_set], dtype="uint64") moab_core.tag_set_data(tags["surf_sense"], face_set, sense_data) moab_core.add_parent_child(volume_set, face_set) moab_core.add_entity(group_set, volume_set) if implicit_complement_material_tag: group_set = moab_core.create_meshset() moab_core.tag_set_data(tags["category"], group_set, "Group") moab_core.tag_set_data( tags["name"], group_set, f"mat:{implicit_complement_material_tag}_comp" ) moab_core.tag_set_data(tags["geom_dimension"], group_set, 4) moab_core.add_entity( group_set, volume_set ) # volume is arbitrary but should exist in moab core all_sets = moab_core.get_entities_by_handle(0) file_set = moab_core.create_meshset() moab_core.add_entities(file_set, all_sets) # makes the folder if it does not exist if Path(h5m_filename).parent: Path(h5m_filename).parent.mkdir(parents=True, exist_ok=True) # moab_core.write_file only accepts strings if isinstance(h5m_filename, Path): moab_core.write_file(str(h5m_filename)) else: moab_core.write_file(h5m_filename) print(f"written DAGMC file {h5m_filename}") return h5m_filename def _vertices_to_h5m_h5py( vertices: list[tuple[float, float, float]] | list["cadquery.occ_impl.geom.Vector"], triangles_by_solid_by_face: dict[int, dict[int, list[list[int]]]], material_tags: list[str], h5m_filename: str = "dagmc.h5m", implicit_complement_material_tag: str | None = None, ): """H5PY backend for vertices_to_h5m. Creates an h5m file compatible with DAGMC using h5py directly, without requiring pymoab. """ import h5py from datetime import datetime if len(material_tags) != len(triangles_by_solid_by_face): msg = f"The number of material_tags provided is {len(material_tags)} and the number of sets of triangles is {len(triangles_by_solid_by_face)}. You must provide one material_tag for every triangle set" raise ValueError(msg) # Convert CadQuery vectors to floats if needed if ( hasattr(vertices[0], "x") and hasattr(vertices[0], "y") and hasattr(vertices[0], "z") ): vertices_floats = [(vert.x, vert.y, vert.z) for vert in vertices] else: vertices_floats = vertices # Build face_ids_with_solid_ids to track shared faces face_ids_with_solid_ids = {} for solid_id, triangles_on_each_face in triangles_by_solid_by_face.items(): for face_id in triangles_on_each_face.keys(): if face_id in face_ids_with_solid_ids: face_ids_with_solid_ids[face_id].append(solid_id) else: face_ids_with_solid_ids[face_id] = [solid_id] # Collect all unique faces and their triangles all_faces = {} # face_id -> list of triangles for solid_id, triangles_on_each_face in triangles_by_solid_by_face.items(): for face_id, triangles_on_face in triangles_on_each_face.items(): if face_id not in all_faces: all_faces[face_id] = triangles_on_face # Convert vertices to numpy array vertices_arr = np.asarray(vertices_floats, dtype=np.float64) num_vertices = len(vertices_arr) # Collect all triangles all_triangles = [] for face_id in sorted(all_faces.keys()): all_triangles.extend(all_faces[face_id]) all_triangles = np.asarray(all_triangles, dtype=np.int64) num_triangles = len(all_triangles) # Create the h5m file # makes the folder if it does not exist if Path(h5m_filename).parent: Path(h5m_filename).parent.mkdir(parents=True, exist_ok=True) with h5py.File(h5m_filename, "w") as f: tstt = f.create_group("tstt") # Global ID counter - starts at 1 global_id = 1 # === NODES === nodes_group = tstt.create_group("nodes") coords = nodes_group.create_dataset("coordinates", data=vertices_arr) coords.attrs.create("start_id", global_id) global_id += num_vertices # Node tags node_tags = nodes_group.create_group("tags") node_tags.create_dataset("GLOBAL_ID", data=np.full(num_vertices, -1, dtype=np.int32)) # === ELEMENTS === elements = tstt.create_group("elements") # Element type enum elems = { "Edge": 1, "Tri": 2, "Quad": 3, "Polygon": 4, "Tet": 5, "Pyramid": 6, "Prism": 7, "Knife": 8, "Hex": 9, "Polyhedron": 10, } tstt["elemtypes"] = h5py.enum_dtype(elems) # History now = datetime.now() tstt.create_dataset( "history", data=[ "cad_to_dagmc".encode("ascii"), __version__.encode("ascii"), now.strftime("%m/%d/%y").encode("ascii"), now.strftime("%H:%M:%S").encode("ascii"), ], ) # Triangles tri3_group = elements.create_group("Tri3") tri3_group.attrs.create("element_type", elems["Tri"], dtype=tstt["elemtypes"]) # Node indices are 1-based in h5m connectivity = tri3_group.create_dataset( "connectivity", data=all_triangles + 1, dtype=np.uint64, ) triangle_start_id = global_id connectivity.attrs.create("start_id", triangle_start_id) global_id += num_triangles # Triangle tags tags_tri3 = tri3_group.create_group("tags") tags_tri3.create_dataset("GLOBAL_ID", data=np.full(num_triangles, -1, dtype=np.int32)) # === SETS === # Plan out the entity set structure: # For each solid: 1 volume set, N surface sets (one per face), 1 group set (material) # Plus: 1 file set at the end, optionally 1 implicit complement group solid_ids = list(triangles_by_solid_by_face.keys()) num_solids = len(solid_ids) # Assign set IDs sets_start_id = global_id # Map solid_id -> volume_set_id volume_set_ids = {} # Map face_id -> surface_set_id surface_set_ids = {} # Map solid_id -> group_set_id group_set_ids = {} current_set_id = sets_start_id # First, assign IDs to all surfaces (one per unique face) for face_id in sorted(all_faces.keys()): surface_set_ids[face_id] = current_set_id current_set_id += 1 # Then assign IDs to volumes for solid_id in solid_ids: volume_set_ids[solid_id] = current_set_id current_set_id += 1 # Then assign IDs to groups (materials) for solid_id in solid_ids: group_set_ids[solid_id] = current_set_id current_set_id += 1 # Implicit complement group (if requested) implicit_complement_set_id = None if implicit_complement_material_tag: implicit_complement_set_id = current_set_id current_set_id += 1 # File set file_set_id = current_set_id current_set_id += 1 global_id = current_set_id # === TAGS === tstt_tags = tstt.create_group("tags") # Collect tagged set IDs for CATEGORY (all entities) # and GEOM_DIMENSION (only surfaces and volumes - not groups, to match pymoab) category_set_ids = [] categories = [] geom_dim_set_ids = [] geom_dimensions = [] # Volumes first (to match pymoab ordering) for solid_id in solid_ids: category_set_ids.append(volume_set_ids[solid_id]) categories.append("Volume") geom_dim_set_ids.append(volume_set_ids[solid_id]) geom_dimensions.append(3) # Groups (CATEGORY only - pymoab doesn't set geom_dimension on groups) # Note: Groups COULD have geom_dimension=4 set, but pymoab doesn't do this for solid_id in solid_ids: category_set_ids.append(group_set_ids[solid_id]) categories.append("Group") # Surfaces for face_id in sorted(all_faces.keys()): category_set_ids.append(surface_set_ids[face_id]) categories.append("Surface") geom_dim_set_ids.append(surface_set_ids[face_id]) geom_dimensions.append(2) # Implicit complement (CATEGORY only) if implicit_complement_material_tag: category_set_ids.append(implicit_complement_set_id) categories.append("Group") # CATEGORY tag # Note: We use opaque dtype (|V32) to match pymoab output exactly. # A string dtype (|S32) would also work and be more readable in h5dump, # but we match pymoab for maximum compatibility. cat_group = tstt_tags.create_group("CATEGORY") cat_group.attrs.create("class", 1, dtype=np.int32) cat_group.create_dataset("id_list", data=np.array(category_set_ids, dtype=np.uint64)) # Create opaque 32-byte type to match pymoab's H5T_OPAQUE opaque_dt = h5py.opaque_dtype(np.dtype("V32")) cat_group["type"] = opaque_dt # Encode category strings as 32-byte null-padded values cat_values = np.array([s.encode("ascii").ljust(32, b"\x00") for s in categories], dtype="V32") cat_group.create_dataset("values", data=cat_values) # GEOM_DIMENSION tag # Note: We only tag surfaces (dim=2) and volumes (dim=3), not groups. # Groups COULD have geom_dimension=4, but pymoab doesn't set this. geom_group = tstt_tags.create_group("GEOM_DIMENSION") geom_group["type"] = np.dtype("i4") geom_group.attrs.create("class", 1, dtype=np.int32) geom_group.attrs.create("default", -1, dtype=geom_group["type"]) geom_group.attrs.create("global", -1, dtype=geom_group["type"]) geom_group.create_dataset("id_list", data=np.array(geom_dim_set_ids, dtype=np.uint64)) geom_group.create_dataset("values", data=np.array(geom_dimensions, dtype=np.int32)) # GEOM_SENSE_2 tag (only for surfaces) surface_ids_list = [surface_set_ids[fid] for fid in sorted(all_faces.keys())] gs2_group = tstt_tags.create_group("GEOM_SENSE_2") gs2_dtype = np.dtype("(2,)u8") gs2_group["type"] = gs2_dtype gs2_group.attrs.create("class", 1, dtype=np.int32) gs2_group.attrs.create("is_handle", 1, dtype=np.int32) gs2_group.create_dataset("id_list", data=np.array(surface_ids_list, dtype=np.uint64)) # Build sense data for each surface sense_values = [] for face_id in sorted(all_faces.keys()): solids_for_face = face_ids_with_solid_ids[face_id] if len(solids_for_face) == 2: # Shared face - both volumes vol1 = volume_set_ids[solids_for_face[0]] vol2 = volume_set_ids[solids_for_face[1]] sense_values.append([vol1, vol2]) else: # Single volume vol = volume_set_ids[solids_for_face[0]] sense_values.append([vol, 0]) if sense_values: gs2_values = np.zeros((len(sense_values),), dtype=[("f0", "<u8", (2,))]) gs2_values["f0"] = np.array(sense_values, dtype=np.uint64) gs2_space = h5py.h5s.create_simple((len(sense_values),)) gs2_arr_type = h5py.h5t.array_create(h5py.h5t.NATIVE_UINT64, (2,)) gs2_dset = h5py.h5d.create(gs2_group.id, b"values", gs2_arr_type, gs2_space) gs2_dset.write(h5py.h5s.ALL, h5py.h5s.ALL, gs2_values, mtype=gs2_arr_type) gs2_dset.close() # GLOBAL_ID tag - store as sparse tag with id_list and values # This stores the user-facing IDs for surfaces and volumes gid_ids = [] gid_values = [] # Surfaces get their face_id as global_id for face_id in sorted(all_faces.keys()): gid_ids.append(surface_set_ids[face_id]) gid_values.append(face_id) # Volumes get their solid_id as global_id for solid_id in solid_ids: gid_ids.append(volume_set_ids[solid_id]) gid_values.append(solid_id) # Groups also get the solid_id for solid_id in solid_ids: gid_ids.append(group_set_ids[solid_id]) gid_values.append(solid_id) gid_group = tstt_tags.create_group("GLOBAL_ID") gid_group["type"] = np.dtype("i4") gid_group.attrs.create("class", 2, dtype=np.int32) gid_group.attrs.create("default", -1, dtype=gid_group["type"]) gid_group.attrs.create("global", -1, dtype=gid_group["type"]) gid_group.create_dataset("id_list", data=np.array(gid_ids, dtype=np.uint64)) gid_group.create_dataset("values", data=np.array(gid_values, dtype=np.int32)) # NAME tag (for groups - material names) name_ids = [] name_values = [] for solid_id, mat_tag in zip(solid_ids, material_tags): name_ids.append(group_set_ids[solid_id]) name_values.append(f"mat:{mat_tag}") if implicit_complement_material_tag: name_ids.append(implicit_complement_set_id) name_values.append(f"mat:{implicit_complement_material_tag}_comp") name_group = tstt_tags.create_group("NAME") name_group.attrs.create("class", 1, dtype=np.int32) name_group.create_dataset("id_list", data=np.array(name_ids, dtype=np.uint64)) name_group["type"] = h5py.opaque_dtype(np.dtype("S32")) name_group.create_dataset("values", data=name_values, dtype=name_group["type"]) # Other standard tags (empty but needed) for tag_name in ["DIRICHLET_SET", "MATERIAL_SET", "NEUMANN_SET"]: tag_grp = tstt_tags.create_group(tag_name) tag_grp["type"] = np.dtype("i4") tag_grp.attrs.create("class", 1, dtype=np.int32) tag_grp.attrs.create("default", -1, dtype=tag_grp["type"]) tag_grp.attrs.create("global", -1, dtype=tag_grp["type"]) # FACETING_TOLERANCE tag — stored on the root meshset via the # "global" dataset so DAGMC's GeomQueryTool reads a valid value. # Without this, DAGMC reads uninitialised memory and particle # tracking fails with lost particles at curved surface boundaries. ft_grp = tstt_tags.create_group("FACETING_TOLERANCE") ft_type = np.dtype("f8") ft_grp["type"] = ft_type ft_grp.attrs.create("class", 2, dtype=np.int32) # Compute a representative faceting tolerance from the mesh extent. _diag = np.linalg.norm(vertices_arr.max(axis=0) - vertices_arr.min(axis=0)) _facet_tol = max(_diag * 1e-3, 1e-3) # MOAB's mhdf reader expects "default" and "global" as HDF5 # datasets (not attributes). Store them both ways for compat. ft_grp.create_dataset("default", data=np.array([_facet_tol], dtype=ft_type)) ft_grp.create_dataset("global", data=np.array([_facet_tol], dtype=ft_type)) # Also store as sparse tag data on root meshset (handle 0). ft_grp.create_dataset("id_list", data=np.array([0], dtype=np.uint64)) ft_grp.create_dataset("values", data=np.array([_facet_tol], dtype=ft_type)) # === SETS structure === sets_group = tstt.create_group("sets") # Build contents, parents, children, and list arrays contents = [] list_rows = [] parents_list = [] children_list = [] # Track triangle ranges per face tri_offset = 0 face_triangle_ranges = {} for face_id in sorted(all_faces.keys()): tris = all_faces[face_id] face_triangle_ranges[face_id] = (tri_offset, len(tris)) tri_offset += len(tris) # Track vertices per face face_vertex_sets = {} for face_id, tris in all_faces.items(): verts = set() for tri in tris: verts.update(tri) face_vertex_sets[face_id] = sorted(verts) contents_end = -1 children_end = -1 parents_end = -1 # Surface sets for face_id in sorted(all_faces.keys()): # Content: vertices + triangles for this face verts = face_vertex_sets[face_id] tri_start, tri_count = face_triangle_ranges[face_id] # Add individual vertex handles (1-based IDs) # Don't assume vertices are contiguous - store each one for v in verts: contents.append(v + 1) # 1-based vertex ID # Add individual triangle handles for i in range(tri_count): contents.append(triangle_start_id + tri_start + i) contents_end = len(contents) - 1 # Parent-child: surface is child of volume(s) solids_for_face = face_ids_with_solid_ids[face_id] for solid_id in solids_for_face: parents_list.append(volume_set_ids[solid_id]) parents_end = len(parents_list) - 1 # flags: 2 = MESHSET_SET (handles, not ranges) list_rows.append([contents_end, children_end, parents_end, 2]) # Volume sets (empty contents, but have surface children) for solid_id in solid_ids: # Volumes have no direct content # Children are the surfaces faces_in_solid = list(triangles_by_solid_by_face[solid_id].keys()) for face_id in faces_in_solid: children_list.append(surface_set_ids[face_id]) children_end = len(children_list) - 1 # flags: 2 = handle-based (0b0010) list_rows.append([contents_end, children_end, parents_end, 2]) # Group sets (contain volume handles) for solid_id in solid_ids: contents.append(volume_set_ids[solid_id]) contents_end = len(contents) - 1 list_rows.append([contents_end, children_end, parents_end, 2]) # Implicit complement group if implicit_complement_material_tag: # Add the last volume to the implicit complement group contents.append(volume_set_ids[solid_ids[-1]]) contents_end = len(contents) - 1 list_rows.append([contents_end, children_end, parents_end, 2]) # File set (contains everything) contents.extend([1, file_set_id - 1]) # range of all entities contents_end = len(contents) - 1 list_rows.append([contents_end, children_end, parents_end, 10]) # Write sets datasets sets_group.create_dataset("contents", data=np.array(contents, dtype=np.uint64)) if children_list: sets_group.create_dataset("children", data=np.array(children_list, dtype=np.uint64)) else: sets_group.create_dataset("children", data=np.array([], dtype=np.uint64)) if parents_list: sets_group.create_dataset("parents", data=np.array(parents_list, dtype=np.uint64)) else: sets_group.create_dataset("parents", data=np.array([], dtype=np.uint64)) lst = sets_group.create_dataset("list", data=np.array(list_rows, dtype=np.int64)) lst.attrs.create("start_id", sets_start_id) # Set tags (GLOBAL_ID for each set) sets_tags = sets_group.create_group("tags") set_global_ids = [] # Surface global IDs for face_id in sorted(all_faces.keys()): set_global_ids.append(face_id) # Volume global IDs for solid_id in solid_ids: set_global_ids.append(solid_id) # Group global IDs for solid_id in solid_ids: set_global_ids.append(solid_id) # Implicit complement if implicit_complement_material_tag: set_global_ids.append(-1) # File set set_global_ids.append(-1) sets_tags.create_dataset("GLOBAL_ID", data=np.array(set_global_ids, dtype=np.int32)) # Max ID attribute tstt.attrs.create("max_id", np.uint64(global_id - 1)) print(f"written DAGMC file {h5m_filename}") return h5m_filename
[docs] def get_volumes(gmsh, assembly, method="file", scale_factor=1.0): if method == "in memory": volumes = gmsh.model.occ.importShapesNativePointer(assembly.wrapped._address()) elif method == "file": with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file: if isinstance(assembly, cq.Assembly): assembly.toCompound().exportBrep(temp_file.name) else: assembly.exportBrep(temp_file.name) volumes = gmsh.model.occ.importShapes(temp_file.name) # updating the model to ensure the entities in the geometry are found gmsh.model.occ.synchronize() if scale_factor != 1.0: dim_tags = gmsh.model.getEntities(3) gmsh.model.occ.dilate( dim_tags, 0.0, 0.0, 0.0, scale_factor, scale_factor, scale_factor ) # update the model to ensure the scaling factor has been applied gmsh.model.occ.synchronize() return gmsh, volumes
[docs] def init_gmsh(): # gmsh is a global singleton. If a previous session was left initialized # (for example by an export that errored part way through, or an earlier # call that did not finalize) then adding a new model here would leave the # stale models from that session alive, leaking memory and growing the # session on every call (see issue #187). Finalize any pre-existing # session first so we always start from a clean, single-model state. if gmsh.isInitialized(): gmsh.finalize() gmsh.initialize() gmsh.option.setNumber("General.Terminal", 1) gmsh.model.add(f"made_with_cad_to_dagmc_package_{__version__}") return gmsh
[docs] def set_sizes_for_mesh( gmsh, min_mesh_size: float | None = None, max_mesh_size: float | None = None, mesh_algorithm: int = 1, set_size: dict[int | str, float] | None = None, original_set_size: dict[int | str, float] | None = None, threads: int = 0, ): """Sets up the mesh sizes for each volume in the mesh. Args: occ_shape: the occ_shape of the Brep file to convert min_mesh_size: the minimum mesh element size to use in Gmsh. Passed into gmsh.option.setNumber("Mesh.MeshSizeMin", min_mesh_size) max_mesh_size: the maximum mesh element size to use in Gmsh. Passed into gmsh.option.setNumber("Mesh.MeshSizeMax", max_mesh_size) mesh_algorithm: The Gmsh mesh algorithm number to use. Passed into gmsh.option.setNumber("Mesh.Algorithm", mesh_algorithm) set_size: a dictionary of volume ids (int) and target mesh sizes (floats) to set for each volume, passed to gmsh.model.mesh.setSize. threads: the number of threads for Gmsh to use. Passed into gmsh.option.setNumber("General.NumThreads", threads). 0 uses all available cores (default), 1 uses a single thread. Returns: The resulting gmsh object and volumes """ if min_mesh_size and max_mesh_size: if min_mesh_size > max_mesh_size: raise ValueError( f"min_mesh_size must be less than or equal to max_mesh_size. Currently min_mesh_size is set to {min_mesh_size} and max_mesh_size is set to {max_mesh_size}" ) if min_mesh_size: gmsh.option.setNumber("Mesh.MeshSizeMin", min_mesh_size) if max_mesh_size: gmsh.option.setNumber("Mesh.MeshSizeMax", max_mesh_size) gmsh.option.setNumber("Mesh.Algorithm", mesh_algorithm) gmsh.option.setNumber("General.NumThreads", threads) if set_size: volumes = gmsh.model.getEntities(3) available_volumes = [volume[1] for volume in volumes] print("volumes", volumes) # Ensure all volume IDs in set_size exist in the available volumes for volume_id in set_size.keys(): if volume_id not in available_volumes: raise ValueError( f"volume ID of {volume_id} set in set_sizes but not found in available volumes {volumes}" ) # Warn if any set_size values fall outside the global min/max range # Use original_set_size keys (which may be material tag strings) for # user-friendly warnings, falling back to resolved volume IDs warn_items = original_set_size.items() if original_set_size else set_size.items() for key, size in warn_items: if min_mesh_size is not None and size < min_mesh_size: warnings.warn( f"set_size for {key} is {size} which is below " f"min_mesh_size of {min_mesh_size}. The mesh size will be " f"clamped to {min_mesh_size}. Try reducing min_mesh_size to " f"encompass the set_size value." ) if max_mesh_size is not None and size > max_mesh_size: warnings.warn( f"set_size for {key} is {size} which is above " f"max_mesh_size of {max_mesh_size}. The mesh size will be " f"clamped to {max_mesh_size}. Try enlarging max_mesh_size to " f"encompass the set_size value." ) # Step 1: Preprocess boundaries to find the smallest size for shared surfaces boundary_sizes = {} # Dictionary to store the minimum mesh size for each boundary for volume_id, size in set_size.items(): boundaries = gmsh.model.getBoundary( [(3, volume_id)], recursive=True ) # dim must be set to 3 print(f"Boundaries for volume {volume_id}: {boundaries}") for boundary in boundaries: boundary_key = (boundary[0], boundary[1]) # (dimension, tag) if boundary_key in boundary_sizes: # If the boundary is already processed, keep the smaller size boundary_sizes[boundary_key] = min(boundary_sizes[boundary_key], size) else: boundary_sizes[boundary_key] = size # Step 2: Apply mesh sizes to all boundaries for boundary, size in boundary_sizes.items(): gmsh.model.mesh.setSize([boundary], size) print(f"Set mesh size {size} for boundary {boundary}") return gmsh
[docs] def mesh_to_vertices_and_triangles( dims_and_vol_ids, ): """Converts gmsh volumes into vertices and triangles for each face. Args: volumes: the volumes in the gmsh file, found with gmsh.model.occ.importShapes Returns: vertices and triangles (grouped by solid then by face) """ n = 3 # number of verts in a triangles triangles_by_solid_by_face = {} for dim_and_vol in dims_and_vol_ids: # removes all groups so that the following getEntitiesForPhysicalGroup # command only finds surfaces for the volume face_groups = gmsh.model.getPhysicalGroups(2) if face_groups: # Only remove if 2D groups exist gmsh.model.removePhysicalGroups(face_groups) vol_id = dim_and_vol[1] entities_in_volume = gmsh.model.getAdjacencies(3, vol_id) surfaces_in_volume = entities_in_volume[1] ps = gmsh.model.addPhysicalGroup(2, surfaces_in_volume) gmsh.model.setPhysicalName(2, ps, f"surfaces_on_volume_{vol_id}") groups = gmsh.model.getPhysicalGroups() group = groups[0] # for group in groups: dim = group[0] tag = group[1] surfaces = gmsh.model.getEntitiesForPhysicalGroup(dim, tag) # nodes_in_all_surfaces = [] nodes_in_each_surface = {} for surface in surfaces: _, _, nodeTags = gmsh.model.mesh.getElements(2, surface) nodeTags = nodeTags[0].tolist() shifted_node_tags = [] for nodeTag in nodeTags: shifted_node_tags.append(nodeTag - 1) grouped_node_tags = [ shifted_node_tags[i : i + n] for i in range(0, len(shifted_node_tags), n) ] nodes_in_each_surface[surface] = grouped_node_tags triangles_by_solid_by_face[vol_id] = nodes_in_each_surface _, all_coords, _ = gmsh.model.mesh.getNodes() vertices = [all_coords[i : i + n].tolist() for i in range(0, len(all_coords), n)] return vertices, triangles_by_solid_by_face
def get_ids_from_assembly(assembly: cq.assembly.Assembly): ids = [] for obj, name, loc, _ in assembly: ids.append(name) return ids def get_ids_from_imprinted_assembly(solid_id_dict): ids = [] for id in list(solid_id_dict.values()): ids.append(id[0]) return ids def check_material_tags(material_tags, iterable_solids): if material_tags: if len(material_tags) != len(iterable_solids): msg = ( "When setting material_tags the number of material_tags \n" "should be equal to the number of volumes in the CAD \n" f"geometry {len(iterable_solids)} volumes found in model \n" f"and {len(material_tags)} material_tags found" ) raise ValueError(msg) for material_tag in material_tags: if not isinstance(material_tag, str): msg = f"material_tags should be an iterable of strings." raise ValueError(msg) if len(material_tag) > 28: msg = ( f"Material tag {material_tag} is too long. DAGMC will truncate this material tag " f"to 28 characters. The resulting tag in the h5m file will be {material_tag[:28]}" ) warnings.warn(msg) def order_material_ids_by_brep_order(original_ids, scrambled_id, material_tags): material_tags_in_brep_order = [] for brep_id in scrambled_id: id_of_solid_in_org = original_ids.index(brep_id) material_tags_in_brep_order.append(material_tags[id_of_solid_in_org]) return material_tags_in_brep_order def resolve_unstructured_volumes( unstructured_volumes: Iterable[int | str], volumes: list[tuple[int, int]], material_tags: list[str], ) -> list[int]: """Resolve a mixed list of volume IDs (int) and material tags (str) to volume IDs. Args: unstructured_volumes: An iterable containing volume IDs (int) or material tag names (str). Material tags are resolved to all volume IDs that have that tag. volumes: List of (dim, volume_id) tuples from GMSH, where the order corresponds to the order of material_tags. material_tags: List of material tags in the same order as volumes. Returns: A list of unique volume IDs (int) corresponding to the input. Raises: ValueError: If a material tag string is not found in material_tags. TypeError: If an element is neither int nor str. """ resolved_ids = [] # Build a mapping from material tag to volume IDs # volumes is a list of (dim, volume_id), and material_tags has the same order material_to_volume_ids: dict[str, list[int]] = {} for (_, volume_id), material_tag in zip(volumes, material_tags): if material_tag not in material_to_volume_ids: material_to_volume_ids[material_tag] = [] material_to_volume_ids[material_tag].append(volume_id) for item in unstructured_volumes: if isinstance(item, int): resolved_ids.append(item) elif isinstance(item, str): if item not in material_to_volume_ids: available_tags = sorted(set(material_tags)) raise ValueError( f"Material tag '{item}' not found. " f"Available material tags are: {available_tags}" ) resolved_ids.extend(material_to_volume_ids[item]) else: raise TypeError( f"unstructured_volumes must contain int (volume ID) or str (material tag), " f"got {type(item).__name__}" ) # Remove duplicates while preserving order seen = set() unique_ids = [] for vol_id in resolved_ids: if vol_id not in seen: seen.add(vol_id) unique_ids.append(vol_id) return unique_ids def resolve_set_size( set_size: dict[int | str, float], volumes: list[tuple[int, int]], material_tags: list[str], ) -> dict[int, float]: """Resolve a set_size dict with int or str keys to int keys only. Args: set_size: A dictionary mapping volume IDs (int) or material tag names (str) to mesh sizes (float). Material tags are resolved to all volume IDs that have that tag. volumes: List of (dim, volume_id) tuples from GMSH, where the order corresponds to the order of material_tags. material_tags: List of material tags in the same order as volumes. Returns: A dictionary mapping volume IDs (int) to mesh sizes (float). Raises: ValueError: If a material tag string is not found in material_tags, or if a volume ID is specified multiple times with different sizes. TypeError: If a key is neither int nor str. """ resolved: dict[int, float] = {} # Build a mapping from material tag to volume IDs material_to_volume_ids: dict[str, list[int]] = {} for (_, volume_id), material_tag in zip(volumes, material_tags): if material_tag not in material_to_volume_ids: material_to_volume_ids[material_tag] = [] material_to_volume_ids[material_tag].append(volume_id) for key, size in set_size.items(): if isinstance(key, int): volume_ids = [key] elif isinstance(key, str): if key not in material_to_volume_ids: available_tags = sorted(set(material_tags)) raise ValueError( f"Material tag '{key}' not found in set_size. " f"Available material tags are: {available_tags}" ) volume_ids = material_to_volume_ids[key] else: raise TypeError( f"set_size keys must be int (volume ID) or str (material tag), " f"got {type(key).__name__}" ) for vol_id in volume_ids: if vol_id in resolved: if resolved[vol_id] != size: raise ValueError( f"Volume ID {vol_id} specified multiple times with different sizes: " f"{resolved[vol_id]} and {size}. " f"Each volume can only have one mesh size." ) else: resolved[vol_id] = size return resolved
[docs] def export_gmsh_object_to_dagmc_h5m_file( material_tags: list[str] | None = None, implicit_complement_material_tag: str | None = None, filename: str = "dagmc.h5m", h5m_backend: str = "h5py", ) -> str: """ Exports a GMSH object to a DAGMC-compatible h5m file. Note gmsh should be initialized by the user prior and the gmsh model should be meshed before calling this. Also users should ensure that the gmsh model is finalized. Args: material_tags: A list of material tags corresponding to the volumes in the GMSH object. implicit_complement_material_tag: The material tag for the implicit complement (void space). filename: The name of the output h5m file. Defaults to "dagmc.h5m". h5m_backend: Backend for writing h5m file, 'pymoab' or 'h5py'. Defaults to 'h5py'. Returns: str: The filename of the generated DAGMC h5m file. Raises: ValueError: If the number of material tags does not match the number of volumes in the GMSH object. """ if material_tags is None: material_tags = _get_material_tags_from_gmsh() dims_and_vol_ids = gmsh.model.getEntities(3) if len(dims_and_vol_ids) != len(material_tags): msg = f"Number of volumes {len(dims_and_vol_ids)} is not equal to number of material tags {len(material_tags)}" raise ValueError(msg) vertices, triangles_by_solid_by_face = mesh_to_vertices_and_triangles( dims_and_vol_ids=dims_and_vol_ids ) h5m_filename = vertices_to_h5m( vertices=vertices, triangles_by_solid_by_face=triangles_by_solid_by_face, material_tags=material_tags, h5m_filename=filename, implicit_complement_material_tag=implicit_complement_material_tag, method=h5m_backend, ) return h5m_filename
def _get_material_tags_from_gmsh() -> list[str]: """Gets the Physical groups of 3D groups from the GMSH object and returns their names.""" # Get all 3D physical groups (volumes) volume_groups = gmsh.model.getPhysicalGroups(3) material_tags = [] # Get the name for each physical group for dim, tag in volume_groups: name = gmsh.model.getPhysicalName(dim, tag) material_tags.append(name) print(f"Material tag: {name}") print(f"Material tags: {material_tags}") return material_tags
[docs] def export_gmsh_file_to_dagmc_h5m_file( gmsh_filename: str, material_tags: list[str] | None = None, implicit_complement_material_tag: str | None = None, dagmc_filename: str = "dagmc.h5m", h5m_backend: str = "h5py", ) -> str: """Saves a DAGMC h5m file of the geometry GMsh file. This function initializes and finalizes Gmsh. Args: gmsh_filename (str): the filename of the GMSH mesh file. material_tags (list[str]): the names of the DAGMC material tags to assign. These will need to be in the same order as the volumes in the GMESH mesh and match the material tags used in the neutronics code (e.g. OpenMC). implicit_complement_material_tag (str | None, optional): the name of the material tag to use for the implicit complement (void space). Defaults to None which is a vacuum. dagmc_filename (str, optional): Output filename. Defaults to "dagmc.h5m". h5m_backend (str, optional): Backend for writing h5m file, 'pymoab' or 'h5py'. Defaults to 'h5py'. Returns: str: The filename of the generated DAGMC h5m file. Raises: ValueError: If the number of material tags does not match the number of volumes in the GMSH object. """ gmsh.initialize() gmsh.open(gmsh_filename) if material_tags is None: material_tags = _get_material_tags_from_gmsh() dims_and_vol_ids = gmsh.model.getEntities(3) if len(dims_and_vol_ids) != len(material_tags): msg = f"Number of volumes {len(dims_and_vol_ids)} is not equal to number of material tags {len(material_tags)}" raise ValueError(msg) vertices, triangles_by_solid_by_face = mesh_to_vertices_and_triangles( dims_and_vol_ids=dims_and_vol_ids ) gmsh.finalize() h5m_filename = vertices_to_h5m( vertices=vertices, triangles_by_solid_by_face=triangles_by_solid_by_face, material_tags=material_tags, h5m_filename=dagmc_filename, implicit_complement_material_tag=implicit_complement_material_tag, method=h5m_backend, ) return h5m_filename
[docs] class CadToDagmc: """Converts Step files and CadQuery parts to a DAGMC h5m file""" def __init__(self): self.parts = [] self.material_tags = []
[docs] def add_stp_file( self, filename: str, scale_factor: float = 1.0, material_tags: list[str] | str | None = None, ) -> int: """Loads the parts from stp file into the model. Args: filename: the filename used to save the html graph. material_tags: the names of the DAGMC material tags to assign. Can be a list of strings (one per volume), or one of the special strings "assembly_names" or "assembly_materials" to automatically extract tags from the STEP file's assembly structure (if the STEP file contains named parts or materials). When using a list, tags must be in the same order as the volumes in the geometry. scale_factor: a scaling factor to apply to the geometry that can be used to increase the size or decrease the size of the geometry. Useful when converting the geometry to cm for use in neutronics simulations. Returns: int: number of volumes in the stp file. """ # If using assembly_names or assembly_materials, try to load as assembly if material_tags in ("assembly_names", "assembly_materials"): assembly = cq.Assembly() importStepAssembly(assembly, str(filename)) if scale_factor != 1.0: # Scale each part in the assembly scaled_assembly = cq.Assembly() for child in assembly.children: scaled_shape = child.obj.scale(scale_factor) scaled_assembly.add( scaled_shape, name=child.name, color=child.color, loc=child.loc, ) if hasattr(child, "material") and child.material is not None: scaled_assembly.children[-1].material = child.material assembly = scaled_assembly return self.add_cadquery_object( cadquery_object=assembly, material_tags=material_tags ) # Default behavior: load as compound/solid part = importers.importStep(str(filename)).val() if scale_factor == 1.0: scaled_part = part else: scaled_part = part.scale(scale_factor) return self.add_cadquery_object( cadquery_object=scaled_part, material_tags=material_tags )
[docs] def add_cadquery_object( self, cadquery_object: ( cq.assembly.Assembly | cq.occ_impl.shapes.Compound | cq.occ_impl.shapes.Solid ), material_tags: list[str] | str, scale_factor: float = 1.0, ) -> int: """Loads the parts from CadQuery object into the model. Args: cadquery_object: the cadquery object to convert, can be a CadQuery assembly cadquery workplane or a cadquery solid material_tags (Optional list[str]): the names of the DAGMC material tags to assign. These will need to be in the same order as the volumes in the geometry added (STP file and CadQuery objects) and match the material tags used in the neutronics code (e.g. OpenMC). scale_factor: a scaling factor to apply to the geometry that can be used to increase the size or decrease the size of the geometry. Useful when converting the geometry to cm for use in neutronics simulations. Returns: int: number of volumes in the stp file. """ if isinstance(material_tags, str) and material_tags not in [ "assembly_materials", "assembly_names", ]: raise ValueError( f"If material_tags is a string it must be 'assembly_materials' or 'assembly_names' but got {material_tags}" ) if isinstance(cadquery_object, cq.assembly.Assembly): # look for materials in each part of the assembly if material_tags == "assembly_materials": material_tags = [] for child in _get_all_leaf_children(cadquery_object): if child.material is not None and child.material.name is not None: # count solids in this child to repeat the tag appropriately child_shape = child.toCompound() if hasattr(child, 'toCompound') else child.obj if child_shape is not None: child_solids = child_shape.Solids() if hasattr(child_shape, 'Solids') else [] else: child_solids = [] for _ in child_solids: material_tags.append(str(child.material.name)) else: raise ValueError( f"Not all parts in the assembly have materials assigned.\n" f"When adding to an assembly include material=cadquery.Material('material_name')\n" f"Missing material tag for child: {child}.\n" "Please assign material tags to all parts or provide material_tags argument when adding the assembly.\n" ) print("material_tags found from assembly materials:", material_tags) elif material_tags == "assembly_names": material_tags = [] for child in _get_all_leaf_children(cadquery_object): # count solids in this child to repeat the tag appropriately child_shape = child.toCompound() if hasattr(child, 'toCompound') else child.obj if child_shape is not None: child_solids = child_shape.Solids() if hasattr(child_shape, 'Solids') else [] else: child_solids = [] # parts always have a name as cq will auto assign one for _ in child_solids: material_tags.append(child.name) print("material_tags found from assembly names:", material_tags) cadquery_compound = cadquery_object.toCompound() else: cadquery_compound = cadquery_object if isinstance( cadquery_compound, (cq.occ_impl.shapes.Compound, cq.occ_impl.shapes.Solid) ): iterable_solids = cadquery_compound.Solids() else: iterable_solids = cadquery_compound.val().Solids() if scale_factor == 1.0: scaled_iterable_solids = iterable_solids else: scaled_iterable_solids = [ part.scale(scale_factor) for part in iterable_solids ] check_material_tags(material_tags, scaled_iterable_solids) if material_tags: self.material_tags = self.material_tags + material_tags self.parts = self.parts + scaled_iterable_solids return len(scaled_iterable_solids)
[docs] def export_unstructured_mesh_file( self, filename: str = "umesh.vtk", min_mesh_size: float = 1, max_mesh_size: float = 5, mesh_algorithm: int = 1, method: str = "file", scale_factor: float = 1.0, imprint: bool | int = True, set_size: dict[int | str, float] | None = None, volumes: Iterable[int] | None = None, threads: int = 0, meshing_backend: str | None = None, target_edge_length: float | None = None, tet_volumes: Iterable[str] | None = None, tolerance: float = 0.01, angular_tolerance: float = 0.2, ): """ Exports an unstructured mesh file in VTK format for use with openmc.UnstructuredMesh. Compatible with the MOAB unstructured mesh library. Example useage openmc.UnstructuredMesh(filename="umesh.vtk", library="moab"). The mesh can be produced either with gmsh or with the cad-to-dagmc-mesher backend. The gmsh backend uses the min/max mesh size and set_size arguments, while the cad-to-dagmc-mesher backend uses target_edge_length (and optionally tet_volumes) to control the tetrahedra. gmsh is used unless meshing_backend or one of the cad-to-dagmc-mesher specific arguments is provided. Parameters: ----------- filename : str, optional The name of the output file. Default is "umesh.vtk". min_mesh_size: the minimum mesh element size to use in Gmsh. Passed into gmsh.option.setNumber("Mesh.MeshSizeMin", min_mesh_size) max_mesh_size: the maximum mesh element size to use in Gmsh. Passed into gmsh.option.setNumber("Mesh.MeshSizeMax", max_mesh_size) mesh_algorithm: The Gmsh mesh algorithm number to use. Passed into gmsh.option.setNumber("Mesh.Algorithm", mesh_algorithm) method: the method to use to import the geometry into gmsh. Options are 'file' or 'in memory'. 'file' is the default and will write the geometry to a temporary file before importing it into gmsh. 'in memory' will import the geometry directly into gmsh but requires the version of OpenCASCADE used to build gmsh to be the same as the version used by CadQuery. This is possible to ensure when installing the package with Conda but harder when installing from PyPI. scale_factor: a scaling factor to apply to the geometry that can be used to enlarge or shrink the geometry. Useful when converting the geometry to cm for use in neutronics. imprint: whether to imprint the geometry or not. Defaults to True as this is normally needed to ensure the geometry is meshed correctly. However if you know your geometry does not need imprinting you can set this to False and this can save time. A positive int can be passed instead of True to imprint with that many threads, for example imprint=1 imprints on a single thread. Imprinting runs in parallel and its peak RAM scales with the number of threads, so fewer threads lowers the peak RAM of large models at the cost of speed. Only the imprint is limited, the meshing that follows it keeps all its threads whichever backend is used, and the thread count is restored afterwards so the cadquery operations that follow are unaffected. set_size: a dictionary mapping volume IDs (int) or material tag names (str) to target mesh sizes (floats). Material tags are resolved to all volume IDs that have that tag. Only used by the gmsh backend. volumes: a list of volume ids (int) to include in the mesh. If left as default (None) then all volumes will be included. Only used by the gmsh backend. threads: the number of threads for Gmsh to use. 0 uses all available cores (default), 1 uses a single thread. meshing_backend: the backend used to generate the tetrahedra, either "gmsh" or "cad-to-dagmc-mesher". If not set, the backend is auto-selected: "cad-to-dagmc-mesher" when target_edge_length or tet_volumes is provided, otherwise "gmsh". target_edge_length: the target tetrahedron edge length used by the cad-to-dagmc-mesher backend. Required when meshing_backend is "cad-to-dagmc-mesher". tet_volumes: an iterable of material tag names identifying which volumes to fill with tetrahedra when using the cad-to-dagmc-mesher backend. Defaults to all volumes. tolerance: linear deflection tolerance for the surface mesh, used by the cad-to-dagmc-mesher backend. This is in the units of the SCALED geometry, since scale_factor is applied before meshing, so scale it alongside scale_factor. With scale_factor=100 the 0.01 default is a 0.1 mm deflection, which on a large model can produce a very fine mesh and exhaust memory. The same applies to min_mesh_size/max_mesh_size/set_size for the gmsh backend. angular_tolerance: angular deflection tolerance for the surface mesh, used by the cad-to-dagmc-mesher backend. An angle, so unaffected by scale_factor. Returns: -------- filename : str The filename of the written unstructured mesh file. """ # gmesh writes out a vtk file that is accepted by openmc.UnstructuredMesh # The library argument must be set to "moab" if Path(filename).suffix != ".vtk": raise ValueError("Unstructured mesh filename must have a .vtk extension") imprint, imprint_threads = resolve_imprint(imprint) if meshing_backend is None: # Auto-select the backend: the tet arguments are specific to # cad-to-dagmc-mesher, everything else defaults to gmsh. if target_edge_length is not None or tet_volumes is not None: meshing_backend = "cad-to-dagmc-mesher" else: meshing_backend = "gmsh" print(f"Using meshing backend: {meshing_backend}") if meshing_backend not in ("gmsh", "cad-to-dagmc-mesher"): raise ValueError( f'meshing_backend "{meshing_backend}" not supported. ' 'Available options are "gmsh" or "cad-to-dagmc-mesher"' ) if meshing_backend == "cad-to-dagmc-mesher": return self._export_unstructured_mesh_file_with_mesher( filename=filename, target_edge_length=target_edge_length, tet_volumes=tet_volumes, tolerance=tolerance, angular_tolerance=angular_tolerance, imprint=imprint, imprint_threads=imprint_threads, scale_factor=scale_factor, ) assembly = cq.Assembly() for part in self.parts: assembly.add(part) if imprint: print("Imprinting assembly for unstructured mesh generation") imprinted_assembly, _ = imprint_assembly(assembly, threads=imprint_threads) else: imprinted_assembly = assembly # gmsh is a global singleton; finalize the session on every exit path # (including a mid-mesh exception) so repeated calls don't accumulate # models. gmsh_session_started is only set once init_gmsh() has bound # the local gmsh name, keeping the finally safe if init_gmsh() itself # raises. See issue #187. gmsh_session_started = False try: gmsh = init_gmsh() gmsh_session_started = True gmsh, volumes_in_model = get_volumes( gmsh, imprinted_assembly, method=method, scale_factor=scale_factor ) # Resolve any material tag strings in set_size to volume IDs resolved_set_size = None if set_size: resolved_set_size = resolve_set_size( set_size, volumes_in_model, self.material_tags ) gmsh = set_sizes_for_mesh( gmsh=gmsh, min_mesh_size=min_mesh_size, max_mesh_size=max_mesh_size, mesh_algorithm=mesh_algorithm, set_size=resolved_set_size, original_set_size=set_size, threads=threads, ) if volumes: for volume_id in volumes_in_model: if volume_id[1] not in volumes: gmsh.model.occ.remove([volume_id], recursive=True) gmsh.option.setNumber("Mesh.SaveAll", 1) gmsh.model.occ.synchronize() # Clear the mesh gmsh.model.mesh.clear() gmsh.option.setNumber( "Mesh.SaveElementTagType", 3 ) # Save only volume elements gmsh.model.mesh.generate(3) # makes the folder if it does not exist if Path(filename).parent: Path(filename).parent.mkdir(parents=True, exist_ok=True) # gmsh.write only accepts strings if isinstance(filename, Path): gmsh.write(str(filename)) else: gmsh.write(filename) return filename finally: if gmsh_session_started and gmsh.isInitialized(): gmsh.finalize()
def _export_unstructured_mesh_file_with_mesher( self, filename: str, target_edge_length: float | None, tet_volumes: Iterable[str] | None, tolerance: float, angular_tolerance: float, imprint: bool, imprint_threads: int | None = None, scale_factor: float = 1.0, ) -> str: """Write an unstructured .vtk volume mesh using cad-to-dagmc-mesher. Meshes the assembly with cad-to-dagmc-mesher, combines the per-solid tetrahedra into a single mesh, and writes it as a legacy VTK file readable by openmc.UnstructuredMesh(filename, library="moab"). """ if target_edge_length is None: raise ValueError( "target_edge_length is required when meshing_backend is " '"cad-to-dagmc-mesher"' ) assembly = _build_assembly( self.parts, scale_factor, names=_solid_names(self.material_tags) ) # Default to tetrahedralising every volume. tet_volumes is matched # against material tags by the mesher, so pass the material tags. if tet_volumes is None: tet_volumes = list(self.material_tags) else: tet_volumes = list(tet_volumes) _, _, _, tet_data = _mesh_with_cad_to_dagmc_mesher( assembly=assembly, material_tags=self.material_tags, tolerance=tolerance, angular_tolerance=angular_tolerance, tet_volumes=tet_volumes, target_edge_length=target_edge_length, imprint=imprint, imprint_threads=imprint_threads, ) if not tet_data: raise ValueError( "cad-to-dagmc-mesher produced no tetrahedra. Check that " "tet_volumes contains valid material tags and that " "target_edge_length is set." ) tet_vertices, tetrahedra = combine_tet_meshes(tet_data) if Path(filename).parent: Path(filename).parent.mkdir(parents=True, exist_ok=True) write_vtk(filename, tet_vertices, tetrahedra) print(f"written unstructured mesh file {filename}") return filename
[docs] def export_gmsh_mesh_file( self, filename: str = "mesh.msh", min_mesh_size: float | None = None, max_mesh_size: float | None = None, mesh_algorithm: int = 1, dimensions: int = 2, method: str = "file", scale_factor: float = 1.0, imprint: bool | int = True, set_size: dict[int | str, float] | None = None, threads: int = 0, ): """Saves a GMesh msh file of the geometry in either 2D surface mesh or 3D volume mesh. Args: filename min_mesh_size: the minimum size of mesh elements to use. max_mesh_size: the maximum size of mesh elements to use. mesh_algorithm: the gmsh mesh algorithm to use. dimensions: The number of dimensions, 2 for a surface mesh 3 for a volume mesh. Passed to gmsh.model.mesh.generate() method: the method to use to import the geometry into gmsh. Options are 'file' or 'in memory'. 'file' is the default and will write the geometry to a temporary file before importing it into gmsh. 'in memory' will import the geometry directly into gmsh but requires the version of OpenCASCADE used to build gmsh to be the same as the version used by CadQuery. This is possible to ensure when installing the package with Conda but harder when installing from PyPI. scale_factor: a scaling factor to apply to the geometry that can be used to enlarge or shrink the geometry. Useful when converting Useful when converting the geometry to cm for use in neutronics imprint: whether to imprint the geometry or not. Defaults to True as this is normally needed to ensure the geometry is meshed correctly. However if you know your geometry does not need imprinting you can set this to False and this can save time. A positive int can be passed instead of True to imprint with that many threads, for example imprint=1 imprints on a single thread. Imprinting runs in parallel and its peak RAM scales with the number of threads, so fewer threads lowers the peak RAM of large models at the cost of speed. The thread count is restored afterwards so the cadquery operations that follow are unaffected. set_size: a dictionary mapping volume IDs (int) or material tag names (str) to target mesh sizes (floats). Material tags are resolved to all volume IDs that have that tag. threads: the number of threads for Gmsh to use. 0 uses all available cores (default), 1 uses a single thread. """ imprint, imprint_threads = resolve_imprint(imprint) assembly = cq.Assembly() for part in self.parts: assembly.add(part) if imprint: print("Imprinting assembly for mesh generation") imprinted_assembly, _ = imprint_assembly(assembly, threads=imprint_threads) else: imprinted_assembly = assembly # gmsh is a global singleton; finalize the session on every exit path # (including a mid-mesh exception) so repeated calls don't accumulate # models. gmsh_session_started is only set once init_gmsh() has bound # the local gmsh name, keeping the finally safe if init_gmsh() itself # raises. See issue #187. gmsh_session_started = False try: gmsh = init_gmsh() gmsh_session_started = True gmsh, volumes = get_volumes( gmsh, imprinted_assembly, method=method, scale_factor=scale_factor ) # Resolve any material tag strings in set_size to volume IDs resolved_set_size = None if set_size: resolved_set_size = resolve_set_size( set_size, volumes, self.material_tags ) gmsh = set_sizes_for_mesh( gmsh=gmsh, min_mesh_size=min_mesh_size, max_mesh_size=max_mesh_size, mesh_algorithm=mesh_algorithm, set_size=resolved_set_size, original_set_size=set_size, threads=threads, ) gmsh.model.mesh.generate(dimensions) # makes the folder if it does not exist if Path(filename).parent: Path(filename).parent.mkdir(parents=True, exist_ok=True) # gmsh.write only accepts strings if isinstance(filename, Path): gmsh.write(str(filename)) else: gmsh.write(filename) print(f"written GMSH mesh file {filename}") finally: if gmsh_session_started and gmsh.isInitialized(): gmsh.finalize()
[docs] def export_dagmc_h5m_file( self, filename: str = "dagmc.h5m", implicit_complement_material_tag: str | None = None, scale_factor: float = 1.0, imprint: bool | int = True, **kwargs, ) -> str: """Saves a DAGMC h5m file of the geometry Args: filename: the filename to use for the saved DAGMC file. implicit_complement_material_tag: the name of the material tag to use for the implicit complement (void space). scale_factor: a scaling factor to apply to the geometry. All the linear mesh sizing arguments (min_mesh_size, max_mesh_size and set_size for gmsh, tolerance and target_edge_length for cad-to-dagmc-mesher, tolerance for cadquery) are in the units of the SCALED geometry, so the same number means the same thing on the output mesh whichever backend is used. For example with scale_factor=100 (m to cm) a tolerance of 0.5 is a 5 mm deflection. Note this means the defaults get finer as scale_factor grows: the cad-to-dagmc-mesher tolerance default of 0.01 is a 0.1 mm deflection at scale_factor=100, which on a large model can produce a very fine mesh and exhaust memory, so scale the tolerance along with the geometry. angular_tolerance is an angle and so is unaffected by scaling. imprint: whether to imprint the geometry or not. A positive int can be passed instead of True to imprint with that many threads, for example imprint=1 imprints on a single thread. Imprinting runs in parallel and its peak RAM scales with the number of threads, so fewer threads lowers the peak RAM of large models at the cost of speed. Only the imprint is limited, the meshing that follows it keeps all its threads whichever backend is used, and the thread count is restored afterwards so the cadquery operations that follow are unaffected. **kwargs: Backend-specific parameters: Backend selection: - meshing_backend (str, optional): explicitly specify 'gmsh', 'cadquery' or 'cad-to-dagmc-mesher'. If not provided, backend is auto-selected based on other arguments: tet_volumes or target_edge_length select 'cad-to-dagmc-mesher', gmsh-specific arguments select 'gmsh'. Defaults to 'cad-to-dagmc-mesher' if no backend-specific arguments are given, falling back to 'cadquery' when cad-to-dagmc-mesher is not installed. - h5m_backend (str, optional): 'pymoab' or 'h5py' for writing h5m files. Defaults to 'h5py'. For GMSH backend: - min_mesh_size (float): minimum mesh element size - max_mesh_size (float): maximum mesh element size - mesh_algorithm (int): GMSH mesh algorithm (default: 1) - method (str): import method 'file' or 'in memory' (default: 'file') - set_size (dict[int | str, float]): volume IDs (int) or material tag names (str) mapped to target mesh sizes. Material tags are resolved to all volume IDs that have that tag. - unstructured_volumes (Iterable[int | str]): volume IDs (int) or material tag names (str) for unstructured mesh. Material tags are resolved to all volume IDs that have that tag. Can mix ints and strings. - umesh_filename (str): filename for unstructured mesh (default: 'umesh.vtk') - threads (int): number of threads for Gmsh to use. 0 uses all available cores (default), 1 uses a single thread. For CadQuery backend: - tolerance (float): meshing tolerance (default: 0.1), in the units of the scaled geometry (see scale_factor above) - angular_tolerance (float): angular tolerance (default: 0.1) For cad-to-dagmc-mesher backend: - tolerance (float): surface meshing tolerance (default: 0.01), in the units of the scaled geometry (see scale_factor above). With scale_factor=100 the 0.01 default is a 0.1 mm deflection, which on a large model can produce a very fine mesh and exhaust memory; scale the value with scale_factor. - angular_tolerance (float): surface angular tolerance (default: 0.2) - tet_volumes (Iterable[str]): material tag names of the volumes to fill with tetrahedra for an unstructured volume mesh. - target_edge_length (float): target tetrahedron edge length. Both tet_volumes and target_edge_length must be given together to write a volume mesh; when they are, the return value is a (dagmc_filename, umesh_filename) tuple. - umesh_filename (str): filename for the unstructured volume mesh (default: 'umesh.vtk'). Returns: str: the filename(s) for the files created. Raises: ValueError: If invalid parameter combinations are used. """ imprint, imprint_threads = resolve_imprint(imprint) # Define all acceptable kwargs cadquery_keys = {"tolerance", "angular_tolerance"} gmsh_keys = { "min_mesh_size", "max_mesh_size", "mesh_algorithm", "set_size", "umesh_filename", "method", "unstructured_volumes", "threads", } cad_to_dagmc_mesher_keys = {"tolerance", "angular_tolerance", "tet_volumes", "target_edge_length"} all_acceptable_keys = cadquery_keys | gmsh_keys | cad_to_dagmc_mesher_keys | {"meshing_backend", "h5m_backend"} # Check for invalid kwargs invalid_keys = set(kwargs.keys()) - all_acceptable_keys if invalid_keys: raise ValueError( f"Invalid keyword arguments: {sorted(invalid_keys)}\n" f"Acceptable arguments are: {sorted(all_acceptable_keys)}" ) # Handle meshing_backend - either from kwargs or auto-detect meshing_backend = kwargs.pop("meshing_backend", None) # Handle h5m_backend - pymoab or h5py h5m_backend = kwargs.pop("h5m_backend", "h5py") if meshing_backend is None: # Auto-select meshing_backend based on kwargs. tolerance and # angular_tolerance are accepted by both the cadquery and the # cad-to-dagmc-mesher backends, and when only those are given the # cad-to-dagmc-mesher backend is preferred. # umesh_filename is accepted by both the gmsh and the # cad-to-dagmc-mesher backends, so it is not gmsh specific, but it # is still part of gmsh_keys because it selects gmsh when nothing # else narrows the choice. Combining it with tolerance stays # ambiguous: the mesher only honours umesh_filename when # tet_volumes and target_edge_length are supplied too, so no single # backend accepts that combination as given. mesher_only_keys = {"tet_volumes", "target_edge_length"} gmsh_only_keys = gmsh_keys - {"umesh_filename"} has_cadquery = any(key in kwargs for key in cadquery_keys) has_gmsh = any(key in kwargs for key in gmsh_keys) has_mesher = any(key in kwargs for key in mesher_only_keys) if has_mesher: provided_gmsh = [key for key in sorted(gmsh_only_keys) if key in kwargs] if provided_gmsh: provided_mesher = [ key for key in sorted(mesher_only_keys) if key in kwargs ] raise ValueError( "Ambiguous backend: both cad-to-dagmc-mesher and GMSH-specific arguments provided.\n" f"cad-to-dagmc-mesher-specific arguments: {sorted(mesher_only_keys)}\n" f"GMSH-specific arguments: {sorted(gmsh_only_keys)}\n" f"Provided cad-to-dagmc-mesher arguments: {provided_mesher}\n" f"Provided GMSH arguments: {provided_gmsh}\n" "Please provide only one backend's arguments." ) meshing_backend = "cad-to-dagmc-mesher" elif has_cadquery and has_gmsh: provided_cadquery = [key for key in sorted(cadquery_keys) if key in kwargs] provided_gmsh_only = [ key for key in sorted(gmsh_only_keys) if key in kwargs ] provided_gmsh_shared = [ key for key in sorted(gmsh_keys - gmsh_only_keys) if key in kwargs ] message = ( "Ambiguous backend: the arguments provided are not all accepted " "by any single meshing backend.\n" f"Accepted by cadquery and cad-to-dagmc-mesher: {provided_cadquery}\n" ) if provided_gmsh_only: message += f"Accepted by gmsh only: {provided_gmsh_only}\n" if provided_gmsh_shared: message += ( "Accepted by gmsh and cad-to-dagmc-mesher: " f"{provided_gmsh_shared}\n" "Note that cad-to-dagmc-mesher only writes an unstructured " "volume mesh when tet_volumes and target_edge_length are " "also given.\n" ) message += "Please set meshing_backend explicitly." raise ValueError(message) elif has_cadquery: # cadquery_keys is a subset of cad_to_dagmc_mesher_keys, so # reaching here means only keys that both backends accept were # given and the choice is genuinely ambiguous. Prefer the # mesher and make the decision visible. provided_shared = [key for key in sorted(cadquery_keys) if key in kwargs] if not _cad_to_dagmc_mesher_is_available(): raise CadToDagmcMesherNotFoundError( f"The arguments {provided_shared} are accepted by both the " "cadquery and the cad-to-dagmc-mesher meshing backends, so " "the cad-to-dagmc-mesher backend would be selected, but " "cad-to-dagmc-mesher is not installed. It is not available " "on conda-forge so it has to be installed separately.\n\n" "Either install it:\n" " pip install cad-to-dagmc-mesher\n\n" "or ask for the cadquery backend explicitly:\n" f" export_dagmc_h5m_file(..., meshing_backend='cadquery', " f"{provided_shared[0]}=...)" ) warnings.warn( f"The arguments {provided_shared} are accepted by both the " "cadquery and the cad-to-dagmc-mesher meshing backends. The " "cad-to-dagmc-mesher backend has been selected. Pass " "meshing_backend='cadquery' or " "meshing_backend='cad-to-dagmc-mesher' to choose explicitly." ) meshing_backend = "cad-to-dagmc-mesher" elif has_gmsh: meshing_backend = "gmsh" elif _cad_to_dagmc_mesher_is_available(): meshing_backend = "cad-to-dagmc-mesher" # default else: # cad-to-dagmc-mesher is a dependency of the pip package but is # not on conda-forge, so a conda installation can be without it. # A call that names no backend at all has expressed no # preference, so fall back to cadquery, which is always present, # rather than failing. Warn so the substitution is visible and # so the remedy is to hand, since pip installing the mesher # works alongside a conda installation. warnings.warn( "No meshing backend was given so the cad-to-dagmc-mesher " "backend would be used, but cad-to-dagmc-mesher is not " "installed. Falling back to the cadquery backend. " "cad-to-dagmc-mesher is not available on conda-forge, " "install it with pip, which works alongside a conda " "installation:\n" " pip install cad-to-dagmc-mesher\n\n" "Pass meshing_backend='cadquery' to select the cadquery " "backend explicitly and silence this warning." ) meshing_backend = "cadquery" # Validate meshing backend if meshing_backend not in ["gmsh", "cadquery", "cad-to-dagmc-mesher"]: raise ValueError( f'meshing_backend "{meshing_backend}" not supported. ' 'Available options are "gmsh", "cadquery", or "cad-to-dagmc-mesher"' ) print(f"Using meshing backend: {meshing_backend}") # Initialize variables to avoid unbound errors tolerance = 0.1 angular_tolerance = 0.1 min_mesh_size = None max_mesh_size = None mesh_algorithm = 1 method = "file" set_size = None unstructured_volumes = None umesh_filename = "umesh.vtk" threads = 0 tet_data = None # Extract backend-specific parameters with defaults if meshing_backend == "cadquery": # CadQuery parameters tolerance = kwargs.get("tolerance", 0.1) angular_tolerance = kwargs.get("angular_tolerance", 0.1) if scale_factor != 1.0: # Transitional warning: tolerance used to be in unscaled units # for this backend only. Remove in a future release once the # consistent behaviour has been out for a while. warnings.warn( f"tolerance ({tolerance}) is in the units of the scaled " f"geometry, so with scale_factor={scale_factor} it is a " f"deflection of {tolerance} in the output mesh's units. " "This matches the gmsh and cad-to-dagmc-mesher backends. " "Previous versions of cad_to_dagmc interpreted tolerance in " "the units of the unscaled geometry for the cadquery " "backend only, so this produces a mesh " f"{scale_factor}x finer than before for the same tolerance; " f"pass tolerance={tolerance * scale_factor} to reproduce the " "old mesh density." ) # Check for invalid parameters unstructured_volumes = kwargs.get("unstructured_volumes") if unstructured_volumes is not None or kwargs.get("tet_volumes") is not None: raise ValueError( "CadQuery backend cannot be used for volume meshing. " "unstructured_volumes and tet_volumes must be None when " "using 'cadquery' backend." ) # Warn about unused GMSH and cad-to-dagmc-mesher parameters gmsh_params = [ "min_mesh_size", "max_mesh_size", "mesh_algorithm", "set_size", "umesh_filename", "method", "threads", "target_edge_length", ] unused_params = [param for param in gmsh_params if param in kwargs] if unused_params: warnings.warn( f"The following parameters are ignored when using CadQuery backend: " f"{', '.join(unused_params)}" ) elif meshing_backend == "gmsh": # GMSH parameters min_mesh_size = kwargs.get("min_mesh_size") max_mesh_size = kwargs.get("max_mesh_size") mesh_algorithm = kwargs.get("mesh_algorithm", 1) method = kwargs.get("method", "file") set_size = kwargs.get("set_size") unstructured_volumes = kwargs.get("unstructured_volumes") umesh_filename = kwargs.get("umesh_filename", "umesh.vtk") threads = kwargs.get("threads", 0) # Warn about unused CadQuery and cad-to-dagmc-mesher parameters non_gmsh_params = [ "tolerance", "angular_tolerance", "tet_volumes", "target_edge_length", ] unused_params = [param for param in non_gmsh_params if param in kwargs] if unused_params: warnings.warn( f"The following parameters are ignored when using GMSH backend: " f"{', '.join(unused_params)}" ) elif meshing_backend == "cad-to-dagmc-mesher": tolerance = kwargs.get("tolerance", 0.01) angular_tolerance = kwargs.get("angular_tolerance", 0.2) assembly = cq.Assembly() for part in self.parts: assembly.add(part) original_ids = get_ids_from_assembly(assembly) # both id lists should be the same length as each other and the same # length as the self.material_tags if len(original_ids) != len(self.material_tags): msg = f"Number of volumes {len(original_ids)} is not equal to number of material tags {len(self.material_tags)}" raise ValueError(msg) # The gmsh backend opens a gmsh session (gmsh is a global singleton). # Wrap the whole meshing and export in try/finally so the session is # always finalized - on every return path and even if meshing raises # part way through. Without this, repeated calls accumulate gmsh models # in the session (see issue #187). gmsh_session_started is only set once # init_gmsh() has run, so the finally never touches the (function-local) # gmsh name before it is bound and never finalizes a session the caller # may own when using a non-gmsh backend. gmsh_session_started = False try: # Use the CadQuery direct mesh plugin if meshing_backend == "cadquery": import cadquery_direct_mesh_plugin # tolerance is documented as being in the units of the scaled # geometry, matching the gmsh and cad-to-dagmc-mesher backends # (both of which scale the geometry before meshing it). This # backend is the odd one out: the plugin tessellates the # unscaled solids and multiplies the resulting vertices by # scale_factor afterwards, so the tolerance it is given is in # unscaled units. Convert so the same number means the same # deflection on the output mesh whichever backend is used. cq_tolerance = tolerance / scale_factor # Mesh the assembly using CadQuery's direct-mesh plugin. The # plugin imprints internally, so the limit is put on the # imprint itself and the tessellation keeps all its threads. with imprint_thread_limit(imprint_threads): cq_mesh = assembly.toMesh( imprint=imprint, tolerance=cq_tolerance, angular_tolerance=angular_tolerance, scale_factor=scale_factor, ) # Fix the material tag order for imprinted assemblies if cq_mesh["imprinted_assembly"] is not None: imprinted_solids_with_org_id = cq_mesh[ "imprinted_solids_with_orginal_ids" ] scrambled_ids = get_ids_from_imprinted_assembly( imprinted_solids_with_org_id ) material_tags_in_brep_order = order_material_ids_by_brep_order( original_ids, scrambled_ids, self.material_tags ) else: material_tags_in_brep_order = self.material_tags check_material_tags(material_tags_in_brep_order, self.parts) # Extract the mesh information to allow export to h5m from the direct-mesh result vertices = cq_mesh["vertices"] triangles_by_solid_by_face = cq_mesh["solid_face_triangle_vertex_map"] if imprint: triangles_by_solid_by_face = share_coincident_face_ids( triangles_by_solid_by_face ) # Use gmsh elif meshing_backend == "gmsh": # If assembly is not to be imprinted, pass through the assembly as-is if imprint: print("Imprinting assembly for mesh generation") imprinted_assembly, imprinted_solids_with_org_id = ( imprint_assembly(assembly, threads=imprint_threads) ) scrambled_ids = get_ids_from_imprinted_assembly( imprinted_solids_with_org_id ) material_tags_in_brep_order = order_material_ids_by_brep_order( original_ids, scrambled_ids, self.material_tags ) else: material_tags_in_brep_order = self.material_tags imprinted_assembly = assembly check_material_tags(material_tags_in_brep_order, self.parts) # Start generating the mesh gmsh = init_gmsh() gmsh_session_started = True gmsh, volumes = get_volumes( gmsh, imprinted_assembly, method=method, scale_factor=scale_factor ) # Resolve any material tag strings in set_size to volume IDs resolved_set_size = None if set_size: resolved_set_size = resolve_set_size( set_size, volumes, material_tags_in_brep_order ) gmsh = set_sizes_for_mesh( gmsh=gmsh, min_mesh_size=min_mesh_size, max_mesh_size=max_mesh_size, mesh_algorithm=mesh_algorithm, set_size=resolved_set_size, original_set_size=set_size, threads=threads, ) gmsh.model.mesh.generate(2) vertices, triangles_by_solid_by_face = mesh_to_vertices_and_triangles( dims_and_vol_ids=volumes ) elif meshing_backend == "cad-to-dagmc-mesher": tet_volumes_arg = kwargs.get("tet_volumes", kwargs.get("unstructured_volumes")) target_edge_length = kwargs.get("target_edge_length") umesh_filename = kwargs.get("umesh_filename", umesh_filename) # A volume (tet) mesh needs BOTH tet_volumes and # target_edge_length. Passing only one (or asking for a # umesh_filename without them) is a user error: fail fast with a # clear message rather than silently writing no .vtk and # returning a bare string instead of the (h5m, vtk) tuple. wants_umesh = ( bool(tet_volumes_arg) or target_edge_length is not None or "umesh_filename" in kwargs ) if wants_umesh and not (tet_volumes_arg and target_edge_length): raise ValueError( "Writing an unstructured volume mesh with the " "cad-to-dagmc-mesher backend requires BOTH tet_volumes " "(material tag names) and target_edge_length. Got " f"tet_volumes={tet_volumes_arg!r}, " f"target_edge_length={target_edge_length!r}." ) # scale_factor is applied to the geometry before meshing so the # h5m and .vtk match the gmsh/cadquery backends (which scale). mesher_assembly = _build_assembly( self.parts, scale_factor, names=_solid_names(self.material_tags), ) vertices, triangles_by_solid_by_face, material_tags_in_brep_order, tet_data = ( _mesh_with_cad_to_dagmc_mesher( assembly=mesher_assembly, material_tags=self.material_tags, tolerance=tolerance, angular_tolerance=angular_tolerance, tet_volumes=tet_volumes_arg, target_edge_length=target_edge_length, imprint=imprint, imprint_threads=imprint_threads, ) ) else: raise ValueError( f'meshing_backend {meshing_backend} not supported. ' 'Available options are "cadquery", "gmsh", or "cad-to-dagmc-mesher"' ) dagmc_filename = vertices_to_h5m( vertices=vertices, triangles_by_solid_by_face=triangles_by_solid_by_face, material_tags=material_tags_in_brep_order, h5m_filename=filename, implicit_complement_material_tag=implicit_complement_material_tag, method=h5m_backend, ) if meshing_backend == "gmsh" and unstructured_volumes: # Resolve any material tag strings to volume IDs unstructured_volumes = resolve_unstructured_volumes( unstructured_volumes, volumes, material_tags_in_brep_order ) # remove all the unused occ volumes, this prevents them being meshed for volume_dim, volume_id in volumes: if volume_id not in unstructured_volumes: gmsh.model.occ.remove( [(volume_dim, volume_id)], recursive=True ) gmsh.option.setNumber("Mesh.SaveAll", 1) gmsh.model.occ.synchronize() # removes all the 2D groups so that 2D faces are not included in the vtk file all_2d_groups = gmsh.model.getPhysicalGroups(2) for entry in all_2d_groups: gmsh.model.removePhysicalGroups([entry]) gmsh.model.mesh.generate(3) gmsh.option.setNumber( "Mesh.SaveElementTagType", 3 ) # Save only volume elements gmsh.write(umesh_filename) return dagmc_filename, umesh_filename # The cad-to-dagmc-mesher backend produces the tetrahedra itself # (when tet_volumes + target_edge_length are given). Combine the # per-solid tet meshes and write a .vtk unstructured volume mesh # without going through gmsh. Keying on the user's request (both # tet args, guaranteed present together by the check above) rather # than on tet_data means a mesher that unexpectedly yields no tets # raises here instead of silently returning a bare string. if meshing_backend == "cad-to-dagmc-mesher" and tet_volumes_arg and target_edge_length: if not tet_data: raise ValueError( "cad-to-dagmc-mesher produced no tetrahedra despite " f"tet_volumes={tet_volumes_arg!r} and " f"target_edge_length={target_edge_length!r}. Check that " "tet_volumes contains valid material tags." ) tet_vertices, tetrahedra = combine_tet_meshes(tet_data) if Path(umesh_filename).parent: Path(umesh_filename).parent.mkdir(parents=True, exist_ok=True) write_vtk(umesh_filename, tet_vertices, tetrahedra) print(f"written unstructured mesh file {umesh_filename}") return dagmc_filename, umesh_filename return dagmc_filename finally: if gmsh_session_started and gmsh.isInitialized(): gmsh.finalize()
def _build_assembly(parts, scale_factor: float = 1.0, names=None): """Build a CadQuery assembly from parts, optionally scaling each part. Shape.scale returns a new shape (it does not mutate in place), so the original parts in self.parts are left untouched and repeated exports stay consistent. names, when given, labels each child. cad-to-dagmc-mesher's SolidConfig addresses solids by assembly child name, so naming them is what lets us ask for per-solid meshing rather than the positional material_tags API. """ assembly = cq.Assembly() for index, part in enumerate(parts): scaled = part.scale(scale_factor) if scale_factor != 1.0 else part if names is None: assembly.add(scaled) else: assembly.add(scaled, name=names[index]) return assembly def _solid_names(material_tags) -> list[str]: """Unique per-solid names for the mesher's SolidConfig. Material tags cannot be used directly: several solids commonly share one tag and SolidConfig needs a distinct name per solid. Suffixing the index keeps them unique while staying readable, which matters because the mesher prints these names when it reports what it refined. The name also carries the input index, so the real tags can be reattached afterwards whatever order the mesher returns solids in. """ return [f"{tag}#{i}" for i, tag in enumerate(material_tags)] def _mesh_with_cad_to_dagmc_mesher( assembly, material_tags, tolerance, angular_tolerance, tet_volumes, target_edge_length, imprint, imprint_threads=None, ): """Mesh using cad-to-dagmc-mesher and return vertices_to_h5m-compatible output. Returns ``(vertices, triangles_by_solid_by_face, material_tags, tet_data)`` where ``tet_data`` is the per-solid tetrahedral mesh dict (``{solid_id: {"vertices": ..., "tetrahedra": ..., ...}}``) or ``None`` when no solids were volume-meshed. Volume meshing only happens when both ``tet_volumes`` and ``target_edge_length`` are supplied. """ try: from cad_to_dagmc_mesher.cad import ( OverlappingSolidsError, SolidConfig, mesh_assembly, ) except ImportError as e: raise CadToDagmcMesherNotFoundError() from e # Address solids individually rather than through the positional # material_tags API. Two reasons: # # - the mesher only refines per solid, and only guards against refinement # making things worse, on its solid_config path. The material_tags path # refines the whole assembly and returns whatever the last round produced, # so on geometry whose fold count grows with refinement it can hand back a # mesh worse than the one it started from. # - tags are reattached by name here instead of by position, so the mapping # survives the mesher returning solids in a different order. names = _solid_names(material_tags) tag_by_name = dict(zip(names, material_tags)) tet_tags = set(tet_volumes or ()) configs = [ SolidConfig( name=name, tolerance=tolerance, angular_tolerance=angular_tolerance, target_edge_length=( target_edge_length if target_edge_length is not None and tag_by_name[name] in tet_tags else None ), ) for name in names ] # The mesher imprints internally, so the limit is put on the imprint # itself and the meshing keeps all its threads. try: with imprint_thread_limit(imprint_threads): result = mesh_assembly( assembly, solid_config=configs, imprint=imprint, ) return ( result["vertices"], result["triangles_by_solid_by_face"], [tag_by_name[name] for name in result["material_tags"]], result.get("tet_data"), ) except OverlappingSolidsError as e: # Imprinting fused two or more solids together, which means they overlap. # That geometry cannot be written as valid DAGMC: a region inside two # volumes has no single material, and a DAGMC surface separates at most # two volumes. It is better to say so than to mesh it, because the result # is silently wrong rather than obviously broken -- it is watertight, it # transports, and the material in the shared region is whichever volume # DAGMC happens to resolve first. # # The mesher names the solids it could not find, but those are the # synthetic per-solid names from _solid_names, so they are re-expressed # here as the material tags the caller actually supplied. fused = sorted({ tag_by_name[name] for name in names if name in str(e) and name in tag_by_name }) detail = ( f" The solids tagged {fused} could not be told apart after imprinting." if fused else "" ) raise ValueError( "Overlapping solids in the CAD." + detail + " Overlapping geometry is " "invalid for DAGMC: a region inside two volumes has no single " "material, so transport through it is not well defined. Resolve the " "overlaps before meshing, for example by boolean-subtracting the " "inner solid from the outer one " "(outer = outer.cut(inner)) so the two share a surface instead of a " "volume." ) from e def _get_all_leaf_children(assembly): """Recursively yield all leaf children (parts, not assemblies) from a CadQuery assembly.""" for child in assembly.children: # If the child is itself an assembly, recurse if hasattr(child, "children") and len(child.children) > 0: yield from _get_all_leaf_children(child) else: yield child