wiring/sheet.py
16 KB · 293 lines · python · Download from the repository page
# 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("&","&").replace("<","<").replace(">",">")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