Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 176 additions & 25 deletions src/osdagbridge/core/bridge_types/plate_girder/plot_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,87 @@ 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):
"""
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

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 = 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.10

# ── 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():
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

# Colour still reflects the sign of the load; direction does not.
color = "#C62828" if fy < 0 else "#00897B"

# 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 = h * 1.30 # label sits above the arrow tail

# 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=7, fontweight="normal",
ha="center", va="bottom", alpha=0.85,
zorder=12, gid="nodal_loads",
bbox=dict(boxstyle="round,pad=0.12", facecolor="white",
alpha=0.6, edgecolor="none"))



# =============================================================================
# DRAWING HELPERS (matplotlib 3-D)
# =============================================================================
Expand All @@ -166,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)
Expand Down Expand Up @@ -231,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)
Expand All @@ -245,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
Expand All @@ -261,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):
Expand All @@ -280,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
),
Expand Down Expand Up @@ -510,7 +608,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.

Expand Down Expand Up @@ -577,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'
Expand Down Expand Up @@ -642,14 +741,20 @@ 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


# =============================================================================
# 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.
"""
Expand Down Expand Up @@ -691,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"
Expand Down Expand Up @@ -730,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}",
Expand Down Expand Up @@ -890,13 +1009,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)

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.

Expand Down Expand Up @@ -945,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"
Expand Down Expand Up @@ -980,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.
Expand Down Expand Up @@ -1035,9 +1169,7 @@ 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)

# 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])

Expand Down Expand Up @@ -1127,6 +1259,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)

return fig, summary_data


Expand Down Expand Up @@ -1286,7 +1423,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.
"""
Expand Down Expand Up @@ -1319,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

Expand Down Expand Up @@ -1373,7 +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

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}"
Expand Down Expand Up @@ -1417,7 +1564,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)
_scatter_data[id(sc)] = (node_list[1:-1], xs[1:-1], vals[1:-1])
if not is_edge_beam and len(vals) > 0:
Expand Down Expand Up @@ -1548,6 +1694,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)

return fig, summary_data


Expand Down
8 changes: 6 additions & 2 deletions src/osdagbridge/core/bridge_types/plate_girder/ui_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,12 @@ def output_values(self, flag=None):

(KEY_ANALYSIS_FORCES, None, # None = no label
TYPE_RADIO_GRID,
[["F<sub>x</sub>","V<sub>y</sub>","V<sub>z</sub>"],
["T<sub>x</sub>","M<sub>y</sub>","M<sub>z</sub>"],
# 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.
[["F<sub>x</sub>","V<sub>y</sub>","V<sub>z</sub>","Grillage"],
["T<sub>x</sub>","M<sub>y</sub>","M<sub>z</sub>"],
["D<sub>x</sub>","D<sub>y</sub>","D<sub>z</sub>"]],
True, "No Validator", {}),

Expand Down
Loading