PyNiteFEA 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {pynitefea-2.2.0 → pynitefea-2.3.0}/PKG-INFO +16 -1
  2. {pynitefea-2.2.0 → pynitefea-2.3.0}/PyNiteFEA.egg-info/PKG-INFO +16 -1
  3. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Analysis.py +6 -6
  4. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/FEModel3D.py +20 -1
  5. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/MatFoundation.py +5 -0
  6. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Member3D.py +178 -79
  7. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Mesh.py +15 -2
  8. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/PhysMember.py +158 -68
  9. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Rendering.py +165 -26
  10. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/ShearWall.py +41 -2
  11. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/VTKWriter.py +28 -0
  12. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Visualization.py +195 -22
  13. {pynitefea-2.2.0 → pynitefea-2.3.0}/README.md +15 -0
  14. {pynitefea-2.2.0 → pynitefea-2.3.0}/setup.py +1 -1
  15. {pynitefea-2.2.0 → pynitefea-2.3.0}/LICENSE +0 -0
  16. {pynitefea-2.2.0 → pynitefea-2.3.0}/PyNiteFEA.egg-info/SOURCES.txt +0 -0
  17. {pynitefea-2.2.0 → pynitefea-2.3.0}/PyNiteFEA.egg-info/dependency_links.txt +0 -0
  18. {pynitefea-2.2.0 → pynitefea-2.3.0}/PyNiteFEA.egg-info/requires.txt +0 -0
  19. {pynitefea-2.2.0 → pynitefea-2.3.0}/PyNiteFEA.egg-info/top_level.txt +0 -0
  20. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/BeamSegY.py +0 -0
  21. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/BeamSegZ.py +0 -0
  22. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/FixedEndReactions.py +0 -0
  23. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/LoadCombo.py +0 -0
  24. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/MainStyleSheet.css +0 -0
  25. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Material.py +0 -0
  26. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Node3D.py +0 -0
  27. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Plate3D.py +0 -0
  28. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Quad3D.py +0 -0
  29. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Report_Template.html +0 -0
  30. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Reporting.py +0 -0
  31. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Section.py +0 -0
  32. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Spring3D.py +0 -0
  33. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/Tri3D.py +0 -0
  34. {pynitefea-2.2.0 → pynitefea-2.3.0}/Pynite/__init__.py +0 -0
  35. {pynitefea-2.2.0 → pynitefea-2.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNiteFEA
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A simple elastic 3D structural finite element library for Python.
5
5
  Home-page: https://github.com/JWock82/Pynite.git
6
6
  Author: D. Craig Brinck, PE, SE
@@ -121,6 +121,21 @@ Here's a list of projects that use Pynite:
121
121
  * Phaenotyp (https://github.com/bewegende-Architektur/Phaenotyp) (https://youtu.be/shloSw9HjVI)
122
122
 
123
123
  # What's New?
124
+ v2.3.0
125
+ * Added enveloping to member diagrams. Set `combo_name` to a list of combo tags and the member diagram will show an envelope of all the combos that have any of those tags.
126
+ * Bug fix: Quadrilateral element displacements had corners swapped during VTK visualization. This was a relic from the old MITC4 formulation that has been corrected.
127
+ * Bug fix: Added shear wall and mat foundation load cases to `FEModel3D.load_cases()`. The list of load cases was sometimes incomplete if these meshes had not yet been generated.
128
+ * Added bending moment end releases to visualizations.
129
+ * Bug fix for shear walls with flanges having an origin with their origin at Y != 0.
130
+ * Rectangular meshes now filter out user defined control points that erroneously lie outside their boudaries.
131
+
132
+ v2.2.1
133
+ * Normalized member force diagrams across the entire model for visual clarity.
134
+ * Made mesh auto-regeneration smarter and more efficient, targeting only meshes that have been altered since the last run.
135
+ * Added `ShearWall` mesh auto-regeneration. Whenever a model is solved any old or outdated `ShearWall` meshes are automatically removed and replaced by a rebuild.
136
+ * Optimized auto-calculated annotation sizing: The new automatic annotation size calculation feature now uses vectorized NumPy operations and caching for efficient rendering of large models with hundreds or thousands of elements.
137
+ * Adjusted member diagram label offsets for clarity.
138
+
124
139
  v2.2.0
125
140
  * Added member diagrams feature for easy visualization of shear, moment, axial, and torsion diagrams along members.
126
141
  * Updated documentation for rendering.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNiteFEA
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: A simple elastic 3D structural finite element library for Python.
5
5
  Home-page: https://github.com/JWock82/Pynite.git
6
6
  Author: D. Craig Brinck, PE, SE
@@ -121,6 +121,21 @@ Here's a list of projects that use Pynite:
121
121
  * Phaenotyp (https://github.com/bewegende-Architektur/Phaenotyp) (https://youtu.be/shloSw9HjVI)
122
122
 
123
123
  # What's New?
124
+ v2.3.0
125
+ * Added enveloping to member diagrams. Set `combo_name` to a list of combo tags and the member diagram will show an envelope of all the combos that have any of those tags.
126
+ * Bug fix: Quadrilateral element displacements had corners swapped during VTK visualization. This was a relic from the old MITC4 formulation that has been corrected.
127
+ * Bug fix: Added shear wall and mat foundation load cases to `FEModel3D.load_cases()`. The list of load cases was sometimes incomplete if these meshes had not yet been generated.
128
+ * Added bending moment end releases to visualizations.
129
+ * Bug fix for shear walls with flanges having an origin with their origin at Y != 0.
130
+ * Rectangular meshes now filter out user defined control points that erroneously lie outside their boudaries.
131
+
132
+ v2.2.1
133
+ * Normalized member force diagrams across the entire model for visual clarity.
134
+ * Made mesh auto-regeneration smarter and more efficient, targeting only meshes that have been altered since the last run.
135
+ * Added `ShearWall` mesh auto-regeneration. Whenever a model is solved any old or outdated `ShearWall` meshes are automatically removed and replaced by a rebuild.
136
+ * Optimized auto-calculated annotation sizing: The new automatic annotation size calculation feature now uses vectorized NumPy operations and caching for efficient rendering of large models with hundreds or thousands of elements.
137
+ * Adjusted member diagram label offsets for clarity.
138
+
124
139
  v2.2.0
125
140
  * Added member diagrams feature for easy visualization of shear, moment, axial, and torsion diagrams along members.
126
141
  * Updated documentation for rendering.
@@ -48,19 +48,19 @@ def _prepare_model(model: FEModel3D, n_modes: int = 0) -> None:
48
48
  for i in range(n_modes):
49
49
  model.add_load_combo(f'Mode {i + 1}', {}, ['modal'])
50
50
 
51
- # Generate all basic meshes that haven't been generated yet
51
+ # Generate all basic meshes that haven't been generated yet or need updating
52
52
  for mesh in model.meshes.values():
53
- if not mesh.is_generated:
53
+ if not mesh.is_generated or mesh.needs_update:
54
54
  mesh.generate()
55
55
 
56
- # Generate all shear wall meshes that haven't been generated yet
56
+ # Generate all shear wall meshes that haven't been generated yet or need updating
57
57
  for shear_wall in model.shear_walls.values():
58
- if not shear_wall.is_generated:
58
+ if not shear_wall.is_generated or shear_wall.needs_update:
59
59
  shear_wall.generate()
60
60
 
61
- # Generate all mat foundation meshes that haven't been generated yet
61
+ # Generate all mat foundation meshes that haven't been generated yet or need updating
62
62
  for mat in model.mats.values():
63
- if not mat.is_generated:
63
+ if not mat.is_generated or mat.needs_update:
64
64
  mat.generate()
65
65
 
66
66
  # Activate all springs and members for all load combinations
@@ -194,6 +194,25 @@ class FEModel3D():
194
194
  # Get the load case for each plate/quad pressure
195
195
  cases.append(load[1])
196
196
 
197
+ # Step through each shear wall helper
198
+ for shear_wall in self.shear_walls.values():
199
+ # Step through each shear wall shear load
200
+ for load in shear_wall._shears:
201
+ # Get the load case for each shear wall shear
202
+ cases.append(load[2])
203
+
204
+ # Step through each shear wall axial load
205
+ for load in shear_wall._axials:
206
+ # Get the load case for each shear wall axial
207
+ cases.append(load[2])
208
+
209
+ # Step through each mat foundation helper
210
+ for mat in self.mats.values():
211
+ # Step through each mat point load
212
+ for load in mat.pt_loads:
213
+ # Get the load case for each mat point load
214
+ cases.append(load[3])
215
+
197
216
  # Remove duplicates and return the list (sorted ascending)
198
217
  return sorted(list(dict.fromkeys(cases)))
199
218
 
@@ -2415,7 +2434,7 @@ class FEModel3D():
2415
2434
  # The partitioned stiffness matrix originates as `coo` and is converted to `csr`
2416
2435
  # format for mathematical operations. The `@` operator performs matrix multiplication
2417
2436
  # on sparse matrices.
2418
- Delta_D1 = spsolve(K11.tocsr(), np.subtract(np.subtract(Delta_P1, Delta_FER1), K12.tocsr() @ Delta_D2))
2437
+ Delta_D1 = spsolve(K11, np.subtract(np.subtract(Delta_P1, Delta_FER1), K12 @ Delta_D2))
2419
2438
  Delta_D1 = Delta_D1.reshape(len(Delta_D1), 1)
2420
2439
  else:
2421
2440
  Delta_D1 = solve(K11, np.subtract(np.subtract(Delta_P1, Delta_FER1), np.matmul(K12, Delta_D2)))
@@ -51,6 +51,7 @@ class MatFoundation(RectangleMesh):
51
51
  self.name = name
52
52
  self.ks = ks
53
53
  self.pt_loads = [] # [XZ_coord, direction, magnitude, case]
54
+ self.needs_update = False # Flag indicating regeneration is needed due to changes
54
55
 
55
56
  def add_rect_opening(self, name, X_min, Z_min, X_max, Z_max):
56
57
  """Add a rectangular opening to the mat by corner coordinates.
@@ -67,6 +68,8 @@ class MatFoundation(RectangleMesh):
67
68
  """
68
69
 
69
70
  super().add_rect_opening(name, X_min, Z_min, X_max - X_min, Z_max - Z_min)
71
+ if self.is_generated:
72
+ self.needs_update = True
70
73
 
71
74
  def add_mat_pt_load(self, XZ_coord, direction, magnitude, case='Case 1'):
72
75
  """Register a concentrated load at an X-Z coordinate on the mat.
@@ -84,6 +87,8 @@ class MatFoundation(RectangleMesh):
84
87
  self.x_control.append(XZ_coord[0])
85
88
  self.y_control.append(XZ_coord[1])
86
89
  self.pt_loads.append([XZ_coord, direction, magnitude, case])
90
+ if self.is_generated:
91
+ self.needs_update = True
87
92
 
88
93
  def generate(self):
89
94
  """Generate the mesh, apply point loads, and define soil springs.
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Literal, Union, List
3
3
  from math import isclose
4
4
 
5
5
  from numpy import array, zeros, add, subtract, matmul, insert, dot, cross, divide, count_nonzero, concatenate
6
- from numpy import linspace, vstack, hstack, allclose, radians, sin, cos
6
+ from numpy import linspace, vstack, hstack, allclose, radians, sin, cos, maximum, minimum
7
7
  from numpy.linalg import inv, pinv, norm
8
8
 
9
9
  import Pynite.FixedEndReactions
@@ -1292,42 +1292,62 @@ class Member3D():
1292
1292
  # Return 0 if no valid combos were found
1293
1293
  return Vmin_global if Vmin_global is not None else 0
1294
1294
 
1295
- def plot_shear(self, Direction: Literal['Fy', 'Fz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
1295
+ def plot_shear(self, Direction: Literal['Fy', 'Fz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
1296
1296
  """
1297
- Plots the shear diagram for the member
1297
+ Plots the shear diagram for the member.
1298
1298
 
1299
1299
  Parameters
1300
1300
  ----------
1301
1301
  Direction : string
1302
- The direction in which to find the moment. Must be one of the following:
1302
+ The direction in which to plot the shear force. Must be one of the following:
1303
1303
  'Fy' = Shear acting on the local y-axis.
1304
1304
  'Fz' = Shear acting on the local z-axis.
1305
- combo_name : string
1306
- The name of the load combination to get the results for (not the load combination itself).
1305
+ combo_name : string or list of strings
1306
+ A single load combination name, or a list of combo tags. When a
1307
+ list of tags is provided, each matching combo is plotted and a
1308
+ max/min envelope is shown.
1307
1309
  n_points: int
1308
1310
  The number of points used to generate the plot
1309
1311
  """
1310
1312
 
1311
- # Segment the member if necessary
1312
- if self._solved_combo is None or combo_name != self._solved_combo.name:
1313
- self._segment_member(combo_name)
1314
- self._solved_combo = self.model.load_combos[combo_name]
1315
-
1316
1313
  # Import 'pyplot' if not already done
1317
1314
  if Member3D.__plt is None:
1318
1315
  from matplotlib import pyplot as plt
1319
1316
  Member3D.__plt = plt
1320
1317
 
1318
+ if isinstance(combo_name, str):
1319
+ combo_names = [combo_name]
1320
+ else:
1321
+ combo_names = [name for name, combo in self.model.load_combos.items()
1322
+ if any(tag in combo.combo_tags for tag in combo_name)]
1323
+
1321
1324
  fig, ax = Member3D.__plt.subplots()
1322
1325
  ax.axhline(0, color='black', lw=1)
1323
1326
  ax.grid()
1324
1327
 
1325
- x, V = self.shear_array(Direction, n_points, combo_name)
1326
-
1327
- Member3D.__plt.plot(x, V)
1328
- Member3D.__plt.ylabel('Shear')
1329
- Member3D.__plt.xlabel('Location')
1330
- Member3D.__plt.title('Member ' + self.name + '\n' + combo_name)
1328
+ if len(combo_names) == 1:
1329
+ # Segment the member if necessary
1330
+ if self._solved_combo is None or combo_names[0] != self._solved_combo.name:
1331
+ self._segment_member(combo_names[0])
1332
+ self._solved_combo = self.model.load_combos[combo_names[0]]
1333
+ x, V = self.shear_array(Direction, n_points, combo_names[0])
1334
+ ax.plot(x, V)
1335
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
1336
+ else:
1337
+ env_max = None
1338
+ env_min = None
1339
+ for name in combo_names:
1340
+ x, V = self.shear_array(Direction, n_points, name)
1341
+ ax.plot(x, V, label=name)
1342
+ env_max = V if env_max is None else maximum(env_max, V)
1343
+ env_min = V if env_min is None else minimum(env_min, V)
1344
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
1345
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
1346
+ ax.legend(fontsize='small')
1347
+ ax.set_title('Member ' + self.name + '\nEnvelope')
1348
+
1349
+ ax.set_ylabel('Shear')
1350
+ ax.set_xlabel('Location')
1331
1351
  Member3D.__plt.show()
1332
1352
 
1333
1353
  def shear_array(self, Direction: Literal['Fy', 'Fz'], n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -1581,9 +1601,9 @@ class Member3D():
1581
1601
  return Mmin_global if Mmin_global is not None else 0
1582
1602
 
1583
1603
 
1584
- def plot_moment(self, Direction: Literal['My', 'Mz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
1604
+ def plot_moment(self, Direction: Literal['My', 'Mz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
1585
1605
  """
1586
- Plots the moment diagram for the member
1606
+ Plots the moment diagram for the member.
1587
1607
 
1588
1608
  Parameters
1589
1609
  ----------
@@ -1591,33 +1611,52 @@ class Member3D():
1591
1611
  The direction in which to plot the moment. Must be one of the following:
1592
1612
  'My' = Moment about the local y-axis.
1593
1613
  'Mz' = moment about the local z-axis.
1594
- combo_name : string
1595
- The name of the load combination to get the results for (not the combination itself).
1614
+ combo_name : string or list of strings
1615
+ A single load combination name, or a list of combo tags. When a
1616
+ list of tags is provided, each matching combo is plotted and a
1617
+ max/min envelope is shown.
1596
1618
  n_points: int
1597
1619
  The number of points used to generate the plot
1598
1620
  """
1599
1621
 
1600
- # Segment the member if necessary
1601
- if self._solved_combo is None or combo_name != self._solved_combo.name:
1602
- self._segment_member(combo_name)
1603
- self._solved_combo = self.model.load_combos[combo_name]
1604
-
1605
1622
  # Import 'pyplot' if not already done
1606
1623
  if Member3D.__plt is None:
1607
1624
  from matplotlib import pyplot as plt
1608
1625
  Member3D.__plt = plt
1609
1626
 
1627
+ if isinstance(combo_name, str):
1628
+ combo_names = [combo_name]
1629
+ else:
1630
+ combo_names = [name for name, combo in self.model.load_combos.items()
1631
+ if any(tag in combo.combo_tags for tag in combo_name)]
1632
+
1610
1633
  fig, ax = Member3D.__plt.subplots()
1611
1634
  ax.axhline(0, color='black', lw=1)
1612
1635
  ax.grid()
1613
1636
 
1614
- # Generate the moment diagram coordinates
1615
- x, M = self.moment_array(Direction, n_points, combo_name)
1616
-
1617
- Member3D.__plt.plot(x, M)
1618
- Member3D.__plt.ylabel('Moment')
1619
- Member3D.__plt.xlabel('Location')
1620
- Member3D.__plt.title('Member ' + self.name + '\n' + combo_name)
1637
+ if len(combo_names) == 1:
1638
+ # Segment the member if necessary
1639
+ if self._solved_combo is None or combo_names[0] != self._solved_combo.name:
1640
+ self._segment_member(combo_names[0])
1641
+ self._solved_combo = self.model.load_combos[combo_names[0]]
1642
+ x, M = self.moment_array(Direction, n_points, combo_names[0])
1643
+ ax.plot(x, M)
1644
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
1645
+ else:
1646
+ env_max = None
1647
+ env_min = None
1648
+ for name in combo_names:
1649
+ x, M = self.moment_array(Direction, n_points, name)
1650
+ ax.plot(x, M, label=name)
1651
+ env_max = M if env_max is None else maximum(env_max, M)
1652
+ env_min = M if env_min is None else minimum(env_min, M)
1653
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
1654
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
1655
+ ax.legend(fontsize='small')
1656
+ ax.set_title('Member ' + self.name + '\nEnvelope')
1657
+
1658
+ ax.set_ylabel('Moment')
1659
+ ax.set_xlabel('Location')
1621
1660
  Member3D.__plt.show()
1622
1661
 
1623
1662
  def moment_array(self, Direction: Literal['My', 'Mz'], n_points: int, combo_name: str = 'Combo 1', x_array: Optional[NDArray[float64]] = None) -> NDArray[float64]:
@@ -1825,38 +1864,58 @@ class Member3D():
1825
1864
  # Return global minimum or 0 if nothing found
1826
1865
  return Tmin_global if Tmin_global is not None else 0
1827
1866
 
1828
- def plot_torque(self, combo_name='Combo 1', n_points=20):
1867
+ def plot_torque(self, combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
1829
1868
  """
1830
1869
  Plots the torque diagram for the member.
1831
1870
 
1832
- Paramters
1833
- ---------
1834
- combo_name : string
1835
- The name of the load combination to get the results for (not the load combination itself).
1871
+ Parameters
1872
+ ----------
1873
+ combo_name : string or list of strings
1874
+ A single load combination name, or a list of combo tags. When a
1875
+ list of tags is provided, each matching combo is plotted and a
1876
+ max/min envelope is shown.
1836
1877
  n_points: int
1837
1878
  The number of points used to generate the plot
1838
1879
  """
1839
1880
 
1840
- # Segment the member if necessary
1841
- if self._solved_combo is None or combo_name != self._solved_combo.name:
1842
- self._segment_member(combo_name)
1843
- self._solved_combo = self.model.load_combos[combo_name]
1844
-
1845
1881
  # Import 'pyplot' if not already done
1846
1882
  if Member3D.__plt is None:
1847
1883
  from matplotlib import pyplot as plt
1848
1884
  Member3D.__plt = plt
1849
1885
 
1886
+ if isinstance(combo_name, str):
1887
+ combo_names = [combo_name]
1888
+ else:
1889
+ combo_names = [name for name, combo in self.model.load_combos.items()
1890
+ if any(tag in combo.combo_tags for tag in combo_name)]
1891
+
1850
1892
  fig, ax = Member3D.__plt.subplots()
1851
1893
  ax.axhline(0, color='black', lw=1)
1852
1894
  ax.grid()
1853
1895
 
1854
- x, T = self.torque_array(n_points, combo_name)
1855
-
1856
- Member3D.__plt.plot(x, T)
1857
- Member3D.__plt.ylabel('Torsional Moment (Warping Torsion Not Included)') # Torsion results are for pure torsion. Torsional warping has not been considered
1858
- Member3D.__plt.xlabel('Location')
1859
- Member3D.__plt.title('Member ' + self.name + '\n' + combo_name)
1896
+ if len(combo_names) == 1:
1897
+ # Segment the member if necessary
1898
+ if self._solved_combo is None or combo_names[0] != self._solved_combo.name:
1899
+ self._segment_member(combo_names[0])
1900
+ self._solved_combo = self.model.load_combos[combo_names[0]]
1901
+ x, T = self.torque_array(n_points, combo_names[0])
1902
+ ax.plot(x, T)
1903
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
1904
+ else:
1905
+ env_max = None
1906
+ env_min = None
1907
+ for name in combo_names:
1908
+ x, T = self.torque_array(n_points, name)
1909
+ ax.plot(x, T, label=name)
1910
+ env_max = T if env_max is None else maximum(env_max, T)
1911
+ env_min = T if env_min is None else minimum(env_min, T)
1912
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
1913
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
1914
+ ax.legend(fontsize='small')
1915
+ ax.set_title('Member ' + self.name + '\nEnvelope')
1916
+
1917
+ ax.set_ylabel('Torsional Moment (Warping Torsion Not Included)')
1918
+ ax.set_xlabel('Location')
1860
1919
  Member3D.__plt.show()
1861
1920
 
1862
1921
  def torque_array(self, n_points, combo_name='Combo 1', x_array = None) -> NDArray[float64]:
@@ -2039,38 +2098,58 @@ class Member3D():
2039
2098
  # Return the global minimum, or 0 if nothing was found
2040
2099
  return Pmin_global if Pmin_global is not None else 0
2041
2100
 
2042
- def plot_axial(self, combo_name: str = 'Combo 1', n_points=20) -> None:
2101
+ def plot_axial(self, combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
2043
2102
  """
2044
2103
  Plots the axial force diagram for the member.
2045
2104
 
2046
2105
  Parameters
2047
2106
  ----------
2048
- combo_name : string
2049
- The name of the load combination to get the results for (not the load combination itself).
2107
+ combo_name : string or list of strings
2108
+ A single load combination name, or a list of combo tags. When a
2109
+ list of tags is provided, each matching combo is plotted and a
2110
+ max/min envelope is shown.
2050
2111
  n_points: int
2051
2112
  The number of points used to generate the plot
2052
2113
  """
2053
2114
 
2054
- # Segment the member if necessary
2055
- if self._solved_combo is None or combo_name != self._solved_combo.name:
2056
- self._segment_member(combo_name)
2057
- self._solved_combo = self.model.load_combos[combo_name]
2058
-
2059
2115
  # Import 'pyplot' if not already done
2060
2116
  if Member3D.__plt is None:
2061
2117
  from matplotlib import pyplot as plt
2062
2118
  Member3D.__plt = plt
2063
2119
 
2120
+ if isinstance(combo_name, str):
2121
+ combo_names = [combo_name]
2122
+ else:
2123
+ combo_names = [name for name, combo in self.model.load_combos.items()
2124
+ if any(tag in combo.combo_tags for tag in combo_name)]
2125
+
2064
2126
  fig, ax = Member3D.__plt.subplots()
2065
2127
  ax.axhline(0, color='black', lw=1)
2066
2128
  ax.grid()
2067
2129
 
2068
- x, P = self.axial_array(n_points, combo_name)
2069
-
2070
- Member3D.__plt.plot(x, P)
2071
- Member3D.__plt.ylabel('Axial Force')
2072
- Member3D.__plt.xlabel('Location')
2073
- Member3D.__plt.title('Member ' + self.name + '\n' + combo_name)
2130
+ if len(combo_names) == 1:
2131
+ # Segment the member if necessary
2132
+ if self._solved_combo is None or combo_names[0] != self._solved_combo.name:
2133
+ self._segment_member(combo_names[0])
2134
+ self._solved_combo = self.model.load_combos[combo_names[0]]
2135
+ x, P = self.axial_array(n_points, combo_names[0])
2136
+ ax.plot(x, P)
2137
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
2138
+ else:
2139
+ env_max = None
2140
+ env_min = None
2141
+ for name in combo_names:
2142
+ x, P = self.axial_array(n_points, name)
2143
+ ax.plot(x, P, label=name)
2144
+ env_max = P if env_max is None else maximum(env_max, P)
2145
+ env_min = P if env_min is None else minimum(env_min, P)
2146
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
2147
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
2148
+ ax.legend(fontsize='small')
2149
+ ax.set_title('Member ' + self.name + '\nEnvelope')
2150
+
2151
+ ax.set_ylabel('Axial Force')
2152
+ ax.set_xlabel('Location')
2074
2153
  Member3D.__plt.show()
2075
2154
 
2076
2155
  def axial_array(self, n_points: int, combo_name: str = 'Combo 1', x_array: Optional[NDArray[float64]] = None) -> NDArray[float64]:
@@ -2309,43 +2388,63 @@ class Member3D():
2309
2388
  # Return global minimum or 0 if nothing found
2310
2389
  return dmin_global if dmin_global is not None else 0
2311
2390
 
2312
- def plot_deflection(self, Direction: Literal['dx', 'dy', 'dz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
2391
+ def plot_deflection(self, Direction: Literal['dx', 'dy', 'dz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
2313
2392
  """
2314
- Plots the deflection diagram for the member
2393
+ Plots the deflection diagram for the member.
2315
2394
 
2316
2395
  Parameters
2317
2396
  ----------
2318
2397
  Direction : string
2319
- The direction in which to find the deflection. Must be one of the following:
2398
+ The direction in which to plot the deflection. Must be one of the following:
2320
2399
  'dx' = Deflection in the local x-axis.
2321
2400
  'dy' = Deflection in the local y-axis.
2322
2401
  'dz' = Deflection in the local z-axis.
2323
- combo_name : string
2324
- The name of the load combination to get the results for (not the load combination itself).
2402
+ combo_name : string or list of strings
2403
+ A single load combination name, or a list of combo tags. When a
2404
+ list of tags is provided, each matching combo is plotted and a
2405
+ max/min envelope is shown.
2325
2406
  n_points: int
2326
2407
  The number of points used to generate the plot
2327
2408
  """
2328
2409
 
2329
- # Segment the member if necessary
2330
- if self._solved_combo is None or combo_name != self._solved_combo.name:
2331
- self._segment_member(combo_name)
2332
- self._solved_combo = self.model.load_combos[combo_name]
2333
-
2334
2410
  # Import 'pyplot' if not already done
2335
2411
  if Member3D.__plt is None:
2336
2412
  from matplotlib import pyplot as plt
2337
2413
  Member3D.__plt = plt
2338
2414
 
2415
+ if isinstance(combo_name, str):
2416
+ combo_names = [combo_name]
2417
+ else:
2418
+ combo_names = [name for name, combo in self.model.load_combos.items()
2419
+ if any(tag in combo.combo_tags for tag in combo_name)]
2420
+
2339
2421
  fig, ax = Member3D.__plt.subplots()
2340
2422
  ax.axhline(0, color='black', lw=1)
2341
2423
  ax.grid()
2342
2424
 
2343
- x, d = self.deflection_array(Direction, n_points, combo_name)
2344
-
2345
- Member3D.__plt.plot(x, d)
2346
- Member3D.__plt.ylabel('Deflection')
2347
- Member3D.__plt.xlabel('Location')
2348
- Member3D.__plt.title('Member ' + self.name + '\n' + combo_name)
2425
+ if len(combo_names) == 1:
2426
+ # Segment the member if necessary
2427
+ if self._solved_combo is None or combo_names[0] != self._solved_combo.name:
2428
+ self._segment_member(combo_names[0])
2429
+ self._solved_combo = self.model.load_combos[combo_names[0]]
2430
+ x, d = self.deflection_array(Direction, n_points, combo_names[0])
2431
+ ax.plot(x, d)
2432
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
2433
+ else:
2434
+ env_max = None
2435
+ env_min = None
2436
+ for name in combo_names:
2437
+ x, d = self.deflection_array(Direction, n_points, name)
2438
+ ax.plot(x, d, label=name)
2439
+ env_max = d if env_max is None else maximum(env_max, d)
2440
+ env_min = d if env_min is None else minimum(env_min, d)
2441
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
2442
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
2443
+ ax.legend(fontsize='small')
2444
+ ax.set_title('Member ' + self.name + '\nEnvelope')
2445
+
2446
+ ax.set_ylabel('Deflection')
2447
+ ax.set_xlabel('Location')
2349
2448
  Member3D.__plt.show()
2350
2449
 
2351
2450
  def deflection_array(self, Direction: Literal['dx', 'dy', 'dz'], n_points: int, combo_name: str = 'Combo 1', x_array: Optional[NDArray[float64]] = None) -> NDArray[float64]:
@@ -48,6 +48,7 @@ class Mesh():
48
48
  self.elements: Dict[str, Union[Quad3D, Plate3D]] = {} # A dictionary containing the elements in the mesh
49
49
  self.element_type = 'Quad' # The type of element used in the mesh
50
50
  self.is_generated = False # A flag indicating whether the mesh has been generated
51
+ self.needs_update = False # A flag indicating whether the mesh needs regeneration due to changes
51
52
 
52
53
  def _remove_from_model(self) -> None:
53
54
  """Removes the mesh's nodes and elements from the model in preparation for regeneration.
@@ -815,6 +816,11 @@ class RectangleMesh(Mesh):
815
816
  unique_list.append(y_control[i])
816
817
  unique_list.append(y_control[-1])
817
818
  y_control = unique_list
819
+
820
+ # Remove any control points that fall outside the mesh boundaries
821
+ # Control points outside [0, width] or [0, height] would create elements outside the intended mesh
822
+ x_control = [val for val in x_control if val >= -1e-10 and val <= width + 1e-10]
823
+ y_control = [val for val in y_control if val >= -1e-10 and val <= height + 1e-10]
818
824
 
819
825
  # Each node number will be increased by the offset calculated below
820
826
  node_offset = int(self.start_node[1:]) - 1
@@ -1033,6 +1039,7 @@ class RectangleMesh(Mesh):
1033
1039
 
1034
1040
  # Flag the mesh as generated
1035
1041
  self.is_generated = True
1042
+ self.needs_update = False
1036
1043
 
1037
1044
  def node_local_coords(self, node: Node3D) -> tuple[float, float]:
1038
1045
  """Calculates a node's position in the mesh's local x/y coordinate system.
@@ -1078,8 +1085,9 @@ class RectangleMesh(Mesh):
1078
1085
  self.x_control.append(x_left + width)
1079
1086
  self.y_control.append(y_bott + height)
1080
1087
 
1081
- # Flag the mesh as not generated yet
1082
- self.is_generated = False
1088
+ # Flag that regeneration is needed if already generated
1089
+ if self.is_generated:
1090
+ self.needs_update = True
1083
1091
 
1084
1092
 
1085
1093
  class RectOpening():
@@ -1233,6 +1241,7 @@ class AnnulusMesh(Mesh):
1233
1241
 
1234
1242
  # Flag the mesh as generated
1235
1243
  self.is_generated = True
1244
+ self.needs_update = False
1236
1245
 
1237
1246
  #%%
1238
1247
  class AnnulusRingMesh(Mesh):
@@ -1390,6 +1399,7 @@ class AnnulusRingMesh(Mesh):
1390
1399
 
1391
1400
  # Flag the mesh as generated
1392
1401
  self.is_generated = True
1402
+ self.needs_update = False
1393
1403
 
1394
1404
 
1395
1405
  class AnnulusTransRingMesh(Mesh):
@@ -1589,6 +1599,7 @@ class AnnulusTransRingMesh(Mesh):
1589
1599
 
1590
1600
  # Flag the mesh as generated
1591
1601
  self.is_generated = True
1602
+ self.needs_update = False
1592
1603
 
1593
1604
 
1594
1605
  class FrustrumMesh(AnnulusMesh):
@@ -1806,6 +1817,7 @@ class CylinderMesh(Mesh):
1806
1817
 
1807
1818
  # Flag the mesh as generated
1808
1819
  self.is_generated = True
1820
+ self.needs_update = False
1809
1821
 
1810
1822
  #%%
1811
1823
  class CylinderRingMesh(Mesh):
@@ -1994,6 +2006,7 @@ class CylinderRingMesh(Mesh):
1994
2006
 
1995
2007
  # Flag the mesh as generated
1996
2008
  self.is_generated = True
2009
+ self.needs_update = False
1997
2010
 
1998
2011
  def check_mesh_integrity(mesh: Mesh, console_log: bool = True) -> Union[str, List[str], None]:
1999
2012
  """Runs basic integrity checks to ensure the mesh is in sync with its model. Usually you don't