From 8fdd5062f6c90d26dd58a0b535ab6c30dc2bb1be Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 16 Jun 2026 10:54:21 -0400 Subject: [PATCH] Added edge tagging --- README.md | 76 +++++++++++++++++- assembly_mesh_plugin/plugin.py | 140 ++++++++++++++++++++++++++++++--- tests/sample_assemblies.py | 41 ++++++++++ tests/test_meshes.py | 95 ++++++++++++++++++++++ 4 files changed, 338 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6b41df8..9437847 100644 --- a/README.md +++ b/README.md @@ -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/) @@ -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 @@ -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. diff --git a/assembly_mesh_plugin/plugin.py b/assembly_mesh_plugin/plugin.py index 5620938..11ae18a 100644 --- a/assembly_mesh_plugin/plugin.py +++ b/assembly_mesh_plugin/plugin.py @@ -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): """ @@ -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: @@ -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) @@ -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 @@ -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 @@ -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( @@ -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() @@ -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 diff --git a/tests/sample_assemblies.py b/tests/sample_assemblies.py index e99140f..c6497a2 100644 --- a/tests/sample_assemblies.py +++ b/tests/sample_assemblies.py @@ -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 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 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 diff --git a/tests/test_meshes.py b/tests/test_meshes.py index 7a9e226..78079e7 100644 --- a/tests/test_meshes.py +++ b/tests/test_meshes.py @@ -9,6 +9,8 @@ generate_assembly, generate_subshape_assembly, generate_materials_assembly, + generate_edge_tagged_assembly, + generate_multi_solid_edge_assembly, ) @@ -178,6 +180,99 @@ def _check_physical_groups(): _check_physical_groups() +def _edge_groups(gmsh): + """Return a {name: [curve ids]} mapping of all 1D (edge) physical groups.""" + return { + gmsh.model.getPhysicalName(1, tag): list( + gmsh.model.getEntitiesForPhysicalGroup(1, tag) + ) + for _, tag in gmsh.model.getPhysicalGroups(1) + } + + +def test_edge_tags_non_imprint(): + """ + Tagged edges should become 1D physical groups, with part-prefixed names for + normal tags and a stripped, merged group for multi-part (~) tags. + """ + + assy = generate_edge_tagged_assembly() + + gmsh = assy.getTaggedGmsh() + + groups = _edge_groups(gmsh) + + # Part-prefixed edge tag + assert "left_outer-edges" in groups + assert len(groups["left_outer-edges"]) == 2 + + # Multi-part (~) edge tag merges across both parts (two curves from each) + assert "contact" in groups + assert len(groups["contact"]) == 4 + + +def test_edge_tags_imprint(): + """ + Edge tagging should also work on the imprinted (conformal) path. + """ + + assy = generate_edge_tagged_assembly() + + gmsh = assy.getImprintedGmsh() + + groups = _edge_groups(gmsh) + + assert "left_outer-edges" in groups + assert "contact" in groups + + +def test_edge_and_face_tags_coexist(): + """ + Face tags (2D groups) and edge tags (1D groups) should both be present, and + re-running should not leak or duplicate groups between calls. + """ + + assy = generate_edge_tagged_assembly() + + gmsh = assy.getTaggedGmsh() + + # Face tag present as a 2D physical group + face_names = [ + gmsh.model.getPhysicalName(2, tag) for _, tag in gmsh.model.getPhysicalGroups(2) + ] + assert "left_top" in face_names + + # Edge tag present as a 1D physical group + assert "left_outer-edges" in _edge_groups(gmsh) + + # Running again should not accumulate duplicate edge groups (reset check) + gmsh = assy.getTaggedGmsh() + edge_names = [ + gmsh.model.getPhysicalName(1, tag) for _, tag in gmsh.model.getPhysicalGroups(1) + ] + assert edge_names.count("contact") == 1 + assert edge_names.count("left_outer-edges") == 1 + + +def test_edge_tags_multi_solid_part_no_duplicates(): + """ + A part made of multiple disjoint solids should not produce duplicate curves + in its edge physical group on the imprinted path. + """ + + assy = generate_multi_solid_edge_assembly() + + gmsh = assy.getImprintedGmsh() + + groups = _edge_groups(gmsh) + + assert "twin_verts" in groups + curves = groups["twin_verts"] + # Two boxes, four vertical edges each, with no duplicates + assert len(curves) == 8 + assert len(curves) == len(set(curves)) + + def test_mesh_materials(): """ Tests to make sure that assembly materials are preserved in the mesh data.