Skip to content

Commit 69c4690

Browse files
authored
Merge branch 'main' into feat/flux_lines_plot
2 parents 08f021f + 55a959e commit 69c4690

File tree

4 files changed

+164
-97
lines changed

4 files changed

+164
-97
lines changed

doc/changelog.d/6459.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Observe specified output path for saving extension results (and minor improvements)

src/ansys/aedt/core/extensions/project/points_cloud.py

Lines changed: 122 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
from pathlib import Path
2828
import tkinter
2929
from tkinter import filedialog
30+
from tkinter import font
3031
from tkinter import ttk
3132
from typing import Union
3233

3334
import ansys.aedt.core
3435
from ansys.aedt.core import get_pyaedt_app
36+
from ansys.aedt.core.extensions.misc import DEFAULT_PADDING
3537
from ansys.aedt.core.extensions.misc import ExtensionCommon
3638
from ansys.aedt.core.extensions.misc import ExtensionCommonData
3739
from ansys.aedt.core.extensions.misc import ExtensionProjectCommon
@@ -52,6 +54,7 @@
5254
# Extension batch arguments
5355
EXTENSION_DEFAULT_ARGUMENTS = {"choice": "", "points": 1000, "output_file": ""}
5456
EXTENSION_TITLE = "Point cloud generator"
57+
EXTENSION_NB_COLUMN = 3
5558

5659

5760
@dataclass
@@ -73,113 +76,135 @@ def __init__(self, withdraw: bool = False):
7376
theme_color="light",
7477
withdraw=withdraw,
7578
add_custom_content=False,
76-
toggle_row=3, # TODO: Adapt position of theme button
77-
toggle_column=2,
7879
)
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()
8984

9085
# Trigger manually since add_extension_content requires loading info from current project first
9186
self.add_extension_content()
9287

93-
def load_aedt_info(self):
88+
def __load_aedt_info(self):
9489
"""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")
9792

98-
if not aedt_solids and not aedt_sheets:
93+
if not solids and not sheets:
9994
self.release_desktop()
10095
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
10398

10499
def add_extension_content(self):
105100
"""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)
109119
# List all objects and surfaces available
110120
entries = []
111-
if self._aedt_solids:
121+
if self.__aedt_solids:
112122
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:
115125
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")
118130
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(
122132
objects_list_frame,
123133
selectmode=tkinter.MULTIPLE,
124134
justify=tkinter.CENTER,
125135
exportselection=False,
126136
height=listbox_height,
127137
)
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"])
137138
# 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(
141141
background=self.theme.light["widget_bg"], foreground=self.theme.light["text"], font=self.theme.default_font
142142
)
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
153169

154170
# 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",
161180
)
181+
self._widgets["output_file_entry"] = output_file_entry
162182

163183
def browse_output_location():
164184
"""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+
165190
filename = filedialog.asksaveasfilename(
166191
initialdir="/",
167192
title="Select output file",
168193
defaultextension=".pts",
169194
filetypes=(("Points file", ".pts"), ("all files", "*.*")),
170195
)
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")
172198

173199
# Output file button
174200
output_file_button = ttk.Button(
175-
self.root,
201+
input_frame,
176202
text="Save as...",
177-
width=20,
178203
command=browse_output_location,
179204
style="PyAEDT.TButton",
180205
name="browse_output",
181206
)
182-
output_file_button.grid(row=2, column=2, padx=0)
207+
output_file_button.grid(row=2, column=2, **DEFAULT_PADDING)
183208

184209
@graphics_required
185210
def preview():
@@ -193,19 +218,24 @@ def preview():
193218

194219
# Visualize the point cloud
195220
plotter = pv.Plotter()
196-
for _, actor in point_cloud.values():
221+
for file, actor in point_cloud.values():
197222
plotter.add_mesh(actor, color="white", point_size=5, render_points_as_spheres=True)
223+
Path.unlink(file) # Delete .pts file
198224
plotter.show()
199225

200-
except Exception as e:
226+
except Exception as e: # pragma: no cover
201227
self.release_desktop()
202228
raise AEDTRuntimeError(str(e))
203229

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+
204234
# Preview button
205235
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"
207237
)
208-
preview_button.grid(row=3, column=0, pady=10, padx=10)
238+
preview_button.grid(row=0, column=0, **DEFAULT_PADDING)
209239

210240
def callback(extension: PointsCloudExtension):
211241
"""Collect extension data."""
@@ -220,31 +250,33 @@ def callback(extension: PointsCloudExtension):
220250

221251
# Generate button
222252
generate_button = ttk.Button(
223-
self.root,
253+
buttons_frame,
224254
text="Generate",
225-
width=40,
226255
command=lambda: callback(self),
227256
style="PyAEDT.TButton",
228257
name="generate",
229258
)
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)
231263

232264
def check_and_format_extension_data(self):
233265
"""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()]
235267
if not selected_objects or any(
236268
element in selected_objects for element in ["--- Objects ---", "--- Surfaces ---", ""]
237269
):
238270
self.release_desktop()
239271
raise AEDTRuntimeError("Please select a valid object or surface.")
240272

241-
points = self.points_entry.get("1.0", tkinter.END).strip()
273+
points = self._widgets["points_entry"].get("1.0", tkinter.END).strip()
242274
num_points = int(points)
243275
if num_points <= 0:
244276
self.release_desktop()
245277
raise AEDTRuntimeError("Number of points must be greater than zero.")
246278

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()
248280
if not Path(output_file).parent.exists():
249281
self.release_desktop()
250282
raise AEDTRuntimeError("Path to the specified output file does not exist.")
@@ -299,8 +331,8 @@ def main(data: PointsCloudExtensionData):
299331

300332
try:
301333
# 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
304336
app.release_desktop(False, False)
305337
raise AEDTRuntimeError(str(e))
306338

@@ -314,19 +346,30 @@ def main(data: PointsCloudExtensionData):
314346
return str(point_cloud[list(point_cloud.keys())[0]][0])
315347

316348

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):
318350
"""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")
321358

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
323362
raise Exception("Object could not be exported.")
324363

325364
# 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
327366
model_plotter = ModelPlotter()
328367
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"))
330373

331374
return point_cloud
332375

tests/system/extensions/test_points_cloud.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def test_point_cloud_extension_logic(aedt_app, local_scratch):
4545
# Define point cloud extension data to use for call to main
4646
data = PointsCloudExtensionData(choice="Torus1", points=1000, output_file=local_scratch.path)
4747

48-
assert Path(main(data)) == Path(local_scratch.path).parent / "Model_AllObjs_AllMats.pts"
48+
assert Path(main(data)) == Path(local_scratch.path) / "Torus1.pts"
4949

5050

5151
def test_point_cloud_exceptions(aedt_app, add_app, local_scratch):

0 commit comments

Comments
 (0)