OverviewFilesCommitsIssuesDocsReleasesPeers

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

826 lines · python ref:
#!/usr/bin/env python3
"""
FIG. R6 — EXPLODED ASSEMBLY (tiered instruction-sheet variant) · FINAL
PRSM Phase-1 ground-mount · INHERITED racking
(SOLAR_RACKING_HANDOFF §5; Vandewetering/Hayibo/Pearce, Designs 2022 6(3):41)

VISUALIZATION ONLY of an inherited engineering ESTIMATE — not a stamped calc.
No electrical numbers. Geometry locked from racking-diagrams.jsx + handoff §5.

Layout variant: instruction-sheet. Each assembly STAGE is its own numbered
isometric vignette on its own tier/row, with a downward arrow to the next
stage. Right column carries the full Phase-1 BOM legend.

This is the FINAL pass with judge-mandated fixes applied:
  - panels: visible inter-panel SEAM + bolder lighter-tint cell grid +
    face-on inset of one 2-high module pair so module count is countable
  - hardware 9/10/11: explicit leadered call-outs (dashed lead-dot + circled
    badge) tied to real geometry inside stages 3, 4, 5
  - footings: below-grade pier shaft darkened/hatched + 'BURIED 4-5 ft' depth
    dimension leader so piers read as buried, distinct from on-grade posts
  - scale bar: raised into clear paper + extra bottom margin so it is uncropped
  - stage chips: extra leading below header; numbers verified vs drawn parts
  - beams: top-face vs side-face shading contrast bumped so they read as solids
  - exploded read: 2-3 dashed drop-leaders + chevrons within stage 5
"""
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 (fix #1)
SLATE     = "#456783"

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 already used for footings/posts.) Keep results in a band clear of the
# UI layers: cards z=1, ground z=1.5, bolt ticks z=22, call-outs z≥29.
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).
    Optional per-face shading overrides (top_k/left_k/right_k) let callers
    bump face contrast — used for the near-white BEAMS (fix #6)."""
    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 face (the +X facing side, c1-c2-c6-c5)
    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 face (the -Y / front-facing side, c0-c1-c5-c4)
    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 face (c4-c5-c6-c7)
    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. Approx with ellipse top
    + side rectangle.  Center at (X,Y).  `hatch` adds a buried-shaft texture."""
    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 body
    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))
    # bottom ellipse (partial, faint)
    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))
    # top ellipse
    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))
    # aggregate speckle
    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 (fix #2 — physically tie 9/10/11 to geometry)
# ----------------------------------------------------------------------------
def callout(ax, anchor_xy, badge_xy, num, z=30, color=INK, ring=SURFACE):
    """Dashed lead-dot leader from a geometry anchor to a circled-number badge."""
    ax_pt, ay = anchor_xy
    bx, by = badge_xy
    # dashed leader
    ax.add_line(Line2D([ax_pt, bx], [ay, by], color=INK3, lw=1.0,
                       linestyle=(0, (3, 3)), zorder=z))
    # lead-dot at the geometry anchor
    ax.add_patch(Circle((ax_pt, ay), 2.6, facecolor=INK, edgecolor="none", zorder=z+0.5))
    # circled-number badge
    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)        # +40 px headroom at bottom for the scale bar (fix #4)
ax.set_aspect("equal")
ax.axis("off")

# Vertical shift so all former y-coordinates clear the new bottom band.
YSHIFT = 40

# Layout columns
LEFT_W = 1010
RIGHT_X = 1028

# ---- TOP STRIP: fig label + title --------------------------------------------
ax.text(34, 1560 + YSHIFT, "FIG. R6 · 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, "PRSM PHASE-1 GROUND-MOUNT · INHERITED RACKING",
        family="monospace", fontsize=16, color=INK, weight="bold")
ax.text(34, 1500 + YSHIFT, "REPRESENTATIVE 2 OF 7 BAYS · 14 × WAAREE 575 W · 55° TILT",
        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)

# ============================================================================
# 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 = 1466 + 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):
    """i=0 top .. 4 bottom. Returns (x0,y0,w,h) of the card."""
    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)
    # stage number badge
    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)
    # title + sub
    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):
    """small chips listing the call-out numbers covered by this stage.
    Extra leading below the sub-spec line so chips don't crowd header (fix #5)."""
    cx = x0 + 64
    cyy = y0 + h - 66       # dropped from -60 to -66 for breathing room
    for c in callouts:
        ax.add_patch(Circle((cx, cyy), 10.5, facecolor=SURFACE, edgecolor=INK2,
                            linewidth=1.3, zorder=6))
        ax.text(cx, cyy, c, family="monospace", fontsize=8.5, color=INK,
                ha="center", va="center", zorder=7)
        cx += 28

def down_arrow(i):
    """Down arrow from tier i to tier i+1 along left-ish axis."""
    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):
    """Faint ground parallelogram at Z=0 spanning front(Y=0)->back(Y=DEPTH)."""
    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):
    """Place iso origin: drawing occupies right ~62% of card."""
    ox = x0 + w * 0.50
    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 band). iso() is linear, so the screen bbox is the
    min/max over the world-AABB corners — center THAT, not a fixed fraction, so
    each stage sits centered regardless of how its geometry extends."""
    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                                   # horizontal target
    tcy = y0 + margin + (h - header - margin) / 2.0     # vertical center below header
    return tcx - cx0, tcy - cy0

# per-stage vertical world extent (Z), for centering
_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):
    """FIX #3: clearly show piers BURIED below grade.
    - draw the below-grade shaft with a darker fill + diagonal hatch
    - lay a soil/grade hatch band just below the dashed grade line
    - 'BURIED 4–5 ft' depth dimension leader with arrowheads
    Posts in stage 2 sit ON grade; here the shafts are clearly sunk."""
    # below-grade shafts first (far row, then near row)
    for Y in (DEPTH, 0.0):
        for p in range(N_PAIRS):
            X = p * BAY
            # buried portion: from grade Z=0 down 1.4 m. Darkened + hatched.
            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)
    # translucent ground plane at grade (Z=0) over the buried shafts
    gp = ground_plane(ox, oy, alpha=0.5)
    # soil hatch band just below grade front edge so 'below = ground' reads
    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))
    # small above-grade stub tops so the pier head is visible ON grade
    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)
    # frost line (dashed) parallel to grade, ~1.2 m below
    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)
    # ---- depth dimension leader (BURIED 4–5 ft) on the near-right pier ----
    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]            # grade
    yb = iso(Xd, 0.0, -1.4, S, ox, oy)[1]           # pier bottom
    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)):              # arrowheads
        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))
    # tie lines to grade & bottom
    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)
    # callout ① (footing item label) -> a pier head ; ⑧ via chip/BOM
    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))
    # back posts 6x6 (Y=DEPTH), front posts 4x4 (Y=0) — depth-sorted per pier
    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):
    """A row beam (2x8) running in +X. Split at each post X so the segment seams
    hide BEHIND the posts and every span carries its own depth key — a near post
    then correctly occludes a far beam span (fix: layering when diagramming 3D).
    .beam keeps fill=var(--surface)/stroke=var(--ink); boosted face contrast."""
    posts_x = [p * BAY for p in range(N_PAIRS)]
    bounds = [-pad] + posts_x + [SPAN_X + pad]
    bounds = sorted(set(round(b, 6) for b in bounds))
    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)
    # context posts — depth-sorted so the beams below can tuck behind near posts
    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))
    # the two row beams, segmented + depth-keyed (back row first is implicit in z)
    beam_row(ox, oy, DEPTH - T2/2, BACK_TOP, S66/2)
    beam_row(ox, oy, 0.0 - T2/2, FRONT_TOP, S44/2)
    # bolt ticks at junctions (above all members)
    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))
    # FIX #2: explicit ⑨ leadered call-out — ½" carriage bolt at a beam-post junction
    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)

def draw_slope(ox, oy):
    # 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)
    zf = FRONT_TOP + D8
    zb = BACK_TOP + D8
    def sloped_joist(Xc, width, depth_dim, fill, stroke, lw, z):
        x0 = Xc - width/2
        x1 = Xc + width/2
        def P(x, Y, Z):
            return iso(x, Y, Z, S, ox, oy)
        L = [P(x0, 0, zf), P(x0, DEPTH, zb), P(x0, DEPTH, zb+depth_dim), P(x0, 0, zf+depth_dim)]
        R = [P(x1, 0, zf), P(x1, DEPTH, zb), P(x1, DEPTH, zb+depth_dim), P(x1, 0, zf+depth_dim)]
        T = [P(x0, 0, zf+depth_dim), P(x0, DEPTH, zb+depth_dim),
             P(x1, DEPTH, zb+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))
        ax.add_patch(Polygon(L, closed=True, facecolor=tuple(v*K_LEFT for v in base),
                             edgecolor=stroke, linewidth=lw, zorder=z+0.1))
        ax.add_patch(Polygon(T, closed=True, facecolor=tuple(v*K_TOP for v in base),
                             edgecolor=stroke, linewidth=lw, zorder=z+0.2))
    edge_x = [-S66/2 + T2/2, SPAN_X + S66/2 - T2/2]
    for p in range(N_PAIRS):
        X = p * BAY
        sloped_joist(X, T2, D10, AMBER_SOFT, AMBER, 1.5, z=6 + p*0.3)
    for ex in edge_x:
        sloped_joist(ex, T2, D6, AMBER_SOFT, AMBER, 1.5, z=5)
    # lateral 2x4 bracing
    for frac in (0.32, 0.72):
        Yb = DEPTH * frac
        Zb = zf + (zb - zf) * frac + D10
        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)
    # FIX #2: explicit ⑪ leadered call-out — fence bracket at a joist-to-beam junction
    Xb = (N_PAIRS - 1) * BAY
    ab = iso(Xb, 0.0, zf, S, ox, oy)
    # small bracket glyph at the junction
    ax.add_patch(FancyBboxPatch((ab[0]-5, ab[1]-5), 10, 10,
                                boxstyle="round,pad=0.4,rounding_size=1.5",
                                facecolor=SLATE, edgecolor=INK, linewidth=1.0,
                                zorder=29, alpha=0.9))
    callout(ax, ab, (ab[0] + 64, ab[1] - 28), "11", color=SLATE)
    ax.text(ab[0] + 78, ab[1] - 28, "HDG bracket", family="monospace",
            fontsize=7.6, color=INK2, va="center", zorder=31)

def draw_panels(ox, oy):
    """FIX #1 + #2 + #7(opt): countable 2-high × 7-wide panels with a clear
    inter-panel seam, bolder lighter cell grid, a small face-on inset of one
    2-high module pair, dashed drop-leaders/chevrons, and a ⑩ module-bolt+WEEB
    call-out at a block-to-frame junction."""
    zf = FRONT_TOP + D8
    zb = BACK_TOP + D8
    def P(x, Y, Z):
        return iso(x, Y, Z, S, ox, oy)
    ztop = D10
    # context joists (light)
    for p in range(N_PAIRS):
        X = p * BAY
        L = [P(X, 0, zf+ztop), P(X, DEPTH, zb+ztop)]
        ax.add_line(Line2D([L[0][0], L[1][0]], [L[0][1], L[1][1]],
                           color=RULE2, lw=1.4, zorder=2))
    # 2 columns (X) × 2 rows (lower/upper along slope)
    ymid = DEPTH * 0.5
    rows = [(0.0, ymid, zf, zf + (zb-zf)*0.5),
            (ymid, DEPTH, zf + (zb-zf)*0.5, zb)]
    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))
            # FIX #1: bolder, lighter-tint cell grid (lw ~1.0, PINE_LITE)
            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))
        # FIX #1: emphatic inter-panel SEAM between lower & upper row of the pair
        sa = P(x_a, ymid, zf + (zb-zf)*0.5 + ztop + 0.04)
        sb = P(x_b, ymid, zf + (zb-zf)*0.5 + 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))
    # module-attach blocks peeking at panel lower edge
    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)

    # ---- FIX #2: ⑩ module bolt + WEEB at a block-to-frame junction ----
    Xm = (N_PAIRS - 1) * BAY
    am = iso(Xm, 0.06, zf + ztop, S, ox, oy)
    callout(ax, am, (am[0] + 58, am[1] - 26), "10")
    ax.text(am[0] + 72, am[1] - 26, "¼\" bolt + WEEB", family="monospace",
            fontsize=7.4, color=INK2, va="center", zorder=31)
    # ⑦ attach-block callout
    ab = iso(0.0, 0.05, zf + ztop, S, ox, oy)
    callout(ax, ab, (ab[0] - 62, ab[1] - 24), "7", color=AMBER)

    # ---- FIX #7(opt): dashed DROP-LEADERS + chevrons (explicit "exploded") ----
    # A panel + a block shown lifting off the frame, dropping into place.
    # drop leader 1: a panel quad floated above, with a dashed drop line + chevron
    lift = 0.55
    fx_a = 0.0
    fx_b = BAY
    gq = [P(fx_a, 0.0, zf+ztop+lift), P(fx_b, 0.0, zf+ztop+lift),
          P(fx_b, ymid, zf+(zb-zf)*0.5+ztop+lift), P(fx_a, ymid, zf+(zb-zf)*0.5+ztop+lift)]
    ax.add_patch(Polygon(gq, closed=True, facecolor=PINE, edgecolor=PINE_STK,
                         linewidth=1.2, zorder=22, alpha=0.45))
    # drop lines at two corners with downward chevrons onto the frame
    for cxy_top, cxy_bot in (
        (P(fx_a, 0.0, zf+ztop+lift), P(fx_a, 0.0, zf+ztop+0.05)),
        (P(fx_b, ymid, zf+(zb-zf)*0.5+ztop+lift), P(fx_b, ymid, zf+(zb-zf)*0.5+ztop+0.05)),
    ):
        ax.add_line(Line2D([cxy_top[0], cxy_bot[0]], [cxy_top[1], cxy_bot[1]],
                           color=INK3, lw=1.0, linestyle=(0,(4,3)), zorder=23))
        # downward chevron near the landing point
        cx, cy = cxy_bot
        ax.add_line(Line2D([cx-4, cx, cx+4], [cy+9, cy+3, cy+9],
                           color=INK2, lw=1.3, zorder=24))
    # drop leader 3: a single attach block dropping in
    bt = P(0.5*BAY, -0.18, zf + ztop + lift*0.7)
    bb = P(0.5*BAY, -0.02, zf + ztop + 0.04)
    box(ax, 0.5*BAY - 0.045, -0.20, zf + ztop + lift*0.7,
        0.09, 0.06, 0.05, AMBER_SOFT, AMBER, lw=1.0, S=S, ox=ox, oy=oy,
        z=22, alpha=0.55)
    ax.add_line(Line2D([bt[0], bb[0]], [bt[1], bb[1]], color=INK3, lw=1.0,
                       linestyle=(0,(4,3)), zorder=23))
    ax.add_line(Line2D([bb[0]-4, bb[0], bb[0]+4], [bb[1]+9, bb[1]+3, bb[1]+9],
                       color=INK2, lw=1.3, zorder=24))

    # ---- FIX #1: face-on INSET of one 2-high × 1-wide module pair ----
    iw, ih = 86, 150
    ix0 = ox + 1.95 * BAY * COS30 * S - 6      # to the right of the iso array
    iy0 = oy - 0.10 * S
    ax.add_patch(FancyBboxPatch((ix0, iy0), iw, ih,
                                boxstyle="round,pad=0,rounding_size=4",
                                facecolor=SURFACE2, edgecolor=RULE2,
                                linewidth=1.0, zorder=25))
    ax.text(ix0 + iw/2, iy0 + ih + 9, "1 PAIR (2-HIGH)", family="monospace",
            fontsize=6.8, color=INK3, ha="center", va="center", zorder=27)
    pad = 8
    pw = iw - 2*pad
    ph = (ih - 3*pad) / 2.0
    for r in range(2):
        py0 = iy0 + pad + r*(ph + pad)
        ax.add_patch(FancyBboxPatch((ix0+pad, py0), pw, ph,
                                    boxstyle="round,pad=0,rounding_size=2",
                                    facecolor=PINE, edgecolor=PINE_STK,
                                    linewidth=1.2, zorder=26))
        # 6x4 cell grid per module so they read as PV
        for cc in range(1, 6):
            gx = ix0 + pad + pw*cc/6.0
            ax.add_line(Line2D([gx, gx], [py0+2, py0+ph-2],
                               color=PINE_LITE, lw=0.8, alpha=0.8, zorder=27))
        for rr in range(1, 4):
            gy = py0 + ph*rr/4.0
            ax.add_line(Line2D([ix0+pad+2, ix0+pad+pw-2], [gy, gy],
                               color=PINE_LITE, lw=0.8, alpha=0.8, zorder=27))
    # seam line between the two modules in the inset
    sy = iy0 + pad + ph + pad/2.0
    ax.add_line(Line2D([ix0+pad, ix0+pad+pw], [sy, sy], color=INK2,
                       lw=1.6, zorder=28))

# ----------------------------------------------------------------------------
# RENDER THE 5 STAGES
# Stage chips verified vs parts actually drawn (fix #5):
#   1 FOOTINGS    -> [8]      (only footings=item 8 drawn; item 1 'back posts'
#                              is NOT in this vignette -> removed stray '1')
#   2 POSTS       -> [1,2]    (back posts=1, front posts=2 are the parts drawn;
#                              no bolts drawn here -> '9' removed, lives in BEAMS)
#   3 BEAMS       -> [3,9]    (beams=3 + ⑨ ½" carriage bolt call-out drawn)
#   4 JOISTS+BR.  -> [4,5,6,11] (2x10=4, 2x6=5, 2x4=6 + ⑪ fence bracket drawn)
#   5 BLOCKS+PAN. -> [7,10]   (blocks=7, panels, + ⑩ module bolt call-out drawn)
# ----------------------------------------------------------------------------
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, "JOISTS + BRACING", "2×10 / 2×6 cut 55° · 2×4 ties", ["4", "5", "6", "11"], draw_slope),
    (5, "BLOCKS + PANELS", "attach blocks · 14× Waaree 575 W", ["7", "10"], draw_panels),
]

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)
    fn(ox, oy)
    if idx < len(stages) - 1:
        down_arrow(idx)

# ============================================================================
# RIGHT COLUMN — BOM LEGEND BOX (full Phase-1 quantities)
# ============================================================================
BOX_X = RIGHT_X
BOX_W = 1300 - RIGHT_X - 18
BOX_Y = 96 + YSHIFT
BOX_H = 1430
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 · PHASE-1",
        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)

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

row_top = BOX_Y + BOX_H - hb_h - 14
# bottom reserve: 4-swatch key (~100 px) + 4-line provenance note (~70 px) + pad
note_zone = 200
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))
    fam_stroke = fam[1]
    ax.add_patch(Circle((BOX_X + 26, ry + 8), 11, facecolor=SURFACE,
                        edgecolor=INK2, linewidth=1.3, zorder=13))
    ax.text(BOX_X + 26, ry + 8, badge, family="monospace",
            fontsize=9.5 if len(badge) == 1 else 8.0,
            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 + 11, name, family="monospace", fontsize=10.5,
            color=INK, weight="bold", va="center", zorder=13)
    ax.text(BOX_X + 46, ry - 5, spec, family="monospace", fontsize=8.3,
            color=INK2, va="center", zorder=13)
    ax.text(BOX_X + BOX_W - 12, ry + 4, qty, family="monospace", fontsize=12,
            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 = ("structural members = INHERITED\n"
        "(Vandewetering/Hayibo/Pearce,\nDesigns 2022 6(3):41)\n"
        "— engineering estimate")
ax.text(BOX_X + 14, ny - 12, note, family="monospace", fontsize=8.0,
        color=INK3, va="top", zorder=12)

# 4-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", SLATE)]
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.6,
            color=INK2, va="center", zorder=13)
    ky += 18

# ============================================================================
# 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", "SLOPE FRAME", "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  (FIX #4: raised into clear paper)
# ============================================================================
prov = ("PROVENANCE: SOLAR_RACKING_HANDOFF §5 · geometry from "
        "racking-diagrams.jsx (locked) · rendered matplotlib/Agg · 2026-06-06")
ax.text(34, 60, prov, family="monospace", fontsize=8.5, color=INK3, va="center")

# graphic scale bar (0.5 m segments at the vignette scale S) — moved up
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_opt1_final.png")
svg = os.path.join(OUT, "exploded_opt1_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)