27
27
from pathlib import Path
28
28
import tkinter
29
29
from tkinter import filedialog
30
+ from tkinter import font
30
31
from tkinter import ttk
31
32
from typing import Union
32
33
33
34
import ansys .aedt .core
34
35
from ansys .aedt .core import get_pyaedt_app
36
+ from ansys .aedt .core .extensions .misc import DEFAULT_PADDING
35
37
from ansys .aedt .core .extensions .misc import ExtensionCommon
36
38
from ansys .aedt .core .extensions .misc import ExtensionCommonData
37
39
from ansys .aedt .core .extensions .misc import ExtensionProjectCommon
52
54
# Extension batch arguments
53
55
EXTENSION_DEFAULT_ARGUMENTS = {"choice" : "" , "points" : 1000 , "output_file" : "" }
54
56
EXTENSION_TITLE = "Point cloud generator"
57
+ EXTENSION_NB_COLUMN = 3
55
58
56
59
57
60
@dataclass
@@ -73,113 +76,135 @@ def __init__(self, withdraw: bool = False):
73
76
theme_color = "light" ,
74
77
withdraw = withdraw ,
75
78
add_custom_content = False ,
76
- toggle_row = 3 , # TODO: Adapt position of theme button
77
- toggle_column = 2 ,
78
79
)
79
- # Add private attributes and initialize them through load_aedt_info
80
- self ._aedt_solids = None
81
- self ._aedt_sheets = None
82
- self .load_aedt_info ()
83
-
84
- # Tkinter widgets
85
- self .objects_list_lb = None
86
- self .scroll_bar = None
87
- self .points_entry = None
88
- self .output_file_entry = None
80
+ # Add private attributes and initialize them through __load_aedt_info
81
+ self .__aedt_solids = None
82
+ self .__aedt_sheets = None
83
+ self .__load_aedt_info ()
89
84
90
85
# Trigger manually since add_extension_content requires loading info from current project first
91
86
self .add_extension_content ()
92
87
93
- def load_aedt_info (self ):
88
+ def __load_aedt_info (self ):
94
89
"""Load info."""
95
- aedt_solids = self .aedt_application .modeler .get_objects_in_group ("Solids" )
96
- aedt_sheets = self .aedt_application .modeler .get_objects_in_group ("Sheets" )
90
+ solids = self .aedt_application .modeler .get_objects_in_group ("Solids" )
91
+ sheets = self .aedt_application .modeler .get_objects_in_group ("Sheets" )
97
92
98
- if not aedt_solids and not aedt_sheets :
93
+ if not solids and not sheets :
99
94
self .release_desktop ()
100
95
raise AEDTRuntimeError ("No solids or sheets are defined in this design." )
101
- self ._aedt_solids = aedt_solids
102
- self ._aedt_sheets = aedt_sheets
96
+ self .__aedt_solids = solids
97
+ self .__aedt_sheets = sheets
103
98
104
99
def add_extension_content (self ):
105
100
"""Add custom content to the extension UI."""
106
- # Dropdown menu for objects and surfaces
107
- objects_surface_label = ttk .Label (self .root , text = "Select Object or Surface:" , width = 20 , style = "PyAEDT.TLabel" )
108
- objects_surface_label .grid (row = 0 , column = 0 , pady = 10 )
101
+ # Upper frame of the extension GUI with widgets receiving user inputs
102
+ input_frame = ttk .Frame (self .root , style = "PyAEDT.TFrame" , name = "input_frame" )
103
+ input_frame .grid (row = 0 , column = 0 , columnspan = EXTENSION_NB_COLUMN )
104
+
105
+ # Points entry - Defined first for geometry management of the tkinter.Listbox above it in GUI
106
+ points_label = ttk .Label (input_frame , width = 20 , text = "Number of Points:" , style = "PyAEDT.TLabel" )
107
+ points_label .grid (row = 1 , column = 0 , ** DEFAULT_PADDING )
108
+ points_entry = tkinter .Text (input_frame , width = 30 , height = 1 )
109
+ points_entry .insert (tkinter .END , "1000" )
110
+ points_entry .grid (row = 1 , column = 1 , ** DEFAULT_PADDING )
111
+ points_entry .configure (
112
+ bg = self .theme .light ["pane_bg" ], foreground = self .theme .light ["text" ], font = self .theme .default_font
113
+ )
114
+ self ._widgets ["points_entry" ] = points_entry
115
+
116
+ # Listbox for objects and surfaces
117
+ objects_label = ttk .Label (input_frame , width = 20 , text = "Select Object or Surface:" , style = "PyAEDT.TLabel" )
118
+ objects_label .grid (row = 0 , column = 0 , ** DEFAULT_PADDING )
109
119
# List all objects and surfaces available
110
120
entries = []
111
- if self ._aedt_solids :
121
+ if self .__aedt_solids :
112
122
entries .append ("--- Objects ---" )
113
- entries .extend (self ._aedt_solids )
114
- if self ._aedt_sheets :
123
+ entries .extend (self .__aedt_solids )
124
+ if self .__aedt_sheets :
115
125
entries .append ("--- Surfaces ---" )
116
- entries .extend (self ._aedt_sheets )
117
- # Create the ListBox
126
+ entries .extend (self .__aedt_sheets )
127
+ # Create the ListBox inside a sub-frame to solve conflict between .grid and .pack methods in GUI
128
+ objects_list_frame = tkinter .Frame (input_frame , width = 20 )
129
+ objects_list_frame .grid (row = 0 , column = 1 , ** DEFAULT_PADDING , sticky = "ew" )
118
130
listbox_height = min (len (entries ), 6 )
119
- objects_list_frame = tkinter .Frame (self .root , width = 20 )
120
- objects_list_frame .grid (row = 0 , column = 1 , pady = 10 , padx = 10 , sticky = "ew" )
121
- self .objects_list_lb = tkinter .Listbox (
131
+ objects_list = tkinter .Listbox (
122
132
objects_list_frame ,
123
133
selectmode = tkinter .MULTIPLE ,
124
134
justify = tkinter .CENTER ,
125
135
exportselection = False ,
126
136
height = listbox_height ,
127
137
)
128
- self .objects_list_lb .pack (expand = True , fill = tkinter .BOTH , side = tkinter .LEFT )
129
- # Add scrollbar if more than 6 elements are to be displayed
130
- if len (entries ) > 6 :
131
- self .scroll_bar = tkinter .Scrollbar (
132
- objects_list_frame , orient = tkinter .VERTICAL , command = self .objects_list_lb .yview
133
- )
134
- self .scroll_bar .pack (side = tkinter .RIGHT , fill = tkinter .Y )
135
- self .objects_list_lb .config (yscrollcommand = self .scroll_bar .set , height = listbox_height )
136
- self .scroll_bar .configure (background = self .theme .light ["widget_bg" ])
137
138
# Populate the Listbox
138
- for obj in entries :
139
- self .objects_list_lb .insert (tkinter .END , obj )
140
- self .objects_list_lb .configure (
139
+ objects_list .insert (tkinter .END , * entries )
140
+ objects_list .configure (
141
141
background = self .theme .light ["widget_bg" ], foreground = self .theme .light ["text" ], font = self .theme .default_font
142
142
)
143
-
144
- # Points entry
145
- points_label = ttk .Label (self .root , text = "Number of Points:" , width = 20 , style = "PyAEDT.TLabel" )
146
- points_label .grid (row = 1 , column = 0 , padx = 15 , pady = 10 )
147
- self .points_entry = tkinter .Text (self .root , width = 40 , height = 1 )
148
- self .points_entry .insert (tkinter .END , "1000" )
149
- self .points_entry .grid (row = 1 , column = 1 , pady = 15 , padx = 10 )
150
- self .points_entry .configure (
151
- bg = self .theme .light ["pane_bg" ], foreground = self .theme .light ["text" ], font = self .theme .default_font
152
- )
143
+ # Add vertical scrollbar if more than 6 elements are to be displayed
144
+ if len (entries ) > 6 :
145
+ scroll_bar = tkinter .Scrollbar (objects_list_frame , orient = tkinter .VERTICAL , command = objects_list .yview )
146
+ objects_list .config (yscrollcommand = scroll_bar .set )
147
+ scroll_bar .configure (background = self .theme .light ["widget_bg" ])
148
+ scroll_bar .pack (side = tkinter .RIGHT , fill = tkinter .Y )
149
+ # Measure width of listbox with vertical scrollbar
150
+ self .root .update ()
151
+ pix_width_listbox = points_entry .winfo_width () - scroll_bar .winfo_width ()
152
+ else :
153
+ # Measure width of listbox without vertical scrollbar
154
+ self .root .update ()
155
+ pix_width_listbox = points_entry .winfo_width ()
156
+ # Measure width of listbox entries to determine if horizontal scrollbar is needed and add it if required
157
+ listbox_font = font .Font (font = objects_list .cget ("font" ))
158
+ entries_pix_width = [listbox_font .measure (entry ) for entry in entries ]
159
+ if max (entries_pix_width ) >= pix_width_listbox :
160
+ horiz_scroll_bar = tkinter .Scrollbar (
161
+ objects_list_frame , orient = tkinter .HORIZONTAL , command = objects_list .xview
162
+ )
163
+ objects_list .config (xscrollcommand = horiz_scroll_bar .set )
164
+ horiz_scroll_bar .configure (background = self .theme .light ["widget_bg" ])
165
+ horiz_scroll_bar .pack (side = tkinter .BOTTOM , fill = tkinter .X )
166
+ # Finally insert listbox - has to be done after both scrollbars
167
+ objects_list .pack (expand = True , fill = tkinter .BOTH , side = tkinter .LEFT )
168
+ self ._widgets ["objects_list" ] = objects_list
153
169
154
170
# Output file entry
155
- output_file_label = ttk .Label (self .root , text = "Output File:" , width = 20 , style = "PyAEDT.TLabel" )
156
- output_file_label .grid (row = 2 , column = 0 , padx = 15 , pady = 10 )
157
- self .output_file_entry = tkinter .Text (self .root , width = 40 , height = 1 , wrap = tkinter .WORD )
158
- self .output_file_entry .grid (row = 2 , column = 1 , pady = 15 , padx = 10 )
159
- self .output_file_entry .configure (
160
- bg = self .theme .light ["pane_bg" ], foreground = self .theme .light ["text" ], font = self .theme .default_font
171
+ output_file_label = ttk .Label (input_frame , width = 20 , text = "Output File:" , style = "PyAEDT.TLabel" )
172
+ output_file_label .grid (row = 2 , column = 0 , ** DEFAULT_PADDING )
173
+ output_file_entry = tkinter .Text (input_frame , width = 30 , height = 1 , wrap = tkinter .WORD )
174
+ output_file_entry .grid (row = 2 , column = 1 , ** DEFAULT_PADDING )
175
+ output_file_entry .configure (
176
+ bg = self .theme .light ["pane_bg" ],
177
+ foreground = self .theme .light ["text" ],
178
+ font = self .theme .default_font ,
179
+ state = "disabled" ,
161
180
)
181
+ self ._widgets ["output_file_entry" ] = output_file_entry
162
182
163
183
def browse_output_location ():
164
184
"""Define output file."""
185
+ self ._widgets ["output_file_entry" ].config (state = "normal" )
186
+ # Clear content if an output file is already provided
187
+ if self ._widgets ["output_file_entry" ].get ("1.0" , tkinter .END ).strip ():
188
+ self ._widgets ["output_file_entry" ].delete ("1.0" , tkinter .END )
189
+
165
190
filename = filedialog .asksaveasfilename (
166
191
initialdir = "/" ,
167
192
title = "Select output file" ,
168
193
defaultextension = ".pts" ,
169
194
filetypes = (("Points file" , ".pts" ), ("all files" , "*.*" )),
170
195
)
171
- self .output_file_entry .insert (tkinter .END , filename )
196
+ self ._widgets ["output_file_entry" ].insert (tkinter .END , filename )
197
+ self ._widgets ["output_file_entry" ].config (state = "disabled" )
172
198
173
199
# Output file button
174
200
output_file_button = ttk .Button (
175
- self . root ,
201
+ input_frame ,
176
202
text = "Save as..." ,
177
- width = 20 ,
178
203
command = browse_output_location ,
179
204
style = "PyAEDT.TButton" ,
180
205
name = "browse_output" ,
181
206
)
182
- output_file_button .grid (row = 2 , column = 2 , padx = 0 )
207
+ output_file_button .grid (row = 2 , column = 2 , ** DEFAULT_PADDING )
183
208
184
209
@graphics_required
185
210
def preview ():
@@ -193,19 +218,24 @@ def preview():
193
218
194
219
# Visualize the point cloud
195
220
plotter = pv .Plotter ()
196
- for _ , actor in point_cloud .values ():
221
+ for file , actor in point_cloud .values ():
197
222
plotter .add_mesh (actor , color = "white" , point_size = 5 , render_points_as_spheres = True )
223
+ Path .unlink (file ) # Delete .pts file
198
224
plotter .show ()
199
225
200
- except Exception as e :
226
+ except Exception as e : # pragma: no cover
201
227
self .release_desktop ()
202
228
raise AEDTRuntimeError (str (e ))
203
229
230
+ # Lower frame of the extension GUI with 3 buttons
231
+ buttons_frame = ttk .Frame (self .root , style = "PyAEDT.TFrame" , name = "buttons_frame" )
232
+ buttons_frame .grid (row = 1 , column = 0 , columnspan = EXTENSION_NB_COLUMN )
233
+
204
234
# Preview button
205
235
preview_button = ttk .Button (
206
- self . root , text = "Preview" , width = 40 , command = preview , style = "PyAEDT.TButton" , name = "preview"
236
+ buttons_frame , text = "Preview" , command = preview , style = "PyAEDT.TButton" , name = "preview"
207
237
)
208
- preview_button .grid (row = 3 , column = 0 , pady = 10 , padx = 10 )
238
+ preview_button .grid (row = 0 , column = 0 , ** DEFAULT_PADDING )
209
239
210
240
def callback (extension : PointsCloudExtension ):
211
241
"""Collect extension data."""
@@ -220,31 +250,33 @@ def callback(extension: PointsCloudExtension):
220
250
221
251
# Generate button
222
252
generate_button = ttk .Button (
223
- self . root ,
253
+ buttons_frame ,
224
254
text = "Generate" ,
225
- width = 40 ,
226
255
command = lambda : callback (self ),
227
256
style = "PyAEDT.TButton" ,
228
257
name = "generate" ,
229
258
)
230
- generate_button .grid (row = 3 , column = 1 , pady = 10 , padx = 10 )
259
+ generate_button .grid (row = 0 , column = 1 , ** DEFAULT_PADDING )
260
+
261
+ # Toggle theme button
262
+ self .add_toggle_theme_button (buttons_frame , 0 , 2 )
231
263
232
264
def check_and_format_extension_data (self ):
233
265
"""Perform checks and formatting on extension input data."""
234
- selected_objects = [self .objects_list_lb .get (i ) for i in self .objects_list_lb .curselection ()]
266
+ selected_objects = [self ._widgets [ "objects_list" ] .get (i ) for i in self ._widgets [ "objects_list" ] .curselection ()]
235
267
if not selected_objects or any (
236
268
element in selected_objects for element in ["--- Objects ---" , "--- Surfaces ---" , "" ]
237
269
):
238
270
self .release_desktop ()
239
271
raise AEDTRuntimeError ("Please select a valid object or surface." )
240
272
241
- points = self .points_entry .get ("1.0" , tkinter .END ).strip ()
273
+ points = self ._widgets [ " points_entry" ] .get ("1.0" , tkinter .END ).strip ()
242
274
num_points = int (points )
243
275
if num_points <= 0 :
244
276
self .release_desktop ()
245
277
raise AEDTRuntimeError ("Number of points must be greater than zero." )
246
278
247
- output_file = self .output_file_entry .get ("1.0" , tkinter .END ).strip ()
279
+ output_file = self ._widgets [ " output_file_entry" ] .get ("1.0" , tkinter .END ).strip ()
248
280
if not Path (output_file ).parent .exists ():
249
281
self .release_desktop ()
250
282
raise AEDTRuntimeError ("Path to the specified output file does not exist." )
@@ -299,8 +331,8 @@ def main(data: PointsCloudExtensionData):
299
331
300
332
try :
301
333
# Generate point cloud
302
- point_cloud = generate_point_cloud (aedtapp , assignment , points , Path ( output_file ). parent )
303
- except Exception as e :
334
+ point_cloud = generate_point_cloud (aedtapp , assignment , points , output_file )
335
+ except Exception as e : # pragma: no cover
304
336
app .release_desktop (False , False )
305
337
raise AEDTRuntimeError (str (e ))
306
338
@@ -314,19 +346,30 @@ def main(data: PointsCloudExtensionData):
314
346
return str (point_cloud [list (point_cloud .keys ())[0 ]][0 ])
315
347
316
348
317
- def generate_point_cloud (aedtapp , selected_objects , num_points , export_path = None ):
349
+ def generate_point_cloud (aedtapp , selected_objects , num_points , output_file = None ):
318
350
"""Generate point cloud from selected objects"""
319
- # Export the mesh
320
- output_file = aedtapp .post .export_model_obj (assignment = selected_objects , export_path = export_path )
351
+ # Export the mesh (export_model_obj expects a file name with the .obj extension passed as a str)
352
+ if not output_file or Path (output_file ).is_dir ():
353
+ file_name = "_" .join (selected_objects ) + ".obj"
354
+ export_path = Path (aedtapp .working_directory ) if not output_file else Path (output_file )
355
+ output_file = export_path / file_name
356
+ else :
357
+ output_file = Path (output_file ).with_suffix (".obj" )
321
358
322
- if not output_file or not Path (output_file [0 ][0 ]).is_file ():
359
+ export_model = aedtapp .post .export_model_obj (assignment = selected_objects , export_path = str (output_file ))
360
+
361
+ if not export_model or not Path (export_model [0 ][0 ]).is_file (): # pragma: no cover
323
362
raise Exception ("Object could not be exported." )
324
363
325
364
# Generate the point cloud
326
- geometry_file = output_file [0 ][0 ]
365
+ geometry_file = export_model [0 ][0 ] # The str path to the .obj file generated by the export_model_obj() method
327
366
model_plotter = ModelPlotter ()
328
367
model_plotter .add_object (geometry_file )
329
- point_cloud = model_plotter .point_cloud (points = num_points )
368
+ point_cloud = model_plotter .point_cloud (points = num_points ) # Generates the .pts file
369
+
370
+ # Delete .mtl and .obj files generated by the export_model_obj() method
371
+ Path .unlink (output_file )
372
+ Path .unlink (output_file .with_suffix (".mtl" ))
330
373
331
374
return point_cloud
332
375
0 commit comments