diff --git a/.coverage b/.coverage deleted file mode 100644 index 81c5338b..00000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index 48c5fe40..4aa83a68 100644 --- a/.gitignore +++ b/.gitignore @@ -89,4 +89,5 @@ MANIFEST build/ .ipynb_checkpoints/ -dev/ \ No newline at end of file +dev/ +.coverage \ No newline at end of file diff --git a/anastruct/__init__.py b/anastruct/__init__.py index 320502d6..028b3d95 100644 --- a/anastruct/__init__.py +++ b/anastruct/__init__.py @@ -1,3 +1,4 @@ from anastruct.fem.system import SystemElements from anastruct.fem.util.load import LoadCase, LoadCombination +from anastruct.preprocess import truss from anastruct.vertex import Vertex diff --git a/anastruct/fem/system.py b/anastruct/fem/system.py index 29160686..2184693c 100644 --- a/anastruct/fem/system.py +++ b/anastruct/fem/system.py @@ -2363,6 +2363,6 @@ def _negative_index_to_id(idx: int, collection: Collection[int]) -> int: idx = int(idx) else: raise TypeError("Node or element id must be an integer") - if idx > 0: + if idx >= 0: return idx return max(collection) + (idx + 1) diff --git a/anastruct/fem/system_components/util.py b/anastruct/fem/system_components/util.py index 55a5b433..4dd0b3fc 100644 --- a/anastruct/fem/system_components/util.py +++ b/anastruct/fem/system_components/util.py @@ -203,6 +203,48 @@ def det_node_ids( return node_ids[0], node_ids[1] +def add_node( + system: "SystemElements", point: Vertex, node_id: Optional[int] = None +) -> int: + """Add a node, optionally with a specific ID, without adding an element + + Args: + system (SystemElements): System in which the nodes are located + point (Vertex): Location of the node + node_id (Optional[int], optional): node_id to assign to the node. Defaults to None, + which means to use the first available node_id automatically. + + Raises: + FEMException: Raised when the location is already assigned to a different node id. + FEMException: Raised when the node id is already assigned to a different location. + + Returns: + int: The node id of the added (or existing) node + """ + if point in system._vertices: + if node_id is not None: + existing_node_id = system._vertices[point] + if existing_node_id != node_id: + raise FEMException( + "Flawed inputs", + f"Location {point} is already assigned to node id {existing_node_id}, " + f"cannot assign to node id {node_id}.", + ) + return existing_node_id + + if node_id is None: + node_id = max(system.node_map.keys(), default=0) + 1 + elif node_id in system.node_map and system.node_map[node_id].vertex != point: + raise FEMException( + "Flawed inputs", + f"Node id {node_id} is already assigned to a different location.", + ) + + system._vertices[point] = node_id + system.node_map[node_id] = Node(node_id, vertex=point) + return node_id + + def support_check(system: "SystemElements", node_id: int) -> None: """Check if the node is a hinge diff --git a/anastruct/preprocess/__init__.py b/anastruct/preprocess/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anastruct/preprocess/truss.py b/anastruct/preprocess/truss.py new file mode 100644 index 00000000..41e93f04 --- /dev/null +++ b/anastruct/preprocess/truss.py @@ -0,0 +1,1278 @@ +from typing import Any, Literal, Optional + +import numpy as np + +from anastruct.preprocess.truss_class import FlatTruss, RoofTruss, Truss +from anastruct.types import SectionProps +from anastruct.vertex import Vertex + + +class HoweFlatTruss(FlatTruss): + """Howe flat truss with vertical web members and diagonal members in compression. + + The Howe truss features vertical web members and diagonal members sloping toward + the center. Under gravity loads, diagonals are typically in compression and + verticals in tension, making it efficient for steel trusses. + """ + + @property + def type(self) -> str: + return "Howe Flat Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type != "triangle_up": + self.nodes.append(Vertex(0.0, 0.0)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, 0.0)) + if self.end_type != "triangle_up": + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + if self.end_type != "triangle_down": + self.nodes.append(Vertex(0, self.height)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, self.height)) + if self.end_type != "triangle_down": + self.nodes.append(Vertex(self.width, self.height)) + + def define_connectivity(self) -> None: + n_bottom_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_up" else 0) + ) + n_top_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_down" else 0) + ) + + # Bottom chord connectivity + self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + + # Top chord connectivity + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) + + # Web diagonals connectivity + start_bot = 0 + start_top = 0 + end_bot = None # a None index means go to the end + end_top = None + if self.end_type == "triangle_up": + # special case: end diagonal slopes in the opposite direction + self.web_node_pairs.append((0, n_bottom_nodes)) + self.web_node_pairs.append( + (n_bottom_nodes - 1, n_bottom_nodes + n_top_nodes - 1) + ) + start_top = 2 + end_top = -3 + elif self.end_type == "flat": + start_top = 1 + end_top = -2 + mid_bot = len(self.bottom_chord_node_ids) // 2 + mid_top = len(self.top_chord_node_ids) // 2 + for b, t in zip( + self.bottom_chord_node_ids[start_bot : mid_bot + 1], + self.top_chord_node_ids[start_top : mid_top + 1], + ): + self.web_node_pairs.append((b, t)) + for b, t in zip( + self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[end_top : mid_top - 1 : -1], + ): + self.web_node_pairs.append((b, t)) + + # Web verticals connectivity + start_bot = 0 + start_top = 0 + end_bot = None + end_top = None + if self.end_type == "triangle_up": + start_top = 1 + end_top = -1 + elif self.end_type == "triangle_down": + start_bot = 1 + end_bot = -1 + for b, t in zip( + self.bottom_chord_node_ids[start_bot:end_bot], + self.top_chord_node_ids[start_top:end_top], + ): + self.web_verticals_node_pairs.append((b, t)) + + +class PrattFlatTruss(FlatTruss): + """Pratt flat truss with vertical web members and diagonal members in tension. + + The Pratt truss features vertical web members and diagonal members sloping away + from the center. Under gravity loads, diagonals are typically in tension and + verticals in compression, making it efficient for a wide range of applications. + """ + + @property + def type(self) -> str: + return "Pratt Flat Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type != "triangle_up": + self.nodes.append(Vertex(0.0, 0.0)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, 0.0)) + if self.end_type != "triangle_up": + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + if self.end_type != "triangle_down": + self.nodes.append(Vertex(0, self.height)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, self.height)) + if self.end_type != "triangle_down": + self.nodes.append(Vertex(self.width, self.height)) + + def define_connectivity(self) -> None: + n_bottom_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_up" else 0) + ) + n_top_nodes = ( + int(self.n_units) + 1 + (2 if self.end_type != "triangle_down" else 0) + ) + + # Bottom chord connectivity + self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + + # Top chord connectivity + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) + + # Web diagonals connectivity + start_bot = 0 + start_top = 0 + end_bot = None # a None index means go to the end + end_top = None + if self.end_type == "triangle_down": + # special case: end diagonal slopes in the opposite direction + self.web_node_pairs.append((n_bottom_nodes, 0)) + self.web_node_pairs.append( + (n_bottom_nodes + n_top_nodes - 1, n_bottom_nodes - 1) + ) + start_bot = 2 + end_bot = -3 + elif self.end_type == "flat": + start_bot = 1 + end_bot = -2 + mid_bot = len(self.bottom_chord_node_ids) // 2 + mid_top = len(self.top_chord_node_ids) // 2 + for b, t in zip( + self.bottom_chord_node_ids[start_bot : mid_bot + 1], + self.top_chord_node_ids[start_top : mid_top + 1], + ): + self.web_node_pairs.append((b, t)) + for b, t in zip( + self.bottom_chord_node_ids[end_bot : mid_bot - 1 : -1], + self.top_chord_node_ids[end_top : mid_top - 1 : -1], + ): + self.web_node_pairs.append((b, t)) + + # Web verticals connectivity + start_bot = 0 + start_top = 0 + end_bot = None + end_top = None + if self.end_type == "triangle_up": + start_top = 1 + end_top = -1 + elif self.end_type == "triangle_down": + start_bot = 1 + end_bot = -1 + for b, t in zip( + self.bottom_chord_node_ids[start_bot:end_bot], + self.top_chord_node_ids[start_top:end_top], + ): + self.web_verticals_node_pairs.append((b, t)) + + +class WarrenFlatTruss(FlatTruss): + """Warren flat truss with diagonal-only web members forming a zigzag pattern. + + The Warren truss has no vertical web members (except optionally at midspan). + Diagonal members alternate direction, creating a series of equilateral or + isosceles triangles. This configuration is simple and efficient. + + Note: Warren trusses don't support the "flat" end_type - only "triangle_down" + or "triangle_up". + """ + + # Data types specific to this truss type + EndType = Literal["triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Computed properties + n_units: int + end_width: float + + @property + def type(self) -> str: + return "Warren Flat Truss" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + # Note that the maths for a Warren truss is simpler than for Howe/Pratt, because there + # cannot be any option for non-even number of units, and there are no special cases for + # web verticals. + min_end_fraction = 0.5 # Not used for Warren truss + enforce_even_units = True # Handled internally for Warren truss + super().__init__( + width, + height, + unit_width, + end_type, + supports_loc, + min_end_fraction, + enforce_even_units, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + self.end_width = (width - self.n_units * unit_width) / 2 + (unit_width / 2) + + def define_nodes(self) -> None: + # Bottom chord nodes + if self.end_type == "triangle_down": + self.nodes.append(Vertex(0.0, 0.0)) + else: + self.nodes.append(Vertex(self.end_width - self.unit_width / 2, 0.0)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, 0.0)) + if self.end_type == "triangle_down": + self.nodes.append(Vertex(self.width, 0.0)) + else: + self.nodes.append( + Vertex(self.width - (self.end_width - self.unit_width / 2), 0.0) + ) + + # Top chord nodes + if self.end_type == "triangle_up": + self.nodes.append(Vertex(0, self.height)) + else: + self.nodes.append(Vertex(self.end_width - self.unit_width / 2, self.height)) + for i in range(int(self.n_units) + 1): + x = self.end_width + i * self.unit_width + self.nodes.append(Vertex(x, self.height)) + if self.end_type == "triangle_up": + self.nodes.append(Vertex(self.width, self.height)) + else: + self.nodes.append( + Vertex(self.width - (self.end_width - self.unit_width / 2), self.height) + ) + + def define_connectivity(self) -> None: + n_bottom_nodes = int(self.n_units) + ( + 1 if self.end_type == "triangle_down" else 0 + ) + n_top_nodes = int(self.n_units) + (1 if self.end_type == "triangle_up" else 0) + + # Bottom chord connectivity + self.bottom_chord_node_ids = list(range(0, n_bottom_nodes)) + + # Top chord connectivity + self.top_chord_node_ids = list( + range(n_bottom_nodes, n_bottom_nodes + n_top_nodes) + ) + + # Web diagonals connectivity + # sloping up from bottom left to top right + top_start = 0 if self.end_type == "triangle_down" else 1 + for b, t in zip( + self.bottom_chord_node_ids, + self.top_chord_node_ids[top_start:], + ): + self.web_node_pairs.append((b, t)) + # sloping down from top left to bottom right + bot_start = 0 if self.end_type == "triangle_up" else 1 + for b, t in zip( + self.top_chord_node_ids, + self.bottom_chord_node_ids[bot_start:], + ): + self.web_node_pairs.append((b, t)) + + +class KingPostRoofTruss(RoofTruss): + """King Post roof truss - simplest pitched roof truss with single center vertical. + + Features a single vertical member (king post) at the center supporting the peak. + Suitable for short spans (up to ~8m). No diagonal web members. + """ + + @property + def type(self) -> str: + return "King Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, self.height)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = {"left": [left_v, 3], "right": [3, right_v]} + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 4) # left overhang + self.top_chord_node_ids["right"].append(5) # right overhang + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 3)) # center vertical + + +class QueenPostRoofTruss(RoofTruss): + """Queen Post roof truss with two vertical members and diagonal bracing. + + Features two vertical members (queen posts) at quarter points with diagonal + members from center to quarter points. Suitable for medium spans (8-15m). + More efficient than King Post for longer spans. + """ + + @property + def type(self) -> str: + return "Queen Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes: [0=left, 1=center, 2=right] + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes: [3=left quarter, 4=peak, 5=right quarter] + self.nodes.append(Vertex(self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + + # Optional overhang nodes + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2] + left_v = 0 + right_v = 2 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = {"left": [left_v, 3, 4], "right": [4, 5, right_v]} + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 6) # left overhang + self.top_chord_node_ids["right"].append(7) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append( + (1, 3) + ) # left diagonal from center bottom to left quarter top + self.web_node_pairs.append( + (1, 5) + ) # right diagonal from center bottom to right quarter top + + # Web verticals connectivity - Fixed: should connect to peak (node 4), not node 3 + self.web_verticals_node_pairs.append( + (1, 4) + ) # center vertical from center bottom to peak + + +class FinkRoofTruss(RoofTruss): + """Fink roof truss with W-shaped web configuration. + + Features diagonal members forming a W pattern between peak and supports. + Efficient for medium to long spans (10-20m). The symmetrical W pattern + distributes loads effectively with minimal material usage. + """ + + @property + def type(self) -> str: + return "Fink Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 3, 0.0)) + self.nodes.append(Vertex(2 * self.width / 3, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3] + left_v = 0 + right_v = 3 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = {"left": [left_v, 4, 5], "right": [5, 6, right_v]} + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 7) # left overhang + self.top_chord_node_ids["right"].append(8) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append((1, 5)) + self.web_node_pairs.append((2, 5)) + self.web_node_pairs.append((2, 6)) + + +class HoweRoofTruss(RoofTruss): + """Howe roof truss with vertical posts and diagonal compression members. + + Features vertical posts with diagonals sloping toward the peak. Under gravity + loads, diagonals are in compression and verticals in tension. Suitable for + medium to long spans with good load distribution. + """ + + @property + def type(self) -> str: + return "Howe Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = {"left": [left_v, 5, 6], "right": [6, 7, right_v]} + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 8) # left overhang + self.top_chord_node_ids["right"].append(9) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((2, 5)) # left diagonal + self.web_node_pairs.append((2, 7)) # right diagonal + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) # left vertical + self.web_verticals_node_pairs.append((2, 6)) # centre vertical + self.web_verticals_node_pairs.append((3, 7)) # right vertical + + +class PrattRoofTruss(RoofTruss): + """Pratt roof truss with vertical posts and diagonal tension members. + + Features vertical posts with diagonals sloping away from the peak. Under gravity + loads, diagonals are in tension and verticals in compression. Widely used for + its efficiency and simple construction. + """ + + @property + def type(self) -> str: + return "Pratt Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, self.height / 2)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(3 * self.width / 4, self.height / 2)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = {"left": [left_v, 5, 6], "right": [6, 7, right_v]} + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 8) # left overhang + self.top_chord_node_ids["right"].append(9) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 6)) # left diagonal + self.web_node_pairs.append((3, 6)) # right diagonal + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) # left vertical + self.web_verticals_node_pairs.append((2, 6)) # centre vertical + self.web_verticals_node_pairs.append((3, 7)) # right vertical + + +class FanRoofTruss(RoofTruss): + """Fan roof truss with radiating diagonal members forming a fan pattern. + + Features diagonal members radiating from lower chord panel points up to the + top chord, creating a fan-like appearance. Provides excellent load distribution + for longer spans (15-25m). + """ + + @property + def type(self) -> str: + return "Fan Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 3, 0.0)) + self.nodes.append(Vertex(2 * self.width / 3, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3] + left_v = 0 + right_v = 3 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 4, 5, 6], + "right": [6, 7, 8, right_v], + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 9) # left overhang + self.top_chord_node_ids["right"].append(10) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append((1, 6)) + self.web_node_pairs.append((2, 6)) + self.web_node_pairs.append((2, 8)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 7)) + + +class ModifiedQueenPostRoofTruss(RoofTruss): + """Modified Queen Post roof truss with enhanced web configuration. + + An enhanced version of the Queen Post truss with additional web members + for better load distribution and reduced member forces. Suitable for + medium to long spans (12-20m). + """ + + @property + def type(self) -> str: + return "Modified Queen Post Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 5, 6, 7], + "right": [7, 8, 9, right_v], + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 10) # left overhang + self.top_chord_node_ids["right"].append(11) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 5)) + self.web_node_pairs.append((1, 6)) + self.web_node_pairs.append((2, 6)) + self.web_node_pairs.append((2, 8)) + self.web_node_pairs.append((3, 8)) + self.web_node_pairs.append((3, 9)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((2, 7)) # center vertical + + +class DoubleFinkRoofTruss(RoofTruss): + """Double Fink roof truss with two W-shaped web patterns. + + An extension of the Fink truss with additional web members creating two + W patterns. Suitable for longer spans (20-30m) where a standard Fink would + have excessive member lengths. + """ + + @property + def type(self) -> str: + return "Double Fink Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 5, 0.0)) + self.nodes.append(Vertex(2 * self.width / 5, 0.0)) + self.nodes.append(Vertex(3 * self.width / 5, 0.0)) + self.nodes.append(Vertex(4 * self.width / 5, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5] + left_v = 0 + right_v = 5 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 6, 7, 8], + "right": [8, 9, 10, right_v], + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 11) # left overhang + self.top_chord_node_ids["right"].append(12) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 6)) + self.web_node_pairs.append((1, 7)) + self.web_node_pairs.append((2, 7)) + self.web_node_pairs.append((2, 8)) + self.web_node_pairs.append((3, 8)) + self.web_node_pairs.append((3, 9)) + self.web_node_pairs.append((4, 9)) + self.web_node_pairs.append((4, 10)) + + +class DoubleHoweRoofTruss(RoofTruss): + """Double Howe roof truss with enhanced vertical and diagonal web pattern. + + An extension of the Howe truss with additional verticals and diagonals for + increased load capacity and reduced member lengths. Suitable for long spans + (20-30m) or heavy loading conditions. + """ + + @property + def type(self) -> str: + return "Double Howe Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, 0.0)) + self.nodes.append(Vertex(2 * self.width / 6, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(4 * self.width / 6, 0.0)) + self.nodes.append(Vertex(5 * self.width / 6, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 6, self.height / 3)) + self.nodes.append(Vertex(2 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(4 * self.width / 6, 2 * self.height / 3)) + self.nodes.append(Vertex(5 * self.width / 6, self.height / 3)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4, 5, 6] + left_v = 0 + right_v = 6 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 7, 8, 9], + "right": [9, 10, 11, right_v], + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 12) # left overhang + self.top_chord_node_ids["right"].append(13) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((2, 7)) + self.web_node_pairs.append((3, 8)) + self.web_node_pairs.append((3, 10)) + self.web_node_pairs.append((4, 11)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 7)) + self.web_verticals_node_pairs.append((2, 8)) + self.web_verticals_node_pairs.append((3, 9)) # center vertical + self.web_verticals_node_pairs.append((4, 10)) + self.web_verticals_node_pairs.append((5, 11)) + + +class ModifiedFanRoofTruss(RoofTruss): + """Modified Fan roof truss with enhanced radiating web pattern. + + An enhanced version of the Fan truss with additional web members for + improved structural performance. Suitable for long spans (20-30m) with + excellent load distribution characteristics. + """ + + @property + def type(self) -> str: + return "Modified Fan Roof Truss" + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width / 2, 0.0)) + self.nodes.append(Vertex(3 * self.width / 4, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(1 * self.width / 8, 1 * self.height / 4)) + self.nodes.append(Vertex(2 * self.width / 8, 2 * self.height / 4)) + self.nodes.append(Vertex(3 * self.width / 8, 3 * self.height / 4)) + self.nodes.append(Vertex(self.width / 2, self.height)) + self.nodes.append(Vertex(5 * self.width / 8, 3 * self.height / 4)) + self.nodes.append(Vertex(6 * self.width / 8, 2 * self.height / 4)) + self.nodes.append(Vertex(7 * self.width / 8, 1 * self.height / 4)) + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3, 4] + left_v = 0 + right_v = 4 + + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 5, 6, 7, 8], + "right": [8, 9, 10, 11, right_v], + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 12) # left overhang + self.top_chord_node_ids["right"].append(13) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 5)) + self.web_node_pairs.append((1, 7)) + self.web_node_pairs.append((2, 7)) + self.web_node_pairs.append((2, 9)) + self.web_node_pairs.append((3, 9)) + self.web_node_pairs.append((3, 11)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 6)) + self.web_verticals_node_pairs.append((2, 8)) # center vertical + self.web_verticals_node_pairs.append((3, 10)) + + +class AtticRoofTruss(RoofTruss): + """Attic (or Room-in-Roof) truss with habitable space under the roof. + + Creates a truss with vertical walls and a flat ceiling to provide usable attic + space. The geometry includes: + - Vertical attic walls at the edges of the attic space + - Horizontal ceiling beam + - Sloped top chords from walls to peak + - Diagonal and vertical web members for support + + The attic space is defined by attic_width (floor width) and attic_height + (ceiling height). If attic_height is not specified, it defaults to the height + where the vertical walls meet the sloped roof. + + Attributes: + attic_width (float): Width of the attic floor (interior dimension) + attic_height (float): Height of the attic ceiling + wall_x (float): Horizontal position where attic walls are located + wall_y (float): Height at top of attic walls where they meet the roof slope + ceiling_y (float): Vertical position of the ceiling beam (equals attic_height) + ceiling_x (float): Horizontal position where ceiling meets the sloped top chord + wall_ceiling_intersect (bool): True if wall top and ceiling intersection coincide + """ + + # Additional properties for this truss type + attic_width: float + attic_height: float + + # Computed properties for this truss type + wall_x: float + wall_y: float + ceiling_y: float + ceiling_x: float + wall_ceiling_intersect: bool = False + + @property + def type(self) -> str: + return "Attic Roof Truss" + + def __init__( + self, + width: float, + roof_pitch_deg: float, + attic_width: float, + attic_height: Optional[float] = None, + overhang_length: float = 0.0, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + """Initialize an attic roof truss. + + Args: + width (float): Total span of the truss + roof_pitch_deg (float): Roof pitch angle in degrees + attic_width (float): Interior width of the attic space. Must be less than width. + attic_height (Optional[float]): Height of the attic ceiling. If None, defaults + to the height where vertical walls meet the roof slope. Must be at least + as high as the wall intersection point. + overhang_length (float): Length of roof overhang. Defaults to 0.0. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If attic dimensions are invalid or create impossible geometry + """ + # NOTE: Must compute attic geometry BEFORE calling super().__init__() because + # define_nodes() needs these values, and it's called within super().__init__() + + if attic_width <= 0: + raise ValueError(f"attic_width must be positive, got {attic_width}") + if attic_width >= width: + raise ValueError( + f"attic_width ({attic_width}) must be less than truss width ({width})" + ) + + self.attic_width = attic_width + + # Compute roof pitch first (needed for geometry calculations) + roof_pitch = np.radians(roof_pitch_deg) + + # Calculate horizontal position of attic walls (from centerline) + wall_x = width / 2 - attic_width / 2 + + # Calculate height where vertical wall meets the sloped roof + # Using: wall_y = wall_x * tan(roof_pitch) + wall_y = wall_x * np.tan(roof_pitch) + + # Set ceiling height + if attic_height is None: + # Default: ceiling at the wall-roof intersection + ceiling_y = wall_y + else: + ceiling_y = attic_height + + # Calculate peak height for this width and pitch + peak_height = (width / 2) * np.tan(roof_pitch) + + # Calculate horizontal position where ceiling meets the sloped top chord + # From peak: horizontal_distance = (peak_height - ceiling_height) / tan(roof_pitch) + # From centerline: ceiling_x = centerline - horizontal_distance + ceiling_x = width / 2 - (peak_height - ceiling_y) / np.tan(roof_pitch) + + # Validate geometry: ceiling must be at or above the wall intersection + # Use tolerance for floating point comparison + tolerance = 1e-6 + if ceiling_y < wall_y - tolerance or ceiling_x < wall_x - tolerance: + raise ValueError( + f"Attic height ({ceiling_y:.2f}) is too low. " + f"Minimum attic height for this configuration is {wall_y:.2f}. " + f"Please increase attic_height or decrease attic_width." + ) + + # Store computed geometry + self.attic_height = ( + ceiling_y # Use the computed ceiling_y which is always a float + ) + self.wall_x = wall_x + self.wall_y = wall_y + self.ceiling_y = ceiling_y + self.ceiling_x = ceiling_x + + # Check if wall top and ceiling intersection are at the same point + self.wall_ceiling_intersect = self.ceiling_y == self.wall_y + + # Now call super().__init__() which will call define_nodes/connectivity/supports + super().__init__( + width=width, + roof_pitch_deg=roof_pitch_deg, + overhang_length=overhang_length, + top_chord_section=top_chord_section, + bottom_chord_section=bottom_chord_section, + web_section=web_section, + web_verticals_section=web_verticals_section, + ) + + def define_nodes(self) -> None: + # Bottom chord nodes + self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.wall_x, 0.0)) + self.nodes.append(Vertex(self.width - self.wall_x, 0.0)) + self.nodes.append(Vertex(self.width, 0.0)) + + # Top chord nodes + # self.nodes.append(Vertex(0.0, 0.0)) + self.nodes.append(Vertex(self.wall_x / 2, self.wall_y / 2)) + self.nodes.append(Vertex(self.wall_x, self.wall_y)) + if not self.wall_ceiling_intersect: + self.nodes.append(Vertex(self.ceiling_x, self.ceiling_y)) + self.nodes.append(Vertex(self.width / 2, self.height)) + if not self.wall_ceiling_intersect: + self.nodes.append(Vertex(self.width - self.ceiling_x, self.ceiling_y)) + self.nodes.append(Vertex(self.width - self.wall_x, self.wall_y)) + self.nodes.append(Vertex(self.width - self.wall_x / 2, self.wall_y / 2)) + self.nodes.append( + Vertex(self.width / 2, self.ceiling_y) + ) # special node in the middle of the ceiling beam + # self.nodes.append(Vertex(self.width, 0.0)) + if self.overhang_length > 0: + self.nodes.append( + Vertex( + -self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + self.nodes.append( + Vertex( + self.width + self.overhang_length * np.cos(self.roof_pitch), + -self.overhang_length * np.sin(self.roof_pitch), + ) + ) + + def define_connectivity(self) -> None: + # Bottom chord connectivity + self.bottom_chord_node_ids = [0, 1, 2, 3] + left_v = 0 + right_v = 3 + + if self.wall_ceiling_intersect: + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 4, 5, 6], + "right": [6, 7, 8, right_v], + "ceiling": [5, 9, 7], # attic ceiling + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 10) # left overhang + self.top_chord_node_ids["right"].append(11) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append( + (9, 6) + ) # special case: this is actually the center vertical post + self.web_node_pairs.append((2, 8)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 7)) + + else: + # Top chord connectivity (left and right slopes stored separately) + self.top_chord_node_ids = { + "left": [left_v, 4, 5, 6, 7], + "right": [7, 8, 9, 10, right_v], + "ceiling": [6, 11, 8], # attic ceiling + } + if self.overhang_length > 0: + self.top_chord_node_ids["left"].insert(0, 12) # left overhang + self.top_chord_node_ids["right"].append(13) # right overhang + + # Web diagonals connectivity + self.web_node_pairs.append((1, 4)) + self.web_node_pairs.append( + (11, 7) + ) # special case: this is actually the center vertical post + self.web_node_pairs.append((2, 10)) + + # Web verticals connectivity + self.web_verticals_node_pairs.append((1, 5)) + self.web_verticals_node_pairs.append((2, 9)) + + +def create_truss(truss_type: str, **kwargs: Any) -> "Truss": + """Factory function to create truss instances by type name. + + Provides a convenient way to create trusses without importing specific classes. + Type names are case-insensitive and can use underscores or hyphens as separators. + + Args: + truss_type (str): Name of the truss type. Supported types: + Flat trusses: "howe", "pratt", "warren" + Roof trusses: "king_post", "queen_post", "fink", "howe_roof", "pratt_roof", + "fan", "modified_queen_post", "double_fink", "double_howe", + "modified_fan", "attic" + **kwargs: Arguments to pass to the truss constructor + + Returns: + Truss: An instance of the requested truss type + + Raises: + ValueError: If truss_type is not recognized + + Examples: + >>> truss = create_truss("howe", width=20, height=2.5, unit_width=2.0) + >>> truss = create_truss("king-post", width=10, roof_pitch_deg=30) + """ + # Normalize the truss type name + normalized = truss_type.lower().replace("-", "_").replace(" ", "_") + + # Map of normalized names to classes + truss_map = { + # Flat trusses + "howe": HoweFlatTruss, + "howe_flat": HoweFlatTruss, + "pratt": PrattFlatTruss, + "pratt_flat": PrattFlatTruss, + "warren": WarrenFlatTruss, + "warren_flat": WarrenFlatTruss, + # Roof trusses + "king_post": KingPostRoofTruss, + "kingpost": KingPostRoofTruss, + "queen_post": QueenPostRoofTruss, + "queenpost": QueenPostRoofTruss, + "fink": FinkRoofTruss, + "howe_roof": HoweRoofTruss, + "pratt_roof": PrattRoofTruss, + "fan": FanRoofTruss, + "modified_queen_post": ModifiedQueenPostRoofTruss, + "modified_queenpost": ModifiedQueenPostRoofTruss, + "double_fink": DoubleFinkRoofTruss, + "doublefink": DoubleFinkRoofTruss, + "double_howe": DoubleHoweRoofTruss, + "doublehowe": DoubleHoweRoofTruss, + "modified_fan": ModifiedFanRoofTruss, + "modifiedfan": ModifiedFanRoofTruss, + "attic": AtticRoofTruss, + "attic_roof": AtticRoofTruss, + } + + if normalized not in truss_map: + available = sorted(set(truss_map.keys())) + raise ValueError( + f"Unknown truss type '{truss_type}'. Available types: {', '.join(available)}" + ) + + truss_class = truss_map[normalized] + assert issubclass(truss_class, Truss) + return truss_class(**kwargs) + + +__all__ = [ + "HoweFlatTruss", + "PrattFlatTruss", + "WarrenFlatTruss", + "KingPostRoofTruss", + "QueenPostRoofTruss", + "FinkRoofTruss", + "HoweRoofTruss", + "PrattRoofTruss", + "FanRoofTruss", + "ModifiedQueenPostRoofTruss", + "DoubleFinkRoofTruss", + "DoubleHoweRoofTruss", + "ModifiedFanRoofTruss", + "AtticRoofTruss", + "create_truss", +] diff --git a/anastruct/preprocess/truss_class.py b/anastruct/preprocess/truss_class.py new file mode 100644 index 00000000..399325c4 --- /dev/null +++ b/anastruct/preprocess/truss_class.py @@ -0,0 +1,794 @@ +from abc import ABC, abstractmethod +from typing import Iterable, Literal, Optional, Sequence, Union, overload + +import numpy as np + +from anastruct.fem.system import SystemElements +from anastruct.fem.system_components.util import add_node +from anastruct.types import LoadDirection, SectionProps +from anastruct.vertex import Vertex + +DEFAULT_TRUSS_SECTION: SectionProps = { + "EI": 1e6, + "EA": 1e8, + "g": 0.0, +} + + +class Truss(ABC): + """Abstract base class for 2D truss structures. + + Provides a framework for creating parametric truss geometries with automated + node generation, connectivity, and support definitions. Subclasses implement + specific truss types (Howe, Pratt, Warren, etc.). + + The truss generation follows a three-phase process: + 1. define_nodes() - Generate node coordinates + 2. define_connectivity() - Define which nodes connect to form elements + 3. define_supports() - Define support locations and types + + Attributes: + width (float): Total span of the truss (length units) + height (float): Height of the truss (length units) + top_chord_section (SectionProps): Section properties for top chord elements + bottom_chord_section (SectionProps): Section properties for bottom chord elements + web_section (SectionProps): Section properties for diagonal web elements + web_verticals_section (SectionProps): Section properties for vertical web elements + top_chord_continuous (bool): If True, top chord is continuous; if False, pinned at joints + bottom_chord_continuous (bool): If True, bottom chord is continuous; if False, pinned at joints + supports_type (Literal["simple", "pinned", "fixed"]): Type of supports to apply + system (SystemElements): The FEM system containing all nodes, elements, and supports + """ + + # Common geometry + width: float + height: float + + # Material properties + top_chord_section: SectionProps + bottom_chord_section: SectionProps + web_section: SectionProps + web_verticals_section: SectionProps + + # Configuration + top_chord_continuous: bool + bottom_chord_continuous: bool + supports_type: Literal["simple", "pinned", "fixed"] + + # Defined by subclass (initialized in define_* methods) + nodes: list[Vertex] + top_chord_node_ids: Union[list[int], dict[str, list[int]]] + bottom_chord_node_ids: Union[list[int], dict[str, list[int]]] + web_node_pairs: list[tuple[int, int]] + web_verticals_node_pairs: list[tuple[int, int]] + support_definitions: dict[int, Literal["fixed", "pinned", "roller"]] + top_chord_length: float + bottom_chord_length: float + + # Defined by main class (initialized in add_elements) + top_chord_element_ids: Union[list[int], dict[str, list[int]]] + bottom_chord_element_ids: Union[list[int], dict[str, list[int]]] + web_element_ids: list[int] + web_verticals_element_ids: list[int] + + # System + system: SystemElements + + def __init__( + self, + width: float, + height: float, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + top_chord_continuous: bool = True, + bottom_chord_continuous: bool = True, + supports_type: Literal["simple", "pinned", "fixed"] = "simple", + ): + """Initialize a truss structure. + + Args: + width (float): Total span of the truss. Must be positive. + height (float): Height of the truss. Must be positive. + top_chord_section (Optional[SectionProps]): Section properties for top chord. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + web_section (Optional[SectionProps]): Section properties for diagonal web members. + Defaults to DEFAULT_TRUSS_SECTION if not provided. + web_verticals_section (Optional[SectionProps]): Section properties for vertical web members. + Defaults to web_section if not provided. + top_chord_continuous (bool): If True, top chord is continuous at joints (moment connection). + If False, top chord is pinned at joints. Defaults to True. + bottom_chord_continuous (bool): If True, bottom chord is continuous at joints. + If False, bottom chord is pinned at joints. Defaults to True. + supports_type (Literal["simple", "pinned", "fixed"]): Type of supports. + "simple" creates pinned+roller, "pinned" creates pinned+pinned, "fixed" creates fixed+fixed. + Defaults to "simple". + + Raises: + ValueError: If width or height is not positive. + """ + if width <= 0: + raise ValueError(f"width must be positive, got {width}") + if height <= 0: + raise ValueError(f"height must be positive, got {height}") + + self.width = width + self.height = height + self.top_chord_section = top_chord_section or DEFAULT_TRUSS_SECTION + self.bottom_chord_section = bottom_chord_section or DEFAULT_TRUSS_SECTION + self.web_section = web_section or DEFAULT_TRUSS_SECTION + self.web_verticals_section = web_verticals_section or self.web_section + self.top_chord_continuous = top_chord_continuous + self.bottom_chord_continuous = bottom_chord_continuous + self.supports_type = supports_type + + # Initialize mutable attributes (prevents sharing between instances) + self.nodes = [] + self.web_node_pairs = [] + self.web_verticals_node_pairs = [] + self.support_definitions = {} + self.top_chord_length = 0.0 + self.bottom_chord_length = 0.0 + + self.define_nodes() + self.define_connectivity() + self.define_supports() + + self.system = SystemElements() + self.add_nodes() + self.add_elements() + self.add_supports() + + @property + @abstractmethod + def type(self) -> str: + """Return the human-readable name of the truss type.""" + + @abstractmethod + def define_nodes(self) -> None: + """Generate node coordinates and populate self.nodes list. + + Must be implemented by subclasses. Should create Vertex objects + representing all node locations in the truss. + """ + + @abstractmethod + def define_connectivity(self) -> None: + """Define element connectivity by populating node ID lists. + + Must be implemented by subclasses. Should populate: + - self.top_chord_node_ids + - self.bottom_chord_node_ids + - self.web_node_pairs + - self.web_verticals_node_pairs + """ + + @abstractmethod + def define_supports(self) -> None: + """Define support locations and types by populating self.support_definitions. + + Must be implemented by subclasses. + """ + + def add_nodes(self) -> None: + """Add all nodes from self.nodes to the SystemElements.""" + for i, vertex in enumerate(self.nodes): + add_node(self.system, point=vertex, node_id=i) + + def add_elements(self) -> None: + """Create elements from connectivity definitions and add to SystemElements. + + Populates element ID lists: + - self.top_chord_element_ids + - self.bottom_chord_element_ids + - self.web_element_ids + - self.web_verticals_element_ids + """ + + def add_segment_elements( + node_pairs: Iterable[tuple[int, int]], + section: SectionProps, + continuous: bool, + ) -> list[int]: + """Helper to add a sequence of connected elements. + + Args: + node_pairs (Iterable[tuple[int, int]]): Pairs of node IDs to connect + section (SectionProps): Section properties for the elements + continuous (bool): If True, create moment connections; if False, pin connections + + Returns: + list[int]: Element IDs of created elements + """ + element_ids = [] + for i, j in node_pairs: + element_ids.append( + self.system.add_element( + location=(self.nodes[i], self.nodes[j]), + EA=section["EA"], + EI=section["EI"], + g=section["g"], + spring=None if continuous else {1: 0.0, 2: 0.0}, + ) + ) + return element_ids + + # Bottom chord elements + if isinstance(self.bottom_chord_node_ids, dict): + self.bottom_chord_element_ids = {} + for key, segment_node_ids in self.bottom_chord_node_ids.items(): + self.bottom_chord_element_ids[key] = add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.bottom_chord_section, + continuous=self.bottom_chord_continuous, + ) + else: + self.bottom_chord_element_ids = add_segment_elements( + node_pairs=zip( + self.bottom_chord_node_ids[:-1], self.bottom_chord_node_ids[1:] + ), + section=self.bottom_chord_section, + continuous=self.bottom_chord_continuous, + ) + + # Top chord elements + if isinstance(self.top_chord_node_ids, dict): + self.top_chord_element_ids = {} + for key, segment_node_ids in self.top_chord_node_ids.items(): + self.top_chord_element_ids[key] = add_segment_elements( + node_pairs=zip(segment_node_ids[:-1], segment_node_ids[1:]), + section=self.top_chord_section, + continuous=self.top_chord_continuous, + ) + else: + self.top_chord_element_ids = add_segment_elements( + node_pairs=zip( + self.top_chord_node_ids[:-1], self.top_chord_node_ids[1:] + ), + section=self.top_chord_section, + continuous=self.top_chord_continuous, + ) + + # Web diagonal elements + self.web_element_ids = add_segment_elements( + node_pairs=self.web_node_pairs, + section=self.web_section, + continuous=False, + ) + + # Web vertical elements + self.web_verticals_element_ids = add_segment_elements( + node_pairs=self.web_verticals_node_pairs, + section=self.web_verticals_section, + continuous=False, + ) + + def add_supports(self) -> None: + """Add supports from self.support_definitions to the SystemElements.""" + for node_id, support_type in self.support_definitions.items(): + if support_type == "fixed": + self.system.add_support_fixed(node_id=node_id) + elif support_type == "pinned": + self.system.add_support_hinged(node_id=node_id) + elif support_type == "roller": + self.system.add_support_roll(node_id=node_id) + + def _resolve_support_type( + self, is_primary: bool = True + ) -> Literal["fixed", "pinned", "roller"]: + """Helper to resolve support type from "simple" to specific type. + + Args: + is_primary (bool): If True, this is the primary (left) support. + If False, this is the secondary (right) support. + + Returns: + Literal["fixed", "pinned", "roller"]: The resolved support type. + For "simple", returns "pinned" if primary, "roller" if secondary. + """ + if self.supports_type != "simple": + return self.supports_type + return "pinned" if is_primary else "roller" + + @overload + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: None = None + ) -> list[int]: ... + + @overload + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: str + ) -> list[int]: ... + + def get_element_ids_of_chord( + self, chord: Literal["top", "bottom"], chord_segment: Optional[str] = None + ) -> list[int]: + """Get element IDs for a chord (top or bottom). + + Args: + chord (Literal["top", "bottom"]): Which chord to query + chord_segment (Optional[str]): If the chord is segmented (dict of segments), + specify which segment to get. If None and chord is segmented, returns + all element IDs from all segments concatenated. + + Returns: + list[int]: Element IDs of the requested chord (segment) + + Raises: + ValueError: If chord is not "top" or "bottom" + KeyError: If chord_segment is specified but doesn't exist in the chord + """ + if chord == "top": + if isinstance(self.top_chord_element_ids, dict): + if chord_segment is None: + all_ids = [] + for ids in self.top_chord_element_ids.values(): + all_ids.extend(ids) + return all_ids + if chord_segment not in self.top_chord_element_ids: + available = list(self.top_chord_element_ids.keys()) + raise KeyError( + f"chord_segment '{chord_segment}' not found. " + f"Available segments: {available}" + ) + return self.top_chord_element_ids[chord_segment] + return self.top_chord_element_ids + + if chord == "bottom": + if isinstance(self.bottom_chord_element_ids, dict): + if chord_segment is None: + all_ids = [] + for ids in self.bottom_chord_element_ids.values(): + all_ids.extend(ids) + return all_ids + if chord_segment not in self.bottom_chord_element_ids: + available = list(self.bottom_chord_element_ids.keys()) + raise KeyError( + f"chord_segment '{chord_segment}' not found. " + f"Available segments: {available}" + ) + return self.bottom_chord_element_ids[chord_segment] + return self.bottom_chord_element_ids + + raise ValueError("chord must be either 'top' or 'bottom'.") + + def apply_q_load_to_top_chord( + self, + q: Union[float, Sequence[float]], + direction: Union[LoadDirection, Sequence[LoadDirection]] = "element", + rotation: Optional[Union[float, Sequence[float]]] = None, + q_perp: Optional[Union[float, Sequence[float]]] = None, + chord_segment: Optional[str] = None, + ) -> None: + """Apply distributed load to all elements in the top chord. + + Args: + q (Union[float, Sequence[float]]): Load magnitude (force/length units) + direction (Union[LoadDirection, Sequence[LoadDirection]]): Load direction. + Options: "element", "x", "y", "parallel", "perpendicular", "angle" + rotation (Optional[Union[float, Sequence[float]]]): Rotation angle in degrees + (used with direction="angle") + q_perp (Optional[Union[float, Sequence[float]]]): Perpendicular load component + chord_segment (Optional[str]): If specified, apply load only to this segment + (for trusses with segmented chords like roof trusses) + """ + element_ids = self.get_element_ids_of_chord( + chord="top", chord_segment=chord_segment + ) + for el_id in element_ids: + self.system.q_load( + element_id=el_id, + q=q, + direction=direction, + rotation=rotation, + q_perp=q_perp, + ) + + def apply_q_load_to_bottom_chord( + self, + q: Union[float, Sequence[float]], + direction: Union[LoadDirection, Sequence[LoadDirection]] = "element", + rotation: Optional[Union[float, Sequence[float]]] = None, + q_perp: Optional[Union[float, Sequence[float]]] = None, + chord_segment: Optional[str] = None, + ) -> None: + """Apply distributed load to all elements in the bottom chord. + + Args: + q (Union[float, Sequence[float]]): Load magnitude (force/length units) + direction (Union[LoadDirection, Sequence[LoadDirection]]): Load direction. + Options: "element", "x", "y", "parallel", "perpendicular", "angle" + rotation (Optional[Union[float, Sequence[float]]]): Rotation angle in degrees + (used with direction="angle") + q_perp (Optional[Union[float, Sequence[float]]]): Perpendicular load component + chord_segment (Optional[str]): If specified, apply load only to this segment + (for trusses with segmented chords like roof trusses) + """ + element_ids = self.get_element_ids_of_chord( + chord="bottom", chord_segment=chord_segment + ) + for el_id in element_ids: + self.system.q_load( + element_id=el_id, + q=q, + direction=direction, + rotation=rotation, + q_perp=q_perp, + ) + + def validate(self) -> bool: + """Validate truss geometry and connectivity. + + Checks for common truss definition issues: + - All node IDs in connectivity lists reference valid nodes + - No duplicate nodes at the same location + - All elements have non-zero length + + Returns: + bool: True if validation passes + + Raises: + ValueError: If validation fails with description of the issue + """ + # Check that all node IDs in connectivity are valid + max_node_id = len(self.nodes) - 1 + + # Helper to validate node ID list + def validate_node_ids( + node_ids: Union[list[int], dict[str, list[int]]], name: str + ) -> None: + if isinstance(node_ids, dict): + for segment_name, ids in node_ids.items(): + for node_id in ids: + if node_id < 0 or node_id > max_node_id: + raise ValueError( + f"{name} segment '{segment_name}' references invalid node ID {node_id}. " + f"Valid range: 0-{max_node_id}" + ) + else: + for node_id in node_ids: + if node_id < 0 or node_id > max_node_id: + raise ValueError( + f"{name} references invalid node ID {node_id}. " + f"Valid range: 0-{max_node_id}" + ) + + validate_node_ids(self.top_chord_node_ids, "top_chord_node_ids") + validate_node_ids(self.bottom_chord_node_ids, "bottom_chord_node_ids") + + for i, (node_a, node_b) in enumerate(self.web_node_pairs): + if node_a < 0 or node_a > max_node_id: + raise ValueError( + f"web_node_pairs[{i}] references invalid node ID {node_a}. " + f"Valid range: 0-{max_node_id}" + ) + if node_b < 0 or node_b > max_node_id: + raise ValueError( + f"web_node_pairs[{i}] references invalid node ID {node_b}. " + f"Valid range: 0-{max_node_id}" + ) + + for i, (node_a, node_b) in enumerate(self.web_verticals_node_pairs): + if node_a < 0 or node_a > max_node_id: + raise ValueError( + f"web_verticals_node_pairs[{i}] references invalid node ID {node_a}. " + f"Valid range: 0-{max_node_id}" + ) + if node_b < 0 or node_b > max_node_id: + raise ValueError( + f"web_verticals_node_pairs[{i}] references invalid node ID {node_b}. " + f"Valid range: 0-{max_node_id}" + ) + + # Check for duplicate node locations (within tolerance) + tolerance = 1e-6 + for i, node_i in enumerate(self.nodes): + for j in range(i + 1, len(self.nodes)): + node_j = self.nodes[j] + dx = abs(node_i.x - node_j.x) + dy = abs(node_i.y - node_j.y) + if dx < tolerance and dy < tolerance: + raise ValueError( + f"Duplicate nodes at position ({node_i.x:.6f}, {node_i.y:.6f}): " + f"node {i} and node {j}" + ) + + # Check for zero-length elements + def check_element_length( + node_a_id: int, node_b_id: int, element_type: str + ) -> None: + node_a = self.nodes[node_a_id] + node_b = self.nodes[node_b_id] + dx = node_b.x - node_a.x + dy = node_b.y - node_a.y + length = np.sqrt(dx**2 + dy**2) + if length < tolerance: + raise ValueError( + f"Zero-length element in {element_type}: nodes {node_a_id} and {node_b_id} " + f"at position ({node_a.x:.6f}, {node_a.y:.6f})" + ) + + # Check chord elements + def check_chord_elements( + node_ids: Union[list[int], dict[str, list[int]]], chord_name: str + ) -> None: + if isinstance(node_ids, dict): + for segment_name, ids in node_ids.items(): + for i in range(len(ids) - 1): + check_element_length( + ids[i], ids[i + 1], f"{chord_name} segment '{segment_name}'" + ) + else: + for i in range(len(node_ids) - 1): + check_element_length(node_ids[i], node_ids[i + 1], chord_name) + + check_chord_elements(self.top_chord_node_ids, "top chord") + check_chord_elements(self.bottom_chord_node_ids, "bottom chord") + + for i, (node_a, node_b) in enumerate(self.web_node_pairs): + check_element_length(node_a, node_b, f"web diagonal {i}") + + for i, (node_a, node_b) in enumerate(self.web_verticals_node_pairs): + check_element_length(node_a, node_b, f"web vertical {i}") + + return True + + def show_structure(self) -> None: + """Display the truss structure using matplotlib.""" + self.system.show_structure() + + +class FlatTruss(Truss): + """Abstract base class for flat (parallel chord) truss structures. + + Flat trusses have parallel top and bottom chords and are divided into + repeating panel units. Specific truss patterns (Howe, Pratt, Warren) + are implemented by subclasses. + + Attributes: + unit_width (float): Width of each panel/bay + end_type (EndType): Configuration of truss ends - "flat", "triangle_down", or "triangle_up" + supports_loc (SupportLoc): Where supports are placed - "bottom_chord", "top_chord", or "both" + min_end_fraction (float): Minimum width of end panels as fraction of unit_width + enforce_even_units (bool): If True, ensure even number of panels for symmetry + n_units (int): Computed number of panel units + end_width (float): Computed width of end panels + """ + + # Data types specific to this truss type + EndType = Literal["flat", "triangle_down", "triangle_up"] + SupportLoc = Literal["bottom_chord", "top_chord", "both"] + + # Additional geometry for this truss type + unit_width: float + end_type: EndType + supports_loc: SupportLoc + + # Additional configuration + min_end_fraction: float + enforce_even_units: bool + + # Computed properties + n_units: int + end_width: float + + @property + @abstractmethod + def type(self) -> str: + return "[Generic] Flat Truss" + + def __init__( + self, + width: float, + height: float, + unit_width: float, + end_type: EndType = "triangle_down", + supports_loc: SupportLoc = "bottom_chord", + min_end_fraction: float = 0.5, + enforce_even_units: bool = True, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + """Initialize a flat truss. + + Args: + width (float): Total span of the truss. Must be positive. + height (float): Height of the truss. Must be positive. + unit_width (float): Width of each panel. Must be positive and less than + width - 2*min_end_fraction*unit_width. + end_type (EndType): End panel configuration. "triangle_down" has diagonals + pointing down at ends, "triangle_up" has diagonals pointing up, + "flat" has vertical end panels. + supports_loc (SupportLoc): Location of supports - "bottom_chord" (typical), + "top_chord" (hanging truss), or "both" (supported at both chords). + min_end_fraction (float): Minimum end panel width as fraction of unit_width. + Must be between 0 and 1. Defaults to 0.5. + enforce_even_units (bool): If True, ensure even number of units for symmetry. + Defaults to True. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If dimensions are invalid or result in negative/zero units + """ + if unit_width <= 0: + raise ValueError(f"unit_width must be positive, got {unit_width}") + if not 0 < min_end_fraction <= 1: + raise ValueError( + f"min_end_fraction must be in (0, 1], got {min_end_fraction}" + ) + + self.unit_width = unit_width + self.end_type = end_type + self.supports_loc = supports_loc + self.min_end_fraction = min_end_fraction + self.enforce_even_units = enforce_even_units + + # Compute number of units + n_units_float = (width - unit_width * 2 * min_end_fraction) / unit_width + if n_units_float < 1: + raise ValueError( + f"Width {width} is too small for unit_width {unit_width} and " + f"min_end_fraction {min_end_fraction}. Would result in {n_units_float:.2f} units." + ) + + self.n_units = int(np.floor(n_units_float)) + if self.enforce_even_units and self.n_units % 2 != 0: + self.n_units -= 1 + + if self.n_units < 2: + raise ValueError( + f"Truss must have at least 2 units. Computed {self.n_units} units. " + f"Reduce unit_width or increase width." + ) + + self.end_width = (width - self.n_units * unit_width) / 2 + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + @abstractmethod + def define_nodes(self) -> None: + pass + + @abstractmethod + def define_connectivity(self) -> None: + pass + + def define_supports(self) -> None: + """Define support locations for flat trusses. + + Default implementation places supports at the ends of the truss. + Assumes single-segment (non-dict) chord node ID lists. + """ + assert isinstance(self.bottom_chord_node_ids, list) + assert isinstance(self.top_chord_node_ids, list) + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids) + top_left = min(self.top_chord_node_ids) + top_right = max(self.top_chord_node_ids) + if self.supports_loc in ["bottom_chord", "both"]: + self.support_definitions[bottom_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[bottom_right] = self._resolve_support_type( + is_primary=False + ) + if self.supports_loc in ["top_chord", "both"]: + self.support_definitions[top_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[top_right] = self._resolve_support_type( + is_primary=False + ) + + +class RoofTruss(Truss): + """Abstract base class for peaked roof truss structures. + + Roof trusses have sloped top chords meeting at a peak, forming a triangular + profile. Height is computed from span and roof pitch. Specific truss patterns + (King Post, Queen Post, Fink, etc.) are implemented by subclasses. + + Attributes: + overhang_length (float): Length of roof overhang beyond supports + roof_pitch_deg (float): Roof pitch angle in degrees + roof_pitch (float): Roof pitch angle in radians (computed) + """ + + # Additional geometry for this truss type + overhang_length: float + roof_pitch_deg: float + + # Computed properties + roof_pitch: float + + @property + @abstractmethod + def type(self) -> str: + return "[Generic] Roof Truss" + + def __init__( + self, + width: float, + roof_pitch_deg: float, + overhang_length: float = 0.0, + top_chord_section: Optional[SectionProps] = None, + bottom_chord_section: Optional[SectionProps] = None, + web_section: Optional[SectionProps] = None, + web_verticals_section: Optional[SectionProps] = None, + ): + """Initialize a roof truss. + + Args: + width (float): Total span of the truss (building width). Must be positive. + roof_pitch_deg (float): Roof pitch angle in degrees. Must be positive and + less than 90 degrees. Common values: 18-45 degrees. + overhang_length (float): Length of roof overhang beyond the supports. + Must be non-negative. Defaults to 0.0. + top_chord_section (Optional[SectionProps]): Section properties for top chord + bottom_chord_section (Optional[SectionProps]): Section properties for bottom chord + web_section (Optional[SectionProps]): Section properties for diagonal webs + web_verticals_section (Optional[SectionProps]): Section properties for vertical webs + + Raises: + ValueError: If dimensions or angles are invalid + """ + if roof_pitch_deg <= 0 or roof_pitch_deg >= 90: + raise ValueError( + f"roof_pitch_deg must be between 0 and 90, got {roof_pitch_deg}" + ) + if overhang_length < 0: + raise ValueError( + f"overhang_length must be non-negative, got {overhang_length}" + ) + + self.roof_pitch_deg = roof_pitch_deg + self.roof_pitch = np.radians(roof_pitch_deg) + height = (width / 2) * np.tan(self.roof_pitch) + self.overhang_length = overhang_length + super().__init__( + width, + height, + top_chord_section, + bottom_chord_section, + web_section, + web_verticals_section, + ) + + @abstractmethod + def define_nodes(self) -> None: + pass + + @abstractmethod + def define_connectivity(self) -> None: + pass + + def define_supports(self) -> None: + """Define support locations for roof trusses. + + Default implementation places supports at the ends of the bottom chord. + Assumes single-segment (non-dict) bottom chord node ID list. + """ + assert isinstance(self.bottom_chord_node_ids, list) + + bottom_left = 0 + bottom_right = max(self.bottom_chord_node_ids) + self.support_definitions[bottom_left] = self._resolve_support_type( + is_primary=True + ) + self.support_definitions[bottom_right] = self._resolve_support_type( + is_primary=False + ) diff --git a/anastruct/types.py b/anastruct/types.py index 22c9d90f..90f596fa 100644 --- a/anastruct/types.py +++ b/anastruct/types.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, Literal, Sequence, Union +from typing import TYPE_CHECKING, Dict, Literal, Sequence, TypedDict, Union import numpy as np @@ -15,3 +15,12 @@ Spring = Dict[Literal[1, 2], float] SupportDirection = Literal["x", "y", "1", "2", 1, 2] VertexLike = Union[Sequence[Union[float, int]], np.ndarray, "Vertex"] + +SectionProps = TypedDict( + "SectionProps", + { + "EI": float, + "EA": float, + "g": float, + }, +) diff --git a/tests/test_truss.py b/tests/test_truss.py new file mode 100644 index 00000000..3137e16d --- /dev/null +++ b/tests/test_truss.py @@ -0,0 +1,669 @@ +"""Tests for truss generator functionality. + +Tests cover: +- Unit tests for each truss type (geometry validation) +- Integration tests (solve and verify structural behavior) +- Factory function +- Validation method +- Edge cases and error handling +""" + +import numpy as np +from pytest import approx, raises + +from anastruct.preprocess.truss import ( + AtticRoofTruss, + DoubleFinkRoofTruss, + DoubleHoweRoofTruss, + FanRoofTruss, + FinkRoofTruss, + HoweFlatTruss, + HoweRoofTruss, + KingPostRoofTruss, + ModifiedFanRoofTruss, + ModifiedQueenPostRoofTruss, + PrattFlatTruss, + PrattRoofTruss, + QueenPostRoofTruss, + WarrenFlatTruss, + create_truss, +) +from anastruct.vertex import Vertex + + +def describe_flat_truss_types(): + """Unit tests for flat truss types.""" + + def describe_howe_flat_truss(): + def it_creates_valid_geometry(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Howe Flat Truss" + assert truss.width == 20 + assert truss.height == 2.5 + assert truss.n_units == 8 + assert len(truss.nodes) == 20 + assert truss.validate() + + def it_has_correct_connectivity(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Should have bottom chord, top chord, web diagonals, and web verticals + # 8 units, bottom chord has more nodes + assert len(truss.bottom_chord_node_ids) == 11 + assert len(truss.top_chord_node_ids) == 9 + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def it_enforces_even_units_by_default(): + # Width that would give 9 units, should round down to 8 + truss = HoweFlatTruss(width=19, height=2.5, unit_width=2.0) + assert truss.n_units == 8 + assert truss.n_units % 2 == 0 + + def it_validates_dimensions(): + with raises(ValueError, match="too small"): + HoweFlatTruss(width=-5, height=2.5, unit_width=2.0) + + with raises(ValueError, match="must be positive"): + HoweFlatTruss(width=20, height=-2.5, unit_width=2.0) + + with raises(ValueError, match="unit_width must be positive"): + HoweFlatTruss(width=20, height=2.5, unit_width=-1.0) + + def it_validates_width_to_unit_width_ratio(): + with raises(ValueError, match="too small"): + HoweFlatTruss(width=5, height=2.5, unit_width=20) + + def describe_pratt_flat_truss(): + def it_creates_valid_geometry(): + truss = PrattFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Pratt Flat Truss" + assert truss.n_units == 8 + assert truss.validate() + + def it_has_different_diagonal_pattern_than_howe(): + howe = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + pratt = PrattFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Same number of nodes and elements, but different connectivity + assert len(howe.nodes) == len(pratt.nodes) + # Web diagonals should be different (opposite slope direction) + assert howe.web_node_pairs != pratt.web_node_pairs + + def describe_warren_flat_truss(): + def it_creates_valid_geometry(): + truss = WarrenFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.type == "Warren Flat Truss" + assert truss.validate() + + def it_has_no_vertical_web_members(): + truss = WarrenFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Warren trusses typically have no vertical web members + assert len(truss.web_verticals_node_pairs) == 0 + + def it_supports_different_end_types(): + # Warren supports triangle_down and triangle_up + truss_down = WarrenFlatTruss( + width=20, height=2.5, unit_width=2.0, end_type="triangle_down" + ) + truss_up = WarrenFlatTruss( + width=20, height=2.5, unit_width=2.0, end_type="triangle_up" + ) + + assert truss_down.validate() + assert truss_up.validate() + # Both end types are valid for Warren trusses + assert len(truss_down.nodes) > 0 + assert len(truss_up.nodes) > 0 + + +def describe_roof_truss_types(): + """Unit tests for roof truss types.""" + + def describe_king_post_roof_truss(): + def it_creates_valid_geometry(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + assert truss.type == "King Post Roof Truss" + assert truss.width == 10 + assert truss.roof_pitch_deg == 30 + assert truss.validate() + + def it_computes_height_from_pitch(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + expected_height = (10 / 2) * np.tan(np.radians(30)) + assert truss.height == approx(expected_height) + + def it_has_single_center_vertical(): + truss = KingPostRoofTruss(width=10, roof_pitch_deg=30) + + # King post has 1 vertical, no diagonals + assert len(truss.web_verticals_node_pairs) == 1 + assert len(truss.web_node_pairs) == 0 + + def it_validates_roof_pitch(): + with raises(ValueError, match="roof_pitch_deg must be between 0 and 90"): + KingPostRoofTruss(width=10, roof_pitch_deg=95) + + with raises(ValueError, match="roof_pitch_deg must be between 0 and 90"): + KingPostRoofTruss(width=10, roof_pitch_deg=-10) + + def it_supports_overhang(): + truss_no_overhang = KingPostRoofTruss(width=10, roof_pitch_deg=30) + truss_with_overhang = KingPostRoofTruss( + width=10, roof_pitch_deg=30, overhang_length=0.5 + ) + + # Overhang adds nodes + assert len(truss_with_overhang.nodes) > len(truss_no_overhang.nodes) + assert truss_with_overhang.validate() + + def describe_queen_post_roof_truss(): + def it_creates_valid_geometry(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Queen Post Roof Truss" + assert truss.validate() + + def it_has_correct_web_configuration(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + # Queen post has 2 diagonals and 1 center vertical + assert len(truss.web_node_pairs) == 2 + assert len(truss.web_verticals_node_pairs) == 1 + + def it_has_center_vertical_to_peak(): + """Test for issue #8 fix - center vertical should connect to peak.""" + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + # Get the center vertical connection + center_vertical = truss.web_verticals_node_pairs[0] + + # Node 1 is center bottom, node 4 is peak + assert center_vertical == (1, 4) + + # Verify these nodes are actually center bottom and peak + center_bottom = truss.nodes[1] + peak = truss.nodes[4] + + assert center_bottom.x == approx(truss.width / 2) + assert center_bottom.y == approx(0) + assert peak.x == approx(truss.width / 2) + assert peak.y == approx(truss.height) + + def describe_fink_roof_truss(): + def it_creates_valid_geometry(): + truss = FinkRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Fink Roof Truss" + assert truss.validate() + + def it_has_w_shaped_web_pattern(): + truss = FinkRoofTruss(width=15, roof_pitch_deg=40) + + # Fink has 4 diagonals forming W pattern + assert len(truss.web_node_pairs) == 4 + assert len(truss.web_verticals_node_pairs) == 0 + + def describe_howe_roof_truss(): + def it_creates_valid_geometry(): + truss = HoweRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Howe Roof Truss" + assert truss.validate() + + def it_has_vertical_and_diagonal_web_members(): + truss = HoweRoofTruss(width=12, roof_pitch_deg=35) + + # Howe has both verticals and diagonals + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def describe_pratt_roof_truss(): + def it_creates_valid_geometry(): + truss = PrattRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Pratt Roof Truss" + assert truss.validate() + + def it_has_vertical_and_diagonal_web_members(): + truss = PrattRoofTruss(width=12, roof_pitch_deg=35) + + # Pratt has both verticals and diagonals + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def it_has_different_diagonal_pattern_than_howe(): + howe = HoweRoofTruss(width=12, roof_pitch_deg=35) + pratt = PrattRoofTruss(width=12, roof_pitch_deg=35) + + # Same structure but different web patterns + assert len(howe.nodes) == len(pratt.nodes) + # Diagonals slope in opposite directions + assert howe.web_node_pairs != pratt.web_node_pairs + + def describe_fan_roof_truss(): + def it_creates_valid_geometry(): + truss = FanRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Fan Roof Truss" + assert truss.validate() + + def it_has_fan_pattern_web_members(): + truss = FanRoofTruss(width=15, roof_pitch_deg=40) + + # Fan has diagonals radiating from bottom chord + assert len(truss.web_node_pairs) > 0 + assert len(truss.web_verticals_node_pairs) > 0 + + def describe_modified_queen_post_roof_truss(): + def it_creates_valid_geometry(): + truss = ModifiedQueenPostRoofTruss(width=12, roof_pitch_deg=35) + + assert truss.type == "Modified Queen Post Roof Truss" + assert truss.validate() + + def it_has_more_web_members_than_standard_queen_post(): + modified = ModifiedQueenPostRoofTruss(width=12, roof_pitch_deg=35) + standard = QueenPostRoofTruss(width=12, roof_pitch_deg=35) + + # Modified version has more web members for enhanced load distribution + total_modified = len(modified.web_node_pairs) + len( + modified.web_verticals_node_pairs + ) + total_standard = len(standard.web_node_pairs) + len( + standard.web_verticals_node_pairs + ) + + assert total_modified > total_standard + + def describe_double_fink_roof_truss(): + def it_creates_valid_geometry(): + truss = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + + assert truss.type == "Double Fink Roof Truss" + assert truss.validate() + + def it_has_more_members_than_standard_fink(): + double = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + standard = FinkRoofTruss(width=20, roof_pitch_deg=35) + + # Double Fink has more nodes and elements + assert len(double.nodes) > len(standard.nodes) + assert len(double.web_node_pairs) > len(standard.web_node_pairs) + + def it_has_two_w_patterns(): + truss = DoubleFinkRoofTruss(width=20, roof_pitch_deg=35) + + # Double Fink should have 8 diagonals (two W patterns) + assert len(truss.web_node_pairs) == 8 + assert len(truss.web_verticals_node_pairs) == 0 + + def describe_double_howe_roof_truss(): + def it_creates_valid_geometry(): + truss = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + + assert truss.type == "Double Howe Roof Truss" + assert truss.validate() + + def it_has_more_verticals_and_diagonals_than_standard(): + double = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + standard = HoweRoofTruss(width=20, roof_pitch_deg=35) + + # Double version has enhanced web pattern + assert len(double.web_node_pairs) > len(standard.web_node_pairs) + assert len(double.web_verticals_node_pairs) > len( + standard.web_verticals_node_pairs + ) + + def it_has_five_verticals(): + truss = DoubleHoweRoofTruss(width=20, roof_pitch_deg=35) + + # Double Howe has 5 vertical members + assert len(truss.web_verticals_node_pairs) == 5 + + def describe_modified_fan_roof_truss(): + def it_creates_valid_geometry(): + truss = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + + assert truss.type == "Modified Fan Roof Truss" + assert truss.validate() + + def it_has_enhanced_web_pattern(): + modified = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + standard = FanRoofTruss(width=15, roof_pitch_deg=40) + + # Modified fan has more web members + total_modified = len(modified.web_node_pairs) + len( + modified.web_verticals_node_pairs + ) + total_standard = len(standard.web_node_pairs) + len( + standard.web_verticals_node_pairs + ) + + assert total_modified > total_standard + + def it_has_six_diagonals_and_three_verticals(): + truss = ModifiedFanRoofTruss(width=15, roof_pitch_deg=40) + + # Modified fan specific configuration + assert len(truss.web_node_pairs) == 6 + assert len(truss.web_verticals_node_pairs) == 3 + + def describe_attic_roof_truss(): + def it_creates_valid_geometry(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + assert truss.type == "Attic Roof Truss" + assert truss.validate() + + def it_validates_attic_width(): + with raises(ValueError, match="attic_width.*must be less than"): + AtticRoofTruss(width=10, roof_pitch_deg=30, attic_width=15) + + with raises(ValueError, match="attic_width must be positive"): + AtticRoofTruss(width=10, roof_pitch_deg=30, attic_width=-5) + + def it_computes_attic_geometry(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + # Wall position should be at edge of attic + assert truss.wall_x == approx((12 - 6) / 2) + + # Ceiling and wall intersect by default + assert truss.wall_ceiling_intersect or not truss.wall_ceiling_intersect + + def it_supports_custom_attic_height(): + # Use attic_height that's higher than default wall intersection + truss = AtticRoofTruss( + width=12, roof_pitch_deg=35, attic_width=6, attic_height=3.0 + ) + + assert truss.attic_height == approx(3.0) + assert truss.validate() + + def it_has_segmented_top_chord_with_ceiling(): + truss = AtticRoofTruss(width=12, roof_pitch_deg=35, attic_width=6) + + # Attic truss has three segments: left, right, and ceiling + assert isinstance(truss.top_chord_node_ids, dict) + assert "left" in truss.top_chord_node_ids + assert "right" in truss.top_chord_node_ids + assert "ceiling" in truss.top_chord_node_ids + + +def describe_factory_function(): + """Tests for create_truss factory function.""" + + def it_creates_truss_by_name(): + truss = create_truss("howe", width=20, height=2.5, unit_width=2.0) + + assert isinstance(truss, HoweFlatTruss) + assert truss.type == "Howe Flat Truss" + + def it_handles_case_insensitive_names(): + trusses = [ + create_truss("howe", width=20, height=2.5, unit_width=2.0), + create_truss("HOWE", width=20, height=2.5, unit_width=2.0), + create_truss("Howe", width=20, height=2.5, unit_width=2.0), + ] + + for truss in trusses: + assert isinstance(truss, HoweFlatTruss) + + def it_handles_different_name_separators(): + # Underscores, hyphens, spaces should all work + names = ["king_post", "king-post", "kingpost"] + + for name in names: + truss = create_truss(name, width=10, roof_pitch_deg=30) + assert isinstance(truss, KingPostRoofTruss) + + def it_creates_all_truss_types(): + # Test that all truss types can be created via factory + test_cases = [ + ("howe", HoweFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ("pratt", PrattFlatTruss, {"width": 20, "height": 2.5, "unit_width": 2.0}), + ( + "warren", + WarrenFlatTruss, + {"width": 20, "height": 2.5, "unit_width": 2.0}, + ), + ("king_post", KingPostRoofTruss, {"width": 10, "roof_pitch_deg": 30}), + ("queen_post", QueenPostRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("fink", FinkRoofTruss, {"width": 15, "roof_pitch_deg": 40}), + ("howe_roof", HoweRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("pratt_roof", PrattRoofTruss, {"width": 12, "roof_pitch_deg": 35}), + ("fan", FanRoofTruss, {"width": 15, "roof_pitch_deg": 40}), + ( + "attic", + AtticRoofTruss, + {"width": 12, "roof_pitch_deg": 35, "attic_width": 6}, + ), + ] + + for name, expected_class, kwargs in test_cases: + truss = create_truss(name, **kwargs) + assert isinstance(truss, expected_class) + assert truss.validate() + + def it_raises_error_for_invalid_type(): + with raises(ValueError, match="Unknown truss type"): + create_truss("invalid_truss_type", width=10, height=2) + + def it_provides_helpful_error_with_available_types(): + try: + create_truss("nonexistent", width=10, height=2) + assert False, "Should have raised ValueError" + except ValueError as e: + # Error should list available types + assert "Available types:" in str(e) + assert "howe" in str(e).lower() + + +def describe_validate_method(): + """Tests for truss validation method.""" + + def it_validates_correct_geometry(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + assert truss.validate() is True + + def it_catches_invalid_node_ids_in_connectivity(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Corrupt connectivity with invalid node ID + truss.web_node_pairs.append((999, 1000)) + + with raises(ValueError, match="invalid node ID"): + truss.validate() + + def it_catches_duplicate_nodes(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Add duplicate node at same location as node 0 + original = truss.nodes[0] + truss.nodes.append(Vertex(original.x, original.y)) + truss.web_node_pairs.append((0, len(truss.nodes) - 1)) + + with raises(ValueError, match="Duplicate nodes"): + truss.validate() + + def it_catches_zero_length_elements(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Create zero-length element by modifying existing node + # Make the second node same as first node + # This will be caught as duplicate nodes + truss.nodes[1] = Vertex(truss.nodes[0].x, truss.nodes[0].y) + + with raises(ValueError, match="Duplicate nodes"): + truss.validate() + + +def describe_integration_tests(): + """Integration tests - system integration and load application.""" + + def describe_system_integration(): + def it_creates_valid_system_elements(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Verify SystemElements was created and populated + assert truss.system is not None + assert len(truss.system.element_map) > 0 + assert len(truss.system.node_map) > 0 + + def it_has_correct_element_count(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Count expected elements + expected_bottom = len(truss.bottom_chord_node_ids) - 1 + expected_top = len(truss.top_chord_node_ids) - 1 + expected_webs = len(truss.web_node_pairs) + expected_verticals = len(truss.web_verticals_node_pairs) + expected_total = ( + expected_bottom + expected_top + expected_webs + expected_verticals + ) + + assert len(truss.system.element_map) == expected_total + + def it_applies_loads_to_chords(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Apply load (don't solve, just verify load application works) + truss.apply_q_load_to_top_chord(q=-10, direction="y") + + # Verify loads were applied to top chord elements + top_chord_ids = truss.get_element_ids_of_chord("top") + for el_id in top_chord_ids: + element = truss.system.element_map[el_id] + # Element should have a q_load attribute after applying + assert hasattr(element, "q_load") + + def describe_roof_truss_integration(): + def it_applies_loads_to_chord_segments(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=30) + + # Apply loads to specific segments + truss.apply_q_load_to_top_chord(q=-5, direction="y", chord_segment="left") + truss.apply_q_load_to_top_chord(q=-5, direction="y", chord_segment="right") + + # Verify loads were applied + left_ids = truss.get_element_ids_of_chord("top", "left") + right_ids = truss.get_element_ids_of_chord("top", "right") + + for el_id in left_ids + right_ids: + element = truss.system.element_map[el_id] + assert hasattr(element, "q_load") + + def it_validates_after_load_application(): + truss = QueenPostRoofTruss(width=12, roof_pitch_deg=30) + + # Validate before loading + assert truss.validate() + + # Apply load + truss.apply_q_load_to_top_chord(q=-5, direction="y") + + # Should still validate after loading + assert truss.validate() + + def describe_different_support_types(): + def it_has_default_simple_supports(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Default is simple = pinned + roller + support_defs = truss.support_definitions + support_types = list(support_defs.values()) + + assert "pinned" in support_types + assert "roller" in support_types + + def it_has_two_support_points(): + truss = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Should have exactly 2 supports at the ends + assert len(truss.support_definitions) == 2 + + +def describe_chord_segment_functionality(): + """Tests for segmented chord access.""" + + def it_gets_all_elements_when_no_segment_specified(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + all_top = truss.get_element_ids_of_chord("top") + + # Should return all top chord elements + assert len(all_top) > 0 + + def it_gets_specific_segment(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + left_elements = truss.get_element_ids_of_chord("top", "left") + right_elements = truss.get_element_ids_of_chord("top", "right") + + # Should get different elements + assert len(left_elements) > 0 + assert len(right_elements) > 0 + assert set(left_elements).isdisjoint(set(right_elements)) + + def it_raises_error_for_invalid_segment(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + with raises(KeyError, match="chord_segment.*not found"): + truss.get_element_ids_of_chord("top", "nonexistent_segment") + + def it_shows_available_segments_in_error(): + truss = QueenPostRoofTruss(width=10, roof_pitch_deg=30) + + try: + truss.get_element_ids_of_chord("top", "invalid") + assert False, "Should have raised KeyError" + except KeyError as e: + # Should list available segments + assert "Available segments:" in str(e) + assert "left" in str(e) + assert "right" in str(e) + + +def describe_edge_cases(): + """Edge case tests.""" + + def it_handles_minimum_viable_dimensions(): + # Smallest practical truss + truss = HoweFlatTruss(width=6, height=1, unit_width=2, min_end_fraction=0.5) + + assert truss.n_units >= 2 + assert truss.validate() + + def it_handles_very_steep_roof_pitch(): + # Very steep but valid pitch + truss = KingPostRoofTruss(width=10, roof_pitch_deg=85) + + assert truss.height > truss.width # Height > width for steep pitch + assert truss.validate() + + def it_handles_very_shallow_roof_pitch(): + # Very shallow but valid pitch + truss = KingPostRoofTruss(width=10, roof_pitch_deg=5) + + assert truss.height < truss.width / 10 # Very shallow + assert truss.validate() + + def it_creates_multiple_independent_instances(): + # Test that instances don't share mutable state (issue #9 fix) + truss1 = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + truss2 = HoweFlatTruss(width=20, height=2.5, unit_width=2.0) + + # Modify one truss + truss1.nodes.append(Vertex(100, 100)) + + # Should not affect the other + assert len(truss1.nodes) != len(truss2.nodes) + assert truss2.validate()