Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://poppyup/wiring/sheet.py

wiring/sheet.py

16 KB · 293 lines · python · Download from the repository page

wiring/sheet.py
# SVG sheet framework for the PoppyUp wiring set. A3 landscape at 96 dpi (1587 × 1123 px, 3.78 px/mm).# Layers are SVG <g id="L-..."> groups so the files stay editable in Inkscape / Illustrator.import json, math, osimport model W, H = 1587, 1123PX_PER_MM = W / 420.0OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sheets")PREVIEW = os.path.join(os.path.dirname(os.path.abspath(__file__)), "preview") # plotted lineweights (mm) → pxdef mm(v): return v * PX_PER_MMLW = dict(outline=mm(0.50), primary=mm(0.35), secondary=mm(0.25), fine=mm(0.18), hatch=mm(0.13)) # connection classes: colour + dash reinforce each other (monochrome print still distinguishes dash)STYLE = {  "POWER": dict(stroke="#111111", sw=mm(0.55), dash=""),  "HS":    dict(stroke="#1f4e9c", sw=mm(0.35), dash=""),  "CTRL":  dict(stroke="#1b7a3a", sw=mm(0.28), dash="7 3"),  "BUS":   dict(stroke="#8a4b00", sw=mm(0.28), dash="9 3 2 3"),  "RF":    dict(stroke="#9c1f1f", sw=mm(0.35), dash="12 2 2 2"),  "BOND":  dict(stroke="#4c7c1c", sw=mm(0.30), dash="3 3"),  "REF":   dict(stroke="#777777", sw=mm(0.25), dash="4 3"),  "MECH":  dict(stroke="#444444", sw=mm(0.25), dash="1 3"),}LEGEND_ORDER = ["POWER","HS","CTRL","BUS","RF","BOND","REF","MECH"]LEGEND_TEXT = {  "POWER": "Power (VBUS, VBAT, VSYS, rails)",  "HS":    "High-speed data (PCIe, USB, USB4, MDI)",  "CTRL":  "Control / discrete (GPIO, enables, detects)",  "BUS":   "Serial bus (I²C, SMBus, SPI, UART)",  "RF":    "RF coax (MHF4)",  "BOND":  "Chassis / ground bond",  "REF":   "Reserved, alternative or depopulated",  "MECH":  "Direct mate (module in socket, no cable)",}LAYERS = ["BORDER","TITLE","REFERENCE","EQUIPMENT","CONN-POWER","CONN-HS","CONN-CTRL","CONN-BUS","CONN-RF","CONN-BOND","CONN-REF","CONN-MECH","ANNOTATION","REVISION"]FONT = "Helvetica, Arial, sans-serif" def esc(t): return str(t).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")def tw(text, fs): return 0.53 * fs * len(str(text))   # Helvetica average advance ≈ 0.53 em def wrap(text, width, fs):  words = str(text).split(" "); lines=[]; cur=""  for w in words:    cand = (cur+" "+w).strip()    if tw(cand, fs) <= width or not cur: cur = cand    else: lines.append(cur); cur = w  if cur: lines.append(cur)  return lines class Sheet:  def __init__(self, number, title, scale="NTS — NOT TO SCALE"):    self.number = number; self.title = title; self.scale = scale    self.layers = {k: [] for k in LAYERS}    self.texts = []          # (x0,y0,x1,y1,text,layer) for overlap checks    self.anchors = {}        # (block, port) -> (x,y)    self.ids_on_sheet = set()    self.frame = (40, 40, W-40, H-40)   # inner drawing frame    self._draw_border()   # ---- primitives -------------------------------------------------------------------------  def add(self, layer, s): self.layers[layer].append(s)  def rect(self, layer, x,y,w,h, fill="#fff", stroke="#222", sw=None, rx=0, extra=""):    sw = LW["secondary"] if sw is None else sw    self.add(layer, f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" rx="{rx}" fill="{fill}" stroke="{stroke}" stroke-width="{sw:.2f}" {extra}/>')  def line(self, layer, x1,y1,x2,y2, stroke="#222", sw=None, dash=""):    sw = LW["fine"] if sw is None else sw    d = f' stroke-dasharray="{dash}"' if dash else ""    self.add(layer, f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" stroke="{stroke}" stroke-width="{sw:.2f}"{d}/>')  def text(self, layer, x,y, t, fs=10, anchor="start", fill="#111", bold=False, italic=False, rotate=None):    t = str(t)    if not t: return    style = (' font-weight="bold"' if bold else "") + (' font-style="italic"' if italic else "")    tr = f' transform="rotate({rotate} {x:.1f} {y:.1f})"' if rotate else ""    self.add(layer, f'<text x="{x:.1f}" y="{y:.1f}" font-size="{fs}" text-anchor="{anchor}" fill="{fill}" font-family="{FONT}"{style}{tr}>{esc(t)}</text>')    w = tw(t, fs)    if rotate:      x0,y0,x1,y1 = x-fs*0.8, y-w, x+fs*0.2, y   # approx for -90 rotation    else:      x0 = x if anchor=="start" else (x-w if anchor=="end" else x-w/2)      x0,y0,x1,y1 = x0, y-fs*0.8, x0+w, y+fs*0.2    self.texts.append((x0,y0,x1,y1,t,layer))    for tok in t.replace("(", " ").replace(")", " ").replace(",", " ").split():      if tok.startswith(("W-","P-","OI-")) and len(tok) <= 6: self.ids_on_sheet.add(tok.rstrip(".;:"))  def path(self, layer, d, stroke="#222", sw=None, fill="none", dash=""):    sw = LW["secondary"] if sw is None else sw    dd = f' stroke-dasharray="{dash}"' if dash else ""    self.add(layer, f'<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{sw:.2f}" stroke-linejoin="round" stroke-linecap="round"{dd}/>')  def circle(self, layer, cx,cy,r, fill="#111", stroke="none", sw=1):    self.add(layer, f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r:.1f}" fill="{fill}" stroke="{stroke}" stroke-width="{sw}"/>')   # ---- border, zones, title block ---------------------------------------------------------  def _draw_border(self):    self.rect("BORDER", 20, 20, W-40, H-40, "none", "#222", LW["outline"])    x0,y0,x1,y1 = self.frame    self.rect("BORDER", x0, y0, x1-x0, y1-y0, "none", "#222", LW["secondary"])    # zones: 8 columns (1–8) left→right, 6 rows (A–F) top→bottom    for i in range(8):      cx = x0 + (x1-x0)*(i+0.5)/8      xx = x0 + (x1-x0)*i/8      if i: self.line("BORDER", xx, 20, xx, y0); self.line("BORDER", xx, y1, xx, H-20)      self.text("BORDER", cx, 34, str(i+1), 9, "middle", "#333"); self.text("BORDER", cx, H-26, str(i+1), 9, "middle", "#333")    for j in range(6):      cy = y0 + (y1-y0)*(j+0.5)/6      yy = y0 + (y1-y0)*j/6      if j: self.line("BORDER", 20, yy, x0, yy); self.line("BORDER", x1, yy, W-20, yy)      self.text("BORDER", 30, cy+3, "ABCDEF"[j], 9, "middle", "#333"); self.text("BORDER", W-30, cy+3, "ABCDEF"[j], 9, "middle", "#333")   def titleblock(self, legend=True):    x0,y0,x1,y1 = self.frame    tbw, tbh = 640, 96    x, y = x1 - tbw, y1 - tbh    self.rect("TITLE", x, y, tbw, tbh, "#fff", "#222", LW["primary"])    self.line("TITLE", x, y+22, x+tbw, y+22, "#222", LW["secondary"])    self.line("TITLE", x, y+58, x+350, y+58, "#222", LW["secondary"])    self.line("TITLE", x+350, y+22, x+350, y+tbh, "#222", LW["secondary"])    self.line("TITLE", x+515, y+22, x+515, y+tbh, "#222", LW["secondary"])    self.text("TITLE", x+8, y+16, f"{model.PROJECT} — {model.SET_TITLE}", 12, bold=True)    if tw(self.title, 11.5) <= 330:      self.text("TITLE", x+8, y+38, self.title, 11.5, bold=True)      self.text("TITLE", x+8, y+52, f"Baseline: {model.BASELINE}   Generated from wiring/model.py + wiring/build.py", 9, fill="#333")      self.text("TITLE", x+8, y+72, f"Scale: {self.scale}", 10)    else:      tl = wrap(self.title, 330, 10)[:2]      for i,l in enumerate(tl): self.text("TITLE", x+8, y+36+i*12, l, 10, bold=True)      self.text("TITLE", x+8, y+72, f"Scale: {self.scale}   Baseline: {model.BASELINE}", 9.5)    self.text("TITLE", x+8, y+88, "Units: mm where dimensioned   Signal names per docs/02 §8, docs/09 §8–9", 9, fill="#333")    self.text("TITLE", x+358, y+38, f"Sheet {model.sheet_no(self.number)}", 13, bold=True)    self.text("TITLE", x+358, y+52, f"Rev {model.REV}   {model.DATE}", 10)    self.text("TITLE", x+358, y+72, f"Status: {model.STATUS}", 10, bold=True, fill="#9c1f1f")    idx = [n for n,_ in model.SHEETS].index(self.number)+1    self.text("TITLE", x+358, y+88, f"Sheet {idx} of {len(model.SHEETS)}", 10)    self.text("TITLE", x+523, y+38, "Prepared: hw architecture", 9, fill="#333")    self.text("TITLE", x+523, y+52, "(auto-generated set)", 9, fill="#333")    self.text("TITLE", x+523, y+72, "Reviewed: —", 9, fill="#333")    self.text("TITLE", x+523, y+88, "Approved: —", 9, fill="#333")    if legend: self.legend(x0+8, y1-96)   def legend(self, x, y, classes=None, cols=2):    classes = classes or LEGEND_ORDER    rows = math.ceil(len(classes)/cols); colw = 240    self.rect("TITLE", x, y, colw*cols+12, rows*12+22, "#fff", "#222", LW["fine"])    self.text("TITLE", x+6, y+13, "LEGEND — connection classes (colour + line type)", 9, bold=True)    for i,c in enumerate(classes):      st = STYLE[c]; cx = x+6+(i//rows)*colw; cy = y+24+(i%rows)*12      self.line("TITLE", cx, cy, cx+34, cy, st["stroke"], st["sw"], st["dash"])      self.text("TITLE", cx+40, cy+3, LEGEND_TEXT[c], 8.5, fill="#222")   # ---- equipment blocks ------------------------------------------------------------------  def block(self, bid, x,y,w,h, title, sub=None, ports=(), fill="#f7f7f7", title_fs=12, port_fs=8.5, dashed=False, layer="EQUIPMENT"):    """ports: list of (side, frac_or_px, port_id, label). side in L R T B. frac in (0,1] else px offset."""    self.rect(layer, x,y,w,h, fill, "#222", LW["primary"], rx=3, extra=('stroke-dasharray="6 3"' if dashed else ""))    self.text(layer, x+w/2, y+16, title, title_fs, "middle", bold=True)    if sub:      for i,l in enumerate(sub if isinstance(sub,(list,tuple)) else [sub]):        self.text(layer, x+w/2, y+30+i*11, l, 8.5, "middle", "#333")    for side,pos,pid,label in ports:      p = pos if pos>1 else pos      if side in "LR":        py = y + (h*pos if pos<=1 else pos); px = x if side=="L" else x+w        self.anchors[(bid,pid)] = (px,py)        self.line(layer, px, py, px+(6 if side=="L" else -6), py, "#222", LW["primary"])        if label:          self.text(layer, px+(9 if side=="L" else -9), py+3, label, port_fs, "start" if side=="L" else "end", "#111")      else:        px = x + (w*pos if pos<=1 else pos); py = y if side=="T" else y+h        self.anchors[(bid,pid)] = (px,py)        self.line(layer, px, py, px, py+(6 if side=="T" else -6), "#222", LW["primary"])        if label:          self.text(layer, px+3, py+(15 if side=="T" else -9), label, port_fs, "start", "#111", rotate=None)    return self.anchors   def A(self, bid, pid): return self.anchors[(bid,pid)]   # ---- connections -----------------------------------------------------------------------  def wire(self, cls, pts, label=None, seg=None, dy=-4, dx=0, fs=8.5, arrow=None, lid=None, lfill=None, anchor="middle"):    st = STYLE[cls]; layer = "CONN-"+cls    d = "M" + " L".join(f"{x:.1f},{y:.1f}" for x,y in pts)    self.path(layer, d, st["stroke"], st["sw"], dash=st["dash"])    if arrow:      ends = [pts[-1]] if arrow in ("end","both") else []      if arrow in ("start","both"): ends.append(pts[0])      for e in ends:        i = pts.index(e); nb = pts[i-1] if i>0 else pts[1]        self._arrow(layer, nb, e, st["stroke"])    if label or lid:      # label on the longest segment unless seg given      if seg is None:        L = [((pts[i][0]-pts[i+1][0])**2+(pts[i][1]-pts[i+1][1])**2, i) for i in range(len(pts)-1)]        seg = max(L)[1]      (xa,ya),(xb,yb) = pts[seg], pts[seg+1]      mx,my = (xa+xb)/2, (ya+yb)/2      vertical = abs(xb-xa) < abs(yb-ya)      txt = (f"{lid}  " if lid else "") + (label or "")      if vertical:        self.text("ANNOTATION", mx+dy*-1+2, my+dx, txt, fs, anchor, lfill or st["stroke"], rotate=-90)      else:        self.text("ANNOTATION", mx+dx, my+dy, txt, fs, anchor, lfill or st["stroke"])    return pts   def _arrow(self, layer, a, b, color):    ax,ay = a; bx,by = b    ang = math.atan2(by-ay, bx-ax); L=7; s=3.2    p1 = (bx - L*math.cos(ang) + s*math.sin(ang), by - L*math.sin(ang) - s*math.cos(ang))    p2 = (bx - L*math.cos(ang) - s*math.sin(ang), by - L*math.sin(ang) + s*math.cos(ang))    self.add(layer, f'<path d="M{bx:.1f},{by:.1f} L{p1[0]:.1f},{p1[1]:.1f} L{p2[0]:.1f},{p2[1]:.1f} Z" fill="{color}"/>')   def bus_tick(self, cls, x, y, n, horizontal=True):    st = STYLE[cls]    if horizontal: self.line("CONN-"+cls, x-4, y+5, x+4, y-5, st["stroke"], st["sw"])    else:          self.line("CONN-"+cls, x-5, y+4, x+5, y-4, st["stroke"], st["sw"])    self.text("ANNOTATION", x+6 if horizontal else x+8, y-6 if horizontal else y-6, str(n), 8, "start", st["stroke"])   def junction(self, cls, x, y): self.circle("CONN-"+cls, x, y, 2.6, STYLE[cls]["stroke"])   def gnd(self, x, y, chassis=False, label=None):    c = STYLE["BOND"]["stroke"]; L="CONN-BOND"    self.line(L, x, y, x, y+8, c, LW["secondary"])    if chassis:      for i,dx in enumerate((-9,-3,3)):        self.line(L, x+dx, y+8, x+dx+6, y+8, c, LW["secondary"]); self.line(L, x+dx, y+8, x+dx-4, y+14, c, LW["secondary"])      self.line(L, x+9, y+8, x+5, y+14, c, LW["secondary"])    else:      for i,w in enumerate((10,6,2)): self.line(L, x-w, y+8+i*3, x+w, y+8+i*3, c, LW["secondary"])    if label: self.text("ANNOTATION", x, y+26, label, 8, "middle", c)   def tag(self, x, y, text, direction="R", fs=8.5, cls="REF"):    """off-sheet / cross-reference hexagon tag; direction R points right."""    w = tw(text, fs)+16; h=16; L="ANNOTATION"    if direction=="R": pts=[(x,y-h/2),(x+w-8,y-h/2),(x+w,y),(x+w-8,y+h/2),(x,y+h/2)]; tx=x+4; an="start"    else:              pts=[(x,y-h/2),(x-w+8,y-h/2),(x-w,y),(x-w+8,y+h/2),(x,y+h/2)]; tx=x-4; an="end"    d="M"+" L".join(f"{a:.1f},{b:.1f}" for a,b in pts)+" Z"    self.path(L, d, "#333", LW["fine"], fill="#fff")    self.text(L, tx, y+3, text, fs, an, "#333")    return (x+w, y) if direction=="R" else (x-w, y)   def label(self, x, y, t, fs=9, anchor="start", fill="#111", bold=False, italic=False, rotate=None):    self.text("ANNOTATION", x, y, t, fs, anchor, fill, bold, italic, rotate)   def leader(self, x1,y1,x2,y2, t, fs=8.5, anchor="start"):    self.line("ANNOTATION", x1,y1,x2,y2, "#333", LW["fine"]); self.circle("ANNOTATION", x1,y1,1.6,"#333")    self.text("ANNOTATION", x2+(3 if anchor=="start" else -3), y2+3, t, fs, anchor, "#333")   # ---- tables and notes ------------------------------------------------------------------  def table(self, x, y, widths, rows, header=None, fs=8.5, pad=4, head_fill="#e9e9e9", zebra=True, layer="ANNOTATION", bold_first=False):    lh = fs*1.25; cy = y    def draw_row(cells, fill, bold=False):      nonlocal cy      lines = [wrap(c, w-2*pad, fs) for c,w in zip(cells, widths)]      n = max(len(l) for l in lines) if lines else 1      rh = n*lh + 2*pad      self.rect(layer, x, cy, sum(widths), rh, fill, "#444", LW["hatch"])      cx = x      for i,(ls,w) in enumerate(zip(lines,widths)):        if i: self.line(layer, cx, cy, cx, cy+rh, "#444", LW["hatch"])        for k,l in enumerate(ls): self.text(layer, cx+pad, cy+pad+lh*(k+0.8), l, fs, bold=(bold or (bold_first and i==0)))        cx += w      cy += rh    if header: draw_row(header, head_fill, True)    for r_i,r in enumerate(rows): draw_row(r, "#fafafa" if (zebra and r_i%2) else "#fff")    return cy   def notes(self, x, y, w, title, lines, fs=8.5, numbered=True, layer="ANNOTATION"):    lh = fs*1.3; cy = y+8    items=[]    for i,l in enumerate(lines):      pre = f"{i+1}. " if numbered else "• "      items.append(wrap(pre+l, w-16, fs))    hgt = 22 + sum(len(it) for it in items)*lh + 6    self.rect(layer, x, y, w, hgt, "#fff", "#222", LW["fine"])    self.text(layer, x+8, y+15, title, 9.5, bold=True); cy = y+24    for it in items:      for k,l in enumerate(it): self.text(layer, x+8+(0 if k==0 else 12), cy+lh*0.8, l, fs); cy += lh    return y+hgt   def heading(self, x, y, t, fs=15): self.text("ANNOTATION", x, y, t, fs, bold=True)  def subheading(self, x, y, t, fs=10.5): self.text("ANNOTATION", x, y, t, fs, bold=True, fill="#333")   # ---- output ---------------------------------------------------------------------------  def write(self):    os.makedirs(OUT, exist_ok=True); os.makedirs(PREVIEW, exist_ok=True)    fn = os.path.join(OUT, f"{model.sheet_no(self.number)}.svg")    head = (f'<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}">\n'            f'<title>{esc(model.sheet_no(self.number))} — {esc(self.title)}</title>\n'            f'<desc>{esc(model.PROJECT)}; {esc(model.SET_TITLE)}; Rev {model.REV}; {model.DATE}; {esc(model.STATUS)}; A3 landscape 420 × 297 mm at 96 dpi</desc>\n'            f'<rect width="100%" height="100%" fill="#fff"/>\n')    body = "".join(f'<g id="L-{k}">\n' + "\n".join(v) + "\n</g>\n" for k,v in self.layers.items() if v)    open(fn,"w").write(head+body+"</svg>\n")    meta = dict(number=self.number, title=self.title, texts=self.texts, ids=sorted(self.ids_on_sheet), frame=self.frame)    open(os.path.join(PREVIEW, f"{model.sheet_no(self.number)}.meta.json"),"w").write(json.dumps(meta))    return fn