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