Source code for fem2d.utils.draw_structure

"""
DrawStructure module implementing visualization of 2D frame structures using Matplotlib.
Supports drawing support symbols, point loads, moments, distributed loads, and deformed shapes.
"""

import numpy as np

try:
    import matplotlib.pyplot as plt
    from matplotlib.patches import Polygon, Circle, FancyArrowPatch, Arc
    from matplotlib.collections import LineCollection
except ImportError:  # pragma: no cover - optional dependency for docs builds
    plt = None
    Polygon = Circle = FancyArrowPatch = Arc = LineCollection = None


[docs] class DrawStructure: """ Visualizes the structural model geometry, boundary conditions, applied loads, and deformed shape. Attributes ---------- structure : Structure The Structure object to draw. scale : float Displacement magnification factor for drawing deformed shapes. arrow_scale : float Scaling factor for loading arrows relative to the characteristic span of the structure. """ def __init__(self, structure, scale=1.0, arrow_scale=0.1): """ Initialize the structure plotter. Parameters ---------- structure : Structure The Structure object to draw. scale : float, optional Displacement magnification factor. Defaults to 1.0. arrow_scale : float, optional Loading arrows scale factor. Defaults to 0.1. """ self.structure = structure self.scale = scale self.arrow_scale = arrow_scale # fraction of structure span def _get_span(self): """ Return the characteristic length (span) of the structure for scaling symbols. Returns ------- float The characteristic length. """ x = [node.x for node in self.structure.nodes.values()] y = [node.y for node in self.structure.nodes.values()] span = max(max(x) - min(x), max(y) - min(y)) if span == 0: span = 1.0 return span def _draw_support(self, node, support_type, span): """ Draw boundary condition (support) symbol at a node based on fixity. Parameters ---------- node : Node The Node object where support is located. support_type : list of bool Fixity condition list [ux, uy, rz]. span : float Characteristic structure span for scaling the support symbol. """ # Support size relative to span size = 0.03 * span x, y = node.x, node.y # Determine support type from node.support list ux, uy, rz = node.support # Fixed (all DOFs fixed) → filled triangle at base if ux and uy and rz: # Triangle pointing downward from node triangle = Polygon( [(x - size, y), (x + size, y), (x, y - 1.5 * size)], closed=True, facecolor="lightgray", edgecolor="black", linewidth=1, ) plt.gca().add_patch(triangle) # Pinned (ux, uy fixed, rz free) → triangle + circle elif ux and uy and not rz: # Triangle triangle = Polygon( [(x - size, y), (x + size, y), (x, y - 1.5 * size)], closed=True, facecolor="lightgray", edgecolor="black", linewidth=1, ) plt.gca().add_patch(triangle) # Circle at top circle = Circle( (x, y - 0.1 * size), radius=0.2 * size, facecolor="white", edgecolor="black", linewidth=1, ) plt.gca().add_patch(circle) # Roller (only one translational DOF fixed, e.g., uy fixed) → two small circles under node else: # Two wheels (circles) wheel_radius = 0.15 * size circle1 = Circle( (x - 0.3 * size, y - wheel_radius), radius=wheel_radius, facecolor="gray", edgecolor="black", ) circle2 = Circle( (x + 0.3 * size, y - wheel_radius), radius=wheel_radius, facecolor="gray", edgecolor="black", ) plt.gca().add_patch(circle1) plt.gca().add_patch(circle2) # Small triangle to connect to node plt.plot([x, x - 0.3 * size], [y, y - wheel_radius], "k-", linewidth=1) plt.plot([x, x + 0.3 * size], [y, y - wheel_radius], "k-", linewidth=1) def _draw_loads(self, span): """ Draw point loads, moments, and distributed loads acting on the structure. Parameters ---------- span : float Characteristic structure span for scaling the load arrows and symbols. """ from fem2d.loads import PointLoad, ElementPointLoad, DistributedLoad # Determine max force for scaling arrows max_force = 0 # Check node loads and structure loads for node in self.structure.nodes.values(): fx, fy, mz = node.load max_force = max(max_force, abs(fx), abs(fy)) for load in self.structure.loads: if isinstance(load, PointLoad): max_force = max(max_force, abs(load.fx), abs(load.fy)) elif isinstance(load, ElementPointLoad): max_force = max(max_force, abs(load.px), abs(load.py)) elif isinstance(load, DistributedLoad): el = load.element if load.wx != 0: max_force = max(max_force, abs(load.wx * el.length)) if load.wy != 0: max_force = max(max_force, abs(load.wy * el.length)) # Draw distributed loads (UDLs) - transform from local to global coordinates for load in self.structure.loads: if isinstance(load, DistributedLoad): el = load.element c, s = el.cos, el.sin n_arrows = 10 # Reduced from 11 for better clarity x_i, y_i = el.node_i.x, el.node_i.y x_j, y_j = el.node_j.x, el.node_j.y # Collect arrow tip positions for drawing connecting line arrow_tips_x = [] arrow_tips_y = [] arrow_head_extension = 0.03 * span # Extension to account for arrow head for t in np.linspace(0, 1, n_arrows): # Position along element x = x_i + t * (x_j - x_i) y = y_i + t * (y_j - y_i) # Transform local load (wx, wy) to global (fx, gy) # Global: fx = wx*c - wy*s, fy = wx*s + wy*c fx_local = load.wx fy_local = load.wy fx_global = fx_local * c - fy_local * s fy_global = fx_local * s + fy_local * c # Compute arrow length magnitude = np.hypot(fx_global, fy_global) if magnitude > 0: length = self.arrow_scale * span * 0.5 # Adjusted scale dx = fx_global / magnitude * length dy = fy_global / magnitude * length else: dx = dy = 0 if magnitude > 0: # Account for arrow head extending beyond endpoint direction_x = fx_global / magnitude direction_y = fy_global / magnitude # Visual tip includes the arrow head extension arrow_tip_x = x + dx + direction_x * arrow_head_extension arrow_tip_y = y + dy + direction_y * arrow_head_extension arrow_tips_x.append(arrow_tip_x) arrow_tips_y.append(arrow_tip_y) plt.arrow( x, y, dx, dy, head_width=0.025 * span, head_length=0.025 * span, fc="green", ec="green", alpha=0.8, linewidth=1.0 ) # Draw connecting line through arrow tips to show distributed load if len(arrow_tips_x) > 1: plt.plot(arrow_tips_x, arrow_tips_y, 'g--', linewidth=1.0, alpha=0.8) # Draw point loads at nodes (from structure.loads PointLoad objects) arrow_len = self.arrow_scale * span for load in self.structure.loads: if isinstance(load, PointLoad): # PointLoad is already in global coordinates fx, fy, mz = load.fx, load.fy, load.mz node = load.node # Draw force if fx != 0 or fy != 0: magnitude = np.hypot(fx, fy) min_length = 1 * arrow_len # Minimum arrow length for visibility length = max( min_length, arrow_len * (magnitude / max_force) if max_force != 0 else arrow_len ) dx = fx / magnitude * length dy = fy / magnitude * length # Draw arrow (includes line + head) arrow = FancyArrowPatch( (node.x, node.y), (node.x + dx, node.y + dy), arrowstyle="->", color="red", linewidth=3, mutation_scale=25 ) plt.gca().add_patch(arrow) # Draw moment if mz != 0: r = 0.05 * span if mz > 0: start_angle, end_angle = 0, 90 else: start_angle, end_angle = 90, 0 arc = Arc( (node.x, node.y), 2 * r, 2 * r, theta1=start_angle, theta2=end_angle, edgecolor="red", linewidth=2 ) plt.gca().add_patch(arc) angle = np.radians(end_angle) tip_x = node.x + r * np.cos(angle) tip_y = node.y + r * np.sin(angle) arrowhead = Polygon( [ (tip_x, tip_y), (tip_x - 0.01 * span, tip_y - 0.01 * span), (tip_x + 0.01 * span, tip_y - 0.01 * span), ], closed=True, facecolor="red", ) plt.gca().add_patch(arrowhead) # Draw element point loads - transform from local to global coordinates for load in self.structure.loads: if isinstance(load, ElementPointLoad): el = load.element c, s = el.cos, el.sin # Position of load on element t = load.x / el.length x = el.node_i.x + t * (el.node_j.x - el.node_i.x) y = el.node_i.y + t * (el.node_j.y - el.node_i.y) # Transform local load (px, py) to global (fx, fy) px_local = load.px py_local = load.py fx_global = px_local * c - py_local * s fy_global = px_local * s + py_local * c # Draw force if fx_global != 0 or fy_global != 0: magnitude = np.hypot(fx_global, fy_global) length = ( arrow_len * (magnitude / max_force) if max_force != 0 else arrow_len ) dx = fx_global / magnitude * length dy = fy_global / magnitude * length # Draw arrow (includes line + head) arrow = FancyArrowPatch( (x, y), (x + dx, y + dy), arrowstyle="->", color="orange", linewidth=3, mutation_scale=25 ) plt.gca().add_patch(arrow) # Draw moment (if any) if load.mz != 0: r = 0.05 * span if load.mz > 0: start_angle, end_angle = 0, 90 else: start_angle, end_angle = 90, 0 arc = Arc( (x, y), 2 * r, 2 * r, theta1=start_angle, theta2=end_angle, edgecolor="orange", linewidth=2 ) plt.gca().add_patch(arc) angle = np.radians(end_angle) tip_x = x + r * np.cos(angle) tip_y = y + r * np.sin(angle) arrowhead = Polygon( [ (tip_x, tip_y), (tip_x - 0.01 * span, tip_y - 0.01 * span), (tip_x + 0.01 * span, tip_y - 0.01 * span), ], closed=True, facecolor="orange", ) plt.gca().add_patch(arrowhead) # Draw node loads from node.load attribute for node in self.structure.nodes.values(): fx, fy, mz = node.load # Force if fx != 0 or fy != 0: magnitude = np.hypot(fx, fy) length = ( arrow_len * (magnitude / max_force) if max_force != 0 else arrow_len ) dx = fx / magnitude * length dy = fy / magnitude * length # Draw arrow (includes line + head) arrow = FancyArrowPatch( (node.x, node.y), (node.x + dx, node.y + dy), arrowstyle="->", color="red", linewidth=3, mutation_scale=25 ) plt.gca().add_patch(arrow) # Moment if mz != 0: r = 0.05 * span if mz > 0: start_angle, end_angle = 0, 90 else: start_angle, end_angle = 90, 0 arc = Arc( (node.x, node.y), 2 * r, 2 * r, theta1=start_angle, theta2=end_angle, edgecolor="red", linewidth=2 ) plt.gca().add_patch(arc) angle = np.radians(end_angle) tip_x = node.x + r * np.cos(angle) tip_y = node.y + r * np.sin(angle) arrowhead = Polygon( [ (tip_x, tip_y), (tip_x - 0.01 * span, tip_y - 0.01 * span), (tip_x + 0.01 * span, tip_y - 0.01 * span), ], closed=True, facecolor="red", ) plt.gca().add_patch(arrowhead)
[docs] def draw(self, show_undeformed=True, show_deformed=True, n_points=20): """ Draw the structure plot (geometry, loads, boundary conditions, displacements) using Matplotlib. Parameters ---------- show_undeformed : bool, optional Whether to draw the undeformed structure. Defaults to True. show_deformed : bool, optional Whether to draw the deformed structure. Defaults to True. n_points : int, optional Number of points for plotting deformed shape curves. Defaults to 20. """ if plt is None: raise ImportError("matplotlib is required to draw structures") plt.figure(figsize=(10, 8)) ax = plt.gca() span = self._get_span() # Undeformed (blue) if show_undeformed: for el in self.structure.elements.values(): x = [el.node_i.x, el.node_j.x] y = [el.node_i.y, el.node_j.y] plt.plot( x, y, "b-", linewidth=1, label="Undeformed" if el.id == 1 else "" ) # Deformed (red) if show_deformed and self.structure.disp is not None: for el in self.structure.elements.values(): points = el.deformed_shape_points( self.structure.disp, n_points, self.scale ) x_def = [p[0] for p in points] y_def = [p[1] for p in points] plt.plot( x_def, y_def, "r-", linewidth=1.5, label="Deformed" if el.id == 1 else "", ) # Nodes for node in self.structure.nodes.values(): plt.plot(node.x, node.y, "ko", markersize=4) plt.text(node.x, node.y, f" {node.id}", ha="left", va="bottom", fontsize=8) # Supports for node in self.structure.nodes.values(): if any(node.support): self._draw_support(node, node.support, span) # Loads self._draw_loads(span) # Legend if show_undeformed and show_deformed: plt.legend() plt.xlabel("x") plt.ylabel("y") plt.title("Structure: Undeformed (blue) and Deformed (red)") plt.axis("equal") plt.grid(True) plt.show()