app
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.
master
John Lauer
Product name out of hints, examples and docs (wiki #118); skills package 1.0.152
4c212c6
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
"""Design rules by fab (wiki adom/kicad-bridge#73).
The canonical per-vendor rules live on the wiki page adom/pcb-design-rules,
which renders one KiCad custom-rules file per (vendor, process, layer count):
https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/<slug>[-<process>]-<layers>L.kicad_dru
and a machine-readable index at .../render/rules/index.json. A rule change is
therefore a wiki publish, not a bridge release: this module never carries rule
values, it only resolves (fab, layers) to the page's file, stages it next to
the board as <board stem>.kicad_dru (the name KiCad loads automatically), and
optionally runs DRC so the caller sees the fab's limits applied.
The 3rd party fab is the baseline and is listed first.
"""
from __future__ import annotations
import json
import shutil
import time
import urllib.request
from pathlib import Path
PAGE = "https://wiki.adom.inc/adom/pcb-design-rules/render/"
INDEX_URL = PAGE + "rules/index.json"
HOUSE_SLUG = "adom-instapcb"
_TIMEOUT = 20
def _fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": "adom-bridge-kicad design_rules"})
with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: # nosec - fixed wiki host
return r.read()
def _index() -> list[dict]:
data = json.loads(_fetch(INDEX_URL).decode("utf-8"))
vendors = data.get("vendors") or []
# Baseline fab first, then the page's order.
vendors.sort(key=lambda v: 0 if v.get("vendorSlug") == HOUSE_SLUG else 1)
return vendors
def _norm(s: str) -> str:
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
def _match_fab(vendors: list[dict], fab: str) -> list[dict]:
"""Accept the slug, the display name, or a distinctive substring of either."""
want = _norm(fab)
if not want:
return []
exact = [v for v in vendors if _norm(v.get("vendorSlug")) == want or _norm(v.get("vendor")) == want]
if exact:
return exact
return [v for v in vendors if want in _norm(v.get("vendorSlug")) or want in _norm(v.get("vendor"))]
def handle_list_design_rules(kicad_info: dict, args: dict) -> dict:
"""List the fabs, processes and layer counts the wiki page carries."""
try:
vendors = _index()
except Exception as e: # pylint: disable=broad-except
return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",
"_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}
fab = args.get("fab")
if fab:
vendors = _match_fab(vendors, fab)
rows = []
for v in vendors:
f = v.get("files") or {}
rows.append({
"fab": v.get("vendor"), "slug": v.get("vendorSlug"), "process": v.get("process"),
"layers": v.get("layerCount"),
"kicadDruUrl": PAGE + f["kicadDru"] if f.get("kicadDru") else None,
"boardSetupUrl": PAGE + f["kicadBoardSetup"] if f.get("kicadBoardSetup") else None,
"house": v.get("vendorSlug") == HOUSE_SLUG,
})
return {"success": True, "count": len(rows), "rules": rows, "indexUrl": INDEX_URL,
"_hint": ("Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. "
"Omit fab for the baseline fab (the 3rd party fab). Values come from the wiki page at call time, so do not copy them into code.")}
def handle_set_design_rules(kicad_info: dict, args: dict) -> dict:
"""Stage a fab's KiCad rules file next to a board and (by default) run DRC.
Args: fab (slug or name; default: the baseline fab), layers (int; default from
the board's copper layer count when readable, else 2), process (optional),
boardPath (.kicad_pcb, required), druPath (local override instead of the
wiki file), runDrc (default true), keepExisting (default false: an existing
<stem>.kicad_dru is backed up as .kicad_dru.bak-<timestamp> and replaced).
"""
board = (args.get("boardPath") or args.get("filePath") or "").strip()
if not board:
return {"success": False, "error": "boardPath is required",
"_hint": "Pass {\"boardPath\": \"<absolute path to the .kicad_pcb>\"}. The rules file is staged next to it as <stem>.kicad_dru, which KiCad loads automatically."}
board_path = Path(board)
if not board_path.exists() or board_path.suffix.lower() != ".kicad_pcb":
return {"success": False, "error": f"Board not found or not a .kicad_pcb: {board}",
"_hint": "The path must exist on the Windows host. Use push_file first if the board lives in the container."}
dru_target = board_path.with_suffix(".kicad_dru")
source: dict = {}
override = (args.get("druPath") or "").strip()
if override:
src = Path(override)
if not src.exists():
return {"success": False, "error": f"druPath not found: {override}"}
body = src.read_bytes()
source = {"kind": "local", "path": str(src)}
else:
try:
vendors = _index()
except Exception as e: # pylint: disable=broad-except
return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",
"_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}
fab = args.get("fab") or HOUSE_SLUG
cands = _match_fab(vendors, fab)
if not cands:
return {"success": False, "error": f"No fab matches {fab!r}", "errorCode": "fab_not_found",
"knownFabs": sorted({v.get("vendorSlug") for v in vendors}),
"_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}
layers = args.get("layers")
if layers is None:
layers = _board_copper_layers(board_path) or 2
try:
layers = int(layers)
except (TypeError, ValueError):
return {"success": False, "error": f"layers must be an integer, got {layers!r}"}
process = args.get("process")
pick = [v for v in cands if int(v.get("layerCount") or 0) == layers
and (not process or _norm(v.get("process")) == _norm(process))]
if not pick:
return {"success": False, "errorCode": "no_rules_for_layers",
"error": f"{cands[0].get('vendor')} has no {layers}-layer{(' ' + process) if process else ''} rules on the page",
"available": [{"process": v.get("process"), "layers": v.get("layerCount")} for v in cands],
"_hint": "Pick one of `available`, or pass druPath with your own file."}
v = pick[0]
rel = (v.get("files") or {}).get("kicadDru")
if not rel:
return {"success": False, "error": f"The page lists no KiCad file for {v.get('vendor')} {layers}L"}
url = PAGE + rel
try:
body = _fetch(url)
except Exception as e: # pylint: disable=broad-except
return {"success": False, "error": f"Could not download {url}: {e}"}
source = {"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),
"process": v.get("process"), "layers": layers}
text = body.decode("utf-8", errors="replace")
if "(version" not in text[:200] or "(rule" not in text:
return {"success": False, "errorCode": "not_a_kicad_dru",
"error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",
"source": source, "head": text[:160]}
backup = None
if dru_target.exists():
if args.get("keepExisting"):
return {"success": False, "errorCode": "dru_exists",
"error": f"{dru_target.name} already exists and keepExisting is set",
"existing": str(dru_target), "source": source,
"_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}
backup = dru_target.with_name(dru_target.name + f".bak-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(dru_target, backup)
dru_target.write_bytes(body)
rule_names = [ln.split('"')[1] for ln in text.splitlines() if ln.strip().startswith("(rule ") and '"' in ln]
out = {"success": True, "druPath": str(dru_target), "backup": str(backup) if backup else None,
"source": source, "ruleCount": len(rule_names), "rules": rule_names[:40]}
if args.get("runDrc", True):
try:
from handlers.kicad_cli_lint import handle_lint_board
drc = handle_lint_board(kicad_info, {"filePath": str(board_path), "schematicParity": bool(args.get("schematicParity", False))})
except Exception as e: # pylint: disable=broad-except
drc = {"success": False, "error": f"DRC did not run: {e}"}
out["drc"] = drc
data = (drc.get("data") or {}) if isinstance(drc, dict) else {}
summary = data.get("summary") or {}
viols = data.get("violations") or []
# The violations that cite one of the fab's rules by name are the ones
# this verb exists for; list them separately from the board's own errors
# (a missing outline, dangling tracks) so the caller sees the fab's
# limits applied, measured (2026-09-03 on ConfRoomROG: "Track width
# (rule 'min-track-width' min width 0.1000 mm; actual 0.0500 mm)").
from_rules = [v for v in viols if "(rule '" in str(v.get("description") or "")]
out["drcSummary"] = summary
out["fabRuleViolations"] = [{"type": v.get("type"), "severity": v.get("severity"),
"description": v.get("description")} for v in from_rules[:40]]
out["otherViolations"] = len(viols) - len(from_rules)
if drc.get("success") is False and not viols:
out["_hint"] = f"Rules staged as {dru_target.name}, but DRC did not run: {drc.get('error') or drc.get('_hint')}"
else:
out["_hint"] = (f"Rules staged as {dru_target.name} (KiCad loads <board stem>.kicad_dru automatically; an open PCB editor applies it on its next DRC run). "
f"DRC with these rules: {len(from_rules)} violation(s) cite the fab's rules, {out['otherViolations']} other; "
f"totals {json.dumps(summary) if summary else 'n/a'}.")
else:
out["_hint"] = f"Rules staged as {dru_target.name}. Run kicad_lint_board or kicad_run_drc on the board to apply them."
return out
def _board_copper_layers(board_path: Path) -> int | None:
"""Count copper layers from the board's (layers ...) block, cheaply."""
try:
head = board_path.read_text(encoding="utf-8", errors="replace")[:20000]
i = head.find("(layers")
if i < 0:
return None
depth = 0
j = i
while j < len(head):
if head[j] == "(":
depth += 1
elif head[j] == ")":
depth -= 1
if depth == 0:
break
j += 1
block = head[i:j]
import re
n = len(re.findall(r'\(\d+\s+"[^"]+\.Cu"\s+(?:signal|power|mixed|jumper)', block))
return n or None
except Exception: # pylint: disable=broad-except
return None