Skip to content

Commit 8fdd506

Browse files
committed
Added edge tagging
1 parent a02e197 commit 8fdd506

4 files changed

Lines changed: 338 additions & 14 deletions

File tree

README.md

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22

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

5-
This plugin makes use of CadQuery tags to collect surfaces into [Gmsh](https://gmsh.info/) physical groups. The
6-
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.
5+
This plugin makes use of CadQuery tags to collect surfaces and edges into [Gmsh](https://gmsh.info/) physical groups.
6+
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.
77

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

12+
Tagged edges are handled a little differently from faces. Because an edge is shared between multiple faces, the
13+
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)
14+
and that search is restricted to the curves belonging to the same assembly part. This keeps edge tags correct even
15+
when separate parts meet at coincident edges.
16+
1217
## Installation
1318

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

35+
### Face Tagging
36+
3037
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.
3138

3239
```python
@@ -94,6 +101,71 @@ gmsh_object.write("tagged_mesh.msh")
94101
gmsh_object.finalize()
95102
```
96103

104+
### Edge Tagging
105+
106+
In addition to faces, you can tag **edges**, which become 1-dimensional physical
107+
groups in the resulting mesh. This is useful for meshing operations that act on
108+
curves, such as Gmsh transfinite meshing.
109+
110+
Edges are tagged the same way as faces, using CadQuery's `tag` method on an edge
111+
selection:
112+
113+
```python
114+
import cadquery as cq
115+
import assembly_mesh_plugin
116+
117+
beam = cq.Workplane("XY").box(50, 50, 50)
118+
beam.edges("|Z").tag("vertical-edges")
119+
120+
assy = cq.Assembly()
121+
assy.add(beam, name="beam")
122+
123+
assy.saveToGmsh(mesh_path="tagged_mesh.msh")
124+
```
125+
126+
Edge tags follow the same naming rules as face tags:
127+
128+
* A normal tag is prefixed with the assembly part name, so `vertical-edges` on a
129+
part named `beam` becomes the physical group `beam_vertical-edges`.
130+
* Prefixing a tag with `~` ignores the part name, so the same tag applied to
131+
edges on different parts (for example `~contact`) is merged into a single
132+
physical group named `contact`.
133+
134+
Faces and edges can be tagged on the same part and will produce separate 2D and
135+
1D physical groups respectively.
136+
137+
#### Using tagged edges for transfinite meshing
138+
139+
Because tagged edges are exposed as named 1D physical groups, you can use
140+
`getTaggedGmsh` to retrieve the Gmsh object, look the curves up by name, and
141+
apply your own constraints before meshing:
142+
143+
```python
144+
import cadquery as cq
145+
import assembly_mesh_plugin
146+
147+
beam = cq.Workplane("XY").box(50, 50, 50)
148+
beam.edges("|Z").tag("vertical-edges")
149+
150+
assy = cq.Assembly()
151+
assy.add(beam, name="beam")
152+
153+
# Get a Gmsh object back with the tagged edges as 1D physical groups
154+
gmsh_object = assy.getTaggedGmsh()
155+
156+
# Find the tagged curves by physical group name and constrain them
157+
for dim, tag in gmsh_object.model.getPhysicalGroups(1):
158+
if gmsh_object.model.getPhysicalName(1, tag) == "beam_vertical-edges":
159+
for curve in gmsh_object.model.getEntitiesForPhysicalGroup(1, tag):
160+
# 11 nodes along each tagged edge
161+
gmsh_object.model.mesh.setTransfiniteCurve(int(curve), 11)
162+
163+
# Generate the mesh and write it to the file
164+
gmsh_object.model.mesh.generate(3)
165+
gmsh_object.write("tagged_mesh.msh")
166+
gmsh_object.finalize()
167+
```
168+
97169
## Tests
98170

99171
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.

assembly_mesh_plugin/plugin.py

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,53 @@
1414
# Holds the collection of individual faces that are tagged
1515
tagged_faces = {}
1616

17+
# Holds the collection of individual edges that are tagged
18+
tagged_edges = {}
19+
20+
# Tracks which gmsh curve tags belong to each part
21+
solid_curves = {}
22+
1723
# Tracks multi-surface physical groups
1824
multi_material_groups = {}
1925
surface_groups = {}
2026

27+
# Tracks edge (1D) physical groups
28+
edge_groups = {}
29+
multi_material_edge_groups = {}
30+
31+
32+
def _gmsh_curve_signatures(gmsh):
33+
"""
34+
Build a geometric signature for every 1D curve currently in the model, keyed
35+
by gmsh curve tag. Matching tagged edges by geometry rather than enumeration
36+
order keeps edge tagging correct even when curves are shared between faces/solids.
37+
"""
38+
sigs = {}
39+
for _, ctag in gmsh.model.getEntities(1):
40+
bbox = gmsh.model.getBoundingBox(1, ctag)
41+
tmin, tmax = gmsh.model.getParametrizationBounds(1, ctag)
42+
mid = gmsh.model.getValue(1, ctag, [0.5 * (tmin[0] + tmax[0])])
43+
sigs[ctag] = (bbox, mid)
44+
45+
return sigs
46+
47+
48+
def _edge_matches(edge, sig, tol=1e-6):
49+
"""True if a CadQuery edge matches a gmsh curve signature."""
50+
bbox, mid = sig
51+
eb = edge.BoundingBox()
52+
cqbox = (eb.xmin, eb.ymin, eb.zmin, eb.xmax, eb.ymax, eb.zmax)
53+
if max(abs(a - b) for a, b in zip(cqbox, bbox)) > tol:
54+
return False
55+
56+
# Midpoint disambiguates curves that share a bounding box
57+
em = edge.positionAt(0.5)
58+
return (
59+
abs(em.x - mid[0]) <= tol
60+
and abs(em.y - mid[1]) <= tol
61+
and abs(em.z - mid[2]) <= tol
62+
)
63+
2164

2265
def extract_subshape_names(assy, name=None):
2366
"""
@@ -43,19 +86,21 @@ def extract_subshape_names(assy, name=None):
4386
else:
4487
tagged_faces[short_name][subshape_tag] = [subshape]
4588

46-
# Check for face tags
89+
# Check for face and edge tags
4790
if assy.objects[short_name].obj:
4891
for tag, wp in assy.objects[short_name].obj.ctx.tags.items():
49-
# Make sure the entry for the assembly child exists
50-
if short_name not in tagged_faces:
51-
tagged_faces[short_name] = {}
52-
53-
for face in wp.faces().all():
54-
# Create a new list for tag if it does not already exist
55-
if tag in tagged_faces[short_name]:
56-
tagged_faces[short_name][tag].append(face.val())
57-
else:
58-
tagged_faces[short_name][tag] = [face.val()]
92+
# Make sure the entries for the assembly child exists
93+
tagged_faces.setdefault(short_name, {})
94+
tagged_edges.setdefault(short_name, {})
95+
96+
# A tag stores a Workplane object that can contain edges or faces
97+
objs = wp.objects
98+
if objs and isinstance(objs[0], cq.Edge):
99+
for edge in wp.edges().all():
100+
tagged_edges[short_name].setdefault(tag, []).append(edge.val())
101+
else:
102+
for face in wp.faces().all():
103+
tagged_faces[short_name].setdefault(tag, []).append(face.val())
59104

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

116+
before = {t for _, t in gmsh.model.getEntities(1)}
117+
71118
with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file:
72119
solid.exportBrep(temp_file.name)
73120
dim_tags = gmsh.model.occ.importShapes(temp_file.name)
@@ -86,6 +133,10 @@ def add_solid_to_mesh(gmsh, solid, name):
86133
# Move to the next volume ID
87134
vol_id += 1
88135

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

90141
def add_faces_to_mesh(gmsh, solid, name, loc=None):
91142
global surface_id, multi_material_groups, surface_groups
@@ -146,12 +197,41 @@ def add_faces_to_mesh(gmsh, solid, name, loc=None):
146197
gmsh.model.occ.synchronize()
147198

148199

200+
def add_edges_to_mesh(gmsh, name, loc=None, curve_sigs=None):
201+
"""Match a part's tagged edges to gmsh curves and collect them into 1D physical groups."""
202+
global edge_groups, multi_material_edge_groups
203+
204+
if not tagged_edges.get(name):
205+
return
206+
207+
for tag, tag_edges in tagged_edges[name].items():
208+
for tag_edge in tag_edges:
209+
# Move the edge into its assembly position
210+
if loc:
211+
tag_edge = tag_edge.moved(loc)
212+
213+
# Find the gmsh curve whose geometry matches this tagged edge
214+
match = next(
215+
(c for c, sig in curve_sigs.items() if _edge_matches(tag_edge, sig)),
216+
None,
217+
)
218+
if match is None:
219+
continue
220+
221+
# Same ~ convention as faces: strip the part name for multi-part groups
222+
if tag.startswith("~"):
223+
group_name = tag.replace("~", "").split("-")[0]
224+
multi_material_edge_groups.setdefault(group_name, []).append(match)
225+
else:
226+
edge_groups.setdefault(f"{name}_{tag}", []).append(match)
227+
228+
149229
def get_gmsh(self, imprint=True):
150230
"""
151231
Allows the user to get a gmsh object from the assembly, respecting assembly part names and face
152232
tags, but have more control over how it is meshed. This method makes sure the mesh is conformal.
153233
"""
154-
global vol_id, surface_id, volumes, volume_map, tagged_faces, multi_material_groups, surface_groups
234+
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
155235

156236
# Reset global state for each call
157237
vol_id = 1
@@ -162,6 +242,10 @@ def get_gmsh(self, imprint=True):
162242
multi_material_groups = {}
163243
surface_groups = {}
164244
solid_materials = []
245+
solid_curves = {}
246+
tagged_edges = {}
247+
edge_groups = {}
248+
multi_material_edge_groups = {}
165249

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

304+
# Second pass: Build the curve signatures once and match each part's tagged edges
305+
# against only the curves that belong to that part.
306+
gmsh.model.occ.synchronize()
307+
curve_sigs = _gmsh_curve_signatures(gmsh)
308+
309+
if imprint:
310+
seen = set()
311+
for _, name in imprinted_solids_with_orginal_ids.items():
312+
short_name = name[0].split("/")[-1]
313+
if short_name in seen:
314+
continue
315+
seen.add(short_name)
316+
part_sigs = {c: curve_sigs[c] for c in solid_curves.get(short_name, ())}
317+
add_edges_to_mesh(gmsh, short_name, None, part_sigs)
318+
else:
319+
for obj, name, loc, _ in self:
320+
short_name = name.split("/")[-1]
321+
part_sigs = {c: curve_sigs[c] for c in solid_curves.get(short_name, ())}
322+
add_edges_to_mesh(gmsh, short_name, loc, part_sigs)
323+
220324
# Step through each of the volumes and add physical groups for each
221325
for volume_id in volumes.keys():
222326
gmsh.model.occ.synchronize()
@@ -242,6 +346,18 @@ def get_gmsh(self, imprint=True):
242346
ps = gmsh.model.addPhysicalGroup(2, mm_group)
243347
gmsh.model.setPhysicalName(2, ps, f"{group_name}")
244348

349+
# Handle tagged edge groups (1D physical groups)
350+
for e_name, edge_group in edge_groups.items():
351+
gmsh.model.occ.synchronize()
352+
ps = gmsh.model.addPhysicalGroup(1, edge_group)
353+
gmsh.model.setPhysicalName(1, ps, e_name)
354+
355+
# Handle multi-material edge tags
356+
for group_name, mm_group in multi_material_edge_groups.items():
357+
gmsh.model.occ.synchronize()
358+
ps = gmsh.model.addPhysicalGroup(1, mm_group)
359+
gmsh.model.setPhysicalName(1, ps, f"{group_name}")
360+
245361
gmsh.model.occ.synchronize()
246362

247363
return gmsh

tests/sample_assemblies.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,44 @@ def generate_materials_assembly():
332332
assy.add(cube_2, name="cube_2", loc=cq.Location(0, 0, 5), material="steel")
333333

334334
return assy
335+
336+
337+
def generate_edge_tagged_assembly():
338+
"""
339+
Two touching boxes that exercise edge tagging:
340+
* a part-prefixed edge tag (left_outer-edges),
341+
* a multi-part (~) edge tag shared across both parts (contact),
342+
* a face tag (left_top) to confirm faces and edges coexist.
343+
"""
344+
345+
# Left box: a normal edge tag, a multi-part edge tag, and a face tag
346+
left = cq.Workplane().box(10, 10, 10)
347+
left.edges("|Z and <X").tag("outer-edges") # -> left_outer-edges
348+
left.edges("|Z and >X").tag("~contact") # -> contact (part name stripped)
349+
left.faces(">Z").tag("top") # -> left_top
350+
351+
# Right box shares the interface edges with the left box's >X edges
352+
right = cq.Workplane().transformed(offset=(10, 0, 0)).box(10, 10, 10)
353+
right.edges("|Z and <X").tag("~contact") # -> contact
354+
355+
assy = cq.Assembly()
356+
assy.add(left, name="left")
357+
assy.add(right, name="right")
358+
359+
return assy
360+
361+
362+
def generate_multi_solid_edge_assembly():
363+
"""
364+
A single assembly part that contains two disjoint solids, with the vertical
365+
edges of both tagged. Used to confirm the imprinted path does not duplicate
366+
curves in the resulting 1D physical group.
367+
"""
368+
369+
twin = cq.Workplane().pushPoints([(-20, 0), (20, 0)]).box(5, 5, 5)
370+
twin.edges("|Z").tag("verts")
371+
372+
assy = cq.Assembly()
373+
assy.add(twin, name="twin")
374+
375+
return assy

0 commit comments

Comments
 (0)