From 0a010be1052fa7f96e2767d2443420a295deef55 Mon Sep 17 00:00:00 2001 From: Soham Date: Fri, 5 Jun 2026 21:57:45 +0530 Subject: [PATCH 1/3] Showing Loads on plots for Load Case/Combinations. --- .../plate_girder/plot_generator.py | 147 +++++++++++++++++- src/osdagbridge/desktop/ui/mpl_plot_widget.py | 28 +++- src/osdagbridge/desktop/ui/template_page.py | 2 +- .../desktop/ui/utils/toolbar_controller.py | 10 ++ 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py index 4bc798ba8..55683ffc2 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py @@ -158,6 +158,107 @@ def find_component(name): return np.array(xs), np.array(ys), np.array(zs), np.array(vals), node_ids +def _compute_nodal_fy(result_data: dict, loadcase: str) -> dict: + """ + Return {node_id (int): fy_kN (float)} for a given load case. + Uses element-end equilibrium: + F_applied(node) = −Σ Vy_end / 1000 (N → kN) + """ + forces = result_data.get("forces", {}).get(loadcase, {}) + members = result_data.get("members", {}) + accum: dict = {} + for eid_str, end_f in forces.items(): + nids = members.get(eid_str) or members.get(int(eid_str), []) + if len(nids) < 2: + continue + n1, n2 = int(nids[0]), int(nids[1]) + vy_i = end_f.get("Vy_i", 0.0) / 1000.0 # N → kN + vy_j = end_f.get("Vy_j", 0.0) / 1000.0 + accum[n1] = accum.get(n1, 0.0) - vy_i # equilibrium flip + accum[n2] = accum.get(n2, 0.0) - vy_j + return accum + +def _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, + eng_scale=1.0, node_values=None): + """ + Draw load arrows hanging directly below each node's plotted position. + + Parameters + ---------- + node_values : dict {nid (int|str): plotted_z_value} + The actual Z (value-axis) coordinate of each node in this figure. + For grillage all nodes sit at z=0 so pass None or {}. + For SFD/BMD/deflection pass the per-node force or displacement value + so arrows hang from the dot on the curve, not from the flat baseline. + """ + if load_mode == "off" or not nodal_fy: + return + + mags = np.array([abs(v) for v in nodal_fy.values() if abs(v) > 0.01]) + if mags.size == 0: + return + + # ── Step 1: arrow height = 15 % of current visible Z range ─────────────── + zlo, zhi = ax.get_zlim() + z_visible = abs(zhi - zlo) + if z_visible < 1e-9: + z_visible = 1.0 + h = z_visible * 0.15 + + # ── Step 2: find lowest node position so we can reserve axis space ──────── + def _node_z(nid): + """Return the plotted Z of a node (defaults to 0).""" + if not node_values: + return 0.0 + return float(node_values.get(int(nid), + node_values.get(str(nid), 0.0))) + + relevant_zs = [_node_z(nid) for nid, fy in nodal_fy.items() if abs(fy) >= 0.01] + min_node_z = min(relevant_zs) if relevant_zs else 0.0 + required_zlo = min(zlo, min_node_z - h * 1.30) + ax.set_zlim(required_zlo, zhi) + + # ── Step 3: draw each arrow ─────────────────────────────────────────────── + for nid, fy in nodal_fy.items(): + if abs(fy) < 0.01: + continue + + coord = nodes.get(int(nid)) or nodes.get(str(nid)) + if not coord: + continue + + x, _y, z = coord + z_plot = z # physical transverse Z → mpl Y axis + z_node = _node_z(nid) # actual plotted value at this node + + is_downward = fy < 0 + color = "#C62828" if is_downward else "#00897B" + + # Arrow hangs directly below the node dot + # Downward: tail at node, head below (↓) + # Upward: tail below node, head at node (↑) + if is_downward: + start_pt = (x, z_plot, z_node) + end_pt = (x, z_plot, z_node - h) + else: + start_pt = (x, z_plot, z_node - h) + end_pt = (x, z_plot, z_node) + + text_z = z_node - h * 1.22 # label sits below arrowhead + + _draw_camera_arrow(ax, start_pt, end_pt, color=color, lw=2.5, + gid="nodal_loads") + + ax.text(x, z_plot, text_z, + f"{fy:+.1f} kN", + color=color, fontsize=9, fontweight="bold", + ha="center", va="top", + zorder=12, gid="nodal_loads", + bbox=dict(boxstyle="round,pad=0.15", facecolor="white", + alpha=0.75, edgecolor="none")) + + + # ============================================================================= # DRAWING HELPERS (matplotlib 3-D) # ============================================================================= @@ -510,7 +611,7 @@ def _add_element_number_labels(ax, nodes, members, visible: bool = False): # GRILLAGE PLOT # ============================================================================= -def build_figure_grillage(nodes, members, edge_dist=0.0, selected_girder="All"): +def build_figure_grillage(nodes, members, edge_dist=0.0, selected_girder="All", nodal_fy=None, load_mode="off"): """ Build a 3-D matplotlib figure showing only the bridge grillage mesh. @@ -642,6 +743,12 @@ def auto_hide(): # Dedicate 18% of the right side purely to the massive axis labels. # This naturally shoves the 3D bridge perfectly into the center of the screen! # fig.subplots_adjust(left=0.05, right=0.88, bottom=0.05, top=0.90) + + if nodal_fy and load_mode != "off": + all_xs = [coord[0] for coord in nodes.values()] + x_range = max(all_xs) - min(all_xs) or 1.0 + _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode) + return fig @@ -649,7 +756,7 @@ def auto_hide(): # SFD PLOT # ============================================================================= -def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All"): +def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All", nodal_fy=None, load_mode="off"): """ Build a 3-D matplotlib figure showing the Shear Force Diagram. """ @@ -704,6 +811,8 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 # and the 3D "grid" can expand/shrink with v_scale. global_vmin = 0.0 global_vmax = 0.0 + # node_values: {nid: Vy_geom} so load arrows start at the node dot on the SFD curve + _arrow_node_values = {} for i, (z_val, elems) in enumerate(girder_items): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders - 1) @@ -759,6 +868,9 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 color=shear_color, s=30, zorder=5, depthshade=False) _scatter_objs.append(sc) _scatter_data[id(sc)] = (node_ids, xs, Vy) + # collect per-node plotted Z for load arrows + for nid, vg in zip(node_ids, Vy_geom): + _arrow_node_values[int(nid)] = float(vg) if len(Vy) > 0: idx_max = int(np.argmax(Vy)) @@ -890,13 +1002,17 @@ def auto_hide(): _add_coordinate_triad(ax, nodes, eng_scale=v_scale) ax.set_axis_off() + if nodal_fy and load_mode != "off": + _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, + eng_scale=v_scale, node_values=_arrow_node_values) + return fig, summary_data # ============================================================================= # BMD PLOT # ============================================================================= -def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All"): +def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All", nodal_fy=None, load_mode="off"): """ Build a 3-D matplotlib figure showing the Bending Moment Diagram. @@ -956,6 +1072,8 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 # Track global scaled z-range for baseline at 0 and cube resizing. global_vmin = 0.0 global_vmax = 0.0 + # node_values: {nid: y_plot} so load arrows start at the node dot on the BMD curve + _arrow_node_values = {} for i, (z_val, elems) in enumerate(girder_items_bmd): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders_bmd - 1) girder_name = f"G{i}" if edge_dist > 0 else f"G{i + 1}" @@ -1035,9 +1153,10 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 # Slicing [1:-1] strips away the first and last dots so the supports stay clean! sc = ax.scatter(xs[1:-1], z_arr[1:-1], y_plot[1:-1], color=moment_color, s=30, zorder=5, depthshade=False) - _scatter_objs.append(sc) - + # collect per-node plotted Z for load arrows (include first/last too) + for nid, yp in zip(node_ids, y_plot): + _arrow_node_values[int(nid)] = float(yp) # CRITICAL: You must slice the hover data too, or the tooltip will show the wrong node! _scatter_data[id(sc)] = (node_ids[1:-1], xs[1:-1], Mz[1:-1]) @@ -1127,6 +1246,11 @@ def auto_hide(): # This naturally shoves the 3D bridge perfectly into the center of the screen! _add_coordinate_triad(ax, nodes, eng_scale=v_scale) ax.set_axis_off() + + if nodal_fy and load_mode != "off": + _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, + eng_scale=v_scale, node_values=_arrow_node_values) + return fig, summary_data @@ -1286,7 +1410,7 @@ def auto_hide(): # DEFLECTION PLOT # ============================================================================= -def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All"): +def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_scale=1.0, selected_girder="All", nodal_fy=None, load_mode="off"): """ Build a 3-D matplotlib figure showing the Deflection Diagram. """ @@ -1373,6 +1497,8 @@ def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_sca # Track global scaled z-range for baseline at 0 and cube resizing. global_vmin = 0.0 global_vmax = 0.0 + # node_values: {nid: y_plot} so load arrows start at the node dot on the deflection curve + _arrow_node_values = {} for i, (z_val, elems) in enumerate(girder_items): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders - 1) @@ -1417,8 +1543,10 @@ def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_sca # Draw Nodes (Pure Black) sc = ax.scatter(xs[1:-1], z_arr[1:-1], y_plot[1:-1], color="black", s=30, zorder=5, depthshade=False) - _scatter_objs.append(sc) + # collect per-node plotted Z for load arrows + for nid, yp in zip(node_list, y_plot): + _arrow_node_values[int(nid)] = float(yp) _scatter_data[id(sc)] = (node_list[1:-1], xs[1:-1], vals[1:-1]) if not is_edge_beam and len(vals) > 0: summary_data[girder_name] = { @@ -1548,6 +1676,11 @@ def auto_hide(): ax.grid(True, linestyle="--", linewidth=0.4, alpha=0.5) _add_coordinate_triad(ax, nodes, eng_scale=v_scale) ax.set_axis_off() + + if nodal_fy and load_mode != "off": + _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, + eng_scale=v_scale, node_values=_arrow_node_values) + return fig, summary_data diff --git a/src/osdagbridge/desktop/ui/mpl_plot_widget.py b/src/osdagbridge/desktop/ui/mpl_plot_widget.py index 5486151d1..6d45394d7 100644 --- a/src/osdagbridge/desktop/ui/mpl_plot_widget.py +++ b/src/osdagbridge/desktop/ui/mpl_plot_widget.py @@ -185,8 +185,9 @@ def __init__(self, parent=None): self._show_element_numbers = False self._is_summary_checked = False self._show_max = False - self._show_min = False self._show_all_vals = False + self._load_mode = "off" + self._result_data = None # Zoom state self._zoom_scale = 1.0 @@ -344,12 +345,13 @@ def clear(self): self._summary_overlay.hide() def setup(self, ds_all, loadcases: list, nodes: dict, members: dict, - edge_dist: float = 0.0): + edge_dist: float = 0.0, result_data: dict = None): self._ds_all = ds_all self._loadcases = list(loadcases) self._nodes = nodes self._members = members self._edge_dist = edge_dist + self._result_data = result_data if hasattr(self, '_fig') and self._fig.axes: ax = self._fig.axes[0] @@ -472,6 +474,13 @@ def update_plot(self, *_args): plt.close(self._fig) self._summary_data = {} + + # Calculate nodal forces from the first load case for arrows + from osdagbridge.core.bridge_types.plate_girder.plot_generator import _compute_nodal_fy + nodal_fy = None + if self._load_mode != "off" and self._result_data and self._loadcases: + active_lc = self._current_loadcase() or self._loadcases[0] + nodal_fy = _compute_nodal_fy(self._result_data, active_lc) # (Your existing if/elif/else block to build the new figures) eng_scale = self._eng_scale @@ -480,18 +489,21 @@ def update_plot(self, *_args): ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, selected_girder=sel_girder + nodal_fy=nodal_fy, load_mode=self._load_mode ) elif force_key in _DEFL_KEYS: self._fig, self._summary_data = build_figure_deflection( ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, selected_girder=sel_girder + nodal_fy=nodal_fy, load_mode=self._load_mode ) else: self._fig, self._summary_data = build_figure_bmd( ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, selected_girder=sel_girder + nodal_fy=nodal_fy, load_mode=self._load_mode ) self._attach_figure(self._fig) @@ -653,7 +665,17 @@ def _on_grillage_toggled(self, checked: bool): old_azim = self._fig.axes[0].azim plt.close(self._fig) sel_girder = self._current_member() - self._fig = build_figure_grillage(self._nodes, self._members, edge_dist=self._edge_dist, selected_girder=sel_girder) + + from osdagbridge.core.bridge_types.plate_girder.plot_generator import _compute_nodal_fy + nodal_fy = None + if self._load_mode != "off" and self._result_data and self._loadcases: + active_lc = self._current_loadcase() or self._loadcases[0] + nodal_fy = _compute_nodal_fy(self._result_data, active_lc) + + self._fig = build_figure_grillage( + self._nodes, self._members, edge_dist=self._edge_dist, selected_girder=sel_girder, + nodal_fy=nodal_fy, load_mode=self._load_mode + ) self._attach_figure(self._fig) if self._fig.axes and old_elev is not None and old_azim is not None: self._fig.axes[0].view_init(elev=old_elev, azim=old_azim) diff --git a/src/osdagbridge/desktop/ui/template_page.py b/src/osdagbridge/desktop/ui/template_page.py index 3edf8017c..a526020aa 100644 --- a/src/osdagbridge/desktop/ui/template_page.py +++ b/src/osdagbridge/desktop/ui/template_page.py @@ -648,7 +648,7 @@ def common_design_func(self, trigger: str, target_tab: str = None): loadcases = self.backend.get_available_loadcases() nodes, members = self.backend.get_nodes_members() edge_dist = self.backend.get_edge_dist() - self.plots_widget.setup(ds_all, loadcases, nodes, members, edge_dist=edge_dist) + self.plots_widget.setup(ds_all, loadcases, nodes, members, edge_dist=edge_dist, result_data=self.backend.result_data) self.plots_widget.link_output_dock(self.output_dock) # Render 3D cad using the parameters from Backend diff --git a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py index a7eacba51..b4136ed82 100644 --- a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py +++ b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py @@ -282,6 +282,16 @@ def _sync_btn_to(self, btn: QPushButton | None, state: bool) -> None: btn.setChecked(state) btn.blockSignals(False) + def _update_loads_button_ui(self, mode: str): + if not self._btn_loads: + return + if mode == "off": + self._sync_btn_to(self._btn_loads, False) + self._btn_loads.setToolTip(self._TIP_LOADS) + elif mode == "all": + self._sync_btn_to(self._btn_loads, True) + self._btn_loads.setToolTip("Loads: On") + # ── CONNECTION MANAGEMENT ───────────────────────────────────────────────── # All connections made by this controller go through _connect() so they # are recorded and can be removed cleanly by _disconnect_all(). From 0076490075d4f7c224705ab389ca64e596059e97 Mon Sep 17 00:00:00 2001 From: Soham Date: Mon, 29 Jun 2026 14:29:42 +0530 Subject: [PATCH 2/3] Connection to toolbar and remove redundant code. --- src/osdagbridge/desktop/ui/mpl_plot_widget.py | 6 ++-- .../desktop/ui/utils/toolbar_controller.py | 29 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/osdagbridge/desktop/ui/mpl_plot_widget.py b/src/osdagbridge/desktop/ui/mpl_plot_widget.py index 6d45394d7..9da7103d6 100644 --- a/src/osdagbridge/desktop/ui/mpl_plot_widget.py +++ b/src/osdagbridge/desktop/ui/mpl_plot_widget.py @@ -488,21 +488,21 @@ def update_plot(self, *_args): self._fig, self._summary_data = build_figure_sfd( ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, - selected_girder=sel_girder + selected_girder=sel_girder, nodal_fy=nodal_fy, load_mode=self._load_mode ) elif force_key in _DEFL_KEYS: self._fig, self._summary_data = build_figure_deflection( ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, - selected_girder=sel_girder + selected_girder=sel_girder, nodal_fy=nodal_fy, load_mode=self._load_mode ) else: self._fig, self._summary_data = build_figure_bmd( ds, force_key, self._nodes, self._members, edge_dist=self._edge_dist, eng_scale=eng_scale, - selected_girder=sel_girder + selected_girder=sel_girder, nodal_fy=nodal_fy, load_mode=self._load_mode ) diff --git a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py index b4136ed82..e566f9284 100644 --- a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py +++ b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py @@ -178,15 +178,13 @@ def __init__(self, tool_bar: "ToolBarWidget") -> None: self._btn_legend: QPushButton | None = self._find_button(self._TIP_LEGEND) # Managed toggle buttons — bulk checkable/restore in reset() and bind_to_*() - # NOTE: _btn_loads is intentionally excluded — it is never made checkable, - # it is simply hidden in CAD view and shown in Plots view. self._managed_buttons: list[QPushButton] = [ b for b in ( self._btn_grillage, self._btn_node, self._btn_node_number, self._btn_element_number, self._btn_zoom_win, self._btn_pan, self._btn_rotate, self._btn_axis, self._btn_legend, self._btn_grid, self._btn_supports, - self._btn_girder_labels, + self._btn_loads, self._btn_girder_labels, ) if b is not None ] @@ -282,16 +280,6 @@ def _sync_btn_to(self, btn: QPushButton | None, state: bool) -> None: btn.setChecked(state) btn.blockSignals(False) - def _update_loads_button_ui(self, mode: str): - if not self._btn_loads: - return - if mode == "off": - self._sync_btn_to(self._btn_loads, False) - self._btn_loads.setToolTip(self._TIP_LOADS) - elif mode == "all": - self._sync_btn_to(self._btn_loads, True) - self._btn_loads.setToolTip("Loads: On") - # ── CONNECTION MANAGEMENT ───────────────────────────────────────────────── # All connections made by this controller go through _connect() so they # are recorded and can be removed cleanly by _disconnect_all(). @@ -989,3 +977,18 @@ def _plots_toggle_girder_labels(): self._connect(self._btn_zoom_fit, plots_widget._zoom_reset) self._connect(self._btn_zoom_in, plots_widget._zoom_in) self._connect(self._btn_zoom_out, plots_widget._zoom_out) + + # ── Loads — toggle arrow/force display on the plot ─────────────────── + loads_init = plots_widget._load_mode != "off" if hasattr(plots_widget, "_load_mode") else False + self._make_checkable(self._btn_loads, loads_init) + + def _plots_toggle_loads(): + try: + checked = self._btn_loads.isChecked() + plots_widget._load_mode = "all" if checked else "off" + self._sync_btn_to(self._btn_loads, checked) + plots_widget.update_plot() + except Exception: + pass + + self._connect(self._btn_loads, _plots_toggle_loads) From 817ad5677a98efdbac2e7922ac2ba447450e2686 Mon Sep 17 00:00:00 2001 From: Osdag Date: Mon, 6 Jul 2026 17:12:26 +0530 Subject: [PATCH 3/3] Grillage overlay toggle, y=0 load arrows, dock grillage option. --- .../plate_girder/plot_generator.py | 176 ++++++++++-------- .../bridge_types/plate_girder/ui_fields.py | 8 +- .../desktop/ui/docks/output_dock.py | 16 +- src/osdagbridge/desktop/ui/mpl_plot_widget.py | 158 ++++++++++------ .../desktop/ui/utils/toolbar_controller.py | 7 +- 5 files changed, 227 insertions(+), 138 deletions(-) diff --git a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py index 55683ffc2..b44d559d3 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py @@ -179,17 +179,10 @@ def _compute_nodal_fy(result_data: dict, loadcase: str) -> dict: return accum def _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, - eng_scale=1.0, node_values=None): + eng_scale=1.0): """ - Draw load arrows hanging directly below each node's plotted position. - - Parameters - ---------- - node_values : dict {nid (int|str): plotted_z_value} - The actual Z (value-axis) coordinate of each node in this figure. - For grillage all nodes sit at z=0 so pass None or {}. - For SFD/BMD/deflection pass the per-node force or displacement value - so arrows hang from the dot on the curve, not from the flat baseline. + Draw load arrows onto each loaded node. Arrows always sit at the y=0 baseline + (the grillage plane): tail above, head landing on the node, pointing down. """ if load_mode == "off" or not nodal_fy: return @@ -198,25 +191,17 @@ def _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, if mags.size == 0: return - # ── Step 1: arrow height = 15 % of current visible Z range ─────────────── + # ── Step 1: arrow height = fixed fraction of the visible Z range ───────── + # box_aspect fixes the rendered Z height, so a constant fraction of z_visible + # renders as a constant on-screen arrow length across every force/scale. zlo, zhi = ax.get_zlim() z_visible = abs(zhi - zlo) if z_visible < 1e-9: z_visible = 1.0 - h = z_visible * 0.15 - - # ── Step 2: find lowest node position so we can reserve axis space ──────── - def _node_z(nid): - """Return the plotted Z of a node (defaults to 0).""" - if not node_values: - return 0.0 - return float(node_values.get(int(nid), - node_values.get(str(nid), 0.0))) + h = z_visible * 0.10 - relevant_zs = [_node_z(nid) for nid, fy in nodal_fy.items() if abs(fy) >= 0.01] - min_node_z = min(relevant_zs) if relevant_zs else 0.0 - required_zlo = min(zlo, min_node_z - h * 1.30) - ax.set_zlim(required_zlo, zhi) + # ── Step 2: reserve headroom above y=0 for the arrows + labels ──────────── + ax.set_zlim(zlo, max(zhi, h * 1.55)) # ── Step 3: draw each arrow ─────────────────────────────────────────────── for nid, fy in nodal_fy.items(): @@ -229,33 +214,28 @@ def _node_z(nid): x, _y, z = coord z_plot = z # physical transverse Z → mpl Y axis - z_node = _node_z(nid) # actual plotted value at this node - is_downward = fy < 0 - color = "#C62828" if is_downward else "#00897B" + # Colour still reflects the sign of the load; direction does not. + color = "#C62828" if fy < 0 else "#00897B" - # Arrow hangs directly below the node dot - # Downward: tail at node, head below (↓) - # Upward: tail below node, head at node (↑) - if is_downward: - start_pt = (x, z_plot, z_node) - end_pt = (x, z_plot, z_node - h) - else: - start_pt = (x, z_plot, z_node - h) - end_pt = (x, z_plot, z_node) + # Arrow always points DOWN onto the node at y=0: tail above, head on the + # baseline — regardless of the load sign. + start_pt = (x, z_plot, h) # tail above the y=0 node + end_pt = (x, z_plot, 0) # head on the y=0 baseline - text_z = z_node - h * 1.22 # label sits below arrowhead + text_z = h * 1.30 # label sits above the arrow tail - _draw_camera_arrow(ax, start_pt, end_pt, color=color, lw=2.5, - gid="nodal_loads") + # Thinner, semi-transparent, smaller head → subtler than the value markers. + _draw_camera_arrow(ax, start_pt, end_pt, color=color, lw=1.3, + gid="nodal_loads", mutation_scale=10, alpha=0.75) ax.text(x, z_plot, text_z, f"{fy:+.1f} kN", - color=color, fontsize=9, fontweight="bold", - ha="center", va="top", + color=color, fontsize=7, fontweight="normal", + ha="center", va="bottom", alpha=0.85, zorder=12, gid="nodal_loads", - bbox=dict(boxstyle="round,pad=0.15", facecolor="white", - alpha=0.75, edgecolor="none")) + bbox=dict(boxstyle="round,pad=0.12", facecolor="white", + alpha=0.6, edgecolor="none")) @@ -267,17 +247,26 @@ def _add_grillage_background( ax, nodes, members, - x_tol=3, - z_tol=3, show_transverse=False, include_edge_longitudinals=True, include_end_transverse=True, show_inner_nodes=True, + include_inner_longitudinals=True, + gid=None, ): """ Draw the structural grid using actual member connectivity. Uses the members dict (element → [n1, n2]) so that skewed bridges where nodes are shifted longitudinally still render correctly. + + include_inner_longitudinals : bool + When False, the inner (non-edge) girder centre-lines are skipped. + The force builders draw those as their always-on base, so the + grillage *overlay* pass (gid="grillage") sets this False to avoid + drawing them a second time. + gid : str | None + Tag applied to every line/scatter drawn here so callers can toggle + the whole grid's visibility by gid (see MplPlotWidget grillage toggle). """ # Colour scheme (same as before) long_kw = dict(color="#388E3C", linewidth=1.0, alpha=0.3, zorder=1) @@ -332,11 +321,13 @@ def _add_grillage_background( # 2. DRAW LONGITUDINAL LINES (green, along span) # ================================================================ for x1, z1, x2, z2 in long_members: - # Skip outermost girders when requested - if not include_edge_longitudinals: - if abs(z1 - edge_z_min) < TOL or abs(z1 - edge_z_max) < TOL: - continue - ax.plot([x1, x2], [z1, z2], [0, 0], **long_kw) + is_edge = abs(z1 - edge_z_min) < TOL or abs(z1 - edge_z_max) < TOL + # Skip outermost girders / inner girders when requested + if is_edge and not include_edge_longitudinals: + continue + if (not is_edge) and not include_inner_longitudinals: + continue + ax.plot([x1, x2], [z1, z2], [0, 0], gid=gid, **long_kw) # ================================================================ # 3. DRAW TRANSVERSE LINES (grey, cross-beams) @@ -346,7 +337,7 @@ def _add_grillage_background( is_end_line = (abs(x1 - min_x) < TOL or abs(x1 - max_x) < TOL) if show_transverse or (include_end_transverse and is_end_line): - ax.plot([x1, x2], [z1, z2], [0, 0], **trans_kw) + ax.plot([x1, x2], [z1, z2], [0, 0], gid=gid, **trans_kw) # ================================================================ # 4. INNER NODE DOTS @@ -362,7 +353,7 @@ def _add_grillage_background( if inner_xs: ax.scatter(inner_xs, inner_zs, inner_ys, color="#388E3C", alpha=0.4, s=5, - zorder=2, depthshade=False) + zorder=2, depthshade=False, gid=gid) class Arrow3D(Annotation): def __init__(self, start, end, *args, **kwargs): @@ -381,15 +372,21 @@ def draw(self, renderer): self.set_position((x1p, y1p)) super().draw(renderer) -def _draw_camera_arrow(ax, start, end, color, lw=2.2, gid=None): - """Draw a clean camera-facing arrow that dynamically updates in 3D.""" +def _draw_camera_arrow(ax, start, end, color, lw=2.2, gid=None, + mutation_scale=15, alpha=1.0): + """Draw a clean camera-facing arrow that dynamically updates in 3D. + + mutation_scale sets the arrowhead size in screen points (constant regardless + of data range); alpha lets callers draw a subtler, semi-transparent arrow. + """ arrow = Arrow3D( start, end, arrowprops=dict( arrowstyle="-|>", color=color, lw=lw, - mutation_scale=15, + mutation_scale=mutation_scale, + alpha=alpha, shrinkA=0, shrinkB=0 ), @@ -678,9 +675,10 @@ def build_figure_grillage(nodes, members, edge_dist=0.0, selected_girder="All", xs = [bg_nodes[n][0] for n in node_ids] ys = [bg_nodes[n][2] for n in node_ids] # The physical Z-coordinate is plotted on the Y-axis here - # Capture the scatter object + # Capture the scatter object. Darker green (#1B5E20) so the grillage nodes + # read solid instead of the washed-out lighter green. sc = ax.scatter(xs, ys, [0] * len(xs), - color="#388E3C", s=14, zorder=4, depthshade=False) + color="#1B5E20", s=14, zorder=4, depthshade=False) # Attach the hover cursor specifically for the Grillage view # Attach the hover cursor directly to the single Grillage scatter object 'sc' @@ -798,7 +796,21 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 include_end_transverse=False, show_inner_nodes=False, ) - + + # Full grillage overlay (side/edge longitudinals + transverse cross lines), + # tagged gid="grillage" and hidden until the toolbar Grillage button turns it + # on (MplPlotWidget._apply_grillage_visibility). include_inner_longitudinals + # is False because the inner girder centre-lines are already drawn above. + _add_grillage_background( + ax, bg_nodes, bg_members, + show_transverse=True, + include_edge_longitudinals=True, + include_end_transverse=True, + show_inner_nodes=False, + include_inner_longitudinals=False, + gid="grillage", + ) + shear_color = "#1565C0" fill_color = "#90CAF9" base_color = "#388E3C" @@ -811,8 +823,6 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 # and the 3D "grid" can expand/shrink with v_scale. global_vmin = 0.0 global_vmax = 0.0 - # node_values: {nid: Vy_geom} so load arrows start at the node dot on the SFD curve - _arrow_node_values = {} for i, (z_val, elems) in enumerate(girder_items): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders - 1) @@ -839,7 +849,7 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 continue ax.scatter(xs, z_arr, np.zeros_like(xs), - color=base_color, s=5, zorder=4, depthshade=False, alpha=0.4) + color=base_color, s=16, zorder=4, depthshade=False, alpha=0.55) dynamic_zorder = 100 - i ax.text(xs[0] - (x_range * 0.02), z_base, 0, f"{girder_name}", @@ -868,9 +878,6 @@ def build_figure_sfd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 color=shear_color, s=30, zorder=5, depthshade=False) _scatter_objs.append(sc) _scatter_data[id(sc)] = (node_ids, xs, Vy) - # collect per-node plotted Z for load arrows - for nid, vg in zip(node_ids, Vy_geom): - _arrow_node_values[int(nid)] = float(vg) if len(Vy) > 0: idx_max = int(np.argmax(Vy)) @@ -1004,7 +1011,7 @@ def auto_hide(): if nodal_fy and load_mode != "off": _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, - eng_scale=v_scale, node_values=_arrow_node_values) + eng_scale=v_scale) return fig, summary_data @@ -1061,6 +1068,17 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 show_inner_nodes=False, ) + # Full grillage overlay — hidden until the toolbar Grillage button turns it on. + _add_grillage_background( + ax, bg_nodes, bg_members, + show_transverse=True, + include_edge_longitudinals=True, + include_end_transverse=True, + show_inner_nodes=False, + include_inner_longitudinals=False, + gid="grillage", + ) + moment_color = "#C62828" fill_color = "#EF9A9A" base_color = "#388E3C" @@ -1072,8 +1090,6 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 # Track global scaled z-range for baseline at 0 and cube resizing. global_vmin = 0.0 global_vmax = 0.0 - # node_values: {nid: y_plot} so load arrows start at the node dot on the BMD curve - _arrow_node_values = {} for i, (z_val, elems) in enumerate(girder_items_bmd): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders_bmd - 1) girder_name = f"G{i}" if edge_dist > 0 else f"G{i + 1}" @@ -1098,7 +1114,7 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 continue ax.scatter(xs, z_arr, np.zeros_like(xs), - color=base_color, s=5, zorder=4, depthshade=False, alpha=0.4) + color=base_color, s=16, zorder=4, depthshade=False, alpha=0.55) # girder label # 1. Reverse the stack: G1 (i=0) gets zorder 100, G2 gets 99, etc. @@ -1154,9 +1170,6 @@ def build_figure_bmd(ds, force_key, nodes, members, edge_dist=0.0, eng_scale=1.0 sc = ax.scatter(xs[1:-1], z_arr[1:-1], y_plot[1:-1], color=moment_color, s=30, zorder=5, depthshade=False) _scatter_objs.append(sc) - # collect per-node plotted Z for load arrows (include first/last too) - for nid, yp in zip(node_ids, y_plot): - _arrow_node_values[int(nid)] = float(yp) # CRITICAL: You must slice the hover data too, or the tooltip will show the wrong node! _scatter_data[id(sc)] = (node_ids[1:-1], xs[1:-1], Mz[1:-1]) @@ -1249,7 +1262,7 @@ def auto_hide(): if nodal_fy and load_mode != "off": _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, - eng_scale=v_scale, node_values=_arrow_node_values) + eng_scale=v_scale) return fig, summary_data @@ -1443,6 +1456,17 @@ def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_sca show_inner_nodes=False, ) + # Full grillage overlay — hidden until the toolbar Grillage button turns it on. + _add_grillage_background( + ax, bg_nodes, bg_members, + show_transverse=True, + include_edge_longitudinals=True, + include_end_transverse=True, + show_inner_nodes=False, + include_inner_longitudinals=False, + gid="grillage", + ) + defl_color = "#6A1B9A" # deep purple base_color = "#388E3C" # green baseline @@ -1497,9 +1521,6 @@ def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_sca # Track global scaled z-range for baseline at 0 and cube resizing. global_vmin = 0.0 global_vmax = 0.0 - # node_values: {nid: y_plot} so load arrows start at the node dot on the deflection curve - _arrow_node_values = {} - for i, (z_val, elems) in enumerate(girder_items): is_edge_beam = edge_dist > 0 and (i == 0 or i == n_girders - 1) girder_name = f"G{i}" if edge_dist > 0 else f"G{i + 1}" @@ -1544,9 +1565,6 @@ def build_figure_deflection(ds, disp_key, nodes, members, edge_dist=0.0, eng_sca # Draw Nodes (Pure Black) sc = ax.scatter(xs[1:-1], z_arr[1:-1], y_plot[1:-1], color="black", s=30, zorder=5, depthshade=False) _scatter_objs.append(sc) - # collect per-node plotted Z for load arrows - for nid, yp in zip(node_list, y_plot): - _arrow_node_values[int(nid)] = float(yp) _scatter_data[id(sc)] = (node_list[1:-1], xs[1:-1], vals[1:-1]) if not is_edge_beam and len(vals) > 0: summary_data[girder_name] = { @@ -1679,7 +1697,7 @@ def auto_hide(): if nodal_fy and load_mode != "off": _add_nodal_load_arrows(ax, nodes, nodal_fy, x_range, load_mode, - eng_scale=v_scale, node_values=_arrow_node_values) + eng_scale=v_scale) return fig, summary_data diff --git a/src/osdagbridge/core/bridge_types/plate_girder/ui_fields.py b/src/osdagbridge/core/bridge_types/plate_girder/ui_fields.py index 9e9946402..8948d4db2 100644 --- a/src/osdagbridge/core/bridge_types/plate_girder/ui_fields.py +++ b/src/osdagbridge/core/bridge_types/plate_girder/ui_fields.py @@ -252,8 +252,12 @@ def output_values(self, flag=None): (KEY_ANALYSIS_FORCES, None, # None = no label TYPE_RADIO_GRID, - [["Fx","Vy","Vz"], - ["Tx","My","Mz"], + # 3 columns (column-first). "Grillage" is a 4th entry in the FIRST + # column so it lands alone on its own row; _make_radio_grid then + # left-aligns a lone-row option across the full width. + # Selecting it switches MplPlotWidget to the exclusive grillage view. + [["Fx","Vy","Vz","Grillage"], + ["Tx","My","Mz"], ["Dx","Dy","Dz"]], True, "No Validator", {}), diff --git a/src/osdagbridge/desktop/ui/docks/output_dock.py b/src/osdagbridge/desktop/ui/docks/output_dock.py index 0ccf5ee41..37346ccfa 100644 --- a/src/osdagbridge/desktop/ui/docks/output_dock.py +++ b/src/osdagbridge/desktop/ui/docks/output_dock.py @@ -511,11 +511,19 @@ def _make_radio_grid(self, key: str, label: str, values, meta: dict): num_rows = max((len(col) for col in columns), default=0) for row in range(num_rows): - for col, col_items in enumerate(columns): - if row < len(col_items): - rb = CustomRadioButton(str(col_items[row])) + row_cols = [c for c, col_items in enumerate(columns) if row < len(col_items)] + # A lone option on its own row (e.g. "Grillage") spans the full width and + # is left-aligned, instead of sitting centred under a single column. + if len(row_cols) == 1: + c = row_cols[0] + rb = CustomRadioButton(str(columns[c][row])) + all_rbs.append(rb) + grid.addWidget(rb, row, 0, 1, num_cols, alignment=Qt.AlignLeft) + else: + for c in row_cols: + rb = CustomRadioButton(str(columns[c][row])) all_rbs.append(rb) - grid.addWidget(rb, row, col, alignment=Qt.AlignCenter) + grid.addWidget(rb, row, c, alignment=Qt.AlignCenter) outer.addLayout(grid) return outer diff --git a/src/osdagbridge/desktop/ui/mpl_plot_widget.py b/src/osdagbridge/desktop/ui/mpl_plot_widget.py index 9da7103d6..7d22f7cc4 100644 --- a/src/osdagbridge/desktop/ui/mpl_plot_widget.py +++ b/src/osdagbridge/desktop/ui/mpl_plot_widget.py @@ -45,6 +45,7 @@ } _DEFAULT_FORCE_LABEL = "Vy" # pre-checked on first link +_GRILLAGE_LABEL = "Grillage" # extra radio in the force grid → exclusive grillage view # ============================================================================= @@ -175,8 +176,13 @@ def __init__(self, parent=None): self._summary_data = {} # Display States + # _grillage_mode : exclusive "Grillage" view chosen from the output-dock + # force grid — builds the standalone grillage figure. + # _show_grillage : non-exclusive overlay toggled from the shared toolbar — + # draws the gid="grillage" side/cross lines on a force plot. self._grillage_mode = False - self._show_nodes = False + self._show_grillage = False + self._show_nodes = False self._show_axis = False self._show_supports = False self._show_grid = False @@ -388,9 +394,9 @@ def link_output_dock(self, output_dock): # Connect Analysis Member Dropdown combo_member = output_dock.output_widget.findChild(QComboBox, "analysis.member") if combo_member is not None: + # update_plot() now rebuilds whichever view is active (force plot or the + # exclusive grillage figure), so a single connection covers both. combo_member.currentTextChanged.connect(self.update_plot) - # Make sure it fires an update if changed from UI, since grillage might be active - combo_member.currentTextChanged.connect(lambda text: self._on_grillage_toggled(self._grillage_mode) if self._grillage_mode else None) # 2. Connect Force Radios from osdagbridge.desktop.ui.utils.custom_widgets import CustomRadioButton @@ -402,6 +408,14 @@ def link_output_dock(self, output_dock): rb.setChecked(rb.text() == _DEFAULT_FORCE_LABEL) rb.toggled.connect(self.update_plot) + # "Grillage" radio (added to the analysis force grid): selecting it switches + # to the exclusive standalone grillage figure; selecting any force switches + # back to that diagram. It shares the grid so it is mutually exclusive with + # the force radios automatically. + for rb in output_dock.output_widget.findChildren(CustomRadioButton): + if rb.text() == _GRILLAGE_LABEL: + rb.toggled.connect(self.update_plot) + # Create placeholders to store the checkboxes self._cb_max = None self._cb_min = None @@ -435,7 +449,8 @@ def set_engineering_scale(self, scale): if abs(scale - self._eng_scale) < 1e-9: return self._eng_scale = scale - if self._grillage_mode: + # No value axis in the exclusive grillage view — nothing to rescale. + if self._grillage_selected(): return self.update_plot() @@ -448,10 +463,14 @@ def _current_member(self): return "All" def update_plot(self, *_args): - if self._grillage_mode: + if self._ds_all is None or self._output_dock is None: return - if self._ds_all is None or self._output_dock is None: + # Exclusive grillage view — chosen from the output-dock "Grillage" radio. + # Renders the standalone grillage figure instead of a force diagram. + self._grillage_mode = self._grillage_selected() + if self._grillage_mode: + self._render_grillage_view() return loadcase = self._current_loadcase() @@ -516,6 +535,7 @@ def update_plot(self, *_args): self._apply_grid_visibility() self._apply_girder_labels_visibility() self._apply_element_number_visibility() + self._apply_grillage_visibility() self._apply_annotation_visibility() # (Your existing HUD logic) @@ -655,54 +675,75 @@ def _on_all_vals_toggled(self, checked): self._canvas.draw_idle() # Toolbar Slots + def _grillage_selected(self) -> bool: + """True when the output-dock 'Grillage' radio is checked (exclusive view).""" + if self._output_dock is None: + return False + from osdagbridge.desktop.ui.utils.custom_widgets import CustomRadioButton + for rb in self._output_dock.output_widget.findChildren(CustomRadioButton): + if rb.text() == _GRILLAGE_LABEL and rb.isChecked(): + return True + return False + def _on_grillage_toggled(self, checked: bool): - self._grillage_mode = checked - if checked: - if not self._nodes: return - old_elev, old_azim = None, None - if self._fig and self._fig.axes and hasattr(self._fig.axes[0], 'elev'): - old_elev = self._fig.axes[0].elev - old_azim = self._fig.axes[0].azim - plt.close(self._fig) - sel_girder = self._current_member() - - from osdagbridge.core.bridge_types.plate_girder.plot_generator import _compute_nodal_fy - nodal_fy = None - if self._load_mode != "off" and self._result_data and self._loadcases: - active_lc = self._current_loadcase() or self._loadcases[0] - nodal_fy = _compute_nodal_fy(self._result_data, active_lc) - - self._fig = build_figure_grillage( - self._nodes, self._members, edge_dist=self._edge_dist, selected_girder=sel_girder, - nodal_fy=nodal_fy, load_mode=self._load_mode + """Toolbar 'Grillage View' toggle — a NON-exclusive overlay. + + Shows/hides the gid='grillage' side (edge longitudinal) and cross + (transverse) lines drawn on every force/BMD/deflection figure. It works + alongside the diagram and survives loadcase/force switches. In the + exclusive grillage view (dock radio) the whole figure is already the grid, + so this simply has nothing extra to toggle. + """ + self._show_grillage = checked + self._apply_grillage_visibility() + self._canvas.draw_idle() + + def _render_grillage_view(self): + """Build the standalone grillage figure (exclusive 'Grillage' dock view).""" + if not self._nodes: + return + old_elev, old_azim = None, None + if self._fig and self._fig.axes and hasattr(self._fig.axes[0], 'elev'): + old_elev = self._fig.axes[0].elev + old_azim = self._fig.axes[0].azim + plt.close(self._fig) + sel_girder = self._current_member() + + from osdagbridge.core.bridge_types.plate_girder.plot_generator import _compute_nodal_fy + nodal_fy = None + if self._load_mode != "off" and self._result_data and self._loadcases: + active_lc = self._current_loadcase() or self._loadcases[0] + nodal_fy = _compute_nodal_fy(self._result_data, active_lc) + + self._fig = build_figure_grillage( + self._nodes, self._members, edge_dist=self._edge_dist, selected_girder=sel_girder, + nodal_fy=nodal_fy, load_mode=self._load_mode + ) + self._attach_figure(self._fig) + if self._fig.axes and old_elev is not None and old_azim is not None: + self._fig.axes[0].view_init(elev=old_elev, azim=old_azim) + if self._fig.axes: + title = self._fig.axes[0].get_title() + self._fig.axes[0].set_title("") + self._title_overlay.update_text(title) + self._position_title_overlay() + + self._apply_node_visibility() + self._apply_axis_visibility() + self._apply_supports_visibility() + self._apply_grid_visibility() + self._apply_girder_labels_visibility() + self._apply_element_number_visibility() + self._summary_overlay.hide() + + if self._fig.axes and hasattr(self._fig.axes[0], 'set_box_aspect'): + self._fig.axes[0].set_box_aspect( + aspect=(2.5, 1.2, 1.0), + zoom=self._zoom_scale, ) - self._attach_figure(self._fig) - if self._fig.axes and old_elev is not None and old_azim is not None: - self._fig.axes[0].view_init(elev=old_elev, azim=old_azim) - if self._fig.axes: - title = self._fig.axes[0].get_title() - self._fig.axes[0].set_title("") - self._title_overlay.update_text(title) - self._position_title_overlay() - - self._apply_node_visibility() - self._apply_axis_visibility() - self._apply_supports_visibility() - self._apply_grid_visibility() - self._apply_girder_labels_visibility() - self._apply_element_number_visibility() - self._summary_overlay.hide() - - if self._fig.axes and hasattr(self._fig.axes[0], 'set_box_aspect'): - self._fig.axes[0].set_box_aspect( - aspect=(2.5, 1.2, 1.0), - zoom=self._zoom_scale, - ) - self._canvas.draw() - self._store_orig_limits() - QTimer.singleShot(100, self._update_navcube_visibility) - else: - self.update_plot() + self._canvas.draw() + self._store_orig_limits() + QTimer.singleShot(100, self._update_navcube_visibility) def _on_nodes_toggled(self, checked: bool): self._show_nodes = checked @@ -765,6 +806,19 @@ def _apply_node_number_visibility(self): if text.get_gid() == "node_number": text.set_visible(self._show_node_numbers) + def _apply_grillage_visibility(self): + """Show/hide the grillage overlay — the gid='grillage' side (edge + longitudinal) and cross (transverse) lines drawn on every force figure by + plot_generator._add_grillage_background(..., gid='grillage'). Toggled by the + toolbar 'Grillage View' button; hidden by default.""" + for ax in self._fig.axes: + for line in ax.lines: + if line.get_gid() == "grillage": + line.set_visible(self._show_grillage) + for collection in ax.collections: + if collection.get_gid() == "grillage": + collection.set_visible(self._show_grillage) + def _apply_supports_visibility(self): for ax in self._fig.axes: for collection in ax.collections: diff --git a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py index e566f9284..eeb50f3c5 100644 --- a/src/osdagbridge/desktop/ui/utils/toolbar_controller.py +++ b/src/osdagbridge/desktop/ui/utils/toolbar_controller.py @@ -777,7 +777,7 @@ def _initial_plot_state(attr_name: str, default: bool = False) -> bool: except Exception: return default - grillage_init = _initial_plot_state('_grillage_mode', False) + grillage_init = _initial_plot_state('_show_grillage', False) node_init = _initial_plot_state('_show_nodes', True) node_number_init = _initial_plot_state('_show_node_numbers', False) axis_init = _initial_plot_state('_show_axis', False) @@ -987,6 +987,11 @@ def _plots_toggle_loads(): checked = self._btn_loads.isChecked() plots_widget._load_mode = "all" if checked else "off" self._sync_btn_to(self._btn_loads, checked) + # Loads drives the grillage overlay both ways: the load arrows hang + # from the y=0 grillage plane, so the grid is shown with them and + # removed when they are turned off. + plots_widget._show_grillage = checked + self._sync_btn_to(self._btn_grillage, checked) plots_widget.update_plot() except Exception: pass