KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
Refill routing snapshots and guard incomplete DRC reports #3
fix/live-routing-drc-completeness → masterRouting snapshots can retain stale zone fills, and capped KiCad DRC lists can hide new errors when comparing before and after. Refill the disposable snapshot, mark capped unconnected counts as lower bounds, and reject error-delta validation when either violation report may be truncated. The live board remains unchanged on rejection.
This ports the missing local zone-refill and DRC-completeness changes onto master 96b31b3 (insiders 0.9.340), preserving its ABI-specific vendored dependency loading. It also restores the unique offline test fixture name so a real open demo cannot trigger the unrelated open-board guard.
Validation: released master passes 27 unit tests; this candidate passes 30, including snapshot refill with project/custom rules, source preservation, cap boundaries, and rejection before mutation. Multi-host release testing is underway; this follow-up is not yet installed or tested live. No release version bump is included.
Diff Skip to comments (2)
handlers/live_routing.py+20−2@@ -174,6 +174,18 @@ def _plan(data, args): return {"netName": netname, "segments": segments, "vias": vias, "blocks": blocks} +def _report_limits(violations, unconnected):+ # KiCad 10.0.3 DRC_ENGINE: 199 per type, extended to 499 for+ # clearance and unconnected items. At the boundary completeness is unknown.+ counts = Counter(v.get("type") for v in violations)+ capped = sorted(str(kind) for kind, count in counts.items()+ if count >= (499 if kind == "clearance" else 199))+ return {"unconnectedReportMayBeTruncated": len(unconnected) >= 499,+ "unconnectedCountIsLowerBound": len(unconnected) >= 499,+ "violationReportMayBeTruncated": bool(capped),+ "possiblyTruncatedViolationTypes": capped}++ def _drc(info, args, text): exe = info.get("kicad_cli_exe") if not exe or not Path(exe).is_file():@@ -191,8 +203,10 @@ def _drc(info, args, text): shutil.copy2(sibling, candidate.with_suffix(suffix)) copied.append(str(sibling)) report = Path(tmp) / "drc.json"+ # Copper edits invalidate cached zone fills. Refill the disposable+ # snapshot so connectivity reflects the proposed copper, not old pours. proc = subprocess.run([exe, "pcb", "drc", "--format", "json", "--severity-all",- "--all-track-errors", "--output", str(report), str(candidate)],+ "--all-track-errors", "--refill-zones", "--output", str(report), str(candidate)], capture_output=True, text=True, timeout=90) if proc.returncode or not report.is_file(): raise RoutingError("drc_failed", proc.stderr or proc.stdout or "No DRC report")@@ -204,7 +218,8 @@ def _drc(info, args, text): "unconnected": len(unconnected), "violations": violations, "unconnectedItems": unconnected, "clean": not violations and not unconnected,- "source": "live-editor-snapshot", "projectRulesCopied": copied}+ "source": "live-editor-snapshot", "projectRulesCopied": copied,+ "zonesRefilled": True, **_report_limits(violations, unconnected)} def _fingerprint(v):@@ -213,6 +228,9 @@ def _fingerprint(v): def _new_errors(before, after):+ if any(r.get("violationReportMayBeTruncated") for r in (before, after)):+ raise RoutingError("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed",+ mutated=False) existing = Counter(_fingerprint(v) for v in before["violations"] if v.get("severity") == "error") added = [] for v in after["violations"]:
tests/test_live_routing.py+46@@ -19,6 +19,52 @@ def board(): 'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]} +class DrcSnapshot(unittest.TestCase):+ def test_capped_opens_are_lower_bound_without_blocking_error_comparison(self):+ limits = live._report_limits([], [{}] * 499)+ self.assertTrue(limits['unconnectedCountIsLowerBound'])+ self.assertFalse(limits['violationReportMayBeTruncated'])+ self.assertEqual(live._new_errors({'violations': [], **limits}, {'violations': [], **limits}), [])+ self.assertFalse(live._report_limits([], [{}] * 498)['unconnectedCountIsLowerBound'])++ def test_per_type_caps_block_error_delta_even_when_reports_match(self):+ for kind, cap in [('clearance', 499), ('shorting_items', 199)]:+ v = {'type': kind, 'severity': 'error', 'items': []}+ self.assertFalse(live._report_limits([v] * (cap - 1), [])['violationReportMayBeTruncated'])+ report = {'violations': [v] * cap, **live._report_limits([v] * cap, [])}+ with self.assertRaises(live.RoutingError) as cm:+ live._new_errors(report, report)+ self.assertEqual(cm.exception.code, 'drc_incomplete')+ self.assertFalse(cm.exception.detail['mutated'])++ def test_refills_snapshot_with_project_rules_without_changing_source(self):+ import json+ from types import SimpleNamespace+ with tempfile.TemporaryDirectory() as tmp:+ source = Path(tmp) / 'board.kicad_pcb'+ source.write_text('original board')+ source.with_suffix('.kicad_pro').write_text('project rules')+ source.with_suffix('.kicad_dru').write_text('custom rules')+ exe = Path(tmp) / 'kicad-cli'+ exe.touch()+ def run(command, **kwargs):+ candidate = Path(command[-1])+ self.assertNotEqual(candidate, source)+ self.assertEqual(candidate.read_text(), 'live snapshot')+ self.assertEqual(candidate.with_suffix('.kicad_pro').read_text(), 'project rules')+ self.assertEqual(candidate.with_suffix('.kicad_dru').read_text(), 'custom rules')+ self.assertIn('--refill-zones', command)+ Path(command[command.index('--output') + 1]).write_text(json.dumps({+ 'violations': [], 'unconnected_items': [{'items': []}]}))+ return SimpleNamespace(returncode=0, stderr='', stdout='')+ with patch.object(live.subprocess, 'run', side_effect=run):+ result = live._drc({'kicad_cli_exe': str(exe)}, {'filePath': str(source)}, 'live snapshot')+ self.assertTrue(result['zonesRefilled'])+ self.assertEqual(result['unconnected'], 1)+ self.assertFalse(result['clean'])+ self.assertEqual(source.read_text(), 'original board')++ class Planning(unittest.TestCase): def test_rotated_pad_coordinates_and_final_layer_transition(self): p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})
tests/test_routing_ipc.py+7−1@@ -140,7 +140,13 @@ class OfflineTests(unittest.TestCase): def test_atomic_file_edit_and_open_board_refusal(self): with tempfile.TemporaryDirectory() as d:- path = make(d); before = path.read_bytes()+ # A user can have the demonstration's default "live-routing" board+ # open on the Windows test host. Give this disposable fixture a+ # unique title so the real open-editor guard does not match it.+ fixture = make(d)+ path = fixture.with_name(Path(d).name + '-offline-test.kicad_pcb')+ fixture.rename(path)+ before = path.read_bytes() r = route.handle_route({}, {'filePath':str(path),'net':'NET_1','points':['J1.1','J2.1']}) self.assertTrue(r['success'], r) self.assertEqual(Path(str(path)+'.adom-bak').read_bytes(), before)
Comments
Log in to comment.