← Commit history
SKILL.md+1
@@ -22,6 +22,7 @@ The bundled shared skills are:  - [Visible routing and copper optimization](skills/eda-visible-routing/SKILL.md): live routing, clean bends, electrical priorities, copper expansion and thermal review. - [Electrical routing](skills/eda-electrical-routing/SKILL.md): current budgets, route geometry and copper-pour decisions.+- [Routing gates](skills/eda-routing-gates/SKILL.md): the routing rules as measured gates that fail the build (geometry, bends, in-line vias, cap-first decoupling, power-stage loops, power-path necks and meanders), with a KiCad-file checker and fail-on-purpose tests. - [Thermal bottlenecks](skills/eda-thermal-bottlenecks/SKILL.md): review connected heat-spreading paths and suitable reroutes. - [Copper ablation](skills/eda-copper-ablation/SKILL.md): retain permissible copper while preserving isolation. - [Assembly planning](skills/eda-assembly-planning/SKILL.md): connect board decisions to fabrication and assembly requirements.
package.json+8−1
@@ -4,7 +4,7 @@   "type": "skill",   "title": "EDA Skillpack",   "brief": "All skills for all EDA capabilities across the Adom ecosystem.",-  "version": "0.2.6",+  "version": "0.2.7",   "hero": {     "type": "image",     "path": "docs/hero.png"@@ -131,6 +131,13 @@     "skills/eda-native-connectivity-conformance/SKILL.md",     "skills/eda-placement-routing-review/SKILL.md",     "skills/eda-pour-planning-measurement/SKILL.md",+    "skills/eda-routing-gates/SKILL.md",+    "skills/eda-routing-gates/examples/buck-spec-adom.json",+    "skills/eda-routing-gates/examples/buck-spec-john.json",+    "skills/eda-routing-gates/scripts/kicad_board.py",+    "skills/eda-routing-gates/scripts/routing_gates.py",+    "skills/eda-routing-gates/tests/run.py",+    "skills/eda-routing-gates/tests/test_routing_gates.py",     "skills/eda-thermal-bottlenecks/SKILL.md",     "skills/eda-visible-routing/SKILL.md",     "uninstall.sh"
page.json+9−2
@@ -4,7 +4,7 @@   "type": "skill",   "title": "EDA Skillpack",   "brief": "All skills for all EDA capabilities across the Adom ecosystem.",-  "version": "0.2.6",+  "version": "0.2.7",   "hero": {     "type": "image",     "path": "docs/hero.png"@@ -131,6 +131,13 @@     "skills/eda-native-connectivity-conformance/SKILL.md",     "skills/eda-placement-routing-review/SKILL.md",     "skills/eda-pour-planning-measurement/SKILL.md",+    "skills/eda-routing-gates/SKILL.md",+    "skills/eda-routing-gates/examples/buck-spec-adom.json",+    "skills/eda-routing-gates/examples/buck-spec-john.json",+    "skills/eda-routing-gates/scripts/kicad_board.py",+    "skills/eda-routing-gates/scripts/routing_gates.py",+    "skills/eda-routing-gates/tests/run.py",+    "skills/eda-routing-gates/tests/test_routing_gates.py",     "skills/eda-thermal-bottlenecks/SKILL.md",     "skills/eda-visible-routing/SKILL.md",     "uninstall.sh"@@ -161,4 +168,4 @@   },   "name": "eda-engineering",   "tag": "latest"-}+}\ No newline at end of file
references/skill-directory.md+1
@@ -14,6 +14,7 @@ Read the entry for the user's task, then the owning package's current skill. The | Manufacturer and process-specific DRC profiles | [PCB Design Rules](https://wiki.adom.inc/adom/pcb-design-rules) | | Live routing and copper optimization | [eda-visible-routing](../skills/eda-visible-routing/SKILL.md) | | Shared electrical routing and copper-pour decisions | [eda-electrical-routing](../skills/eda-electrical-routing/SKILL.md) |+| Routing rules as measured gates that fail the build | [eda-routing-gates](../skills/eda-routing-gates/SKILL.md) | | Shared heat-spreading and bottleneck review | [eda-thermal-bottlenecks](../skills/eda-thermal-bottlenecks/SKILL.md) | | Copper retention for subtractive fabrication | [eda-copper-ablation](../skills/eda-copper-ablation/SKILL.md) | | Assembly process planning | [eda-assembly-planning](../skills/eda-assembly-planning/SKILL.md) |
skills/eda-electrical-routing/SKILL.md+1−1
@@ -22,7 +22,7 @@ Classify nets before optimizing geometry:  # Routing appearance and thermal planning -Default to horizontal and vertical runs joined by 45-degree transitions. Optimize the number of bends and vias as well as route length; do not leave arbitrary-angle links or search-grid stair steps just because they pass DRC. Preserve intentional pad escapes, component rotations, differential spacing and electrical constraints. Constrain planner escapes and post-processing too: an unrestricted line-of-sight shortcut can undo an eight-direction search. Remove redundant collinear vertices, and recheck each shortcut against fixed and newly generated copper. Audit segment angles numerically, then inspect the actual board visually. Report any exceptions rather than claiming every segment is orthogonal/45-degree. Smooth tangent arcs are an optional alternative when the native CAD API, clearances and fabrication process support them; use actual native arcs and verify their radius and tangency rather than approximating curves with many tiny segments.+Default to horizontal and vertical runs joined by 45-degree transitions. Optimize the number of bends and vias as well as route length; do not leave arbitrary-angle links or search-grid stair steps just because they pass DRC. Preserve intentional pad escapes, component rotations, differential spacing and electrical constraints. Constrain planner escapes and post-processing too: an unrestricted line-of-sight shortcut can undo an eight-direction search. Remove redundant collinear vertices, and recheck each shortcut against fixed and newly generated copper. Audit segment angles numerically, then inspect the actual board visually. Report any exceptions rather than claiming every segment is orthogonal/45-degree. [eda-routing-gates](../eda-routing-gates/SKILL.md) does that audit as gates that fail the build, and adds power-path neck and meander checks for switching nodes. Smooth tangent arcs are an optional alternative when the native CAD API, clearances and fabrication process support them; use actual native arcs and verify their radius and tangency rather than approximating curves with many tiny segments.  Before allocating routes and pours, inventory heat-producing and high-current components, exposed pads, heatsink tabs, existing thermal-via arrays and thermal features embedded in footprints. Determine each thermal pad's electrical net from the schematic and manufacturer data; do not assume an exposed pad or heatsink tab is ground. Estimate relevant conduction, switching and regulator losses under the load contract and document uncertainty. 
skills/eda-routing-gates/SKILL.mdadded+89
@@ -0,0 +1,89 @@+---+name: eda-routing-gates+description: Turn PCB routing rules into measured gates that fail the build - 0/45/90 geometry, no extra bends, no via inside a trace, cap-first decoupling, measured decoupling loops, power-stage loop distances, an unbroken plane under the hot loop, and power paths that must not neck or wander (a switching trace thinner than the pin it leaves). Use after routing and before calling a board done, when reviewing someone else's layout, or when a reviewer points at a trace and you want the check that would have caught it.+---+Parent skill: eda-engineering++# Routing gates: rules that fail the build++A routing rule that is only written down gets argued about trace by trace. These gates turn the rules into numbers with thresholds, in a script that exits non-zero, so a board that breaks one stops the build and says where. They complement two existing skills:+- [eda-visible-routing](../eda-visible-routing/SKILL.md) and [eda-electrical-routing](../eda-electrical-routing/SKILL.md) say how to route: the priorities, the electrical judgement and the copper decisions;+- this skill checks the result mechanically, after routing and before anyone calls the board done.++The rules are tool-agnostic. The script reads KiCad files (`.kicad_pcb`) with the Python standard library, so no KiCad install is needed to check a board. For Fusion or Altium designs, KiCad 10 imports Altium `.PcbDoc` and EAGLE/Fusion `.brd` boards: import a copy, refill its zones in KiCad, and run the gates on that. Alternatively, reimplement the same measurements on the native API. The thresholds and their reasons carry over unchanged.++## The gates++| gate | the rule | default threshold | why |+|---|---|---|---|+| grid | every straight segment at 0, 45 or 90 degrees | 0 off-grid | free-angle links read as unfinished and hide search-grid artefacts; arcs are reported, and allowed only when the spec says so |+| extra bends | no redundant collinear vertex, no short jog that sidesteps and returns to its heading | 0 | an extra bend is visible on every render and adds nothing |+| via inside a trace | where a trace changes layer, the via hangs off the trace on a side stub; two same-layer track ends never meet at a via | 0 | an in-line via reads as a kink and hides whether the trace continues |+| cap first | each decoupled supply pin reaches its own cap's pad before any junction: no third route joining and no pour on the way | none allowed | the cap must see the pin before the rail or plane does; otherwise the pin's current loop closes through the junction and the cap hangs off it |+| decoupling loops | supply path pin -> cap and ground sink cap -> via, measured along copper | an untraced path FAILS | a measurement that silently returns nothing is a gate that stopped measuring |+| input loop | the best input cap's pads to the IC's VIN and GND pins, and the loop area pin -> cap -> cap -> pin | 2.5 mm, 6.0 mm2 | the datasheet's own layout rule for a switcher: the input ceramic directly across VIN and GND |+| output caps | spread of the output caps, and the furthest from the inductor's output pad | 5.0 mm, 5.0 mm | clustered caps keep the output loop and its return short |+| plane under the hot loop | no non-plane-net copper (tracks, vias, fills) on the plane layer under the input loop and switch pin | 0 | a slot under the hot loop breaks the return path exactly where di/dt is highest |+| power path: trace neck | the narrowest track on a power path, against the narrow side of the pin it leaves | at least the pin | a switching trace thinner than its pin is a neck, whatever the copper under it does |+| power path: filled-copper neck | the widest conductor that fits from pin to pin through the net's filled copper (tracks, pads and refilled pours) | at least the pin | the true bottleneck when a pour carries the current |+| power path: meander | routed track length against the straight pin-to-pin distance, and the bends along it | x1.15, 4 bends | a wandering power path adds inductance, length and the "wiggle" a reviewer sees |++The two neck measurements are separate on purpose. A thin trace drawn over a wide pour is still a defect: it is what the board shows, and it carries the current alone wherever the pour does not fill. The filled-copper figure tells you whether the pour saves the current path.++## Run it++```+python3 scripts/routing_gates.py <board>.kicad_pcb <spec>.json [--json result.json] [--only grid,bends,...]+python3 tests/run.py          # the regressions: every gate made to fail on purpose+```++Run it on a board whose zones KiCad has refilled (`kicad-cli pcb drc --refill-zones --save-board`). Stored fills can be stale, and two gates measure the filled copper. The spec names the parts each rule is about, because nothing is guessed from reference designators. Two worked specs are in `examples/`:++```json+{"powerStage": {"ic": "U1", "vinPin": "3", "gndPin": "1", "inputCaps": ["C2", "C3"], "outputCaps": ["C6", "C7", "C8"],+                "inductor": "L1", "inductorOutPin": "2", "planeLayer": "B.Cu", "planeNet": "GND"},+ "powerPaths": [{"net": "SW", "from": "U1.2", "to": "L1.1"}],+ "decoupling": [{"pin": "U1.3", "cap": "C2"}],+ "thresholds": {}}+```++Every threshold can be overridden in `thresholds`. Change one only with a reason written beside it in the spec, and never after seeing a result in order to pass it.++## Calibrated on two real boards++Two layouts of one 12 V to 5 V / 1 A TPS54202 buck:+- [john/buck-12v5v-molecule](https://wiki.adom.inc/john/buck-12v5v-molecule): the published board, refilled with KiCad 10.0.1;+- [adom/buck-12v-5v-1a-molecule](https://wiki.adom.inc/adom/buck-12v-5v-1a-molecule) (org-private): a re-layout of the same circuit to these rules, refilled with KiCad 10.++The neck gate was calibrated on the SW trace a reviewer pointed at in the buck process video at 2:32: the trace leaving U1 pin 2 visibly necks and wiggles.++| gate | john/buck-12v5v-molecule | adom/buck-12v-5v-1a-molecule |+|---|---|---|+| segments off the 0/45/90 grid | **38 of 248** | 0 of 63 |+| extra bends (redundant vertices, jogs) | **1, 7** | 0, 0 |+| vias inside a trace | **4** | 0 |+| cap first, VIN pin 3 to its decoupling cap | **through the VIN pour** (C4) | direct (C2) |+| decoupling loop, supply / ground sink | 1.55 / 0.88 mm (C4) | 2.39 / 1.08 mm (C2) |+| best input cap to VIN / GND pins | **3.67 / 5.82 mm** (C2) | 2.23 / 2.23 mm (C2) |+| input loop area | **7.17 mm2** | 5.24 mm2 |+| output caps spread / furthest from L1 | **11.0 / 6.5 mm** | 4.1 / 4.9 mm |+| non-GND copper on B.Cu under the hot loop | 0 | 0 |+| SW trace: narrowest track vs the 0.6 mm pin | **0.30 mm** | 1.10 mm |+| SW trace: routed / straight, bends | **4.63 / 3.69 mm (x1.26), 8 bends** | 7.13 / 7.13 mm (x1.00), 0 bends |+| SW filled copper: narrowest conductor | 0.96 mm | 1.12 mm |+| result | **FAIL (11 gates)** | PASS |++What the table says about the SW trace the reviewer pointed at: on John's board, a 0.3 mm trace leaves U1's 0.6 mm SW pin and jogs eight times on its way to the inductor. The SW pour patch underneath is 0.96 mm at its narrowest, which is why a current-density solve of that board (Adom Fields, 49.8 A/mm2 peak) found no hot spot. The gates fail the trace and report that the copper beneath it is adequate; both statements are true. On the re-layout, the SW trace is 1.1 to 1.4 mm wide and straight, and the copper bottleneck is the trace itself.++## Calibrating your own thresholds++Defaults are for a two-layer, 1 A-class switcher in a SOT-23 or similar small package. For another design:+- **pad-to-pin and loop area:** start from the datasheet's layout example and the caps' own pad pitch;+- **neck ratios:** keep them at 1.0 against the pin unless the current is far below the pin's rating, and say so;+- **meander and bend limits:** derive them from a route you would accept.++Then make each gate fail on purpose against your board, as `tests/test_routing_gates.py` does: move the input cap 5 mm away, thin the switch trace, add a jog, or put a via in a trace. A gate that has only ever seen a passing board tests nothing.++## What this does not claim++These are geometry gates. They do not replace native DRC, connectivity and schematic parity (see [eda-native-connectivity-conformance](../eda-native-connectivity-conformance/SKILL.md)), a current-density or thermal solve ([eda-thermal-bottlenecks](../eda-thermal-bottlenecks/SKILL.md), [eda-multilayer-current-review](../eda-multilayer-current-review/SKILL.md)), or bench and EMI measurement. Pads are modelled as their bounding rectangles. The filled-copper width is a 0.02 mm raster with a chamfer distance transform, accurate to about 2 % plus one cell. The cap-first walk sees tracks, vias, pads and refilled pours; a connection made only through an unrefilled zone is reported as untraced.
skills/eda-routing-gates/examples/buck-spec-adom.jsonadded+7
@@ -0,0 +1,7 @@+{+ "board": "adom/buck-12v-5v-1a-molecule (refilled by KiCad 10.0.2)",+ "powerStage": {"ic": "U1", "vinPin": "3", "gndPin": "1", "inputCaps": ["C2", "C3"], "outputCaps": ["C6", "C7", "C8"],+                "inductor": "L1", "inductorOutPin": "2", "planeLayer": "B.Cu", "planeNet": "GND"},+ "powerPaths": [{"net": "SW", "from": "U1.2", "to": "L1.1", "amps": 1.0}],+ "decoupling": [{"pin": "U1.3", "cap": "C2", "note": "the input cap across VIN pin 3 and GND pin 1"}]+}
skills/eda-routing-gates/examples/buck-spec-john.jsonadded+36
@@ -0,0 +1,36 @@+{+ "board": "john/buck-12v5v-molecule (published board, refilled by KiCad 10.0.1)",+ "powerStage": {+  "ic": "U1",+  "vinPin": "3",+  "gndPin": "1",+  "inputCaps": [+   "C2",+   "C3"+  ],+  "outputCaps": [+   "C6",+   "C7",+   "C8"+  ],+  "inductor": "L1",+  "inductorOutPin": "2",+  "planeLayer": "B.Cu",+  "planeNet": "GND"+ },+ "powerPaths": [+  {+   "net": "SW",+   "from": "U1.2",+   "to": "L1.1",+   "amps": 1.0+  }+ ],+ "decoupling": [+  {+   "pin": "U1.3",+   "cap": "C4",+   "note": "John's HF bypass, 'closest to VIN/GND pins' in his BOM"+  }+ ]+}\ No newline at end of file
skills/eda-routing-gates/scripts/kicad_board.pyadded+128
@@ -0,0 +1,128 @@+"""Read a .kicad_pcb with the Python standard library: footprints and pads (absolute positions), tracks, arcs,+vias, zones with their filled polygons per layer, and the aux origin. No KiCad install needed.++Coordinates are returned in mm in KiCad's own frame (x right, y DOWN). `design(p)` converts a point to the+board's design frame (origin at the aux origin when one is set, y up), which is how the reports print positions.+"""+import math, re++_TOK = re.compile(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+')+++def parse(text):+    stack = [[]]+    for t in _TOK.findall(text):+        if t == '(':+            stack.append([])+        elif t == ')':+            e = stack.pop(); stack[-1].append(e)+        else:+            stack[-1].append(t[1:-1] if t.startswith('"') else t)+    return stack[0][0]+++def kids(e, head):+    return [c for c in e if isinstance(c, list) and c and c[0] == head]+++def kid(e, head):+    k = kids(e, head)+    return k[0] if k else None+++def num(v):+    return float(v)+++def rot(x, y, deg):+    """KiCad's rotation in its y-down frame: a positive angle turns counter-clockwise as seen on screen."""+    a = math.radians(deg)+    return x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a)+++class Board:+    def __init__(self, path):+        self.path = path+        root = parse(open(path, encoding='utf-8').read())+        setup = kid(root, 'setup')+        ao = kid(setup, 'aux_axis_origin') if setup else None+        self.origin = (num(ao[1]), num(ao[2])) if ao else (0.0, 0.0)+        self.nets = {}+        for n in kids(root, 'net'):+            if len(n) >= 3: self.nets[n[1]] = n[2]+        self.pads, self.footprints = [], {}+        for f in kids(root, 'footprint'):+            at = kid(f, 'at'); fx, fy = num(at[1]), num(at[2]); frot = num(at[3]) if len(at) > 3 else 0.0+            ref = next((p[2] for p in kids(f, 'property') if p[1] == 'Reference'), None)+            if ref is None:+                fr = kid(f, 'fp_text'); ref = fr[2] if fr else '?'+            self.footprints[ref] = {'at': (fx, fy), 'rot': frot, 'layer': kid(f, 'layer')[1]}+            for p in kids(f, 'pad'):+                pat = kid(p, 'at'); px, py = num(pat[1]), num(pat[2]); prot = num(pat[3]) if len(pat) > 3 else 0.0+                dx, dy = rot(px, py, frot)+                size = kid(p, 'size'); w, h = num(size[1]), num(size[2])+                netk = kid(p, 'net'); net = (netk[2] if len(netk) > 2 else self.nets.get(netk[1], netk[1])) if netk else None+                layers = kid(p, 'layers')[1:] if kid(p, 'layers') else []+                self.pads.append({'ref': ref, 'num': p[1], 'kind': p[2], 'shape': p[3], 'x': fx + dx, 'y': fy + dy,+                                  'w': w, 'h': h, 'rot': prot, 'net': net, 'layers': layers})+        self.tracks, self.vias = [], []+        for s in kids(root, 'segment'):+            st, en = kid(s, 'start'), kid(s, 'end')+            self.tracks.append({'a': (num(st[1]), num(st[2])), 'b': (num(en[1]), num(en[2])), 'w': num(kid(s, 'width')[1]),+                                'layer': kid(s, 'layer')[1], 'net': self._net(s), 'arc': False})+        for s in kids(root, 'arc'):+            st, md, en = kid(s, 'start'), kid(s, 'mid'), kid(s, 'end')+            self.tracks.append({'a': (num(st[1]), num(st[2])), 'b': (num(en[1]), num(en[2])), 'mid': (num(md[1]), num(md[2])),+                                'w': num(kid(s, 'width')[1]), 'layer': kid(s, 'layer')[1], 'net': self._net(s), 'arc': True})+        for v in kids(root, 'via'):+            at = kid(v, 'at')+            self.vias.append({'x': num(at[1]), 'y': num(at[2]), 'd': num(kid(v, 'size')[1]), 'net': self._net(v)})+        self.zones = []+        for z in kids(root, 'zone'):+            net = self._net(z)+            fills = []+            for fp in kids(z, 'filled_polygon'):+                lay = kid(fp, 'layer')[1]+                pts = [(num(q[1]), num(q[2])) for q in kid(fp, 'pts') if isinstance(q, list) and q[0] == 'xy']+                fills.append({'layer': lay, 'pts': pts})+            lk = kid(z, 'layers') or kid(z, 'layer')+            self.zones.append({'net': net, 'layers': list(lk[1:]) if lk else [], 'fills': fills})++    def _net(self, e):+        n = kid(e, 'net')+        if not n: return None+        return n[2] if len(n) > 2 else self.nets.get(n[1], n[1])++    def pad(self, ref, number):+        for p in self.pads:+            if p['ref'] == ref and p['num'] == str(number): return p+        raise KeyError(f'{ref}.{number}')++    def pads_of(self, ref):+        return [p for p in self.pads if p['ref'] == ref]++    def design(self, p):+        return (round(p[0] - self.origin[0], 3), round(self.origin[1] - p[1], 3))+++def pad_polygon(p, grow=0.0):+    """The pad as a rotated rectangle (round/oval/roundrect pads are approximated by their bounding rectangle;+    circles are kept as circles by callers that need it)."""+    hw, hh = p['w'] / 2 + grow, p['h'] / 2 + grow+    out = []+    for cx, cy in ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)):+        dx, dy = rot(cx, cy, p['rot'])+        out.append((p['x'] + dx, p['y'] + dy))+    return out+++def inside(pt, poly):+    x, y = pt; c = False+    for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):+        if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:+            c = not c+    return c+++def area(poly):+    return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(poly, poly[1:] + poly[:1]))) / 2
skills/eda-routing-gates/scripts/routing_gates.pyadded+407
@@ -0,0 +1,407 @@+#!/usr/bin/env python3+"""Routing gates: layout rules as numbers that fail the build. Reads a .kicad_pcb with the standard library.++    python3 routing_gates.py <board.kicad_pcb> <spec.json> [--json out.json] [--only grid,necks,...]++Run it on a board whose zones have been REFILLED by KiCad (kicad-cli pcb drc --refill-zones --save-board):+stored fills can be stale, and two checks measure the filled copper.++The spec names the parts the rules are about (nothing is guessed from reference designators):+{+  "powerStage": {"ic": "U1", "vinPin": "3", "gndPin": "1", "inputCaps": ["C2", "C3"],+                 "outputCaps": ["C6", "C7", "C8"], "inductor": "L1", "inductorOutPin": "2",+                 "planeLayer": "B.Cu", "planeNet": "GND"},+  "powerPaths": [{"net": "SW", "from": "U1.2", "to": "L1.1", "amps": 1.0}],+  "decoupling": [{"pin": "U1.3", "cap": "C2"}],+  "thresholds": {...optional overrides of THRESH below...}+}+Exit 0 when every gate passes, 1 when any fails, 2 on a spec or board error.+"""+import json, math, sys+from collections import defaultdict, deque+from pathlib import Path++sys.path.insert(0, str(Path(__file__).resolve().parent))+import kicad_board as K  # noqa: E402++THRESH = {+    'off_grid_segments': 0,          # 0/45/90 only; arcs are reported separately and allowed only if the spec says so+    'extra_bends': 0,                # collinear (redundant) vertices plus short jogs that return to their heading+    'jog_max_mm': 0.6,               # a bend pair closer than this that restores the heading is a jog+    'inline_vias': 0,                # a via where two same-layer track ends meet (the trace continues through it)+    'input_pad_to_pin_mm': 2.5,      # best input cap: VIN pad to the IC VIN pin, GND pad to the IC GND pin+    'input_loop_area_mm2': 6.0,      # IC VIN pin -> cap VIN pad -> cap GND pad -> IC GND pin+    'output_cluster_spread_mm': 5.0,+    'output_to_inductor_mm': 5.0,+    'plane_intrusions': 0,           # non-plane-net copper on the plane layer under the hot loop+    'hot_loop_margin_mm': 1.0,+    'neck_track_vs_pad': 1.0,        # power path: narrowest track / the narrow side of the pad it leaves+    'neck_copper_vs_pad': 1.0,       # power path: narrowest filled-copper width along the path / the same pad side+    'meander_ratio': 1.15,           # power path: routed track length / straight pad-to-pad distance+    'path_bends': 4,                 # power path: direction changes along its track route+    'raster_mm': 0.02,               # filled-copper width raster+}+++def seg_angle_ok(a, b):+    ang = math.degrees(math.atan2(b[1] - a[1], b[0] - a[0])) % 45+    return min(ang, 45 - ang) < 0.05+++def unit(a, b):+    dx, dy = b[0] - a[0], b[1] - a[1]; n = math.hypot(dx, dy) or 1+    return dx / n, dy / n+++def key(p):+    return (round(p[0], 3), round(p[1], 3))+++class Gates:+    def __init__(self, board, spec):+        self.B, self.S = board, spec+        self.T = {**THRESH, **spec.get('thresholds', {})}+        self.res, self.fails = {}, []+        self.straight = [t for t in board.tracks if not t['arc']]++    def fail(self, msg):+        self.fails.append(msg)++    # ---- appearance ---------------------------------------------------------------------------------+    def grid(self):+        off = [(t['net'], self.B.design(t['a']), self.B.design(t['b'])) for t in self.straight if not seg_angle_ok(t['a'], t['b'])]+        arcs = sum(t['arc'] for t in self.B.tracks)+        self.res['grid'] = {'segments': len(self.straight), 'off_grid': len(off), 'examples': off[:5], 'arcs': arcs}+        if len(off) > self.T['off_grid_segments']: self.fail(f'{len(off)} of {len(self.straight)} segments off the 0/45/90 grid')+        if arcs and not self.S.get('allowArcs'): self.fail(f'{arcs} arc track(s); set "allowArcs" if they are intended')++    def _chains(self):+        """Per net and layer: vertices where exactly two segments of equal width meet and nothing else touches."""+        touch = defaultdict(list)+        for i, t in enumerate(self.straight):+            for e in ('a', 'b'):+                touch[(t['net'], t['layer'], key(t[e]))].append(i)+        pads = [(p, K.pad_polygon(p)) for p in self.B.pads]+        vias = {key((v['x'], v['y'])) for v in self.B.vias}+        out = []+        for (net, layer, k), ids in touch.items():+            if len(ids) != 2 or k in vias: continue+            if any(p['net'] == net and K.inside(k, poly) for p, poly in pads): continue+            out.append((net, layer, k, [self.straight[i] for i in ids]))+        return out++    def bends(self):+        redundant, jogs = [], []+        chains = self._chains()+        for net, layer, k, (s1, s2) in chains:+            o1 = s1['a'] if key(s1['b']) == k else s1['b']; o2 = s2['a'] if key(s2['b']) == k else s2['b']+            u1, u2 = unit(o1, k), unit(k, o2)+            if abs(u1[0] * u2[1] - u1[1] * u2[0]) < 1e-6 and u1[0] * u2[0] + u1[1] * u2[1] > 0 and s1['w'] == s2['w']:+                redundant.append((net, self.B.design(k)))+        # jogs: a short segment between two bends whose outer headings agree (a sidestep that adds two bends)+        mids = {}+        for net, layer, k, segs in chains:+            mids[(net, layer, k)] = segs+        for t in self.straight:+            ln = math.dist(t['a'], t['b'])+            if ln >= self.T['jog_max_mm']: continue+            ends = []+            for e in ('a', 'b'):+                segs = mids.get((t['net'], t['layer'], key(t[e])))+                if not segs: break+                other = segs[0] if segs[1] is t else segs[1]+                far = other['a'] if key(other['b']) == key(t[e]) else other['b']+                ends.append((e, far))+            if len(ends) != 2: continue+            ua = unit(ends[0][1], t['a']); ub = unit(t['b'], ends[1][1])+            if abs(ua[0] * ub[1] - ua[1] * ub[0]) < 1e-3 and ua[0] * ub[0] + ua[1] * ub[1] > 0 and not (abs(ua[0] * unit(t['a'], t['b'])[1] - ua[1] * unit(t['a'], t['b'])[0]) < 1e-3):+                jogs.append((t['net'], self.B.design(t['a']), round(ln, 3)))+        self.res['bends'] = {'redundant_vertices': len(redundant), 'jogs': len(jogs), 'examples': (redundant + jogs)[:6]}+        n = len(redundant) + len(jogs)+        if n > self.T['extra_bends']: self.fail(f'{n} extra bend(s): {len(redundant)} redundant vertex(es), {len(jogs)} short jog(s)')++    def inline_vias(self):+        ends = defaultdict(int)+        for t in self.B.tracks:+            for e in ('a', 'b'):+                ends[(key(t[e]), t['layer'])] += 1+        bad = [(v['net'], self.B.design((v['x'], v['y']))) for v in self.B.vias+               if any(ends[(key((v['x'], v['y'])), l)] >= 2 for l in ('F.Cu', 'B.Cu'))]+        self.res['inline_vias'] = {'count': len(bad), 'examples': bad[:5]}+        if len(bad) > self.T['inline_vias']: self.fail(f'{len(bad)} via(s) inside a trace (two same-layer track ends meet at the via)')++    # ---- connectivity graph (tracks, vias, pads, filled zones) -------------------------------------------------+    def graph(self, net):+        """Nodes: track ends, pads and vias of `net`. Edges carry (length, kind): 'track' (its length), 'join' (a track+        end inside a pad or via), and 'fill': members of one filled polygon are joined pairwise at their straight-line+        distance, so crossing a pour costs the distance across it, never zero."""+        g = defaultdict(list)+        def edge(a, b, mm, kind):+            g[a].append((b, mm, kind)); g[b].append((a, mm, kind))+        trs = [t for t in self.B.tracks if t['net'] == net]+        pads = [p for p in self.B.pads if p['net'] == net]+        vias = [v for v in self.B.vias if v['net'] == net]+        for t in trs:+            edge(('pt', key(t['a']), t['layer']), ('pt', key(t['b']), t['layer']), math.dist(t['a'], t['b']), 'track')+        on_layer = lambda p, layer: layer in p['layers'] or '*.Cu' in p['layers']+        for t in trs:+            for e in ('a', 'b'):+                n = ('pt', key(t[e]), t['layer'])+                for p in pads:+                    if on_layer(p, t['layer']) and K.inside(t[e], K.pad_polygon(p)):+                        edge(n, ('pad', p['ref'], p['num']), math.dist(t[e], (p['x'], p['y'])), 'join')+                for v in vias:+                    if math.dist(t[e], (v['x'], v['y'])) <= v['d'] / 2:+                        edge(n, ('via', key((v['x'], v['y']))), math.dist(t[e], (v['x'], v['y'])), 'join')+        for z in self.B.zones:+            if z['net'] != net: continue+            for f in z['fills']:+                members = [(('pad', p['ref'], p['num']), (p['x'], p['y'])) for p in pads if on_layer(p, f['layer']) and K.inside((p['x'], p['y']), f['pts'])]+                members += [(('via', key((v['x'], v['y']))), (v['x'], v['y'])) for v in vias if K.inside((v['x'], v['y']), f['pts'])]+                members += [(('pt', key(t[e]), t['layer']), t[e]) for t in trs if t['layer'] == f['layer'] for e in ('a', 'b') if K.inside(t[e], f['pts'])]+                for i in range(len(members)):+                    for j in range(i + 1, len(members)):+                        edge(members[i][0], members[j][0], math.dist(members[i][1], members[j][1]), 'fill')+        return g++    @staticmethod+    def shortest(g, src, dst_pred):+        dist, prev, todo, seen = {src: 0.0}, {}, [src], set()+        while todo:+            u = min(todo, key=dist.get); todo.remove(u)+            if u in seen: continue+            seen.add(u)+            if dst_pred(u):+                path, kinds = [u], []+                while path[-1] in prev:+                    q, kind = prev[path[-1]]; kinds.append(kind); path.append(q)+                return dist[u], (path[::-1], kinds[::-1])+            for v, w, kind in g[u]:+                if dist[u] + w < dist.get(v, 1e18):+                    dist[v] = dist[u] + w; prev[v] = (u, kind); todo.append(v)+        return None, None++    # ---- decoupling: cap first, loops measured, never a silent None ---------------------------------------------+    def decoupling(self):+        rows = []+        for d in self.S.get('decoupling', []):+            ref, num = d['pin'].split('.')+            pin = self.B.pad(ref, num); net = pin['net']+            cap_pads = self.B.pads_of(d['cap']); cap = next((p for p in cap_pads if p['net'] == net), None)+            gnd = next((p for p in cap_pads if p['net'] != net), None)+            row = {'pin': d['pin'], 'cap': d['cap'], 'net': net}+            if cap is None:+                row['error'] = f"{d['cap']} has no pad on {net}"; rows.append(row); self.fail(row['error']); continue+            g = self.graph(net)+            length, pk = self.shortest(g, ('pad', ref, num), lambda u: u == ('pad', d['cap'], cap['num']))+            row['supplyPathMm'] = None if length is None else round(length, 3)+            # cap first: the pin reaches its cap over its own copper. A pour on the way, or a node where a third+            # route joins (a track end, pad or via with other routes), is a junction before the cap.+            junction = None+            if pk:+                path, kinds = pk+                if 'fill' in kinds:+                    junction = 'a pour'+                for u in path[1:-1]:+                    if junction: break+                    if len([1 for _, _, kd in g[u] if kd != 'fill']) >= 3:+                        junction = u[0] if u[0] != 'pt' else self.B.design(u[1])+            row['capFirst'] = pk is not None and junction is None+            row['junction'] = junction+            gg = self.graph(gnd['net'])+            sink, spk = self.shortest(gg, ('pad', d['cap'], gnd['num']), lambda u: u[0] == 'via' or (u[0] == 'pad' and u[1] in self.S.get('groundPins', [])))+            row['gndSinkMm'] = None if sink is None else round(sink, 3)+            row['gndSink'] = None if not spk else (spk[0][-1][0] if spk[0][-1][0] == 'via' else '.'.join(spk[0][-1][1:]))+            rows.append(row)+            if row['supplyPathMm'] is None: self.fail(f"decoupling {d['pin']} -> {d['cap']}: supply path not traced")+            elif not row['capFirst']: self.fail(f"decoupling {d['pin']} -> {d['cap']}: a junction ({row['junction']}) before the cap")+            if row['gndSinkMm'] is None: self.fail(f"decoupling {d['cap']}: ground sink not traced")+        self.res['decoupling'] = rows++    # ---- power stage ------------------------------------------------------------------------------------------+    def power_stage(self):+        ps = self.S.get('powerStage')+        if not ps: return+        vin, gndp = self.B.pad(ps['ic'], ps['vinPin']), self.B.pad(ps['ic'], ps['gndPin'])+        caps = {}+        for r in ps['inputCaps']:+            v = next(p for p in self.B.pads_of(r) if p['net'] == vin['net']); g = next(p for p in self.B.pads_of(r) if p['net'] == gndp['net'])+            P = lambda p: (p['x'], p['y'])+            caps[r] = {'vin_mm': round(math.dist(P(v), P(vin)), 3), 'gnd_mm': round(math.dist(P(g), P(gndp)), 3),+                       'loop_mm2': round(K.area([P(vin), P(v), P(g), P(gndp)]), 2)}+        best = min(caps, key=lambda r: max(caps[r]['vin_mm'], caps[r]['gnd_mm']))+        b = caps[best]+        lv = self.B.pad(ps['inductor'], ps['inductorOutPin'])+        outs = {r: next(p for p in self.B.pads_of(r) if p['net'] == lv['net']) for r in ps['outputCaps']}+        pts = {r: (p['x'], p['y']) for r, p in outs.items()}+        spread = max(math.dist(pts[a], pts[c]) for a in pts for c in pts)+        far = max(math.dist(p, (lv['x'], lv['y'])) for p in pts.values())+        self.res['power_stage'] = {'input': caps, 'best_input': best, 'output_spread_mm': round(spread, 3), 'output_to_inductor_mm': round(far, 3)}+        if max(b['vin_mm'], b['gnd_mm']) > self.T['input_pad_to_pin_mm']:+            self.fail(f"input loop: {best} pads {b['vin_mm']} / {b['gnd_mm']} mm from the VIN / GND pins (> {self.T['input_pad_to_pin_mm']})")+        if b['loop_mm2'] > self.T['input_loop_area_mm2']: self.fail(f"input loop area {b['loop_mm2']} mm2 (> {self.T['input_loop_area_mm2']})")+        if spread > self.T['output_cluster_spread_mm']: self.fail(f'output caps spread {spread:.2f} mm (> {self.T["output_cluster_spread_mm"]})')+        if far > self.T['output_to_inductor_mm']: self.fail(f'an output cap is {far:.2f} mm from the inductor (> {self.T["output_to_inductor_mm"]})')+        # plane under the hot loop+        hot = [vin, gndp] + [p for r in ps['inputCaps'] for p in self.B.pads_of(r)] + [self.B.pad(ps['ic'], pp['from'].split('.')[1])+                for pp in self.S.get('powerPaths', []) if pp['from'].startswith(ps['ic'] + '.')]+        m = self.T['hot_loop_margin_mm']+        xs = [p['x'] for p in hot]; ys = [p['y'] for p in hot]+        box = (min(xs) - m, min(ys) - m, max(xs) + m, max(ys) + m)+        ins = lambda q: box[0] <= q[0] <= box[2] and box[1] <= q[1] <= box[3]+        layer, pnet = ps['planeLayer'], ps['planeNet']+        intr = [(t['net'], 'track') for t in self.B.tracks if t['layer'] == layer and t['net'] != pnet and (ins(t['a']) or ins(t['b']))]+        intr += [(v['net'], 'via') for v in self.B.vias if v['net'] != pnet and ins((v['x'], v['y']))]+        intr += [(z['net'], 'fill') for z in self.B.zones if z['net'] != pnet for f in z['fills'] if f['layer'] == layer and any(ins(q) for q in f['pts'])]+        self.res['plane_under_hot_loop'] = {'layer': layer, 'intrusions': intr}+        if len(intr) > self.T['plane_intrusions']: self.fail(f'{len(intr)} non-{pnet} item(s) on {layer} under the hot loop')++    # ---- power paths: necks and meanders -----------------------------------------------------------------------+    def power_paths(self):+        rows = []+        for pp in self.S.get('powerPaths', []):+            fr, to = pp['from'].split('.'), pp['to'].split('.')+            a, b = self.B.pad(*fr), self.B.pad(*to)+            pad_side = min(a['w'], a['h'])+            net = pp['net']+            # the track route: shortest track-only path between the two pads (fills excluded, so a trace is judged as drawn)+            full = self.graph(net)+            g = defaultdict(list, {u: [(v, w, k) for v, w, k in es if k != 'fill'] for u, es in full.items()})+            length, pk = self.shortest(g, ('pad', *fr), lambda u: u == ('pad', *to))+            path = pk[0] if pk else None+            row = {'net': net, 'from': pp['from'], 'to': pp['to'], 'padNarrowSideMm': round(pad_side, 3),+                   'straightMm': round(math.dist((a['x'], a['y']), (b['x'], b['y'])), 3)}+            if path:+                pts = [u[1] for u in path if u[0] == 'pt']+                widths = []+                for t in self.B.tracks:+                    if t['net'] == net and key(t['a']) in pts and key(t['b']) in pts: widths.append(t['w'])+                heads = [unit(p, q) for p, q in zip(pts, pts[1:]) if math.dist(p, q) > 1e-6]+                turns = sum(1 for u, v in zip(heads, heads[1:]) if abs(u[0] * v[1] - u[1] * v[0]) > 1e-3 or u[0] * v[0] + u[1] * v[1] < 0)+                row.update(trackRouteMm=round(length, 3), minTrackMm=min(widths) if widths else None, bends=turns,+                           meanderRatio=round(length / row['straightMm'], 3) if row['straightMm'] else None)+            else:+                row.update(trackRouteMm=None, minTrackMm=None, bends=None, meanderRatio=None)+            row['copperNeckMm'] = self.copper_neck(net, a, b)+            rows.append(row)+            if row['minTrackMm'] is not None and row['minTrackMm'] < self.T['neck_track_vs_pad'] * pad_side:+                self.fail(f"{net}: the trace necks to {row['minTrackMm']} mm, narrower than the {pad_side:.2f} mm pin it leaves ({pp['from']})")+            if row['meanderRatio'] is not None and row['meanderRatio'] > self.T['meander_ratio']:+                self.fail(f"{net}: the trace wanders, {row['trackRouteMm']} mm routed for {row['straightMm']} mm straight (x{row['meanderRatio']})")+            if row['bends'] is not None and row['bends'] > self.T['path_bends']:+                self.fail(f"{net}: {row['bends']} bends between {pp['from']} and {pp['to']} (> {self.T['path_bends']})")+            if row['copperNeckMm'] is None:+                self.fail(f"{net}: no filled-copper path between {pp['from']} and {pp['to']} (refill the zones first)")+            elif row['copperNeckMm'] < self.T['neck_copper_vs_pad'] * pad_side:+                self.fail(f"{net}: the filled copper necks to {row['copperNeckMm']} mm, narrower than the {pad_side:.2f} mm pin ({pp['from']})")+        self.res['power_paths'] = rows++    def copper_neck(self, net, a, b):+        """Widest-path bottleneck of the net's copper on the pads' layer, outside the two terminal pads: the widest+        conductor you can push from pad a to pad b. Copper = tracks (capsules), pads and filled polygons of the net."""+        layer = 'F.Cu' if 'F.Cu' in a['layers'] or '*.Cu' in a['layers'] else 'B.Cu'+        r = self.T['raster_mm']+        trs = [t for t in self.straight if t['net'] == net and t['layer'] == layer]+        pads = [p for p in self.B.pads if p['net'] == net and (layer in p['layers'] or '*.Cu' in p['layers'])]+        fills = [f['pts'] for z in self.B.zones if z['net'] == net for f in z['fills'] if f['layer'] == layer]+        xs = [a['x'], b['x']] + [q[0] for t in trs for q in (t['a'], t['b'])] + [q[0] for f in fills for q in f]+        ys = [a['y'], b['y']] + [q[1] for t in trs for q in (t['a'], t['b'])] + [q[1] for f in fills for q in f]+        x0, y0 = min(xs) - 2, min(ys) - 2; nx, ny = int((max(xs) + 2 - x0) / r) + 1, int((max(ys) + 2 - y0) / r) + 1+        if nx * ny > 4_000_000: return None+        cu = bytearray(nx * ny); term = bytearray(nx * ny)+        def fill_poly(poly, mark):+            py0 = max(0, int((min(q[1] for q in poly) - y0) / r)); py1 = min(ny - 1, int((max(q[1] for q in poly) - y0) / r) + 1)+            for j in range(py0, py1 + 1):+                y = y0 + (j + 0.5) * r; xs_ = []+                for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):+                    if (y1 > y) != (y2 > y): xs_.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))+                xs_.sort()+                for k in range(0, len(xs_) - 1, 2):+                    i0 = max(0, int((xs_[k] - x0) / r)); i1 = min(nx - 1, int((xs_[k + 1] - x0) / r))+                    for i in range(i0, i1 + 1): mark[j * nx + i] = 1+        for f in fills: fill_poly(f, cu)+        for p in pads: fill_poly(K.pad_polygon(p), cu)+        for t in trs:+            hw = t['w'] / 2; (ax, ay), (bx, by) = t['a'], t['b']+            i0 = max(0, int((min(ax, bx) - hw - x0) / r)); i1 = min(nx - 1, int((max(ax, bx) + hw - x0) / r) + 1)+            j0 = max(0, int((min(ay, by) - hw - y0) / r)); j1 = min(ny - 1, int((max(ay, by) + hw - y0) / r) + 1)+            L = (bx - ax) ** 2 + (by - ay) ** 2+            for j in range(j0, j1 + 1):+                y = y0 + (j + 0.5) * r+                for i in range(i0, i1 + 1):+                    x = x0 + (i + 0.5) * r+                    s = 0 if L == 0 else max(0, min(1, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / L))+                    if (x - ax - s * (bx - ax)) ** 2 + (y - ay - s * (by - ay)) ** 2 <= hw * hw: cu[j * nx + i] = 1+        fill_poly(K.pad_polygon(a), term); fill_poly(K.pad_polygon(b), term)+        # distance to the nearest non-copper cell (chamfer 5-7-11, about 2 % error), in mm+        INF = 1 << 30; d = [0 if not c else INF for c in cu]+        W = ((-1, -1, 7), (0, -1, 5), (1, -1, 7), (-1, 0, 5), (-2, -1, 11), (-1, -2, 11), (1, -2, 11), (2, -1, 11))+        for j in range(ny):+            for i in range(nx):+                k = j * nx + i+                if d[k]:+                    best = d[k]+                    for di, dj, w in W:+                        ii, jj = i + di, j + dj+                        v = d[jj * nx + ii] + w if 0 <= ii < nx and 0 <= jj < ny else w+                        if v < best: best = v+                    d[k] = best+        for j in range(ny - 1, -1, -1):+            for i in range(nx - 1, -1, -1):+                k = j * nx + i+                if d[k]:+                    best = d[k]+                    for di, dj, w in W:+                        ii, jj = i - di, j - dj+                        v = d[jj * nx + ii] + w if 0 <= ii < nx and 0 <= jj < ny else w+                        if v < best: best = v+                    d[k] = best+        width = [2 * v * r / 5 if v < INF else 0 for v in d]        # local conductor width ~ twice the distance+        # widest path: maximise the minimum width over cells outside the terminal pads+        import heapq+        starts = [k for k in range(nx * ny) if term[k] and cu[k] and K.inside((x0 + (k % nx + 0.5) * r, y0 + (k // nx + 0.5) * r), K.pad_polygon(a))]+        goal = set(k for k in range(nx * ny) if term[k] and K.inside((x0 + (k % nx + 0.5) * r, y0 + (k // nx + 0.5) * r), K.pad_polygon(b)))+        best = {}; h = []+        for k in starts: best[k] = 1e9; heapq.heappush(h, (-1e9, k))+        while h:+            negw, k = heapq.heappop(h); w = -negw+            if k in goal: return round(w if w < 1e8 else min(a['w'], a['h']), 3)+            if best.get(k, -1) > w: continue+            i, j = k % nx, k // nx+            for di, dj in ((1, 0), (-1, 0), (0, 1), (0, -1)):+                ii, jj = i + di, j + dj+                if not (0 <= ii < nx and 0 <= jj < ny): continue+                kk = jj * nx + ii+                if not cu[kk]: continue+                nw = w if term[kk] else min(w, width[kk])+                if nw > best.get(kk, -1):+                    best[kk] = nw; heapq.heappush(h, (-nw, kk))+        return None++    def run(self, only=None):+        for name in ('grid', 'bends', 'inline_vias', 'decoupling', 'power_stage', 'power_paths'):+            if only and name not in only: continue+            getattr(self, name)()+        self.res['thresholds'] = self.T; self.res['failures'] = self.fails; self.res['passed'] = not self.fails+        return self.res+++def main(argv):+    if len(argv) < 2:+        print(__doc__); return 2+    try:+        board, spec = K.Board(argv[0]), json.load(open(argv[1]))+    except Exception as e:+        print(f'ERROR: {e}'); return 2+    only = set(argv[argv.index('--only') + 1].split(',')) if '--only' in argv else None+    res = Gates(board, spec).run(only)+    if '--json' in argv:+        json.dump(res, open(argv[argv.index('--json') + 1], 'w'), indent=1, default=str)+    for k in ('grid', 'bends', 'inline_vias', 'power_stage', 'power_paths', 'decoupling', 'plane_under_hot_loop'):+        if k in res: print(k, json.dumps(res[k], default=str)[:400])+    print('PASS' if res['passed'] else 'FAIL:\n  ' + '\n  '.join(res['failures']))+    return 0 if res['passed'] else 1+++if __name__ == '__main__':+    raise SystemExit(main(sys.argv[1:]))
skills/eda-routing-gates/tests/run.pyadded+9
@@ -0,0 +1,9 @@+#!/usr/bin/env python3+"""Run this skill's regressions (standard library only)."""+import sys, unittest+from pathlib import Path+HERE = Path(__file__).resolve().parent+suite = unittest.TestLoader().discover(str(HERE), pattern='test_*.py', top_level_dir=str(HERE))+r = unittest.TextTestRunner(verbosity=2).run(suite)+print('FAILED' if (r.failures or r.errors) else 'OK', HERE.parent.name)+sys.exit(1 if (r.failures or r.errors) else 0)
skills/eda-routing-gates/tests/test_routing_gates.pyadded+159
@@ -0,0 +1,159 @@+"""Every gate fails on purpose: a small generated power stage passes them all, and each test breaks one rule.++Run: python3 tests/run.py   (standard library only)+"""+import sys, tempfile, unittest+from pathlib import Path++sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))+import kicad_board as K   # noqa: E402+import routing_gates as R  # noqa: E402++SPEC = {+    'powerStage': {'ic': 'U1', 'vinPin': '3', 'gndPin': '1', 'inputCaps': ['C2', 'C3'], 'outputCaps': ['C6', 'C7', 'C8'],+                   'inductor': 'L1', 'inductorOutPin': '2', 'planeLayer': 'B.Cu', 'planeNet': 'GND'},+    'powerPaths': [{'net': 'SW', 'from': 'U1.2', 'to': 'L1.1'}],+    'decoupling': [{'pin': 'U1.3', 'cap': 'C2'}],+}+++def pad(n, x, y, w, h, net, layers='"F.Cu"'):+    return f'(pad "{n}" smd rect (at {x} {y}) (size {w} {h}) (layers {layers}) (net "{net}"))'+++def fp(ref, pads):+    return f'(footprint "x:{ref}" (layer "F.Cu") (at 0 0) (property "Reference" "{ref}") {" ".join(pads)})'+++def seg(a, b, w, net, layer='F.Cu'):+    return f'(segment (start {a[0]} {a[1]}) (end {b[0]} {b[1]}) (width {w}) (layer "{layer}") (net "{net}"))'+++def via(x, y, net):+    return f'(via (at {x} {y}) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "{net}"))'+++def fill(net, layer, pts):+    xy = ' '.join(f'(xy {x} {y})' for x, y in pts)+    return f'(zone (net "{net}") (layer "{layer}") (filled_polygon (layer "{layer}") (pts {xy})))'+++def base():+    """A buck power stage in KiCad's frame (y down): U1's pins 1-3 face +x, the input cap straddles SW, L1 beyond."""+    parts = {+        'U1': fp('U1', [pad(1, 111.14, 110.95, 1.32, 0.6, 'GND'), pad(2, 111.14, 110.0, 1.32, 0.6, 'SW'), pad(3, 111.14, 109.05, 1.32, 0.6, 'VIN')]),+        'C2': fp('C2', [pad(1, 113.2, 108.52, 1.8, 1.15, 'VIN'), pad(2, 113.2, 111.48, 1.8, 1.15, 'GND')]),+        'C3': fp('C3', [pad(1, 115.3, 108.52, 1.8, 1.15, 'VIN'), pad(2, 115.3, 111.48, 1.8, 1.15, 'GND')]),+        'L1': fp('L1', [pad(1, 118.0, 110.0, 2.15, 3.5, 'SW'), pad(2, 122.4, 110.0, 2.15, 3.5, 'VOUT')]),+        'C6': fp('C6', [pad(1, 121.0, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 121.0, 104.5, 1.45, 1.0, 'GND')]),+        'C7': fp('C7', [pad(1, 122.8, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 122.8, 104.5, 1.45, 1.0, 'GND')]),+        'C8': fp('C8', [pad(1, 124.6, 106.4, 1.45, 1.0, 'VOUT'), pad(2, 124.6, 104.5, 1.45, 1.0, 'GND')]),+    }+    tracks = [seg((111.14, 109.05), (113.2, 109.05), 0.5, 'VIN'), seg((111.14, 110.95), (113.2, 110.95), 0.5, 'GND'),+              seg((113.2, 111.48), (113.2, 112.6), 0.4, 'GND'), seg((111.14, 110.0), (118.0, 110.0), 1.0, 'SW')]+    vias = [via(113.2, 112.6, 'GND')]+    zones = [fill('GND', 'B.Cu', [(100, 100), (130, 100), (130, 120), (100, 120)])]+    return parts, tracks, vias, zones+++def write(d, parts, tracks, vias, zones):+    body = ' '.join(list(parts.values()) + tracks + vias + zones)+    p = Path(d) / 'b.kicad_pcb'+    p.write_text(f'(kicad_pcb (version 20250114) (setup (aux_axis_origin 100 100)) {body})')+    return p+++class RoutingGates(unittest.TestCase):+    def setUp(self):+        self.tmp = tempfile.TemporaryDirectory(); self.d = self.tmp.name++    def tearDown(self):+        self.tmp.cleanup()++    def gates(self, parts, tracks, vias, zones, spec=SPEC):+        return R.Gates(K.Board(write(self.d, parts, tracks, vias, zones)), spec).run()++    def assertFailsWith(self, res, text):+        self.assertFalse(res['passed'], res['failures'])+        self.assertTrue(any(text in f for f in res['failures']), f'{text!r} not in {res["failures"]}')++    def test_the_base_board_passes_every_gate(self):+        res = self.gates(*base())+        self.assertTrue(res['passed'], res['failures'])+        self.assertEqual(res['power_paths'][0]['minTrackMm'], 1.0)+        self.assertGreaterEqual(res['power_paths'][0]['copperNeckMm'], 0.95)++    def test_off_grid_segment(self):+        p, t, v, z = base(); t.append(seg((101, 101), (102, 101.6), 0.2, 'X'))+        self.assertFailsWith(self.gates(p, t, v, z), 'off the 0/45/90 grid')++    def test_redundant_vertex(self):+        p, t, v, z = base(); t += [seg((101, 115), (102, 115), 0.2, 'X'), seg((102, 115), (103, 115), 0.2, 'X')]+        self.assertFailsWith(self.gates(p, t, v, z), 'redundant vertex')++    def test_short_jog(self):+        p, t, v, z = base(); t += [seg((101, 117), (102, 117), 0.2, 'X'), seg((102, 117), (102.2, 117.2), 0.2, 'X'), seg((102.2, 117.2), (103.2, 117.2), 0.2, 'X')]+        self.assertFailsWith(self.gates(p, t, v, z), 'short jog')++    def test_via_inside_a_trace(self):+        p, t, v, z = base(); t += [seg((104, 102), (105, 102), 0.2, 'X'), seg((105, 102), (106, 102), 0.2, 'X')]; v.append(via(105, 102, 'X'))+        self.assertFailsWith(self.gates(p, t, v, z), 'inside a trace')++    def test_junction_before_the_cap(self):+        p, t, v, z = base()+        t[0] = seg((111.14, 109.05), (112.2, 109.05), 0.5, 'VIN'); t += [seg((112.2, 109.05), (113.2, 109.05), 0.5, 'VIN'), seg((112.2, 109.05), (112.2, 104.0), 0.5, 'VIN')]+        self.assertFailsWith(self.gates(p, t, v, z), 'before the cap')++    def test_rail_pour_before_the_cap(self):+        p, t, v, z = base(); del t[0]; z.append(fill('VIN', 'F.Cu', [(110.6, 108.0), (114.2, 108.0), (114.2, 109.3), (110.6, 109.3)]))+        self.assertFailsWith(self.gates(p, t, v, z), 'a pour')++    def test_untraced_loop_is_a_failure_not_a_none(self):+        p, t, v, z = base(); del t[0]+        self.assertFailsWith(self.gates(p, t, v, z), 'supply path not traced')++    def test_input_cap_5mm_away(self):+        p, t, v, z = base()+        p['C2'] = fp('C2', [pad(1, 118.2, 103.52, 1.8, 1.15, 'VIN'), pad(2, 118.2, 106.48, 1.8, 1.15, 'GND')])+        p['C3'] = fp('C3', [pad(1, 120.3, 103.52, 1.8, 1.15, 'VIN'), pad(2, 120.3, 106.48, 1.8, 1.15, 'GND')])+        self.assertFailsWith(self.gates(p, t, v, z), 'input loop: C2 pads')++    def test_output_cap_scattered(self):+        p, t, v, z = base(); p['C8'] = fp('C8', [pad(1, 112.0, 118.0, 1.45, 1.0, 'VOUT'), pad(2, 112.0, 119.9, 1.45, 1.0, 'GND')])+        self.assertFailsWith(self.gates(p, t, v, z), 'output caps spread')++    def test_plane_cut_under_the_hot_loop(self):+        p, t, v, z = base(); t.append(seg((112.0, 109.0), (113.0, 109.0), 0.2, 'VOUT', 'B.Cu'))+        self.assertFailsWith(self.gates(p, t, v, z), 'under the hot loop')++    def test_power_trace_necks_below_its_pin(self):+        p, t, v, z = base(); t[3] = seg((111.14, 110.0), (118.0, 110.0), 0.3, 'SW')+        res = self.gates(p, t, v, z)+        self.assertFailsWith(res, 'the trace necks to 0.3 mm')+        self.assertFailsWith(res, 'the filled copper necks')          # with no pour beneath, the copper necks too++    def test_wide_pour_carries_the_current_but_the_thin_trace_still_fails(self):+        # John's buck: a 0.3 mm trace drawn over a wide SW patch. The copper is wide enough; the trace is still flagged+        p, t, v, z = base(); t[3] = seg((111.14, 110.0), (118.0, 110.0), 0.3, 'SW')+        z.append(fill('SW', 'F.Cu', [(111.7, 109.45), (117.0, 109.45), (117.0, 110.55), (111.7, 110.55)]))+        res = self.gates(p, t, v, z)+        self.assertGreaterEqual(res['power_paths'][0]['copperNeckMm'], 0.6)+        self.assertFailsWith(res, 'the trace necks to 0.3 mm')+        self.assertFalse(any('filled copper necks' in f for f in res['failures']))++    def test_power_trace_wanders(self):+        p, t, v, z = base()+        t[3:4] = [seg((111.14, 110.0), (113.8, 110.0), 1.0, 'SW'), seg((113.8, 110.0), (114.8, 111.0), 1.0, 'SW'), seg((114.8, 111.0), (114.8, 113.0), 1.0, 'SW'),+                  seg((114.8, 113.0), (115.8, 113.0), 1.0, 'SW'), seg((115.8, 113.0), (115.8, 111.0), 1.0, 'SW'), seg((115.8, 111.0), (116.8, 110.0), 1.0, 'SW'),+                  seg((116.8, 110.0), (118.0, 110.0), 1.0, 'SW')]+        res = self.gates(p, t, v, z)+        self.assertFailsWith(res, 'the trace wanders')+        self.assertFailsWith(res, 'bends between U1.2 and L1.1')++    def test_no_copper_between_the_pins_is_a_failure(self):+        p, t, v, z = base(); del t[3]+        self.assertFailsWith(self.gates(p, t, v, z), 'no filled-copper path')+++if __name__ == '__main__':+    unittest.main()
skills/eda-visible-routing/SKILL.md+2
@@ -18,4 +18,6 @@ Read [eda-thermal-bottlenecks](../eda-thermal-bottlenecks/SKILL.md) when optimiz  For copper-ablation fabrication, read [eda-copper-ablation](../eda-copper-ablation/SKILL.md). Minimize unnecessary copper removal within the applicable electrical and manufacturing constraints. Prefer useful connected copper. Retain floating copper only when requested or permitted by the selected process and electrical requirements; report it separately from useful net-connected copper. +Check the routed result mechanically with [eda-routing-gates](../eda-routing-gates/SKILL.md): 0/45/90 geometry, no extra bends, no via inside a trace, cap-first decoupling, power-stage loops and power paths that must not neck or wander, each a gate that fails the build.+ Save meaningful checkpoints. Refill copper, run native DRC and connectivity checks, and read back the result. Report measured connectivity, violations and significant tradeoffs; distinguish inherited issues from new ones. Disclose bridge or stackup limitations rather than substituting an unsupported layer model or claiming completion.