diff --git a/assembly_mesh_plugin/plugin.py b/assembly_mesh_plugin/plugin.py index 27a6613..3743372 100644 --- a/assembly_mesh_plugin/plugin.py +++ b/assembly_mesh_plugin/plugin.py @@ -4,294 +4,219 @@ import cadquery as cq import gmsh +# The mesh volume and surface ids should line up with the order of solids and faces in the assembly +vol_id = 1 +surface_id = 1 -def get_tagged_gmsh(self): - """ - 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. - """ - gmsh.initialize() - gmsh.option.setNumber("General.Terminal", 0) - gmsh.model.add("coil_assembly") - - # The mesh volume and surface ids should line up with the order of solids and faces in the assembly - vol_id = 1 - surface_id = 1 +volumes = {} +volume_map = {} - # Tracks multi-surface physical groups - multi_material_groups = {} - surface_groups = {} +# Holds the collection of individual faces that are tagged +tagged_faces = {} - # Holds the collection of individual faces that are tagged - tagged_faces = {} +# Tracks multi-surface physical groups +multi_material_groups = {} +surface_groups = {} - for obj, name, loc, _ in self: - # CadQuery assembly code prepends a UUID to names sometimes that we do not need - short_name = name.split("/")[-1] - # Separate tagged faces by solid since they might be duplicates - tagged_faces[short_name] = {} +def extract_subshape_names(assy, name=None): + """ + Extracts any subshape names from the current assembly. + """ + global tagged_faces + + # We only want the last part of the name parent path + short_name = name.split("/")[-1] + + # Try extracting via names and layers + if assy._subshape_names or assy._subshape_layers: + # Make sure the entry for the assembly child exists + if short_name not in tagged_faces: + tagged_faces[short_name] = {} + + # Step through the subshape names and layers together + combined_subshapes = assy._subshape_names | assy._subshape_layers + for subshape, subshape_tag in combined_subshapes.items(): + # Create a new list for tag if it does not already exist + if subshape_tag in tagged_faces[short_name]: + tagged_faces[short_name][subshape_tag].append(subshape) + else: + tagged_faces[short_name][subshape_tag] = [subshape] + + # Check for face 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] = {} - # Extract the tagged faces and make sure they are in the appropriate relative locations - # in the assembly. Tags can hold multiple faces, so we have to extract all of them. - for tag, wp in self.objects[short_name].obj.ctx.tags.items(): for face in wp.faces().all(): - # Check to see if we have found this tag before (multi-material tag) + # 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()] - # Extract the tagged faces that have been added by the addSubshape method of cq.Assembly - if self._subshape_names: - for subshape, subshape_tag in self._subshape_names.items(): - if subshape_tag in tagged_faces[short_name]: - # Check to see if this is a duplicate - if subshape in tagged_faces[short_name][subshape_tag]: - print( - f"WARNING: Duplicate subshape found for tag {subshape_tag}." - ) - - tagged_faces[short_name][subshape_tag].append(subshape) - else: - tagged_faces[short_name][subshape_tag] = [subshape] - - # Extract the tagged faces that have been added by the addSubshape method of cq.Assembly - if self._subshape_layers: - for subshape, subshape_tag in self._subshape_layers.items(): - if subshape_tag in tagged_faces[short_name]: - # Check to see if this is a duplicate - if subshape in tagged_faces[short_name][subshape_tag]: - print( - f"WARNING: Duplicate subshape found for tag {subshape_tag}." - ) - - tagged_faces[short_name][subshape_tag].append(subshape) - else: - tagged_faces[short_name][subshape_tag] = [subshape] - - # All the solids in the current part should be added to the mesh - for s in obj.moved(loc).Solids(): - # Add the current solid to the mesh - with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file: - s.exportBrep(temp_file.name) - ps = gmsh.model.occ.importShapes(temp_file.name) - - # TODO find a way to check if the OCC in gmsh is compatible with the - # OCC in CadQuery. When pip installed they tend to be incompatible - # and this importShapesNativePointer will seg fault. When both - # packages are conda installed the importShapesNativePointer works. - # Work around that works in both cases is to write a brep and import - # it into gmsh. This is slower but works in all cases. - # gmsh.model.occ.importShapesNativePointer(s.wrapped._address()) - - gmsh.model.occ.synchronize() - - # Technically, importShapes could import multiple entities/dimensions, so filter those - vol_ents = [] - for p in ps: - if p[0] == 3: - vol_ents.append(p[1]) - - # Set the physical name to be the part name in the assembly for all the solids - ps2 = gmsh.model.addPhysicalGroup(3, vol_ents) - gmsh.model.setPhysicalName(3, ps2, f"{name.split('/')[-1]}") - - # All the faces in the current part should be added to the mesh - for face in s.Faces(): - # Face name can be based on a tag, or just be a generic name - found_tag = False - - # - # Handle tagged faces - # Step through the faces in the solid and check them against all the tagged faces - # - for tag, tag_faces in tagged_faces[short_name].items(): - for tag_face in tag_faces: - # Move the face to the correct location in the assembly - tag_face = tag_face.moved(loc) - - # If OpenCASCADE says the faces are the same, we have a match for the tag - if TopoDS_Shape.IsEqual(face.wrapped, tag_face.wrapped): - # Make sure a generic surface is not added for this face - found_tag = True - - # Find out if this is a multi-material tag - if tag.startswith("~"): - # Set the surface name to be the name of the tag without the ~ - group_name = tag.replace("~", "").split("-")[0] - - # Add this face to the multi-material group - if group_name in multi_material_groups: - multi_material_groups[group_name].append(surface_id) - else: - multi_material_groups[group_name] = [surface_id] - else: - # We want to track all surfaces that might be in a tag group - cur_tag_name = f"{short_name}_{tag}" - if cur_tag_name in surface_groups: - print( - "Append: ", cur_tag_name, short_name, surface_id - ) - surface_groups[cur_tag_name].append(surface_id) - else: - print("New: ", cur_tag_name, short_name, surface_id) - surface_groups[cur_tag_name] = [surface_id] - - # If no tag was found, set a physical group generic name - if not found_tag: - face_name = f"{short_name}_surface_{surface_id}" - ps = gmsh.model.addPhysicalGroup(2, [surface_id]) - gmsh.model.setPhysicalName(2, ps, f"{face_name}") - - # Move to the next surface id - surface_id += 1 - - # Move to the next volume id - vol_id += 1 - - # Handle tagged surface groups - for t_name, surf_group in surface_groups.items(): - ps = gmsh.model.addPhysicalGroup(2, surf_group) - gmsh.model.setPhysicalName(2, ps, t_name) - - # Handle multi-material tags - for group_name, mm_group in multi_material_groups.items(): - ps = gmsh.model.addPhysicalGroup(2, mm_group) - gmsh.model.setPhysicalName(2, ps, f"{group_name}") - - gmsh.model.occ.synchronize() - - return gmsh + # Recurse through the assembly children + for child in assy.children: + extract_subshape_names(child, child.name) -def assembly_to_gmsh(self, mesh_path="tagged_mesh.msh"): +def add_solid_to_mesh(gmsh, solid, name): """ - Pack the assembly into a gmsh object, respecting assembly part names and face tags when creating - the physical groups. + Adds a given CadQuery solid to the gmsh mesh. """ + global vol_id, volumes, volume_map + + with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file: + solid.exportBrep(temp_file.name) + dim_tags = gmsh.model.occ.importShapes(temp_file.name) + + for dim, tag in dim_tags: + # We only want volumes in this pass + if dim == 3: + # Initialize the list holding the volume entities, if needed + if tag in volumes.keys(): + volumes[tag].append((3, tag)) + else: + volumes[tag] = [] + volumes[tag].append((3, tag)) + volume_map[tag] = name + + # Move to the next volume ID + vol_id += 1 + + +def add_faces_to_mesh(gmsh, solid, name, loc=None): + global surface_id, multi_material_groups, surface_groups + + # If the current solid has no tagged faces, there is nothing to do + if name not in tagged_faces.keys(): + return + + # All the faces in the current part should be added to the mesh + for face in solid.Faces(): + # Face name can be based on a tag, or just be a generic name + found_tag = False + + # + # Handle tagged faces + # Step through the faces in the solid and check them against all the tagged faces + # + for tag, tag_faces in tagged_faces[name].items(): + for tag_face in tag_faces: + # Move the face to the correct location in the assembly + if loc: + tag_face = tag_face.moved(loc) + + # If OpenCASCADE says the faces are the same, we have a match for the tag + if TopoDS_Shape.IsEqual(face.wrapped, tag_face.wrapped): + # Make sure a generic surface is not added for this face + found_tag = True + + # Find out if this is a multi-material tag + if tag.startswith("~"): + # Set the surface name to be the name of the tag without the ~ + group_name = tag.replace("~", "").split("-")[0] + + # Add this face to the multi-material group + if group_name in multi_material_groups: + multi_material_groups[group_name].append(surface_id) + else: + multi_material_groups[group_name] = [surface_id] + else: + # We want to track all surfaces that might be in a tag group + cur_tag_name = f"{name}_{tag}" + if cur_tag_name in surface_groups: + surface_groups[cur_tag_name].append(surface_id) + else: + surface_groups[cur_tag_name] = [surface_id] + + # If the solid does not have any tagged faces, add them to a generic physical group + if not found_tag: + # Generate a unique name for the surface and set it on the physical group + face_name = f"{name}_surface_{surface_id}" + ps = gmsh.model.addPhysicalGroup(2, [surface_id]) + gmsh.model.setPhysicalName(2, ps, f"{face_name}") + gmsh.model.occ.synchronize() - # Turn this assembly with potentially tagged faces into a gmsh object - gmsh = get_tagged_gmsh(self) - - gmsh.model.mesh.field.setAsBackgroundMesh(2) - - gmsh.model.mesh.generate(3) - gmsh.write(mesh_path) + # Make sure to move to the next surface ID + surface_id += 1 - gmsh.finalize() + gmsh.model.occ.synchronize() -def get_imprinted_gmsh(self): +def get_gmsh(self, imprint=True): """ - Allows the user to get a gmsh object from the assembly, with the assembly being imprinted. + 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 - # Initialize gmsh and create a new model - gmsh.initialize() - gmsh.option.setNumber("General.Terminal", 0) - gmsh.model.add("assembly") - - # The mesh volume and surface ids should line up with the order of solids and faces in the assembly + # Reset global state for each call vol_id = 1 surface_id = 1 - - # Tracks multi-surface physical groups + volumes = {} + volume_map = {} + tagged_faces = {} multi_material_groups = {} surface_groups = {} - # Tracks the solids with tagged faces - tagged_faces = {} - solids_with_tagged_faces = {} + gmsh.initialize() + gmsh.option.setNumber( + "General.Terminal", 0 + ) # Make sure this is 0 for production for clean stdout + gmsh.model.add("assembly") + + # Get all of the subshapes and their corresponding names/positions + extract_subshape_names(self, self.name) # Imprint the assembly imprinted_assembly, imprinted_solids_with_orginal_ids = ( cq.occ_impl.assembly.imprint(self) ) - for solid, id in imprinted_solids_with_orginal_ids.items(): - # Add the current solid to the mesh - # Work-around for a segfault with in-memory passing of OCCT objects - with tempfile.NamedTemporaryFile(suffix=".brep") as temp_file: - solid.exportBrep(temp_file.name) - ps = gmsh.model.occ.importShapes(temp_file.name) - gmsh.model.occ.synchronize() + # Handle the imprinted assembly + if imprint: + for solid, name in imprinted_solids_with_orginal_ids.items(): + # Get just the name of the current assembly + short_name = name[0].split("/")[-1] - # Technically, importShapes could import multiple entities/dimensions, so filter those - vol_ents = [] - for p in ps: - if p[0] == 3: - vol_ents.append(p[1]) + # Add the current solid to the mesh + add_solid_to_mesh(gmsh, solid, short_name) - # Set the physical name to be the part name in the assembly for all the solids - ps2 = gmsh.model.addPhysicalGroup(3, vol_ents) - gmsh.model.setPhysicalName(3, ps2, f"{id[0].split('/')[-1]}") + # Add faces to the mesh and handle tagged faces + add_faces_to_mesh(gmsh, solid, short_name, None) - # Get the original assembly part - object_name = id[0].split("/")[-1] - assembly_part = self.objects[object_name] + # Handle the non-imprinted assembly + else: + # Step through all of the solids in the assembly + for obj, name, loc, _ in self: + # Get just the name of the current assembly + short_name = name.split("/")[-1] - # Collect any tags from the part - for tag, wp in assembly_part.obj.ctx.tags.items(): - tagged_face = wp.faces().all()[0].val() - for face in wp.faces().all(): - tagged_faces[face.val()] = tag - - # Iterate over the faces of the assembly part - for face in assembly_part.obj.faces(): - for tagged_face, tag in tagged_faces.items(): - if TopoDS_Shape.IsEqual(face.wrapped, tagged_face.wrapped): - print(f"{vol_id}_{surface_id}", tag) - solids_with_tagged_faces[f"{vol_id}_{surface_id}"] = ( - object_name, - tag, - ) - - surface_id += 1 - vol_id += 1 - - # Reset the volume and surface IDs - vol_id = 1 - surface_id = 1 + for solid in obj.moved(loc).Solids(): + # Add the current solid to the mesh + add_solid_to_mesh(gmsh, solid, short_name) - # Step through the imprinted assembly/shape and check for tagged faces - for solid in imprinted_assembly.solids(): - for face in solid.faces().Faces(): - # Check to see if this face has been tagged - if f"{vol_id}_{surface_id}" in solids_with_tagged_faces.keys(): - short_name = solids_with_tagged_faces[f"{vol_id}_{surface_id}"][0] - tag = solids_with_tagged_faces[f"{vol_id}_{surface_id}"][1] - - # Find out if this is a multi-material tag - if tag.startswith("~"): - # Set the surface name to be the name of the tag without the ~ - group_name = tag.replace("~", "").split("-")[0] - - # Add this face to the multi-material group - if group_name in multi_material_groups: - multi_material_groups[group_name].append(surface_id) - else: - multi_material_groups[group_name] = [surface_id] - else: - # We want to track all surfaces that might be in a tag group - cur_tag_name = f"{short_name}_{tag}" - if cur_tag_name in surface_groups: - print("Append: ", cur_tag_name, short_name, surface_id) - surface_groups[cur_tag_name].append(surface_id) - else: - print("New: ", cur_tag_name, short_name, surface_id) - surface_groups[cur_tag_name] = [surface_id] + # Add faces to the mesh and handle tagged faces + add_faces_to_mesh(gmsh, solid, short_name, loc) - surface_id += 1 - vol_id += 1 + # Step through each of the volumes and add physical groups for each + for volume_id in volumes.keys(): + gmsh.model.occ.synchronize() + ps = gmsh.model.addPhysicalGroup(3, volumes[volume_id][0]) + gmsh.model.setPhysicalName(3, ps, f"{volume_map[volume_id]}") # Handle tagged surface groups for t_name, surf_group in surface_groups.items(): + gmsh.model.occ.synchronize() ps = gmsh.model.addPhysicalGroup(2, surf_group) gmsh.model.setPhysicalName(2, ps, t_name) # Handle multi-material tags for group_name, mm_group in multi_material_groups.items(): + gmsh.model.occ.synchronize() ps = gmsh.model.addPhysicalGroup(2, mm_group) gmsh.model.setPhysicalName(2, ps, f"{group_name}") @@ -300,6 +225,44 @@ def get_imprinted_gmsh(self): return gmsh +def get_tagged_gmsh(self): + """ + 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. + """ + + gmsh = get_gmsh(self, imprint=False) + + return gmsh + + +def get_imprinted_gmsh(self): + """ + Allows the user to get a gmsh object from the assembly, with the assembly being imprinted. + """ + + gmsh = get_gmsh(self, imprint=True) + + return gmsh + + +def assembly_to_gmsh(self, mesh_path="tagged_mesh.msh"): + """ + Pack the assembly into a gmsh object, respecting assembly part names and face tags when creating + the physical groups. + """ + + # Turn this assembly with potentially tagged faces into a gmsh object + gmsh = get_tagged_gmsh(self) + + gmsh.model.mesh.field.setAsBackgroundMesh(2) + + gmsh.model.mesh.generate(3) + gmsh.write(mesh_path) + + gmsh.finalize() + + def assembly_to_imprinted_gmsh(self, mesh_path="tagged_mesh.msh"): """ Exports an imprinted assembly to capture conformal meshes. @@ -319,6 +282,7 @@ def assembly_to_imprinted_gmsh(self, mesh_path="tagged_mesh.msh"): # Patch the new assembly functions into CadQuery's importers package cq.Assembly.assemblyToGmsh = assembly_to_gmsh cq.Assembly.saveToGmsh = assembly_to_gmsh # Alias name that works better on cq.Assembly -cq.Assembly.getTaggedGmsh = get_tagged_gmsh cq.Assembly.assemblyToImprintedGmsh = assembly_to_imprinted_gmsh +cq.Assembly.getTaggedGmsh = get_tagged_gmsh cq.Assembly.getImprintedGmsh = get_imprinted_gmsh +cq.Assembly.getGmsh = get_gmsh diff --git a/tests/sample_assemblies.py b/tests/sample_assemblies.py index ef01c81..4e546c6 100644 --- a/tests/sample_assemblies.py +++ b/tests/sample_assemblies.py @@ -1,6 +1,38 @@ import cadquery as cq +def generate_nested_spheres(): + """ + Used for confirming conformal meshing. + """ + + # Generate the simple assembly of two nested spheres + box_cutter = cq.Workplane("XY").moveTo(0, 5).box(20, 10, 20) + inner_sphere = cq.Workplane("XY").sphere(6).cut(box_cutter) + middle_sphere = cq.Workplane("XY").sphere(6.1).cut(box_cutter).cut(inner_sphere) + + assy = cq.Assembly() + assy.add(inner_sphere, name="inner_sphere") + assy.add(middle_sphere, name="middle_sphere") + + return assy + + +def generate_touching_boxes(): + """ + Generates an assembly of two cubes which touch on one face. + """ + + cube_1 = cq.Workplane().box(10, 10, 10) + cube_2 = cq.Workplane().transformed(offset=(10, 0, 0)).box(10, 10, 10) + + assy = cq.Assembly() + assy.add(cube_1, name="left_cube") + assy.add(cube_2, name="right_cube") + + return assy + + def generate_nested_boxes(): """ Generates a simple assembly of two cubes where one is nested inside the other. diff --git a/tests/test_meshes.py b/tests/test_meshes.py index 65cb484..24485c9 100644 --- a/tests/test_meshes.py +++ b/tests/test_meshes.py @@ -1,6 +1,8 @@ import gmsh import assembly_mesh_plugin from tests.sample_assemblies import ( + generate_nested_spheres, + generate_touching_boxes, generate_nested_boxes, generate_simple_nested_boxes, generate_test_cross_section, @@ -24,16 +26,22 @@ def test_simple_assembly(): gmsh.open("tagged_mesh.msh") - # Check the solids for the correct tags + # Make sure that there are physical groups for the volumes physical_groups = gmsh.model.getPhysicalGroups(3) + assert len(physical_groups) > 0, "There should be some physical groups for volumes" + + # Check the solids for the correct tags for group in physical_groups: # Get the name for the current volume cur_name = gmsh.model.getPhysicalName(3, group[1]) assert cur_name in ["shell", "insert"] - # Check the surfaces for the correct tags + # Check to make sure there are physical groups for the surfaces physical_groups = gmsh.model.getPhysicalGroups(2) + assert len(physical_groups) > 0, "There should be some physical groups for surfaces" + + # Check the surfaces for the correct tags for group in physical_groups: # Get the name for this group cur_name = gmsh.model.getPhysicalName(2, group[1]) @@ -60,16 +68,22 @@ def test_subshape_assembly(): gmsh.open("tagged_subshape_mesh.msh") - # Check the solids for the correct tags + # Make sure that there are physical groups for the volumes physical_groups = gmsh.model.getPhysicalGroups(3) + assert len(physical_groups) > 0, "There should be some physical groups for volumes" + + # Check the solids/volumes for the correct tags for group in physical_groups: # Get the name for the current volume cur_name = gmsh.model.getPhysicalName(3, group[1]) assert cur_name in ["cube_1"] - # Check the surfaces for the correct tags + # Check to make sure there are physical groups for the surfaces physical_groups = gmsh.model.getPhysicalGroups(2) + assert len(physical_groups) > 0, "There should be some physical groups for surfaces" + + # Check the surfaces for the correct tags for group in physical_groups: # Get the name for this group cur_name = gmsh.model.getPhysicalName(2, group[1]) @@ -91,16 +105,22 @@ def test_imprinted_assembly(): gmsh.open("tagged_imprinted_mesh.msh") - # Check the solids for the correct tags + # Make sure that there are physical groups for the volumes physical_groups = gmsh.model.getPhysicalGroups(3) + assert len(physical_groups) > 0, "There should be some physical groups for volumes" + + # Check the solids for the correct tags for group in physical_groups: # Get the name for the current volume cur_name = gmsh.model.getPhysicalName(3, group[1]) assert cur_name in ["shell", "insert"] - # Check the surfaces for the correct tags + # Check to make sure there are physical groups for the surfaces physical_groups = gmsh.model.getPhysicalGroups(2) + assert len(physical_groups) > 0, "There should be some physical groups for surfaces" + + # Check the surfaces for the correct tags for group in physical_groups: # Get the name for this group cur_name = gmsh.model.getPhysicalName(2, group[1]) @@ -110,3 +130,48 @@ def test_imprinted_assembly(): continue assert cur_name in ["shell_inner-right", "insert_outer-right", "in_contact"] + + +def test_nested_sphere_assembly(): + """ + Tests to make sure the the nested sphere example works. + """ + + def _check_physical_groups(): + # Make sure that there are physical groups for the volumes + physical_groups = gmsh.model.getPhysicalGroups(3) + assert ( + len(physical_groups) == 2 + ), "There should be two physical groups for volumes" + + # Check the solids for the correct tags + for group in physical_groups: + # Get the name for the current volume + cur_name = gmsh.model.getPhysicalName(3, group[1]) + + assert cur_name in ["inner_sphere", "middle_sphere"] + + # Make sure we can retrieve the physical groups + inner_sphere_volume = gmsh.model.getEntitiesForPhysicalName("inner_sphere") + middle_sphere_volume = gmsh.model.getEntitiesForPhysicalName("middle_sphere") + + # Create a basic assembly + assy = generate_nested_spheres() + + # + # Go through the entire process with an imprinted assembly. + # + gmsh = assy.getGmsh(imprint=True) + gmsh.model.mesh.generate(3) + + # Ensure that there are physical groups and that they have the right names + _check_physical_groups() + + # + # Go othrough the entire process again with a non-imprinted assembly. + # + gmsh = assy.getGmsh(imprint=False) + gmsh.model.mesh.generate(3) + + # Ensure that there are physical groups + _check_physical_groups()