diff --git a/_unittest/example_models/T20/sphere.stl b/_unittest/example_models/T20/sphere.stl new file mode 100644 index 00000000000..d0474c785f2 Binary files /dev/null and b/_unittest/example_models/T20/sphere.stl differ diff --git a/_unittest/test_20_HFSS.py b/_unittest/test_20_HFSS.py index cd13bf50322..ae8d68895f2 100644 --- a/_unittest/test_20_HFSS.py +++ b/_unittest/test_20_HFSS.py @@ -1403,7 +1403,12 @@ def test_59_test_nastran(self): cads = self.aedtapp.modeler.import_nastran(example_project) assert len(cads) > 0 - assert self.aedtapp.modeler.import_nastran(example_project2) + assert self.aedtapp.modeler.import_nastran(example_project2, decimation=0.5) + example_project = os.path.join(local_path, "../_unittest/example_models", test_subfolder, "sphere.stl") + from pyaedt.modules.solutions import simplify_stl + + out = simplify_stl(example_project, decimation=0.8, aggressiveness=5) + assert os.path.exists(out) def test_60_set_variable(self): self.aedtapp.variable_manager.set_variable("var_test", expression="123") diff --git a/pyaedt/modeler/cad/Primitives.py b/pyaedt/modeler/cad/Primitives.py index a7913b071ae..e08329a677b 100644 --- a/pyaedt/modeler/cad/Primitives.py +++ b/pyaedt/modeler/cad/Primitives.py @@ -4612,6 +4612,11 @@ def import_3d_cad( separate_disjoints_lumped_object=False, import_free_surfaces=False, point_coicidence_tolerance=1e-6, + heal_stl=True, + reduce_stl=False, + reduce_percentage=0, + reduce_error=0, + merge_planar_faces=True, ): """Import a CAD model. @@ -4643,6 +4648,16 @@ def import_3d_cad( Either to import free surfaces parts. The default is ``False``. point_coicidence_tolerance : float, optional Tolerance on point. Default is ``1e-6``. + heal_stl : bool, optional + Whether to heal the stl file on import or not. Default is ``True``. + reduce_stl : bool, optional + Whether to reduce the stl file on import or not. Default is ``True``. + reduce_percentage : int, optional + Stl reduce percentage. Default is ``0``. + reduce_error : int, optional + Stl error percentage during reduce operation. Default is ``0``. + merge_planar_faces : bool, optional + Stl automatic planar face merge during import. Default is ``True``. Returns ------- @@ -4668,7 +4683,14 @@ def import_3d_cad( vArg1.append("GroupByAssembly:="), vArg1.append(group_by_assembly) vArg1.append("CreateGroup:="), vArg1.append(create_group) vArg1.append("STLFileUnit:="), vArg1.append("Auto") - vArg1.append("MergeFacesAngle:="), vArg1.append(-1) + vArg1.append("MergeFacesAngle:="), vArg1.append( + 0.02 if input_file.endswith(".stl") and merge_planar_faces else -1 + ) + if input_file.endswith(".stl"): + vArg1.append("HealSTL:="), vArg1.append(heal_stl) + vArg1.append("ReduceSTL:="), vArg1.append(reduce_stl) + vArg1.append("ReduceMaxError:="), vArg1.append(reduce_error) + vArg1.append("ReducePercentage:="), vArg1.append(reduce_percentage) vArg1.append("PointCoincidenceTol:="), vArg1.append(point_coicidence_tolerance) vArg1.append("CreateLightweightPart:="), vArg1.append(create_lightweigth_part) vArg1.append("ImportMaterialNames:="), vArg1.append(import_materials) diff --git a/pyaedt/modeler/modeler3d.py b/pyaedt/modeler/modeler3d.py index 32e01bf219b..cc3bccfba02 100644 --- a/pyaedt/modeler/modeler3d.py +++ b/pyaedt/modeler/modeler3d.py @@ -877,7 +877,15 @@ def objects_in_bounding_box(self, bounding_box, check_solids=True, check_lines=T return objects @pyaedt_function_handler() - def import_nastran(self, file_path, import_lines=True, lines_thickness=0, import_as_light_weight=False, **kwargs): + def import_nastran( + self, + file_path, + import_lines=True, + lines_thickness=0, + import_as_light_weight=False, + decimation=0, + group_parts=True, + ): """Import Nastran file into 3D Modeler by converting the faces to stl and reading it. The solids are translated directly to AEDT format. @@ -892,15 +900,26 @@ def import_nastran(self, file_path, import_lines=True, lines_thickness=0, import Every line will be parametrized with a design variable called ``xsection_linename``. import_as_light_weight : bool, optional Import the stl generatated as light weight. It works only on SBR+ and HFSS Regions. Default is ``False``. + decimation : float, optional + Fraction of the original mesh to remove before creating the stl file. If set to ``0.9``, + this function tries to reduce the data set to 10% of its + original size and removes 90% of the input triangles. + group_parts : bool, optional + Whether to group imported parts by object ID. The default is ``True``. Returns ------- List of :class:`pyaedt.modeler.Object3d.Object3d` """ + autosave = ( + True if self._app.odesktop.GetRegistryInt("Desktop/Settings/ProjectOptions/DoAutoSave") == 1 else False + ) + self._app.odesktop.EnableAutoSave(False) - def _write_solid_stl(triangle, nas_to_dict): + def _write_solid_stl(triangle, pp): try: - points = [nas_to_dict["Points"][id] for id in triangle] + # points = [nas_to_dict["Points"][id] for id in triangle] + points = [pp[i] for i in triangle] except KeyError: return fc = GeometryOperators.get_polygon_centroid(points) @@ -927,14 +946,13 @@ def _write_solid_stl(triangle, nas_to_dict): f.write(" endloop\n") f.write(" endfacet\n") - nas_to_dict = {"Points": {}, "PointsId": {}, "Triangles": {}, "Lines": {}, "Solids": {}} + nas_to_dict = {"Points": [], "PointsId": {}, "Triangles": {}, "Lines": {}, "Solids": {}} self.logger.reset_timer() self.logger.info("Loading file") - el_ids = [] + pid = 0 with open_file(file_path, "r") as f: lines = f.read().splitlines() - id = 0 for lk in range(len(lines)): line = lines[lk] line_type = line[:8].strip() @@ -956,11 +974,15 @@ def _write_solid_stl(triangle, nas_to_dict): if "-" in n3[1:] and "e" not in n3[1:].lower(): n3 = n3[0] + n3[1:].replace("-", "e-") if line_type == "GRID": - nas_to_dict["Points"][grid_id] = [float(n1), float(n2), float(n3)] - nas_to_dict["PointsId"][grid_id] = grid_id - id += 1 + nas_to_dict["PointsId"][grid_id] = pid + nas_to_dict["Points"].append([float(n1), float(n2), float(n3)]) + pid += 1 else: - tri = [int(n1), int(n2), int(n3)] + tri = [ + nas_to_dict["PointsId"][int(n1)], + nas_to_dict["PointsId"][int(n2)], + nas_to_dict["PointsId"][int(n3)], + ] nas_to_dict["Triangles"][tria_id].append(tri) elif line_type in ["GRID*", "CTRIA3*"]: @@ -984,13 +1006,17 @@ def _write_solid_stl(triangle, nas_to_dict): n3 = n3[0] + n3[1:].replace("-", "e-") if line_type == "GRID*": try: - nas_to_dict["Points"][grid_id] = [float(n1), float(n2), float(n3)] + nas_to_dict["Points"].append([float(n1), float(n2), float(n3)]) except Exception: # nosec continue - nas_to_dict["PointsId"][grid_id] = id - id += 1 + nas_to_dict["PointsId"][grid_id] = pid + pid += 1 else: - tri = (int(n1), int(n2), int(n3)) + tri = [ + nas_to_dict["PointsId"][int(n1)], + nas_to_dict["PointsId"][int(n2)], + nas_to_dict["PointsId"][int(n3)], + ] nas_to_dict["Triangles"][tria_id].append(tri) elif line_type in [ @@ -1014,7 +1040,12 @@ def _write_solid_stl(triangle, nas_to_dict): from itertools import combinations for k in list(combinations(n, 3)): - tri = [int(k[0]), int(k[1]), int(k[2])] + # tri = [int(k[0]), int(k[1]), int(k[2])] + tri = [ + nas_to_dict["PointsId"][int(k[0])], + nas_to_dict["PointsId"][int(k[1])], + nas_to_dict["PointsId"][int(k[2])], + ] tri.sort() tri = tuple(tri) nas_to_dict["Solids"][el_id].append(tri) @@ -1040,20 +1071,33 @@ def _write_solid_stl(triangle, nas_to_dict): if line_type == "CTETRA*": for k in list(combinations(n, 3)): - tri = [int(k[0]), int(k[1]), int(k[2])] + # tri = [int(k[0]), int(k[1]), int(k[2])] + tri = [ + nas_to_dict["PointsId"][int(k[0])], + nas_to_dict["PointsId"][int(k[1])], + nas_to_dict["PointsId"][int(k[2])], + ] tri.sort() tri = tuple(tri) nas_to_dict["Solids"][el_id].append(tri) else: spli1 = [n[0], n[1], n[2], n[4]] for k in list(combinations(spli1, 3)): - tri = [int(k[0]), int(k[1]), int(k[2])] + tri = [ + nas_to_dict["PointsId"][int(k[0])], + nas_to_dict["PointsId"][int(k[1])], + nas_to_dict["PointsId"][int(k[2])], + ] tri.sort() tri = tuple(tri) nas_to_dict["Solids"][el_id].append(tri) spli1 = [n[0], n[2], n[3], n[4]] for k in list(combinations(spli1, 3)): - tri = [int(k[0]), int(k[1]), int(k[2])] + tri = [ + nas_to_dict["PointsId"][int(k[0])], + nas_to_dict["PointsId"][int(k[1])], + nas_to_dict["PointsId"][int(k[2])], + ] tri.sort() tri = tuple(tri) nas_to_dict["Solids"][el_id].append(tri) @@ -1063,48 +1107,107 @@ def _write_solid_stl(triangle, nas_to_dict): n1 = int(line[24:32]) n2 = int(line[32:40]) if obj_id in nas_to_dict["Lines"]: - nas_to_dict["Lines"][obj_id].append([n1, n2]) + nas_to_dict["Lines"][obj_id].append( + [nas_to_dict["PointsId"][int(n1)], nas_to_dict["PointsId"][int(n2)]] + ) else: - nas_to_dict["Lines"][obj_id] = [[n1, n2]] + nas_to_dict["Lines"][obj_id] = [ + [nas_to_dict["PointsId"][int(n1)], nas_to_dict["PointsId"][int(n2)]] + ] - self.logger.info_timer("File loaded") + self.logger.info("File loaded") objs_before = [i for i in self.object_names] + if nas_to_dict["Triangles"] or nas_to_dict["Solids"] or nas_to_dict["Lines"]: - self.logger.reset_timer() self.logger.info("Creating STL file with detected faces") - f = open(os.path.join(self._app.working_directory, self._app.design_name + "_test.stl"), "w") + output_stl = "" + if nas_to_dict["Triangles"]: + output_stl = os.path.join(self._app.working_directory, self._app.design_name + "_tria.stl") + f = open(output_stl, "w") + + def decimate(points_in, faces_in, points_out, faces_out): + if 0 < decimation < 1: + aggressivity = 3 + if 0.7 > decimation > 0.3: + aggressivity = 5 + elif decimation >= 0.7: + aggressivity = 7 + points_out, faces_out = fast_simplification.simplify( + points_in, faces_in, decimation, agg=aggressivity + ) + + return points_out, faces_out + for tri_id, triangles in nas_to_dict["Triangles"].items(): + tri_out = triangles + p_out = nas_to_dict["Points"][::] + if decimation > 0 and len(triangles) > 20: + try: + import fast_simplification + + p_out, tri_out = decimate(nas_to_dict["Points"], tri_out, p_out, tri_out) + except Exception: + self.logger.error("Package fast-decimation is needed to perform model simplification.") + self.logger.error("Please install it using pip.") f.write("solid Sheet_{}\n".format(tri_id)) - for triangle in triangles: - _write_solid_stl(triangle, nas_to_dict) + + for triangle in tri_out: + _write_solid_stl(triangle, p_out) f.write("endsolid\n") + if nas_to_dict["Triangles"]: + f.close() + output_solid = "" + if nas_to_dict["Solids"]: + output_solid = os.path.join(self._app.working_directory, self._app.design_name + "_solids.stl") + f = open(output_solid, "w") for solidid, solid_triangles in nas_to_dict["Solids"].items(): f.write("solid Solid_{}\n".format(solidid)) import pandas as pd df = pd.Series(solid_triangles) - undulicated_values = df.drop_duplicates(keep=False).to_list() - for triangle in undulicated_values: - _write_solid_stl(triangle, nas_to_dict) + tri_out = df.drop_duplicates(keep=False).to_list() + p_out = nas_to_dict["Points"][::] + if decimation > 0 and len(solid_triangles) > 20: + try: + import fast_simplification + + p_out, tri_out = decimate(nas_to_dict["Points"], tri_out, p_out, tri_out) + except Exception: + self.logger.error("Package fast-decimation is needed to perform model simplification.") + self.logger.error("Please install it using pip.") + for triangle in tri_out: + _write_solid_stl(triangle, p_out) f.write("endsolid\n") - f.close() - self.logger.info_timer("STL file created") + if output_solid: + f.close() + self.logger.info("STL file created") self._app.odesktop.CloseAllWindows() - self.logger.reset_timer() self.logger.info("Importing STL in 3D Modeler") - self.import_3d_cad( - os.path.join(self._app.working_directory, self._app.design_name + "_test.stl"), - create_lightweigth_part=import_as_light_weight, - ) - for el in nas_to_dict["Solids"].keys(): - obj_names = [i for i in self.object_names if i.startswith("Solid_{}".format(el))] - self.create_group(obj_names, group_name=str(el)) - for el in nas_to_dict["Triangles"].keys(): - obj_names = [i for i in self.object_names if i.startswith("Sheet_{}".format(el))] - self.create_group(obj_names, group_name=str(el)) - self.logger.info_timer("Model imported") - - if import_lines: + if output_stl: + self.import_3d_cad( + output_stl, + create_lightweigth_part=import_as_light_weight, + healing=False, + ) + if output_solid: + self.import_3d_cad( + output_solid, + create_lightweigth_part=import_as_light_weight, + healing=False, + ) + self.logger.info("Model imported") + + if group_parts: + for el in nas_to_dict["Solids"].keys(): + obj_names = [i for i in self.object_names if i.startswith("Solid_{}".format(el))] + self.create_group(obj_names, group_name=str(el)) + objs = self.object_names[::] + for el in nas_to_dict["Triangles"].keys(): + obj_names = [i for i in objs if i == "Sheet_{}".format(el) or i.startswith("Sheet_{}_".format(el))] + self.create_group(obj_names, group_name=str(el)) + self.logger.info("Parts grouped") + + if import_lines and nas_to_dict["Lines"]: for line_name, lines in nas_to_dict["Lines"].items(): if lines_thickness: self._app["x_section_{}".format(line_name)] = lines_thickness @@ -1133,10 +1236,13 @@ def _write_solid_stl(triangle, nas_to_dict): out_poly = self.unite(polys, purge=not lines_thickness) if not lines_thickness and out_poly: self.generate_object_history(out_poly) + self.logger.info("Lines imported") objs_after = [i for i in self.object_names] new_objects = [self[i] for i in objs_after if i not in objs_before] self._app.oproject.SetActiveDesign(self._app.design_name) + self._app.odesktop.EnableAutoSave(autosave) + self.logger.info_timer("Nastran model correctly imported.") return new_objects @pyaedt_function_handler() diff --git a/pyaedt/modules/solutions.py b/pyaedt/modules/solutions.py index 41cd7c3434d..8e798b5c0b0 100644 --- a/pyaedt/modules/solutions.py +++ b/pyaedt/modules/solutions.py @@ -7,6 +7,7 @@ import shutil import sys import time +import warnings from pyaedt import is_ironpython from pyaedt import pyaedt_function_handler @@ -50,6 +51,46 @@ plt = None +def simplify_stl(input_file, output_file=None, decimation=0.5, aggressiveness=7): + """Import and simplify a stl file using pyvista and fast-simplification. + + Parameters + ---------- + input_file : str + Input stl file. + output_file : str, optional + Output stl file. + decimation : float, optional + Fraction of the original mesh to remove before creating the stl file. If set to ``0.9``, + this function will try to reduce the data set to 10% of its + original size and will remove 90% of the input triangles. + aggressiveness : int, optional + Controls how aggressively to decimate the mesh. A value of 10 + will result in a fast decimation at the expense of mesh + quality and shape. A value of 0 will attempt to preserve the + original mesh geometry at the expense of time. Setting a low + value may result in being unable to reach the + ``decimation``. + + Returns + ------- + str + Full path to output stl. + """ + try: + import fast_simplification + except Exception: + warnings.warn("Package fast-decimation is needed to perform model simplification.") + warnings.warn("Please install it using pip.") + return False + mesh = pv.read(input_file) + if not output_file: + output_file = os.path.splitext(input_file)[0] + "_output.stl" + simple = fast_simplification.simplify_mesh(mesh, target_reduction=decimation, agg=aggressiveness, verbose=True) + simple.save(output_file) + return output_file + + class SolutionData(object): """Contains information from the :func:`GetSolutionDataPerVariation` method.""" diff --git a/pyaedt/workflows/project/import_nastran.py b/pyaedt/workflows/project/import_nastran.py index b9e9f4abebf..b0a164423f2 100644 --- a/pyaedt/workflows/project/import_nastran.py +++ b/pyaedt/workflows/project/import_nastran.py @@ -1,39 +1,128 @@ import os.path +from tkinter import Button +from tkinter import Checkbutton # import filedialog module +from tkinter import END +from tkinter import IntVar +from tkinter import Label +from tkinter import StringVar +from tkinter import Text +from tkinter import Tk from tkinter import filedialog +from tkinter import mainloop +from tkinter import ttk + +import PIL.Image +import PIL.ImageTk from pyaedt import Desktop from pyaedt import get_pyaedt_app +import pyaedt.workflows + +decimate = 0.0 + +lightweight = False +file_path = "" + + +def browse_nastran(): + # Function for opening the + # file explorer window + + master = Tk() + + master.geometry("700x200") + + master.title("Import Nastran or STL file") + # Load the logo for the main window + icon_path = os.path.join(pyaedt.workflows.__path__[0], "images", "large", "logo.png") + im = PIL.Image.open(icon_path) + photo = PIL.ImageTk.PhotoImage(im) -# Function for opening the -# file explorer window -def browseFiles(): - filename = filedialog.askopenfilename( - initialdir="/", title="Select a File", filetypes=(("Nastran files", "*.nas*"), ("all files", "*.*")) - ) + # Set the icon for the main window + master.iconphoto(True, photo) - # Change label contents - return filename + # Configure style for ttk buttons + style = ttk.Style() + style.configure("Toolbutton.TButton", padding=6, font=("Helvetica", 8)) + var = StringVar() + label = Label(master, textvariable=var) + var.set("Decimation factor (0-0.9). It may affect results:") + label.grid(row=0, column=0, pady=10) + check = Text(master, width=20, height=1) # Set the width of the combobox + check.insert(END, "0.0") + check.grid(row=0, column=1, pady=10, padx=5) + var = StringVar() + label = Label(master, textvariable=var) + var.set("Import as lightweight (only HFSS):") + label.grid(row=1, column=0, pady=10) + light = IntVar() + check2 = Checkbutton(master, width=30, variable=light) # Set the width of the combobox + check2.grid(row=1, column=1, pady=10, padx=5) + var2 = StringVar() + label2 = Label(master, textvariable=var2) + var2.set("Browse file:") + label2.grid(row=2, column=0, pady=10) + text = Text(master, width=40, height=1) + text.grid(row=2, column=1, pady=10, padx=5) -nas_input = browseFiles() + def browseFiles(): + filename = filedialog.askopenfilename( + initialdir="/", + title="Select a Nastran or stl File", + filetypes=(("Nastran", "*.nas"), ("STL", "*.stl"), ("all files", "*.*")), + ) + text.insert(END, filename) + # # Change label contents + # return filename + + b1 = Button(master, text="...", width=10, command=browseFiles) + b1.grid(row=3, column=0) + # b1.pack(pady=10) + b1.grid(row=2, column=2, pady=10) + + def callback(): + global lightweight, decimate, file_path + decimate = float(check.get("1.0", END).strip()) + lightweight = True if light.get() == 1 else False + file_path = text.get("1.0", END).strip() + master.destroy() + return True + + b = Button(master, text="Ok", width=40, command=callback) + # b.pack(pady=10) + b.grid(row=3, column=1, pady=10) + + mainloop() + + +browse_nastran() + +# nas_input = browseFiles() if "PYAEDT_SCRIPT_PORT" in os.environ and "PYAEDT_SCRIPT_VERSION" in os.environ: port = int(os.environ["PYAEDT_SCRIPT_PORT"]) version = os.environ["PYAEDT_SCRIPT_VERSION"] else: port = 0 version = "2024.1" -if os.path.exists(nas_input): +if os.path.exists(file_path): with Desktop(new_desktop_session=False, close_on_exit=False, specified_version=version, port=port) as d: proj = d.active_project() des = d.active_design() projname = proj.GetName() desname = des.GetName() app = get_pyaedt_app(projname, desname) - app.modeler.import_nastran(nas_input) + if file_path.endswith(".nas"): + app.modeler.import_nastran(file_path, import_as_light_weight=lightweight, decimation=decimate) + else: + from pyaedt.modules.solutions import simplify_stl + + outfile = simplify_stl(file_path, decimation=decimate, aggressiveness=5) + app.modeler.import_3d_cad(outfile, healing=False, create_lightweigth_part=lightweight) d.logger.info("Nastran imported correctly.") else: with Desktop(new_desktop_session=False, close_on_exit=False, specified_version=version, port=port) as d: - d.odesktop.AddMessage("", "", 3, "Wrong file selected. Select a .nas file") + d.odesktop.AddMessage("", "", 3, "Wrong file selected. Select a .nas or .stl file") diff --git a/pyaedt/workflows/project/toolkits_catalog.toml b/pyaedt/workflows/project/toolkits_catalog.toml index 0706b1685e5..4cbdc4cd4b9 100644 --- a/pyaedt/workflows/project/toolkits_catalog.toml +++ b/pyaedt/workflows/project/toolkits_catalog.toml @@ -6,7 +6,7 @@ template = "Run_PyAEDT_Toolkit_Script" pip = "" [ImportNastran] -name = "Import Nastran" +name = "Import Nastran/STL" script = "import_nastran.py" icon = "images/large/cad3d.png" template = "Run_PyAEDT_Toolkit_Script" diff --git a/pyproject.toml b/pyproject.toml index 80a1e6da159..f9db3bd3ae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ tests = [ "pytest-cov>=4.0.0,<5.1", "pytest-xdist>=3.5.0,<3.7", "pyvista>=0.38.0,<0.44", + "fast-simplification>=0.1.7", "scikit-learn>=1.0.0,<1.5", "scikit-rf>=0.30.0,<1.1", "SRTM.py", @@ -79,6 +80,7 @@ doc = [ "pypandoc>=1.10.0,<1.14", #"pytest-sphinx", "pyvista>=0.38.0,<0.44", + "fast-simplification>=0.1.7", "recommonmark", #"scikit-learn", "scikit-rf>=0.30.0,<1.1", @@ -123,6 +125,7 @@ all = [ "osmnx>=1.1.0,<1.10", "pandas>=1.1.0,<2.3", "pyvista>=0.38.0,<0.44", + "fast-simplification>=0.1.7", "scikit-rf>=0.30.0,<1.1", "SRTM.py", "utm",