PyNiteFEA 2.2.1__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.1 → pynitefea-2.3.0}/PKG-INFO +9 -1
  2. {pynitefea-2.2.1 → pynitefea-2.3.0}/PyNiteFEA.egg-info/PKG-INFO +9 -1
  3. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/FEModel3D.py +20 -1
  4. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Member3D.py +178 -79
  5. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Mesh.py +5 -0
  6. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/PhysMember.py +158 -68
  7. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Rendering.py +72 -6
  8. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/ShearWall.py +1 -1
  9. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/VTKWriter.py +28 -0
  10. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Visualization.py +97 -0
  11. {pynitefea-2.2.1 → pynitefea-2.3.0}/README.md +8 -0
  12. {pynitefea-2.2.1 → pynitefea-2.3.0}/setup.py +1 -1
  13. {pynitefea-2.2.1 → pynitefea-2.3.0}/LICENSE +0 -0
  14. {pynitefea-2.2.1 → pynitefea-2.3.0}/PyNiteFEA.egg-info/SOURCES.txt +0 -0
  15. {pynitefea-2.2.1 → pynitefea-2.3.0}/PyNiteFEA.egg-info/dependency_links.txt +0 -0
  16. {pynitefea-2.2.1 → pynitefea-2.3.0}/PyNiteFEA.egg-info/requires.txt +0 -0
  17. {pynitefea-2.2.1 → pynitefea-2.3.0}/PyNiteFEA.egg-info/top_level.txt +0 -0
  18. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Analysis.py +0 -0
  19. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/BeamSegY.py +0 -0
  20. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/BeamSegZ.py +0 -0
  21. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/FixedEndReactions.py +0 -0
  22. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/LoadCombo.py +0 -0
  23. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/MainStyleSheet.css +0 -0
  24. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/MatFoundation.py +0 -0
  25. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Material.py +0 -0
  26. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Node3D.py +0 -0
  27. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Plate3D.py +0 -0
  28. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Quad3D.py +0 -0
  29. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Report_Template.html +0 -0
  30. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Reporting.py +0 -0
  31. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Section.py +0 -0
  32. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Spring3D.py +0 -0
  33. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/Tri3D.py +0 -0
  34. {pynitefea-2.2.1 → pynitefea-2.3.0}/Pynite/__init__.py +0 -0
  35. {pynitefea-2.2.1 → 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.1
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,14 @@ 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
+
124
132
  v2.2.1
125
133
  * Normalized member force diagrams across the entire model for visual clarity.
126
134
  * Made mesh auto-regeneration smarter and more efficient, targeting only meshes that have been altered since the last run.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNiteFEA
3
- Version: 2.2.1
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,14 @@ 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
+
124
132
  v2.2.1
125
133
  * Normalized member force diagrams across the entire model for visual clarity.
126
134
  * Made mesh auto-regeneration smarter and more efficient, targeting only meshes that have been altered since the last run.
@@ -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)))
@@ -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]:
@@ -816,6 +816,11 @@ class RectangleMesh(Mesh):
816
816
  unique_list.append(y_control[i])
817
817
  unique_list.append(y_control[-1])
818
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]
819
824
 
820
825
  # Each node number will be increased by the offset calculated below
821
826
  node_offset = int(self.start_node[1:]) - 1
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations # Allows more recent type hints features
2
- from typing import Dict, List, Literal, Tuple, TYPE_CHECKING
2
+ from typing import Dict, List, Literal, Tuple, Union, TYPE_CHECKING
3
3
  from Pynite.Member3D import Member3D
4
4
 
5
5
  if TYPE_CHECKING:
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
10
10
  from numpy import float64
11
11
  from numpy.typing import NDArray
12
12
 
13
- from numpy import array, dot, linspace, hstack, empty
13
+ from numpy import array, dot, linspace, hstack, empty, maximum, minimum
14
14
  from numpy.linalg import norm
15
15
  from math import isclose, acos
16
16
 
@@ -257,9 +257,9 @@ class PhysMember(Member3D):
257
257
  Vmin = V
258
258
  return Vmin
259
259
 
260
- def plot_shear(self, Direction: Literal['Fy', 'Fz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
260
+ def plot_shear(self, Direction: Literal['Fy', 'Fz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
261
261
  """
262
- Plots the shear diagram for the member
262
+ Plots the shear diagram for the member.
263
263
 
264
264
  Parameters
265
265
  ----------
@@ -267,8 +267,10 @@ class PhysMember(Member3D):
267
267
  The direction in which to plot the shear force. Must be one of the following:
268
268
  'Fy' = Shear in the local y-axis.
269
269
  'Fz' = Shear in the local z-axis.
270
- combo_name : string
271
- The name of the load combination to get the results for (not the combination itself).
270
+ combo_name : string or list of strings
271
+ A single load combination name, or a list of combo tags. When a
272
+ list of tags is provided, each matching combo is plotted and a
273
+ max/min envelope is shown.
272
274
  n_points: int
273
275
  The number of points used to generate the plot
274
276
  """
@@ -278,19 +280,35 @@ class PhysMember(Member3D):
278
280
  from matplotlib import pyplot as plt
279
281
  PhysMember.__plt = plt
280
282
 
283
+ if isinstance(combo_name, str):
284
+ combo_names = [combo_name]
285
+ else:
286
+ combo_names = [name for name, combo in self.model.load_combos.items()
287
+ if any(tag in combo.combo_tags for tag in combo_name)]
288
+
281
289
  fig, ax = PhysMember.__plt.subplots()
282
290
  ax.axhline(0, color='black', lw=1)
283
291
  ax.grid()
284
292
 
285
- # Generate the shear diagram
286
- V_array = self.shear_array(Direction, n_points, combo_name)
287
- x = V_array[0]
288
- V = V_array[1]
289
-
290
- PhysMember.__plt.plot(x, V)
291
- PhysMember.__plt.ylabel('Shear')
292
- PhysMember.__plt.xlabel('Location')
293
- PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name)
293
+ if len(combo_names) == 1:
294
+ x, V = self.shear_array(Direction, n_points, combo_names[0])
295
+ ax.plot(x, V)
296
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
297
+ else:
298
+ env_max = None
299
+ env_min = None
300
+ for name in combo_names:
301
+ x, V = self.shear_array(Direction, n_points, name)
302
+ ax.plot(x, V, label=name)
303
+ env_max = V if env_max is None else maximum(env_max, V)
304
+ env_min = V if env_min is None else minimum(env_min, V)
305
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
306
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
307
+ ax.legend(fontsize='small')
308
+ ax.set_title('Member ' + self.name + '\nEnvelope')
309
+
310
+ ax.set_ylabel('Shear')
311
+ ax.set_xlabel('Location')
294
312
  PhysMember.__plt.show()
295
313
 
296
314
  def shear_array(self, Direction: Literal['Fy', 'Fz'], n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -429,19 +447,20 @@ class PhysMember(Member3D):
429
447
  Mmin = M
430
448
  return Mmin
431
449
 
432
- def plot_moment(self, Direction: Literal['My', 'Mz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
450
+ def plot_moment(self, Direction: Literal['My', 'Mz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
433
451
  """
434
- Plots the moment diagram for the member
452
+ Plots the moment diagram for the member.
435
453
 
436
454
  Parameters
437
455
  ----------
438
-
439
456
  Direction : string
440
457
  The direction in which to plot the moment. Must be one of the following:
441
458
  'My' = Moment about the local y-axis.
442
- 'Mz' = moment about the local z-axis.
443
- combo_name : string
444
- The name of the load combination to get the results for (not the combination itself).
459
+ 'Mz' = Moment about the local z-axis.
460
+ combo_name : string or list of strings
461
+ A single load combination name, or a list of combo tags. When a
462
+ list of tags is provided, each matching combo is plotted and a
463
+ max/min envelope is shown.
445
464
  n_points: int
446
465
  The number of points used to generate the plot
447
466
  """
@@ -451,19 +470,35 @@ class PhysMember(Member3D):
451
470
  from matplotlib import pyplot as plt
452
471
  PhysMember.__plt = plt
453
472
 
473
+ if isinstance(combo_name, str):
474
+ combo_names = [combo_name]
475
+ else:
476
+ combo_names = [name for name, combo in self.model.load_combos.items()
477
+ if any(tag in combo.combo_tags for tag in combo_name)]
478
+
454
479
  fig, ax = PhysMember.__plt.subplots()
455
480
  ax.axhline(0, color='black', lw=1)
456
481
  ax.grid()
457
482
 
458
- # Generate the moment diagram
459
- M_array = self.moment_array(Direction, n_points, combo_name)
460
- x = M_array[0]
461
- M = M_array[1]
462
-
463
- PhysMember.__plt.plot(x, M)
464
- PhysMember.__plt.ylabel('Moment')
465
- PhysMember.__plt.xlabel('Location')
466
- PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name)
483
+ if len(combo_names) == 1:
484
+ x, M = self.moment_array(Direction, n_points, combo_names[0])
485
+ ax.plot(x, M)
486
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
487
+ else:
488
+ env_max = None
489
+ env_min = None
490
+ for name in combo_names:
491
+ x, M = self.moment_array(Direction, n_points, name)
492
+ ax.plot(x, M, label=name)
493
+ env_max = M if env_max is None else maximum(env_max, M)
494
+ env_min = M if env_min is None else minimum(env_min, M)
495
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
496
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
497
+ ax.legend(fontsize='small')
498
+ ax.set_title('Member ' + self.name + '\nEnvelope')
499
+
500
+ ax.set_ylabel('Moment')
501
+ ax.set_xlabel('Location')
467
502
  PhysMember.__plt.show()
468
503
 
469
504
  def moment_array(self, Direction: Literal['My', 'Mz'], n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -589,14 +624,16 @@ class PhysMember(Member3D):
589
624
  Tmin = T
590
625
  return Tmin
591
626
 
592
- def plot_torque(self, combo_name: str = 'Combo 1', n_points: int = 20) -> None:
627
+ def plot_torque(self, combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
593
628
  """
594
- Plots the torque diagram for the member
629
+ Plots the torque diagram for the member.
595
630
 
596
631
  Parameters
597
632
  ----------
598
- combo_name : string
599
- The name of the load combination to get the results for (not the combination itself).
633
+ combo_name : string or list of strings
634
+ A single load combination name, or a list of combo tags. When a
635
+ list of tags is provided, each matching combo is plotted and a
636
+ max/min envelope is shown.
600
637
  n_points: int
601
638
  The number of points used to generate the plot
602
639
  """
@@ -606,19 +643,35 @@ class PhysMember(Member3D):
606
643
  from matplotlib import pyplot as plt
607
644
  PhysMember.__plt = plt
608
645
 
646
+ if isinstance(combo_name, str):
647
+ combo_names = [combo_name]
648
+ else:
649
+ combo_names = [name for name, combo in self.model.load_combos.items()
650
+ if any(tag in combo.combo_tags for tag in combo_name)]
651
+
609
652
  fig, ax = PhysMember.__plt.subplots()
610
653
  ax.axhline(0, color='black', lw=1)
611
654
  ax.grid()
612
655
 
613
- # Generate the torque diagram
614
- T_array = self.torque_array(n_points, combo_name)
615
- x = T_array[0]
616
- T = T_array[1]
617
-
618
- PhysMember.__plt.plot(x, T)
619
- PhysMember.__plt.ylabel('Torque')
620
- PhysMember.__plt.xlabel('Location')
621
- PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name)
656
+ if len(combo_names) == 1:
657
+ x, T = self.torque_array(n_points, combo_names[0])
658
+ ax.plot(x, T)
659
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
660
+ else:
661
+ env_max = None
662
+ env_min = None
663
+ for name in combo_names:
664
+ x, T = self.torque_array(n_points, name)
665
+ ax.plot(x, T, label=name)
666
+ env_max = T if env_max is None else maximum(env_max, T)
667
+ env_min = T if env_min is None else minimum(env_min, T)
668
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
669
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
670
+ ax.legend(fontsize='small')
671
+ ax.set_title('Member ' + self.name + '\nEnvelope')
672
+
673
+ ax.set_ylabel('Torque')
674
+ ax.set_xlabel('Location')
622
675
  PhysMember.__plt.show()
623
676
 
624
677
  def torque_array(self, n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -721,14 +774,16 @@ class PhysMember(Member3D):
721
774
  Pmin = P
722
775
  return Pmin
723
776
 
724
- def plot_axial(self, combo_name: str = 'Combo 1', n_points: int = 20) -> None:
777
+ def plot_axial(self, combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
725
778
  """
726
- Plots the axial force diagram for the member
779
+ Plots the axial force diagram for the member.
727
780
 
728
781
  Parameters
729
782
  ----------
730
- combo_name : string
731
- The name of the load combination to get the results for (not the combination itself).
783
+ combo_name : string or list of strings
784
+ A single load combination name, or a list of combo tags. When a
785
+ list of tags is provided, each matching combo is plotted and a
786
+ max/min envelope is shown.
732
787
  n_points: int
733
788
  The number of points used to generate the plot
734
789
  """
@@ -738,19 +793,35 @@ class PhysMember(Member3D):
738
793
  from matplotlib import pyplot as plt
739
794
  PhysMember.__plt = plt
740
795
 
796
+ if isinstance(combo_name, str):
797
+ combo_names = [combo_name]
798
+ else:
799
+ combo_names = [name for name, combo in self.model.load_combos.items()
800
+ if any(tag in combo.combo_tags for tag in combo_name)]
801
+
741
802
  fig, ax = PhysMember.__plt.subplots()
742
803
  ax.axhline(0, color='black', lw=1)
743
804
  ax.grid()
744
805
 
745
- # Generate the axial force array
746
- P_array = self.axial_array(n_points, combo_name)
747
- x = P_array[0]
748
- P = P_array[1]
749
-
750
- PhysMember.__plt.plot(x, P)
751
- PhysMember.__plt.ylabel('Axial Force')
752
- PhysMember.__plt.xlabel('Location')
753
- PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name)
806
+ if len(combo_names) == 1:
807
+ x, P = self.axial_array(n_points, combo_names[0])
808
+ ax.plot(x, P)
809
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
810
+ else:
811
+ env_max = None
812
+ env_min = None
813
+ for name in combo_names:
814
+ x, P = self.axial_array(n_points, name)
815
+ ax.plot(x, P, label=name)
816
+ env_max = P if env_max is None else maximum(env_max, P)
817
+ env_min = P if env_min is None else minimum(env_min, P)
818
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
819
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
820
+ ax.legend(fontsize='small')
821
+ ax.set_title('Member ' + self.name + '\nEnvelope')
822
+
823
+ ax.set_ylabel('Axial')
824
+ ax.set_xlabel('Location')
754
825
  PhysMember.__plt.show()
755
826
 
756
827
  def axial_array(self, n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -897,9 +968,9 @@ class PhysMember(Member3D):
897
968
  member, x_mod = self.find_member(x)
898
969
  return member.rel_deflection(Direction, x_mod, combo_name)
899
970
 
900
- def plot_deflection(self, Direction: Literal['dx', 'dy', 'dz'], combo_name: str = 'Combo 1', n_points: int = 20) -> None:
971
+ def plot_deflection(self, Direction: Literal['dx', 'dy', 'dz'], combo_name: Union[str, List[str]] = 'Combo 1', n_points: int = 20) -> None:
901
972
  """
902
- Plots the deflection diagram for the member
973
+ Plots the deflection diagram for the member.
903
974
 
904
975
  Parameters
905
976
  ----------
@@ -907,8 +978,10 @@ class PhysMember(Member3D):
907
978
  The direction in which to plot the deflection. Must be one of the following:
908
979
  'dy' = Deflection in the local y-axis.
909
980
  'dz' = Deflection in the local z-axis.
910
- combo_name : string
911
- The name of the load combination to get the results for (not the combination itself).
981
+ combo_name : string or list of strings
982
+ A single load combination name, or a list of combo tags. When a
983
+ list of tags is provided, each matching combo is plotted and a
984
+ max/min envelope is shown.
912
985
  n_points: int
913
986
  The number of points used to generate the plot
914
987
  """
@@ -918,18 +991,35 @@ class PhysMember(Member3D):
918
991
  from matplotlib import pyplot as plt
919
992
  PhysMember.__plt = plt
920
993
 
994
+ if isinstance(combo_name, str):
995
+ combo_names = [combo_name]
996
+ else:
997
+ combo_names = [name for name, combo in self.model.load_combos.items()
998
+ if any(tag in combo.combo_tags for tag in combo_name)]
999
+
921
1000
  fig, ax = PhysMember.__plt.subplots()
922
1001
  ax.axhline(0, color='black', lw=1)
923
1002
  ax.grid()
924
1003
 
925
- d_array = self.deflection_array(Direction, n_points, combo_name)
926
- x = d_array[0]
927
- d = d_array[1]
928
-
929
- PhysMember.__plt.plot(x, d)
930
- PhysMember.__plt.ylabel('Deflection')
931
- PhysMember.__plt.xlabel('Location')
932
- PhysMember.__plt.title('Member ' + self.name + '\n' + combo_name)
1004
+ if len(combo_names) == 1:
1005
+ x, d = self.deflection_array(Direction, n_points, combo_names[0])
1006
+ ax.plot(x, d)
1007
+ ax.set_title('Member ' + self.name + '\n' + combo_names[0])
1008
+ else:
1009
+ env_max = None
1010
+ env_min = None
1011
+ for name in combo_names:
1012
+ x, d = self.deflection_array(Direction, n_points, name)
1013
+ ax.plot(x, d, label=name)
1014
+ env_max = d if env_max is None else maximum(env_max, d)
1015
+ env_min = d if env_min is None else minimum(env_min, d)
1016
+ ax.plot(x, env_max, color='green', alpha=0.4, lw=3, label='Max Envelope')
1017
+ ax.plot(x, env_min, color='red', alpha=0.4, lw=3, label='Min Envelope')
1018
+ ax.legend(fontsize='small')
1019
+ ax.set_title('Member ' + self.name + '\nEnvelope')
1020
+
1021
+ ax.set_ylabel('Deflection')
1022
+ ax.set_xlabel('Location')
933
1023
  PhysMember.__plt.show()
934
1024
 
935
1025
  def deflection_array(self, Direction: Literal['dx', 'dy', 'dz'], n_points: int, combo_name='Combo 1', x_array=None) -> NDArray[float64]:
@@ -405,7 +405,7 @@ class Renderer:
405
405
  vis_spring.add_to_plotter(self.plotter)
406
406
 
407
407
  if self.model.members:
408
- vis_members = [VisMember(member, self.theme) for member in self.model.members.values()]
408
+ vis_members = [VisMember(member, self.theme, self.annotation_size) for member in self.model.members.values()]
409
409
  for vis_member in vis_members:
410
410
  vis_member.add_to_plotter(self.plotter)
411
411
 
@@ -425,16 +425,19 @@ class Renderer:
425
425
  if self.show_labels and vis_nodes:
426
426
  label_points = [vis_node.label_point for vis_node in vis_nodes]
427
427
  labels = [vis_node.label for vis_node in vis_nodes]
428
+ label_points = np.asarray(label_points, dtype=float)
428
429
  self.plotter.add_point_labels(label_points, labels, bold=False, text_color='black', show_points=True, point_color='grey', point_size=5, shape=None, render_points_as_spheres=True)
429
430
 
430
431
  if self.show_labels and vis_springs:
431
432
  self._spring_label_points = [vis_spring.label_point for vis_spring in vis_springs]
432
433
  self._spring_labels = [vis_spring.label for vis_spring in vis_springs]
433
- self.plotter.add_point_labels(self._spring_label_points, self._spring_labels, text_color='black', bold=False, shape=None, render_points_as_spheres=False)
434
+ spring_label_points = np.asarray(self._spring_label_points, dtype=float)
435
+ self.plotter.add_point_labels(spring_label_points, self._spring_labels, text_color='black', bold=False, shape=None, render_points_as_spheres=False)
434
436
 
435
437
  if self.show_labels and vis_members:
436
438
  label_points = [vis_member.label_point for vis_member in vis_members]
437
439
  labels = [vis_member.label for vis_member in vis_members]
440
+ label_points = np.asarray(label_points, dtype=float)
438
441
  self.plotter.add_point_labels(label_points, labels, bold=False, text_color='black', show_points=False, shape=None, render_points_as_spheres=False)
439
442
 
440
443
  # Render the loads if requested
@@ -444,7 +447,8 @@ class Renderer:
444
447
  self.plot_loads()
445
448
 
446
449
  # Plot the load labels
447
- self.plotter.add_point_labels(self._load_label_points, self._load_labels, bold=False, text_color='green', show_points=False, shape=None, render_points_as_spheres=False)
450
+ load_label_points = np.asarray(self._load_label_points, dtype=float)
451
+ self.plotter.add_point_labels(load_label_points, self._load_labels, bold=False, text_color='green', show_points=False, shape=None, render_points_as_spheres=False)
448
452
 
449
453
  # Render the plates and quads, if present
450
454
  if self.model.quads or self.model.plates:
@@ -829,7 +833,7 @@ class Renderer:
829
833
  i+=1
830
834
 
831
835
  # Add the vertices and the faces to our lists
832
- plate_vertices = np.array(plate_vertices)
836
+ plate_vertices = np.array(plate_vertices, dtype=float)
833
837
  plate_faces = np.array(plate_faces)
834
838
 
835
839
  # Create a new PyVista dataset to store plate data
@@ -1058,7 +1062,7 @@ class Renderer:
1058
1062
  v2 = _PerpVector(v1)
1059
1063
 
1060
1064
  # Generate the arc for the moment
1061
- arc = pv.CircularArcFromNormal(center, resolution=20, normal=v1, angle=215, polar=v2*radius)
1065
+ arc = pv.CircularArcFromNormal(center=center, resolution=20, normal=v1, angle=215, polar=v2*radius)
1062
1066
 
1063
1067
  # Add the arc to the plot
1064
1068
  self.plotter.add_mesh(arc, line_width=2, color=color)
@@ -1837,15 +1841,18 @@ class VisSpring:
1837
1841
  class VisMember:
1838
1842
  """Visual wrapper for a Member3D as a simple line."""
1839
1843
 
1840
- def __init__(self, member: 'Member3D', theme: str) -> None:
1844
+ def __init__(self, member: 'Member3D', theme: str, annotation_size: float) -> None:
1841
1845
  """Build visual elements for a member.
1842
1846
 
1843
1847
  :param Member3D member: Member to visualize.
1844
1848
  :param str theme: Rendering theme (``'default'`` or ``'print'``).
1849
+ :param float annotation_size: Base size for release symbols.
1845
1850
  """
1846
1851
  self.member = member
1847
1852
  self.theme = theme
1853
+ self.annotation_size = annotation_size
1848
1854
  self.mesh: pv.PolyData = self._build_geometry()
1855
+ self.release_meshes: List[pv.PolyData] = self._build_release_meshes()
1849
1856
  self.label = member.name
1850
1857
  self.label_point = [(member.i_node.X + member.j_node.X) / 2,
1851
1858
  (member.i_node.Y + member.j_node.Y) / 2,
@@ -1857,6 +1864,63 @@ class VisMember:
1857
1864
  line.points[1] = [self.member.j_node.X, self.member.j_node.Y, self.member.j_node.Z]
1858
1865
  return line
1859
1866
 
1867
+ def _build_release_meshes(self) -> List[pv.PolyData]:
1868
+ releases = self.member.Releases
1869
+ if not releases:
1870
+ return []
1871
+
1872
+ show_i_ry = releases[4]
1873
+ show_i_rz = releases[5]
1874
+ show_j_ry = releases[10]
1875
+ show_j_rz = releases[11]
1876
+
1877
+ if not (show_i_ry or show_i_rz or show_j_ry or show_j_rz):
1878
+ return []
1879
+
1880
+ T = self.member.T()[0:3, 0:3]
1881
+ local_x = T[0, 0:3]
1882
+ local_y = T[1, 0:3]
1883
+ local_z = T[2, 0:3]
1884
+
1885
+ local_x = local_x / np.linalg.norm(local_x)
1886
+ local_y = local_y / np.linalg.norm(local_y)
1887
+ local_z = local_z / np.linalg.norm(local_z)
1888
+
1889
+ radius = self.annotation_size * 0.3
1890
+ member_length = self.member.L()
1891
+ default_offset = self.annotation_size * 1.2
1892
+ short_offset = member_length * 0.05
1893
+ offset = short_offset if default_offset > short_offset else default_offset
1894
+ num_segments = 24
1895
+
1896
+ i_point = np.array([self.member.i_node.X, self.member.i_node.Y, self.member.i_node.Z])
1897
+ j_point = np.array([self.member.j_node.X, self.member.j_node.Y, self.member.j_node.Z])
1898
+
1899
+ release_meshes: List[pv.PolyData] = []
1900
+
1901
+ def add_circle(center: np.ndarray, axis1: np.ndarray, axis2: np.ndarray) -> None:
1902
+ angles = np.linspace(0.0, 2 * np.pi, num_segments, endpoint=False)
1903
+ points = [center + radius * (np.cos(a) * axis1 + np.sin(a) * axis2) for a in angles]
1904
+ release_meshes.append(pv.lines_from_points(np.array(points), close=True))
1905
+
1906
+ if show_i_ry:
1907
+ center = i_point + local_x * offset
1908
+ add_circle(center, local_x, local_z)
1909
+
1910
+ if show_i_rz:
1911
+ center = i_point + local_x * offset
1912
+ add_circle(center, local_x, local_y)
1913
+
1914
+ if show_j_ry:
1915
+ center = j_point - local_x * offset
1916
+ add_circle(center, local_x, local_z)
1917
+
1918
+ if show_j_rz:
1919
+ center = j_point - local_x * offset
1920
+ add_circle(center, local_x, local_y)
1921
+
1922
+ return release_meshes
1923
+
1860
1924
  def add_to_plotter(self, plotter: pv.Plotter) -> None:
1861
1925
  """Add the member line to the plotter.
1862
1926
 
@@ -1864,6 +1928,8 @@ class VisMember:
1864
1928
  """
1865
1929
  color = 'black' if self.theme == 'print' else 'black'
1866
1930
  plotter.add_mesh(self.mesh, color=color, line_width=2)
1931
+ for release_mesh in self.release_meshes:
1932
+ plotter.add_mesh(release_mesh, color=color, line_width=2)
1867
1933
 
1868
1934
 
1869
1935
  class VisDeformedMember:
@@ -214,7 +214,7 @@ class ShearWall():
214
214
  # Identify the global origin for the flange mesh
215
215
  if self.plane == 'XY':
216
216
  Xof = self.origin[0] + x
217
- Yof = self.origin[0] + y_start
217
+ Yof = self.origin[1] + y_start
218
218
  Zof = self.origin[2] + z
219
219
  flg_plane = 'YZ'
220
220
  elif self.plane == 'XZ':
@@ -203,6 +203,34 @@ class VTKWriter:
203
203
  ugrid_members.SetPoints(points)
204
204
  ugrid_members.SetCells(vtk.VTK_LINE, lines)
205
205
 
206
+ #### MEMBER RELEASES DATA ####
207
+ # Add member end releases as cell data
208
+ member_releases = vtk.vtkIntArray()
209
+ member_releases.SetName("End Releases")
210
+ member_releases.SetNumberOfComponents(12) # 12 DOFs (Rx, Ry, Rz, Mx, My, Mz for each end)
211
+
212
+ for i, name in enumerate(["DXi", "DYi", "DZi", "RXi", "RYi", "RZi", "DXj", "DYj", "DZj", "RXj", "RYj", "RZj"]):
213
+ member_releases.SetComponentName(i, name)
214
+
215
+ cell_id = 0
216
+ for member in self.model.members.values():
217
+ if len(member.sub_members) == 0:
218
+ # Single uninformed element
219
+ member_releases.InsertTuple(cell_id, tuple(int(r) for r in member.Releases))
220
+ cell_id += 1
221
+ else:
222
+ # Member releases are defined once on the full member (i/j ends).
223
+ # They are NOT applied per sub-member segment. We only duplicate the
224
+ # same 12 release flags onto every exported line cell so the metadata is
225
+ # available regardless of which segment is selected in post-processing.
226
+ for subm in member.sub_members.values():
227
+ n = 11 # Number of segments
228
+ for _ in range(n - 1):
229
+ member_releases.InsertTuple(cell_id, tuple(int(r) for r in member.Releases))
230
+ cell_id += 1
231
+
232
+ ugrid_members.GetCellData().AddArray(member_releases)
233
+
206
234
  #### MEMBER Data ####
207
235
  for combo in self.model.load_combos.keys():
208
236
  # Displacement
@@ -475,6 +475,10 @@ class Renderer():
475
475
  # Add the actor for the member
476
476
  renderer.AddActor(vis_member.actor)
477
477
 
478
+ # Add visualization for member end releases
479
+ for release_actor in vis_member.release_actors:
480
+ renderer.AddActor(release_actor)
481
+
478
482
  if self.labels == True:
479
483
 
480
484
  # Add the actor for the member label
@@ -950,6 +954,9 @@ class VisMember():
950
954
  :param str theme: ``'default'`` or ``'print'`` to control colors.
951
955
  """
952
956
 
957
+ self.member = member
958
+ self.release_actors = []
959
+
953
960
  # Generate a line for the member
954
961
  line = vtk.vtkLineSource()
955
962
 
@@ -997,6 +1004,96 @@ class VisMember():
997
1004
  self.actor.GetProperty().SetColor(0/255, 0/255, 0/255) # Black
998
1005
  self.lblActor.GetProperty().SetColor(0/255, 0/255, 0/255) # Black
999
1006
 
1007
+ # Add visualization for member end releases
1008
+ self._create_release_actors(annotation_size)
1009
+
1010
+ def _create_release_actors(self, annotation_size):
1011
+ """Create VTK actors for rotational end releases.
1012
+
1013
+ Only local y/z rotational releases are shown.
1014
+
1015
+ :param float annotation_size: Base size for symbol scaling.
1016
+ """
1017
+ from numpy import array, cos, sin, linalg
1018
+
1019
+ releases = self.member.Releases
1020
+ if not releases:
1021
+ return
1022
+
1023
+ show_i_ry = releases[4]
1024
+ show_i_rz = releases[5]
1025
+ show_j_ry = releases[10]
1026
+ show_j_rz = releases[11]
1027
+
1028
+ if not (show_i_ry or show_i_rz or show_j_ry or show_j_rz):
1029
+ return
1030
+
1031
+ T = self.member.T()[0:3, 0:3]
1032
+ local_x = T[0, 0:3]
1033
+ local_y = T[1, 0:3]
1034
+ local_z = T[2, 0:3]
1035
+
1036
+ local_x = local_x / linalg.norm(local_x)
1037
+ local_y = local_y / linalg.norm(local_y)
1038
+ local_z = local_z / linalg.norm(local_z)
1039
+
1040
+ radius = annotation_size * 0.3
1041
+ member_length = self.member.L()
1042
+ default_offset = annotation_size * 1.2
1043
+ short_offset = member_length * 0.05
1044
+ offset = short_offset if default_offset > short_offset else default_offset
1045
+ num_segments = 24
1046
+
1047
+ i_point = array([self.member.i_node.X, self.member.i_node.Y, self.member.i_node.Z])
1048
+ j_point = array([self.member.j_node.X, self.member.j_node.Y, self.member.j_node.Z])
1049
+ member_color = self.actor.GetProperty().GetColor()
1050
+
1051
+ def create_circle_actor(center, axis1, axis2):
1052
+ angles = linspace(0.0, 2 * 3.14159265, num_segments, endpoint=False)
1053
+ points = vtk.vtkPoints()
1054
+ lines = vtk.vtkCellArray()
1055
+
1056
+ for i, a in enumerate(angles):
1057
+ pt = center + radius * (cos(a) * axis1 + sin(a) * axis2)
1058
+ points.InsertNextPoint(pt[0], pt[1], pt[2])
1059
+
1060
+ # Close the circle
1061
+ for i in range(num_segments):
1062
+ line = vtk.vtkLine()
1063
+ line.GetPointIds().SetId(0, i)
1064
+ line.GetPointIds().SetId(1, (i + 1) % num_segments)
1065
+ lines.InsertNextCell(line)
1066
+
1067
+ polydata = vtk.vtkPolyData()
1068
+ polydata.SetPoints(points)
1069
+ polydata.SetLines(lines)
1070
+
1071
+ mapper = vtk.vtkPolyDataMapper()
1072
+ mapper.SetInputData(polydata)
1073
+
1074
+ actor = vtk.vtkActor()
1075
+ actor.SetMapper(mapper)
1076
+ actor.GetProperty().SetColor(*member_color)
1077
+ actor.GetProperty().SetLineWidth(2)
1078
+
1079
+ return actor
1080
+
1081
+ if show_i_ry:
1082
+ center = i_point + local_x * offset
1083
+ self.release_actors.append(create_circle_actor(center, local_x, local_z))
1084
+
1085
+ if show_i_rz:
1086
+ center = i_point + local_x * offset
1087
+ self.release_actors.append(create_circle_actor(center, local_x, local_y))
1088
+
1089
+ if show_j_ry:
1090
+ center = j_point - local_x * offset
1091
+ self.release_actors.append(create_circle_actor(center, local_x, local_z))
1092
+
1093
+ if show_j_rz:
1094
+ center = j_point - local_x * offset
1095
+ self.release_actors.append(create_circle_actor(center, local_x, local_y))
1096
+
1000
1097
  # Converts a node object into a node in its deformed position for the viewer
1001
1098
  class VisDeformedNode():
1002
1099
  """Sphere representing a node in its deformed position."""
@@ -68,6 +68,14 @@ Here's a list of projects that use Pynite:
68
68
  * Phaenotyp (https://github.com/bewegende-Architektur/Phaenotyp) (https://youtu.be/shloSw9HjVI)
69
69
 
70
70
  # What's New?
71
+ v2.3.0
72
+ * 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.
73
+ * Bug fix: Quadrilateral element displacements had corners swapped during VTK visualization. This was a relic from the old MITC4 formulation that has been corrected.
74
+ * 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.
75
+ * Added bending moment end releases to visualizations.
76
+ * Bug fix for shear walls with flanges having an origin with their origin at Y != 0.
77
+ * Rectangular meshes now filter out user defined control points that erroneously lie outside their boudaries.
78
+
71
79
  v2.2.1
72
80
  * Normalized member force diagrams across the entire model for visual clarity.
73
81
  * Made mesh auto-regeneration smarter and more efficient, targeting only meshes that have been altered since the last run.
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="PyNiteFEA",
8
- version="2.2.1",
8
+ version="2.3.0",
9
9
  author="D. Craig Brinck, PE, SE",
10
10
  author_email="Building.Code@outlook.com",
11
11
  description="A simple elastic 3D structural finite element library for Python.",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes