Coverage for src/sdynpy/core/sdynpy_geometry.py: 15%

2880 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-18 21:32 +0000

1""" 

2Objects and procedures to handle operations on test or model geometries 

3 

4This module defines a Geometry object as well as all of the subcomponents of 

5a geometry object: nodes, elements, tracelines and coordinate system. Geometry 

6plotting is also handled in this module. 

7""" 

8 

9""" 

10Copyright 2022 National Technology & Engineering Solutions of Sandia, 

11LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S. 

12Government retains certain rights in this software. 

13 

14This program is free software: you can redistribute it and/or modify 

15it under the terms of the GNU General Public License as published by 

16the Free Software Foundation, either version 3 of the License, or 

17(at your option) any later version. 

18 

19This program is distributed in the hope that it will be useful, 

20but WITHOUT ANY WARRANTY; without even the implied warranty of 

21MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

22GNU General Public License for more details. 

23 

24You should have received a copy of the GNU General Public License 

25along with this program. If not, see <https://www.gnu.org/licenses/>. 

26""" 

27 

28import copy 

29import numpy as np 

30import pyvista as pv 

31import pyvistaqt as pvqt 

32import pyqtgraph as pg 

33import vtk 

34from qtpy import uic, QtCore 

35from qtpy.QtGui import QKeySequence 

36try: 

37 from qtpy.QtGui import QAction 

38except ImportError: 

39 from qtpy.QtWidgets import QAction 

40from qtpy.QtWidgets import QApplication, QMainWindow, QSizePolicy, QFileDialog, QMenu 

41from .sdynpy_array import SdynpyArray 

42from .sdynpy_colors import colormap, coord_colormap 

43from .sdynpy_coordinate import CoordinateArray, coordinate_array, from_nodelist 

44from ..signal_processing.sdynpy_rotation import R, lstsq_rigid_transform 

45from ..signal_processing.sdynpy_camera import point_on_pixel 

46from ..signal_processing.sdynpy_massprops import rigid_body_shape_scaling 

47from .sdynpy_matrix import matrix 

48import time 

49import os 

50from PIL import Image 

51from scipy.spatial import Delaunay 

52from scipy.linalg import block_diag 

53import pandas as pd 

54import openpyxl 

55import warnings 

56from scipy.io import savemat as scipy_savemat 

57 

58try: 

59 repr(pv.GPUInfo()) 

60 IGNORE_PLOTS = False 

61 # Set Qt Preferences 

62 

63 # Adding this to help out non-interactive consoles 

64 os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" 

65 if hasattr(QtCore.Qt, "AA_EnableHighDpiScaling"): # PyQt5 only 

66 QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) 

67 

68 if hasattr(QtCore.Qt, "AA_UseHighDpiPixmaps"): # PyQt5 only 

69 QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps) 

70 

71 QApplication.setHighDpiScaleFactorRoundingPolicy( 

72 QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough 

73 ) 

74 if QApplication.instance() is None: 

75 app = QApplication(['']) 

76 

77except RuntimeError: 

78 IGNORE_PLOTS = True 

79 print('No GPU Found, Geometry plotting will not work!') 

80 

81# Enumerations 

82 

83_cs_type_strings = {0: 'Cartesian', 

84 1: 'Polar', 

85 2: 'Spherical'} 

86 

87_element_types = { 

88 11: 'Rod', 

89 21: 'Linear beam', 

90 22: 'Tapered beam', 

91 23: 'Curved beam', 

92 24: 'Parabolic beam', 

93 31: 'Straight pipe', 

94 32: 'Curved pipe', 

95 41: 'Plane Stress Linear Triangle', 

96 42: 'Plane Stress Parabolic Triangle', 

97 43: 'Plane Stress Cubic Triangle', 

98 44: 'Plane Stress Linear Quadrilateral', 

99 45: 'Plane Stress Parabolic Quadrilateral', 

100 46: 'Plane Strain Cubic Quadrilateral', 

101 51: 'Plane Strain Linear Triangle', 

102 52: 'Plane Strain Parabolic Triangle', 

103 53: 'Plane Strain Cubic Triangle', 

104 54: 'Plane Strain Linear Quadrilateral', 

105 55: 'Plane Strain Parabolic Quadrilateral', 

106 56: 'Plane Strain Cubic Quadrilateral', 

107 61: 'Plate Linear Triangle', 

108 62: 'Plate Parabolic Triangle', 

109 63: 'Plate Cubic Triangle', 

110 64: 'Plate Linear Quadrilateral', 

111 65: 'Plate Parabolic Quadrilateral', 

112 66: 'Plate Cubic Quadrilateral', 

113 71: 'Membrane Linear Quadrilateral', 

114 72: 'Membrane Parabolic Triangle', 

115 73: 'Membrane Cubic Triangle', 

116 74: 'Membrane Linear Triangle', 

117 75: 'Membrane Parabolic Quadrilateral', 

118 76: 'Membrane Cubic Quadrilateral', 

119 81: 'Axisymetric Solid Linear Triangle', 

120 82: 'Axisymetric Solid Parabolic Triangle', 

121 84: 'Axisymetric Solid Linear Quadrilateral', 

122 85: 'Axisymetric Solid Parabolic Quadrilateral', 

123 91: 'Thin Shell Linear Triangle', 

124 92: 'Thin Shell Parabolic Triangle', 

125 93: 'Thin Shell Cubic Triangle', 

126 94: 'Thin Shell Linear Quadrilateral', 

127 95: 'Thin Shell Parabolic Quadrilateral', 

128 96: 'Thin Shell Cubic Quadrilateral', 

129 101: 'Thick Shell Linear Wedge', 

130 102: 'Thick Shell Parabolic Wedge', 

131 103: 'Thick Shell Cubic Wedge', 

132 104: 'Thick Shell Linear Brick', 

133 105: 'Thick Shell Parabolic Brick', 

134 106: 'Thick Shell Cubic Brick', 

135 111: 'Solid Linear Tetrahedron', 

136 112: 'Solid Linear Wedge', 

137 113: 'Solid Parabolic Wedge', 

138 114: 'Solid Cubic Wedge', 

139 115: 'Solid Linear Brick', 

140 116: 'Solid Parabolic Brick', 

141 117: 'Solid Cubic Brick', 

142 118: 'Solid Parabolic Tetrahedron', 

143 121: 'Rigid Bar', 

144 122: 'Rigid Element', 

145 136: 'Node To Node Translational Spring', 

146 137: 'Node To Node Rotational Spring', 

147 138: 'Node To Ground Translational Spring', 

148 139: 'Node To Ground Rotational Spring', 

149 141: 'Node To Node Damper', 

150 142: 'Node To Gound Damper', 

151 151: 'Node To Node Gap', 

152 152: 'Node To Ground Gap', 

153 161: 'Lumped Mass', 

154 171: 'Axisymetric Linear Shell', 

155 172: 'Axisymetric Parabolic Shell', 

156 181: 'Constraint', 

157 191: 'Plastic Cold Runner', 

158 192: 'Plastic Hot Runner', 

159 193: 'Plastic Water Line', 

160 194: 'Plastic Fountain', 

161 195: 'Plastic Baffle', 

162 196: 'Plastic Rod Heater', 

163 201: 'Linear node-to-node interface', 

164 202: 'Linear edge-to-edge interface', 

165 203: 'Parabolic edge-to-edge interface', 

166 204: 'Linear face-to-face interface', 

167 208: 'Parabolic face-to-face interface', 

168 212: 'Linear axisymmetric interface', 

169 213: 'Parabolic axisymmetric interface', 

170 221: 'Linear rigid surface', 

171 222: 'Parabolic rigid surface', 

172 231: 'Axisymetric linear rigid surface', 

173 232: 'Axisymentric parabolic rigid surface'} 

174 

175_exodus_elem_type_map = {'triangle':41, 

176 'quadrilateral':44, 

177 'hexahedral':115, 

178 'tet':111, 

179 'tetrahedral':111, 

180 'sphere':161, 

181 'spring':136, 

182 'bar':21, 

183 'bar2':21, 

184 'bar3':23, 

185 'beam':21, 

186 'beam2':21, 

187 'beam3':23, 

188 'truss':21, 

189 'truss2':21, 

190 'truss3':23, 

191 'quad':94, 

192 'quad4':94, 

193 'quad5':94, 

194 'quad8':95, 

195 'quad9':95, 

196 'shell':94, 

197 'shell4':94, 

198 'shell8':95, 

199 'shell9':95, 

200 'tri':91, 

201 'tri3':91, 

202 'tri6':92, 

203 'tri7':92, 

204 'trishell':91, 

205 'trishell3':91, 

206 'trishell6':92, 

207 'trishell7':92, 

208 'hex':115, 

209 'hex8':115, 

210 'hex9':116, 

211 'hex20':116, 

212 'hex27':117, 

213 'tetra':111, 

214 'tetra4':111, 

215 'tetra8':118, 

216 'tetra10':118, 

217 'tetra14':118, 

218 'wedge':112, 

219 'hexshell':116, 

220 # 'pyramid' 

221 # 'pyramid5' 

222 # 'pyramid8' 

223 # 'pyramid13' 

224 # 'pyramid18' 

225 # Add new elements here 

226 } 

227 

228_beam_elem_types = [11, 21, 22, 23, 24, 31, 32, 121] 

229_face_element_types = [v for v in range(41, 107)] 

230_solid_element_types = [v for v in range(111, 119)] 

231_vtk_element_map = { 

232 111: vtk.VTK_TETRA, 

233 112: vtk.VTK_WEDGE, 

234 113: vtk.VTK_QUADRATIC_WEDGE, 

235 115: vtk.VTK_HEXAHEDRON, 

236 116: vtk.VTK_QUADRATIC_HEXAHEDRON, 

237 118: vtk.VTK_QUADRATIC_TETRA, 

238} 

239 

240_vtk_connectivity_reorder = { 

241 vtk.VTK_QUADRATIC_HEXAHEDRON: [0, 1, 2, 3, 4, 5, 6, 

242 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15] 

243} 

244 

245MAX_NUMBER_REPR = 100 

246 

247 

248class GeometryPlotter(pvqt.BackgroundPlotter): 

249 """Class used to plot geometry 

250 

251 This class is essentially identical to PyVista's BackgroundPlotter; 

252 however a small amount of additional functionality is added.""" 

253 

254 def __init__(self, *args, **kwargs): 

255 super().__init__(*args, **kwargs) 

256 for action in self.main_menu.actions(): 

257 if action.menu() and action.text().lower() == 'view': 

258 view_menu = action.menu() 

259 break 

260 for action in view_menu.actions(): 

261 if action.menu() and action.text().lower() == 'orientation marker': 

262 orien_menu = action.menu() 

263 break 

264 orien_menu.addSeparator() 

265 orien_menu.addAction('Show Origin', self.show_origin_marker) 

266 orien_menu.addAction('Hide Origin', self.hide_origin_marker) 

267 self._origin_marker = None 

268 

269 def show_origin_marker(self, arrow_scale=0.05): 

270 if self._origin_marker is None: 

271 bbox_diagonal = self.length 

272 arrow_factor = bbox_diagonal * arrow_scale 

273 self._origin_marker = pv.tools.create_axes_marker(label_size=np.array([0.25, 0.1])*arrow_scale) 

274 self._origin_marker.SetTotalLength(arrow_factor, arrow_factor, arrow_factor) 

275 self.renderer.AddActor(self._origin_marker) 

276 self.renderer.Modified() 

277 

278 def hide_origin_marker(self): 

279 if self._origin_marker is not None: 

280 self.renderer.remove_actor(self._origin_marker) 

281 self.renderer.Modified() 

282 

283 def save_rotation_animation(self, filename=None, frames=20, frame_rate=20, 

284 individual_images=False): 

285 """ 

286 Saves an animation of the rotating geometry to a file 

287 

288 Parameters 

289 ---------- 

290 filename : str, optional 

291 Path to the file being saved. The default is None, which returns 

292 the images as an array rather than saves them to a file 

293 frames : int, optional 

294 Number of frames in the animation. The default is 20. 

295 frame_rate : float, optional 

296 Number of frames per second if the animation is saved to a GIF. 

297 The default is 20. 

298 individual_images : bool, optional 

299 Boolean to specify whether the images are saved as individual PNG 

300 files or a single GIF. The default is False. 

301 

302 Returns 

303 ------- 

304 imgs : np.ndarray or None 

305 Returns array of images if filename is None, otherwise returns None 

306 

307 """ 

308 imgs = [] 

309 phases = np.linspace(0, 2 * np.pi, frames, endpoint=False) 

310 focus = np.array(self.camera.focal_point) 

311 distance = np.array(self.camera.distance) 

312 initial_view_from = np.array(self.camera.position) - focus 

313 view_up = self.camera.up 

314 for phase in phases: 

315 # Compute a rotation matrix 

316 rotmat = R(view_up, phase) 

317 view_from = (rotmat @ initial_view_from[:, np.newaxis]).squeeze() 

318 self.camera.position = np.array(focus) + distance * \ 

319 np.array(view_from) / np.linalg.norm(view_from) 

320 self.camera.focal_point = focus 

321 self.camera.up = view_up 

322 self.render() 

323 # Force GUI Update to ensure the OS doesn't delay events 

324 QApplication.processEvents() 

325 imgs.append(self.screenshot()) 

326 if filename is None: 

327 return imgs 

328 else: 

329 if individual_images: 

330 for i, img in enumerate(imgs): 

331 fname = os.path.splitext(filename) 

332 fname = fname[0] + '-{:d}'.format(i) + fname[1] 

333 Image.fromarray(img).save(fname) 

334 else: 

335 imgs = [Image.fromarray(img).convert( 

336 'P', palette=Image.ADAPTIVE, colors=256) for img in imgs] 

337 imgs[0].save(fp=filename, format='GIF', append_images=imgs[1:], 

338 save_all=True, duration=int(1 / frame_rate * 1000), loop=0) 

339 

340 

341class TransientPlotter(GeometryPlotter): 

342 """Class used to plot transient deformations""" 

343 

344 def __init__(self, geometry, displacement_data, displacement_scale=1.0, 

345 frames_per_second=20, 

346 undeformed_opacity=0.0, deformed_opacity=1.0, plot_kwargs={}, 

347 transformation_shapes=None, num_curves=50, 

348 show: bool = True, 

349 app=None, 

350 window_size=None, 

351 off_screen=None, 

352 allow_quit_keypress=True, 

353 toolbar=True, 

354 menu_bar=True, 

355 editor=False, 

356 update_app_icon=None, 

357 **kwargs): 

358 """ 

359 Create a TransientPlotter object to plot displacements over time 

360 

361 Parameters 

362 ---------- 

363 geometry : Geometry 

364 Geometry on which the displacements will be plotted 

365 displacement_data : TimeHistoryArray 

366 Transient displacement data that will be applied 

367 displacement_scale : float, optional 

368 Scale factor applied to displacements. The default is 1.0. 

369 frames_per_second : float, optional 

370 Number of time steps to plot per second while the displacement is 

371 animating. Default is 20. 

372 undeformed_opacity : float, optional 

373 Opacity of the undeformed geometry. The default is 0.0, or 

374 completely transparent. 

375 deformed_opacity : float, optional 

376 Opacity of the deformed geometry. The default is 1.0, or completely 

377 opaque. 

378 plot_kwargs : dict, optional 

379 Keyword arguments passed to the Geometry.plot function 

380 transformation_shapes : ShapeArray 

381 Shape matrix that will be used to expand the data. Must be the 

382 same size as the `displacement_data` 

383 num_curves : int, optional 

384 Maximum number of curves to plot on the time selector. Default is 

385 50 

386 show : bool, optional 

387 Show the plotting window. If ``False``, show this window by 

388 running ``show()``. The default is True. 

389 app : QApplication, optional 

390 Creates a `QApplication` if left as `None`. The default is None. 

391 window_size : list of int, optional 

392 Window size in pixels. Defaults to ``[1024, 768]`` 

393 off_screen : TYPE, optional 

394 Renders off screen when True. Useful for automated 

395 screenshots or debug testing. The default is None. 

396 allow_quit_keypress : bool, optional 

397 Allow user to exit by pressing ``"q"``. The default is True. 

398 toolbar : bool, optional 

399 If True, display the default camera toolbar. Defaults to True. 

400 menu_bar : bool, optional 

401 If True, display the default main menu. Defaults to True. 

402 editor : TYPE, optional 

403 If True, display the VTK object editor. Defaults to False. 

404 update_app_icon : bool, optional 

405 If True, update_app_icon will be called automatically to update the 

406 Qt app icon based on the current rendering output. If None, the 

407 logo of PyVista will be used. If False, no icon will be set. 

408 Defaults to None. The default is None. 

409 title : str, optional 

410 Title of plotting window. 

411 multi_samples : int, optional 

412 The number of multi-samples used to mitigate aliasing. 4 is a 

413 good default but 8 will have better results with a potential 

414 impact on performance. 

415 line_smoothing : bool, optional 

416 If True, enable line smothing 

417 point_smoothing : bool, optional 

418 If True, enable point smothing 

419 polygon_smoothing : bool, optional 

420 If True, enable polygon smothing 

421 auto_update : float, bool, optional 

422 Automatic update rate in seconds. Useful for automatically 

423 updating the render window when actors are change without 

424 being automatically ``Modified``. If set to ``True``, update 

425 rate will be 1 second. 

426 

427 Returns 

428 ------- 

429 None. 

430 

431 """ 

432 self.shape_select_toolbar: pvqt.plotting.QToolBar = None 

433 self.animation_control_toolbar: pvqt.plotting.QToolBar = None 

434 self.loop_action = None 

435 self.plot_abscissa_action = None 

436 self.timer: pvqt.plotting.QTimer = None 

437 self.frames_per_second = frames_per_second 

438 self.animation_speed = 1.0 

439 self.last_time = time.time() 

440 

441 super(TransientPlotter, self).__init__(show, app, None if window_size is None else tuple(window_size), 

442 off_screen, 

443 allow_quit_keypress, toolbar, 

444 menu_bar, editor, update_app_icon, 

445 **kwargs) 

446 

447 self.text = self.add_text('', font_size=10) 

448 self.loop_action.setChecked(False) 

449 self.plot_abscissa_action.setChecked(True) 

450 

451 # Get the layout so we can add a new widget to it 

452 layout = self.frame.layout() 

453 self.time_selector_plot = pg.PlotWidget() 

454 layout.addWidget(self.time_selector_plot) 

455 sp = self.time_selector_plot.sizePolicy() 

456 sp.setVerticalPolicy(QSizePolicy.Minimum) 

457 self.time_selector_plot.setSizePolicy(sp) 

458 self.time_selector_plot.sizeHint = lambda: QtCore.QSize(1006, 80) 

459 # self.time_selector_plot.getPlotItem().hideAxis('left') 

460 # self.time_selector_plot.getPlotItem().axes['left']['item'].setStyle(showValues=False) 

461 

462 # Now we need to plot the data in the plot 

463 if num_curves < displacement_data.size: 

464 indices = np.linspace(0, displacement_data.size - 1, num_curves).astype(int) 

465 else: 

466 indices = np.arange(displacement_data.size) 

467 for data in displacement_data.flatten()[indices]: 

468 self.time_selector_plot.plot(data.abscissa, data.ordinate) 

469 # Plot the envelope 

470 max_envelope = displacement_data.flatten().ordinate.max(axis=0) 

471 min_envelope = displacement_data.flatten().ordinate.min(axis=0) 

472 self.time_selector_plot.plot(displacement_data.flatten( 

473 )[0].abscissa, max_envelope, pen=pg.mkPen(color='k', width=2)) 

474 self.time_selector_plot.plot(displacement_data.flatten( 

475 )[0].abscissa, min_envelope, pen=pg.mkPen(color='k', width=2)) 

476 

477 # Add an infinite line for a cursor 

478 self.time_selector = pg.InfiniteLine( 

479 0, movable=True, bounds=[displacement_data.abscissa.min(), 

480 displacement_data.abscissa.max()]) 

481 self.time_selector_plot.addItem(self.time_selector) 

482 self.time_selector.sigPositionChanged.connect(self.modify_abscissa) 

483 

484 # Set initial values 

485 self.displacement_scale = displacement_scale 

486 self.abscissa_index = 0 

487 self.displacement_data = displacement_data.flatten() 

488 self.transformation_shapes = None if transformation_shapes is None else transformation_shapes.flatten() 

489 self.plot_geometry = geometry 

490 self.num_abscissa = self.displacement_data.num_elements 

491 self.abscissa = self.displacement_data[0].abscissa 

492 self.abscissa_sig_figs = int(np.ceil(-np.log10(np.min(np.diff(self.abscissa))))) + 1 

493 self.abscissa_format_spec = 'Time: {{:0.{:}f}}'.format( 

494 self.abscissa_sig_figs if self.abscissa_sig_figs > 0 else 0) 

495 

496 # Plot the geometry 

497 plotter, self.face_mesh_undeformed, self.point_mesh_undeformed, self.solid_mesh_undeformed = geometry.plot(plotter=self, 

498 opacity=undeformed_opacity, 

499 **plot_kwargs) 

500 

501 plotter, self.face_mesh_deformed, self.point_mesh_deformed, self.solid_mesh_deformed = geometry.plot(plotter=self, 

502 opacity=deformed_opacity, 

503 **plot_kwargs) 

504 

505 # Compute the node indices up front to make things faster when switching steps 

506 if self.transformation_shapes is None: 

507 coordinates = np.unique(self.displacement_data.coordinate) 

508 else: 

509 coordinates = np.unique(self.transformation_shapes.coordinate) 

510 coordinates = coordinates[ 

511 np.isin(coordinates.node, self.plot_geometry.node.id) 

512 & (np.abs(coordinates.direction) > 0) 

513 & (np.abs(coordinates.direction) < 4) 

514 ] 

515 global_deflections = self.plot_geometry.global_deflection(coordinates).T 

516 if self.transformation_shapes is None: 

517 shape_array = self.displacement_data[coordinates[:, np.newaxis]].ordinate 

518 else: 

519 shape_array = self.transformation_shapes[coordinates].T 

520 # Compute global displacements for each node 

521 self.node_deflections = np.zeros((3 * self.plot_geometry.node.size, shape_array.shape[-1])) 

522 for node_index, node in self.plot_geometry.node.ndenumerate(): 

523 coord_indices = coordinates.node == node.id 

524 self.node_deflections[node_index[0] * 3:(node_index[0] + 1) * 

525 3] = global_deflections[:, coord_indices] @ shape_array[coord_indices] 

526 if self.transformation_shapes is None: 

527 self.weighting = None 

528 else: 

529 self.weighting = self.displacement_data.ordinate 

530 self.update_displacement() 

531 

532 def modify_abscissa(self): 

533 abscissa = self.time_selector.value() 

534 self.abscissa_index = np.argmin(np.abs(self.abscissa - abscissa)) 

535 self.update_displacement(update_selector=False) 

536 

537 def update_displacement(self, update_selector=True): 

538 index = int(round(self.abscissa_index)) 

539 abscissa = self.abscissa[index] 

540 if self.plot_abscissa_action.isChecked(): 

541 self.text.SetText(2, self.abscissa_format_spec.format(abscissa)) 

542 else: 

543 self.text.SetText(2, '') 

544 if self.weighting is None: 

545 displacement = self.node_deflections[:, index] * self.displacement_scale 

546 else: 

547 displacement = self.node_deflections @ self.weighting[:, 

548 np.newaxis, index] * self.displacement_scale 

549 for mesh_deformed, mesh_undeformed in ( 

550 (self.face_mesh_deformed, self.face_mesh_undeformed), 

551 (self.point_mesh_deformed, self.point_mesh_undeformed), 

552 (self.solid_mesh_deformed, self.solid_mesh_undeformed) 

553 ): 

554 if mesh_deformed is not None: 

555 mesh_deformed.points = (mesh_undeformed.points + 

556 displacement.reshape(-1, 3) * self.displacement_scale 

557 ) 

558 # Update the time selector 

559 if update_selector: 

560 self.time_selector.blockSignals(True) 

561 self.time_selector.setValue(abscissa) 

562 self.time_selector.blockSignals(False) 

563 self.render() 

564 

565 def add_toolbars(self) -> None: 

566 """ 

567 Adds toolbars to the BackgroundPlotter 

568 

569 """ 

570 super().add_toolbars() 

571 self.shape_select_toolbar = self.app_window.addToolBar( 

572 "Shape Selector" 

573 ) 

574 

575 self._add_action( 

576 self.shape_select_toolbar, '|<', self.go_to_first_step 

577 ) 

578 self._add_action( 

579 self.shape_select_toolbar, '<', self.go_to_previous_step 

580 ) 

581 self._add_action( 

582 self.shape_select_toolbar, '>', self.go_to_next_step 

583 ) 

584 self._add_action( 

585 self.shape_select_toolbar, '>|', self.go_to_last_step 

586 ) 

587 

588 self.animation_control_toolbar = self.app_window.addToolBar( 

589 "Animation Control" 

590 ) 

591 

592 self._add_action( 

593 self.animation_control_toolbar, '< Play', self.play_animation_reverse 

594 ) 

595 self._add_action( 

596 self.animation_control_toolbar, 'Stop', self.stop_animation 

597 ) 

598 self._add_action( 

599 self.animation_control_toolbar, 'Play >', self.play_animation 

600 ) 

601 

602 def add_menu_bar(self) -> None: 

603 """ 

604 Adds the menu bar to the BackgroundPlotter 

605 

606 """ 

607 super().add_menu_bar() 

608 

609 shape_menu = self.main_menu.addMenu("Shape") 

610 scaling_menu = shape_menu.addMenu('Scaling') 

611 scaling_menu.addAction('1/4x', self.select_scaling_0p25) 

612 scaling_menu.addAction('1/2x', self.select_scaling_0p5) 

613 self.scale_0p8_action = QAction('0.8x') 

614 self.scale_0p8_action.triggered.connect(self.select_scaling_0p8) 

615 self.scale_0p8_action.setShortcut(QKeySequence(',')) 

616 scaling_menu.addAction(self.scale_0p8_action) 

617 self.scale_reset_action = QAction('Reset') 

618 self.scale_reset_action.triggered.connect(self.select_scaling_1) 

619 self.scale_reset_action.setShortcut(QKeySequence('/')) 

620 scaling_menu.addAction(self.scale_reset_action) 

621 self.scale_1p25_action = QAction('1.25x') 

622 self.scale_1p25_action.triggered.connect(self.select_scaling_1p25) 

623 self.scale_1p25_action.setShortcut(QKeySequence('.')) 

624 scaling_menu.addAction(self.scale_1p25_action) 

625 scaling_menu.addAction('2x', self.select_scaling_2p0) 

626 scaling_menu.addAction('4x', self.select_scaling_4p0) 

627 speed_menu = shape_menu.addMenu('Animation Speed') 

628 self.speed_0p8_action = QAction('0.8x') 

629 self.speed_0p8_action.triggered.connect(self.select_speed_0p8) 

630 self.speed_0p8_action.setShortcut(QKeySequence(';')) 

631 speed_menu.addAction(self.speed_0p8_action) 

632 speed_menu.addAction('Reset', self.select_speed_1) 

633 self.speed_1p25_action = QAction('1.25x') 

634 self.speed_1p25_action.triggered.connect(self.select_speed_1p25) 

635 self.speed_1p25_action.setShortcut(QKeySequence("'")) 

636 speed_menu.addAction(self.speed_1p25_action) 

637 shape_menu.addSeparator() 

638 self.plot_abscissa_action = pvqt.plotting.QAction('Show Abscissa', checkable=True) 

639 shape_menu.addAction(self.plot_abscissa_action) 

640 self.loop_action = pvqt.plotting.QAction('Loop Animation', checkable=True) 

641 shape_menu.addAction(self.loop_action) 

642 

643 def go_to_first_step(self): 

644 self.stop_animation() 

645 self.abscissa_index = 0 

646 self.update_displacement() 

647 

648 def go_to_previous_step(self): 

649 self.stop_animation() 

650 self.abscissa_index -= 1 

651 if self.abscissa_index < 0: 

652 if self.loop_action.isChecked(): 

653 self.abscissa_index = self.num_abscissa - 1 

654 else: 

655 self.abscissa_index = 0 

656 self.update_displacement() 

657 

658 def go_to_next_step(self): 

659 self.stop_animation() 

660 self.abscissa_index += 1 

661 if self.abscissa_index > self.num_abscissa - 1: 

662 if self.loop_action.isChecked(): 

663 self.abscissa_index = 0 

664 else: 

665 self.abscissa_index = self.num_abscissa - 1 

666 self.update_displacement() 

667 

668 def go_to_last_step(self): 

669 self.stop_animation() 

670 self.abscissa_index = self.num_abscissa - 1 

671 self.update_displacement() 

672 

673 def select_scaling_0p25(self): 

674 """ 

675 Adjusts the current shape scaling by 0.25x 

676 """ 

677 self.displacement_scale *= 0.25 

678 self.last_time = time.time() 

679 self.update_displacement() 

680 

681 def select_scaling_0p5(self): 

682 """ 

683 Adjusts the current shape scaling by 0.5x 

684 """ 

685 self.displacement_scale *= 0.5 

686 self.last_time = time.time() 

687 self.update_displacement() 

688 

689 def select_scaling_0p8(self): 

690 """ 

691 Adjusts the current shape scaling by 0.8x 

692 """ 

693 self.displacement_scale *= 0.8 

694 self.last_time = time.time() 

695 self.update_displacement() 

696 

697 def select_scaling_1(self): 

698 """ 

699 Resets shape scaling to 1 

700 """ 

701 self.displacement_scale = 1.0 

702 self.last_time = time.time() 

703 self.update_displacement() 

704 

705 def select_scaling_1p25(self): 

706 """ 

707 Adjusts the current shape scaling by 1.25x 

708 """ 

709 self.displacement_scale *= 1.25 

710 self.last_time = time.time() 

711 self.update_displacement() 

712 

713 def select_scaling_2p0(self): 

714 """ 

715 Adjusts the current shape scaling by 2x 

716 """ 

717 self.displacement_scale *= 2.0 

718 self.last_time = time.time() 

719 self.update_displacement() 

720 

721 def select_scaling_4p0(self): 

722 """ 

723 Adjusts the current shape scaling by 4x 

724 """ 

725 self.displacement_scale *= 4.0 

726 self.last_time = time.time() 

727 self.update_displacement() 

728 

729 def select_speed_1(self): 

730 """ 

731 Resets animation speed to default quantities 

732 """ 

733 self.animation_speed = 1.0 

734 

735 def select_speed_0p8(self): 

736 """ 

737 Adjusts the current animation speed by 0.8 

738 """ 

739 self.animation_speed *= 0.8 

740 

741 def select_speed_1p25(self): 

742 """ 

743 Adjusts the current animation speed by 1.25 

744 """ 

745 self.animation_speed *= 1.25 

746 

747 def play_animation(self): 

748 self.timer = pvqt.plotting.QTimer() 

749 self.timer.timeout.connect(self.update_abscissa) 

750 self.timer.start(10) 

751 self.last_time = time.time() 

752 

753 def play_animation_reverse(self): 

754 self.timer = pvqt.plotting.QTimer() 

755 self.timer.timeout.connect(self.update_abscissa_reverse) 

756 self.timer.start(10) 

757 self.last_time = time.time() 

758 

759 def stop_animation(self): 

760 if self.timer is not None: 

761 self.timer.stop() 

762 self.timer = None 

763 

764 def update_abscissa(self): 

765 now = time.time() 

766 time_elapsed = now - self.last_time 

767 index_change = self.frames_per_second * self.animation_speed * time_elapsed 

768 self.last_time = now 

769 self.abscissa_index += index_change 

770 if self.abscissa_index > self.num_abscissa - 1: 

771 if self.loop_action.isChecked(): 

772 self.abscissa_index = 0 

773 else: 

774 self.stop_animation() 

775 self.abscissa_index = self.num_abscissa - 1 

776 self.update_displacement() 

777 

778 def update_abscissa_reverse(self): 

779 now = time.time() 

780 time_elapsed = now - self.last_time 

781 index_change = self.frames_per_second * self.animation_speed * time_elapsed 

782 self.last_time = now 

783 self.abscissa_index -= index_change 

784 if self.abscissa_index < 0: 

785 if self.loop_action.isChecked(): 

786 self.abscissa_index = self.num_abscissa - 1 

787 else: 

788 self.stop_animation() 

789 self.abscissa_index = 0 

790 self.update_displacement() 

791 

792 def save_animation(self, filename=None, frame_rate=20, 

793 individual_images=False, start_time=None, stop_time=None, 

794 step=None): 

795 """ 

796 Saves the current shape animation to a file 

797 

798 Parameters 

799 ---------- 

800 filename : str, optional 

801 Path to the file being saved. The default is None, which returns 

802 the images as an array rather than saves them to a file 

803 frame_rate : float, optional 

804 Number of frames per second if the animation is saved to a GIF. 

805 The default is 20. 

806 individual_images : bool, optional 

807 Boolean to specify whether the images are saved as individual PNG 

808 files or a single GIF. The default is False. 

809 start_time : float, optional 

810 Time value at which the animation will start. Default is first 

811 time step 

812 stop_time : float, optional 

813 Time value at which the animation will end. Default is last time 

814 step 

815 step : int, optional 

816 Only render every ``step`` time steps. Default is to render all 

817 timesteps 

818 Returns 

819 ------- 

820 imgs : np.ndarray or None 

821 Returns array of images if filename is None, otherwise returns None 

822 

823 """ 

824 self.stop_animation() 

825 imgs = [] 

826 indices = np.arange(self.abscissa.size) 

827 if start_time is not None: 

828 indices = indices[self.abscissa[indices] >= start_time] 

829 if stop_time is not None: 

830 indices = indices[self.abscissa[indices] <= stop_time] 

831 if step is not None: 

832 indices = indices[::step] 

833 for index in indices: 

834 self.abscissa_index = index 

835 self.update_displacement() 

836 # Force GUI Update to ensure the OS doesn't delay events 

837 QApplication.processEvents() 

838 imgs.append(self.screenshot()) 

839 if filename is None: 

840 return imgs 

841 else: 

842 if individual_images: 

843 for i, img in enumerate(imgs): 

844 fname = os.path.splitext(filename) 

845 fname = fname[0] + '-{:d}'.format(i) + fname[1] 

846 Image.fromarray(img).save(fname) 

847 else: 

848 imgs = [Image.fromarray(img).convert( 

849 'P', palette=Image.ADAPTIVE, colors=256) for img in imgs] 

850 imgs[0].save(fp=filename, format='GIF', append_images=imgs[1:], 

851 save_all=True, duration=int(1 / frame_rate * 1000), loop=0) 

852 

853 def _close(self): 

854 self.stop_animation() 

855 super(TransientPlotter, self)._close() 

856 

857 

858class ShapePlotter(GeometryPlotter): 

859 """Class used to plot animated shapes""" 

860 

861 def __init__(self, geometry, shapes, plot_kwargs, 

862 background_plotter_kwargs={'auto_update': 0.01}, 

863 undeformed_opacity=0.25, starting_scale=1.0, 

864 deformed_opacity=1.0, shape_name='Mode', 

865 show_damping=True): 

866 """ 

867 Create a Shape Plotter object to plot shapes 

868 

869 Parameters 

870 ---------- 

871 geometry : Geometry 

872 Geometry on which the shapes will be plotted 

873 shapes : ShapeArray 

874 Shapes to plot on the geometry 

875 plot_kwargs : dict 

876 Keyword arguments passed to the Geometry.plot function 

877 background_plotter_kwargs : dict, optional 

878 Keyword arguments passed to the BackgroundPlotter constructor. 

879 The default is {'auto_update':0.01}. 

880 undeformed_opacity : float, optional 

881 Opacity of the undeformed geometry. Set to zero for no undeformed 

882 geometry. The default is 0.25. 

883 starting_scale : float, optional 

884 Starting scale of the shapes on the plot. The default is 1.0. 

885 shape_name : str, optional 

886 Name that will be used in the plotter to describe the shape. The 

887 default is "Mode" 

888 show_damping : bool, optional 

889 Boolean that specifies whether the damping should be displayed in 

890 the comment. 

891 """ 

892 self.shape_select_toolbar: pvqt.plotting.QToolBar = None 

893 self.animation_control_toolbar: pvqt.plotting.QToolBar = None 

894 self.signed_amplitude_action = None 

895 self.complex_action = None 

896 self.real_action = None 

897 self.imag_action = None 

898 self.loop_action = None 

899 self.plot_comments_action = None 

900 self.save_animation_action = None 

901 self.display_undeformed = None 

902 self.geometry = geometry 

903 self.shapes = shapes.ravel() 

904 self.node_displacements = None 

905 self.current_shape = 0 

906 self.displacement_scale = 1.0 

907 self.displacement_scale_modification = starting_scale 

908 self.animation_speed = 1.0 

909 self.last_time = time.time() 

910 self.phase = 0.0 

911 self.timer: pvqt.plotting.QTimer = None 

912 self.shape_name = shape_name 

913 self.show_damping = show_damping 

914 self.shape_comment_table = None 

915 

916 super().__init__(**background_plotter_kwargs) 

917 

918 self.text = self.add_text('', font_size=10) 

919 

920 self.complex_action.setChecked(True) 

921 self.loop_action.setChecked(True) 

922 self.plot_comments_action.setChecked(True) 

923 

924 plotter, self.face_mesh_undeformed, self.point_mesh_undeformed, self.solid_mesh_undeformed = geometry.plot(plotter=self, 

925 opacity=undeformed_opacity, 

926 **plot_kwargs) 

927 

928 plotter, self.face_mesh_deformed, self.point_mesh_deformed, self.solid_mesh_deformed = geometry.plot(plotter=self, 

929 opacity=deformed_opacity, 

930 **plot_kwargs) 

931 

932 # Compute the node indices up front to make things faster when switching shapes 

933 coordinates = self.shapes[self.current_shape].coordinate 

934 nodes = coordinates.node 

935 # Only do coordinates that are in the geometry node array 

936 self.indices = np.isin(coordinates.node, self.geometry.node.id) 

937 coordinates = coordinates[self.indices] 

938 nodes = nodes[self.indices] 

939 direction = coordinates.direction 

940 self.coordinate_node_index_map = {} 

941 for coordinate_index, node_id in np.ndenumerate(nodes): 

942 if direction[coordinate_index] == 0 or abs(direction[coordinate_index]) > 3: 

943 continue 

944 if node_id not in self.coordinate_node_index_map: 

945 self.coordinate_node_index_map[node_id] = [] 

946 self.coordinate_node_index_map[node_id].append(coordinate_index[0]) 

947 local_deformations = coordinates.local_direction() 

948 ordered_nodes = self.geometry.node(nodes) 

949 coordinate_systems = self.geometry.coordinate_system(ordered_nodes.disp_cs) 

950 points = global_coord(self.geometry.coordinate_system( 

951 ordered_nodes.def_cs), ordered_nodes.coordinate) 

952 self.global_deflections = global_deflection(coordinate_systems, local_deformations, points) 

953 

954 self.compute_displacements() 

955 self.show_comment() 

956 self.render() 

957 self.update_shape() 

958 self.update_shape_mode(0) 

959 QApplication.processEvents() 

960 

961 def compute_displacements(self, compute_scale=True) -> np.ndarray: 

962 """ 

963 Computes displacements to apply to the geometry 

964 

965 Parameters 

966 ---------- 

967 compute_scale : bool, optional 

968 Renormalize the displacement scaling. The default is True. 

969 

970 """ 

971 shape_displacements = self.shapes[self.current_shape].shape_matrix[..., self.indices] 

972 node_displacements = np.zeros(self.geometry.node.shape + (3,), 

973 dtype=shape_displacements.dtype) 

974 for node_index, node in self.geometry.node.ndenumerate(): 

975 try: 

976 node_indices = self.coordinate_node_index_map[node.id] 

977 except KeyError: 

978 print('Node {:} not found in shape array'.format(node.id)) 

979 node_displacements[node_index] = 0 

980 continue 

981 node_deflection_scales = shape_displacements[node_indices] 

982 node_deflection_directions = self.global_deflections[node_indices] 

983 node_displacements[node_index] += np.sum(node_deflection_scales[:, np.newaxis] * 

984 node_deflection_directions, axis=0) 

985 self.node_displacements = node_displacements.reshape(-1, 3) 

986 # Compute maximum displacement 

987 if compute_scale: 

988 max_displacement = np.max(np.linalg.norm(self.node_displacements, axis=-1)) 

989 global_coords = self.geometry.global_node_coordinate() 

990 bbox_diagonal = np.linalg.norm( 

991 np.max(global_coords, axis=0) - np.min(global_coords, axis=0)) 

992 self.displacement_scale = 0.05 * bbox_diagonal / \ 

993 max_displacement # Set to 5% of bounding box dimension 

994 

995 def update_shape_mode(self, phase=None): 

996 """ 

997 Updates the mode that is being plotted 

998 

999 Parameters 

1000 ---------- 

1001 phase : float, optional 

1002 Sets the current phase of the shape. The default is None, which 

1003 computes the new phase based on the time elapsed. 

1004 

1005 """ 

1006 if phase is None: 

1007 now = time.time() 

1008 time_elapsed = now - self.last_time 

1009 phase_change = 2 * np.pi / self.animation_speed * time_elapsed 

1010 self.last_time = now 

1011 self.phase += phase_change 

1012 else: 

1013 self.phase = phase 

1014 if self.phase > 2 * np.pi: 

1015 self.phase -= 2 * np.pi 

1016 if not self.loop_action.isChecked(): 

1017 self.stop_animation() 

1018 # print('Updating Phase: {:}'.format(self.phase)) 

1019 if self.complex_action.isChecked(): 

1020 # print('Complex') 

1021 deformation = np.real(np.exp(self.phase * 1j) * self.node_displacements) 

1022 elif self.real_action.isChecked(): 

1023 # print('Real') 

1024 deformation = np.cos(self.phase) * np.real(self.node_displacements) 

1025 elif self.imag_action.isChecked(): 

1026 # print('Imag') 

1027 deformation = np.cos(self.phase) * np.imag(self.node_displacements) 

1028 elif self.signed_amplitude_action.isChecked(): 

1029 # print('Signed Amplitude') 

1030 imag = np.imag(self.node_displacements).flatten()[:, np.newaxis] 

1031 real = np.real(self.node_displacements).flatten()[:, np.newaxis] 

1032 best_fit = np.linalg.lstsq(real, imag)[0][0, 0] 

1033 best_fit_phase = np.arctan(best_fit) 

1034 rectified_displacements = self.node_displacements / np.exp(1j * best_fit_phase) 

1035 deformation = np.cos(self.phase) * np.real(rectified_displacements) 

1036 for mesh_deformed, mesh_undeformed in ( 

1037 (self.face_mesh_deformed, self.face_mesh_undeformed), 

1038 (self.point_mesh_deformed, self.point_mesh_undeformed), 

1039 (self.solid_mesh_deformed, self.solid_mesh_undeformed) 

1040 ): 

1041 if mesh_deformed is not None: 

1042 mesh_deformed.points = (mesh_undeformed.points + 

1043 deformation * self.displacement_scale * self.displacement_scale_modification 

1044 ) 

1045 self.render() 

1046 

1047 def update_shape(self): 

1048 """ 

1049 Updates the shape that is being plotted 

1050 """ 

1051 self.update_shape_mode() 

1052 

1053 def save_animation_from_action(self): 

1054 filename, file_filter = QFileDialog.getSaveFileName( 

1055 self, 'Select File to Save Animation', filter='GIF (*.gif)') 

1056 if filename == '': 

1057 return 

1058 self.save_animation(filename) 

1059 

1060 def save_animation(self, filename=None, frames=20, frame_rate=20, 

1061 individual_images=False): 

1062 """ 

1063 Saves the current shape animation to a file 

1064 

1065 Parameters 

1066 ---------- 

1067 filename : str, optional 

1068 Path to the file being saved. The default is None, which returns 

1069 the images as an array rather than saves them to a file 

1070 frames : int, optional 

1071 Number of frames in the animation. The default is 20. 

1072 frame_rate : float, optional 

1073 Number of frames per second if the animation is saved to a GIF. 

1074 The default is 20. 

1075 individual_images : bool, optional 

1076 Boolean to specify whether the images are saved as individual PNG 

1077 files or a single GIF. The default is False. 

1078 

1079 Returns 

1080 ------- 

1081 imgs : np.ndarray or None 

1082 Returns array of images if filename is None, otherwise returns None 

1083 

1084 """ 

1085 self.stop_animation() 

1086 imgs = [] 

1087 phases = np.linspace(0, 2 * np.pi, frames, endpoint=False) 

1088 for phase in phases: 

1089 self.update_shape_mode(phase) 

1090 # Force GUI Update to ensure the OS doesn't delay events 

1091 QApplication.processEvents() 

1092 imgs.append(self.screenshot()) 

1093 if filename is None: 

1094 return imgs 

1095 else: 

1096 if individual_images: 

1097 for i, img in enumerate(imgs): 

1098 fname = os.path.splitext(filename) 

1099 fname = fname[0] + '-{:d}'.format(i) + fname[1] 

1100 Image.fromarray(img).save(fname) 

1101 else: 

1102 imgs = [Image.fromarray(img).convert( 

1103 'P', palette=Image.ADAPTIVE, colors=256) for img in imgs] 

1104 imgs[0].save(fp=filename, format='GIF', append_images=imgs[1:], 

1105 save_all=True, duration=int(1 / frame_rate * 1000), loop=0) 

1106 

1107 def save_animation_all_shapes(self, filename_base='Shape_{:}.gif', 

1108 frames=20, frame_rate=20, 

1109 individual_images=False): 

1110 """ 

1111 Helper function to easily save animations for all shapes 

1112 

1113 Parameters 

1114 ---------- 

1115 filename_base : str, optional 

1116 Filename that will be passed to the format function to produce the 

1117 actual file name for each shape. The default is 'Shape_{:}.gif'. 

1118 frames : int, optional 

1119 Number of frames in the animation. The default is 20. 

1120 frame_rate : float, optional 

1121 Number of frames per second if the animation is saved to a GIF. 

1122 The default is 20. 

1123 individual_images : bool, optional 

1124 Boolean to specify whether the images are saved as individual PNG 

1125 files or a single GIF. The default is False. 

1126 

1127 """ 

1128 for shape in range(self.shapes.size): 

1129 self.current_shape = shape 

1130 self.compute_displacements() 

1131 self.show_comment() 

1132 self.save_animation(filename_base.format(shape + 1), 

1133 frames, frame_rate, individual_images) 

1134 

1135 def add_menu_bar(self) -> None: 

1136 """ 

1137 Adds the menu bar to the BackgroundPlotter 

1138 

1139 """ 

1140 super().add_menu_bar() 

1141 

1142 # Get the file menu 

1143 file_menu = [child for child in self.main_menu.children() 

1144 if child.__class__.__name__ == 'QMenu' 

1145 and child.title() == 'File'][0] 

1146 self.save_animation_action = pvqt.plotting.QAction('Save Animation') 

1147 self.save_animation_action.triggered.connect(self.save_animation_from_action) 

1148 file_menu.insertAction(file_menu.actions()[1], self.save_animation_action) 

1149 

1150 shape_menu = self.main_menu.addMenu("Shape") 

1151 complex_display_menu = shape_menu.addMenu("Complex Display") 

1152 self.signed_amplitude_action = pvqt.plotting.QAction('Signed Amplitude', checkable=True) 

1153 self.signed_amplitude_action.triggered.connect(self.select_complex) 

1154 complex_display_menu.addAction(self.signed_amplitude_action) 

1155 self.complex_action = pvqt.plotting.QAction('Complex', checkable=True) 

1156 self.complex_action.triggered.connect(self.select_complex) 

1157 complex_display_menu.addAction(self.complex_action) 

1158 self.real_action = pvqt.plotting.QAction('Real', checkable=True) 

1159 self.real_action.triggered.connect(self.select_complex) 

1160 complex_display_menu.addAction(self.real_action) 

1161 self.imag_action = pvqt.plotting.QAction('Imaginary', checkable=True) 

1162 self.imag_action.triggered.connect(self.select_complex) 

1163 complex_display_menu.addAction(self.imag_action) 

1164 scaling_menu = shape_menu.addMenu('Scaling') 

1165 scaling_menu.addAction('1/4x', self.select_scaling_0p25) 

1166 scaling_menu.addAction('1/2x', self.select_scaling_0p5) 

1167 self.scale_0p8_action = QAction('0.8x') 

1168 self.scale_0p8_action.triggered.connect(self.select_scaling_0p8) 

1169 self.scale_0p8_action.setShortcut(QKeySequence(',')) 

1170 scaling_menu.addAction(self.scale_0p8_action) 

1171 self.scale_reset_action = QAction('Reset') 

1172 self.scale_reset_action.triggered.connect(self.select_scaling_1) 

1173 self.scale_reset_action.setShortcut(QKeySequence('/')) 

1174 scaling_menu.addAction(self.scale_reset_action) 

1175 self.scale_1p25_action = QAction('1.25x') 

1176 self.scale_1p25_action.triggered.connect(self.select_scaling_1p25) 

1177 self.scale_1p25_action.setShortcut(QKeySequence('.')) 

1178 scaling_menu.addAction(self.scale_1p25_action) 

1179 scaling_menu.addAction('2x', self.select_scaling_2p0) 

1180 scaling_menu.addAction('4x', self.select_scaling_4p0) 

1181 speed_menu = shape_menu.addMenu('Animation Speed') 

1182 self.speed_0p8_action = QAction('0.8x') 

1183 self.speed_0p8_action.triggered.connect(self.select_speed_0p8) 

1184 self.speed_0p8_action.setShortcut(QKeySequence(';')) 

1185 speed_menu.addAction(self.speed_0p8_action) 

1186 speed_menu.addAction('Reset', self.select_speed_1) 

1187 self.speed_1p25_action = QAction('1.25x') 

1188 self.speed_1p25_action.triggered.connect(self.select_speed_1p25) 

1189 self.speed_1p25_action.setShortcut(QKeySequence("'")) 

1190 speed_menu.addAction(self.speed_1p25_action) 

1191 shape_menu.addSeparator() 

1192 self.plot_comments_action = pvqt.plotting.QAction('Show Comments', checkable=True) 

1193 self.plot_comments_action.triggered.connect(self.show_comment) 

1194 shape_menu.addAction(self.plot_comments_action) 

1195 self.loop_action = pvqt.plotting.QAction('Loop Animation', checkable=True) 

1196 self.loop_action.triggered.connect(self.select_loop) 

1197 shape_menu.addAction(self.loop_action) 

1198 

1199 display_menu = self.main_menu.addMenu('Display') 

1200 self.display_undeformed = pvqt.plotting.QAction('Show Undeformed', checkable=True) 

1201 self.display_undeformed.triggered.connect(self.toggle_undeformed) 

1202 display_menu.addAction(self.display_undeformed) 

1203 

1204 def add_toolbars(self) -> None: 

1205 """ 

1206 Adds toolbars to the BackgroundPlotter 

1207 

1208 """ 

1209 super().add_toolbars() 

1210 self.shape_select_toolbar = self.app_window.addToolBar( 

1211 "Shape Selector" 

1212 ) 

1213 

1214 self._add_action( 

1215 self.shape_select_toolbar, '<<', self.prev_shape 

1216 ) 

1217 self._add_action( 

1218 self.shape_select_toolbar, '?', self.select_shape 

1219 ) 

1220 self._add_action( 

1221 self.shape_select_toolbar, '>>', self.next_shape 

1222 ) 

1223 

1224 self.animation_control_toolbar = self.app_window.addToolBar( 

1225 "Animation Control" 

1226 ) 

1227 

1228 self._add_action( 

1229 self.animation_control_toolbar, 'Play', self.play_animation 

1230 ) 

1231 self._add_action( 

1232 self.animation_control_toolbar, 'Stop', self.stop_animation 

1233 ) 

1234 

1235 def play_animation(self): 

1236 """Starts the animation playing""" 

1237 self.timer = pvqt.plotting.QTimer() 

1238 self.timer.timeout.connect(self.update_shape) 

1239 self.timer.start(10) 

1240 self.phase = 0.0 

1241 self.last_time = time.time() 

1242 

1243 def stop_animation(self): 

1244 """Stops the animation from playing""" 

1245 if self.timer is not None: 

1246 self.timer.stop() 

1247 self.timer = None 

1248 

1249 def toggle_undeformed(self) -> None: 

1250 pass 

1251 

1252 def select_scaling_0p25(self): 

1253 """ 

1254 Adjusts the current shape scaling by 0.25x 

1255 """ 

1256 self.displacement_scale_modification *= 0.25 

1257 self.phase = 0.0 

1258 self.last_time = time.time() 

1259 self.update_shape() 

1260 

1261 def select_scaling_0p5(self): 

1262 """ 

1263 Adjusts the current shape scaling by 0.5x 

1264 """ 

1265 self.displacement_scale_modification *= 0.5 

1266 self.phase = 0.0 

1267 self.last_time = time.time() 

1268 self.update_shape() 

1269 

1270 def select_scaling_0p8(self): 

1271 """ 

1272 Adjusts the current shape scaling by 0.8x 

1273 """ 

1274 self.displacement_scale_modification *= 0.8 

1275 self.phase = 0.0 

1276 self.last_time = time.time() 

1277 self.update_shape() 

1278 

1279 def select_scaling_1(self): 

1280 """ 

1281 Resets shape scaling to 1 

1282 """ 

1283 self.displacement_scale_modification = 1.0 

1284 self.phase = 0.0 

1285 self.last_time = time.time() 

1286 self.update_shape() 

1287 

1288 def select_scaling_1p25(self): 

1289 """ 

1290 Adjusts the current shape scaling by 1.25x 

1291 """ 

1292 self.displacement_scale_modification *= 1.25 

1293 self.phase = 0.0 

1294 self.last_time = time.time() 

1295 self.update_shape() 

1296 

1297 def select_scaling_2p0(self): 

1298 """ 

1299 Adjusts the current shape scaling by 2x 

1300 """ 

1301 self.displacement_scale_modification *= 2.0 

1302 self.phase = 0.0 

1303 self.last_time = time.time() 

1304 self.update_shape() 

1305 

1306 def select_scaling_4p0(self): 

1307 """ 

1308 Adjusts the current shape scaling by 4x 

1309 """ 

1310 self.displacement_scale_modification *= 4.0 

1311 self.phase = 0.0 

1312 self.last_time = time.time() 

1313 self.update_shape() 

1314 

1315 def select_speed_1(self): 

1316 """ 

1317 Resets animation speed to default quantities 

1318 """ 

1319 self.animation_speed = 1.0 

1320 

1321 def select_speed_0p8(self): 

1322 """ 

1323 Adjusts the current animation speed by 0.8 

1324 """ 

1325 self.animation_speed /= 0.8 

1326 

1327 def select_speed_1p25(self): 

1328 """ 

1329 Adjusts the current animation speed by 1.25 

1330 """ 

1331 self.animation_speed /= 1.25 

1332 

1333 def select_complex(self) -> None: 

1334 """ 

1335 Adjusts how complex shapes are plotted 

1336 """ 

1337 complex_actions = [ 

1338 self.signed_amplitude_action, 

1339 self.complex_action, 

1340 self.real_action, 

1341 self.imag_action 

1342 ] 

1343 sender = self.sender() 

1344 sender_index = complex_actions.index(sender) 

1345 complex_actions.pop(sender_index) 

1346 if not sender.isChecked(): 

1347 sender.setChecked(True) 

1348 else: 

1349 for action in complex_actions: 

1350 action.setChecked(False) 

1351 self.phase = 0.0 

1352 self.last_time = time.time() 

1353 self.update_shape() 

1354 

1355 def select_loop(self) -> None: 

1356 pass 

1357 

1358 def next_shape(self) -> None: 

1359 """ 

1360 Increases the index of the shape being plotted 

1361 """ 

1362 self.current_shape += 1 

1363 if self.current_shape >= self.shapes.size: 

1364 self.current_shape = 0 

1365 self.compute_displacements() 

1366 self.phase = 0.0 

1367 self.last_time = time.time() 

1368 self.update_shape() 

1369 self.show_comment() 

1370 

1371 def prev_shape(self) -> None: 

1372 """ 

1373 Decreases the index of the shape being plotted 

1374 """ 

1375 self.current_shape -= 1 

1376 if self.current_shape < 0: 

1377 self.current_shape = self.shapes.size - 1 

1378 self.compute_displacements() 

1379 self.phase = 0.0 

1380 self.last_time = time.time() 

1381 self.update_shape() 

1382 self.show_comment() 

1383 

1384 def reset_shape(self) -> None: 

1385 """ 

1386 Resets a shape if the value of the current shape is changed 

1387 """ 

1388 self.compute_displacements() 

1389 self.phase = 0.0 

1390 self.last_time = time.time() 

1391 self.update_shape() 

1392 self.show_comment() 

1393 

1394 def select_shape(self) -> None: 

1395 from .sdynpy_shape import ShapeCommentTable 

1396 self.shape_comment_table = ShapeCommentTable(self.shapes, self) 

1397 

1398 def show_comment(self): 

1399 """ 

1400 Shows or hides the shape comment 

1401 """ 

1402 if self.plot_comments_action.isChecked(): 

1403 self.text.SetText(2, '\n'.join([ 

1404 '{:} {:}'.format(self.shape_name, self.current_shape + 1), 

1405 ' Frequency: {:0.2f}'.format(self.shapes[self.current_shape].frequency)] + 

1406 ([' Damping: {:.2f}%'.format(self.shapes[self.current_shape].damping * 100)] if self.show_damping else []) + 

1407 [' ' + self.shapes[self.current_shape].comment1, 

1408 ' ' + self.shapes[self.current_shape].comment2, 

1409 ' ' + self.shapes[self.current_shape].comment3, 

1410 ' ' + self.shapes[self.current_shape].comment4, 

1411 ' ' + self.shapes[self.current_shape].comment5])) 

1412 else: 

1413 self.text.SetText(2, '') 

1414 

1415 def _close(self): 

1416 self.stop_animation() 

1417 super(ShapePlotter, self)._close() 

1418 

1419 

1420class DeflectionShapePlotter(ShapePlotter): 

1421 """Class used to plot animated deflection shapes from spectra""" 

1422 

1423 def __init__(self, geometry, deflection_shape_data, plot_kwargs, 

1424 background_plotter_kwargs={'auto_update': 0.01}, 

1425 undeformed_opacity=0.25, starting_scale=1.0, 

1426 deformed_opacity=1.0, num_curves=50): 

1427 """ 

1428 Create a DeflectionShapePlotter object to plot shapes 

1429 

1430 Parameters 

1431 ---------- 

1432 geometry : Geometry 

1433 Geometry on which the shapes will be plotted 

1434 deflection_shape_data : NDDataArray 

1435 Data array containing the deflection shapes to plot 

1436 plot_kwargs : dict 

1437 Keyword arguments passed to the Geometry.plot function 

1438 background_plotter_kwargs : dict, optional 

1439 Keyword arguments passed to the BackgroundPlotter constructor. 

1440 The default is {'auto_update':0.01}. 

1441 undeformed_opacity : float, optional 

1442 Opacity of the undeformed geometry. Set to zero for no undeformed 

1443 geometry. The default is 0.25. 

1444 starting_scale : float, optional 

1445 Starting scale of the shapes on the plot. The default is 1.0. 

1446 num_curves : int, optional 

1447 Maximum number of curves to plot on the frequency selector. 

1448 Default is 50 

1449 """ 

1450 # Convert function data into shapes 

1451 from .sdynpy_shape import shape_array 

1452 flattened_data = deflection_shape_data.flatten() 

1453 response_coordinates = flattened_data.response_coordinate 

1454 if not np.unique(response_coordinates).size == response_coordinates.size: 

1455 raise ValueError('all response coordinates for operating data must be unique') 

1456 shapes = shape_array(response_coordinates, flattened_data.ordinate.T, 

1457 flattened_data[0].abscissa) 

1458 super().__init__(geometry, shapes, plot_kwargs, background_plotter_kwargs, 

1459 undeformed_opacity, starting_scale, deformed_opacity, shape_name='Shape', 

1460 show_damping=False) 

1461 

1462 # Get the layout so we can add a new widget to it 

1463 layout = self.frame.layout() 

1464 self.frequency_selector_plot = pg.PlotWidget() 

1465 layout.addWidget(self.frequency_selector_plot) 

1466 sp = self.frequency_selector_plot.sizePolicy() 

1467 sp.setVerticalPolicy(QSizePolicy.Minimum) 

1468 self.frequency_selector_plot.setSizePolicy(sp) 

1469 self.frequency_selector_plot.sizeHint = lambda: QtCore.QSize(1006, 120) 

1470 

1471 # Now we need to plot the data in the plot 

1472 if num_curves < flattened_data.size: 

1473 indices = np.linspace(0, flattened_data.size - 1, num_curves).astype(int) 

1474 else: 

1475 indices = np.arange(flattened_data.size) 

1476 for data in flattened_data[indices]: 

1477 self.frequency_selector_plot.plot(data.abscissa, np.abs(data.ordinate)) 

1478 # Plot the envelope 

1479 max_envelope = np.linalg.svd( 

1480 flattened_data.ordinate.T[..., np.newaxis], False, False, False).squeeze() 

1481 self.frequency_selector_plot.plot( 

1482 flattened_data[0].abscissa, max_envelope, pen=pg.mkPen(color='k', width=2)) 

1483 

1484 # Add an infinite line for a cursor 

1485 self.frequency_selector = pg.InfiniteLine( 

1486 0, movable=True, bounds=[flattened_data.abscissa.min(), 

1487 flattened_data.abscissa.max()]) 

1488 self.frequency_selector_plot.addItem(self.frequency_selector) 

1489 self.frequency_selector.sigPositionChanged.connect(self.modify_abscissa) 

1490 

1491 self.frequency_selector_plot.setLogMode(False, True) 

1492 self.frequency_selector_plot.setRange( 

1493 yRange=(np.log10(max_envelope.min()), np.log10(max_envelope.max()))) 

1494 

1495 def modify_abscissa(self): 

1496 frequency = self.frequency_selector.value() 

1497 self.current_shape = np.argmin(np.abs(self.shapes.frequency - frequency)) 

1498 self.compute_displacements() 

1499 self.update_shape() 

1500 self.show_comment() 

1501 

1502 def save_multiline_animation(self, filename=None, frame_rate=20, 

1503 phase_change_per_frame=0.087, 

1504 frequency_change_per_frame=0.5, 

1505 start_phase=0.0, 

1506 start_frequency=None, end_frequency=None, 

1507 individual_images=False): 

1508 self.stop_animation() 

1509 imgs = [] 

1510 if start_frequency is None: 

1511 start_frequency = self.shapes.frequency[0] 

1512 if end_frequency is None: 

1513 end_frequency = self.shapes.frequency[-1] 

1514 frequency_frames = np.arange(start_frequency, end_frequency, frequency_change_per_frame) 

1515 phase_frames = start_phase + (np.arange(frequency_frames.size) * phase_change_per_frame) 

1516 for frequency, phase in zip(frequency_frames, phase_frames): 

1517 self.current_shape = np.argmin(np.abs(self.shapes.frequency - frequency)) 

1518 self.compute_displacements() 

1519 self.update_shape_mode(phase) 

1520 self.show_comment() 

1521 # Force GUI Update to ensure the OS doesn't delay events 

1522 QApplication.processEvents() 

1523 imgs.append(self.screenshot()) 

1524 if filename is None: 

1525 return imgs 

1526 else: 

1527 if individual_images: 

1528 for i, img in enumerate(imgs): 

1529 fname = os.path.splitext(filename) 

1530 fname = fname[0] + '-{:d}'.format(i) + fname[1] 

1531 Image.fromarray(img).save(fname) 

1532 else: 

1533 imgs = [Image.fromarray(img).convert( 

1534 'P', palette=Image.ADAPTIVE, colors=256) for img in imgs] 

1535 imgs[0].save(fp=filename, format='GIF', append_images=imgs[1:], 

1536 save_all=True, duration=int(1 / frame_rate * 1000), loop=0) 

1537 

1538 

1539def split_list(seq, value): 

1540 """ 

1541 Splits a list by a value that exists in the list 

1542 

1543 Parameters 

1544 ---------- 

1545 seq : iterable 

1546 The sequence to split up 

1547 value : 

1548 Value within the list to use to split up the list 

1549 

1550 Yields 

1551 ------ 

1552 group : list 

1553 Sublist of seq separated into groups. 

1554 

1555 """ 

1556 group = [] 

1557 for val in seq: 

1558 if val != value: 

1559 group.append(val) 

1560 elif group: 

1561 yield group 

1562 group = [] 

1563 yield group 

1564 

1565 

1566def global_coord(coordinate_system, local_coord): 

1567 '''Compute global coordinates from local coordinates 

1568 

1569 Compute the global coordinates of a node from its local coordinates in the 

1570 corresponding coordinate system. 

1571 

1572 Parameters 

1573 ---------- 

1574 

1575 coordinate_system : CoordinateSystemArray 

1576 An array of coordinate systems of shape [...] 

1577 local_coord : ndarray 

1578 An array of coordinates of shape [...,3] 

1579 

1580 Returns 

1581 ------ 

1582 global_coord : ndarray 

1583 An array of coordinates of shape [...,3] 

1584 ''' 

1585 # Make sure we don't change local_coord 

1586 local_coord = np.array(copy.deepcopy(local_coord)) 

1587 

1588 # First convert to cartesian if necessary 

1589 cylindrical_css = coordinate_system.cs_type == 1 

1590 cyl_coord = local_coord[cylindrical_css] 

1591 cyl_coord[..., 0], cyl_coord[..., 1] = (cyl_coord[..., 0] * np.cos(cyl_coord[..., 1] * np.pi / 180), 

1592 cyl_coord[..., 0] * np.sin(cyl_coord[..., 1] * np.pi / 180)) 

1593 local_coord[cylindrical_css] = cyl_coord 

1594 

1595 spherical_css = coordinate_system.cs_type == 2 

1596 sph_coord = local_coord[spherical_css] 

1597 sph_coord[..., 0], sph_coord[..., 1], sph_coord[..., 2] = ( 

1598 sph_coord[..., 0] * np.sin(sph_coord[..., 1] * np.pi / 180) * 

1599 np.cos(sph_coord[..., 2] * np.pi / 180), 

1600 sph_coord[..., 0] * np.sin(sph_coord[..., 1] * np.pi / 180) * 

1601 np.sin(sph_coord[..., 2] * np.pi / 180), 

1602 sph_coord[..., 0] * np.cos(sph_coord[..., 1] * np.pi / 180)) 

1603 local_coord[spherical_css] = sph_coord 

1604 # Convert to global cartesian from local cartesian 

1605 return np.einsum('...ij,...i->...j', coordinate_system.matrix[..., :3, :], local_coord) + coordinate_system.matrix[..., 3, :] 

1606 

1607 

1608def local_coord(coordinate_system, global_coord): 

1609 """Compute local coordinates from global coordinates 

1610 

1611 Compute the local coordinates of a node in the 

1612 corresponding coordinate system from its global coordinates. 

1613 

1614 Parameters 

1615 ---------- 

1616 

1617 coordinate_system : CoordinateSystemArray 

1618 An array of coordinate systems of shape [...] 

1619 global_coord : ndarray 

1620 An array of coordinates of shape [...,3] 

1621 

1622 Returns 

1623 ------ 

1624 local_coord : ndarray 

1625 An array of coordinates of shape [...,3] 

1626 """ 

1627 # Make sure we don't change global_coord 

1628 global_coord = np.array(copy.deepcopy(global_coord)) 

1629 # Make sure we're working with a numpy array 

1630 # Convert to the local cartesian 

1631 local_coordinate = np.einsum( 

1632 '...ji,...i->...j', coordinate_system.matrix[..., :3, :], global_coord - coordinate_system.matrix[..., 3, :]) 

1633 

1634 # Convert to cylindrical or spherical if necessary. 

1635 cylindrical_css = coordinate_system.cs_type == 1 

1636 cyl_coord = local_coordinate[cylindrical_css] 

1637 cyl_coord[..., 0], cyl_coord[..., 1] = (np.sqrt(cyl_coord[..., 0]**2 + cyl_coord[..., 1]**2), 

1638 np.arctan2(cyl_coord[..., 1], cyl_coord[..., 0]) * 180 / np.pi) 

1639 local_coordinate[cylindrical_css] = cyl_coord 

1640 

1641 spherical_css = coordinate_system.cs_type == 2 

1642 sph_coord = local_coordinate[spherical_css] 

1643 sph_coord[..., 0], sph_coord[..., 1] = ( 

1644 np.sqrt(sph_coord[..., 0]**2 + sph_coord[..., 1]**2 + sph_coord[..., 2]**2), 

1645 np.arctan2(sph_coord[..., 1], sph_coord[..., 0]) * 180 / np.pi) 

1646 # At this point local_coordinate[2,:] is still z and local_coordinate[0,:] is 

1647 # now r instead of x 

1648 sph_coord[..., 2] = np.arccos(sph_coord[..., 2] / sph_coord[..., 0]) * 180 / np.pi 

1649 # Flip the 1 and 2 rows to be consistent with IMAT 

1650 sph_coord = sph_coord[..., [0, 2, 1]] 

1651 local_coordinate[spherical_css] = sph_coord 

1652 return local_coordinate 

1653 

1654 

1655def global_deflection(coordinate_system, local_deflection, global_point=None): 

1656 """ 

1657 Compute deflections in the global coordinate system 

1658 

1659 Parameters 

1660 ---------- 

1661 coordinate_system : CoordinateSystemArray 

1662 An array of coordinate systems of shape [...] 

1663 local_deflection : ndarray 

1664 An array of deflections of shape [...,3] 

1665 global_point : np.ndarray, optional 

1666 An array of coordinates in the global coordinate system of shape 

1667 [...,3]. Must be specified if there are spherical or cylindrical 

1668 coordinate systems. The default is None. 

1669 

1670 Raises 

1671 ------ 

1672 ValueError 

1673 If cylindrical or spherical coordinate systems exist and global_point 

1674 is not specified 

1675 

1676 Returns 

1677 ------- 

1678 global_deflection : ndarray 

1679 An array of coordinates of shape [...,3] specifying the deflection 

1680 directions 

1681 

1682 """ 

1683 # Make sure we don't change local_deflection or point 

1684 local_deflection = np.array(copy.deepcopy(local_deflection)) 

1685 if global_point is not None: 

1686 global_point = np.array(copy.deepcopy(global_point)) 

1687 # Convert cylindrical and spherical to a local cartesian AT THE POINT 

1688 # OF INTEREST (i.e. rotated in the coordinate system) 

1689 local_rotation = np.zeros(coordinate_system.shape + (3, 3)) 

1690 for key, cs in coordinate_system.ndenumerate(): 

1691 if cs.cs_type == 0: 

1692 this_local_rotation = np.eye(3) 

1693 elif cs.cs_type == 1: 

1694 if global_point is None: 

1695 raise ValueError('point must be specified for Cylindrical coordinate systems') 

1696 local_point = local_coord(cs, global_point[key]) 

1697 this_local_rotation = R(2, local_point[1], degrees=True) 

1698 elif cs.cs_type == 2: 

1699 if global_point is None: 

1700 raise ValueError('point must be specified for Spherical coordinate systems') 

1701 local_point = local_coord(cs, global_point[key]) 

1702 this_local_rotation = R( 

1703 2, local_point[2], degrees=True) @ R(1, local_point[1], degrees=True) 

1704 else: 

1705 raise ValueError('invalid coordinate system type {:}'.format(cs.cs_type)) 

1706 local_rotation[key] = this_local_rotation 

1707 return np.einsum('...ij,...j->...i', np.einsum('...ij,...ik->...jk', coordinate_system.matrix[..., :3, :], local_rotation), local_deflection) 

1708 

1709 

1710class CoordinateSystemArray(SdynpyArray): 

1711 """Coordinate System Information specifying position and directions. 

1712 

1713 Use the coordinate_system_array helper function to create the array. 

1714 """ 

1715 data_dtype = [('id', 'uint64'), ('name', '<U40'), ('color', 'uint16'), 

1716 ('cs_type', 'uint16'), ('matrix', 'float64', (4, 3))] 

1717 

1718 def __new__(subtype, shape, buffer=None, offset=0, 

1719 strides=None, order=None): 

1720 # Create the ndarray instance of our type, given the usual 

1721 # ndarray input arguments. This will call the standard 

1722 # ndarray constructor, but return an object of our type. 

1723 # It also triggers a call to InfoArray.__array_finalize__ 

1724 obj = super(CoordinateSystemArray, subtype).__new__(subtype, shape, 

1725 CoordinateSystemArray.data_dtype, buffer, offset, strides, order) 

1726 # Finally, we must return the newly created object: 

1727 return obj 

1728 

1729 def __repr__(self): 

1730 string_out = '{:>8s}, {:>6s}, {:>20s}, {:>5s}, {:>10s}\n'.format( 

1731 'Index', 'ID', 'Name', 'Color', 'Type') 

1732 if self.size == 0: 

1733 string_out += '----------- Empty -------------\n' 

1734 for i, (key, val) in enumerate(self.ndenumerate()): 

1735 if i >= MAX_NUMBER_REPR: 

1736 string_out += ' .\n .\n .\n' 

1737 break 

1738 string_out += '{:>8}, {:>6}, {:>20}, {:>5}, {:>10}\n'.format( 

1739 str(key), val.id, val.name, val.color, _cs_type_strings[val.cs_type[()]]) 

1740 return string_out 

1741 

1742 def __call__(self, index_by_id): 

1743 """ 

1744 Select coordinate system by id rather than by index 

1745 

1746 Parameters 

1747 ---------- 

1748 index_by_id : int or np.ndarray 

1749 ID number(s) to use to select coordinate systems. 

1750 

1751 Raises 

1752 ------ 

1753 ValueError 

1754 If specified ID(s) not found in array. 

1755 

1756 Returns 

1757 ------- 

1758 output : CoordinateSystemArray 

1759 Subset of CoordinateSystemArray with the specified IDs. 

1760 

1761 """ 

1762 index_dict = {node.id[()]: index for index, node in self.ndenumerate()} 

1763 ids = np.array(index_by_id) 

1764 output = np.empty(ids.shape, dtype=self.dtype).view(self.__class__) 

1765 for key, val in np.ndenumerate(ids): 

1766 try: 

1767 index = index_dict[val] 

1768 except KeyError: 

1769 raise ValueError('ID {:} not found in array'.format(val)) 

1770 output[key] = self[index] 

1771 return output 

1772 

1773 @staticmethod 

1774 def from_unv(unv_data_dict, combine=True): 

1775 """ 

1776 Load CoordinateSystemArrays from universal file data from read_unv 

1777 

1778 Parameters 

1779 ---------- 

1780 unv_data_dict : dict 

1781 Dictionary containing data from read_unv 

1782 combine : bool, optional 

1783 If True, return as a single CoordinateSytemArray 

1784 

1785 Returns 

1786 ------- 

1787 output_arrays : CoordinateSystemArray 

1788 Coordinate Systems read from unv 

1789 

1790 """ 

1791 try: 

1792 datasets = unv_data_dict[2420] 

1793 except KeyError: 

1794 if combine: 

1795 return CoordinateSystemArray(0) 

1796 else: 

1797 return [] 

1798 output_arrays = [] 

1799 for dataset in datasets: 

1800 output_arrays.append(coordinate_system_array(dataset.cs_labels, 

1801 dataset.cs_names, 

1802 dataset.cs_colors, 

1803 dataset.cs_types, 

1804 dataset.cs_matrices)) 

1805 if combine: 

1806 output_arrays = np.concatenate(output_arrays) 

1807 return output_arrays 

1808 

1809 

1810def coordinate_system_array(id=1, name='', color=1, cs_type=0, matrix=np.concatenate((np.eye(3), np.zeros((1, 3))), axis=0), 

1811 structured_array=None): 

1812 """ 

1813 Creates an array that specifies coordinate systems in the geometry 

1814 

1815 Creates an array of coordinate systems that specify directions of sensors 

1816 in a test or analysis. Coordinate system arrays can be created using a 

1817 numpy structured array or individual arrays for each attribute. 

1818 Multidimensional arrays can be used. 

1819 

1820 Parameters 

1821 ---------- 

1822 id : ndarray 

1823 Integer array corresponding to the id of the coordinate systems. Input 

1824 will be cast to an integer (i.e. 2.0 -> 2, 1.9 -> 1) 

1825 name : ndarray 

1826 name of the coordinate systems 

1827 color : ndarray 

1828 color of the coordinate systems as integers 

1829 cs_type : ndarray 

1830 Coordinate system types (0 = Cartesian, 1 = Polar, 2 = Spherical) 

1831 matrix : ndarray 

1832 Coordinate system transformation matrix with shape [... x 4 x 3] 

1833 structured_array : ndarray (structured) 

1834 Alternatively to the individual attributes, a single numpy structured 

1835 array can be passed, which should have the same name as the inputs to 

1836 the function listed above. 

1837 

1838 Returns 

1839 ------- 

1840 coordinate_system_array : CoordinateSystemArray 

1841 """ 

1842 if structured_array is not None: 

1843 try: 

1844 id = structured_array['id'] 

1845 name = structured_array['name'] 

1846 color = structured_array['color'] 

1847 cs_type = structured_array['cs_type'] 

1848 matrix = structured_array['matrix'] 

1849 except (ValueError, TypeError): 

1850 raise ValueError( 

1851 'structured_array must be numpy.ndarray with dtype names "id", "name", "color", "cs_type" and "matrix"') 

1852 else: 

1853 id = np.array(id) 

1854 name = np.array(name) 

1855 color = np.array(color) 

1856 cs_type = np.array(cs_type) 

1857 matrix = np.array(matrix) 

1858 

1859 # Don't check shapes because we want them to be broadcastable, but id have to 

1860 # be unique so we will use that for the shape 

1861 coord_sys_array = CoordinateSystemArray(id.shape) 

1862 coord_sys_array.id = id 

1863 coord_sys_array.name = name 

1864 coord_sys_array.color = color 

1865 coord_sys_array.cs_type = cs_type 

1866 coord_sys_array.matrix = matrix 

1867 

1868 return coord_sys_array 

1869 

1870 

1871class ElementArray(SdynpyArray): 

1872 """Element information array 

1873 

1874 Use the element_array helper function to create the array. 

1875 """ 

1876 

1877 data_dtype = [('id', 'uint64'), ('type', 'uint8'), ('color', 'uint16'), 

1878 ('connectivity', 'object')] 

1879 

1880 def __new__(subtype, shape, buffer=None, offset=0, 

1881 strides=None, order=None): 

1882 # Create the ndarray instance of our type, given the usual 

1883 # ndarray input arguments. This will call the standard 

1884 # ndarray constructor, but return an object of our type. 

1885 # It also triggers a call to InfoArray.__array_finalize__ 

1886 

1887 obj = super(ElementArray, subtype).__new__(subtype, shape, 

1888 ElementArray.data_dtype, buffer, offset, strides, order) 

1889 # Finally, we must return the newly created object: 

1890 return obj 

1891 

1892 def __repr__(self): 

1893 string_out = '{:>8s}, {:>6s}, {:>4s}, {:>5s}, {:>7s}\n'.format( 

1894 'Index', 'ID', 'Type', 'Color', '# Nodes') 

1895 if self.size == 0: 

1896 string_out += '----------- Empty -------------\n' 

1897 for i, (key, val) in enumerate(self.ndenumerate()): 

1898 if i >= MAX_NUMBER_REPR: 

1899 string_out += ' .\n .\n .\n' 

1900 break 

1901 string_out += '{:>8}, {:>6}, {:>4}, {:>5}, {:>7}\n'.format( 

1902 str(key), val.id, val.type, val.color, len(val.connectivity)) 

1903 return string_out 

1904 

1905 def __call__(self, index_by_id): 

1906 """ 

1907 Select elements by id rather than by index 

1908 

1909 Parameters 

1910 ---------- 

1911 index_by_id : int or np.ndarray 

1912 ID number(s) to use to select elements. 

1913 

1914 Raises 

1915 ------ 

1916 ValueError 

1917 If specified ID(s) not found in array. 

1918 

1919 Returns 

1920 ------- 

1921 output : ElementArray 

1922 Subset of ElementArray with the specified IDs. 

1923 

1924 """ 

1925 index_dict = {node.id[()]: index for index, node in self.ndenumerate()} 

1926 ids = np.array(index_by_id) 

1927 output = np.empty(ids.shape, dtype=self.dtype).view(self.__class__) 

1928 for key, val in np.ndenumerate(ids): 

1929 try: 

1930 index = index_dict[val] 

1931 except KeyError: 

1932 raise ValueError('ID {:} not found in array'.format(val)) 

1933 output[key] = self[index] 

1934 return output 

1935 

1936 def reduce(self, node_list): 

1937 """ 

1938 Keep only elements that have all nodes contained in node_list 

1939 

1940 Parameters 

1941 ---------- 

1942 node_list : iterable 

1943 Iterable containing nodes to keep. 

1944 

1945 Returns 

1946 ------- 

1947 ElementArray 

1948 ElementArray containing only elements with all nodes in node_list. 

1949 

1950 """ 

1951 output_list = [] 

1952 for key, element in self.ndenumerate(): 

1953 if np.all(np.isin(element.connectivity, node_list)): 

1954 output_list.append(element) 

1955 return np.array(output_list, self.dtype).view(ElementArray) 

1956 

1957 def remove(self, element_list): 

1958 """ 

1959 Removes elements with id numbers in traceline_list 

1960  

1961 Parameters 

1962 ---------- 

1963 traceline_list : interable 

1964 Iterable containing tracelines to discard. 

1965  

1966 Returns 

1967 ------- 

1968 TracelineArray 

1969 TracelineArray containing tracelines with id numbers not it 

1970 traceline_list. 

1971  

1972 """ 

1973 output_list = [] 

1974 for key,element in self.ndenumerate(): 

1975 if element.id not in element_list: 

1976 output_list.append(element) 

1977 return np.array(output_list, self.dtype).view(ElementArray) 

1978 

1979 @staticmethod 

1980 def from_unv(unv_data_dict, combine=True): 

1981 """ 

1982 Load ElementArrays from universal file data from read_unv 

1983 

1984 Parameters 

1985 ---------- 

1986 unv_data_dict : dict 

1987 Dictionary containing data from read_unv 

1988 combine : bool, optional 

1989 If True, return as a single ElementArray 

1990 

1991 Returns 

1992 ------- 

1993 output_arrays : ElementArray 

1994 Elements read from unv 

1995 

1996 """ 

1997 try: 

1998 datasets = unv_data_dict[2412] 

1999 except KeyError: 

2000 if combine: 

2001 return ElementArray(0) 

2002 else: 

2003 return [] 

2004 output_arrays = [] 

2005 for dataset in datasets: 

2006 output_arrays.append(element_array(dataset.element_labels, 

2007 dataset.fe_descriptor_ids, 

2008 dataset.colors, 

2009 dataset.connectivities)) 

2010 if combine: 

2011 output_arrays = np.concatenate(output_arrays) 

2012 return output_arrays 

2013 

2014 

2015def element_array(id=1, type=None, color=1, 

2016 connectivity=None, structured_array=None): 

2017 """ 

2018 Creates an array that specifies elements in the geometry 

2019 

2020 Creates an array of elements that specify connectivity of sensors 

2021 in a test or analysis. Element arrays can be created using a 

2022 numpy structured array or individual arrays for each attribute. 

2023 Multidimensional arrays can be used. 

2024 

2025 Parameters 

2026 ---------- 

2027 id : ndarray 

2028 Integer array corresponding to the id of the node. Input 

2029 will be cast to an integer (i.e. 2.0 -> 2, 1.9 -> 1) 

2030 type : ndarray 

2031 Element types. See notes for type mapping 

2032 color : ndarray 

2033 color of the elements as integers 

2034 connectivity : ndarray 

2035 An object array of iterables defining the connectivity for each element. 

2036 structured_array : ndarray (structured) 

2037 Alternatively to the individual attributes, a single numpy structured 

2038 array can be passed, which should have the same name as the inputs to 

2039 the function listed above. 

2040 

2041 

2042 Returns 

2043 ------- 

2044 element_array : ElementArray 

2045 

2046 

2047 Notes 

2048 ----- 

2049 Here is a list of element types 

2050 

2051 11 : 'Rod', 

2052 21 : 'Linear beam', 

2053 22 : 'Tapered beam', 

2054 23 : 'Curved beam', 

2055 24 : 'Parabolic beam', 

2056 31 : 'Straight pipe', 

2057 32 : 'Curved pipe', 

2058 41 : 'Plane Stress Linear Triangle', 

2059 42 : 'Plane Stress Parabolic Triangle', 

2060 43 : 'Plane Stress Cubic Triangle', 

2061 44 : 'Plane Stress Linear Quadrilateral', 

2062 45 : 'Plane Stress Parabolic Quadrilateral', 

2063 46 : 'Plane Strain Cubic Quadrilateral', 

2064 51 : 'Plane Strain Linear Triangle', 

2065 52 : 'Plane Strain Parabolic Triangle', 

2066 53 : 'Plane Strain Cubic Triangle', 

2067 54 : 'Plane Strain Linear Quadrilateral', 

2068 55 : 'Plane Strain Parabolic Quadrilateral', 

2069 56 : 'Plane Strain Cubic Quadrilateral', 

2070 61 : 'Plate Linear Triangle', 

2071 62 : 'Plate Parabolic Triangle', 

2072 63 : 'Plate Cubic Triangle', 

2073 64 : 'Plate Linear Quadrilateral', 

2074 65 : 'Plate Parabolic Quadrilateral', 

2075 66 : 'Plate Cubic Quadrilateral', 

2076 71 : 'Membrane Linear Quadrilateral', 

2077 72 : 'Membrane Parabolic Triangle', 

2078 73 : 'Membrane Cubic Triangle', 

2079 74 : 'Membrane Linear Triangle', 

2080 75 : 'Membrane Parabolic Quadrilateral', 

2081 76 : 'Membrane Cubic Quadrilateral', 

2082 81 : 'Axisymetric Solid Linear Triangle', 

2083 82 : 'Axisymetric Solid Parabolic Triangle', 

2084 84 : 'Axisymetric Solid Linear Quadrilateral', 

2085 85 : 'Axisymetric Solid Parabolic Quadrilateral', 

2086 91 : 'Thin Shell Linear Triangle', 

2087 92 : 'Thin Shell Parabolic Triangle', 

2088 93 : 'Thin Shell Cubic Triangle', 

2089 94 : 'Thin Shell Linear Quadrilateral', 

2090 95 : 'Thin Shell Parabolic Quadrilateral', 

2091 96 : 'Thin Shell Cubic Quadrilateral', 

2092 101: 'Thick Shell Linear Wedge', 

2093 102: 'Thick Shell Parabolic Wedge', 

2094 103: 'Thick Shell Cubic Wedge', 

2095 104: 'Thick Shell Linear Brick', 

2096 105: 'Thick Shell Parabolic Brick', 

2097 106: 'Thick Shell Cubic Brick', 

2098 111: 'Solid Linear Tetrahedron', 

2099 112: 'Solid Linear Wedge', 

2100 113: 'Solid Parabolic Wedge', 

2101 114: 'Solid Cubic Wedge', 

2102 115: 'Solid Linear Brick', 

2103 116: 'Solid Parabolic Brick', 

2104 117: 'Solid Cubic Brick', 

2105 118: 'Solid Parabolic Tetrahedron', 

2106 121: 'Rigid Bar', 

2107 122: 'Rigid Element', 

2108 136: 'Node To Node Translational Spring', 

2109 137: 'Node To Node Rotational Spring', 

2110 138: 'Node To Ground Translational Spring', 

2111 139: 'Node To Ground Rotational Spring', 

2112 141: 'Node To Node Damper', 

2113 142: 'Node To Gound Damper', 

2114 151: 'Node To Node Gap', 

2115 152: 'Node To Ground Gap', 

2116 161: 'Lumped Mass', 

2117 171: 'Axisymetric Linear Shell', 

2118 172: 'Axisymetric Parabolic Shell', 

2119 181: 'Constraint', 

2120 191: 'Plastic Cold Runner', 

2121 192: 'Plastic Hot Runner', 

2122 193: 'Plastic Water Line', 

2123 194: 'Plastic Fountain', 

2124 195: 'Plastic Baffle', 

2125 196: 'Plastic Rod Heater', 

2126 201: 'Linear node-to-node interface', 

2127 202: 'Linear edge-to-edge interface', 

2128 203: 'Parabolic edge-to-edge interface', 

2129 204: 'Linear face-to-face interface', 

2130 208: 'Parabolic face-to-face interface', 

2131 212: 'Linear axisymmetric interface', 

2132 213: 'Parabolic axisymmetric interface', 

2133 221: 'Linear rigid surface', 

2134 222: 'Parabolic rigid surface', 

2135 231: 'Axisymetric linear rigid surface', 

2136 232: 'Axisymentric parabolic rigid surface' 

2137 """ 

2138 if structured_array is not None: 

2139 try: 

2140 id = structured_array['id'] 

2141 type = structured_array['type'] 

2142 color = structured_array['color'] 

2143 connectivity = structured_array['connectivity'] 

2144 except (ValueError, TypeError): 

2145 raise ValueError( 

2146 'structured_array must be numpy.ndarray with dtype names "id", "type", "color", and "connectivity"') 

2147 else: 

2148 id = np.array(id) 

2149 type = np.array(type) 

2150 color = np.array(color) 

2151 # Need to make connectivity an object array so don't do this! 

2152 # connectivity = np.array(connectivity,dtype=object) 

2153 

2154 # Don't check shapes because we want them to be broadcastable, but id have to 

2155 # be unique so we will use that for the shape 

2156 earray = ElementArray(id.shape) 

2157 earray.id = id 

2158 earray.type = type 

2159 earray.color = color 

2160 if earray.ndim == 0: 

2161 # This is gross, but it works! 

2162 np.ndarray.__getitem__(earray, 'connectivity')[()] = np.array(connectivity, dtype='uint32') 

2163 else: 

2164 connectivity = np.array(connectivity, dtype=object) 

2165 for key, val in np.ndenumerate(id): 

2166 earray.connectivity[key] = np.array(connectivity[key], dtype='uint32') 

2167 return earray 

2168 

2169 

2170class NodeArray(SdynpyArray): 

2171 """Node information array 

2172 

2173 Use the node_array helper function to create the array. 

2174 """ 

2175 

2176 data_dtype = [('id', 'uint64'), ('coordinate', 'float64', (3,)), ('color', 'uint16'), 

2177 ('def_cs', 'uint64'), ('disp_cs', 'uint64')] 

2178 

2179 def __new__(subtype, shape, buffer=None, offset=0, 

2180 strides=None, order=None): 

2181 # Create the ndarray instance of our type, given the usual 

2182 # ndarray input arguments. This will call the standard 

2183 # ndarray constructor, but return an object of our type. 

2184 # It also triggers a call to InfoArray.__array_finalize__ 

2185 

2186 obj = super(NodeArray, subtype).__new__(subtype, shape, 

2187 NodeArray.data_dtype, buffer, offset, strides, order) 

2188 # Finally, we must return the newly created object: 

2189 return obj 

2190 

2191 def __repr__(self): 

2192 string_out = '{:>8s}, {:>6s}, {:>8s}, {:>8s}, {:>8s}, {:>5s}, {:>5s}\n'.format( 

2193 'Index', 'ID', 'X', 'Y', 'Z', 'DefCS', 'DisCS') 

2194 if self.size == 0: 

2195 string_out += '----------- Empty -------------\n' 

2196 for i, (key, val) in enumerate(self.ndenumerate()): 

2197 if i >= MAX_NUMBER_REPR: 

2198 string_out += ' .\n .\n .\n' 

2199 break 

2200 string_out += '{:>8}, {:>6}, {:>8.3f}, {:>8.3f}, {:>8.3f}, {:>5}, {:>5}\n'.format( 

2201 str(key), val.id, *val.coordinate, val.def_cs, val.disp_cs) 

2202 return string_out 

2203 

2204 def __call__(self, index_by_id): 

2205 """ 

2206 Select nodes by id rather than by index 

2207 

2208 Parameters 

2209 ---------- 

2210 index_by_id : int or np.ndarray 

2211 ID number(s) to use to select nodes. 

2212 

2213 Raises 

2214 ------ 

2215 ValueError 

2216 If specified ID(s) not found in array. 

2217 

2218 Returns 

2219 ------- 

2220 output : NodeArray 

2221 Subset of NodeArray with the specified IDs. 

2222 

2223 """ 

2224 index_dict = {node.id[()]: index for index, node in self.ndenumerate()} 

2225 ids = np.array(index_by_id) 

2226 output = np.empty(ids.shape, dtype=self.dtype).view(self.__class__) 

2227 for key, val in np.ndenumerate(ids): 

2228 try: 

2229 index = index_dict[val] 

2230 except KeyError: 

2231 raise ValueError('ID {:} not found in array'.format(val)) 

2232 output[key] = self[index] 

2233 return output 

2234 

2235 def reduce(self, node_list): 

2236 """ 

2237 Keep only nodes that are contained in node_list 

2238 

2239 Parameters 

2240 ---------- 

2241 node_list : iterable 

2242 Iterable containing nodes to keep. 

2243 

2244 Returns 

2245 ------- 

2246 NodeArray 

2247 NodeArray containing only nodes in node_list. 

2248 

2249 """ 

2250 return self(node_list) 

2251 

2252 @staticmethod 

2253 def from_unv(unv_data_dict, combine=True): 

2254 """ 

2255 Load NodeArrays from universal file data from read_unv 

2256 

2257 Parameters 

2258 ---------- 

2259 unv_data_dict : dict 

2260 Dictionary containing data from read_unv 

2261 combine : bool, optional 

2262 If True, return as a single NodeArray 

2263 

2264 Returns 

2265 ------- 

2266 output_arrays : NodeArray 

2267 Nodes read from unv 

2268 

2269 """ 

2270 try: 

2271 datasets = unv_data_dict[2411] 

2272 except KeyError: 

2273 if combine: 

2274 return NodeArray(0) 

2275 else: 

2276 return [] 

2277 output_arrays = [] 

2278 for dataset in datasets: 

2279 output_arrays.append(node_array(dataset.node_labels, 

2280 dataset.coordinates, 

2281 dataset.colors, 

2282 dataset.export_coordinate_systems, 

2283 dataset.displacement_coordinate_systems)) 

2284 if combine: 

2285 output_arrays = np.concatenate(output_arrays) 

2286 return output_arrays 

2287 

2288 def by_position(self, position_array): 

2289 """ 

2290 Select node by closest position 

2291 

2292 Parameters 

2293 ---------- 

2294 position_array : np.ndarray 

2295 A (...,3) shape array containing positions of nodes to keep 

2296 

2297 Returns 

2298 ------- 

2299 NodeArray 

2300 NodArray containing nodes that were closest to the positions in 

2301 position_array. 

2302 

2303 """ 

2304 node_differences = np.linalg.norm( 

2305 self.coordinate - position_array[(Ellipsis,) + (np.newaxis,) * self.ndim + (slice(None),)], axis=-1) 

2306 node_differences = node_differences.reshape(*position_array.shape[:-1], -1) 

2307 node_indices = np.argmin( 

2308 node_differences, 

2309 axis=-1) 

2310 return self.flatten()[node_indices] 

2311 

2312 @staticmethod 

2313 def project_to_minimum_plane(coordinates, return_3D=True): 

2314 """ 

2315 Projects coordinates to a single best-fit plane 

2316 

2317 Parameters 

2318 ---------- 

2319 coordinates : np.ndarray 

2320 A (...,3) coordinate array. 

2321 return_3D : bool, optional 

2322 If True, return the 3D coordinates of the projected points. 

2323 Otherwise return the projected 2D coordinate. The default is True. 

2324 

2325 Returns 

2326 ------- 

2327 np.ndarray 

2328 Points projected to a best-fit plane. 

2329 

2330 """ 

2331 coords = coordinates.T.copy() 

2332 mean = np.mean(coords, axis=-1, keepdims=True) 

2333 coords -= mean 

2334 U, S, V = np.linalg.svd(coords) 

2335 coords = U.T @ coords 

2336 coords[2, :] = 0 

2337 if return_3D: 

2338 return (U @ coords + mean).T 

2339 else: 

2340 return coords[:2].T 

2341 

2342 def global_coordinate(self, cs_array): 

2343 """ 

2344 Get the global coordinate of each node 

2345 

2346 Parameters 

2347 ---------- 

2348 cs_array : CoordinateSystemArray 

2349 CoordinateSystemArray consisting of the local coordinate systems for 

2350 each node 

2351 

2352 Returns 

2353 ------- 

2354 points : np.ndarray 

2355 Coordinates of the nodes in the global coordinate system. 

2356 

2357 """ 

2358 points = global_coord(cs_array(self.def_cs), self.coordinate) 

2359 return points 

2360 

2361 def triangulate(self, cs_array, projection_function=None, 

2362 return_element_array=True, element_color=1, 

2363 condition_threshold=None): 

2364 """ 

2365 Creates elements for a node set using Delaunay Triangulation 

2366 

2367 Parameters 

2368 ---------- 

2369 cs_array : CoordinateSystemArray 

2370 CoordinateSystemArray containing coordinate systems for each node. 

2371 projection_function : function, optional 

2372 Function to use to project 3D coordinates to 2D coordinates for 

2373 triangulation. The default is None. 

2374 return_element_array : bool, optional 

2375 Returns an ElementArray if True, otherwise it simply returns the 

2376 triangle simplices. The default is True. 

2377 element_color : np.ndarray or int, optional 

2378 Integers representing colors applied to the elements. 

2379 The default is 1. 

2380 condition_threshold : float, optional 

2381 Condition number threshold used to remove triangles that are poorly 

2382 shaped. The default is None. 

2383 

2384 Returns 

2385 ------- 

2386 ElementArray or np.ndarray 

2387 ElementArray containing elements or np.ndarray containing triangle 

2388 simplices. 

2389 

2390 """ 

2391 if projection_function is None: 

2392 def projection_function(coords): return self.project_to_minimum_plane(coords, False) 

2393 # Compute global positions of the nodes 

2394 global_positions = self.global_coordinate(cs_array) 

2395 # Now compute the projection function 

2396 projected_positions = projection_function(global_positions) 

2397 # Now triangulate 

2398 tri = Delaunay(projected_positions) 

2399 simplices = tri.simplices 

2400 if condition_threshold is not None: 

2401 # Now remove small triangles via condition number 

2402 tri_points = projected_positions[tri.simplices, :] 

2403 tri_points -= np.mean(tri_points, axis=1, keepdims=True) 

2404 simplices = simplices[np.linalg.cond(tri_points) < condition_threshold] 

2405 if not return_element_array: 

2406 return simplices 

2407 else: 

2408 # Create an element array 

2409 connectivity = self.id[simplices] 

2410 return element_array(id=np.arange(connectivity.shape[0]) + 1, 

2411 type=61, color=element_color, 

2412 connectivity=connectivity) 

2413 

2414 def by_grid(self, grid_spacing, x_min=None, y_min=None, z_min=None, 

2415 x_max=None, y_max=None, z_max=None): 

2416 """ 

2417 Selects nodes in a grid 

2418 

2419 Parameters 

2420 ---------- 

2421 grid_spacing : float 

2422 Approximate grid spacing between selected nodes 

2423 x_min : float, optional 

2424 Minimum X-value of the grid. 

2425 The default is the minimum node x-coordinate. 

2426 y_min : float, optional 

2427 Minimun Y-value of the grid. 

2428 The default is the minimum node y-coordinate. 

2429 z_min : float, optional 

2430 Minimum Z-value of the grid. 

2431 The default is the minimum node z-coordinate. 

2432 x_max : float, optional 

2433 Maximum X-value of the grid. 

2434 The default is the maximum node x-coordinate. 

2435 y_max : float, optional 

2436 Maximum Y-value of the grid. 

2437 The default is the maximum node y-coordinate. 

2438 z_max : float, optional 

2439 Maximum Z-value of the grid. 

2440 The default is the maximum node z-coordinate. 

2441 

2442 Returns 

2443 ------- 

2444 NodeArray 

2445 NodeArray containing the closest nodes to the grid points 

2446 """ 

2447 if x_min is None: 

2448 x_min = self.coordinate[:, 0].min() 

2449 if y_min is None: 

2450 y_min = self.coordinate[:, 1].min() 

2451 if z_min is None: 

2452 z_min = self.coordinate[:, 2].min() 

2453 if x_max is None: 

2454 x_max = self.coordinate[:, 0].max() 

2455 if y_max is None: 

2456 y_max = self.coordinate[:, 1].max() 

2457 if z_max is None: 

2458 z_max = self.coordinate[:, 2].max() 

2459 min_coords = np.array((x_min, y_min, z_min)) 

2460 max_coords = np.array((x_max, y_max, z_max)) 

2461 range_coords = max_coords - min_coords 

2462 num_grids = np.ceil(range_coords / grid_spacing).astype(int) 

2463 # Now we will create the grid in each dimension using linspace 

2464 grid_arrays = [np.linspace(min_coord, max_coord, num_coord) 

2465 for min_coord, max_coord, num_coord 

2466 in zip(min_coords, max_coords, num_grids)] 

2467 # We will then use meshgrid to assemble the array grid. We will flatten the 

2468 # point dimension and transpose so the array has shape (n_points x 3) 

2469 grid_points = np.array(np.meshgrid(*grid_arrays, indexing='ij')).reshape(3, -1).T 

2470 # Now we will select nodes that are closest to the points in the grid 

2471 candidate_nodes = self.by_position(grid_points) 

2472 # Reduce to only nodes that are within one grid spacing of their requested point 

2473 # this reduces the nodes that are selected by points far from the model 

2474 candidate_nodes = candidate_nodes[ 

2475 np.linalg.norm(candidate_nodes.coordinate - grid_points, axis=-1) < grid_spacing] 

2476 # Remove any duplicates 

2477 candidate_node_ids = np.unique(candidate_nodes.id) 

2478 return candidate_nodes(candidate_node_ids) 

2479 

2480 

2481def node_array(id=1, coordinate=np.array((0, 0, 0)), color=1, def_cs=1, disp_cs=1, 

2482 structured_array=None): 

2483 """ 

2484 Creates an array that specifies nodes in the geometry 

2485 

2486 Creates an array of nodes that specify positions of sensors 

2487 in a test or analysis. Node arrays can be created using a 

2488 numpy structured array or individual arrays for each attribute. 

2489 Multidimensional arrays can be used. 

2490 

2491 Parameters 

2492 ---------- 

2493 id : ndarray 

2494 Integer array corresponding to the id of the node. Input 

2495 will be cast to an integer (i.e. 2.0 -> 2, 1.9 -> 1) 

2496 coordinate : ndarray 

2497 Positions of the nodes in space 

2498 color : ndarray 

2499 color of the nodes as integers 

2500 def_cs : ndarray 

2501 Coordinate system where the node's position is defined 

2502 disp_cs : ndarray 

2503 Coordinate system where the node's displacements are defined 

2504 structured_array : ndarray (structured) 

2505 Alternatively to the individual attributes, a single numpy structured 

2506 array can be passed, which should have the same name as the inputs to 

2507 the function listed above. 

2508 

2509 

2510 Returns 

2511 ------- 

2512 node_array : NodeArray 

2513 """ 

2514 if structured_array is not None: 

2515 try: 

2516 id = structured_array['id'] 

2517 coordinate = structured_array['coordinate'] 

2518 color = structured_array['color'] 

2519 def_cs = structured_array['def_cs'] 

2520 disp_cs = structured_array['disp_cs'] 

2521 except (ValueError, TypeError): 

2522 raise ValueError( 

2523 'structured_array must be numpy.ndarray with dtype names "id", "coordinate", "color", "def_cs" and "disp_cs"') 

2524 else: 

2525 id = np.array(id) 

2526 coordinate = np.array(coordinate) 

2527 color = np.array(color) 

2528 def_cs = np.array(def_cs) 

2529 disp_cs = np.array(disp_cs) 

2530 

2531 # Don't check shapes because we want them to be broadcastable, but id have to 

2532 # be unique so we will use that for the shape 

2533 narray = NodeArray(id.shape) 

2534 narray.id = id 

2535 narray.coordinate = coordinate 

2536 narray.color = color 

2537 narray.def_cs = def_cs 

2538 narray.disp_cs = disp_cs 

2539 

2540 return narray 

2541 

2542 

2543class TracelineArray(SdynpyArray): 

2544 """Traceline information array 

2545 

2546 Use the traceline_array helper function to create the array. 

2547 """ 

2548 data_dtype = [('id', 'uint64'), ('color', 'uint16'), ('description', '<U40'), 

2549 ('connectivity', 'object')] 

2550 

2551 def __new__(subtype, shape, buffer=None, offset=0, 

2552 strides=None, order=None): 

2553 # Create the ndarray instance of our type, given the usual 

2554 # ndarray input arguments. This will call the standard 

2555 # ndarray constructor, but return an object of our type. 

2556 # It also triggers a call to InfoArray.__array_finalize__ 

2557 

2558 obj = super(TracelineArray, subtype).__new__(subtype, shape, 

2559 TracelineArray.data_dtype, buffer, offset, strides, order) 

2560 # Finally, we must return the newly created object: 

2561 return obj 

2562 

2563 def __repr__(self): 

2564 string_out = '{:>8s}, {:>6s}, {:>20s}, {:>5s}, {:>7s}\n'.format( 

2565 'Index', 'ID', 'Description', 'Color', '# Nodes') 

2566 if self.size == 0: 

2567 string_out += '----------- Empty -------------\n' 

2568 for i, (key, val) in enumerate(self.ndenumerate()): 

2569 if i >= MAX_NUMBER_REPR: 

2570 string_out += ' .\n .\n .\n' 

2571 break 

2572 string_out += '{:>8s}, {:>6d}, {:>20s}, {:>5d}, {:>7d}\n'.format( 

2573 str(key), val.id, val.description, val.color, len(val.connectivity)) 

2574 return string_out 

2575 

2576 def __call__(self, index_by_id): 

2577 """ 

2578 Select nodes by id rather than by index 

2579 

2580 Parameters 

2581 ---------- 

2582 index_by_id : int or np.ndarray 

2583 ID number(s) to use to select nodes. 

2584 

2585 Raises 

2586 ------ 

2587 ValueError 

2588 If specified ID(s) not found in array. 

2589 

2590 Returns 

2591 ------- 

2592 output : TracelineArray 

2593 Subset of TracelineArray with the specified IDs. 

2594 

2595 """ 

2596 index_dict = {node.id[()]: index for index, node in self.ndenumerate()} 

2597 ids = np.array(index_by_id) 

2598 output = np.empty(ids.shape, dtype=self.dtype).view(self.__class__) 

2599 for key, val in np.ndenumerate(ids): 

2600 try: 

2601 index = index_dict[val] 

2602 except KeyError: 

2603 raise ValueError('ID {:} not found in array'.format(val)) 

2604 output[key] = self[index] 

2605 return output 

2606 

2607 def reduce(self, node_list): 

2608 """ 

2609 Keep only tracelines fully contain nodes in node_list 

2610 

2611 Parameters 

2612 ---------- 

2613 node_list : iterable 

2614 Iterable containing nodes to keep. 

2615 

2616 Returns 

2617 ------- 

2618 TracelineArray 

2619 TracelineArray containing lines that contain only nodes in node_list. 

2620 

2621 """ 

2622 output_list = [] 

2623 for key, traceline in self.ndenumerate(): 

2624 if np.all(np.isin(traceline.connectivity, node_list) | (traceline.connectivity == 0)): 

2625 output_list.append(traceline) 

2626 return np.array(output_list, self.dtype).view(TracelineArray) 

2627 

2628 def remove(self, traceline_list): 

2629 """ 

2630 Removes tracelines with id numbers in traceline_list 

2631 

2632 Parameters 

2633 ---------- 

2634 traceline_list : interable 

2635 Iterable containing tracelines to discard. 

2636 

2637 Returns 

2638 ------- 

2639 TracelineArray 

2640 TracelineArray containing tracelines with id numbers not it 

2641 traceline_list. 

2642 

2643 """ 

2644 output_list = [] 

2645 for key,traceline in self.ndenumerate(): 

2646 if traceline.id not in traceline_list: 

2647 output_list.append(traceline) 

2648 return np.array(output_list, self.dtype).view(TracelineArray) 

2649 

2650 @staticmethod 

2651 def from_unv(unv_data_dict, combine=True): 

2652 """ 

2653 Load TracelineArrays from universal file data from read_unv 

2654 

2655 Parameters 

2656 ---------- 

2657 unv_data_dict : dict 

2658 Dictionary containing data from read_unv 

2659 combine : bool, optional 

2660 If True, return as a single TracelineArray object 

2661 

2662 Returns 

2663 ------- 

2664 output_arrays : TracelineArray 

2665 Tracelines read from unv 

2666 

2667 """ 

2668 try: 

2669 datasets = unv_data_dict[82] 

2670 except KeyError: 

2671 if combine: 

2672 return TracelineArray(0) 

2673 else: 

2674 return [] 

2675 output_arrays = [] 

2676 for dataset in datasets: 

2677 output_arrays.append(traceline_array(dataset.traceline_number, 

2678 dataset.identification, 

2679 dataset.color, 

2680 dataset.nodes)[np.newaxis]) 

2681 if combine: 

2682 output_arrays = np.concatenate(output_arrays) 

2683 return output_arrays 

2684 

2685 

2686def traceline_array(id=1, description='', color=1, 

2687 connectivity=None, structured_array=None): 

2688 """ 

2689 Creates an array that specifies tracelines in the geometry 

2690 

2691 Creates an array of tracelines that specify connectivity of sensors 

2692 in a test or analysis. Traceline arrays can be created using a 

2693 numpy structured array or individual arrays for each attribute. 

2694 Multidimensional arrays can be used. 

2695 

2696 Parameters 

2697 ---------- 

2698 id : ndarray 

2699 Integer array corresponding to the id of the node. Input 

2700 will be cast to an integer (i.e. 2.0 -> 2, 1.9 -> 1) 

2701 Description : ndarray 

2702 Traceline name 

2703 color : ndarray 

2704 color of the elements as integers 

2705 connectivity : ndarray 

2706 An object array of iterables defining the connectivity for each traceline. 

2707 structured_array : ndarray (structured) 

2708 Alternatively to the individual attributes, a single numpy structured 

2709 array can be passed, which should have the same name as the inputs to 

2710 the function listed above. 

2711 

2712 

2713 Returns 

2714 ------- 

2715 traceline_array : TracelineArray 

2716 

2717 """ 

2718 if structured_array is not None: 

2719 try: 

2720 id = structured_array['id'] 

2721 description = structured_array['description'] 

2722 color = structured_array['color'] 

2723 connectivity = structured_array['connectivity'] 

2724 except (ValueError, TypeError): 

2725 raise ValueError( 

2726 'structured_array must be numpy.ndarray with dtype names "id", "color", "description", and "connectivity"') 

2727 else: 

2728 id = np.array(id) 

2729 description = np.array(description) 

2730 color = np.array(color) 

2731 # Don't do this, need to keep as an object dtype 

2732 # connectivity = np.array(connectivity) 

2733 

2734 # Don't check shapes because we want them to be broadcastable, but id have to 

2735 # be unique so we will use that for the shape 

2736 tlarray = TracelineArray(id.shape) 

2737 tlarray.id = id 

2738 tlarray.description = description 

2739 tlarray.color = color 

2740 if tlarray.ndim == 0: 

2741 np.ndarray.__getitem__(tlarray, 'connectivity')[()] = np.array(connectivity, dtype='uint32') 

2742 else: 

2743 connectivity = np.array(connectivity, dtype=object) 

2744 for key, val in np.ndenumerate(id): 

2745 tlarray.connectivity[key] = np.array(connectivity[key], dtype='uint32') 

2746 

2747 return tlarray 

2748 

2749 

2750from ..fem.sdynpy_exodus import Exodus, ExodusInMemory # noqa: E402 

2751from .sdynpy_shape import shape_array # noqa: E402 

2752 

2753 

2754class Geometry: 

2755 """Container for nodes, coordinate systems, tracelines, and elements 

2756 

2757 Geometry is the class that is most useful for working with the positioning 

2758 and spatial visualization of test or analysis data. It contains functions 

2759 to plot and animate 3D geometry""" 

2760 

2761 def __init__(self, node=NodeArray((0,)), coordinate_system=CoordinateSystemArray((0,)), 

2762 traceline=TracelineArray((0,)), element=ElementArray((0,))): 

2763 """ 

2764 Initialize a geometry object with nodes, coordinate systems, tracelines, 

2765 and elements. 

2766 

2767 All input arguments will be flattened when passed to the Geometry, as 

2768 the geometry does not support multi-dimensional object arrays. 

2769 

2770 Parameters 

2771 ---------- 

2772 node : NodeArray, optional 

2773 The set of nodes in the geometry. The default is NodeArray((0,)), 

2774 an empty NodeArray. 

2775 coordinate_system : CoordinateSystemArray, optional 

2776 The set of coordinate systems in the geometry. The default is 

2777 CoordinateSystemArray((0,)), an empty CoordinateSystemArray 

2778 traceline : TracelineArray, optional 

2779 The set of tracelines defined in the geometry. The default is 

2780 TracelineArray((0,)), an empty TracelineArray 

2781 element : ElementArray, optional 

2782 The set of elements defined in the geometry. The default is 

2783 ElementArray((0,)), an empty element array. 

2784 

2785 Returns 

2786 ------- 

2787 None. 

2788 

2789 """ 

2790 self.node = node.flatten() 

2791 self.coordinate_system = coordinate_system.flatten() 

2792 self.traceline = traceline.flatten() 

2793 self.element = element.flatten() 

2794 

2795 def __repr__(self): 

2796 string_out = '\n'.join(val.capitalize( 

2797 ) + '\n' + repr(self.__dict__[val]) for val in ['node', 'coordinate_system', 'traceline', 'element']) 

2798 return string_out 

2799 

2800 def reduce(self, node_list): 

2801 """ 

2802 Reduce the geometry to only contain nodes in `node_list` 

2803 

2804 Elements and tracelines will only be kept if all nodes in each element 

2805 or traceline are in `node_list`. Coordinate systems will only be kept 

2806 if they are required by a node in `node_list` 

2807 

2808 Parameters 

2809 ---------- 

2810 node_list : iterable 

2811 An iterable of integer node id numbers. 

2812 

2813 Returns 

2814 ------- 

2815 Geometry 

2816 A geometry only containing the nodes in `node_list` 

2817 

2818 """ 

2819 node = self.node.reduce(node_list).copy() 

2820 traceline = self.traceline.reduce(node_list).copy() 

2821 element = self.element.reduce(node_list).copy() 

2822 css = np.unique(np.concatenate((np.unique(node.def_cs), node.disp_cs))) 

2823 coordinate_system = self.coordinate_system(css).copy() 

2824 return Geometry(node, coordinate_system, traceline, element) 

2825 

2826 def add_traceline(self, connectivity, id=None, description='', color=1): 

2827 """ 

2828 Adds a traceline to the geometry 

2829 

2830 Parameters 

2831 ---------- 

2832 connectivity : iterable 

2833 Iterable containing the node_ids to connect with the traceline 

2834 id : TYPE, optional 

2835 The id number of the new traceline. The default is None, which 

2836 results in the new traceline having an id of one greater than the 

2837 current maximum traceline it. 

2838 description : str, optional 

2839 A string description of the new traceline. The default is ''. 

2840 color : int, optional 

2841 An integer corresponding to the color of the new traceline. 

2842 The default is 1. 

2843 

2844 Returns 

2845 ------- 

2846 None. Modifications are made in-place to the current geometry. 

2847 

2848 """ 

2849 if id is None: 

2850 if self.traceline.size > 0: 

2851 id = np.max(self.traceline.id) + 1 

2852 else: 

2853 id = 1 

2854 new_traceline = traceline_array((id,), description, color, 

2855 [np.array(connectivity)]) 

2856 self.traceline = np.concatenate((self.traceline, new_traceline), axis=0) 

2857 

2858 def add_element(self, elem_type, connectivity, id=None, color=1): 

2859 if id is None: 

2860 if self.element.size > 0: 

2861 id = np.max(self.element.id) + 1 

2862 else: 

2863 id = 1 

2864 new_element = element_array((id,), elem_type, color, [np.array(connectivity)]) 

2865 self.element = np.concatenate((self.element, new_element)) 

2866 

2867 def add_node(self, coordinate, id=None, color=1, def_cs=1, disp_cs=1): 

2868 """ 

2869 Adds a node to the geometry 

2870 

2871 Parameters 

2872 ---------- 

2873 coordinate : iterable 

2874 A length-3 iterable containing the node's position in its 

2875 definition coordinate system. 

2876 id : int, optional 

2877 The id number of the new node. The default is None, which results 

2878 in the new node having an id of one greater than the current 

2879 maximum node id. 

2880 color : int, optional 

2881 An integer corresponding to the color of the new node. 

2882 The default is 1. 

2883 def_cs : int, optional 

2884 The id of the coordinate system in which the node's position is 

2885 defined. The default is 1. 

2886 disp_cs : int, optional 

2887 The id of the coordinate system in which the node's displacements 

2888 are defined. The default is 1. 

2889 

2890 Returns 

2891 ------- 

2892 None. Modifications are made in-place to the current geometry. 

2893 

2894 """ 

2895 if id is None: 

2896 if self.node.size > 0: 

2897 id = np.max(self.node.id) + 1 

2898 else: 

2899 id = 1 

2900 new_node = node_array((id,), np.array(coordinate, dtype='float64'), 

2901 color, def_cs, disp_cs) 

2902 self.node = np.concatenate((self.node, new_node), axis=0) 

2903 

2904 def add_coordinate_system(self, matrix=None, id=None, name='', color=1, cs_type=0): 

2905 """ 

2906 Adds a coordinate system to the geometry 

2907 

2908 Parameters 

2909 ---------- 

2910 matrix : ndarray, optional 

2911 A 4x3 transformation matrix (rows 0-2 are the rotation matrix, row 

2912 3 is the origin). The default is None, which results in a global 

2913 Cartesian coordinate system (identity rotation, zero origin). 

2914 id : int, optional 

2915 The id number of the new coordinate system. The default is None, 

2916 which results in the new coordinate system having an id of one 

2917 greater than the current maximum coordinate system id. 

2918 name : str, optional 

2919 A string name for the new coordinate system. The default is ''. 

2920 color : int, optional 

2921 An integer corresponding to the color of the new coordinate system. 

2922 The default is 1. 

2923 cs_type : int, optional 

2924 The coordinate system type (0 = Cartesian, 1 = Polar, 

2925 2 = Spherical). The default is 0. 

2926 

2927 Returns 

2928 ------- 

2929 None. Modifications are made in-place to the current geometry. 

2930 

2931 """ 

2932 if id is None: 

2933 if self.coordinate_system.size > 0: 

2934 id = np.max(self.coordinate_system.id) + 1 

2935 else: 

2936 id = 1 

2937 if matrix is None: 

2938 matrix = np.concatenate((np.eye(3), np.zeros((1, 3))), axis=0) 

2939 new_cs = coordinate_system_array((id,), name, color, cs_type, 

2940 np.array(matrix, dtype='float64')) 

2941 self.coordinate_system = np.concatenate((self.coordinate_system, new_cs), axis=0) 

2942 

2943 def edit(self=None, node_size=10, line_width=2): 

2944 """ 

2945 Open an interactive Qt GUI for editing this geometry in place. 

2946 

2947 Can also be called on the class itself — ``new_geom = 

2948 Geometry.edit()`` — to create a new empty geometry and build it from 

2949 scratch in the editor. In that case the new Geometry object is 

2950 returned (populated in place as you edit; if the call blocks in the Qt 

2951 event loop, it returns once the window is closed). 

2952 

2953 A window opens with a 3-D view of the geometry (left, resizable via the 

2954 splitter) beside editable tables for the nodes, coordinate systems, 

2955 tracelines, and elements (right). Each coordinate system is drawn as a 

2956 labeled X/Y/Z arrow triad at its origin (labels read e.g. '1X+' for 

2957 coordinate system 1's X+ axis). The active tab determines what 

2958 clicking in the 

2959 3-D view selects — nodes, coordinate systems, tracelines, or elements — 

2960 and what the buttons operate on. Hold Shift to select multiple items; 

2961 press Escape to deselect everything; changing tabs deselects everything. 

2962 

2963 On the Nodes and Coord Systems tabs, "Add" immediately appends a new 

2964 item. On the Tracelines tab, "Add" is a toggle: while active, click the 

2965 first node and Shift-click the rest in order, then "Create" commits 

2966 them as a new traceline. On the Elements tab, "Add" is replaced by 

2967 three mutually exclusive toggles — "Add Beams" (type 21), "Add 

2968 Triangles" (type 41), and "Add Quadrilaterals" (type 44). While one is 

2969 active, clicking in the 3-D view picks nodes instead of elements 

2970 (plain click restarts the pick list, Shift-click extends it), and each 

2971 element is committed automatically as soon as enough nodes have been 

2972 picked (2, 3, or 4), ready for the next element of the same type. 

2973 

2974 All changes are applied to this Geometry object in place, so the object 

2975 reflects the edits once the window is closed. This method depends only 

2976 on ``qtpy``/``pyvistaqt`` (already required by :meth:`plot`) and on the 

2977 ``add_node``, ``add_coordinate_system``, ``add_traceline``, and 

2978 ``add_element`` methods, so it is self-contained and can be shared as a 

2979 single method. 

2980 

2981 Parameters 

2982 ---------- 

2983 node_size : int, optional 

2984 Pixel size used to display nodes in the 3-D view. The default is 10. 

2985 line_width : int, optional 

2986 Pixel width used to display tracelines and element edges. The 

2987 default is 2. 

2988 

2989 Returns 

2990 ------- 

2991 window or geometry 

2992 When called on an instance (``g.edit()``), the editor QMainWindow 

2993 is returned (also stored on ``self._editor_window``). When called 

2994 on the class (``Geometry.edit()``), the newly created Geometry is 

2995 returned instead. 

2996 """ 

2997 import sys 

2998 import pyvista as pv 

2999 from qtpy import QtCore 

3000 from qtpy.QtCore import QItemSelectionModel 

3001 from qtpy.QtWidgets import ( 

3002 QApplication, QMainWindow, QWidget, QSplitter, QVBoxLayout, 

3003 QHBoxLayout, QTabWidget, QTableWidget, QTableWidgetItem, QPushButton, 

3004 QLabel, QMessageBox, QAbstractItemView, QComboBox, QStyledItemDelegate, 

3005 QInputDialog, QDialog, QFormLayout, QDoubleSpinBox, QDialogButtonBox) 

3006 try: 

3007 from qtpy.QtWidgets import QShortcut 

3008 except ImportError: 

3009 from qtpy.QtGui import QShortcut 

3010 from pyvistaqt import QtInteractor 

3011 

3012 # Called on the class (Geometry.edit()) rather than an instance: 

3013 # create a new empty geometry to build in the editor and return it 

3014 created_new = self is None 

3015 if created_new: 

3016 self = Geometry() 

3017 geometry = self 

3018 # A node's global position requires its definition coordinate system to 

3019 # exist, so guarantee at least a global Cartesian system is present. 

3020 if geometry.coordinate_system.size == 0: 

3021 geometry.add_coordinate_system() 

3022 

3023 app = QApplication.instance() 

3024 created_app = app is None 

3025 if created_app: 

3026 app = QApplication(sys.argv) 

3027 

3028 # ------------------------------------------------------------------ # 

3029 # Editor state and small helpers 

3030 # ------------------------------------------------------------------ # 

3031 # add_mode: None (selecting), 'traceline', or an element type (21/41/44) 

3032 # picked: ordered node ids accumulated while an add mode is active 

3033 # selected: ids of selected items of the active tab's kind 

3034 state = {'add_mode': None, 'picked': [], 'selected': [], 

3035 'show_dofs': False} 

3036 flags = {'populating': False} 

3037 elem_node_counts = {21: 2, 41: 3, 44: 4} 

3038 

3039 def _fmt(v): 

3040 if isinstance(v, (float, np.floating)): 

3041 return repr(float(v)) 

3042 return str(int(v)) 

3043 

3044 def _conn_to_str(connectivity): 

3045 return ', '.join(str(int(c)) for c in np.atleast_1d(connectivity)) 

3046 

3047 def _str_to_conn(text): 

3048 return np.array([int(t) for t in text.replace(',', ' ').split()], 

3049 dtype='uint32') 

3050 

3051 # Color names in the order of the sdynpy (I-deas) rendering colormap: 

3052 # index 0 = Black ... index 15 = White 

3053 color_names = ['Black', 'Blue', 'Gray Blue', 'Light Blue', 'Cyan', 

3054 'Dark Olive', 'Dark Green', 'Green', 'Yellow', 

3055 'Golden Orange', 'Orange', 'Red', 'Magenta', 

3056 'Light Magenta', 'Pink', 'White'] 

3057 

3058 def _color_name(value): 

3059 value = int(value) 

3060 return color_names[value] if 0 <= value < 16 else str(value) 

3061 

3062 def _color_value(text): 

3063 text = text.strip() 

3064 try: 

3065 return int(text) 

3066 except ValueError: 

3067 pass 

3068 for value, name in enumerate(color_names): 

3069 if name.lower() == text.lower(): 

3070 return value 

3071 raise ValueError('Unknown color: {}'.format(text)) 

3072 

3073 cs_type_names = ['Cartesian', 'Cylindrical', 'Spherical'] 

3074 

3075 def _cs_type_name(value): 

3076 value = int(value) 

3077 return cs_type_names[value] if 0 <= value < 3 else str(value) 

3078 

3079 def _cs_type_value(text): 

3080 text = text.strip() 

3081 try: 

3082 return int(text) 

3083 except ValueError: 

3084 pass 

3085 for value, name in enumerate(cs_type_names): 

3086 if name.lower() == text.lower(): 

3087 return value 

3088 if text.lower() == 'sypherical': # sdynpy's Excel template spelling 

3089 return 2 

3090 raise ValueError('Unknown coordinate system type: {}'.format(text)) 

3091 

3092 # ------------------------------------------------------------------ # 

3093 # Window scaffolding 

3094 # ------------------------------------------------------------------ # 

3095 window = QMainWindow() 

3096 window.setWindowTitle('SDynPy Geometry Editor') 

3097 window.resize(1250, 780) 

3098 central = QWidget() 

3099 window.setCentralWidget(central) 

3100 splitter = QSplitter(QtCore.Qt.Horizontal) 

3101 splitter.setHandleWidth(8) 

3102 splitter.setChildrenCollapsible(False) 

3103 QHBoxLayout(central).addWidget(splitter) 

3104 

3105 # multi_samples=0: multisampling causes partially-rendered/faded lines 

3106 # on some macOS/VTK combinations, and it must be disabled at 

3107 # construction (before the GL context is realized) to take effect 

3108 interactor = QtInteractor(window, multi_samples=0) 

3109 # plot() calls plotter.show(); the interactor is already visible via its 

3110 # parent window, so neutralize its show() (headless idiom). 

3111 interactor.show = lambda *a, **k: None 

3112 interactor.add_axes() 

3113 splitter.addWidget(interactor) 

3114 

3115 panel = QWidget() 

3116 panel_layout = QVBoxLayout(panel) 

3117 splitter.addWidget(panel) 

3118 splitter.setStretchFactor(0, 2) 

3119 splitter.setStretchFactor(1, 1) 

3120 splitter.setSizes([2 * window.width() // 3, window.width() // 3]) 

3121 

3122 tabs = QTabWidget() 

3123 panel_layout.addWidget(tabs) 

3124 # Transient feedback (pick progress, errors) lives below the table 

3125 status = QLabel('') 

3126 panel_layout.addWidget(status) 

3127 

3128 node_cols = ['ID', 'X', 'Y', 'Z', 'Color', 'Def CS', 'Disp CS'] 

3129 cs_cols = ['ID', 'Name', 'Color', 'Type', 'Origin X', 'Origin Y', 'Origin Z'] 

3130 tl_cols = ['ID', 'Color', 'Description', 'Connectivity'] 

3131 elem_cols = ['ID', 'Type', 'Color', 'Connectivity'] 

3132 

3133 def _make_table(headers): 

3134 t = QTableWidget(0, len(headers)) 

3135 t.setHorizontalHeaderLabels(headers) 

3136 t.setSelectionBehavior(QAbstractItemView.SelectRows) 

3137 # Excel-style: plain click selects one row (deselecting the rest), 

3138 # Shift-click selects the range in between, Cmd-click (Ctrl on 

3139 # Windows) adds/removes individual rows 

3140 t.setSelectionMode(QAbstractItemView.ExtendedSelection) 

3141 t.horizontalHeader().setStretchLastSection(True) 

3142 return t 

3143 

3144 node_table = _make_table(node_cols) 

3145 cs_table = _make_table(cs_cols) 

3146 tl_table = _make_table(tl_cols) 

3147 elem_table = _make_table(elem_cols) 

3148 tabs.addTab(node_table, 'Nodes') 

3149 tabs.addTab(cs_table, 'Coord Systems') 

3150 tabs.addTab(tl_table, 'Tracelines') 

3151 tabs.addTab(elem_table, 'Elements') 

3152 all_tables = [node_table, cs_table, tl_table, elem_table] 

3153 

3154 def _ids_for_tab(tab): 

3155 return (geometry.node.id, geometry.coordinate_system.id, 

3156 geometry.traceline.id, geometry.element.id)[tab] 

3157 

3158 class _ChoiceDelegate(QStyledItemDelegate): 

3159 # Dropdown of named choices for columns stored as integers 

3160 def __init__(self, parent, items): 

3161 super().__init__(parent) 

3162 self._items = items 

3163 

3164 def createEditor(self, parent, option, index): 

3165 combo = QComboBox(parent) 

3166 combo.addItems(self._items) 

3167 return combo 

3168 

3169 def setEditorData(self, editor, index): 

3170 position = editor.findText(str(index.data() or '')) 

3171 if position >= 0: 

3172 editor.setCurrentIndex(position) 

3173 

3174 def setModelData(self, editor, model, index): 

3175 model.setData(index, editor.currentText()) 

3176 

3177 # Named-choice columns per table: {column: list of names}. 

3178 # Nodes/Tracelines/Elements have a Color column; Coord Systems also 

3179 # has the Type column. 

3180 choice_columns_by_table = ({4: color_names}, 

3181 {2: color_names, 3: cs_type_names}, 

3182 {1: color_names}, 

3183 {2: color_names}) 

3184 for table, choice_columns in zip(all_tables, choice_columns_by_table): 

3185 for column, items in choice_columns.items(): 

3186 table.setItemDelegateForColumn( 

3187 column, _ChoiceDelegate(table, items)) 

3188 

3189 # ------------------------------------------------------------------ # 

3190 # Buttons 

3191 # ------------------------------------------------------------------ # 

3192 buttons = QHBoxLayout() 

3193 panel_layout.addLayout(buttons) 

3194 btn_add = QPushButton('Add') 

3195 btn_beam = QPushButton('Add Beams') 

3196 btn_tri = QPushButton('Add Triangles') 

3197 btn_quad = QPushButton('Add Quadrilaterals') 

3198 for b in (btn_beam, btn_tri, btn_quad): 

3199 b.setCheckable(True) 

3200 b.setVisible(False) 

3201 btn_create = QPushButton('Create') 

3202 btn_create.setVisible(False) 

3203 btn_delete = QPushButton('Delete') 

3204 btn_dofs = QPushButton('Show DOF Arrows') 

3205 btn_dofs.setCheckable(True) 

3206 btn_rotate = QPushButton('Rotate Coordinate Systems') 

3207 btn_rotate.setVisible(False) 

3208 for w in (btn_add, btn_beam, btn_tri, btn_quad, btn_create, btn_delete, 

3209 btn_dofs, btn_rotate): 

3210 buttons.addWidget(w) 

3211 

3212 # ------------------------------------------------------------------ # 

3213 # Geometry queries for picking 

3214 # ------------------------------------------------------------------ # 

3215 def _scene_scale(): 

3216 if geometry.node.size > 1: 

3217 extent = np.max(np.ptp(geometry.global_node_coordinate(), axis=0)) 

3218 if extent > 0: 

3219 return extent 

3220 return 1.0 

3221 

3222 def _traceline_segments(connectivity): 

3223 # Split a traceline's connectivity on 0 (pen-up) entries 

3224 segments, current = [], [] 

3225 for c in np.atleast_1d(connectivity): 

3226 if int(c) == 0: 

3227 if len(current) >= 2: 

3228 segments.append(np.array(current)) 

3229 current = [] 

3230 else: 

3231 current.append(int(c)) 

3232 if len(current) >= 2: 

3233 segments.append(np.array(current)) 

3234 return segments 

3235 

3236 def _point_segment_distance(p, a, b): 

3237 ab = b - a 

3238 denom = np.dot(ab, ab) 

3239 t = 0.0 if denom == 0 else np.clip(np.dot(p - a, ab) / denom, 0, 1) 

3240 return np.linalg.norm(p - (a + t * ab)) 

3241 

3242 def _polyline_distance(p, node_ids, close=False): 

3243 ids = [int(i) for i in np.atleast_1d(node_ids) 

3244 if np.any(geometry.node.id == i)] 

3245 if len(ids) == 0: 

3246 return np.inf 

3247 points = geometry.global_node_coordinate(np.array(ids)) 

3248 if len(ids) == 1: 

3249 return np.linalg.norm(points[0] - p) 

3250 if close and len(points) > 2: 

3251 points = np.concatenate((points, points[:1])) 

3252 return min(_point_segment_distance(p, points[k], points[k + 1]) 

3253 for k in range(len(points) - 1)) 

3254 

3255 def _nearest_node(p): 

3256 if geometry.node.size == 0: 

3257 return None 

3258 coords = geometry.global_node_coordinate() 

3259 return int(geometry.node.id[ 

3260 int(np.argmin(np.linalg.norm(coords - p[None, :], axis=1)))]) 

3261 

3262 def _nearest_cs(p): 

3263 if geometry.coordinate_system.size == 0: 

3264 return None 

3265 origins = geometry.coordinate_system.matrix[:, 3, :] 

3266 return int(geometry.coordinate_system.id[ 

3267 int(np.argmin(np.linalg.norm(origins - p[None, :], axis=1)))]) 

3268 

3269 def _nearest_traceline(p): 

3270 best, best_distance = None, np.inf 

3271 for tl in geometry.traceline: 

3272 segments = _traceline_segments(tl.connectivity) 

3273 distance = min((_polyline_distance(p, s) for s in segments), 

3274 default=np.inf) 

3275 if distance < best_distance: 

3276 best, best_distance = int(tl.id), distance 

3277 return best 

3278 

3279 def _nearest_element(p): 

3280 best, best_distance = None, np.inf 

3281 for elem in geometry.element: 

3282 connectivity = np.atleast_1d(elem.connectivity) 

3283 distance = _polyline_distance(p, connectivity, 

3284 close=len(connectivity) > 2) 

3285 if distance < best_distance: 

3286 best, best_distance = int(elem.id), distance 

3287 return best 

3288 

3289 # ------------------------------------------------------------------ # 

3290 # Table <-> geometry synchronization 

3291 # ------------------------------------------------------------------ # 

3292 def refresh_tables(): 

3293 flags['populating'] = True 

3294 node_table.setRowCount(geometry.node.size) 

3295 for r in range(geometry.node.size): 

3296 n = geometry.node[r] 

3297 for c, v in enumerate((_fmt(n.id), _fmt(n.coordinate[0]), 

3298 _fmt(n.coordinate[1]), _fmt(n.coordinate[2]), 

3299 _color_name(n.color), _fmt(n.def_cs), 

3300 _fmt(n.disp_cs))): 

3301 node_table.setItem(r, c, QTableWidgetItem(v)) 

3302 

3303 cs_table.setRowCount(geometry.coordinate_system.size) 

3304 for r in range(geometry.coordinate_system.size): 

3305 cs = geometry.coordinate_system[r] 

3306 cs_table.setItem(r, 0, QTableWidgetItem(_fmt(cs.id))) 

3307 cs_table.setItem(r, 1, QTableWidgetItem(str(cs.name))) 

3308 cs_table.setItem(r, 2, QTableWidgetItem(_color_name(cs.color))) 

3309 cs_table.setItem(r, 3, QTableWidgetItem(_cs_type_name(cs.cs_type))) 

3310 for c in range(3): 

3311 cs_table.setItem(r, 4 + c, QTableWidgetItem(_fmt(cs.matrix[3, c]))) 

3312 

3313 tl_table.setRowCount(geometry.traceline.size) 

3314 for r in range(geometry.traceline.size): 

3315 tl = geometry.traceline[r] 

3316 tl_table.setItem(r, 0, QTableWidgetItem(_fmt(tl.id))) 

3317 tl_table.setItem(r, 1, QTableWidgetItem(_color_name(tl.color))) 

3318 tl_table.setItem(r, 2, QTableWidgetItem(str(tl.description))) 

3319 tl_table.setItem(r, 3, QTableWidgetItem(_conn_to_str(tl.connectivity))) 

3320 

3321 elem_table.setRowCount(geometry.element.size) 

3322 for r in range(geometry.element.size): 

3323 el = geometry.element[r] 

3324 elem_table.setItem(r, 0, QTableWidgetItem(_fmt(el.id))) 

3325 elem_table.setItem(r, 1, QTableWidgetItem(_fmt(el.type))) 

3326 elem_table.setItem(r, 2, QTableWidgetItem(_color_name(el.color))) 

3327 elem_table.setItem(r, 3, QTableWidgetItem(_conn_to_str(el.connectivity))) 

3328 flags['populating'] = False 

3329 

3330 def _apply_node(row, col, txt): 

3331 if col == 0: 

3332 geometry.node.id[row] = int(txt) 

3333 elif col in (1, 2, 3): 

3334 geometry.node.coordinate[row, col - 1] = float(txt) 

3335 elif col == 4: 

3336 geometry.node.color[row] = _color_value(txt) 

3337 elif col == 5: 

3338 geometry.node.def_cs[row] = int(txt) 

3339 elif col == 6: 

3340 geometry.node.disp_cs[row] = int(txt) 

3341 

3342 def _apply_cs(row, col, txt): 

3343 if col == 0: 

3344 geometry.coordinate_system.id[row] = int(txt) 

3345 elif col == 1: 

3346 geometry.coordinate_system.name[row] = txt 

3347 elif col == 2: 

3348 geometry.coordinate_system.color[row] = _color_value(txt) 

3349 elif col == 3: 

3350 geometry.coordinate_system.cs_type[row] = _cs_type_value(txt) 

3351 else: 

3352 geometry.coordinate_system.matrix[row, 3, col - 4] = float(txt) 

3353 

3354 def _apply_tl(row, col, txt): 

3355 if col == 0: 

3356 geometry.traceline.id[row] = int(txt) 

3357 elif col == 1: 

3358 geometry.traceline.color[row] = _color_value(txt) 

3359 elif col == 2: 

3360 geometry.traceline.description[row] = txt 

3361 elif col == 3: 

3362 geometry.traceline.connectivity[row] = _str_to_conn(txt) 

3363 

3364 def _apply_elem(row, col, txt): 

3365 if col == 0: 

3366 geometry.element.id[row] = int(txt) 

3367 elif col == 1: 

3368 geometry.element.type[row] = int(txt) 

3369 elif col == 2: 

3370 geometry.element.color[row] = _color_value(txt) 

3371 elif col == 3: 

3372 geometry.element.connectivity[row] = _str_to_conn(txt) 

3373 

3374 def _remap_node_ids(mapping): 

3375 # Renumber node references in traceline/element connectivity after 

3376 # node ids change. Applied as one simultaneous mapping so 

3377 # overlapping renames (e.g. offsetting 1,2,3 by +1) don't chain. 

3378 if not mapping: 

3379 return 

3380 for idx in range(geometry.traceline.size): 

3381 conn = np.atleast_1d(geometry.traceline.connectivity[idx]) 

3382 geometry.traceline.connectivity[idx] = np.array( 

3383 [mapping.get(int(c), int(c)) if int(c) != 0 else 0 

3384 for c in conn], dtype='uint64') 

3385 for idx in range(geometry.element.size): 

3386 conn = np.atleast_1d(geometry.element.connectivity[idx]) 

3387 geometry.element.connectivity[idx] = np.array( 

3388 [mapping.get(int(c), int(c)) for c in conn], dtype='uint64') 

3389 

3390 def _remap_cs_ids(mapping): 

3391 # Renumber coordinate-system references on nodes after cs ids change 

3392 if not mapping or geometry.node.size == 0: 

3393 return 

3394 for field in (geometry.node.def_cs, geometry.node.disp_cs): 

3395 for i in range(len(field)): 

3396 field[i] = mapping.get(int(field[i]), int(field[i])) 

3397 

3398 def _change_ids(tab_index, new_id_by_row): 

3399 # Assign new ids for the given rows and remap all references. 

3400 # Validated up front so a bad value changes nothing. 

3401 id_array = _ids_for_tab(tab_index) 

3402 mapping = {} 

3403 for r, new_id in new_id_by_row.items(): 

3404 new_id = int(new_id) 

3405 if new_id < 0: 

3406 raise ValueError('ids must be non-negative') 

3407 old_id = int(id_array[r]) 

3408 if old_id != new_id: 

3409 mapping[old_id] = new_id 

3410 for r, new_id in new_id_by_row.items(): 

3411 id_array[r] = int(new_id) 

3412 if tab_index == 0: 

3413 _remap_node_ids(mapping) 

3414 elif tab_index == 1: 

3415 _remap_cs_ids(mapping) 

3416 

3417 def _finish_table_edit(table, tab_index, selected): 

3418 # Repopulate (normalizes display, reverts bad input) and restore 

3419 # the row selection it clears 

3420 refresh_tables() 

3421 flags['populating'] = True 

3422 selection_model = table.selectionModel() 

3423 for r in selected: 

3424 if r < table.rowCount(): 

3425 selection_model.select( 

3426 table.model().index(r, 0), 

3427 QItemSelectionModel.Select | QItemSelectionModel.Rows) 

3428 flags['populating'] = False 

3429 ids_arr = _ids_for_tab(tab_index) 

3430 state['selected'] = [int(ids_arr[r]) for r in selected 

3431 if r < len(ids_arr)] 

3432 render() 

3433 

3434 def _apply_edit(table, tab_index, apply_func, row, col, txt, selected): 

3435 # Editing a cell in a multi-row selection applies the new value to 

3436 # that column for every selected row 

3437 if len(selected) > 1 and row in selected: 

3438 apply_rows = selected 

3439 else: 

3440 apply_rows = [row] 

3441 try: 

3442 if col == 0: 

3443 # id edits also renumber everything referencing those ids 

3444 _change_ids(tab_index, 

3445 {r: int(txt) for r in apply_rows}) 

3446 else: 

3447 for r in apply_rows: 

3448 apply_func(r, col, txt) 

3449 except (ValueError, TypeError, OverflowError): 

3450 pass 

3451 _finish_table_edit(table, tab_index, selected) 

3452 

3453 def _make_cell_changed(table, tab_index, apply_func): 

3454 def handler(row, col): 

3455 if flags['populating']: 

3456 return 

3457 txt = table.item(row, col).text() 

3458 selected = sorted({i.row() for i in table.selectedIndexes()}) 

3459 _apply_edit(table, tab_index, apply_func, row, col, txt, selected) 

3460 return handler 

3461 

3462 def _make_context_edit(table, tab_index, apply_func, choice_columns): 

3463 # Right-clicking a cell edits it WITHOUT disturbing the current 

3464 # selection (double-click editing collapses the selection first), 

3465 # so this is the way to edit a column for many rows at once 

3466 def show_dialog(row, col, current, selected): 

3467 n_rows = len(selected) if (len(selected) > 1 and row in selected) else 1 

3468 header = table.horizontalHeaderItem(col).text() 

3469 label = '{} for {} selected row{}:'.format( 

3470 header, n_rows, 's' if n_rows > 1 else '') 

3471 if col == 0 and n_rows > 1: 

3472 # IDs must stay distinct, so a multi-row ID edit adds an 

3473 # offset to each selected ID instead of setting them equal 

3474 offset, ok = QInputDialog.getInt( 

3475 window, 'Add ID Offset', 

3476 'Offset to add to {} selected IDs:'.format(n_rows), 0) 

3477 if not ok or offset == 0: 

3478 return 

3479 ids_before = _ids_for_tab(tab_index).copy() 

3480 try: 

3481 _change_ids(tab_index, 

3482 {r: int(ids_before[r]) + offset 

3483 for r in selected}) 

3484 except (ValueError, TypeError, OverflowError): 

3485 pass 

3486 _finish_table_edit(table, tab_index, selected) 

3487 return 

3488 if col in choice_columns: 

3489 items = choice_columns[col] 

3490 start = items.index(current) if current in items else 0 

3491 txt, ok = QInputDialog.getItem( 

3492 window, 'Edit ' + header, label, items, start, False) 

3493 else: 

3494 txt, ok = QInputDialog.getText( 

3495 window, 'Edit ' + header, label, text=current) 

3496 if not ok: 

3497 return 

3498 _apply_edit(table, tab_index, apply_func, row, col, txt, selected) 

3499 

3500 def handler(pos): 

3501 item = table.itemAt(pos) 

3502 if item is None: 

3503 return 

3504 row, col = item.row(), item.column() 

3505 current = item.text() 

3506 selected = sorted({i.row() for i in table.selectedIndexes()}) 

3507 

3508 # Defer the modal dialog until the right-click's mouse release 

3509 # has actually been processed -- opening it while the button is 

3510 # still down leaves the table holding a mouse grab that eats 

3511 # every subsequent click in the window (tabs stop responding). 

3512 # A 0 ms defer can still fire before the release arrives, so 

3513 # poll until no mouse button is pressed. 

3514 def open_when_released(): 

3515 if QApplication.mouseButtons() != QtCore.Qt.NoButton: 

3516 QtCore.QTimer.singleShot(50, open_when_released) 

3517 return 

3518 show_dialog(row, col, current, selected) 

3519 QtCore.QTimer.singleShot(0, open_when_released) 

3520 return handler 

3521 

3522 table_wiring = ((node_table, 0, _apply_node), 

3523 (cs_table, 1, _apply_cs), 

3524 (tl_table, 2, _apply_tl), 

3525 (elem_table, 3, _apply_elem)) 

3526 for (table, tab_index, apply_func), choice_columns in zip( 

3527 table_wiring, choice_columns_by_table): 

3528 table.cellChanged.connect( 

3529 _make_cell_changed(table, tab_index, apply_func)) 

3530 table.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 

3531 table.customContextMenuRequested.connect( 

3532 _make_context_edit(table, tab_index, apply_func, choice_columns)) 

3533 

3534 def _clear_table_selections(): 

3535 flags['populating'] = True 

3536 for t in all_tables: 

3537 t.clearSelection() 

3538 flags['populating'] = False 

3539 

3540 def _sync_current_table_selection(): 

3541 tab = tabs.currentIndex() 

3542 table = all_tables[tab] 

3543 ids_arr = _ids_for_tab(tab) 

3544 flags['populating'] = True 

3545 table.clearSelection() 

3546 selection_model = table.selectionModel() 

3547 for row in range(len(ids_arr)): 

3548 if int(ids_arr[row]) in state['selected']: 

3549 selection_model.select( 

3550 table.model().index(row, 0), 

3551 QItemSelectionModel.Select | QItemSelectionModel.Rows) 

3552 flags['populating'] = False 

3553 

3554 def table_selection_changed(): 

3555 if flags['populating']: 

3556 return 

3557 tab = tabs.currentIndex() 

3558 table = all_tables[tab] 

3559 ids_arr = _ids_for_tab(tab) 

3560 rows = sorted({i.row() for i in table.selectedIndexes()}) 

3561 state['selected'] = [int(ids_arr[r]) for r in rows if r < len(ids_arr)] 

3562 render() 

3563 for t in all_tables: 

3564 t.itemSelectionChanged.connect(table_selection_changed) 

3565 

3566 # ------------------------------------------------------------------ # 

3567 # 3-D rendering 

3568 # ------------------------------------------------------------------ # 

3569 def _draw_red_polyline(connectivity): 

3570 for segment in _traceline_segments(connectivity): 

3571 valid = [i for i in segment if np.any(geometry.node.id == i)] 

3572 if len(valid) < 2: 

3573 continue 

3574 points = geometry.global_node_coordinate(np.array(valid)) 

3575 line = pv.PolyData(points, 

3576 lines=[len(points)] + list(range(len(points)))) 

3577 interactor.add_mesh(line, color='red', line_width=line_width + 3, 

3578 reset_camera=False) 

3579 

3580 def _draw_red_points(points): 

3581 interactor.add_mesh(pv.PolyData(np.atleast_2d(points)), color='red', 

3582 point_size=node_size + 8, reset_camera=False, 

3583 render_points_as_spheres=True) 

3584 

3585 def _draw_all_lines(): 

3586 # Workaround for a VTK bug on some platforms where scalar-mapped 

3587 # (cmap-colored) line cells are only partially rendered: re-draw 

3588 # every traceline and beam-type element as direct-colored meshes 

3589 # (no scalar mapping), grouped by color. Lines that plot() DID 

3590 # draw are simply overdrawn in the identical color. 

3591 groups = {} 

3592 for tl in geometry.traceline: 

3593 for segment in _traceline_segments(tl.connectivity): 

3594 groups.setdefault(int(tl.color), []).append(segment) 

3595 for elem in geometry.element: 

3596 if elem.type in _beam_elem_types: 

3597 groups.setdefault(int(elem.color), []).append( 

3598 np.atleast_1d(elem.connectivity)) 

3599 for color_value, segments in groups.items(): 

3600 all_points, lines, offset = [], [], 0 

3601 for segment in segments: 

3602 valid = [int(i) for i in segment 

3603 if np.any(geometry.node.id == i)] 

3604 if len(valid) < 2: 

3605 continue 

3606 points = geometry.global_node_coordinate(np.array(valid)) 

3607 all_points.append(points) 

3608 lines.extend([len(valid)] + list(range(offset, offset + len(valid)))) 

3609 offset += len(valid) 

3610 if not all_points: 

3611 continue 

3612 mesh = pv.PolyData(np.concatenate(all_points), lines=lines) 

3613 rgb = colormap(np.clip(color_value, 0, 15) / 15)[:3] 

3614 interactor.add_mesh(mesh, color=rgb, line_width=line_width, 

3615 reset_camera=False) 

3616 

3617 def _draw_node_dofs(): 

3618 # Draw labeled DOF arrows (translation directions in each node's 

3619 # displacement coordinate system) at every node. A temporary 

3620 # geometry with only the nodes and coordinate systems is used so 

3621 # plot_coordinate adds just the arrows and labels -- the geometry 

3622 # itself is already drawn. 

3623 if geometry.node.size == 0: 

3624 return 

3625 arrow_geometry = Geometry(geometry.node.copy(), 

3626 geometry.coordinate_system.copy()) 

3627 arrow_geometry.plot_coordinate( 

3628 label_dofs=True, 

3629 arrow_scale=0.1 * _scene_scale(), 

3630 arrow_scale_type='absolute', 

3631 plot_kwargs={'plotter': interactor, 'node_size': 0, 

3632 'line_width': 0}) 

3633 

3634 def _draw_coordinate_systems(): 

3635 # Draw one labeled X/Y/Z arrow triad per coordinate system at its 

3636 # origin by calling plot_coordinate on a temporary geometry that 

3637 # has one node per coordinate system (node id = cs id, placed at 

3638 # the cs origin, displaced in that cs). Labels read e.g. '1X+' 

3639 # for coordinate system 1's X+ axis. 

3640 if geometry.coordinate_system.size == 0: 

3641 return 

3642 css = geometry.coordinate_system 

3643 identity_cs_id = int(np.max(css.id)) + 1 

3644 triad_nodes = node_array( 

3645 css.id.copy(), np.atleast_2d(css.matrix[:, 3, :]).copy(), 

3646 1, identity_cs_id, css.id.copy()) 

3647 triad_geometry = Geometry( 

3648 triad_nodes, 

3649 np.concatenate((css.copy(), 

3650 coordinate_system_array((identity_cs_id,))))) 

3651 triad_geometry.plot_coordinate( 

3652 label_dofs=True, 

3653 arrow_scale=0.1 * _scene_scale(), 

3654 arrow_scale_type='absolute', 

3655 plot_kwargs={'plotter': interactor, 'node_size': 0, 

3656 'line_width': 0}) 

3657 

3658 def _draw_highlights(): 

3659 tab = tabs.currentIndex() 

3660 highlight_nodes = list(state['picked']) 

3661 if state['add_mode'] is None: 

3662 if tab == 0: 

3663 highlight_nodes.extend(state['selected']) 

3664 elif tab == 1: 

3665 rows = [int(np.argmax(geometry.coordinate_system.id == i)) 

3666 for i in state['selected'] 

3667 if np.any(geometry.coordinate_system.id == i)] 

3668 if rows: 

3669 _draw_red_points( 

3670 geometry.coordinate_system.matrix[rows, 3, :]) 

3671 elif tab == 2: 

3672 for i in state['selected']: 

3673 match = geometry.traceline.id == i 

3674 if np.any(match): 

3675 _draw_red_polyline( 

3676 geometry.traceline.connectivity[np.argmax(match)]) 

3677 elif tab == 3: 

3678 for i in state['selected']: 

3679 match = geometry.element.id == i 

3680 if not np.any(match): 

3681 continue 

3682 row = int(np.argmax(match)) 

3683 connectivity = np.atleast_1d( 

3684 geometry.element.connectivity[row]) 

3685 valid = [c for c in connectivity 

3686 if np.any(geometry.node.id == c)] 

3687 if len(valid) < 3 or geometry.element.type[row] in _beam_elem_types: 

3688 _draw_red_polyline(connectivity) 

3689 else: 

3690 points = geometry.global_node_coordinate(np.array(valid)) 

3691 face = pv.PolyData( 

3692 points, faces=[len(points)] + list(range(len(points)))) 

3693 # Translucent so it draws after (on top of) the 

3694 # coincident opaque element face 

3695 interactor.add_mesh(face, color='red', opacity=0.7, 

3696 reset_camera=False) 

3697 valid = [n for n in dict.fromkeys(highlight_nodes) 

3698 if np.any(geometry.node.id == n)] 

3699 if valid: 

3700 _draw_red_points(geometry.global_node_coordinate(np.array(valid))) 

3701 

3702 def render(): 

3703 try: 

3704 cam = interactor.camera_position 

3705 except Exception: 

3706 cam = None 

3707 interactor.clear() 

3708 try: 

3709 if geometry.node.size > 0: 

3710 geometry.plot(plotter=interactor, node_size=node_size, 

3711 line_width=line_width, label_nodes=True) 

3712 _draw_all_lines() 

3713 # Coordinate-system triads only show on the Coord Systems tab 

3714 if tabs.currentIndex() == 1: 

3715 _draw_coordinate_systems() 

3716 # Per-node DOF arrows only show on the Nodes tab when toggled 

3717 if tabs.currentIndex() == 0 and state['show_dofs']: 

3718 _draw_node_dofs() 

3719 _draw_highlights() 

3720 except Exception as e: 

3721 status.setText('Render error: {}'.format(e)) 

3722 try: 

3723 if cam is not None: 

3724 interactor.camera_position = cam 

3725 interactor.render() 

3726 except Exception as e: 

3727 status.setText('Render error: {}'.format(e)) 

3728 

3729 # ------------------------------------------------------------------ # 

3730 # 3-D picking (tab-dependent selection / add-mode node picking) 

3731 # ------------------------------------------------------------------ # 

3732 def on_pick(point, *args): 

3733 if point is None: 

3734 return 

3735 p = np.asarray(point, dtype=float).ravel()[:3] 

3736 # Shift or Cmd (Ctrl on Windows; Qt maps Command to ControlModifier 

3737 # on macOS) extends the selection/pick instead of restarting it 

3738 shift = bool(QApplication.keyboardModifiers() 

3739 & (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier)) 

3740 if state['add_mode'] is not None: 

3741 nid = _nearest_node(p) 

3742 if nid is None: 

3743 return 

3744 # Same selection rule as everywhere else: a plain click starts 

3745 # over with just this node; Shift-click extends the pick list 

3746 if shift: 

3747 if not state['picked'] or state['picked'][-1] != nid: 

3748 state['picked'].append(nid) 

3749 else: 

3750 state['picked'] = [nid] 

3751 mode = state['add_mode'] 

3752 if mode in elem_node_counts and \ 

3753 len(state['picked']) == elem_node_counts[mode]: 

3754 geometry.add_element(mode, state['picked']) 

3755 state['picked'] = [] 

3756 status.setText('Added element {} (type {})'.format( 

3757 int(geometry.element.id[-1]), mode)) 

3758 refresh_tables() 

3759 else: 

3760 status.setText('Picked nodes: {}'.format(state['picked'])) 

3761 render() 

3762 return 

3763 tab = tabs.currentIndex() 

3764 item_id = (_nearest_node, _nearest_cs, 

3765 _nearest_traceline, _nearest_element)[tab](p) 

3766 if item_id is None: 

3767 return 

3768 if shift: 

3769 if item_id in state['selected']: 

3770 state['selected'].remove(item_id) 

3771 else: 

3772 state['selected'].append(item_id) 

3773 else: 

3774 state['selected'] = [item_id] 

3775 _sync_current_table_selection() 

3776 render() 

3777 

3778 interactor.enable_point_picking(callback=on_pick, show_message=False, 

3779 left_clicking=True, picker='cell', 

3780 show_point=False) 

3781 

3782 # ------------------------------------------------------------------ # 

3783 # Button and mode handlers 

3784 # ------------------------------------------------------------------ # 

3785 def deselect_all(): 

3786 state['selected'] = [] 

3787 state['picked'] = [] 

3788 _clear_table_selections() 

3789 status.setText('') 

3790 render() 

3791 

3792 def add_clicked(checked=False): 

3793 tab = tabs.currentIndex() 

3794 if tab == 0: 

3795 if geometry.node.size > 0: 

3796 coord = geometry.global_node_coordinate().mean(axis=0) 

3797 else: 

3798 coord = np.zeros(3) 

3799 geometry.add_node(coord) 

3800 refresh_tables() 

3801 render() 

3802 elif tab == 1: 

3803 geometry.add_coordinate_system() 

3804 refresh_tables() 

3805 render() 

3806 elif tab == 2: 

3807 if checked: 

3808 state['add_mode'] = 'traceline' 

3809 state['picked'] = [] 

3810 state['selected'] = [] 

3811 _clear_table_selections() 

3812 status.setText('Click the first node, Shift-click the rest ' 

3813 'in order, then press Create.') 

3814 else: 

3815 state['add_mode'] = None 

3816 state['picked'] = [] 

3817 status.setText('') 

3818 render() 

3819 

3820 def elem_mode_handler(button, elem_type): 

3821 def handler(checked): 

3822 if checked: 

3823 for b in (btn_beam, btn_tri, btn_quad): 

3824 if b is not button: 

3825 b.blockSignals(True) 

3826 b.setChecked(False) 

3827 b.blockSignals(False) 

3828 state['add_mode'] = elem_type 

3829 state['picked'] = [] 

3830 state['selected'] = [] 

3831 _clear_table_selections() 

3832 status.setText( 

3833 'Click the first node, then Shift-click {} more per ' 

3834 'element (type {}).'.format( 

3835 elem_node_counts[elem_type] - 1, elem_type)) 

3836 else: 

3837 state['add_mode'] = None 

3838 state['picked'] = [] 

3839 status.setText('') 

3840 render() 

3841 return handler 

3842 

3843 def create_clicked(): 

3844 if state['add_mode'] == 'traceline' and len(state['picked']) >= 2: 

3845 geometry.add_traceline(state['picked']) 

3846 state['picked'] = [] 

3847 status.setText('Traceline added; keep clicking to build another.') 

3848 refresh_tables() 

3849 render() 

3850 

3851 def tab_changed(index): 

3852 state['add_mode'] = None 

3853 state['picked'] = [] 

3854 state['selected'] = [] 

3855 for b in (btn_beam, btn_tri, btn_quad): 

3856 b.blockSignals(True) 

3857 b.setChecked(False) 

3858 b.blockSignals(False) 

3859 b.setVisible(index == 3) 

3860 btn_add.blockSignals(True) 

3861 btn_add.setChecked(False) 

3862 btn_add.blockSignals(False) 

3863 btn_add.setCheckable(index == 2) 

3864 btn_add.setVisible(index != 3) 

3865 btn_create.setVisible(index == 2) 

3866 btn_dofs.setVisible(index == 0) 

3867 btn_rotate.setVisible(index == 1) 

3868 _clear_table_selections() 

3869 status.setText('') 

3870 render() 

3871 

3872 def _prune_connectivity(remove_ids): 

3873 remove = set(int(x) for x in np.atleast_1d(remove_ids)) 

3874 if geometry.traceline.size > 0: 

3875 keep = [not (set(int(c) for c in tl.connectivity) & remove) 

3876 for tl in geometry.traceline] 

3877 geometry.traceline = geometry.traceline[np.array(keep, dtype=bool)] 

3878 if geometry.element.size > 0: 

3879 keep = [not (set(int(c) for c in el.connectivity) & remove) 

3880 for el in geometry.element] 

3881 geometry.element = geometry.element[np.array(keep, dtype=bool)] 

3882 

3883 def _selected_rows(table): 

3884 return sorted({i.row() for i in table.selectedIndexes()}) 

3885 

3886 def delete_selected(): 

3887 tab = tabs.currentIndex() 

3888 if tab == 0: 

3889 rows = _selected_rows(node_table) 

3890 if not rows: 

3891 return 

3892 remove_ids = geometry.node.id[rows] 

3893 keep = np.ones(geometry.node.size, dtype=bool) 

3894 keep[rows] = False 

3895 geometry.node = geometry.node[keep] 

3896 _prune_connectivity(remove_ids) 

3897 elif tab == 1: 

3898 rows = _selected_rows(cs_table) 

3899 if not rows: 

3900 return 

3901 cs_ids = geometry.coordinate_system.id[rows] 

3902 used = np.concatenate((geometry.node.def_cs, geometry.node.disp_cs)) \ 

3903 if geometry.node.size > 0 else np.array([], dtype='uint64') 

3904 in_use = np.isin(cs_ids, used) 

3905 if in_use.any(): 

3906 QMessageBox.warning( 

3907 window, 'Coordinate system in use', 

3908 'Cannot delete coordinate system(s) still referenced by ' 

3909 'nodes: {}'.format(cs_ids[in_use].tolist())) 

3910 return 

3911 keep = np.ones(geometry.coordinate_system.size, dtype=bool) 

3912 keep[rows] = False 

3913 geometry.coordinate_system = geometry.coordinate_system[keep] 

3914 elif tab == 2: 

3915 rows = _selected_rows(tl_table) 

3916 if not rows: 

3917 return 

3918 keep = np.ones(geometry.traceline.size, dtype=bool) 

3919 keep[rows] = False 

3920 geometry.traceline = geometry.traceline[keep] 

3921 elif tab == 3: 

3922 rows = _selected_rows(elem_table) 

3923 if not rows: 

3924 return 

3925 keep = np.ones(geometry.element.size, dtype=bool) 

3926 keep[rows] = False 

3927 geometry.element = geometry.element[keep] 

3928 state['selected'] = [] 

3929 state['picked'] = [] 

3930 refresh_tables() 

3931 render() 

3932 

3933 btn_add.clicked.connect(add_clicked) 

3934 btn_beam.toggled.connect(elem_mode_handler(btn_beam, 21)) 

3935 btn_tri.toggled.connect(elem_mode_handler(btn_tri, 41)) 

3936 btn_quad.toggled.connect(elem_mode_handler(btn_quad, 44)) 

3937 def toggle_dofs(checked): 

3938 state['show_dofs'] = checked 

3939 render() 

3940 

3941 def rotate_cs(): 

3942 rows = _selected_rows(cs_table) 

3943 if not rows: 

3944 status.setText('Select coordinate system(s) to rotate first.') 

3945 return 

3946 dialog = QDialog(window) 

3947 dialog.setWindowTitle('Rotate Coordinate Systems') 

3948 form = QFormLayout(dialog) 

3949 angle_box = QDoubleSpinBox() 

3950 angle_box.setRange(-360.0, 360.0) 

3951 angle_box.setDecimals(3) 

3952 angle_box.setSuffix(' deg') 

3953 axis_combo = QComboBox() 

3954 axis_combo.addItems(['X', 'Y', 'Z']) 

3955 form.addRow('Rotation angle:', angle_box) 

3956 form.addRow('Rotation axis:', axis_combo) 

3957 buttons_box = QDialogButtonBox( 

3958 QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 

3959 buttons_box.accepted.connect(dialog.accept) 

3960 buttons_box.rejected.connect(dialog.reject) 

3961 form.addRow(buttons_box) 

3962 if not dialog.exec_(): 

3963 return 

3964 angle = angle_box.value() 

3965 axis_index = axis_combo.currentIndex() 

3966 # Same convention as the Excel-template reader: 

3967 # new rotation = R(axis, angle, degrees=True).T @ existing 

3968 rotation = R(axis_index, angle, degrees=True).T 

3969 for r in rows: 

3970 geometry.coordinate_system.matrix[r, :3, :] = \ 

3971 rotation @ geometry.coordinate_system.matrix[r, :3, :] 

3972 status.setText('Rotated {} coordinate system(s) {} deg about {}.'.format( 

3973 len(rows), angle, axis_combo.currentText())) 

3974 _finish_table_edit(cs_table, 1, rows) 

3975 

3976 btn_create.clicked.connect(create_clicked) 

3977 btn_delete.clicked.connect(delete_selected) 

3978 btn_dofs.toggled.connect(toggle_dofs) 

3979 btn_rotate.clicked.connect(rotate_cs) 

3980 tabs.currentChanged.connect(tab_changed) 

3981 escape_shortcut = QShortcut(QKeySequence(QtCore.Qt.Key_Escape), window) 

3982 escape_shortcut.activated.connect(deselect_all) 

3983 try: 

3984 interactor.add_key_event('Escape', deselect_all) 

3985 except Exception: 

3986 pass 

3987 

3988 # ------------------------------------------------------------------ # 

3989 # Launch 

3990 # ------------------------------------------------------------------ # 

3991 # Shut the VTK render window down cleanly when the editor closes -- 

3992 # otherwise its render timer keeps firing into a destroyed native 

3993 # window, which spams wglMakeCurrent/'handle is invalid' errors on 

3994 # Windows the next time the editor is opened 

3995 original_close_event = window.closeEvent 

3996 

3997 def close_event(event): 

3998 try: 

3999 interactor.close() 

4000 except Exception: 

4001 pass 

4002 original_close_event(event) 

4003 window.closeEvent = close_event 

4004 

4005 refresh_tables() 

4006 window.show() 

4007 window.raise_() 

4008 render() 

4009 interactor.reset_camera() 

4010 self._editor_window = window 

4011 if created_app: 

4012 app.exec_() 

4013 return geometry if created_new else window 

4014 

4015 @classmethod 

4016 def from_unv(cls, unv_dict): 

4017 """ 

4018 Create a geometry from universal file format data from readunv 

4019 

4020 Parameters 

4021 ---------- 

4022 unv_dict : dict 

4023 Dictionary containing data from read_unv 

4024 

4025 Returns 

4026 ------- 

4027 Geometry 

4028 Geometry object read from the unv data 

4029 

4030 """ 

4031 try: 

4032 coordinate_systems = CoordinateSystemArray.from_unv(unv_dict, combine=True) 

4033 except KeyError: 

4034 coordinate_systems = CoordinateSystemArray((0,)) 

4035 # Nodes 

4036 try: 

4037 nodes = NodeArray.from_unv(unv_dict, combine=True) 

4038 cs_indices = coordinate_systems.id 

4039 for node_index, node in nodes.ndenumerate(): 

4040 cs_index = np.where(cs_indices == node.def_cs) 

4041 matching_cs = coordinate_systems[cs_index] 

4042 if matching_cs.size == 0: 

4043 print('Warning: Coordinate System {:} not found in universal file structure. Adding Global Cartesian with ID {:}'.format( 

4044 node.def_cs, node.def_cs)) 

4045 coordinate_systems = np.concatenate( 

4046 (coordinate_systems, coordinate_system_array((node.def_cs,)))) 

4047 cs_indices = coordinate_systems.id 

4048 # cs_index = np.where(cs_indices == node.def_cs) 

4049 # node.coordinate = local_coord(coordinate_systems[cs_index][0],node.coordinate) 

4050 except KeyError: 

4051 nodes = NodeArray((0,)) 

4052 try: 

4053 elements = ElementArray.from_unv(unv_dict, combine=True) 

4054 except KeyError: 

4055 elements = ElementArray((0,)) 

4056 try: 

4057 tracelines = TracelineArray.from_unv(unv_dict, combine=True) 

4058 except KeyError: 

4059 tracelines = TracelineArray((0,)) 

4060 

4061 return cls(nodes, coordinate_systems, tracelines, elements) 

4062 

4063 from_uff = from_unv 

4064 

4065 @staticmethod 

4066 def write_excel_template(path_to_xlsx): 

4067 """ 

4068 Writes an Excel File Template for Creating Geometry 

4069 

4070 Parameters 

4071 ---------- 

4072 path_to_xlsx : string 

4073 Path to write xlsx Excel file 

4074 

4075 Returns 

4076 ------- 

4077 Nothing 

4078 

4079 Notes 

4080 ----- 

4081 See documentation for from_excel_template for instructions on filling 

4082 out the template to create a geometry. 

4083 """ 

4084 

4085 # Create a new workbook 

4086 workbook = openpyxl.Workbook() 

4087 

4088 # Add multiple sheets to the workbook 

4089 sheet_names = ['Coordinate Systems', 'Nodes', 'Elements', 'Trace Lines'] 

4090 for sheet_name in sheet_names: 

4091 workbook.create_sheet(sheet_name) 

4092 

4093 # Remove the default sheet created at the start 

4094 del workbook['Sheet'] 

4095 

4096 # Define column names for each sheet 

4097 columns = { 

4098 'Coordinate Systems': ['ID', 'Name', 'Color', 'Type', 'X Location', 'Y Location', 'Z Location', 'Rotation 1 Angle', 'Rotation 1 Axis', 'Rotation 2 Angle', 'Rotation 2 Axis', 'Rotation 3 Angle', 'Rotation 3 Axis'], 

4099 'Nodes': ['ID', 'Color', 'X Location', 'Y Location', 'Z Location', 'Displacement CS', 'Definition CS'], 

4100 'Elements': ['ID', 'Color', 'Type', 'Node 1', 'Node 2', 'Node 3', 'Node 4', 'Node 5'], 

4101 'Trace Lines': ['ID', 'Description', 'Color', 'Node 1', 'Node 2', 'Node 3', 'Node 4', 'Node 5'] 

4102 } 

4103 

4104 # Create data validations 

4105 data_validation_ID = openpyxl.worksheet.datavalidation.DataValidation(type="whole", operator='greaterThan', formula1='-1',allow_blank=True,error='Must be interger greater than -1',errorStyle='stop',showErrorMessage=True) 

4106 data_validation_Color = openpyxl.worksheet.datavalidation.DataValidation(type="list", formula1='"Black,Blue,Gray Blue,Light Blue,Cyan,Dark Olive,Dark Green,Green,Yellow,Golden Orange,Orange,Red,Magenta,Light Magenta,Pink,White"',allow_blank=True,error='Must be value from list',errorStyle='stop',showErrorMessage=True) 

4107 data_validation_CSType = openpyxl.worksheet.datavalidation.DataValidation(type="list", formula1='"Cartesian,Cylindrical,Sypherical"',allow_blank=True,error='Must be value from list',errorStyle='stop',showErrorMessage=True) 

4108 data_validation_Axis = openpyxl.worksheet.datavalidation.DataValidation(type="list", formula1='"X,Y,Z"',allow_blank=True,error='Must be value from list',errorStyle='stop',showErrorMessage=True) 

4109 data_validation_ElemType = openpyxl.worksheet.datavalidation.DataValidation(type="list", formula1='"beam,triangle,quadrilateral,tetrahedral"',allow_blank=True,error='Must be value from list',errorStyle='stop',showErrorMessage=True) 

4110 data_validation_Node = openpyxl.worksheet.datavalidation.DataValidation(type="whole", operator='greaterThan', formula1='-1',allow_blank=True,error='Must be value from list',errorStyle='stop',showErrorMessage=True) 

4111 data_validation_Decimal = openpyxl.worksheet.datavalidation.DataValidation(type="decimal",allow_blank=True,error='Must be decimal',errorStyle='stop',showErrorMessage=True) 

4112 

4113 # Specify Columns for data validation 

4114 table_height = 10 

4115 data_validation_ID_A = copy.deepcopy(data_validation_ID) 

4116 data_validation_ID_A.add('A2:A'+str(table_height+1)) 

4117 data_validation_Color_B = copy.deepcopy(data_validation_Color) 

4118 data_validation_Color_B.add('B2:B'+str(table_height+1)) 

4119 data_validation_Color_C = copy.deepcopy(data_validation_Color) 

4120 data_validation_Color_C.add('C2:C'+str(table_height+1)) 

4121 data_validation_CSType.add('D2:D'+str(table_height+1)) 

4122 data_validation_Axis.add('I2:I'+str(table_height+1)) 

4123 data_validation_Axis.add('K2:K'+str(table_height+1)) 

4124 data_validation_Axis.add('M2:M'+str(table_height+1)) 

4125 data_validation_Decimal_Coordinate_System = data_validation_Decimal 

4126 data_validation_Decimal_Coordinate_System.add('E2:H'+str(table_height+1)) 

4127 data_validation_Decimal_Coordinate_System.add('J2:J'+str(table_height+1)) 

4128 data_validation_Decimal_Coordinate_System.add('L2:L'+str(table_height+1)) 

4129 data_validation_Decimal_Nodes = copy.deepcopy(data_validation_Decimal) 

4130 data_validation_Decimal_Nodes.add('C2:E'+str(table_height+1)) 

4131 data_validation_ID_Nodes = copy.deepcopy(data_validation_ID) 

4132 data_validation_ID_Nodes.add('F2:G'+str(table_height+1)) 

4133 data_validation_ElemType.add('C2:C'+str(table_height+1)) 

4134 data_validation_Node.add('D2:H'+str(table_height+1)) 

4135 

4136 # Add data and tables to each sheet 

4137 for sheet_name in sheet_names: 

4138 sheet = workbook[sheet_name] 

4139 

4140 # Define the column headers 

4141 headers = columns[sheet_name] 

4142 

4143 # Write the headers into the first row 

4144 for col_num, header in enumerate(headers, start=1): 

4145 sheet.cell(row=1, column=col_num, value=header) 

4146 

4147 table_range = f'A1:{chr(65 + len(headers) - 1)}{table_height + 1}' 

4148 

4149 table = openpyxl.worksheet.table.Table(displayName=sheet_name.replace(' ','_'), ref=table_range) 

4150 

4151 # Apply table style 

4152 style = openpyxl.worksheet.table.TableStyleInfo(name="TableStyleMedium9", showFirstColumn=False,showLastColumn=False, showRowStripes=True, showColumnStripes=False) 

4153 table.tableStyleInfo = style 

4154 

4155 # Add table to the worksheet 

4156 sheet.add_table(table) 

4157 

4158 # Apply Data Validations for "Coordinate Systems" Sheet 

4159 sheet = workbook['Coordinate Systems'] 

4160 sheet.add_data_validation(data_validation_ID_A) 

4161 sheet.add_data_validation(data_validation_Color_C) 

4162 sheet.add_data_validation(data_validation_CSType) 

4163 sheet.add_data_validation(data_validation_Decimal_Coordinate_System) 

4164 sheet.add_data_validation(data_validation_Axis) 

4165 

4166 # Apply Data Validations for "Nodes" Sheet 

4167 sheet = workbook['Nodes'] 

4168 sheet.add_data_validation(data_validation_ID_A) 

4169 sheet.add_data_validation(data_validation_ID_Nodes) 

4170 sheet.add_data_validation(data_validation_Color_B) 

4171 sheet.add_data_validation(data_validation_Decimal_Nodes) 

4172 

4173 # Apply Data Validations for "Elements" Sheet 

4174 sheet = workbook['Elements'] 

4175 sheet.add_data_validation(data_validation_ID_A) 

4176 sheet.add_data_validation(data_validation_Color_B) 

4177 sheet.add_data_validation(data_validation_ElemType) 

4178 sheet.add_data_validation(data_validation_Node) 

4179 

4180 # Apply Data Validations for "Trace Lines" Sheet 

4181 sheet = workbook['Trace Lines'] 

4182 sheet.add_data_validation(data_validation_ID_A) 

4183 sheet.add_data_validation(data_validation_Color_C) 

4184 sheet.add_data_validation(data_validation_Node) 

4185 

4186 # Save the workbook 

4187 workbook.save(path_to_xlsx) 

4188 

4189 @classmethod 

4190 def from_excel_template(cls, path_to_xlsx): 

4191 """ 

4192 Create a geometry from Excel file template 

4193 

4194 Parameters 

4195 ---------- 

4196 path_to_xlsx : string 

4197 Path to xlsx Excel file containing geometry information 

4198 

4199 Returns 

4200 ------- 

4201 Geometry 

4202 Geometry object created from the Excel file 

4203 

4204 Notes 

4205 ----- 

4206 To use this function, first save out an excel template file using the 

4207 `write_excel_template` function. This will construct an excel 

4208 workbook with four worksheets on which the different portions of the 

4209 Geometry are defined. 

4210 

4211 On the Coordinate Systems tab, users will define the various global and 

4212 local coordinate systems in their geometry. Each geometry requires 

4213 an ID number. A Name can optionally be given. The Color should be 

4214 selectred from the dropdown list of colors in the Excel template. The 

4215 type of the coordinate system should be selected from the dropdown list of 

4216 coordinate system types. 

4217  

4218 The origin of the coordinate system can be specified with the X Location, 

4219 Y Location, Z Location columns. Then rotations of the coordinate system 

4220 can be specified using rotations about axes. Up to three axes and angles 

4221 can be specified to create arbitrary compound rotations. The rotation 

4222 axes should be X, Y, or Z, and the rotation angles are in degrees. 

4223 

4224 On the Nodes tab, all tabs must be filled out. ID numbers must be unique. 

4225 Colors should be selected from the dropdown list in the Excel template. The 

4226 position of the node is specified using the X Location, Y Location, Z 

4227 Location columns. Each node has a displacement coordinate system in which 

4228 its position is defined, and a a definition coordinate system in which 

4229 its displacements are defined. These columns should consist of integers 

4230 corresponding to ID numbers from the Coordinate Systems tab. 

4231 

4232 On the Elements tab, elements connecting nodes are defined. The ID 

4233 column must consist of unique integer identifiers. The Color tab should 

4234 be selected from the dropdown list. The type can be selected from the dropdown list.  

4235 If the type column 

4236 is empty, an element type based on the number of connections given will 

4237 be used. Defult element types for connection length of 2 is "Type 21 - Linear Beam", 

4238 for a connection length of 3 is "Type 41 - Plane Stress Linear Triangle", 

4239 and for a connection length of 4 is "Type 44 - Plane Stress Linear Quadrilateral". 

4240 The columns Node 1 through Node 4 contain the nodes in each element. Only the required 

4241 number of nodes must be filled out (e.g. a tri element would only contain 

4242 3 nodes, so only columns Node 1, Node 2, and Node 3 would be filled). 

4243 

4244 On the Trace Lines tab, lines connecting the nodes are defined. The 

4245 ID column must consist of unique integer identifiers. The Description 

4246 column contains a string description of the line. The Color should be  

4247 selected from the dropdown list. The 

4248 Node 1 through Node 5 columns should contain the nodes for each line. 

4249 Only the number of nodes in the line must be filled out, so if a line 

4250 only connects 5 nodes, only Node 1 though Node 5 must be filled. More columns can 

4251 can be added for additional Nodes if longer tracelines are needed. 

4252 

4253 

4254 """ 

4255 

4256 # % Read Geometry Information from Excel File 

4257 df_coordinate_systems = pd.read_excel(io = path_to_xlsx,sheet_name='Coordinate Systems') 

4258 df_nodes = pd.read_excel(io = path_to_xlsx,sheet_name='Nodes') 

4259 df_elements = pd.read_excel(io = path_to_xlsx,sheet_name='Elements') 

4260 df_trace_lines = pd.read_excel(io = path_to_xlsx,sheet_name='Trace Lines') 

4261 

4262 color_number_dict = { 

4263 'Black':1, 

4264 'Blue':2, 

4265 'Gray Blue':3, 

4266 'Light Blue':4, 

4267 'Cyan':5, 

4268 'Dark Olive':6, 

4269 'Dark Green':7, 

4270 'Green':8, 

4271 'Yellow':9, 

4272 'Golden Orange':10, 

4273 'Orange':11, 

4274 'Red':12, 

4275 'Magenta':13, 

4276 'Light Magenta':14, 

4277 'Pink':15, 

4278 'White':16, 

4279 } 

4280 

4281 cs_type_dict = { 

4282 'Cartesian':0, 

4283 'Cylindrical':1, 

4284 'Sypherical':2, 

4285 } 

4286 

4287 df_coordinate_systems = df_coordinate_systems.replace({'Color':color_number_dict}) 

4288 df_coordinate_systems = df_coordinate_systems.replace({'Type':cs_type_dict}) 

4289 df_nodes = df_nodes.replace({'Color':color_number_dict}) 

4290 df_elements = df_elements.replace({'Color':color_number_dict}) 

4291 df_trace_lines = df_trace_lines.replace({'Color':color_number_dict}) 

4292 

4293 # % Add Coordinate Systems 

4294 # Start with an empty coordinate system 

4295 coordinate_systems = coordinate_system_array([]) 

4296 for row_index, df_coordinate_system in df_coordinate_systems.iterrows(): 

4297 # Create Transformation Matrix 

4298 T = np.eye(3) 

4299 for rotation in range(1,(len(df_coordinate_system)-7)//2+1): 

4300 rotation_angle = df_coordinate_system['Rotation ' + str(rotation) + ' Angle'] 

4301 rotation_axis = df_coordinate_system['Rotation ' + str(rotation) + ' Axis'] 

4302 rotation_axis_dict = {'X':0,'Y':1,'Z':2} 

4303 if pd.notnull(rotation_angle) and pd.notnull(rotation_axis): 

4304 T = R(rotation_axis_dict[rotation_axis],rotation_angle,degrees=True).T@T 

4305 

4306 # Add Coordinate System to Geometry 

4307 origin = np.array((df_coordinate_system['X Location'],df_coordinate_system['Y Location'],df_coordinate_system['Z Location']))[np.newaxis,:] 

4308 coordinate_systems = np.concatenate((coordinate_systems,coordinate_system_array(id=df_coordinate_system['ID'], name=df_coordinate_system['Name'], color=df_coordinate_system['Color']-1, cs_type=df_coordinate_system['Type'], matrix = np.concatenate((T,origin),axis=0))),axis=None) 

4309 

4310 # % Add Nodes 

4311 # print(df_nodes['Color']) 

4312 nodes = node_array( 

4313 id=np.array(df_nodes['ID']), 

4314 coordinate=np.concatenate( 

4315 [np.array(df_nodes['X Location'])[:,np.newaxis],np.array(df_nodes['Y Location'])[:,np.newaxis],np.array(df_nodes['Z Location'])[:,np.newaxis]],axis=-1), 

4316 color=np.array(df_nodes['Color'])-1, 

4317 def_cs=np.array(df_nodes['Definition CS']), 

4318 disp_cs=np.array(df_nodes['Displacement CS'])) 

4319 

4320 # % Add Elements 

4321 elements = element_array([]) 

4322 element_connection_length_dict = { 

4323 2:21, # 21 : 'Linear Beam' 

4324 3:41, # 41 : 'Plane Stress Linear Triangle' 

4325 4:44, # 44 : 'Plane Stress Linear Quadrilateral' 

4326 } 

4327 for row_index, df_element in df_elements.iterrows(): 

4328 connectivity = df_element[3:].values 

4329 connectivity = connectivity[pd.notnull(connectivity)] 

4330 if isinstance(df_element['Type'],str): 

4331 element_type = _exodus_elem_type_map[df_element['Type'].lower()] 

4332 elif isinstance(df_element['Type'],int): 

4333 element_type = df_element['Type'] 

4334 elif pd.isnull(df_element['Type']): 

4335 element_type = element_connection_length_dict[len(connectivity)] 

4336 new_element = element_array(id=df_element['ID'], type=element_type, color=df_element['Color']-1,connectivity=connectivity) 

4337 elements = np.concatenate((elements,new_element),axis=None) 

4338 

4339 # % Add Tracelines 

4340 tracelines = traceline_array([]) 

4341 for row_index, df_trace_line in df_trace_lines.iterrows(): 

4342 connectivity=df_trace_line[3:].values 

4343 connectivity = connectivity[pd.notnull(connectivity)] 

4344 new_traceline = traceline_array(connectivity=connectivity,id=df_trace_line['ID'],description=df_trace_line['Description'],color=df_trace_line['Color']-1) 

4345 tracelines = np.concatenate((tracelines,new_traceline),axis=None) 

4346 

4347 return cls(nodes, coordinate_systems, tracelines, elements) 

4348 

4349 @classmethod 

4350 def from_exodus(cls, exo: Exodus, blocks=None, local=False, 

4351 preferred_local_orientation=np.array((0.0, 0.0, 1.0)), 

4352 secondary_preferred_local_orientation=np.array((1.0, 0.0, 0.0)), 

4353 local_nodes=None): 

4354 """ 

4355 Generate a geometry from exodus file data 

4356 

4357 Parameters 

4358 ---------- 

4359 exo : Exodus or ExodusInMemory 

4360 The exodus data from which geometry will be created. 

4361 blocks : iterable, optional 

4362 Iterable containing a set of block ids to import when creating the 

4363 geometry. The default is None, which uses all blocks in the model. 

4364 local : bool, optional 

4365 Flag to specify whether or not to create local coordinate systems 

4366 for each node in the model. This can be useful when creating 

4367 instrumnetation positions from a finite element model, where the 

4368 sensor will be oriented perpendicular to the surface it is mounted 

4369 on. The default is False, which returns all data in a single 

4370 global coordinate system. 

4371 preferred_local_orientation : np.ndarray, optional 

4372 A preferred direction for the local coordinate system. The first 

4373 constraint is that the local Z+ axis is perpendicular to the 

4374 surface. The coordinate system will then try to align itself as 

4375 much as it can with this direction. The default is 

4376 np.array((0.0,0.0,1.0)), which points the local coordinate systems 

4377 along the global Z+ axis. 

4378 secondary_preferred_local_orientation : np.ndarray, optional 

4379 A secondary preferred direction is only used if the surface normal 

4380 direction is parallel to the primary preferred_local_orientation. 

4381 The default is np.array((1.0,0.0,0.0)), which points the local 

4382 coordinate system along the local Z+ axis. 

4383 local_nodes : np.ndarray, optional 

4384 If specified, only create local coordinate systems at the specified 

4385 nodes. 

4386 

4387 Returns 

4388 ------- 

4389 Geometry 

4390 A geometry consisting of the finite element nodes and element 

4391 connectivity. 

4392 

4393 """ 

4394 if isinstance(exo, Exodus): 

4395 exo = exo.load_into_memory(close=False, variables=[], timesteps=[], blocks=blocks) 

4396 # Get nodes 

4397 coordinates = exo.nodes.coordinates 

4398 node_map = np.arange( 

4399 coordinates.shape[0]) + 1 if exo.nodes.node_num_map is None else exo.nodes.node_num_map 

4400 nodes = node_array(node_map, coordinates) 

4401 tl_connectivity = [] 

4402 elem_connectivity = [] 

4403 tl_color = [] 

4404 elem_color = [] 

4405 elem_types = [] 

4406 if blocks is None: 

4407 blocks = exo.blocks 

4408 else: 

4409 blocks = [block for block in exo.blocks if block.id in blocks] 

4410 for i, block in enumerate(blocks): 

4411 color_index = (i % 14) + 1 

4412 try: 

4413 connectivity = node_map[block.connectivity] 

4414 except AttributeError: 

4415 continue 

4416 elem_type = block.elem_type 

4417 if connectivity.shape[-1] <= 1: 

4418 # Skip conmasses 

4419 continue 

4420 for element in connectivity: 

4421 elem_connectivity.append(element) 

4422 elem_color.append(color_index) 

4423 elem_types.append(_exodus_elem_type_map[elem_type.lower()]) 

4424 tls = traceline_array(np.arange(len(tl_connectivity)) + 1, 

4425 color=tl_color, 

4426 connectivity=tl_connectivity) 

4427 elems = element_array(np.arange(len(elem_connectivity)) + 1, 

4428 elem_types, elem_color, elem_connectivity) 

4429 if local: 

4430 if local_nodes is not None: 

4431 node_map_dict = {val: index for index, val in enumerate(node_map)} 

4432 local_node_indices = np.array([node_map_dict[node] for node in local_nodes]) 

4433 local_node_index_map = {node_index: index for index, node_index in enumerate(local_node_indices)} 

4434 preferred_local_orientation /= np.linalg.norm(preferred_local_orientation) 

4435 secondary_preferred_local_orientation /= np.linalg.norm( 

4436 secondary_preferred_local_orientation) 

4437 # Go through each node, find the block that it's in, get its elements 

4438 normal_sum = np.zeros( 

4439 (coordinates.shape[0] if local_nodes is None else len(local_nodes), 

4440 3)) 

4441 for block in blocks: 

4442 if block.connectivity.shape[-1] <= 1: 

4443 # Skip conmasses 

4444 continue 

4445 full_block_node_positions = coordinates[block.connectivity, :] 

4446 normal_vectors = np.cross(full_block_node_positions[:, 2, :] - full_block_node_positions[:, 0, :], 

4447 full_block_node_positions[:, 1, :] - full_block_node_positions[:, 0, :]) 

4448 normal_vectors /= np.linalg.norm(normal_vectors, axis=-1, keepdims=True) 

4449 block_nodes = np.unique(block.connectivity) 

4450 if local_nodes is not None: 

4451 block_nodes = local_node_indices[np.isin(local_node_indices, block_nodes)] 

4452 for index in block_nodes: 

4453 node_elements = np.any(block.connectivity == index, axis=-1) 

4454 node_normals = normal_vectors[node_elements] 

4455 if local_nodes is None: 

4456 normal_sum[index] += np.sum(node_normals, axis=0) 

4457 else: 

4458 normal_sum[local_node_index_map[index]] += np.sum(node_normals, axis=0) 

4459 # Put small nodes to reasonable values to avoid divide by zero 

4460 normal_sum[np.linalg.norm(normal_sum, axis=-1) < 1e-14] = preferred_local_orientation 

4461 node_rotations = np.zeros((3,) + normal_sum.shape) 

4462 node_rotations[2] = -normal_sum / np.linalg.norm(normal_sum, axis=-1, keepdims=True) 

4463 # Find the nodes that are basically in the direction of the preferred direction 

4464 nodes_in_preferred_direction = np.abs( 

4465 np.einsum('ij,j->i', node_rotations[2], preferred_local_orientation)) > 0.99 

4466 # Compute cross products if we are not parallel with the preferred orientation 

4467 node_rotations[1, ~nodes_in_preferred_direction] = np.cross( 

4468 node_rotations[2, ~nodes_in_preferred_direction], preferred_local_orientation) 

4469 node_rotations[1, nodes_in_preferred_direction] = np.cross( 

4470 node_rotations[2, nodes_in_preferred_direction], secondary_preferred_local_orientation) 

4471 node_rotations[1] /= np.linalg.norm(node_rotations[1], axis=-1, keepdims=True) 

4472 node_rotations[0] = np.cross(node_rotations[1], node_rotations[2]) 

4473 node_rotations[0] /= np.linalg.norm(node_rotations[0], axis=-1, keepdims=True) 

4474 node_rotations = np.moveaxis(node_rotations, 0, 1) 

4475 # Create the coordinate system matrix 

4476 cs_matrix = np.concatenate((node_rotations, np.zeros( 

4477 (node_rotations.shape[0], 1, node_rotations.shape[-1]))), axis=1) 

4478 # Put a global coordinate system at the end 

4479 cs_matrix = np.concatenate((cs_matrix, np.eye(4, 3)[np.newaxis]), axis=0) 

4480 if local_nodes is None: 

4481 cs_ids = np.concatenate((node_map, (node_map.max() + 1,)), axis=0) 

4482 cs_descriptions = ['Node {:} Disp'.format(id) for id in node_map] + ['Global'] 

4483 css = coordinate_system_array(cs_ids, cs_descriptions, 1, 0, cs_matrix) 

4484 nodes.def_cs = cs_ids[-1] 

4485 nodes.disp_cs = cs_ids[:-1] 

4486 else: 

4487 cs_ids = np.concatenate((local_nodes, (node_map.max() + 1,)), axis=0) 

4488 cs_descriptions = ['Node {:} Disp'.format(id) for id in local_nodes] + ['Global'] 

4489 css = coordinate_system_array(cs_ids, cs_descriptions, 1, 0, cs_matrix) 

4490 nodes.def_cs = cs_ids[-1] 

4491 nodes.disp_cs = cs_ids[-1] 

4492 nodes.disp_cs[local_node_indices] = cs_ids[:-1] 

4493 else: 

4494 css = coordinate_system_array((1,)) 

4495 return Geometry(nodes, css, tls, elems) 

4496 

4497 @classmethod 

4498 def camera_visualization(cls, K, RT, image_size, size=1, colors=1): 

4499 """ 

4500 Create a geometry used to visualize a camera specified with K and RT 

4501 

4502 Parameters 

4503 ---------- 

4504 K : ndarray 

4505 A (...,3,3) matrix containing the intrinsic parameters of the cameras 

4506 RT : ndarray 

4507 A (...,3,4) matrix containing the extrinsic parameters of the cameras 

4508 image_size : ndarray 

4509 A (...,2) matrix containing the width,height of each camera 

4510 size : float 

4511 The distance that the rays will project from the pinhole of the camera 

4512 colors : int or ndarray 

4513 The colors assigned to each camera 

4514 

4515 Returns 

4516 ------- 

4517 geometry : cls 

4518 A geoemtry containing the cameras that can be used for visualization 

4519 

4520 """ 

4521 if K.ndim == 2: 

4522 K = K[np.newaxis, ...] 

4523 if K.ndim > 3: 

4524 K = K.reshape(-1, 3, 3) 

4525 if RT.ndim == 2: 

4526 RT = RT[np.newaxis, ...] 

4527 if RT.ndim > 3: 

4528 RT = RT.reshape(-1, 3, 4) 

4529 if image_size.ndim == 1: 

4530 image_size = image_size[np.newaxis, ...] 

4531 if image_size.ndim > 2: 

4532 image_size = image_size.reshape(-1, 2) 

4533 if np.array(colors).ndim == 0: 

4534 colors = colors * np.ones(K.shape[0]) 

4535 if np.array(colors).ndim > 1: 

4536 colors = np.array(colors).flatten() 

4537 

4538 corners = np.array([(0, 0), 

4539 (1, 0), 

4540 (1, 1), 

4541 (0, 1)]) 

4542 

4543 geometry = cls(coordinate_system=coordinate_system_array((1,))) 

4544 

4545 for i, (thisK, thisRT, this_size, this_color) in enumerate(zip(K, RT, image_size, colors)): 

4546 pixel_corners = corners * this_size 

4547 

4548 # Compute the positions 

4549 image_corners = point_on_pixel(thisK, thisRT, pixel_corners, size) 

4550 pinhole = -thisRT[:3, :3].T @ thisRT[..., :3, 3:] 

4551 # Create a geometry 

4552 pinhole_node = node_array(((i + 1) * 10,), pinhole.T, disp_cs=i + 2, color=this_color) 

4553 pixel_nodes = node_array((i + 1) * 10 + np.arange(4) + 1, 

4554 image_corners, disp_cs=i + 2, color=this_color) 

4555 cs = coordinate_system_array((i + 2,), color=this_color) 

4556 cs.matrix[..., :3, :3] = thisRT[:3, :3] 

4557 geometry.node = np.concatenate((geometry.node, pinhole_node, pixel_nodes)) 

4558 geometry.coordinate_system = np.concatenate((geometry.coordinate_system, cs)) 

4559 geometry.add_traceline(np.array([0, 1, 2, 3, 4, 1]) + 10 * (i + 1), color=this_color) 

4560 geometry.add_traceline(np.array([0, 2]) + 10 * (i + 1), color=this_color) 

4561 geometry.add_traceline(np.array([0, 3]) + 10 * (i + 1), color=this_color) 

4562 geometry.add_traceline(np.array([0, 4]) + 10 * (i + 1), color=this_color) 

4563 return geometry 

4564 

4565 def compress_ids(self, compress_nodes=True, compress_elements=True, 

4566 compress_tracelines=True, compress_cs=True, 

4567 return_maps=False): 

4568 """ 

4569 Compresses ID numbers to make node, element, etc. contiguous 

4570 

4571 Parameters 

4572 ---------- 

4573 compress_nodes : bool, optional 

4574 If True, compress the node ids. The default is True. 

4575 compress_elements : bool, optional 

4576 If True, compress the element ids. The default is True. 

4577 compress_tracelines : bool, optional 

4578 If True, compress the traceline ids. The default is True. 

4579 compress_cs : bool, optional 

4580 If True, compress the coordinate system ids. The default is True. 

4581 return_maps : bool, optional 

4582 If True, return id maps for nodes, elements, tracelines, and 

4583 coordinate systems. Maps will be equal to `None` if no compression 

4584 was done on that field. The default is False. 

4585 

4586 Returns 

4587 ------- 

4588 mapped_geometry : Geometry 

4589 Geometry with contiguous id numbers for the selected fields. 

4590 node_map : id_map or `None` 

4591 Mapping from the old set of nodes to the new set of nodes, only 

4592 returned if `return_maps` is True. If `compress_nodes` is False, 

4593 this will be `None`. 

4594 traceline_map : id_map or `None` 

4595 Mapping from the old set of tracelines to the new set of tracelines, only 

4596 returned if `return_maps` is True. If `compress_tracelines` is False, 

4597 this will be `None`. 

4598 element_map : id_map or `None` 

4599 Mapping from the old set of elements to the new set of elements, only 

4600 returned if `return_maps` is True. If `compress_elements` is False, 

4601 this will be `None`. 

4602 cs_map : id_map or `None` 

4603 Mapping from the old set of coordinate systems to the new set of 

4604 coordinate systems, only 

4605 returned if `return_maps` is True. If `compress_cs` is False, 

4606 this will be `None`. 

4607 

4608 """ 

4609 if compress_nodes: 

4610 to_nodes = np.arange(self.node.size)+1 

4611 from_nodes = self.node.id.flatten() 

4612 node_map = id_map(from_nodes, to_nodes) 

4613 else: 

4614 node_map = None 

4615 if compress_cs: 

4616 to_cs = np.arange(self.coordinate_system.size)+1 

4617 from_cs = self.coordinate_system.id.flatten() 

4618 cs_map = id_map(from_cs, to_cs) 

4619 else: 

4620 cs_map = None 

4621 if compress_elements: 

4622 to_elements = np.arange(self.element.size)+1 

4623 from_elements = self.coordinate_system.id.flatten() 

4624 element_map = id_map(from_elements, to_elements) 

4625 else: 

4626 element_map = None 

4627 if compress_tracelines: 

4628 to_tls = np.arange(self.traceline.size)+1 

4629 from_tls = self.coordinate_system.id.flatten() 

4630 traceline_map = id_map(from_tls, to_tls) 

4631 else: 

4632 traceline_map = None 

4633 mapped_geometry = self.map_ids(node_map, traceline_map, element_map, cs_map) 

4634 if return_maps: 

4635 return mapped_geometry, node_map, traceline_map, element_map, cs_map 

4636 else: 

4637 return mapped_geometry 

4638 

4639 @classmethod 

4640 def from_imat_struct(cls, imat_fem_struct): 

4641 """ 

4642 Constructs a Geometry from an imat_fem class saved to a Matlab structure 

4643 

4644 In IMAT, a structure can be created from an `imat_fem` by using the get() 

4645 function. This can then be saved to a .mat file and loaded using 

4646 `scipy.io.loadmat`. The output from loadmat can be passed into this function 

4647 

4648 Parameters 

4649 ---------- 

4650 imat_fem_struct : np.ndarray 

4651 structure from loadmat containing data from an imat_fem 

4652 

4653 Returns 

4654 ------- 

4655 Geometry 

4656 Geometry constructed from the data in the imat structure 

4657 

4658 """ 

4659 node_struct = imat_fem_struct['node'][0, 0] 

4660 cs_array = node_struct['cs'][0, 0].reshape(-1, 3) 

4661 nodes = node_array(id=node_struct['id'][0, 0].flatten(), 

4662 coordinate=node_struct['coord'][0, 0].reshape(-1, 3), 

4663 color=node_struct['color'][0, 0].flatten(), 

4664 def_cs=cs_array[:, 0], disp_cs=cs_array[:, 1]) 

4665 cs_struct = imat_fem_struct['cs'][0, 0] 

4666 cs_ids = cs_struct['id'][0, 0].flatten() 

4667 if cs_ids.size > 0: 

4668 cs_names = np.concatenate(cs_struct['name'][0, 0].flatten()).flatten() 

4669 else: 

4670 cs_names = [] 

4671 css = coordinate_system_array(id=cs_ids, 

4672 name=cs_names, 

4673 color=cs_struct['color'][0, 0].flatten(), 

4674 cs_type=cs_struct['type'][0, 0].flatten(), 

4675 matrix=cs_struct['matrix'][0, 0].reshape(4, 3, -1).transpose(2, 0, 1)) 

4676 elem_struct = imat_fem_struct['elem'][0, 0] 

4677 elems = element_array(id=elem_struct['id'][0, 0].flatten(), 

4678 type=elem_struct['type'][0, 0].flatten(), 

4679 color=elem_struct['color'][0, 0].flatten(), 

4680 connectivity=[val.flatten() for val in elem_struct['conn'][0, 0].flatten()]) 

4681 tl_struct = imat_fem_struct['tl'][0, 0] 

4682 tl_ids = tl_struct['id'][0, 0].flatten() 

4683 if tl_ids.size > 0: 

4684 tl_names = np.concatenate(tl_struct['desc'][0, 0].flatten()).flatten() 

4685 else: 

4686 tl_names = [] 

4687 tls = traceline_array(id=tl_ids, 

4688 description=tl_names, 

4689 color=tl_struct['color'][0, 0].flatten(), 

4690 connectivity=[val.flatten() for val in tl_struct['conn'][0, 0].flatten()]) 

4691 return cls(nodes, css, tls, elems) 

4692 

4693 def global_node_coordinate(self, node_ids=None): 

4694 """ 

4695 Position of the Geometry's nodes in the global coordinate system 

4696 

4697 Parameters 

4698 ---------- 

4699 node_ids : np.ndarray 

4700 An array of node id numbers to keep in the output coordinate. If 

4701 not specified, all nodes will be returned in the order they are in 

4702 the model. 

4703 

4704 

4705 Returns 

4706 ------- 

4707 np.ndarray 

4708 numpy array with shape (n,3) where n is the number of nodes in the 

4709 geometry. This array contains the 3d position of each node in the 

4710 global coordinate system. 

4711 

4712 """ 

4713 if node_ids is None: 

4714 node = self.node 

4715 else: 

4716 node = self.node(node_ids) 

4717 cs_array = self.coordinate_system(node.def_cs) 

4718 return node.global_coordinate(cs_array) 

4719 

4720 def node_by_global_position(self, global_position_array): 

4721 """ 

4722 Select node by closest position 

4723 

4724 Parameters 

4725 ---------- 

4726 global_position_array : np.ndarray 

4727 A (...,3) shape array containing positions of nodes to keep 

4728 

4729 Returns 

4730 ------- 

4731 NodeArray 

4732 NodArray containing nodes that were closest to the positions in 

4733 position_array. 

4734 

4735 """ 

4736 node_differences = np.linalg.norm( 

4737 self.global_node_coordinate() - global_position_array[(Ellipsis,) + (np.newaxis,) * self.node.ndim + (slice(None),)], axis=-1) 

4738 node_differences = node_differences.reshape(*global_position_array.shape[:-1], -1) 

4739 node_indices = np.argmin( 

4740 node_differences, 

4741 axis=-1) 

4742 return self.node.flatten()[node_indices] 

4743 

4744 def node_by_global_grid(self,grid_spacing, x_min = None, y_min = None, 

4745 z_min = None, x_max = None, y_max = None, 

4746 z_max = None): 

4747 """ 

4748 Selects nodes in a grid 

4749 

4750 Parameters 

4751 ---------- 

4752 grid_spacing : float 

4753 Approximate grid spacing between selected nodes 

4754 x_min : float, optional 

4755 Minimum X-value of the grid. 

4756 The default is the minimum node x-coordinate. 

4757 y_min : float, optional 

4758 Minimun Y-value of the grid. 

4759 The default is the minimum node y-coordinate. 

4760 z_min : float, optional 

4761 Minimum Z-value of the grid. 

4762 The default is the minimum node z-coordinate. 

4763 x_max : float, optional 

4764 Maximum X-value of the grid. 

4765 The default is the maximum node x-coordinate. 

4766 y_max : float, optional 

4767 Maximum Y-value of the grid. 

4768 The default is the maximum node y-coordinate. 

4769 z_max : float, optional 

4770 Maximum Z-value of the grid. 

4771 The default is the maximum node z-coordinate. 

4772 

4773 Returns 

4774 ------- 

4775 NodeArray 

4776 NodeArray containing the closest nodes to the grid points 

4777 """ 

4778 coordinate = self.global_node_coordinate() 

4779 if x_min is None: 

4780 x_min = coordinate[:, 0].min() 

4781 if y_min is None: 

4782 y_min = coordinate[:, 1].min() 

4783 if z_min is None: 

4784 z_min = coordinate[:, 2].min() 

4785 if x_max is None: 

4786 x_max = coordinate[:, 0].max() 

4787 if y_max is None: 

4788 y_max = coordinate[:, 1].max() 

4789 if z_max is None: 

4790 z_max = coordinate[:, 2].max() 

4791 min_coords = np.array((x_min, y_min, z_min)) 

4792 max_coords = np.array((x_max, y_max, z_max)) 

4793 range_coords = max_coords - min_coords 

4794 num_grids = np.ceil(range_coords / grid_spacing).astype(int) 

4795 # Now we will create the grid in each dimension using linspace 

4796 grid_arrays = [np.linspace(min_coord, max_coord, num_coord) 

4797 for min_coord, max_coord, num_coord 

4798 in zip(min_coords, max_coords, num_grids)] 

4799 # We will then use meshgrid to assemble the array grid. We will flatten the 

4800 # point dimension and transpose so the array has shape (n_points x 3) 

4801 grid_points = np.array(np.meshgrid(*grid_arrays, indexing='ij')).reshape(3, -1).T 

4802 # Now we will select nodes that are closest to the points in the grid 

4803 candidate_nodes = self.node_by_global_position(grid_points) 

4804 # Reduce to only nodes that are within one grid spacing of their requested point 

4805 # this reduces the nodes that are selected by points far from the model 

4806 candidate_nodes = candidate_nodes[ 

4807 np.linalg.norm(self.global_node_coordinate(candidate_nodes.id) - grid_points, axis=-1) < grid_spacing] 

4808 # Remove any duplicates 

4809 candidate_node_ids = np.unique(candidate_nodes.id) 

4810 return candidate_nodes(candidate_node_ids) 

4811 

4812 def global_deflection(self, coordinate_array): 

4813 """ 

4814 Direction of local deflection in the global coordinate system 

4815 

4816 Parameters 

4817 ---------- 

4818 coordinate_array : CoordinateArray 

4819 A list of coordinates for which the global deformation direction 

4820 will be computed 

4821 

4822 Returns 

4823 ------- 

4824 global_deflections : np.ndarray 

4825 numpy array with shape (n,3) where n is the number of coordinates 

4826 in the specified `coordinate_array`. This array contains the 3d 

4827 direction of motion for each coordinate in the global coordinate 

4828 system. 

4829 

4830 """ 

4831 local_deformations = coordinate_array.local_direction() 

4832 ordered_nodes = self.node(coordinate_array.node) 

4833 coordinate_systems = self.coordinate_system(ordered_nodes.disp_cs) 

4834 points = global_coord(self.coordinate_system( 

4835 ordered_nodes.def_cs), ordered_nodes.coordinate) 

4836 global_deflections = global_deflection(coordinate_systems, local_deformations, points) 

4837 return global_deflections 

4838 

4839 @staticmethod 

4840 def overlay_geometries(geometries, color_override=None, return_node_id_offset=False): 

4841 """ 

4842 Combines several geometries, offsetting the id numbers to avoid conflicts 

4843 

4844 Parameters 

4845 ---------- 

4846 geometries : iterable 

4847 An iterable of geometry objects that will be combined into a single 

4848 geometry 

4849 color_override : iterable, optional 

4850 An iterble of integers specifying colors, which will override the 

4851 existing geometry colors. This should have the same length as the 

4852 `geometries` input. The default is None, which keeps the original 

4853 geometry colors. 

4854 return_node_id_offset : bool, optional 

4855 Specifies whether or not the applied node offset should be returned, 

4856 which is useful if the new node numbers are to be mapped to the old 

4857 node numbers. The default is False, which only returns the combined 

4858 geometry. 

4859 

4860 Returns 

4861 ------- 

4862 Geometry 

4863 A geometry consisting of a combination of the specified geometries 

4864 node_offset 

4865 An integer specifying the node id offset applied to avoid conflicts 

4866 

4867 """ 

4868 node_ids = np.concatenate([geometry.node.id for geometry in geometries]) 

4869 cs_ids = np.concatenate([geometry.coordinate_system.id for geometry in geometries]) 

4870 tl_ids = np.concatenate([geometry.traceline.id for geometry in geometries]) 

4871 elem_ids = np.concatenate([geometry.element.id for geometry in geometries]) 

4872 try: 

4873 max_node_id = np.max(node_ids) 

4874 except ValueError: 

4875 max_node_id = 1 

4876 try: 

4877 max_cs_id = np.max(cs_ids) 

4878 except ValueError: 

4879 max_cs_id = 1 

4880 try: 

4881 max_tl_id = np.max(tl_ids) 

4882 except ValueError: 

4883 max_tl_id = 1 

4884 try: 

4885 max_elem_id = np.max(elem_ids) 

4886 except ValueError: 

4887 max_elem_id = 1 

4888 node_length = int(np.floor(np.log10(max_node_id)) + 1) 

4889 tl_length = int(np.floor(np.log10(max_tl_id)) + 1) 

4890 elem_length = int(np.floor(np.log10(max_elem_id)) + 1) 

4891 cs_length = int(np.floor(np.log10(max_cs_id)) + 1) 

4892 new_geometries = [geometry.modify_ids( 

4893 node_change=10**(node_length) * (i + 1), 

4894 traceline_change=10**(tl_length) * (i + 1), 

4895 element_change=10**(elem_length) * (i + 1), 

4896 coordinate_system_change=10**(cs_length) * (i + 1)) 

4897 for i, geometry in enumerate(geometries)] 

4898 if color_override is not None: 

4899 for i, color in enumerate(color_override): 

4900 new_geometries[i].node.color = color 

4901 new_geometries[i].traceline.color = color 

4902 new_geometries[i].element.color = color 

4903 new_geometries[i].coordinate_system.color = color 

4904 

4905 new_geometry = new_geometries[0] 

4906 for i in range(1, len(new_geometries)): 

4907 new_geometry = new_geometry + new_geometries[i] 

4908 

4909 if return_node_id_offset: 

4910 return new_geometry, 10**(node_length) 

4911 else: 

4912 return new_geometry 

4913 

4914 def map_coordinate(self,dof_list,to_geometry,node_map=None, 

4915 plot_maps = False): 

4916 """ 

4917 Map degrees of freedom from one geometry to the closest aligned on a 

4918 second geometry. 

4919 

4920 Parameters 

4921 ---------- 

4922 dof_list : CoordinateArray 

4923 An array of degrees of freedom on the geometry to map. 

4924 to_geometry : Geometry 

4925 The geometry to which the degrees of freedom will be mapped. 

4926 node_map : None, str, or id_map, optional 

4927 If specified, it will map the node ids from the present geometry to 

4928 the mapping geometry. If the string 'position' is specified, then 

4929 the closest node by position on the mapping geometry to the present 

4930 geometry will be chosen. To explicitly map between nodes, pass a  

4931 sdpy.id_map. If not specified, it is assumed that the node numbers 

4932 in the present geometry map to the nodes in the mapping geometry. 

4933 plot_maps : bool 

4934 If True, it will plot the CoordinateArray on the geometries to 

4935 visualize the two mapped degrees of freedom. 

4936 

4937 Raises 

4938 ------ 

4939 ValueError 

4940 If an invalid mapping is specified. 

4941 

4942 Returns 

4943 ------- 

4944 output_dof_list : CoordinateArray 

4945 A CoordinateArray of the same size and shape of dof_list with each 

4946 entry in dof_list corresponding to the same position in output_dof_list. 

4947 

4948 """ 

4949 dof_list = dof_list.copy() 

4950 if node_map == 'position': 

4951 from_nodes = np.unique(dof_list.node) 

4952 to_nodes = to_geometry.node_by_global_position(self.global_node_coordinate(from_nodes)).id 

4953 to_geometry = to_geometry.reduce(to_nodes) 

4954 node_map = id_map(from_nodes,to_nodes) 

4955 self = self.reduce(from_nodes).map_ids(node_id_map = node_map) 

4956 dof_list.node = node_map(dof_list.node) 

4957 elif isinstance(node_map,id_map): 

4958 self = self.reduce(node_map.from_ids).map_ids(node_id_map = node_map) 

4959 to_geometry = to_geometry.reduce(node_map.to_nodes) 

4960 dof_list.node = node_map(dof_list.node) 

4961 elif node_map is None: 

4962 self = self.reduce(np.unique(dof_list.node)) 

4963 to_geometry = to_geometry.reduce(np.unique(dof_list.node)) 

4964 else: 

4965 raise ValueError('Invalid node_map. Must be an id_map, "position", or None.') 

4966 

4967 output_dof_list = dof_list.copy() 

4968 global_directions = self.global_deflection(dof_list) 

4969 to_directions = {id_num:to_geometry.global_deflection(coordinate_array(id_num,[1,2,3])) 

4970 for id_num in to_geometry.node.id} 

4971 for index,value in dof_list.ndenumerate(): 

4972 dirs = to_directions[value.node] 

4973 dots = np.einsum('ij,j',dirs,global_directions[index]) 

4974 direction = np.argmax(abs(dots)) 

4975 sign = np.sign(dots[direction]) 

4976 output_dof_list[index].direction = sign*(direction+1) 

4977 if plot_maps: 

4978 plotter = self.plot_coordinate(dof_list[index]) 

4979 to_geometry.plot_coordinate(output_dof_list[index],plot_kwargs={'plotter':plotter}) 

4980 

4981 return output_dof_list 

4982 

4983 def convert_elements_to_tracelines(self,keep_ids = False, start_id = None, 

4984 in_place = False): 

4985 """ 

4986 Transforms all elements in the geometry into tracelines 

4987 

4988 Parameters 

4989 ---------- 

4990 keep_ids : bool, optional 

4991 If True, the element ids will be used as the new traceline ids. 

4992 The default is False. 

4993 start_id : int, optional 

4994 The starting id number for the tracelines converted from elements. 

4995 Only used if keep_ids is False. If not specified, each subsequent 

4996 element will use the current highest traceline id number + 1. 

4997 in_place : bool, optional 

4998 If True, modify the geometry in place. If False, a copy will be 

4999 created and returned. The default is False. 

5000 

5001 Raises 

5002 ------ 

5003 ValueError 

5004 If conflicts are expected to occur between the newly created 

5005 traceline IDs and the current existing traceline IDs. 

5006 NotImplementedError 

5007 If solid element types are converted. 

5008 

5009 Returns 

5010 ------- 

5011 geometry : Geometry 

5012 Only returned if in_place is False. 

5013 

5014 """ 

5015 if in_place: 

5016 geometry = self 

5017 else: 

5018 geometry = self.copy() 

5019 if keep_ids: 

5020 if np.any(np.isin(geometry.element.id,geometry.traceline.id)): 

5021 raise ValueError('Keeping element IDs would result in a conflict with existing traceline IDs') 

5022 if start_id is not None: 

5023 if np.any(np.isin(np.arange(geometry.element.size)+start_id,geometry.traceline.id)): 

5024 raise ValueError('Setting new traceline ID starting at {:} would result in a conflict with existing traceline IDs'.format(start_id)) 

5025 if not keep_ids and start_id is None: 

5026 if len(geometry.traceline) > 0: 

5027 start_id = np.max(geometry.traceline.id) + 1 

5028 else: 

5029 start_id = 1 

5030 for idx, element in enumerate(geometry.element.flatten()): 

5031 if keep_ids: 

5032 new_id = element.id 

5033 else: 

5034 new_id = start_id + idx 

5035 if element.type <= 96: 

5036 conn = element.connectivity 

5037 conn = np.concatenate((conn,[conn[0]])) 

5038 else: 

5039 raise NotImplementedError('Solid Elements are not implemented yet!') 

5040 geometry.add_traceline(conn,new_id,color=element.color) 

5041 geometry.element = ElementArray((0,)) 

5042 if not in_place: 

5043 return geometry 

5044 

5045 def split_tracelines_into_segments(self, in_place = False): 

5046 """ 

5047 Splits long tracelines into many length-2 tracelines 

5048 

5049 Parameters 

5050 ---------- 

5051 in_place : bool, optional 

5052 If True, the current geometry will be modified in place. The 

5053 default is False. 

5054 

5055 Returns 

5056 ------- 

5057 geometry : Geometry 

5058 Only returned if in_place is False. 

5059 

5060 """ 

5061 temp_geometry = Geometry() 

5062 for traceline in self.traceline: 

5063 connectivity = np.array((traceline.connectivity[:-1],traceline.connectivity[1:])).T 

5064 connectivity = connectivity[np.all(connectivity != 0,axis=-1)] 

5065 for conn in connectivity: 

5066 temp_geometry.add_traceline(conn, traceline.id, traceline.description, traceline.color) 

5067 if not in_place: 

5068 geometry = self.copy() 

5069 geometry.traceline = temp_geometry.traceline 

5070 return geometry 

5071 else: 

5072 self.traceline = temp_geometry.traceline 

5073 

5074 def remove_duplicate_tracelines(self, in_place = False): 

5075 """ 

5076 Removes tracelines that are effectively equivalent to each other. 

5077  

5078 If two tracelines have the same connectivity (or reversed connectivity), 

5079 then the first will be kept and the second will be discarded. This will 

5080 not take into account other fields such as description or color. 

5081  

5082 Parameters 

5083 ---------- 

5084 in_place : bool, optional 

5085 If True, the current geometry will be modified in place. The 

5086 default is False. 

5087 

5088 Returns 

5089 ------- 

5090 geometry : Geometry 

5091 Only returned if in_place is False. 

5092 

5093 """ 

5094 conn_set = set([]) 

5095 keep_array = [] 

5096 for traceline in self.traceline: 

5097 conn = tuple(traceline.connectivity) 

5098 if conn in conn_set or conn[::-1] in conn_set: 

5099 keep_array.append(False) 

5100 else: 

5101 keep_array.append(True) 

5102 conn_set.add(conn) 

5103 if not in_place: 

5104 geometry = self.copy() 

5105 geometry.traceline = geometry.traceline[keep_array] 

5106 return geometry 

5107 else: 

5108 self.traceline = self.traceline[keep_array] 

5109 

5110 def plot(self, node_size: int = 5, line_width: int = 1, opacity=1.0, view_up=None, view_from=None, plotter=None, 

5111 show_edges=False, label_nodes = False, label_tracelines = False, 

5112 label_elements = False, label_font_size = 16, 

5113 plot_individual_items = False): 

5114 """ 

5115 Plots the geometry in an interactive 3D window. 

5116 

5117 Parameters 

5118 ---------- 

5119 node_size : int, optional 

5120 Size to display the nodes in pixels. Set to 0 to not display nodes. 

5121 The default is 5. 

5122 line_width : int, optional 

5123 Width to display tracelines and element edges in pixels. Set to 0 

5124 to not show tracelines or edges. The default is 1. 

5125 opacity : float, optional 

5126 A float between 0 and 1 to specify the transparency of the geometry. 

5127 Set to 1 for completely opaque, and 0 for completely transparent 

5128 (invisible). The default is 1.0, no transparency. 

5129 view_up : np.ndarray, optional 

5130 Set the "up" direction in the plot by passing in a size-3 numpy 

5131 array. The default is None. 

5132 view_from : np.ndarray, optional 

5133 Specify the direction from which the geometry is viewed. The 

5134 default is None. 

5135 plotter : BackgroundPlotter, optional 

5136 A plotter can be specified to plot the geometry in an existing 

5137 plot. The default is None, which creates a new window and plot. 

5138 show_edges : bool, optional 

5139 Specify whether or not to draw edges on elements. The default is False. 

5140 label_nodes : bool or iteratble, optional 

5141 Specify whether or not to label the nodes in the geometry. If True, 

5142 all nodes will be plotted. If label_nodes is an array or list, it 

5143 should contain the node numbers to label. The default is False, 

5144 which does not label nodes. 

5145 label_tracelines : bool or iteratble, optional 

5146 Specify whether or not to label the tracelines in the geometry. If True, 

5147 all tracelines will be plotted. If label_tracelines is an array or list, it 

5148 should contain the traceline numbers to label. The default is False, 

5149 which does not label tracelines. 

5150 label_elements : bool or iteratble, optional 

5151 Specify whether or not to label the elements in the geometry. If True, 

5152 all elements will be plotted. If label_elements is an array or list, it 

5153 should contain the element numbers to label. The default is False, 

5154 which does not label elements. 

5155 label_font_size : int, optional 

5156 Specify the font size of the node labels. The default is 16. 

5157 plot_individual_items : bool, optional 

5158 If True, then each item will be plotted one at a time. This will 

5159 make the plotting much slower, but it is required for certain 

5160 features (like exporting to 3D PDF content) where having all data 

5161 in one mesh is not feasible. Note that volume elements will NOT 

5162 be plotted in this case. 

5163 

5164 Raises 

5165 ------ 

5166 KeyError 

5167 If referenced id numbers are not found in the corresponding object, 

5168 for example if a traceline references node 11 but there is no node 

5169 11 in the NodeArray 

5170 ValueError 

5171 If an invalid or unknown element type is used 

5172 

5173 Returns 

5174 ------- 

5175 plotter : BackgroundPlotter 

5176 A reference to the plotter object that the geometry was plotted in 

5177 face_mesh : TYPE 

5178 A reference to the mesh used to plot surface elements 

5179 point_mesh : TYPE 

5180 A reference to the mesh used to plot nodes and tracelines 

5181 solid_mesh : TYPE 

5182 A reference to the mesh used to plot volume elements 

5183 

5184 """ 

5185 if IGNORE_PLOTS: 

5186 return None 

5187 # Get part information 

5188 nodes = self.node.flatten() 

5189 css = self.coordinate_system.flatten() 

5190 elems = self.element.flatten() 

5191 tls = self.traceline.flatten() 

5192 

5193 num_labels = ((1 if (label_nodes is True or (label_nodes is not False and len(label_nodes) > 0)) else 0) 

5194 + (1 if (label_tracelines is True or (label_tracelines is not False and len(label_tracelines) > 0)) else 0) 

5195 + (1 if (label_elements is True or (label_elements is not False and len(label_elements) > 0)) else 0)) 

5196 

5197 coordinate_system = CoordinateSystemArray(nodes.shape) 

5198 cs_map = {cs['id']:idx for idx,cs in np.ndenumerate(self.coordinate_system)} 

5199 for key, node in nodes.ndenumerate(): 

5200 coordinate_system[key] = css[cs_map[node.def_cs]] 

5201 global_node_positions = global_coord(coordinate_system, nodes.coordinate) 

5202 node_index_dict = {node.id[()]: index[0] for index, node in nodes.ndenumerate()} 

5203 node_index_map = np.vectorize(node_index_dict.__getitem__) 

5204 tl_index_dict = {tl.id[()]: index[0] for index, tl in tls.ndenumerate()} 

5205 tl_index_map = np.vectorize(tl_index_dict.__getitem__) 

5206 elem_index_dict = {elem.id[()]: index[0] for index, elem in elems.ndenumerate()} 

5207 elem_index_map = np.vectorize(elem_index_dict.__getitem__) 

5208 

5209 # Now go through and get the element and line connectivity 

5210 if not plot_individual_items: 

5211 face_element_connectivity = [] 

5212 face_element_colors = [] 

5213 solid_element_connectivity = [] 

5214 solid_element_colors = [] 

5215 solid_element_types = [] 

5216 node_colors = [] 

5217 line_connectivity = [] 

5218 line_colors = [] 

5219 for index, node in nodes.ndenumerate(): 

5220 # element_connectivity.append(1) 

5221 # element_connectivity.append(index[0]) 

5222 # element_colors.append(node.color) 

5223 node_colors.append(node.color) 

5224 for index, element in elems.ndenumerate(): 

5225 # Check which type of element it is 

5226 if element.type in _beam_elem_types: # Beamlike element, use a line 

5227 line_connectivity.append(len(element.connectivity)) 

5228 try: 

5229 line_connectivity.extend(node_index_map(element.connectivity)) 

5230 except KeyError: 

5231 raise KeyError( 

5232 'Element {:} contains a node id not found in the node array'.format(element.id)) 

5233 line_colors.append(element.color) 

5234 elif element.type in _face_element_types: 

5235 face_element_connectivity.append(len(element.connectivity)) 

5236 try: 

5237 face_element_connectivity.extend(node_index_map(element.connectivity)) 

5238 except KeyError: 

5239 raise KeyError( 

5240 'Element {:} contains a node id not found in the node array'.format(element.id)) 

5241 face_element_colors.append(element.color) 

5242 elif element.type in _solid_element_types: 

5243 try: 

5244 solid_element_types.append(_vtk_element_map[element.type]) 

5245 except KeyError: 

5246 raise ValueError('Do not know equivalent VTK element type for {:}: {:}'.format( 

5247 element.type, _element_types[element.type])) 

5248 solid_element_connectivity.append(len(element.connectivity)) 

5249 try: 

5250 if solid_element_types[-1] in _vtk_connectivity_reorder: 

5251 solid_element_connectivity.extend(node_index_map( 

5252 element.connectivity[..., _vtk_connectivity_reorder[solid_element_types[-1]]])) 

5253 else: 

5254 solid_element_connectivity.extend(node_index_map(element.connectivity)) 

5255 except KeyError: 

5256 raise KeyError( 

5257 'Element {:} contains a node id not found in the node array'.format(element.id)) 

5258 solid_element_colors.append(element.color) 

5259 else: 

5260 raise ValueError('Unknown element type {:}'.format(element.type)) 

5261 

5262 for index, tl in tls.ndenumerate(): 

5263 for conn_group in split_list(tl.connectivity, 0): 

5264 if len(conn_group) == 0: 

5265 continue 

5266 line_connectivity.append(len(conn_group)) 

5267 try: 

5268 line_connectivity.extend(node_index_map(conn_group)) 

5269 except KeyError: 

5270 raise KeyError( 

5271 'Traceline {:} contains a node id not found in the node array'.format(tl.id)) 

5272 line_colors.append(tl.color) 

5273 else: 

5274 face_element_connectivity = [] 

5275 face_element_colors = [] 

5276 node_colors = [] 

5277 line_connectivity = [] 

5278 line_colors = [] 

5279 for index, node in nodes.ndenumerate(): 

5280 # element_connectivity.append(1) 

5281 # element_connectivity.append(index[0]) 

5282 # element_colors.append(node.color) 

5283 node_colors.append(node.color) 

5284 for index, element in elems.ndenumerate(): 

5285 # Check which type of element it is 

5286 if element.type in _beam_elem_types: # Beamlike element, use a line 

5287 try: 

5288 line_connectivity.append(node_index_map(element.connectivity)) 

5289 except KeyError: 

5290 raise KeyError( 

5291 'Element {:} contains a node id not found in the node array'.format(element.id)) 

5292 line_colors.append(element.color) 

5293 elif element.type in _face_element_types: 

5294 try: 

5295 if len(element.connectivity) == 3: 

5296 face_element_connectivity.append(node_index_map(element.connectivity)) 

5297 face_element_colors.append(element.color) 

5298 else: 

5299 face_element_connectivity.append(node_index_map(element.connectivity[[0,1,3]])) 

5300 face_element_colors.append(element.color) 

5301 face_element_connectivity.append(node_index_map(element.connectivity[[1,2,3]])) 

5302 face_element_colors.append(element.color) 

5303 except KeyError: 

5304 raise KeyError( 

5305 'Element {:} contains a node id not found in the node array'.format(element.id)) 

5306 elif element.type in _solid_element_types: 

5307 warnings.warn('Solid Elements are currently not supported and will be skipped!') 

5308 else: 

5309 raise ValueError('Unknown element type {:}'.format(element.type)) 

5310 

5311 for index, tl in tls.ndenumerate(): 

5312 for conn_group in split_list(tl.connectivity, 0): 

5313 if len(conn_group) == 0: 

5314 continue 

5315 try: 

5316 mapped_conn_group = node_index_map(conn_group) 

5317 except KeyError: 

5318 raise KeyError( 

5319 'Traceline {:} contains a node id not found in the node array'.format(tl.id)) 

5320 for indices in zip(mapped_conn_group[:-1],mapped_conn_group[1:]): 

5321 line_connectivity.append(indices) 

5322 line_colors.append(tl.color) 

5323 

5324 # Now we start to plot 

5325 if plotter is None: 

5326 plotter = GeometryPlotter(editor=False) 

5327 

5328 if not plot_individual_items: 

5329 # Need to split up between point mesh, face/line mesh, and solid mesh 

5330 if len(face_element_connectivity) == 0 and len(line_connectivity) == 0: 

5331 face_mesh = None 

5332 else: 

5333 face_mesh = pv.PolyData(global_node_positions, 

5334 faces=None if len( 

5335 face_element_connectivity) == 0 else face_element_connectivity, 

5336 lines=None if len(line_connectivity) == 0 else line_connectivity) 

5337 face_mesh.cell_data['color'] = line_colors + face_element_colors 

5338 

5339 plotter.add_mesh(face_mesh, scalars='color', cmap=colormap, clim=[0, 15], 

5340 show_edges=show_edges, # True if line_width > 0 else False, 

5341 show_scalar_bar=False, line_width=line_width, 

5342 opacity=opacity) 

5343 if len(solid_element_connectivity) == 0: 

5344 solid_mesh = None 

5345 else: 

5346 solid_mesh = pv.UnstructuredGrid(np.array(solid_element_connectivity), 

5347 np.array(solid_element_types, dtype='uint8'), 

5348 np.array(global_node_positions)) 

5349 solid_mesh.cell_data['color'] = solid_element_colors 

5350 

5351 plotter.add_mesh(solid_mesh, scalars='color', cmap=colormap, clim=[0, 15], 

5352 show_edges=show_edges, # True if line_width > 0 else False, 

5353 show_scalar_bar=False, line_width=line_width, 

5354 opacity=opacity) 

5355 

5356 if node_size > 0: 

5357 point_mesh = pv.PolyData(global_node_positions) 

5358 point_mesh.cell_data['color'] = node_colors 

5359 plotter.add_mesh(point_mesh, scalars=node_colors, cmap=colormap, clim=[0, 15], 

5360 show_edges=show_edges, # True if line_width > 0 else False, 

5361 show_scalar_bar=False, point_size=node_size, 

5362 opacity=opacity) 

5363 else: 

5364 point_mesh = None 

5365 

5366 if (label_nodes is True or (label_nodes is not False and len(label_nodes) > 0)): 

5367 try: 

5368 label_node_ids = [id_num for id_num in label_nodes] 

5369 label_node_indices = node_index_map(label_node_ids) 

5370 except TypeError: 

5371 label_node_ids = [id_num for id_num in self.node.id] 

5372 label_node_indices = node_index_map(label_node_ids) 

5373 

5374 node_label_mesh = pv.PolyData(global_node_positions[label_node_indices]) 

5375 node_label_mesh['NODE_ID'] = [ 

5376 ('N' if num_labels > 1 else '') + str(val) for val in label_node_ids] 

5377 plotter.add_point_labels(node_label_mesh, 'NODE_ID', tolerance=0.0, shape=None, 

5378 show_points=False, always_visible=True, font_size=label_font_size) 

5379 

5380 if (label_tracelines is True or (label_tracelines is not False and len(label_tracelines) > 0)): 

5381 try: 

5382 label_traceline_ids = [id_num for id_num in label_tracelines] 

5383 label_traceline_indices = tl_index_map(label_traceline_ids) 

5384 except TypeError: 

5385 label_traceline_ids = [id_num for id_num in self.traceline.id] 

5386 label_traceline_indices = tl_index_map(label_traceline_ids) 

5387 

5388 # Need to get the point to plot for each traceline 

5389 traceline_locations = [] 

5390 for idx in label_traceline_indices: 

5391 tl = tls[idx] 

5392 connectivity = tl.connectivity[tl.connectivity != 0] 

5393 # Find the middle of the traceline 

5394 center_nodes = [connectivity[len(connectivity)//2-1],connectivity[len(connectivity)//2]] 

5395 position = np.mean(global_node_positions[node_index_map(center_nodes)],axis=0) 

5396 traceline_locations.append(position) 

5397 

5398 tl_label_mesh = pv.PolyData(np.array(traceline_locations)) 

5399 tl_label_mesh['TL_ID'] = [ 

5400 ('L' if num_labels > 1 else '') + str(val) for val in label_traceline_ids] 

5401 plotter.add_point_labels(tl_label_mesh, 'TL_ID', tolerance=0.0, shape=None, 

5402 show_points=False, always_visible=True, font_size=label_font_size) 

5403 

5404 if (label_elements is True or (label_elements is not False and len(label_elements) > 0)): 

5405 try: 

5406 label_element_ids = [id_num for id_num in label_elements] 

5407 label_element_indices = elem_index_map(label_element_ids) 

5408 except TypeError: 

5409 label_element_ids = [id_num for id_num in self.element.id] 

5410 label_element_indices = elem_index_map(label_element_ids) 

5411 

5412 # Need to get the point to plot for each traceline 

5413 element_locations = [] 

5414 for idx in label_element_indices: 

5415 elem = elems[idx] 

5416 connectivity = elem.connectivity 

5417 # Find the middle of the traceline 

5418 position = np.mean(global_node_positions[node_index_map(connectivity)],axis=0) 

5419 element_locations.append(position) 

5420 

5421 elem_label_mesh = pv.PolyData(np.array(element_locations)) 

5422 elem_label_mesh['ELEM_ID'] = [ 

5423 ('E' if num_labels > 1 else '') + str(val) for val in label_element_ids] 

5424 plotter.add_point_labels(elem_label_mesh, 'ELEM_ID', tolerance=0.0, shape=None, 

5425 show_points=False, always_visible=True, font_size=label_font_size) 

5426 

5427 else: 

5428 face_map = [] 

5429 face_mesh = [] 

5430 for conn,color in zip(face_element_connectivity,face_element_colors): 

5431 node_indices,inverse = np.unique(conn,return_inverse=True) 

5432 node_positions = global_node_positions[node_indices][inverse] 

5433 node_connectivity = np.arange(node_positions.shape[0]) 

5434 mesh = pv.PolyData(node_positions,faces=[len(node_connectivity)]+[c for c in node_connectivity]) 

5435 mesh.cell_data['color'] = color 

5436 mesh.SetObjectName('Elem: {:} {:} {:}'.format(*self.node.id[node_indices[inverse]])) 

5437 plotter.add_mesh(mesh,scalars='color',cmap=colormap, clim = [0,15], 

5438 show_edges=show_edges, show_scalar_bar = False, 

5439 line_width=line_width,opacity=opacity,label='Elem: {:} {:} {:}'.format(*self.node.id[node_indices[inverse]])) 

5440 face_map.append(self.node.id[node_indices[inverse]]) 

5441 face_mesh.append(mesh) 

5442 

5443 line_map = [] 

5444 line_mesh = [] 

5445 for conn,color in zip(line_connectivity,line_colors): 

5446 node_indices,inverse = np.unique(conn,return_inverse=True) 

5447 node_positions = global_node_positions[node_indices][inverse] 

5448 node_connectivity = np.arange(node_positions.shape[0]) 

5449 mesh = pv.PolyData(node_positions,lines=[len(node_connectivity)]+[c for c in node_connectivity]) 

5450 mesh.cell_data['color'] = color 

5451 mesh.SetObjectName('Line: {:} {:}'.format(*self.node.id[node_indices[inverse]])) 

5452 plotter.add_mesh(mesh,scalars='color',cmap=colormap, clim = [0,15], 

5453 show_edges=show_edges, show_scalar_bar = False, 

5454 line_width=line_width,opacity=opacity,label='Line: {:} {:}'.format(*self.node.id[node_indices[inverse]])) 

5455 line_map.append(self.node.id[node_indices[inverse]]) 

5456 line_mesh.append(mesh) 

5457 

5458 point_map = [] 

5459 point_mesh = [] 

5460 for node,position,color in zip(self.node.id,global_node_positions,self.node.color): 

5461 if node_size > 0: 

5462 mesh = pv.PolyData(position) 

5463 mesh.cell_data['color'] = color 

5464 plotter.add_mesh(mesh, scalars = color, cmap=colormap, clim=[0,15], 

5465 show_edges=show_edges, show_scalar_bar=False, point_size=node_size, 

5466 opacity=opacity) 

5467 point_map.append(node) 

5468 point_mesh.append(mesh) 

5469 

5470 if label_nodes: 

5471 try: 

5472 label_node_ids = [id_num for id_num in label_nodes] 

5473 label_node_indices = node_index_map(label_node_ids) 

5474 except TypeError: 

5475 label_node_ids = [id_num for id_num in self.node.id] 

5476 label_node_indices = node_index_map(label_node_ids) 

5477 

5478 for index,label in zip(label_node_indices,label_node_ids): 

5479 node_label_mesh = pv.PolyData(global_node_positions[index]) 

5480 node_label_mesh['NODE_ID'] = [str(label)] 

5481 plotter.add_point_labels(node_label_mesh, 'NODE_ID', tolerance=0.0, shape=None, 

5482 show_points=False, always_visible=True, font_size=label_font_size) 

5483 

5484 if view_from is not None: 

5485 focus = plotter.camera.focal_point 

5486 distance = plotter.camera.distance 

5487 plotter.camera.position = np.array( 

5488 focus) + distance * np.array(view_from) / np.linalg.norm(view_from) 

5489 plotter.camera.focal_point = focus 

5490 if view_up is not None: 

5491 plotter.camera.up = view_up 

5492 plotter.show() 

5493 plotter.render() 

5494 QApplication.processEvents() 

5495 if plot_individual_items: 

5496 return plotter, (face_mesh,face_map), (line_mesh,line_map), (point_mesh,point_map) 

5497 else: 

5498 return plotter, face_mesh, point_mesh, solid_mesh 

5499 

5500 def global_geometry_table(self,coordinate_array_or_nodelist = None): 

5501 if coordinate_array_or_nodelist is None: 

5502 coord = coordinate_array(self.node.id,[1,2,3],force_broadcast=True) 

5503 elif isinstance(coordinate_array_or_nodelist,CoordinateArray): 

5504 coord = coordinate_array_or_nodelist.flatten() 

5505 else: 

5506 coord = coordinate_array(coordinate_array_or_nodelist,[1,2,3],force_broadcast=True) 

5507 global_positions = self.global_node_coordinate(coord.node) 

5508 global_directions = self.global_deflection(coord) 

5509 coord_string = coord.string_array() 

5510 df_dict = {} 

5511 df_dict['Degree of Freedom'] = coord_string 

5512 for label,column in zip('XYZ',global_positions.T): 

5513 df_dict[f'Coordinate {label}'] = column 

5514 for label,column in zip('XYZ',global_directions.T): 

5515 df_dict[f'Direction {label}'] = column 

5516 df = pd.DataFrame(df_dict) 

5517 return df 

5518 

5519 def plot_coordinate(self, coordinates: CoordinateArray = None, 

5520 arrow_scale=0.1, 

5521 arrow_scale_type='bbox', label_dofs=False, 

5522 label_font_size=16, opacity=1.0, 

5523 arrow_ends_on_node=False, plot_kwargs={}, 

5524 plot_individual_items = False): 

5525 """ 

5526 Plots coordinate arrows on the geometry 

5527 

5528 Parameters 

5529 ---------- 

5530 coordinates : CoordinateArray, optional 

5531 Coordinates to draw on the geometry. If no coordinates are specified, 

5532 all translation degrees of freedom at each node will be plotted. 

5533 arrow_scale : float | np.ndarray, optional 

5534 Size of the arrows in proportion to the length of the diagonal of 

5535 the bounding box of the Geometry if `arrow_scale_type` is 'bbox', 

5536 otherwise the raw length of the arrow. If an ndarray is provided,  

5537 it should match the shape of `coordinates`. The default is 0.1. 

5538 arrow_scale_type : str, optional 

5539 Specifies how to compute the size of the arrows. If 'bbox', then 

5540 the arrow is scaled based on the size of the geometry. Otherwise, 

5541 the arrow size is the specified length. The default is 'bbox'. 

5542 label_dofs : bool, optional 

5543 Specify whether or not to label the coordinates with strings. 

5544 The default is False. 

5545 label_font_size : int, optional 

5546 Specifies the font size for the node labels. 

5547 Default is 16. 

5548 opacity : float, optional 

5549 A float between 0 and 1 to specify the transparency of the geometry. 

5550 Set to 1 for completely opaque, and 0 for completely transparent 

5551 (invisible). The default is 1.0, no transparency. 

5552 arrow_ends_on_node : bool, optional 

5553 If True, arrow tip ends at the node, otherwise the arrow begins at 

5554 node. 

5555 Defualt is False 

5556 plot_kwargs : dict, optional 

5557 Any additional keywords that should be passed to the Geometry.plot 

5558 function. The default is {}. 

5559 plot_individual_items : bool, optional 

5560 If True, then each item will be plotted one at a time. This will 

5561 make the plotting much slower, but it is required for certain 

5562 features (like exporting to 3D PDF content) where having all data 

5563 in one mesh is not feasible. Note that volume elements will NOT 

5564 be plotted in this case. 

5565 

5566 Returns 

5567 ------- 

5568 plotter : BackgroundPlotter 

5569 A reference to the window the geometry was plotted in. 

5570 

5571 """ 

5572 if IGNORE_PLOTS: 

5573 return None 

5574 plot_kwargs = plot_kwargs.copy() 

5575 plot_kwargs['plot_individual_items'] = plot_individual_items 

5576 plotter, face_mesh, point_mesh, solid_mesh = self.plot(opacity=opacity, **plot_kwargs) 

5577 # Add deflection directions to each node 

5578 # Get local deformation for each coordinate direction 

5579 

5580 if coordinates is None: 

5581 coordinates = from_nodelist(self.node.id) 

5582 

5583 def build_coord_mesh(coordinates, geom, scale_factors=None): 

5584 nodes = coordinates.node 

5585 indices = np.isin(coordinates.node, geom.node.id) 

5586 coordinates = coordinates[indices] 

5587 nodes = nodes[indices] 

5588 local_deformations = coordinates.local_direction() 

5589 coordinate_systems = geom.coordinate_system(geom.node(nodes).disp_cs) 

5590 points = global_coord(geom.coordinate_system( 

5591 geom.node(nodes).def_cs), geom.node(nodes).coordinate) 

5592 global_deflections = global_deflection(coordinate_systems, local_deformations, points) 

5593 # Now add the point array to the mesh 

5594 coord_mesh = pv.PolyData(points) 

5595 coord_mesh.point_data['Coordinates'] = global_deflections 

5596 coord_mesh.point_data['Direction'] = abs(coordinates.direction) 

5597 coord_mesh.point_data['Node ID'] = nodes 

5598 if scale_factors is not None: 

5599 coord_mesh.point_data['Scale'] = scale_factors 

5600 return coord_mesh, points, global_deflections 

5601 

5602 coordinates = coordinates.flatten() 

5603 

5604 if isinstance(arrow_scale, int | float): 

5605 scale = False 

5606 arrow_scale_array = np.ones(len(coordinates)) 

5607 global_arrow_scale = arrow_scale 

5608 elif isinstance(arrow_scale, np.ndarray): 

5609 scale = 'Scale' 

5610 arrow_scale_array = arrow_scale 

5611 global_arrow_scale = 1 

5612 

5613 if arrow_scale_type == 'bbox': 

5614 node_coords = self.global_node_coordinate() 

5615 bbox_diagonal = np.linalg.norm( 

5616 np.max(node_coords, axis=0) - np.min(node_coords, axis=0)) 

5617 arrow_factor = bbox_diagonal * global_arrow_scale 

5618 else: 

5619 arrow_factor = global_arrow_scale 

5620 

5621 # Create Straight Arrows and Labels 

5622 coordinates_straight = coordinates[np.abs(coordinates.direction) < 4] 

5623 if arrow_scale_array is not None: 

5624 straight_scaling = arrow_scale_array[np.abs(coordinates.direction) < 4] 

5625 else: 

5626 straight_scaling = None 

5627 if coordinates_straight.size: 

5628 if plot_individual_items: 

5629 for coordinate_straight in coordinates_straight: 

5630 [coord_mesh_straight, points_straight, global_deflections_straight] = build_coord_mesh( 

5631 coordinate_straight[np.newaxis], self, straight_scaling) 

5632 

5633 if arrow_ends_on_node: 

5634 arrow_start = (-1.0, 0.0, 0.0) 

5635 else: 

5636 arrow_start = (0.0, 0.0, 0.0) 

5637 coord_arrows_stright = coord_mesh_straight.glyph( 

5638 orient='Coordinates', scale=scale, factor=arrow_factor, geom=pv.Arrow(start=arrow_start)) 

5639 color = ((1.0,0.0,0.0) if abs(coordinate_straight.direction) == 1 else 

5640 (0.0,1.0,0.0) if abs(coordinate_straight.direction) == 2 else 

5641 (0.0,0.0,1.0)) 

5642 plotter.add_mesh(coord_arrows_stright, color = color, show_scalar_bar=False) 

5643 else: 

5644 [coord_mesh_straight, points_straight, global_deflections_straight] = build_coord_mesh( 

5645 coordinates_straight, self, straight_scaling) 

5646 

5647 if arrow_ends_on_node: 

5648 arrow_start = (-1.0, 0.0, 0.0) 

5649 else: 

5650 arrow_start = (0.0, 0.0, 0.0) 

5651 coord_arrows_stright = coord_mesh_straight.glyph( 

5652 orient='Coordinates', scale=scale, factor=arrow_factor, geom=pv.Arrow(start=arrow_start)) 

5653 plotter.add_mesh(coord_arrows_stright, scalars='Direction', 

5654 cmap=coord_colormap, clim=[1, 6], show_scalar_bar=False) 

5655 

5656 if label_dofs: 

5657 if arrow_ends_on_node: 

5658 dof_label_mesh = pv.PolyData( 

5659 points_straight - global_deflections_straight * arrow_factor * straight_scaling[:,np.newaxis]) 

5660 else: 

5661 dof_label_mesh = pv.PolyData( 

5662 points_straight + global_deflections_straight * arrow_factor * straight_scaling[:,np.newaxis]) 

5663 dof_label_mesh['DOF'] = [val for val in coordinates_straight.string_array()] 

5664 plotter.add_point_labels(dof_label_mesh, 'DOF', tolerance=0.0, shape=None, 

5665 show_points=False, always_visible=True, font_size=label_font_size) 

5666 

5667 # Create Rotational Arrows and Labels 

5668 coordinates_rotation = coordinates[np.abs(coordinates.direction) > 3] 

5669 if arrow_scale_array is not None: 

5670 rotation_scaling = arrow_scale_array[np.abs(coordinates.direction) > 3] 

5671 else: 

5672 rotation_scaling = None 

5673 if coordinates_rotation.size: 

5674 if plot_individual_items: 

5675 for coordinate_rotation in coordinates_rotation: 

5676 [coord_mesh_rotation, points_rotation, global_deflections_rotation] = build_coord_mesh( 

5677 coordinate_rotation[np.newaxis], self, rotation_scaling) 

5678 

5679 arrow_index = np.arange(4) 

5680 head_location_angles = 1 / 8 * np.pi + 1 / 2 * np.pi * arrow_index 

5681 tail_location_angles = 3 / 8 * np.pi + 1 / 2 * np.pi * arrow_index 

5682 

5683 r = 1 

5684 arrow_head_locations = np.array([np.zeros( 

5685 head_location_angles.size), r * np.sin(head_location_angles), r * np.cos(head_location_angles)]).T 

5686 arrow_tail_locations = np.array([np.zeros( 

5687 tail_location_angles.size), r * np.sin(tail_location_angles), r * np.cos(tail_location_angles)]).T 

5688 cone_vectors = np.array([np.zeros(head_location_angles.size), np.sin( 

5689 head_location_angles - np.pi / 2), np.cos(head_location_angles - np.pi / 2)]).T 

5690 

5691 arc = pv.merge([pv.CircularArc(pointa=start, pointb=stop, center=(0, 0, 0)).tube( 

5692 radius=0.05) for start, stop in zip(arrow_head_locations, arrow_tail_locations)]) 

5693 cone = pv.merge([pv.Cone(center=cone_center, direction=cone_vector, height=0.3, radius=0.1, resolution=20) 

5694 for cone_center, cone_vector in zip(arrow_head_locations, cone_vectors)]) 

5695 

5696 curved_arrow = pv.merge([arc, cone]) 

5697 coord_arrows_rotation = coord_mesh_rotation.glyph( 

5698 orient='Coordinates', scale=scale, factor=arrow_factor, geom=curved_arrow) 

5699 color = ((1.0,0.0,0.0) if abs(coordinate_straight.direction) == 1 else 

5700 (0.0,1.0,0.0) if abs(coordinate_straight.direction) == 2 else 

5701 (0.0,0.0,1.0)) 

5702 plotter.add_mesh(coord_arrows_rotation, color=color, show_scalar_bar=False) 

5703 else: 

5704 [coord_mesh_rotation, points_rotation, global_deflections_rotation] = build_coord_mesh( 

5705 coordinates_rotation, self, rotation_scaling) 

5706 

5707 arrow_index = np.arange(4) 

5708 head_location_angles = 1 / 8 * np.pi + 1 / 2 * np.pi * arrow_index 

5709 tail_location_angles = 3 / 8 * np.pi + 1 / 2 * np.pi * arrow_index 

5710 

5711 r = 1 

5712 arrow_head_locations = np.array([np.zeros( 

5713 head_location_angles.size), r * np.sin(head_location_angles), r * np.cos(head_location_angles)]).T 

5714 arrow_tail_locations = np.array([np.zeros( 

5715 tail_location_angles.size), r * np.sin(tail_location_angles), r * np.cos(tail_location_angles)]).T 

5716 cone_vectors = np.array([np.zeros(head_location_angles.size), np.sin( 

5717 head_location_angles - np.pi / 2), np.cos(head_location_angles - np.pi / 2)]).T 

5718 

5719 arc = pv.merge([pv.CircularArc(pointa=start, pointb=stop, center=(0, 0, 0)).tube( 

5720 radius=0.05) for start, stop in zip(arrow_head_locations, arrow_tail_locations)]) 

5721 cone = pv.merge([pv.Cone(center=cone_center, direction=cone_vector, height=0.3, radius=0.1, resolution=20) 

5722 for cone_center, cone_vector in zip(arrow_head_locations, cone_vectors)]) 

5723 

5724 curved_arrow = pv.merge([arc, cone]) 

5725 coord_arrows_rotation = coord_mesh_rotation.glyph( 

5726 orient='Coordinates', scale=scale, factor=arrow_factor, geom=curved_arrow) 

5727 # Make Colors of Rotations same as Straight 

5728 coord_arrows_rotation['Direction'] = coord_arrows_rotation['Direction'] - 3 

5729 

5730 plotter.add_mesh(coord_arrows_rotation, scalars='Direction', 

5731 cmap=coord_colormap, clim=[1, 6], show_scalar_bar=False) 

5732 

5733 if label_dofs: 

5734 nodes = coordinates_rotation.node 

5735 indices = np.isin(coordinates_rotation.node, self.node.id) 

5736 nodes = nodes[indices] 

5737 local_deformations = coordinates_rotation.local_direction() 

5738 local_deformation_transformation = np.array(([arrow_head_locations[0, 0], arrow_head_locations[0, 1], arrow_head_locations[0, 2]], 

5739 [arrow_head_locations[0, 2], arrow_head_locations[0, 

5740 0], arrow_head_locations[0, 1]], 

5741 [arrow_head_locations[0, 1], arrow_head_locations[0, 2], arrow_head_locations[0, 0]])) 

5742 local_deformations_new = np.transpose( 

5743 local_deformation_transformation.T @ local_deformations.T) 

5744 coordinate_systems = self.coordinate_system(self.node(nodes).disp_cs) 

5745 points = global_coord(self.coordinate_system( 

5746 self.node(nodes).def_cs), self.node(nodes).coordinate) 

5747 global_deflections_rotation_new = global_deflection( 

5748 coordinate_systems, local_deformations_new, points) 

5749 

5750 dof_label_mesh = pv.PolyData( 

5751 points_rotation + global_deflections_rotation_new * arrow_factor * rotation_scaling[:,np.newaxis]) 

5752 

5753 dof_label_mesh['DOF'] = [val for val in coordinates_rotation.string_array()] 

5754 plotter.add_point_labels(dof_label_mesh, 'DOF', tolerance=0.0, shape=None, 

5755 show_points=False, always_visible=True, font_size=label_font_size) 

5756 return plotter 

5757 

5758 def plot_shape(self, shape, plot_kwargs={}, background_plotter_kwargs={'editor': False}, undeformed_opacity=0.25, deformed_opacity=1.0, starting_scale=1.0): 

5759 """ 

5760 Plot mode shapes on the geometry 

5761 

5762 Parameters 

5763 ---------- 

5764 shape : ShapeArray 

5765 The set of shapes to plot 

5766 plot_kwargs : dict, optional 

5767 Any additional keywords that should be passed to the Geometry.plot 

5768 function. The default is {}. 

5769 background_plotter_kwargs : dict, optional 

5770 Any additional arguments that should be passed to the 

5771 BackgroundPlotter initializer. The default is {'editor':False}. 

5772 undeformed_opacity : float, optional 

5773 A float between 0 and 1 to specify the transparency of the undeformed 

5774 geometry. Set to 1 for completely opaque, and 0 for completely 

5775 transparent (invisible). The default is 0.25. 

5776 deformed_opacity : float, optional 

5777 A float between 0 and 1 to specify the transparency of the deformed 

5778 geometry. Set to 1 for completely opaque, and 0 for completely 

5779 transparent (invisible). The default is 1.0. 

5780 starting_scale : float, optional 

5781 The starting scale factor of the animation. The default is 1.0. 

5782 

5783 Returns 

5784 ------- 

5785 ShapePlotter 

5786 A reference to the ShapePlotter class that is created to plot the 

5787 animated shapes 

5788 

5789 """ 

5790 if IGNORE_PLOTS: 

5791 return None 

5792 return ShapePlotter(self, shape, plot_kwargs, background_plotter_kwargs, 

5793 undeformed_opacity, deformed_opacity=deformed_opacity, starting_scale=starting_scale) 

5794 

5795 def plot_deflection_shape(self, deflection_shape_data, plot_kwargs={}, 

5796 background_plotter_kwargs={'editor': False}, 

5797 undeformed_opacity=0.25, deformed_opacity=1.0, 

5798 starting_scale=1.0): 

5799 """ 

5800 Plot deflection shapes shapes on the geometry 

5801 

5802 Parameters 

5803 ---------- 

5804 deflection_shape_data : NDDataArray 

5805 Data array containing the deflection shapes to plot 

5806 plot_kwargs : dict, optional 

5807 Any additional keywords that should be passed to the Geometry.plot 

5808 function. The default is {}. 

5809 background_plotter_kwargs : dict, optional 

5810 Any additional arguments that should be passed to the 

5811 BackgroundPlotter initializer. The default is {'editor':False}. 

5812 undeformed_opacity : float, optional 

5813 A float between 0 and 1 to specify the transparency of the undeformed 

5814 geometry. Set to 1 for completely opaque, and 0 for completely 

5815 transparent (invisible). The default is 0.25. 

5816 deformed_opacity : float, optional 

5817 A float between 0 and 1 to specify the transparency of the deformed 

5818 geometry. Set to 1 for completely opaque, and 0 for completely 

5819 transparent (invisible). The default is 1.0. 

5820 starting_scale : float, optional 

5821 The starting scale factor of the animation. The default is 1.0. 

5822 

5823 Returns 

5824 ------- 

5825 deflection_shape_data 

5826 A reference to the deflection_shape_data object that is created to 

5827 plot the animated shapes 

5828 

5829 """ 

5830 if IGNORE_PLOTS: 

5831 return None 

5832 return DeflectionShapePlotter(self, deflection_shape_data, plot_kwargs, background_plotter_kwargs, 

5833 undeformed_opacity, deformed_opacity=deformed_opacity, starting_scale=starting_scale) 

5834 

5835 def plot_transient(self, displacement_data, displacement_scale=1.0, 

5836 frames_per_second=20, 

5837 undeformed_opacity=0.0, deformed_opacity=1.0, 

5838 plot_kwargs={}, 

5839 transformation_shapes=None, 

5840 num_curves=50, 

5841 show: bool = True, 

5842 app=None, 

5843 window_size=None, 

5844 off_screen=None, 

5845 allow_quit_keypress=True, 

5846 toolbar=True, 

5847 menu_bar=True, 

5848 editor=False, 

5849 update_app_icon=None, 

5850 **kwargs): 

5851 """ 

5852 Create a TransientPlotter object to plot displacements over time 

5853 

5854 Parameters 

5855 ---------- 

5856 displacement_data : TimeHistoryArray 

5857 Transient displacement data that will be applied 

5858 displacement_scale : float, optional 

5859 Scale factor applied to displacements. The default is 1.0. 

5860 frames_per_second : float, optional 

5861 Number of time steps to plot per second while the displacement is 

5862 animating. Default is 20. 

5863 undeformed_opacity : float, optional 

5864 Opacity of the undeformed geometry. The default is 0.0, or 

5865 completely transparent. 

5866 deformed_opacity : float, optional 

5867 Opacity of the deformed geometry. The default is 1.0, or completely 

5868 opaque. 

5869 plot_kwargs : dict, optional 

5870 Keyword arguments passed to the Geometry.plot function 

5871 transformation_shapes : ShapeArray 

5872 Shape matrix that will be used to expand the data. Must be the 

5873 same size as the `displacement_data` 

5874 num_curves : int, optional 

5875 Maximum number of curves to plot on the time selector. Default is 

5876 50. 

5877 show : bool, optional 

5878 Show the plotting window. If ``False``, show this window by 

5879 running ``show()``. The default is True. 

5880 app : QApplication, optional 

5881 Creates a `QApplication` if left as `None`. The default is None. 

5882 window_size : list of int, optional 

5883 Window size in pixels. Defaults to ``[1024, 768]`` 

5884 off_screen : TYPE, optional 

5885 Renders off screen when True. Useful for automated 

5886 screenshots or debug testing. The default is None. 

5887 allow_quit_keypress : bool, optional 

5888 Allow user to exit by pressing ``"q"``. The default is True. 

5889 toolbar : bool, optional 

5890 If True, display the default camera toolbar. Defaults to True. 

5891 menu_bar : bool, optional 

5892 If True, display the default main menu. Defaults to True. 

5893 editor : TYPE, optional 

5894 If True, display the VTK object editor. Defaults to False. 

5895 update_app_icon : bool, optional 

5896 If True, update_app_icon will be called automatically to update the 

5897 Qt app icon based on the current rendering output. If None, the 

5898 logo of PyVista will be used. If False, no icon will be set. 

5899 Defaults to None. The default is None. 

5900 title : str, optional 

5901 Title of plotting window. 

5902 multi_samples : int, optional 

5903 The number of multi-samples used to mitigate aliasing. 4 is a 

5904 good default but 8 will have better results with a potential 

5905 impact on performance. 

5906 line_smoothing : bool, optional 

5907 If True, enable line smothing 

5908 point_smoothing : bool, optional 

5909 If True, enable point smothing 

5910 polygon_smoothing : bool, optional 

5911 If True, enable polygon smothing 

5912 auto_update : float, bool, optional 

5913 Automatic update rate in seconds. Useful for automatically 

5914 updating the render window when actors are change without 

5915 being automatically ``Modified``. If set to ``True``, update 

5916 rate will be 1 second. 

5917 

5918 Returns 

5919 ------- 

5920 TransientPlotter 

5921 """ 

5922 if IGNORE_PLOTS: 

5923 return None 

5924 return TransientPlotter(self, displacement_data, displacement_scale, 

5925 frames_per_second, 

5926 undeformed_opacity, deformed_opacity, 

5927 plot_kwargs, 

5928 transformation_shapes, 

5929 num_curves, 

5930 show, 

5931 app, 

5932 window_size, 

5933 off_screen, 

5934 allow_quit_keypress, 

5935 toolbar, 

5936 menu_bar, 

5937 editor, 

5938 update_app_icon, 

5939 **kwargs) 

5940 

5941 def __add__(self, geometry): 

5942 if not isinstance(self, geometry.__class__): 

5943 return NotImplemented 

5944 final_fields = {} 

5945 for field in ['node', 'traceline', 'element', 'coordinate_system']: 

5946 # Look through and see which are in common 

5947 common_ids = np.intersect1d(getattr(self, field).id, getattr(geometry, field).id) 

5948 if common_ids.size > 0: 

5949 equal_ids = getattr(self, field)(common_ids) == getattr(geometry, field)(common_ids) 

5950 if not all(equal_ids): 

5951 raise ValueError('Both geometries contain {:} with ID {:} but they are not equivalent'.format( 

5952 field, common_ids[~equal_ids])) 

5953 self_ids = np.concatenate( 

5954 (np.setdiff1d(getattr(self, field).id, getattr(geometry, field).id), common_ids)) 

5955 geometry_ids = np.setdiff1d(getattr(geometry, field).id, getattr(self, field).id) 

5956 final_fields[field] = np.concatenate( 

5957 (getattr(self, field)(self_ids), getattr(geometry, field)(geometry_ids))) 

5958 return Geometry(**final_fields) 

5959 

5960 def modify_ids(self, node_change=0, traceline_change=0, element_change=0, coordinate_system_change=0): 

5961 """ 

5962 Shifts the id numbers in the geometry 

5963 

5964 Parameters 

5965 ---------- 

5966 node_change : int, optional 

5967 The amount to shift the node ids. The default is 0. 

5968 traceline_change : int, optional 

5969 The amount to shift the traceline ids.. The default is 0. 

5970 element_change : int, optional 

5971 The amount to shift the element ids. The default is 0. 

5972 coordinate_system_change : int, optional 

5973 The amount to shift the coordinate system ids. The default is 0. 

5974 

5975 Returns 

5976 ------- 

5977 geom_out : Geometry 

5978 A copy of the original geometry with id numbers modified 

5979 

5980 """ 

5981 geom_out = self.copy() 

5982 geom_out.node.id += node_change 

5983 geom_out.traceline.id += traceline_change 

5984 geom_out.element.id += element_change 

5985 geom_out.coordinate_system.id += coordinate_system_change 

5986 geom_out.node.def_cs += coordinate_system_change 

5987 geom_out.node.disp_cs += coordinate_system_change 

5988 geom_out.traceline.connectivity += node_change 

5989 geom_out.element.connectivity += node_change 

5990 # Set values of tracelines back to zero if they just equal node_change, 

5991 # because that means they were zero and should represent picking up the 

5992 # pen 

5993 for connectivity_array in geom_out.traceline.connectivity: 

5994 connectivity_array[connectivity_array == node_change] = 0 

5995 return geom_out 

5996 

5997 def map_ids(self, node_id_map=None, traceline_id_map=None, element_id_map=None, coordinate_system_id_map=None): 

5998 """ 

5999 Maps id numbers from an original set of ids to a new set of ids. 

6000 

6001 This function accepts id_map classes defining "from" and "to" ids 

6002 Existing ids found in the "from" set are transformed to the corresponding 

6003 id in the "to" set. 

6004 

6005 Parameters 

6006 ---------- 

6007 node_id_map : id_map, optional 

6008 An id_map defining the mapping applied to node ids. 

6009 The default is None, which results in the ids being unchanged 

6010 traceline_id_map : id_map, optional 

6011 An id_map defining the mapping applied to traceline ids. 

6012 The default is None, which results in the ids being unchanged 

6013 element_id_map : id_map, optional 

6014 An id_map defining the mapping applied to element ids. 

6015 The default is None, which results in the ids being unchanged 

6016 coordinate_system_id_map : id_map, optional 

6017 An id_map defining the mapping applied to coordinate system ids. 

6018 The default is None, which results in the ids being unchanged 

6019 

6020 Returns 

6021 ------- 

6022 geom_out : Geometry 

6023 A copy of the original geometry with id numbers modified 

6024 

6025 """ 

6026 geom_out = self.copy() 

6027 if node_id_map is not None: 

6028 geom_out.node.id = node_id_map(self.node.id) 

6029 for key, val in self.traceline.ndenumerate(): 

6030 geom_out.traceline.connectivity[key] = node_id_map(self.traceline.connectivity[key]) 

6031 for key, val in self.element.ndenumerate(): 

6032 geom_out.element.connectivity[key] = node_id_map(self.element.connectivity[key]) 

6033 if coordinate_system_id_map is not None: 

6034 geom_out.coordinate_system.id = coordinate_system_id_map(self.coordinate_system.id) 

6035 geom_out.node.disp_cs = coordinate_system_id_map(self.node.disp_cs) 

6036 geom_out.node.def_cs = coordinate_system_id_map(self.node.def_cs) 

6037 if traceline_id_map is not None: 

6038 geom_out.traceline.id = traceline_id_map(self.traceline.id) 

6039 if element_id_map is not None: 

6040 geom_out.element.id = element_id_map(self.element.id) 

6041 return geom_out 

6042 

6043 def response_kinematic_transformation(self, response_coordinate, 

6044 virtual_point_node_number, 

6045 virtual_point_location=[0,0,0]): 

6046 """ 

6047 Creates a kinematic response transformation from the rigid body body  

6048 shapes for the associated geometry and response coordinates.  

6049 

6050 Parameters 

6051 ---------- 

6052 response_coordinate : CoordinateArray 

6053 The "physical" response coordinates for the kinematic transformation. 

6054 virtual_point_node_number : int 

6055 The numeric ID for the virtual point in the kinematic transformation. 

6056 virtual_point_location : list or ndarray 

6057 The [X, Y, Z] coordinates that defined the location of the virtual  

6058 point. The default is is [0, 0, 0].  

6059 

6060 Returns 

6061 ------- 

6062 transformation : Matrix 

6063 The kinematic transformation as a matrix object. It is organized with 

6064 the virtual point CoordinateArray on the rows and the physical  

6065 response CoordinateArray on the columns.  

6066 

6067 Notes  

6068 ----- 

6069 The transformation automatically handles polarity differences in the geometry  

6070 and response_coordinate. 

6071 

6072 References 

6073 ---------- 

6074 .. [1] M. Van der Seijs, D. van den Bosch, D. Rixen, and D. Klerk, "An improved  

6075 methodology for the virtual point transformation of measured frequency  

6076 response functions in dynamic substructuring," in Proceedings of the 4th  

6077 International Conference on Computational Methods in Structural Dynamics  

6078 and Earthquake Engineering, Kos Island, 2013, pp. 4334-4347,  

6079 doi: 10.7712/120113.4816.C1539.  

6080 """ 

6081 shapes = self.rigid_body_shapes(response_coordinate, cg = virtual_point_location) 

6082 transformation_array = np.linalg.pinv(shapes.shape_matrix.T) 

6083 

6084 virtual_point_coordinate = coordinate_array(node=virtual_point_node_number, direction=[1,2,3,4,5,6]) 

6085 

6086 return matrix(transformation_array, virtual_point_coordinate, response_coordinate) 

6087 

6088 def force_kinematic_transformation(self, force_coordinate, 

6089 virtual_point_node_number, 

6090 virtual_point_location=[0,0,0]): 

6091 """ 

6092 Creates a kinematic force transformation from the rigid body body  

6093 shapes for the associated geometry and force coordinates.  

6094 

6095 Parameters 

6096 ---------- 

6097 force_coordinate : CoordinateArray 

6098 The "physical" force coordinates for the kinematic transformation. 

6099 virtual_point_node_number : int 

6100 The numeric ID for the virtual point in the kinematic transformation. 

6101 virtual_point_location : list or ndarray 

6102 The [X, Y, Z] coordinates that defined the location of the virtual  

6103 point. The default is is [0, 0, 0].  

6104 

6105 Returns 

6106 ------- 

6107 transformation : Matrix 

6108 The kinematic transformation as a matrix object. It is organized with 

6109 the virtual point CoordinateArray on the rows and the physical  

6110 force CoordinateArray on the columns.  

6111 

6112 Notes  

6113 ----- 

6114 The transformation automatically handles polarity differences in the geometry  

6115 and force_coordinate. 

6116 

6117 References 

6118 ---------- 

6119 .. [1] M. Van der Seijs, D. van den Bosch, D. Rixen, and D. Klerk, "An improved  

6120 methodology for the virtual point transformation of measured frequency  

6121 response functions in dynamic substructuring," in Proceedings of the 4th  

6122 International Conference on Computational Methods in Structural Dynamics  

6123 and Earthquake Engineering, Kos Island, 2013, pp. 4334-4347,  

6124 doi: 10.7712/120113.4816.C1539.  

6125 """ 

6126 shapes = self.rigid_body_shapes(force_coordinate, cg = virtual_point_location) 

6127 virtual_point_coordinate = coordinate_array(node=virtual_point_node_number, direction=[1,2,3,4,5,6]) 

6128 

6129 return matrix(shapes.shape_matrix, virtual_point_coordinate, force_coordinate) 

6130 

6131 def copy(self): 

6132 """ 

6133 Return's a copy of the current Geometry 

6134 

6135 Changes to the copy will not also be applied to the original geometry 

6136 

6137 Returns 

6138 ------- 

6139 Geometry 

6140 A copy of the current Geometry 

6141 

6142 """ 

6143 return Geometry(self.node.copy(), self.coordinate_system.copy(), self.traceline.copy(), self.element.copy()) 

6144 

6145 def save(self, filename): 

6146 """Saves the geometry to a numpy .npz file 

6147 

6148 The .npz file will have fields 'node', 'coordinate_system', 'element', 

6149 and 'traceline', with each field storing the respective portion of the 

6150 geometry 

6151 

6152 Parameters 

6153 ---------- 

6154 filename : str 

6155 Filename to save the geometry to. If the filename doesn't end with 

6156 .npz, it will be appended. 

6157 

6158 Returns 

6159 ------- 

6160 None. 

6161 

6162 """ 

6163 np.savez(filename, 

6164 node=self.node.view(np.ndarray), 

6165 coordinate_system=self.coordinate_system.view(np.ndarray), 

6166 element=self.element.view(np.ndarray), 

6167 traceline=self.traceline.view(np.ndarray)) 

6168 

6169 def savemat(self, filename): 

6170 """Saves the geometry to a .mat file 

6171 

6172 The .mat file will have fields 'node', 'coordinate_system', 'element', 

6173 and 'traceline', with each field storing the respective portion of the 

6174 geometry 

6175 

6176 Parameters 

6177 ---------- 

6178 filename : str 

6179 Filename to save the geometry to.  

6180 

6181 Returns 

6182 ------- 

6183 None. 

6184 """ 

6185 

6186 mat_dict = {key: getattr(self, key).assemble_mat_dict() for key in 

6187 ['node', 'coordinate_system', 'element', 'traceline']} 

6188 scipy_savemat(filename,mat_dict) 

6189 

6190 def mass_props_from_rigid_body_shapes(self, rigid_shapes, return_rectified_shapes = False, 

6191 rectified_rotations_about_center_of_mass = False): 

6192 """ 

6193 Computes mass properties (mass, inertia tensor, center of mass) given 

6194 a set of mass-normalized rigid body shapes. 

6195 

6196 Parameters 

6197 ---------- 

6198 rigid_shapes : ShapeArray 

6199 A set of mass normalized rigid body mode shapes. They need not be 

6200 in any particular ordering, and may be linear combinations of  

6201 translations and rotations. They must, however, span the space of 

6202 rigid body modes. 

6203 return_rectified_shapes : bool 

6204 If True, a new set of shapes will be returned that will be rectified 

6205 to the three pure translations, and the three pure rotations about 

6206 the center of mass. 

6207 rectified_rotations_about_center_of_mass : bool 

6208 If True, the rectified shapes will be constructed such that they 

6209 rotate about the test article's center of mass. 

6210 

6211 Returns 

6212 ------- 

6213 mass_properties : dict 

6214 Contains: 

6215 - 'A' : (6, 6) transformation matrix such that B ≈ Phi @ A 

6216 - 'G' : (6, 6) rigid-body generalized mass matrix in basis B 

6217 - 'mass' : scalar total mass 

6218 - 'com_relative' : (3,) center of mass relative to rotation reference point 

6219 - 'com_global' : (3,) center of mass in global coordinates (if center_of_rotation provided) 

6220 - 'inertia_about_reference' : (3, 3) inertia tensor about the rotation reference point 

6221 - 'inertia_about_com' : (3, 3) inertia tensor about center of mass 

6222 - 'principal_inertias' : (3,) eigenvalues of inertia_about_com 

6223 - 'principal_axes' : (3, 3) eigenvectors of inertia_about_com 

6224 - 'reconstruction_error' : relative Frobenius norm of B - Phi @ A 

6225 - 'G_tt', 'G_tr', 'G_rt', 'G_rr' : 3x3 blocks of G 

6226 rectified_rigid_shapes : ShapeArray 

6227 Scaled versions of the rigid body shapes consisting of three pure 

6228 translation and three pure rotations. 

6229 

6230 """ 

6231 if rigid_shapes.size != 6: 

6232 raise ValueError('There must be six rigid shapes.') 

6233 if not np.all(rigid_shapes.ravel()[0].coordinate == rigid_shapes.coordinate): 

6234 raise ValueError('All rigid shapes must have the same coordinate.') 

6235 coordinates = rigid_shapes.ravel()[0].coordinate 

6236 rbm_modes = rigid_shapes.ravel().shape_matrix.T 

6237 rb_shapes = self.rigid_body_shapes(coordinates) 

6238 translation_shapes = rb_shapes[:3].shape_matrix.T 

6239 rotation_shapes = rb_shapes[3:].shape_matrix.T 

6240 scaled_shapes, mass_properties = rigid_body_shape_scaling( 

6241 rbm_modes, translation_shapes, rotation_shapes, 

6242 center_of_rotation=np.array([0,0,0]), 

6243 return_mass_properties = True) 

6244 if return_rectified_shapes: 

6245 if rectified_rotations_about_center_of_mass: 

6246 com = mass_properties['com_global'] 

6247 rb_shapes = self.rigid_body_shapes(coordinates, cg=com) 

6248 translation_shapes = rb_shapes[:3].shape_matrix.T 

6249 rotation_shapes = rb_shapes[3:].shape_matrix.T 

6250 scaled_shapes, mass_properties_com = rigid_body_shape_scaling( 

6251 rbm_modes, translation_shapes, rotation_shapes, 

6252 center_of_rotation=com, 

6253 return_mass_properties = True) 

6254 rectified_rigid_shapes = rigid_shapes.copy() 

6255 rectified_rigid_shapes.frequency = 0 

6256 rectified_rigid_shapes.damping = 0 

6257 rectified_rigid_shapes.comment1 = [ 

6258 'Translation X','Translation Y','Translation Z', 

6259 'Rotation X','Rotation Y','Rotation Z'] 

6260 rectified_rigid_shapes.comment2 = '' 

6261 rectified_rigid_shapes.comment3 = '' 

6262 rectified_rigid_shapes.comment4 = '' 

6263 rectified_rigid_shapes.comment5 = '' 

6264 rectified_rigid_shapes.shape_matrix = scaled_shapes.T 

6265 return mass_properties, rectified_rigid_shapes 

6266 else: 

6267 return mass_properties 

6268 

6269 

6270 def rigid_body_shapes(self, coordinates, mass=1, inertia=np.eye(3), cg=np.zeros(3), principal_axes=False): 

6271 """ 

6272 Creates a set of shapes corresponding to the rigid body motions 

6273 

6274 Rigid body translation and rotation shapes are computed analytically 

6275 from the Geoemtry 

6276 

6277 Parameters 

6278 ---------- 

6279 coordinates : CoordinateArray 

6280 coordinates at which to compute deformations 

6281 mass : float, optional 

6282 The mass of the geometry used to scale the rigid body translation 

6283 shapes. The default is 1. 

6284 inertia : np.ndarray, optional 

6285 A 3x3 array consisting of the mass moments of inertia, used to 

6286 scale the rotation shapes. The default is np.eye(3). 

6287 cg : np.ndarray, optional 

6288 The center of gravity of the Geometry about which the rotations 

6289 occur. The default is np.zeros(3). 

6290 principal_axes : bool, optional 

6291 If True, compute the principal axes of the test article and 

6292 perform rotations about those axes. The default is False. 

6293 

6294 Returns 

6295 ------- 

6296 output_shape : ShapeArray 

6297 A set of rigid body shapes for the current geometry 

6298 

6299 """ 

6300 if principal_axes: 

6301 rotation_scale, rotation_directions = np.linalg.eig(inertia) 

6302 rotation_directions /= np.linalg.norm(rotation_directions, axis=0, keepdims=True) 

6303 else: 

6304 rotation_scale = np.diag(inertia) 

6305 rotation_directions = np.eye(3) 

6306 M = np.diag(np.concatenate((mass * np.ones(3), rotation_scale))) 

6307 phi = np.sqrt(np.linalg.inv(M)) 

6308 # Construct the full coordinate array for all degrees of freedom for each node 

6309 full_coordinates = coordinate_array(np.unique(coordinates.node)[:, np.newaxis], 

6310 ['X+', 'Y+', 'Z+']).flatten() 

6311 # Assume local coordinates for now, transform later 

6312 translation_shape_matrix = full_coordinates.local_direction().T/np.sqrt(mass) 

6313 rotation_shape_matrix = np.zeros(translation_shape_matrix.shape) 

6314 for i in range(3): 

6315 for j in range(len(full_coordinates)): 

6316 direction = full_coordinates[j].local_direction() 

6317 coord = self.global_node_coordinate(full_coordinates[j].node) 

6318 rotation_shape_matrix[i, j] = phi[i + 3, i + 3] * \ 

6319 np.dot(direction, np.cross(rotation_directions[:, i], coord - cg)) 

6320 # Concatenate shapes together 

6321 shape_matrix = np.concatenate((translation_shape_matrix, rotation_shape_matrix), axis=0) 

6322 full_shapes = shape_array(full_coordinates, shape_matrix) 

6323 # Now we need to transform to the local coordinate systems 

6324 # First set up a geometry with just the global coordinate system in it 

6325 global_geometry = Geometry(node=self.node.copy(), 

6326 coordinate_system=coordinate_system_array((1,))) 

6327 global_geometry.node.disp_cs = 1 

6328 global_geometry.node.def_cs = 1 

6329 transformed_shape = full_shapes.transform_coordinate_system(global_geometry, self) 

6330 # Now turn it into our requested coordinates 

6331 reduced_shape_matrix = transformed_shape[coordinates] 

6332 output_shape = shape_array(coordinates, reduced_shape_matrix) 

6333 return output_shape 

6334 

6335 def coordinate_transformation_matrix(self, to_geometry : 'Geometry', 

6336 nodes : np.ndarray = None, 

6337 rotations : bool = False): 

6338 """ 

6339 Creates a transformation matrix that transforms one geometry to a new 

6340 geometry with different coordinate systems defined. 

6341 

6342 Parameters 

6343 ---------- 

6344 to_geometry : Geometry 

6345 Geometry object with coordinate systems that the transformation 

6346 matrix will transform into 

6347 nodes : np.ndarray, optional 

6348 An array of node id numbers to include in the transformation. 

6349 The default is to include all nodes in the geometry. 

6350 rotations : bool, optional 

6351 If True, create degrees of freedom for rotations as well as 

6352 translations. 

6353  

6354 

6355 Returns 

6356 ------- 

6357 Matrix 

6358 A Matrix object that transforms data or shapes currently 

6359 represented by this Geometry to the Geometry specified in the 

6360 `to_geometry` argument. 

6361 """ 

6362 if nodes is None: 

6363 nodes = np.intersect1d(self.node.id,to_geometry.node.id) 

6364 else: 

6365 nodes = np.ravel(nodes) 

6366 if rotations: 

6367 dofs = coordinate_array(nodes[:,np.newaxis,np.newaxis],[[1, 2, 3], 

6368 [4, 5, 6]]) 

6369 else: 

6370 dofs = coordinate_array(nodes[:,np.newaxis],[1, 2, 3]) 

6371 transform_from_original = self.global_deflection(dofs) 

6372 transform_to_new = to_geometry.global_deflection(dofs) 

6373 # We need to transform this matrix into block-diagonal form. 

6374 transform_from_original = transform_from_original.reshape(-1,3,3) 

6375 transform_to_new = transform_to_new.reshape(-1,3,3) 

6376 transform_from_original = block_diag(*transform_from_original) 

6377 transform_to_new = block_diag(*transform_to_new) 

6378 transform = transform_to_new @ np.linalg.pinv(transform_from_original) 

6379 transformation_matrix = matrix(transform,dofs.flatten(),dofs.flatten()) 

6380 return transformation_matrix 

6381 

6382 @classmethod 

6383 def load(cls, filename): 

6384 """ 

6385 Loads a geometry from a numpy .npz or .unv file 

6386 

6387 The .npz file must have fields 'node', 'coordinate_system', 'element', 

6388 and 'traceline', with each field storing the respective portion of the 

6389 geometry 

6390 

6391 The .unv file will need to have the proper datasets specified to define 

6392 a geometry 

6393 

6394 Parameters 

6395 ---------- 

6396 filename : str 

6397 Filename to load the geometry from. 

6398 

6399 Raises 

6400 ------ 

6401 AttributeError 

6402 Raised if the calling class does not have a `from_unv` method 

6403 defined 

6404 

6405 Returns 

6406 ------- 

6407 Geometry 

6408 Geometry constructed from the data in the loaded file 

6409 

6410 """ 

6411 if filename[-4:].lower() in ['.unv', '.uff']: 

6412 try: 

6413 from ..fileio.sdynpy_uff import readunv 

6414 unv_dict = readunv(filename) 

6415 return cls.from_unv(unv_dict) 

6416 except AttributeError: 

6417 raise AttributeError('Class {:} has no from_unv attribute defined'.format(cls)) 

6418 else: 

6419 with np.load(filename, allow_pickle=True) as data: 

6420 return Geometry(node=data['node'].view(NodeArray), 

6421 coordinate_system=data['coordinate_system'].view( 

6422 CoordinateSystemArray), 

6423 traceline=data['traceline'].view(TracelineArray), 

6424 element=data['element'].view(ElementArray)) 

6425 

6426 def write_to_unv(self, filename, write_nodes=True, write_coordinate_systems=True, 

6427 write_tracelines=True, write_elements=True, 

6428 dataset_2412_kwargs={}, 

6429 dataset_2420_kwargs={}): 

6430 """ 

6431 Write the geometry to a unversal file format file 

6432 

6433 Parameters 

6434 ---------- 

6435 filename : str 

6436 Filename to which the geometry will be written. If None, a 

6437 unv data dictionary will be returned instead, similar to that 

6438 obtained from the readunv function in sdynpy 

6439 write_nodes : bool, optional 

6440 If True, write the geometry's nodes to dataset 2411 in the output 

6441 file. The default is True. 

6442 write_coordinate_systems : True, optional 

6443 If True, write the geometry's coordinate systems to dataset 2420 

6444 in the output file. The default is True. 

6445 write_tracelines : bool, optional 

6446 If True, write the geometry's tracelines to dataset 82 in the 

6447 output file. The default is True. 

6448 write_elements : TYPE, optional 

6449 If True, write the geometry's elements to dataset 2412 in the 

6450 output file. The default is True. 

6451 dataset_2412_kwargs : dict, optional 

6452 Allows users to specify additional element parameters not stored 

6453 by the Geometry. The default is {}. 

6454 dataset_2420_kwargs : dict, optional 

6455 Allows users to specify additional coordinate system parameters not 

6456 stored by the Geometry. The default is {}. 

6457 

6458 Returns 

6459 ------- 

6460 unv_dict : dict 

6461 Dictionary containing unv information, similar to that obtained from 

6462 readunv. Only returned if filename is None. 

6463 

6464 """ 

6465 from ..fileio.sdynpy_uff import dataset_82, dataset_2411, dataset_2412, dataset_2420 

6466 # Write Coordinate systems 

6467 if write_coordinate_systems: 

6468 if self.coordinate_system.size > 0: 

6469 cs_unv = dataset_2420.Sdynpy_UFF_Dataset_2420( 

6470 cs_labels=self.coordinate_system.id.flatten(), 

6471 cs_types=self.coordinate_system.cs_type.flatten(), 

6472 cs_colors=self.coordinate_system.color.flatten(), 

6473 cs_names=[cs.name for index, cs in self.coordinate_system.ndenumerate()], 

6474 cs_matrices=self.coordinate_system.matrix.reshape(-1, 4, 3), 

6475 **dataset_2420_kwargs) 

6476 else: 

6477 cs_unv = None 

6478 if write_nodes: 

6479 if self.node.size > 0: 

6480 node_unv = dataset_2411.Sdynpy_UFF_Dataset_2411( 

6481 self.node.id.flatten(), 

6482 self.node.def_cs.flatten(), 

6483 self.node.disp_cs.flatten(), 

6484 self.node.color.flatten(), 

6485 self.node.coordinate.reshape(-1, 3)) 

6486 else: 

6487 node_unv = None 

6488 if write_elements: 

6489 if self.element.size > 0: 

6490 if 'physical_property_table_numbers' not in dataset_2412_kwargs: 

6491 dataset_2412_kwargs['physical_property_table_numbers'] = [1] * self.element.size 

6492 if 'material_property_table_numbers' not in dataset_2412_kwargs: 

6493 dataset_2412_kwargs['material_property_table_numbers'] = [1] * self.element.size 

6494 if 'beam_orientations' not in dataset_2412_kwargs: 

6495 dataset_2412_kwargs['beam_aft_cross_section_numbers'] = [ 

6496 None] * self.element.size 

6497 if 'beam_aft_cross_section_numbers' not in dataset_2412_kwargs: 

6498 dataset_2412_kwargs['beam_orientations'] = [None] * self.element.size 

6499 if 'beam_fore_cross_section_numbers' not in dataset_2412_kwargs: 

6500 dataset_2412_kwargs['beam_fore_cross_section_numbers'] = [ 

6501 None] * self.element.size 

6502 elem_unv = dataset_2412.Sdynpy_UFF_Dataset_2412( 

6503 element_labels=self.element.id.flatten(), 

6504 fe_descriptor_ids=self.element.type.flatten(), 

6505 colors=self.element.color.flatten(), 

6506 connectivities=[elem.connectivity for index, 

6507 elem in self.element.ndenumerate()], 

6508 **dataset_2412_kwargs) 

6509 else: 

6510 elem_unv = None 

6511 if write_tracelines: 

6512 tl_unv = [] 

6513 for index, tl in self.traceline.ndenumerate(): 

6514 tl_unv.append( 

6515 dataset_82.Sdynpy_UFF_Dataset_82( 

6516 tl.id, tl.color, tl.description, tl.connectivity, 

6517 )) 

6518 if filename is None: 

6519 unv_dict = {} 

6520 unv_dict[2420] = [cs_unv] 

6521 unv_dict[2411] = [node_unv] 

6522 unv_dict[2412] = [elem_unv] 

6523 unv_dict[82] = tl_unv 

6524 return unv_dict 

6525 else: 

6526 with open(filename, 'w') as f: 

6527 if cs_unv is not None: 

6528 f.write(' -1\n') 

6529 f.write(' 2420\n') 

6530 f.write(cs_unv.write_string()) 

6531 f.write(' -1\n') 

6532 if node_unv is not None: 

6533 f.write(' -1\n') 

6534 f.write(' 2411\n') 

6535 f.write(node_unv.write_string()) 

6536 f.write(' -1\n') 

6537 if elem_unv is not None: 

6538 f.write(' -1\n') 

6539 f.write(' 2412\n') 

6540 f.write(elem_unv.write_string()) 

6541 f.write(' -1\n') 

6542 for dataset in tl_unv: 

6543 f.write(' -1\n') 

6544 f.write(' 82\n') 

6545 f.write(dataset.write_string()) 

6546 f.write(' -1\n') 

6547 

6548 

6549from_excel_template = Geometry.from_excel_template 

6550write_excel_template = Geometry.write_excel_template 

6551from_imat_struct = Geometry.from_imat_struct 

6552from_exodus = Geometry.from_exodus 

6553from_unv = Geometry.from_unv 

6554from_uff = Geometry.from_uff 

6555load = Geometry.load 

6556 

6557 

6558class id_map: 

6559 """Class defining mapping between two sets of id numbers""" 

6560 

6561 def __init__(self, from_ids, to_ids): 

6562 """ 

6563 Initializes the id map 

6564 

6565 Parameters 

6566 ---------- 

6567 from_ids : np.ndarray 

6568 Id numbers to map from 

6569 to_ids : np.ndarray 

6570 Id numbers to map to 

6571 

6572 Returns 

6573 ------- 

6574 None. 

6575 

6576 """ 

6577 self.from_ids = from_ids 

6578 self.to_ids = to_ids 

6579 map_dict = {f: t for f, t in zip(from_ids, to_ids)} 

6580 map_dict[0] = 0 

6581 self.mapper = np.vectorize(map_dict.__getitem__, otypes=['uint64']) 

6582 

6583 def __call__(self, val): 

6584 """ 

6585 Map id numbers 

6586 

6587 Parameters 

6588 ---------- 

6589 val : np.ndarray 

6590 A set of id numbers to map. These must all be contained in the 

6591 id_map's from_ids array. 

6592 

6593 Returns 

6594 ------- 

6595 np.ndarray 

6596 The set of id numbers from the id_map's to_ids array corresponding 

6597 to those passed in to the function from the from_ids array. 

6598 

6599 """ 

6600 return self.mapper(val) 

6601 

6602 def inverse(self): 

6603 """ 

6604 Returns an inverse map, swapping the from and to ids. 

6605  

6606 If duplicate id entries exist, they will be overwritten. 

6607 

6608 Returns 

6609 ------- 

6610 id_map 

6611 An id_map object with swapped from and to ids. 

6612 

6613 """ 

6614 return id_map(self.to_ids,self.from_ids)