Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@

CadQuery plugin to create a mesh of an assembly with corresponding data.

This plugin makes use of CadQuery tags to collect surfaces into [Gmsh](https://gmsh.info/) physical groups. The
tagged faces are matched to their corresponding surfaces in the mesh via their position in the CadQuery solid(s) vs the Gmsh surface ID. There are a few challenges with mapping tags to surfaces to be aware of.
This plugin makes use of CadQuery tags to collect surfaces and edges into [Gmsh](https://gmsh.info/) physical groups.
The tagged faces are matched to their corresponding surfaces in the mesh via their position in the CadQuery solid(s) vs the Gmsh surface ID. There are a few challenges with mapping tags to surfaces to be aware of.

1. Each tag can select multiple faces/surfaces at once, and this has to be accounted for when mapping tags to surfaces.
2. Tags are present at the higher level of the Workplane class, but are do not propagate to lower-level classes like Face.
3. OpenCASCADE does not provide a built-in mechanism for tagging low-level entities without the use of an external data structure or framework.

Tagged edges are handled a little differently from faces. Because an edge is shared between multiple faces, the
Gmsh curve IDs do not line up with the CadQuery edge order the way surface IDs line up with faces. Instead of matching by position, each tagged edge is matched to its Gmsh curve geometrically (by comparing bounding boxes and midpoints)
and that search is restricted to the curves belonging to the same assembly part. This keeps edge tags correct even
when separate parts meet at coincident edges.

## Installation

You can install via [PyPI](https://pypi.org/project/assembly-mesh-plugin/)
Expand All @@ -27,6 +32,8 @@ The plugin needs to be imported in order to monkey-patch its method into CadQuer
import assembly_mesh_plugin
```

### Face Tagging

You can then tag faces in each of the assembly parts and create your assembly. To export the assembly to a mesh file, you do the following.

```python
Expand Down Expand Up @@ -94,6 +101,71 @@ gmsh_object.write("tagged_mesh.msh")
gmsh_object.finalize()
```

### Edge Tagging

In addition to faces, you can tag **edges**, which become 1-dimensional physical
groups in the resulting mesh. This is useful for meshing operations that act on
curves, such as Gmsh transfinite meshing.

Edges are tagged the same way as faces, using CadQuery's `tag` method on an edge
selection:

```python
import cadquery as cq
import assembly_mesh_plugin

beam = cq.Workplane("XY").box(50, 50, 50)
beam.edges("|Z").tag("vertical-edges")

assy = cq.Assembly()
assy.add(beam, name="beam")

assy.saveToGmsh(mesh_path="tagged_mesh.msh")
```

Edge tags follow the same naming rules as face tags:

* A normal tag is prefixed with the assembly part name, so `vertical-edges` on a
part named `beam` becomes the physical group `beam_vertical-edges`.
* Prefixing a tag with `~` ignores the part name, so the same tag applied to
edges on different parts (for example `~contact`) is merged into a single
physical group named `contact`.

Faces and edges can be tagged on the same part and will produce separate 2D and
1D physical groups respectively.

#### Using tagged edges for transfinite meshing

Because tagged edges are exposed as named 1D physical groups, you can use
`getTaggedGmsh` to retrieve the Gmsh object, look the curves up by name, and
apply your own constraints before meshing:

```python
import cadquery as cq
import assembly_mesh_plugin

beam = cq.Workplane("XY").box(50, 50, 50)
beam.edges("|Z").tag("vertical-edges")

assy = cq.Assembly()
assy.add(beam, name="beam")

# Get a Gmsh object back with the tagged edges as 1D physical groups
gmsh_object = assy.getTaggedGmsh()

# Find the tagged curves by physical group name and constrain them
for dim, tag in gmsh_object.model.getPhysicalGroups(1):
if gmsh_object.model.getPhysicalName(1, tag) == "beam_vertical-edges":
for curve in gmsh_object.model.getEntitiesForPhysicalGroup(1, tag):
# 11 nodes along each tagged edge
gmsh_object.model.mesh.setTransfiniteCurve(int(curve), 11)

# Generate the mesh and write it to the file
gmsh_object.model.mesh.generate(3)
gmsh_object.write("tagged_mesh.msh")
gmsh_object.finalize()
```

## Tests

These tests are also run in Github Actions, and the meshes which are generated can be viewed as artifacts on the successful `tests` Actions there.
Expand Down
140 changes: 128 additions & 12 deletions assembly_mesh_plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,53 @@
# Holds the collection of individual faces that are tagged
tagged_faces = {}

# Holds the collection of individual edges that are tagged
tagged_edges = {}

# Tracks which gmsh curve tags belong to each part
solid_curves = {}

# Tracks multi-surface physical groups
multi_material_groups = {}
surface_groups = {}

# Tracks edge (1D) physical groups
edge_groups = {}
multi_material_edge_groups = {}


def _gmsh_curve_signatures(gmsh):
"""
Build a geometric signature for every 1D curve currently in the model, keyed
by gmsh curve tag. Matching tagged edges by geometry rather than enumeration
order keeps edge tagging correct even when curves are shared between faces/solids.
"""
sigs = {}
for _, ctag in gmsh.model.getEntities(1):
bbox = gmsh.model.getBoundingBox(1, ctag)
tmin, tmax = gmsh.model.getParametrizationBounds(1, ctag)
mid = gmsh.model.getValue(1, ctag, [0.5 * (tmin[0] + tmax[0])])
sigs[ctag] = (bbox, mid)

return sigs


def _edge_matches(edge, sig, tol=1e-6):
"""True if a CadQuery edge matches a gmsh curve signature."""
bbox, mid = sig
eb = edge.BoundingBox()
cqbox = (eb.xmin, eb.ymin, eb.zmin, eb.xmax, eb.ymax, eb.zmax)
if max(abs(a - b) for a, b in zip(cqbox, bbox)) > tol:
return False

# Midpoint disambiguates curves that share a bounding box
em = edge.positionAt(0.5)
return (
abs(em.x - mid[0]) <= tol
and abs(em.y - mid[1]) <= tol
and abs(em.z - mid[2]) <= tol
)


def extract_subshape_names(assy, name=None):
"""
Expand All @@ -43,19 +86,21 @@ def extract_subshape_names(assy, name=None):
else:
tagged_faces[short_name][subshape_tag] = [subshape]

# Check for face tags
# Check for face and edge tags
if assy.objects[short_name].obj:
for tag, wp in assy.objects[short_name].obj.ctx.tags.items():
# Make sure the entry for the assembly child exists
if short_name not in tagged_faces:
tagged_faces[short_name] = {}

for face in wp.faces().all():
# Create a new list for tag if it does not already exist
if tag in tagged_faces[short_name]:
tagged_faces[short_name][tag].append(face.val())
else:
tagged_faces[short_name][tag] = [face.val()]
# Make sure the entries for the assembly child exists
tagged_faces.setdefault(short_name, {})
tagged_edges.setdefault(short_name, {})

# A tag stores a Workplane object that can contain edges or faces
objs = wp.objects
if objs and isinstance(objs[0], cq.Edge):
for edge in wp.edges().all():
tagged_edges[short_name].setdefault(tag, []).append(edge.val())
else:
for face in wp.faces().all():
tagged_faces[short_name].setdefault(tag, []).append(face.val())

# Recurse through the assembly children
for child in assy.children:
Expand All @@ -68,6 +113,8 @@ def add_solid_to_mesh(gmsh, solid, name):
"""
global vol_id, volumes, volume_map

before = {t for _, t in gmsh.model.getEntities(1)}

with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file:
solid.exportBrep(temp_file.name)
dim_tags = gmsh.model.occ.importShapes(temp_file.name)
Expand All @@ -86,6 +133,10 @@ def add_solid_to_mesh(gmsh, solid, name):
# Move to the next volume ID
vol_id += 1

gmsh.model.occ.synchronize()
after = {t for _, t in gmsh.model.getEntities(1)}
solid_curves.setdefault(name, set()).update(after - before)


def add_faces_to_mesh(gmsh, solid, name, loc=None):
global surface_id, multi_material_groups, surface_groups
Expand Down Expand Up @@ -146,12 +197,41 @@ def add_faces_to_mesh(gmsh, solid, name, loc=None):
gmsh.model.occ.synchronize()


def add_edges_to_mesh(gmsh, name, loc=None, curve_sigs=None):
"""Match a part's tagged edges to gmsh curves and collect them into 1D physical groups."""
global edge_groups, multi_material_edge_groups

if not tagged_edges.get(name):
return

for tag, tag_edges in tagged_edges[name].items():
for tag_edge in tag_edges:
# Move the edge into its assembly position
if loc:
tag_edge = tag_edge.moved(loc)

# Find the gmsh curve whose geometry matches this tagged edge
match = next(
(c for c, sig in curve_sigs.items() if _edge_matches(tag_edge, sig)),
None,
)
if match is None:
continue

# Same ~ convention as faces: strip the part name for multi-part groups
if tag.startswith("~"):
group_name = tag.replace("~", "").split("-")[0]
multi_material_edge_groups.setdefault(group_name, []).append(match)
else:
edge_groups.setdefault(f"{name}_{tag}", []).append(match)


def get_gmsh(self, imprint=True):
"""
Allows the user to get a gmsh object from the assembly, respecting assembly part names and face
tags, but have more control over how it is meshed. This method makes sure the mesh is conformal.
"""
global vol_id, surface_id, volumes, volume_map, tagged_faces, multi_material_groups, surface_groups
global vol_id, surface_id, volumes, volume_map, tagged_faces, multi_material_groups, surface_groups, solid_curves, tagged_edges, edge_groups, multi_material_edge_groups

# Reset global state for each call
vol_id = 1
Expand All @@ -162,6 +242,10 @@ def get_gmsh(self, imprint=True):
multi_material_groups = {}
surface_groups = {}
solid_materials = []
solid_curves = {}
tagged_edges = {}
edge_groups = {}
multi_material_edge_groups = {}

gmsh.initialize()
gmsh.option.setNumber(
Expand Down Expand Up @@ -217,6 +301,26 @@ def get_gmsh(self, imprint=True):
if self.objects[name.split("/")[-1]].material:
solid_materials.append(self.objects[name.split("/")[-1]].material.name)

# Second pass: Build the curve signatures once and match each part's tagged edges
# against only the curves that belong to that part.
gmsh.model.occ.synchronize()
curve_sigs = _gmsh_curve_signatures(gmsh)

if imprint:
seen = set()
for _, name in imprinted_solids_with_orginal_ids.items():
short_name = name[0].split("/")[-1]
if short_name in seen:
continue
seen.add(short_name)
part_sigs = {c: curve_sigs[c] for c in solid_curves.get(short_name, ())}
add_edges_to_mesh(gmsh, short_name, None, part_sigs)
else:
for obj, name, loc, _ in self:
short_name = name.split("/")[-1]
part_sigs = {c: curve_sigs[c] for c in solid_curves.get(short_name, ())}
add_edges_to_mesh(gmsh, short_name, loc, part_sigs)

# Step through each of the volumes and add physical groups for each
for volume_id in volumes.keys():
gmsh.model.occ.synchronize()
Expand All @@ -242,6 +346,18 @@ def get_gmsh(self, imprint=True):
ps = gmsh.model.addPhysicalGroup(2, mm_group)
gmsh.model.setPhysicalName(2, ps, f"{group_name}")

# Handle tagged edge groups (1D physical groups)
for e_name, edge_group in edge_groups.items():
gmsh.model.occ.synchronize()
ps = gmsh.model.addPhysicalGroup(1, edge_group)
gmsh.model.setPhysicalName(1, ps, e_name)

# Handle multi-material edge tags
for group_name, mm_group in multi_material_edge_groups.items():
gmsh.model.occ.synchronize()
ps = gmsh.model.addPhysicalGroup(1, mm_group)
gmsh.model.setPhysicalName(1, ps, f"{group_name}")

gmsh.model.occ.synchronize()

return gmsh
Expand Down
41 changes: 41 additions & 0 deletions tests/sample_assemblies.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,44 @@ def generate_materials_assembly():
assy.add(cube_2, name="cube_2", loc=cq.Location(0, 0, 5), material="steel")

return assy


def generate_edge_tagged_assembly():
"""
Two touching boxes that exercise edge tagging:
* a part-prefixed edge tag (left_outer-edges),
* a multi-part (~) edge tag shared across both parts (contact),
* a face tag (left_top) to confirm faces and edges coexist.
"""

# Left box: a normal edge tag, a multi-part edge tag, and a face tag
left = cq.Workplane().box(10, 10, 10)
left.edges("|Z and <X").tag("outer-edges") # -> left_outer-edges
left.edges("|Z and >X").tag("~contact") # -> contact (part name stripped)
left.faces(">Z").tag("top") # -> left_top

# Right box shares the interface edges with the left box's >X edges
right = cq.Workplane().transformed(offset=(10, 0, 0)).box(10, 10, 10)
right.edges("|Z and <X").tag("~contact") # -> contact

assy = cq.Assembly()
assy.add(left, name="left")
assy.add(right, name="right")

return assy


def generate_multi_solid_edge_assembly():
"""
A single assembly part that contains two disjoint solids, with the vertical
edges of both tagged. Used to confirm the imprinted path does not duplicate
curves in the resulting 1D physical group.
"""

twin = cq.Workplane().pushPoints([(-20, 0), (20, 0)]).box(5, 5, 5)
twin.edges("|Z").tag("verts")

assy = cq.Assembly()
assy.add(twin, name="twin")

return assy
Loading
Loading