#!/usr/bin/python3 """Read-only draft audit; needs pcbnew, numpy, shapely and kicad-cli. Writes fresh DRC and audit JSON reports, never modifies the board. Independent continuous centreline/radius checks complement KiCad DRC. Restricted here to the existing circle/axis-aligned rectangular THT pads and straight tracks. """ from collections import Counter import hashlib import importlib.util import json from pathlib import Path import subprocess import pcbnew as p from shapely.geometry import Point, LineString, Polygon, box ROOT = Path(__file__).resolve().parent.parent spec = importlib.util.spec_from_file_location('router', ROOT / 'route_pcb_draft.py') r = importlib.util.module_from_spec(spec) spec.loader.exec_module(r) board = p.LoadBoard(str(r.PCB)) original = p.LoadBoard(str(ROOT / 'validation/pcb-routing-input.kicad_pcb')) assert r.identity(board) == r.identity(original), 'immutable source geometry/linkage changed' items, holes = [], [] for f in board.GetFootprints(): for pad in f.Pads(): xy = r.pos(pad) assert pad.GetOrientationDegrees() % 90 == 0 assert pad.GetShape() in (p.PAD_SHAPE_CIRCLE, p.PAD_SHAPE_RECT) if pad.GetShape() == p.PAD_SHAPE_CIRCLE: assert pad.GetSize().x == pad.GetSize().y geom, radius = Point(xy), p.ToMM(pad.GetSize().x) / 2 else: bb = pad.GetBoundingBox() geom = box(p.ToMM(bb.GetX()), p.ToMM(bb.GetY()), p.ToMM(bb.GetRight()), p.ToMM(bb.GetBottom())) radius = 0 label = f.GetReference() + '.' + pad.GetNumber() + ':' + pad.m_Uuid.AsString() layers = {l for l in r.LAYERS if pad.IsOnLayer(l)} items.append((label, pad.GetNetCode(), layers, geom, radius)) if pad.GetDrillSize().x: assert pad.GetDrillSize().x == pad.GetDrillSize().y holes.append((label, Point(xy), p.ToMM(pad.GetDrillSize().x) / 2, False)) track_lengths = Counter() widths = Counter() for t in board.GetTracks(): is_via = isinstance(t, p.PCB_VIA) if is_via: geom, radius, layers = Point(r.pos(t)), p.ToMM(t.GetWidth(p.F_Cu)) / 2, set(r.LAYERS) assert abs(radius - 0.35) < 1e-8 and t.GetDrillValue() == r.mm(0.3) holes.append((t.m_Uuid.AsString(), geom, 0.15, True)) else: geom = LineString([(p.ToMM(t.GetStart().x), p.ToMM(t.GetStart().y)), (p.ToMM(t.GetEnd().x), p.ToMM(t.GetEnd().y))]) radius, layers = p.ToMM(t.GetWidth()) / 2, {t.GetLayer()} expected = 0.5 if t.GetNetname() in ('/+3V3', '/GND') else 0.25 assert abs(2 * radius - expected) < 1e-8 widths[str(2 * radius)] += 1 track_lengths[t.GetNetname()] += geom.length items.append((t.m_Uuid.AsString(), t.GetNetCode(), layers, geom, radius)) min_clearance = float('inf') closest = None for i, a in enumerate(items): for b in items[i + 1:]: if not a[2].intersection(b[2]) or (a[1] and a[1] == b[1]): continue distance = a[3].distance(b[3]) - a[4] - b[4] if distance < min_clearance: min_clearance, closest = distance, (a[0], b[0]) assert min_clearance >= 0.25 - 1e-6, (min_clearance, closest) outline = Polygon([(100,100),(182,100),(182,180),(136,180),(136,161),(100,161)]) rf = box(110, 100, 131, 113) min_edge, min_rf = float('inf'), float('inf') for label, net, layers, geom, radius in items: assert outline.covers(geom), label edge_gap = outline.boundary.distance(geom) - radius rf_gap = rf.distance(geom) - radius min_edge, min_rf = min(min_edge, edge_gap), min(min_rf, rf_gap) assert edge_gap >= 0.5 - 1e-6, (label, edge_gap) assert rf_gap > 0, (label, rf_gap) min_via_hole_gap = float('inf') for i, a in enumerate(holes): for b in holes[i + 1:]: if not (a[3] or b[3]): continue gap = a[1].distance(b[1]) - a[2] - b[2] min_via_hole_gap = min(min_via_hole_gap, gap) assert gap >= 0.25 - 1e-6, (a[0], b[0], gap) # Refusal paths must leave the routed board byte-for-byte unchanged. before = r.PCB.read_bytes() guards = [] for args in (['--route'], ['--route', '--overwrite'], []): result = subprocess.run(['/usr/bin/python3', '-B', str(ROOT/'route_pcb_draft.py'), *args], capture_output=True, text=True, timeout=20) assert result.returncode == 2, (args, result.stdout, result.stderr) assert r.PCB.read_bytes() == before guards.append({'args': args, 'exit_code': result.returncode, 'reason': result.stderr.splitlines()[-1]}) subprocess.run(['kicad-cli', 'pcb', 'drc', '--format', 'json', '--all-track-errors', '--severity-all', '--exit-code-violations', '-o', str(ROOT/'validation/pcb-routing-drc.json'), str(r.PCB)], check=True, timeout=60) assert r.PCB.read_bytes() == before, 'board changed during audit' drc = json.loads(ROOT.joinpath('validation/pcb-routing-drc.json').read_text()) assert not drc['violations'] and not drc['unconnected_items'] report = dict(immutable_geometry_nets_pads_linkage_preserved=True, board_sha256=hashlib.sha256(before).hexdigest(), independent_min_different_net_or_npth_clearance_mm=min_clearance, closest_items=closest, min_copper_or_npth_edge_gap_mm=min_edge, min_rf_gap_mm=min_rf, min_via_to_other_drilled_hole_gap_mm=min_via_hole_gap, track_width_counts=widths, routed_length_mm_by_net=track_lengths, tracks=sum(not isinstance(t,p.PCB_VIA) for t in board.GetTracks()), vias=sum(isinstance(t,p.PCB_VIA) for t in board.GetTracks()), copper_zones=sum(not z.GetIsRuleArea() for z in board.Zones()), drc_freshly_run=True, drc_violations=len(drc['violations']), unconnected_items=len(drc['unconnected_items']), drc_ignored_checks=drc['ignored_checks'], overwrite_guard_tests=guards) ROOT.joinpath('validation/pcb-routing-check.json').write_text(json.dumps(report, indent=2)+'\n') print(json.dumps(report, indent=2))