#!/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)