OverviewFilesCommitsIssuesDocsReleasesPeers

electrical-review / artifacts / racking / exploded / exploded_opt2_final.py

1031 lines · python ref:
#!/usr/bin/env python3
"""
FIG. R7 — OPTION 2 · SEASONAL ADJUSTABLE (drilled-preset wood) · EXPLODED ASSEMBLY
PRSM Phase-1 ground-mount · INHERITED racking + Option-2 adaptation · FINAL
(SOLAR_RACKING_HANDOFF §5 base + takeoff Part B adder;
 Vandewetering/Hayibo/Pearce, Designs 2022 6(3):41 base; Pearce, Designs 2022 6(3):54 adjustable)

FINAL pass — visual-QA fixes applied vs the draft:
  - Stage 5 declutter: the solid 55° bay shifted left so the right third of the
    card is a clean zone for the mechanism inset + B2/B3/B4 leaders (no overlap
    of brace callouts on the solid panel).
  - GHOST PRESETS no longer fan across the solid panel: 45/35/30 ghosts are now
    short alternate-position outlines hinged off the LOW edge with a tidy
    vertically-stacked angle key, so each reads as a distinct alt panel angle.
  - Inset enlarged + re-housed in its own reserved frame clear of the BOM column
    and the title chip; mechanism labels de-collided.
  - "ADJUST ~4×/YR · PIN INTO PRESET HOLE" chip moved to the stage-5 header band
    (top-left) so it no longer sits on top of the inset.
  - Bond jumper (B6, >=10 AWG) given a clearer hop + leader at the hinge.
  - Energy chip leading bumped so it clears the title sub-line.

VISUALIZATION ONLY of an inherited engineering ESTIMATE + the option's adaptation
docs — NOT a stamped calc. ENERGY figures are script-computed (15_tilt_sweep.py),
used verbatim. The only electrical items are the >=10 AWG bond jumper and PV
service loops; no new electrical numbers are introduced here.

Adapted from the proven Fixed-55 tiered-cards template (exploded_final.py).
Same iso() projection, box/cyl helpers, shading, palette and 5-stage tiered
layout. CHANGED CONTENT for Option 2:
  - Stage 4: the rigid sloped frame becomes a TILTABLE BAY pivoting on a T-HINGE
    at the LOW edge (replaces the fixed low connection).
  - Stage 5: panels shown on the tilted bay WITH GHOST PRESET ANGLES (55 set,
    45/35/30 ghosted) + the DRILLED REAR BRACE (2x4, 4 holes) + removable PIN.
  - A hinge+brace mechanism inset/callout.
  - BOM legend adds Part-B rows B1..B5.
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon, FancyBboxPatch, Circle, Ellipse, FancyArrow
from matplotlib.lines import Line2D
from matplotlib import font_manager
import numpy as np

# ----------------------------------------------------------------------------
# FONTS — mono field-guide feel. Prefer IBM Plex Mono, fall back to DejaVu mono.
# ----------------------------------------------------------------------------
MONO = "DejaVu Sans Mono"
for cand in ("IBM Plex Mono",):
    try:
        font_manager.findfont(cand, fallback_to_default=False)
        MONO = cand
        break
    except Exception:
        pass
plt.rcParams["font.family"] = "monospace"
plt.rcParams["font.monospace"] = [MONO, "DejaVu Sans Mono"]
plt.rcParams["svg.fonttype"] = "none"

# ----------------------------------------------------------------------------
# PALETTE (hex resolved from the OKLCH CSS vars in styles.css / racking.css)
# ----------------------------------------------------------------------------
PAPER     = "#F4F0E7"
SURFACE   = "#FCFAF4"
SURFACE2  = "#F9F5EE"
INK       = "#271F18"
INK2      = "#50473F"
INK3      = "#797269"
RULE      = "#D3CFC6"
RULE2     = "#B6B1A9"
AMBER     = "#AE692D"
AMBER_SOFT= "#EBDECF"
PINE      = "#316843"
PINE_SOFT = "#DDE0D3"
PINE_STK  = "#091E10"
PINE_LITE = "#7FA587"   # lighter pine tint for the panel cell grid
SLATE     = "#456783"
CLAY      = "#A6492F"    # .lock-strut / over-budget accent (racking.css var(--clay))

def hx(c):
    c = c.lstrip("#")
    return tuple(int(c[i:i+2], 16) / 255 for i in (0, 2, 4))

def darken(c, k):
    r, g, b = hx(c)
    return (r * k, g * k, b * k)

# ----------------------------------------------------------------------------
# ISOMETRIC PROJECTION  (true 2:1 iso, Maya/SE convention; y-up data space)
#   +X = array WIDTH (bays L->R), +Y = DEPTH (front->back), +Z = HEIGHT
# ----------------------------------------------------------------------------
COS30 = 0.86602540378
SIN30 = 0.5

def iso(X, Y, Z, S=1.0, ox=0.0, oy=0.0):
    return (ox + S * (X - Y) * COS30,
            oy + S * (-(X + Y) * SIN30 + Z))

# Light: upper-left.  TOP=100%  LEFT=82%  RIGHT=66%
K_TOP, K_LEFT, K_RIGHT = 1.00, 0.82, 0.66

# Painter's-order depth key. This iso's into-screen axis is world (1,1,1), so a
# NEARER part has a SMALLER (X+Y+Z) and must paint LAST (higher zorder). Map a
# member's centroid sum -> zorder, descending (matches the manual far→near draw
# order used for footings/posts). Keeps members clear of UI layers.
Z_BASE, Z_SCALE = 16.0, 1.2
def zkey(Xc, Yc, Zc):
    return Z_BASE - Z_SCALE * (Xc + Yc + Zc)

def box(ax, X, Y, Z, dx, dy, dz, fill, stroke, lw=1.5, S=1.0, ox=0.0, oy=0.0,
        z=2, alpha=1.0, top_k=None, left_k=None, right_k=None):
    """Draw a 3D extruded box (3 visible faces: right, left, top)."""
    kt = K_TOP if top_k is None else top_k
    kl = K_LEFT if left_k is None else left_k
    kr = K_RIGHT if right_k is None else right_k
    def P(x, y, zz):
        return iso(x, y, zz, S, ox, oy)
    c = [P(X, Y, Z),            # 0 near-bottom
         P(X+dx, Y, Z),         # 1
         P(X+dx, Y+dy, Z),      # 2
         P(X, Y+dy, Z),         # 3
         P(X, Y, Z+dz),         # 4 near-top
         P(X+dx, Y, Z+dz),      # 5
         P(X+dx, Y+dy, Z+dz),   # 6
         P(X, Y+dy, Z+dz)]      # 7
    base = hx(fill)
    right = [c[1], c[2], c[6], c[5]]
    ax.add_patch(Polygon(right, closed=True, facecolor=tuple(v*kr for v in base),
                         edgecolor=stroke, linewidth=lw, zorder=z, joinstyle="miter",
                         alpha=alpha))
    left = [c[0], c[1], c[5], c[4]]
    ax.add_patch(Polygon(left, closed=True, facecolor=tuple(v*kl for v in base),
                         edgecolor=stroke, linewidth=lw, zorder=z+0.1, joinstyle="miter",
                         alpha=alpha))
    top = [c[4], c[5], c[6], c[7]]
    ax.add_patch(Polygon(top, closed=True, facecolor=tuple(v*kt for v in base),
                         edgecolor=stroke, linewidth=lw, zorder=z+0.2, joinstyle="miter",
                         alpha=alpha))
    return c

def cyl(ax, X, Y, Z, dia, depth, fill, stroke, lw=1.4, S=1.0, ox=0.0, oy=0.0, z=2,
        hatch=None, side_k=K_LEFT):
    """Iso pier cylinder from grade Z down to Z-depth."""
    r = dia / 2.0
    cxt, cyt = iso(X, Y, Z, S, ox, oy)
    cxb, cyb = iso(X, Y, Z - depth, S, ox, oy)
    ew = dia * S * 1.0
    eh = dia * S * SIN30
    base = hx(fill)
    side = [(cxt - ew/2, cyt), (cxt + ew/2, cyt),
            (cxb + ew/2, cyb), (cxb - ew/2, cyb)]
    ax.add_patch(Polygon(side, closed=True, facecolor=tuple(v*side_k for v in base),
                         edgecolor=stroke, linewidth=lw, zorder=z, hatch=hatch))
    ax.add_patch(Ellipse((cxb, cyb), ew, eh, facecolor=tuple(v*K_RIGHT for v in base),
                         edgecolor=stroke, linewidth=lw*0.8, zorder=z+0.05))
    ax.add_patch(Ellipse((cxt, cyt), ew, eh, facecolor=tuple(v*K_TOP for v in base),
                         edgecolor=stroke, linewidth=lw, zorder=z+0.3))
    rng = np.random.default_rng(int(abs(X*97 + Y*131)))
    for _ in range(7):
        fx = cxt + (rng.random()-0.5)*ew*0.7
        fy = cyt + (rng.random()-0.5)*eh*0.7
        ax.add_patch(Circle((fx, fy), ew*0.018, facecolor=INK3, alpha=0.4,
                            edgecolor="none", zorder=z+0.4))
    return (cxt, cyt)

# ----------------------------------------------------------------------------
# DRESSED-LUMBER CROSS SECTIONS (meters)
# ----------------------------------------------------------------------------
IN = 0.0254
S66 = 5.5 * IN        # 0.1397
S44 = 3.5 * IN        # 0.0889
T2  = 1.5 * IN        # 0.0381 (nominal 2x thickness)
D10 = 9.25 * IN       # 0.23495  (2x10)
D8  = 7.25 * IN       # 0.18415  (2x8)
D6  = 5.5 * IN        # 0.1397   (2x6)
D4  = 3.5 * IN        # 0.0889   (2x4)

BAY   = 16.1 / 7.0    # 2.30 m
DEPTH = 1.30          # front->back run
BACK_TOP  = 2.23
FRONT_TOP = 0.50
N_PAIRS = 3           # representative: 3 post-pairs => 2 bays
SPAN_X  = 2 * BAY     # 4.60 m

# ----------------------------------------------------------------------------
# CALL-OUT BADGE + LEADER helper
# ----------------------------------------------------------------------------
def callout(ax, anchor_xy, badge_xy, num, z=30, color=INK, ring=SURFACE):
    ax_pt, ay = anchor_xy
    bx, by = badge_xy
    ax.add_line(Line2D([ax_pt, bx], [ay, by], color=INK3, lw=1.0,
                       linestyle=(0, (3, 3)), zorder=z))
    ax.add_patch(Circle((ax_pt, ay), 2.6, facecolor=INK, edgecolor="none", zorder=z+0.5))
    ax.add_patch(Circle((bx, by), 10.5, facecolor=ring, edgecolor=color,
                        linewidth=1.5, zorder=z+1))
    ax.text(bx, by, num, family="monospace",
            fontsize=8.5 if len(num) == 1 else 7.2,
            color=color, ha="center", va="center", zorder=z+2, weight="bold")

# ============================================================================
# FIGURE
# ============================================================================
fig = plt.figure(figsize=(13, 16.4), dpi=200)
fig.patch.set_facecolor(PAPER)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_facecolor(PAPER)
ax.set_xlim(0, 1300)
ax.set_ylim(0, 1640)
ax.set_aspect("equal")
ax.axis("off")

YSHIFT = 40

LEFT_W = 1010
RIGHT_X = 1028

# ---- TOP STRIP: fig label + title --------------------------------------------
ax.text(34, 1560 + YSHIFT, "FIG. R7 · OPTION 2 — SEASONAL ADJUSTABLE · EXPLODED ASSEMBLY",
        family="monospace", fontsize=12, color=INK3, weight="bold")
ax.add_line(Line2D([34, 990], [1548 + YSHIFT, 1548 + YSHIFT], color=RULE2, lw=1.0))
ax.text(34, 1524 + YSHIFT, "OPTION 2 — SEASONAL ADJUSTABLE (DRILLED-PRESET WOOD)",
        family="monospace", fontsize=16, color=INK, weight="bold")
ax.text(34, 1500 + YSHIFT,
        "REPRESENTATIVE 2 OF 7 BAYS · 14 × WAAREE 575 W · TILTABLE BAY · PRESETS 55/45/35/30°",
        family="monospace", fontsize=10.5, color=INK2)

# ---- STAMP (top-right, estimate/amber style) ---------------------------------
stamp_w, stamp_h = 268, 46
sx0, sy0 = LEFT_W - stamp_w, 1512 + YSHIFT
stamp = FancyBboxPatch((sx0, sy0), stamp_w, stamp_h,
                       boxstyle="round,pad=2,rounding_size=6",
                       facecolor=AMBER_SOFT, edgecolor=AMBER, linewidth=1.4,
                       zorder=20)
ax.add_patch(stamp)
ax.add_patch(Circle((sx0 + 16, sy0 + stamp_h/2), 4.5, facecolor=AMBER,
                    edgecolor="none", zorder=21))
ax.text(sx0 + 30, sy0 + stamp_h/2 + 8, "INHERITED · NOT STAMPED",
        family="monospace", fontsize=9.0, color=AMBER, weight="bold",
        va="center", zorder=21)
ax.text(sx0 + 30, sy0 + stamp_h/2 - 8, "ENGINEER REVIEW BEFORE POUR",
        family="monospace", fontsize=9.0, color=AMBER, weight="bold",
        va="center", zorder=21)

# ---- ENERGY chip (under the stamp) -------------------------------------------
# Energy figures are script-computed (15_tilt_sweep.py) — verbatim.
en_w, en_h = 268, 40
ex0, ey0 = LEFT_W - en_w, 1452 + YSHIFT
ec = FancyBboxPatch((ex0, ey0), en_w, en_h,
                    boxstyle="round,pad=2,rounding_size=6",
                    facecolor=PINE_SOFT, edgecolor=PINE, linewidth=1.2, zorder=20)
ax.add_patch(ec)
ax.text(ex0 + 12, ey0 + en_h - 11, "11,023 kWh/yr · +577 (+5.5%) GROSS",
        family="monospace", fontsize=8.2, color=PINE, weight="bold", va="center", zorder=21)
ax.text(ex0 + 12, ey0 + en_h/2 - 2, "vs FIXED-55 · USEFUL ~+135 (+2.5%)",
        family="monospace", fontsize=8.0, color=INK2, va="center", zorder=21)
ax.text(ex0 + 12, ey0 + 9, "computed · 15_tilt_sweep.py",
        family="monospace", fontsize=7.4, color=INK3, va="center", zorder=21)

# ============================================================================
# STAGE VIGNETTES  (instruction-sheet tiers)
# ============================================================================
S = 50.0   # px per meter inside each vignette (fits the tallest stage in-card)

TIER_X0 = 34
TIER_X1 = LEFT_W - 6
TIER_TOP = 1430 + YSHIFT
TIER_BOT = 70 + YSHIFT
N_TIERS = 5
GAP = 18
tier_h = (TIER_TOP - TIER_BOT - GAP * (N_TIERS - 1)) / N_TIERS

def tier_rect(i):
    y1 = TIER_TOP - i * (tier_h + GAP)
    y0 = y1 - tier_h
    return (TIER_X0, y0, TIER_X1 - TIER_X0, tier_h)

def draw_card(i, num, name, sub):
    x0, y0, w, h = tier_rect(i)
    card = FancyBboxPatch((x0, y0), w, h,
                          boxstyle="round,pad=0,rounding_size=8",
                          facecolor=SURFACE, edgecolor=RULE2, linewidth=1.2,
                          zorder=1)
    ax.add_patch(card)
    bx, by = x0 + 34, y0 + h - 32
    ax.add_patch(Circle((bx, by), 19, facecolor=SURFACE2, edgecolor=INK2,
                        linewidth=1.6, zorder=5))
    ax.text(bx, by, str(num), family="monospace", fontsize=17, color=INK,
            weight="bold", ha="center", va="center", zorder=6)
    ax.text(x0 + 64, y0 + h - 22, name, family="monospace", fontsize=12.5,
            color=INK, weight="bold", va="center")
    ax.text(x0 + 64, y0 + h - 40, sub, family="monospace", fontsize=9.0,
            color=INK2, va="center")
    return (x0, y0, w, h)

def stage_label_chip(x0, y0, h, callouts):
    cx = x0 + 64
    cyy = y0 + h - 66
    for c in callouts:
        w_ch = 10.5 if len(c) <= 1 else 13.0
        ax.add_patch(Circle((cx, cyy), w_ch, facecolor=SURFACE, edgecolor=INK2,
                            linewidth=1.3, zorder=6))
        ax.text(cx, cyy, c, family="monospace",
                fontsize=8.5 if len(c) <= 1 else 7.2,
                color=INK, ha="center", va="center", zorder=7)
        cx += 28 if len(c) <= 1 else 32

def down_arrow(i):
    x0, y0, w, h = tier_rect(i)
    ax_pt = x0 + 150
    ya = y0 - 3
    yb = y0 - GAP + 3
    ax.add_patch(FancyArrow(ax_pt, ya, 0, (yb - ya), width=2.4,
                            head_width=12, head_length=8, length_includes_head=True,
                            facecolor=INK3, edgecolor="none", zorder=8, alpha=0.9))

# ----------------------------------------------------------------------------
# VIGNETTE BUILDERS
# ----------------------------------------------------------------------------
def ground_plane(ox, oy, x_lo=-0.55, x_hi=None, alpha=0.5):
    if x_hi is None:
        x_hi = SPAN_X + 0.55
    pts = [iso(x_lo, 0.0, 0.0, S, ox, oy),
           iso(x_hi, 0.0, 0.0, S, ox, oy),
           iso(x_hi, DEPTH, 0.0, S, ox, oy),
           iso(x_lo, DEPTH, 0.0, S, ox, oy)]
    ax.add_patch(Polygon(pts, closed=True, facecolor=SURFACE2, edgecolor=RULE2,
                         linewidth=1.0, zorder=1.5, alpha=alpha))
    ax.add_line(Line2D([pts[0][0], pts[1][0]], [pts[0][1], pts[1][1]],
                       color=INK, lw=1.6, zorder=1.7))
    return pts

def vignette_origin(x0, y0, w, h, shift=0.0):
    """shift (fraction of card width) moves the iso origin LEFT to free up the
    right side of the card for callouts / an inset (used by stages 4 & 5)."""
    ox = x0 + w * (0.50 - shift)
    oy = y0 + h * 0.34
    return ox, oy

def centered_origin(rect, zlo, zhi, x_lo=-0.55, x_hi=None,
                    header=46, margin=10, fx=0.52):
    """Center a stage's *projected* iso bounding box inside the card's drawing
    area (below the header). iso() is linear → screen bbox = min/max over the
    world-AABB corners; center THAT, so each stage sits centered regardless of
    how its geometry extends. fx<0.5 biases left to free a right-side inset zone."""
    x0, y0, w, h = rect
    if x_hi is None:
        x_hi = SPAN_X + 0.55
    corners = [(X, Y, Z) for X in (x_lo, x_hi) for Y in (0.0, DEPTH)
               for Z in (zlo, zhi)]
    sxs = [S * (X - Y) * COS30 for (X, Y, Z) in corners]
    sys = [S * (-(X + Y) * SIN30 + Z) for (X, Y, Z) in corners]
    cx0 = (min(sxs) + max(sxs)) / 2.0
    cy0 = (min(sys) + max(sys)) / 2.0
    tcx = x0 + w * fx
    tcy = y0 + margin + (h - header - margin) / 2.0
    return tcx - cx0, tcy - cy0

_ZT = BACK_TOP + D8 + D10
STAGE_Z = {
    "draw_footings": (-1.45, 0.30),
    "draw_posts":    (0.0, BACK_TOP + 0.15),
    "draw_beams":    (0.0, BACK_TOP + D8 + 0.05),
    "draw_slope":    (0.0, _ZT + 0.10),
    "draw_panels":   (0.0, _ZT + 0.20),
}

def draw_footings(ox, oy):
    for Y in (DEPTH, 0.0):
        for p in range(N_PAIRS):
            X = p * BAY
            cyl(ax, X, Y, 0.0, 0.32, 1.4, "#9C8A6E", INK2, lw=1.3, S=S,
                ox=ox, oy=oy, z=3, hatch="////", side_k=0.62)
    gp = ground_plane(ox, oy, alpha=0.5)
    gx0, gy0 = gp[0]
    gx1, gy1 = gp[1]
    band_h = 16
    soil = [(gx0, gy0), (gx1, gy1), (gx1, gy1 - band_h), (gx0, gy0 - band_h)]
    ax.add_patch(Polygon(soil, closed=True, facecolor="#E4D8C2", edgecolor=INK2,
                         linewidth=0.8, hatch="....", zorder=2.2, alpha=0.9))
    for Y in (DEPTH, 0.0):
        for p in range(N_PAIRS):
            X = p * BAY
            cyl(ax, X, Y, 0.10, 0.32, 0.10, SURFACE, INK2, lw=1.3, S=S,
                ox=ox, oy=oy, z=5)
    fx0, fy0 = iso(-0.55, 0.0, -1.2, S, ox, oy)
    fx1, fy1 = iso(SPAN_X + 0.55, 0.0, -1.2, S, ox, oy)
    ax.add_line(Line2D([fx0, fx1], [fy0, fy1], color=SLATE, lw=1.4,
                       linestyle=(0, (6, 4)), zorder=4))
    ax.text(fx1 + 6, fy1, "frost ~1.2 m", family="monospace", fontsize=8,
            color=SLATE, va="center", zorder=6)
    Xd = (N_PAIRS - 1) * BAY
    dim_x = iso(Xd, 0.0, 0.0, S, ox, oy)[0] + 0.36 * S * COS30 + 14
    yg = iso(Xd, 0.0, 0.0, S, ox, oy)[1]
    yb = iso(Xd, 0.0, -1.4, S, ox, oy)[1]
    ax.add_line(Line2D([dim_x, dim_x], [yg, yb], color=INK2, lw=1.2, zorder=6))
    for yy, dy in ((yg, -7), (yb, 7)):
        ax.add_patch(Polygon([(dim_x, yy), (dim_x - 3.2, yy + dy),
                              (dim_x + 3.2, yy + dy)], closed=True,
                             facecolor=INK2, edgecolor="none", zorder=6))
    ax.add_line(Line2D([iso(Xd,0,0,S,ox,oy)[0], dim_x], [yg, yg],
                       color=INK2, lw=0.7, linestyle=(0,(2,2)), zorder=6))
    ax.add_line(Line2D([iso(Xd,0,-1.4,S,ox,oy)[0], dim_x], [yb, yb],
                       color=INK2, lw=0.7, linestyle=(0,(2,2)), zorder=6))
    ax.text(dim_x + 7, (yg + yb)/2, "BURIED\n4–5 ft", family="monospace",
            fontsize=8, color=INK2, va="center", zorder=6, weight="bold")
    ax.text(iso(0,DEPTH,0.4,S,ox,oy)[0]-118, iso(0,DEPTH,0.4,S,ox,oy)[1],
            "30 MPa pier\nØ320 mm", family="monospace", fontsize=8,
            color=INK2, va="center", zorder=6)
    ah = iso((N_PAIRS-1)*BAY, DEPTH, 0.18, S, ox, oy)
    callout(ax, ah, (ah[0] - 70, ah[1] + 34), "8")

def draw_posts(ox, oy):
    ground_plane(ox, oy, alpha=0.5)
    for p in range(N_PAIRS):
        X = p * BAY
        for Y in (0.0, DEPTH):
            cx, cy = iso(X, Y, 0.0, S, ox, oy)
            ax.add_patch(Ellipse((cx, cy), 0.32*S, 0.32*S*SIN30,
                                 facecolor=SURFACE2, edgecolor=RULE2,
                                 linewidth=1.0, zorder=2))
    for p in range(N_PAIRS):
        X = p * BAY
        box(ax, X - S66/2, DEPTH - S66/2, 0.0, S66, S66, BACK_TOP,
            AMBER_SOFT, AMBER, lw=2.0, S=S, ox=ox, oy=oy,
            z=zkey(X, DEPTH, BACK_TOP/2))
        box(ax, X - S44/2, 0.0 - S44/2, 0.0, S44, S44, FRONT_TOP,
            AMBER_SOFT, AMBER, lw=2.0, S=S, ox=ox, oy=oy,
            z=zkey(X, 0.0, FRONT_TOP/2))

def beam_row(ox, oy, Yc, Ztop, pad):
    """2x8 row beam split at each post X so seams hide behind posts and each span
    carries its own depth key (near post correctly occludes a far beam span)."""
    posts_x = [p * BAY for p in range(N_PAIRS)]
    bounds = sorted(set(round(b, 6) for b in ([-pad] + posts_x + [SPAN_X + pad])))
    for xa, xb in zip(bounds[:-1], bounds[1:]):
        if xb - xa < 1e-6:
            continue
        zc = zkey((xa + xb) / 2, Yc + T2 / 2, Ztop + D8 / 2)
        box(ax, xa, Yc, Ztop, xb - xa, T2, D8, SURFACE, INK, lw=2.0,
            S=S, ox=ox, oy=oy, z=zc, top_k=1.00, left_k=0.90, right_k=0.74)

def draw_beams(ox, oy):
    ground_plane(ox, oy, alpha=0.4)
    for p in range(N_PAIRS):
        X = p * BAY
        box(ax, X - S66/2, DEPTH - S66/2, 0.0, S66, S66, BACK_TOP,
            AMBER_SOFT, AMBER, lw=1.4, S=S, ox=ox, oy=oy,
            z=zkey(X, DEPTH, BACK_TOP/2))
        box(ax, X - S44/2, 0.0 - S44/2, 0.0, S44, S44, FRONT_TOP,
            AMBER_SOFT, AMBER, lw=1.4, S=S, ox=ox, oy=oy,
            z=zkey(X, 0.0, FRONT_TOP/2))
    beam_row(ox, oy, DEPTH - T2/2, BACK_TOP, S66/2)
    beam_row(ox, oy, 0.0 - T2/2, FRONT_TOP, S44/2)
    for p in range(N_PAIRS):
        X = p * BAY
        bx, by = iso(X, DEPTH, BACK_TOP + 0.05, S, ox, oy)
        ax.add_patch(Circle((bx, by), 2.2, facecolor=INK, edgecolor="none", zorder=22))
        fx, fy = iso(X, 0.0, FRONT_TOP + 0.05, S, ox, oy)
        ax.add_patch(Circle((fx, fy), 2.2, facecolor=INK, edgecolor="none", zorder=22))
    Xj = (N_PAIRS - 1) * BAY
    aj = iso(Xj, DEPTH, BACK_TOP + 0.02, S, ox, oy)
    callout(ax, aj, (aj[0] + 60, aj[1] + 30), "9")
    ax.text(aj[0] + 72, aj[1] + 30, "½\" carriage", family="monospace",
            fontsize=7.6, color=INK2, va="center", zorder=31)

# ----------------------------------------------------------------------------
# OPTION-2 TILTABLE-BAY GEOMETRY
#   The sloped frame no longer rigidly lands on the FRONT beam. Instead the
#   whole bay PIVOTS about a T-hinge at the LOW edge (front, Z=zf). The set
#   angle is held by a drilled 2x4 rear brace + pin. We parameterise the
#   sloped plane by a tilt angle (deg) measured from horizontal; the rear
#   (high) edge rises by L*sin(tilt) and runs back by L*cos(tilt).
# ----------------------------------------------------------------------------
PIVOT_Z = FRONT_TOP + D8          # T-hinge sits on the FRONT beam top
LRAFT   = 2.26                    # up-slope frame length (m), per shared geom
PIVOT_Y = 0.0                     # low edge at the front row

def slope_edge(tilt_deg):
    """Return (Y_high, Z_high) of the high edge for a given tilt about pivot."""
    import math
    t = math.radians(tilt_deg)
    return (PIVOT_Y + LRAFT * math.cos(t), PIVOT_Z + LRAFT * math.sin(t))

def draw_slope(ox, oy):
    """STAGE 4 — TILTABLE BAY. Joists are drawn as a hinged plane set at 55°,
    pivoting on a T-hinge at the low (front) edge. Context beams shown light."""
    # context beams (light)
    box(ax, -S66/2, DEPTH - T2/2, BACK_TOP, SPAN_X + S66, T2, D8,
        SURFACE, RULE2, lw=1.2, S=S, ox=ox, oy=oy, z=3)
    box(ax, -S44/2, 0.0 - T2/2, FRONT_TOP, SPAN_X + S44, T2, D8,
        SURFACE, RULE2, lw=1.2, S=S, ox=ox, oy=oy, z=4)

    Yh, Zh = slope_edge(55.0)     # set angle for stage 4
    zf = PIVOT_Z
    D10v = D10

    def P(x, Y, Z):
        return iso(x, Y, Z, S, ox, oy)

    def tilt_joist(Xc, width, depth_dim, fill, stroke, lw, z, alpha=1.0):
        """A joist as a tilted bar: low edge at (Y=0,Z=zf), high edge (Yh,Zh)."""
        x0 = Xc - width/2
        x1 = Xc + width/2
        # top-face normal offset (the joist depth) is applied perpendicular to
        # the slope plane; approximate by lifting depth_dim in +Z (simple & legible).
        L = [P(x0, 0, zf), P(x0, Yh, Zh), P(x0, Yh, Zh+depth_dim), P(x0, 0, zf+depth_dim)]
        R = [P(x1, 0, zf), P(x1, Yh, Zh), P(x1, Yh, Zh+depth_dim), P(x1, 0, zf+depth_dim)]
        T = [P(x0, 0, zf+depth_dim), P(x0, Yh, Zh+depth_dim),
             P(x1, Yh, Zh+depth_dim), P(x1, 0, zf+depth_dim)]
        base = hx(fill)
        ax.add_patch(Polygon(R, closed=True, facecolor=tuple(v*K_RIGHT for v in base),
                             edgecolor=stroke, linewidth=lw, zorder=z, alpha=alpha))
        ax.add_patch(Polygon(L, closed=True, facecolor=tuple(v*K_LEFT for v in base),
                             edgecolor=stroke, linewidth=lw, zorder=z+0.1, alpha=alpha))
        ax.add_patch(Polygon(T, closed=True, facecolor=tuple(v*K_TOP for v in base),
                             edgecolor=stroke, linewidth=lw, zorder=z+0.2, alpha=alpha))

    edge_x = [-S66/2 + T2/2, SPAN_X + S66/2 - T2/2]
    for p in range(N_PAIRS):
        X = p * BAY
        tilt_joist(X, T2, D10v, AMBER_SOFT, AMBER, 1.5, z=6 + p*0.3)
    for ex in edge_x:
        tilt_joist(ex, T2, D6, AMBER_SOFT, AMBER, 1.5, z=5)

    # lateral 2x4 bracing along the tilted plane (sub-section corner stiffening)
    for frac in (0.32, 0.72):
        Yb = Yh * frac
        Zb = zf + (Zh - zf) * frac + D10v
        box(ax, edge_x[0], Yb - D4/2, Zb, (edge_x[1]-edge_x[0]), T2, D4,
            AMBER_SOFT, INK2, lw=1.1, S=S, ox=ox, oy=oy, z=12)

    # ---- T-HINGE at the LOW (front) edge — the new fixed-low replacement ----
    for p in range(N_PAIRS):
        X = p * BAY
        hp = P(X, 0.0, zf)
        # hinge knuckle: small slate barrel + two leaves
        ax.add_patch(Circle((hp[0], hp[1]), 4.4, facecolor=SLATE, edgecolor=INK,
                            linewidth=1.1, zorder=20))
        # leaf onto beam (down) and leaf onto joist (up the slope)
        bn = P(X, 0.0, zf - 0.05)
        ax.add_line(Line2D([hp[0], bn[0]], [hp[1], bn[1]], color=SLATE, lw=3.0, zorder=19))
        upn = P(X, Yh*0.06, zf + (Zh-zf)*0.06)
        ax.add_line(Line2D([hp[0], upn[0]], [hp[1], upn[1]], color=SLATE, lw=3.0, zorder=19))

    # leadered call-out: T-hinge pivot — anchored at the near (rightmost) hinge
    # and leadered well UP into the clear space above the low edge so it clears
    # both the card's bottom border and the stage-5 header chip below.
    Xp = (N_PAIRS - 1) * BAY
    ap = P(Xp, 0.0, zf)
    bxy = (ap[0] + 30, ap[1] + 70)
    callout(ax, ap, bxy, "B1", color=SLATE)
    ax.text(bxy[0] + 16, bxy[1], "T-HINGE PIVOT (LOW EDGE)", family="monospace",
            fontsize=7.4, color=INK2, va="center", zorder=31)

    # tiny rotation arc + arrow at the pivot to imply tiltability
    ar = P(0.0, 0.0, zf)
    th = np.linspace(np.radians(20), np.radians(85), 30)
    rr = 26
    ax.add_line(Line2D(ar[0] + rr*np.cos(th), ar[1] + rr*np.sin(th),
                       color=INK3, lw=1.2, linestyle=(0,(3,2)), zorder=21))
    ahx, ahy = ar[0] + rr*np.cos(th[-1]), ar[1] + rr*np.sin(th[-1])
    ax.add_patch(Polygon([(ahx, ahy), (ahx-5, ahy-2), (ahx-1, ahy-6)], closed=True,
                         facecolor=INK3, edgecolor="none", zorder=21))
    ax.text(ar[0] + 4, ar[1] + rr + 8, "TILT", family="monospace", fontsize=7.0,
            color=INK3, ha="center", va="center", zorder=21)

def draw_panels(ox, oy, card=None):
    """STAGE 5 — BLOCKS + PANELS on the TILTED bay, with GHOST PRESET ANGLES
    (55 set; 45/35/30 ghosted), the DRILLED REAR BRACE (2x4, 4 holes) + PIN.

    FINAL declutter: the solid 55° bay is drawn left-of-centre (origin shifted).
    Ghost presets are drawn ONLY as near-edge side-profile lines fanning DOWN
    from the low-edge pivot (lower tilt -> lower high edge), so they never cross
    the solid panel face. Brace/pin/gusset callouts leader into a clean stacked
    column on the right. The mechanism inset lives in its own reserved frame at
    the bottom-right of the card, clear of the BOM column and the title chip."""
    Yh, Zh = slope_edge(55.0)
    zf = PIVOT_Z
    ztop = D10
    def P(x, Y, Z):
        return iso(x, Y, Z, S, ox, oy)

    # context joists (light) on the tilted plane
    for p in range(N_PAIRS):
        X = p * BAY
        L0 = P(X, 0, zf+ztop)
        L1 = P(X, Yh, Zh+ztop)
        ax.add_line(Line2D([L0[0], L1[0]], [L0[1], L1[1]],
                           color=RULE2, lw=1.4, zorder=2))

    # ---- GHOST PRESET ANGLES (45/35/30) ------------------------------------
    # Drawn as side-profile lines pivoting off the LEFTMOST low edge so the fan
    # opens into clear paper to the LEFT of the solid panel (not across it).
    # Lower tilt => high edge swings back-and-down, so each is a distinct alt
    # panel position; the inset is the precise clarifier.
    piv = P(0.0, 0.0, zf + ztop)
    for gtilt in (45.0, 35.0, 30.0):
        gYh, gZh = slope_edge(gtilt)
        ge = P(0.0, gYh, gZh + ztop)
        ax.add_line(Line2D([piv[0], ge[0]], [piv[1], ge[1]],
                           color=PINE, lw=1.6, linestyle=(0,(5,3)),
                           alpha=0.55, zorder=11))
        ax.add_patch(Circle((ge[0], ge[1]), 2.6, facecolor=SURFACE,
                            edgecolor=PINE, linewidth=1.2, alpha=0.85, zorder=12))
        ax.text(ge[0] + 5, ge[1] - 6, f"{int(gtilt)}°", family="monospace",
                fontsize=8.0, color=PINE, alpha=0.95, ha="left", va="center",
                weight="bold", zorder=12)
    # the 55 set profile line (solid) for completeness of the fan
    se = P(0.0, Yh, Zh + ztop)
    ax.add_line(Line2D([piv[0], se[0]], [piv[1], se[1]], color=PINE,
                       lw=2.4, zorder=11.5))
    # small "GHOST = ALT PRESET" key just under the pivot, in clear paper
    ax.text(piv[0] - 4, piv[1] - 18, "ghost = alt preset",
            family="monospace", fontsize=7.0, color=PINE, ha="center",
            va="center", alpha=0.9, zorder=12)

    # ---- SET (55°) panels — solid pine, 2 cols x 2 rows ---------------------
    ymid = Yh * 0.5
    zmid = zf + (Zh - zf) * 0.5
    rows = [(0.0, ymid, zf, zmid), (ymid, Yh, zmid, Zh)]
    for col in range(2):
        x_a = col * BAY
        x_b = (col+1) * BAY
        for ri, (ya, yb, za, zb2) in enumerate(rows):
            quad = [P(x_a, ya, za+ztop+0.02), P(x_b, ya, za+ztop+0.02),
                    P(x_b, yb, zb2+ztop+0.02), P(x_a, yb, zb2+ztop+0.02)]
            ax.add_patch(Polygon(quad, closed=True, facecolor=PINE,
                                 edgecolor=PINE_STK, linewidth=1.6, zorder=14))
            for t in (0.25, 0.5, 0.75):
                ga = P(x_a + (x_b-x_a)*t, ya, za+ztop+0.03)
                gb = P(x_a + (x_b-x_a)*t, yb, zb2+ztop+0.03)
                ax.add_line(Line2D([ga[0], gb[0]], [ga[1], gb[1]],
                                   color=PINE_LITE, lw=1.0, alpha=0.85, zorder=15))
            for t in (0.5,):
                ga = P(x_a, ya + (yb-ya)*t, za + (zb2-za)*t + ztop + 0.03)
                gb = P(x_b, ya + (yb-ya)*t, za + (zb2-za)*t + ztop + 0.03)
                ax.add_line(Line2D([ga[0], gb[0]], [ga[1], gb[1]],
                                   color=PINE_LITE, lw=1.0, alpha=0.85, zorder=15))
        sa = P(x_a, ymid, zmid + ztop + 0.04)
        sb = P(x_b, ymid, zmid + ztop + 0.04)
        ax.add_line(Line2D([sa[0], sb[0]], [sa[1], sb[1]], color=SURFACE, lw=3.0, zorder=16))
        ax.add_line(Line2D([sa[0], sb[0]], [sa[1], sb[1]], color=PINE_STK, lw=1.4, zorder=16.1))

    # "55° SET" label on the solid panel top edge (clear of the right callouts)
    sl = P(0.0, Yh, Zh + ztop)
    ax.text(sl[0] - 8, sl[1] + 16, "55° SET", family="monospace", fontsize=9.0,
            color=PINE, weight="bold", ha="right", va="center", zorder=17)

    # module-attach blocks at the low edge of the tilted bay
    for p in range(N_PAIRS):
        X = p * BAY
        box(ax, X - 0.045, 0.02, zf + ztop - 0.04, 0.09, 0.06, 0.04,
            AMBER_SOFT, AMBER, lw=1.0, S=S, ox=ox, oy=oy, z=13)

    # ---- DRILLED REAR BRACE (2x4) + PIN at the high edge --------------------
    # The brace runs from the high (rear) edge down to the BACK beam, with 4
    # preset holes; the pin sits in the 55° (top) hole. Drawn on the near side.
    Xb = (N_PAIRS - 1) * BAY
    top_pt = P(Xb, Yh, Zh + ztop*0.3)              # near the high edge of the bay
    base_pt = P(Xb, DEPTH, BACK_TOP)               # down at the back beam
    bdx, bdy = base_pt[0]-top_pt[0], base_pt[1]-top_pt[1]
    blen = (bdx**2 + bdy**2) ** 0.5
    perp = (-bdy/blen, bdx/blen)
    halfw = 4.0
    brace_poly = [
        (top_pt[0]+perp[0]*halfw, top_pt[1]+perp[1]*halfw),
        (top_pt[0]-perp[0]*halfw, top_pt[1]-perp[1]*halfw),
        (base_pt[0]-perp[0]*halfw, base_pt[1]-perp[1]*halfw),
        (base_pt[0]+perp[0]*halfw, base_pt[1]+perp[1]*halfw),
    ]
    ax.add_patch(Polygon(brace_poly, closed=True, facecolor=hx(AMBER_SOFT),
                         edgecolor=AMBER, linewidth=1.4, zorder=18))
    # 4 preset holes along the brace; top one (55°) is PINNED
    hole_pts = []
    for hi in range(4):
        t = 0.16 + hi * 0.2
        hxp = top_pt[0] + bdx*t
        hyp = top_pt[1] + bdy*t
        hole_pts.append((hxp, hyp))
        if hi == 0:
            ax.add_patch(Circle((hxp, hyp), 3.4, facecolor=CLAY, edgecolor=INK,
                                linewidth=1.0, zorder=19))
        else:
            ax.add_patch(Circle((hxp, hyp), 3.0, facecolor=SURFACE, edgecolor=INK2,
                                linewidth=1.3, zorder=19))
    # gusset/doubler plate at the pinned (top) hole
    gp0 = hole_pts[0]
    ax.add_patch(FancyBboxPatch((gp0[0]-9, gp0[1]-9), 18, 18,
                                boxstyle="round,pad=0,rounding_size=2",
                                facecolor="none", edgecolor=SLATE, linewidth=1.4,
                                linestyle=(0,(2,2)), zorder=18.5, alpha=0.9))

    # ---- CLEAN STACKED CALLOUT COLUMN (right side of the card) --------------
    # Anchors at the brace geometry; badges + text stacked vertically so B2/B3/
    # B4 never overlap each other or the solid panel.
    if card is not None:
        cx0, cy0, cw, ch = card
        col_bx = cx0 + cw * 0.595      # badge x for the right stack
    else:
        col_bx = top_pt[0] + 96
    pin_pt = hole_pts[0]
    mid_brace = (top_pt[0] + bdx*0.5, top_pt[1] + bdy*0.5)
    stack = [
        (pin_pt,   "B3", CLAY,  "PIN (REMOVABLE)"),
        (mid_brace,"B2", AMBER, "DRILLED 2×4 BRACE · 4 HOLES"),
        (gp0,      "B4", SLATE, "GUSSET / DOUBLER"),
    ]
    # vertical band for the stack, centred near the brace
    band_yc = (top_pt[1] + base_pt[1]) / 2 + 14
    gaps = (28, 0, -28)
    for (anchor, num, colr, txt), gy in zip(stack, gaps):
        by = band_yc + gy
        callout(ax, anchor, (col_bx, by), num, color=colr)
        ax.text(col_bx + 16, by, txt, family="monospace", fontsize=7.4,
                color=INK2, va="center", zorder=31)

    # module bolt + WEEB callout at a block-to-frame junction (leader down-left)
    Xm = (N_PAIRS - 1) * BAY
    am = iso(Xm, 0.06, zf + ztop, S, ox, oy)
    callout(ax, am, (am[0] + 54, am[1] - 30), "10")
    ax.text(am[0] + 70, am[1] - 30, "¼\" bolt + WEEB", family="monospace",
            fontsize=7.4, color=INK2, va="center", zorder=31)
    # attach-block callout — leadered UP-left so it clears the ghost fan
    ab = iso(0.0, 0.05, zf + ztop, S, ox, oy)
    callout(ax, ab, (ab[0] - 56, ab[1] + 30), "7", color=AMBER)
    ax.text(ab[0] - 72, ab[1] + 30, "BLOCK", family="monospace", fontsize=7.2,
            color=INK2, ha="right", va="center", zorder=31)

    # bond jumper (>=10 AWG) hopping the hinge — the only electrical item shown.
    # A short pine "staple" over the pivot, leadered DOWN-left to clear paper
    # below the ghost fan.
    j0 = P(0.0, 0.0, zf - 0.02)
    j1 = P(0.0, 0.07, zf + 0.11)
    jm = ((j0[0]+j1[0])/2 - 12, (j0[1]+j1[1])/2 + 9)
    ax.add_line(Line2D([j0[0]-6, jm[0], j1[0]-6], [j0[1], jm[1], j1[1]],
                       color=PINE, lw=2.6, zorder=20))
    bjb = (ab[0] - 66, ab[1] - 54)
    callout(ax, jm, bjb, "B6", color=PINE)
    ax.text(bjb[0] - 16, bjb[1], "BOND JUMPER ≥10 AWG", family="monospace",
            fontsize=7.0, color=INK2, va="center", ha="right", zorder=31)

    # ---- INSET: hinge + drilled-brace MECHANISM (side elevation) ------------
    # Re-housed in its own reserved frame at the bottom-RIGHT of the card,
    # clear of the BOM column (x>=1028) and the header chip.
    import math
    iw, ih = 196, 196
    if card is not None:
        cx0, cy0, cw, ch = card
        ix0 = cx0 + cw - iw - 14
        iy0 = cy0 + 16
    else:
        ix0 = ox + 220
        iy0 = oy - 0.30 * S
    ax.add_patch(FancyBboxPatch((ix0, iy0), iw, ih,
                                boxstyle="round,pad=0,rounding_size=5",
                                facecolor=SURFACE2, edgecolor=SLATE,
                                linewidth=1.3, zorder=25))
    ax.text(ix0 + iw/2, iy0 + ih - 12, "MECHANISM DETAIL — SIDE ELEVATION",
            family="monospace", fontsize=7.0, color=INK2, ha="center",
            va="center", weight="bold", zorder=27)
    ax.add_line(Line2D([ix0 + 12, ix0 + iw - 12], [iy0 + ih - 22, iy0 + ih - 22],
                       color=RULE2, lw=0.9, zorder=27))

    # mini side-elevation of the mechanism inside the inset
    mS = 78.0
    mclr = 0.30
    mpx = ix0 + 34
    mpy = iy0 + 40
    def MP(xm, ym):
        return (mpx + xm*mS, mpy + ym*mS)
    # grade line
    ax.add_line(Line2D([ix0+12, ix0+iw-12], [mpy-3, mpy-3], color=INK, lw=1.6, zorder=26))
    # FRONT beam stub under the pivot (low) + BACK post (tall) at right
    front_top = MP(0.0, mclr)
    ax.add_line(Line2D([front_top[0], front_top[0]], [mpy-3, front_top[1]],
                       color=AMBER, lw=4.0, zorder=26))
    bxm = MP(1.30, 0)
    back_post_top = mpy + 1.30*mS
    ax.add_line(Line2D([bxm[0], bxm[0]], [mpy-3, back_post_top], color=AMBER,
                       lw=4.0, zorder=26))
    # pivot (T-hinge) at low edge
    P0m = front_top
    ax.add_patch(Circle(P0m, 4.2, facecolor=SLATE, edgecolor=INK, linewidth=1.1, zorder=29))
    # ghost arms (45/35/30) fanning down from pivot
    Lm = 1.18
    def HEm(deg):
        t = math.radians(deg)
        return MP(Lm*math.cos(t), mclr + Lm*math.sin(t))
    for a in (45, 35, 30):
        h = HEm(a)
        ax.add_line(Line2D([P0m[0], h[0]], [P0m[1], h[1]], color=PINE,
                           lw=1.5, linestyle=(0,(4,3)), alpha=0.5, zorder=27))
        ax.text(h[0]+4, h[1]-1, f"{a}°", family="monospace", fontsize=6.6,
                color=PINE, alpha=0.85, va="center", zorder=27)
    # the SET (55) arm — bold solid pine
    he = HEm(55)
    ax.add_line(Line2D([P0m[0], he[0]], [P0m[1], he[1]], color=PINE, lw=4.2, zorder=28))
    ax.text(he[0]+4, he[1]+3, "55°", family="monospace", fontsize=7.4, color=PINE,
            weight="bold", va="center", zorder=28)
    # drilled brace: vertical bar on the back post with 4 holes; top hole pinned
    brace_top = MP(1.30, 1.04)
    brace_bot = MP(1.30, 0.14)
    ax.add_line(Line2D([brace_top[0], brace_top[0]], [brace_top[1], brace_bot[1]],
                       color=AMBER, lw=5.0, zorder=26.5))
    pin_y = None
    for hi in range(4):
        ty = brace_top[1] - (brace_top[1]-brace_bot[1]) * (0.08 + hi*0.27)
        if hi == 0:
            pin_y = ty
            ax.add_patch(Circle((brace_top[0], ty), 3.0, facecolor=CLAY,
                                edgecolor=INK, linewidth=0.9, zorder=29))
        else:
            ax.add_patch(Circle((brace_top[0], ty), 2.4, facecolor=SURFACE,
                                edgecolor=INK2, linewidth=1.1, zorder=29))
    # strut from the SET (55) high edge to the pinned hole (clay)
    ax.add_line(Line2D([he[0], brace_top[0]], [he[1], pin_y], color=CLAY,
                       lw=2.2, zorder=27.5))
    # mini labels (de-collided)
    ax.text(P0m[0]-6, P0m[1]+10, "T-hinge", family="monospace", fontsize=6.4,
            color=SLATE, ha="left", va="center", zorder=29)
    ax.text(brace_top[0]+6, brace_top[1]+6, "drilled\nbrace", family="monospace",
            fontsize=6.4, color=AMBER, ha="left", va="bottom", zorder=29)
    ax.add_patch(Circle((brace_top[0]+34, pin_y), 3.0, facecolor=CLAY,
                        edgecolor=INK, linewidth=0.8, zorder=29))
    ax.text(brace_top[0]+40, pin_y, "pin", family="monospace", fontsize=6.4,
            color=CLAY, ha="left", va="center", zorder=29)

# ----------------------------------------------------------------------------
# RENDER THE 5 STAGES
#   1 FOOTINGS    -> [8]
#   2 POSTS       -> [1,2]
#   3 BEAMS       -> [3,9]
#   4 TILTABLE BAY-> [4,5,6,B1]   (joists 2x10=4, 2x6=5, 2x4=6 + T-hinge=B1)
#   5 BLOCKS+PAN. -> [7,10,B2,B3,B4,B6]
# ----------------------------------------------------------------------------
stages = [
    (1, "FOOTINGS", "16 piers · 30 MPa · Ø320 mm", ["8"], draw_footings),
    (2, "POSTS", "8× 6×6 back · 8× 4×4 front", ["1", "2"], draw_posts),
    (3, "BEAMS", "2×8 on each row, full width", ["3", "9"], draw_beams),
    (4, "TILTABLE BAY · T-HINGE", "joists pivot at low edge · 2×10 / 2×6 · 2×4 ties",
     ["4", "5", "6", "B1"], draw_slope),
    (5, "BLOCKS + PANELS · PRESETS", "14× Waaree 575 W · 55° set, 45/35/30° presets",
     ["7", "10", "B2", "B3"], draw_panels),
]

# stages 4 (tiltable bay) & 5 (panels+mechanism) bias the iso LEFT (fx<0.5) so the
# right third of the card is a clean zone for callouts and the mechanism inset.
STAGE_FX = {3: 0.42, 4: 0.37}

for idx, (num, name, sub, calls, fn) in enumerate(stages):
    x0, y0, w, h = draw_card(idx, num, name, sub)
    stage_label_chip(x0, y0, h, calls)
    zlo, zhi = STAGE_Z.get(fn.__name__, (0.0, BACK_TOP))
    ox, oy = centered_origin((x0, y0, w, h), zlo, zhi, fx=STAGE_FX.get(idx, 0.52))
    if fn is draw_panels:
        fn(ox, oy, card=(x0, y0, w, h))
    else:
        fn(ox, oy)
    if idx < len(stages) - 1:
        down_arrow(idx)

# ---- title chip on the bottom (panels) stage: ADJUST ~4x/YR ------------------
# Re-housed into the stage-5 HEADER band (top, just right of the title block)
# so it no longer sits on top of the mechanism inset.
x0, y0, w, h = tier_rect(4)
chip_w, chip_h = 300, 24
cxp, cyp = x0 + 360, y0 + h - 36
ax.add_patch(FancyBboxPatch((cxp, cyp), chip_w, chip_h,
                            boxstyle="round,pad=0,rounding_size=5",
                            facecolor=PINE_SOFT, edgecolor=PINE, linewidth=1.2,
                            zorder=33))
ax.text(cxp + chip_w/2, cyp + chip_h/2, "ADJUST ~4×/YR · PIN INTO PRESET HOLE",
        family="monospace", fontsize=8.6, color=PINE, weight="bold",
        ha="center", va="center", zorder=34)

# ============================================================================
# RIGHT COLUMN — BOM LEGEND BOX (base + Part-B adds)
# ============================================================================
BOX_X = RIGHT_X
BOX_W = 1300 - RIGHT_X - 18
BOX_Y = 96 + YSHIFT
BOX_H = 1466
ax.add_patch(FancyBboxPatch((BOX_X, BOX_Y), BOX_W, BOX_H,
                            boxstyle="round,pad=0,rounding_size=6",
                            facecolor=SURFACE, edgecolor=RULE2, linewidth=1.2,
                            zorder=10))
hb_h = 30
ax.add_patch(FancyBboxPatch((BOX_X, BOX_Y + BOX_H - hb_h), BOX_W, hb_h,
                            boxstyle="round,pad=0,rounding_size=6",
                            facecolor=SURFACE2, edgecolor=RULE2, linewidth=1.0,
                            zorder=11))
ax.text(BOX_X + BOX_W/2, BOX_Y + BOX_H - hb_h/2, "BILL OF MATERIALS · OPTION 2",
        family="monospace", fontsize=10.5, color=INK3, weight="bold",
        ha="center", va="center", zorder=12)

AMB = ("amber", AMBER, AMBER_SOFT)
BM  = ("beam", INK, SURFACE)
PN  = ("pine", PINE, PINE_SOFT)
HW  = ("slate", SLATE, SLATE)
FT  = ("foot", INK2, SURFACE2)
CL  = ("clay", CLAY, CLAY)

bom = [
    ("1", "Back posts", "6×6 UC4A ~3m MCA", "8", AMB),
    ("2", "Front posts", "4×4 UC4A MCA", "8", AMB),
    ("3", "Beams", "2×8 MCA", "~16", BM),
    ("4", "Inside joists", "2×10 cut 55° (upsize)", "~14", AMB),
    ("5", "Outside joists", "2×6 cut 55°", "2", AMB),
    ("6", "Lateral bracing", "2×4 MCA", "~20", AMB),
    ("7", "Module blocks", "offcut MCA", "~56", AMB),
    ("8", "Footings", "30 MPa pier Ø320", "16", FT),
    ("9", "Beam-post bolts", "½\" carriage SS/HDG", "~32", HW),
    ("10", "Module bolts", "¼\"×2½\" SS + WEEB", "~56", HW),
    ("11", "Fence brackets", "HDG", "~60", HW),
    ("B1", "T-hinges", "HDG, low-edge pivot", "~8", HW),
    ("B2", "Drilled rear braces", "2×4 · 4 preset holes", "~8", AMB),
    ("B3", "Adjustment pins", "⅜–½\" removable", "~10", CL),
    ("B4", "Gusset/doubler plt", "at holes · eng-set", "~8 pr", HW),
    ("B5", "Sub-sect corner bolts", "HDG", "~16", HW),
]

row_top = BOX_Y + BOX_H - hb_h - 12
# bottom reserve: family key (~118 px) + provenance note (~96 px) + retilt note
note_zone = 246
row_area = (row_top - (BOX_Y + note_zone))
rh = row_area / len(bom)

for i, (badge, name, spec, qty, fam) in enumerate(bom):
    ry = row_top - i * rh - rh/2
    if i > 0:
        ax.add_line(Line2D([BOX_X + 10, BOX_X + BOX_W - 10],
                           [ry + rh/2, ry + rh/2], color=RULE,
                           lw=0.8, zorder=11))
    # Part-B section divider before B1
    if badge == "B1":
        ax.add_line(Line2D([BOX_X + 10, BOX_X + BOX_W - 10],
                           [ry + rh/2, ry + rh/2], color=AMBER, lw=1.4, zorder=11.5))
        ax.text(BOX_X + 14, ry + rh/2 + 8, "— PART B · SEASONAL-ADJUSTABLE ADDER —",
                family="monospace", fontsize=7.0, color=AMBER, weight="bold",
                va="center", zorder=12)
    ax.add_patch(Circle((BOX_X + 26, ry + 7), 11, facecolor=SURFACE,
                        edgecolor=INK2, linewidth=1.3, zorder=13))
    ax.text(BOX_X + 26, ry + 7, badge, family="monospace",
            fontsize=9.5 if len(badge) == 1 else 7.6,
            color=INK, ha="center", va="center", zorder=14)
    ax.add_patch(Circle((BOX_X + 26, ry - 12), 4.5, facecolor=fam[1],
                        edgecolor="none", zorder=14))
    ax.text(BOX_X + 46, ry + 10, name, family="monospace", fontsize=9.8,
            color=INK, weight="bold", va="center", zorder=13)
    ax.text(BOX_X + 46, ry - 5, spec, family="monospace", fontsize=8.0,
            color=INK2, va="center", zorder=13)
    ax.text(BOX_X + BOX_W - 12, ry + 4, qty, family="monospace", fontsize=11,
            color=INK, weight="bold", ha="right", va="center", zorder=13)

ny = BOX_Y + note_zone - 8
ax.add_line(Line2D([BOX_X + 10, BOX_X + BOX_W - 10], [ny, ny],
                   color=RULE2, lw=1.0, zorder=12))
note = ("base structure = INHERITED\n"
        "(Vandewetering/Hayibo/Pearce,\nDesigns 2022 6(3):41) — estimate.\n"
        "Part-B adjustable adder open-source\n"
        "(Pearce, Designs 2022 6(3):54).\n"
        "RE-TILT ONLY WHEN PINNED INTO\nA PRESET HOLE.")
ax.text(BOX_X + 14, ny - 12, note, family="monospace", fontsize=7.6,
        color=INK3, va="top", zorder=12)

# 5-swatch family key, anchored to the bottom of the box BELOW the note
keys = [("amber=timber", AMBER), ("beam=2×8", INK), ("pine=panel", PINE),
        ("slate=hardware/hinge", SLATE), ("clay=pin", CLAY)]
kx = BOX_X + 14
ky = BOX_Y + 14
for lbl, col in keys:
    ax.add_patch(Polygon([(kx, ky), (kx+12, ky), (kx+12, ky+10), (kx, ky+10)],
                         closed=True, facecolor=col, edgecolor=INK2,
                         linewidth=0.6, zorder=13))
    ax.text(kx + 16, ky + 5, lbl, family="monospace", fontsize=7.4,
            color=INK2, va="center", zorder=13)
    ky += 17

# ============================================================================
# ASSEMBLY-ORDER RIBBON — thin vertical track at the far left margin
# ============================================================================
rib_x = 16
ax.add_line(Line2D([rib_x, rib_x], [TIER_BOT, TIER_TOP], color=RULE2,
                   lw=1.4, zorder=2))
ribbon = ["FOOTINGS", "POSTS", "BEAMS", "TILT BAY", "PANELS"]
for idx, lbl in enumerate(ribbon):
    x0, y0, w, h = tier_rect(idx)
    yc = y0 + h/2
    ax.add_patch(Circle((rib_x, yc), 7, facecolor=SURFACE, edgecolor=INK2,
                        linewidth=1.2, zorder=3))
    ax.text(rib_x, yc, str(idx+1), family="monospace", fontsize=8, color=INK,
            ha="center", va="center", zorder=4)
    ax.text(rib_x + 12, yc, lbl, family="monospace", fontsize=8, color=INK3,
            rotation=90, ha="center", va="center", zorder=4)

# ============================================================================
# BOTTOM STRIP — provenance + scale bar
# ============================================================================
prov = ("PROVENANCE: SOLAR_RACKING_HANDOFF §5 base + takeoff Part B · adjustable "
        "open-source (Pearce, Designs 2022 6(3):54) · energy 15_tilt_sweep.py · "
        "geometry racking-diagrams.jsx · matplotlib/Agg · 2026-06-06")
ax.text(34, 60, prov, family="monospace", fontsize=7.6, color=INK3, va="center")

sb_x = 34
sb_y = 30
seg = 0.5 * S
n_seg = 4
ax.text(sb_x, sb_y + 4, "0", family="monospace", fontsize=7.5, color=INK3,
        ha="right", va="center")
for k in range(n_seg):
    col = INK2 if k % 2 == 0 else SURFACE
    ax.add_patch(Polygon([(sb_x + k*seg, sb_y), (sb_x + (k+1)*seg, sb_y),
                          (sb_x + (k+1)*seg, sb_y + 8), (sb_x + k*seg, sb_y + 8)],
                         closed=True, facecolor=col, edgecolor=INK,
                         linewidth=1.0, zorder=5))
ax.text(sb_x + n_seg*seg + 8, sb_y + 4,
        f"{n_seg*0.5:.1f} m / ~{n_seg*0.5*3.281:.1f} ft  · VIGNETTE SCALE",
        family="monospace", fontsize=7.5, color=INK3, ha="left", va="center")

# ----------------------------------------------------------------------------
# EXPORT
# ----------------------------------------------------------------------------
import os
OUT = "/Users/jasonkrueger/Library/CloudStorage/GoogleDrive-jasonkrue@gmail.com/My Drive/prsm/electrical-review/artifacts/racking/exploded"
png = os.path.join(OUT, "exploded_opt2_final.png")
svg = os.path.join(OUT, "exploded_opt2_final.svg")
fig.savefig(png, dpi=200, facecolor=PAPER, bbox_inches="tight", pad_inches=0.22)
fig.savefig(svg, facecolor=PAPER, bbox_inches="tight", pad_inches=0.22)
print("wrote", png)
print("wrote", svg)