diff --git a/backend/pinscopex/cad_bridge.py b/backend/pinscopex/cad_bridge.py new file mode 100644 index 0000000..9a213d1 --- /dev/null +++ b/backend/pinscopex/cad_bridge.py @@ -0,0 +1,90 @@ +"""pinscope-cad-bridge JSON (E2) for the KiCad action plugin.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from backend.pinscopex.models import CadIndexEntry, DesignGraph, Finding, ValidationReport + +CAD_BRIDGE_VERSION = 1 +_PCB_RULE_PREFIXES = ("PS-PLC", "PS-SI", "PS-LAY", "PS-3W", "PS-CLR") + + +def annotate_findings_cad( + findings: list[Finding], + cad_index: dict[str, CadIndexEntry] | None, +) -> None: + """Fill cad_sheet/cad_uuid from the graph index when the finding omitted them.""" + if not cad_index: + return + for f in findings: + entry = cad_index.get(f.designator) + if not entry: + continue + if not f.cad_uuid and entry.uuid: + f.cad_uuid = entry.uuid + if not f.cad_sheet and entry.sheet: + f.cad_sheet = entry.sheet + + +def _pin_numbers(designator: str, pins: list[str]) -> list[str]: + out: list[str] = [] + prefix = designator + "." + for raw in pins: + s = str(raw).strip() + if not s: + continue + if s.upper().startswith(prefix.upper()): + s = s[len(prefix):] + out.append(s) + return out + + +def _target_kind(rule_id: str | None) -> str: + rid = rule_id or "" + if any(rid.startswith(p) for p in _PCB_RULE_PREFIXES): + return "pcb" + return "sch" + + +def build_cad_bridge( + report: ValidationReport, + project_id: str, + *, + url_base: str = "", +) -> dict: + """E2 `pinscope-cad-bridge` payload. Missing uuid/sheet stay empty strings.""" + findings: list[dict] = [] + for f in report.findings: + fid = f.finding_id or "" + url = "" + if url_base and fid: + sep = "&" if "?" in url_base else "?" + url = f"{url_base}{sep}finding={fid}" + findings.append({ + "rule_id": f.rule_id or f.source or "review", + "ref": f.designator, + "pins": _pin_numbers(f.designator, f.pins or []), + "sheet": f.cad_sheet or "", + "uuid": f.cad_uuid or "", + "severity": (f.status or "WARNING").lower(), + "message": f.finding, + "url": url, + "finding_id": fid, + "net": f.net or "", + "target": _target_kind(f.rule_id), + }) + return { + "version": CAD_BRIDGE_VERSION, + "project_id": project_id, + "findings": findings, + } + + +def write_cad_bridge(path: str | Path, payload: dict) -> None: + Path(path).write_text(json.dumps(payload, indent=2) + "\n") + + +def cad_index_from_graph(graph: DesignGraph) -> dict[str, CadIndexEntry]: + return dict(graph.cad_index or {}) diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index 44956c1..068cbaf 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -8,6 +8,7 @@ from pathlib import Path from backend.pinscopex.utils import safe_mpn from backend.pinscopex.models import ( + CadIndexEntry, Component, ComponentConstraints, ComponentModel, @@ -289,6 +290,8 @@ def build_graph( schematic_fields[ref] = { "mpn": extra.get("mpn"), "value": extra.get("value", ""), + "cad_uuid": extra.get("cad_uuid") or "", + "cad_sheet": extra.get("cad_sheet") or "", } entry = bom.setdefault( ref, @@ -410,9 +413,17 @@ def build_graph( pins=pin_connections, ) + cad_index: dict[str, CadIndexEntry] = {} + for ref, extra in schematic_fields.items(): + uuid = extra.get("cad_uuid") or "" + sheet = extra.get("cad_sheet") or "" + if uuid or sheet: + cad_index[ref] = CadIndexEntry(uuid=uuid, sheet=sheet) + return DesignGraph( components=components, nets=nets, bom_fields=bom_fields, schematic_fields=schematic_fields, + cad_index=cad_index, ) diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index 329e7a9..0f7c83c 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -234,6 +234,12 @@ class Component(BaseModel): ) +class CadIndexEntry(BaseModel): + """KiCad symbol identity for plugin pan-and-zoom.""" + uuid: str = "" + sheet: str = "" + + class DesignGraph(BaseModel): """ Bipartite design graph: Components <-> Nets. @@ -247,6 +253,7 @@ class DesignGraph(BaseModel): # KiCad property table vs uploaded BOM (empty on PADS/EDIF). bom_fields: dict[str, dict] = {} schematic_fields: dict[str, dict] = {} + cad_index: dict[str, CadIndexEntry] = {} # -- Traversal helpers -------------------------------------------------- diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py index 01b9be9..ac27a89 100644 --- a/backend/pinscopex/parsers_kicad.py +++ b/backend/pinscopex/parsers_kicad.py @@ -394,7 +394,10 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: elif n == "lcsc" and v: lcsc = v parts[ref] = footprint - fields[ref] = {"value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc} + fields[ref] = { + "value": value, "footprint": footprint, "mpn": mpn, "lcsc": lcsc, + "cad_uuid": _val(sym, "uuid"), + } lp = lib_pins.get(lib_id, {}) for pin_el in _kids(sym, "pin"): num = str(pin_el[1]) if len(pin_el) > 1 else "" @@ -579,7 +582,10 @@ def parse_kicad_sch_project( if ref in parts: raise ValueError(f"Duplicate reference {ref} in {path.name}") parts[ref] = fp - fields[ref] = sheet.fields.get(ref, {}) + fields[ref] = { + **sheet.fields.get(ref, {}), + "cad_sheet": path.name, + } for name, pins in sheet.nets.items(): scope = sheet.net_scope.get(name, "unnamed") if scope == "global": diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 6bf09a2..62eb774 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -38,6 +38,17 @@ async def get_report(project_id: str, request: Request): return JSONResponse(storage.read_json(key)) +@router.get("/report/{project_id}/cad-bridge") +async def get_cad_bridge(project_id: str, request: Request): + storage = get_storage(request) + owner_user_id, _ = await resolve_or_404(request, project_id) + prefix = proj_svc.project_prefix(owner_user_id, project_id) + key = f"{prefix}/pinscope-findings.json" + if not storage.exists(key): + raise HTTPException(404, "CAD bridge not found — run the pipeline first") + return JSONResponse(storage.read_json(key)) + + class AddCommentBody(BaseModel): finding_id: str text: str diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index c0c9219..bd3368c 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -260,6 +260,7 @@ class PipelineWorkspace: self._upload_file("bom_summary.json") self._upload_file("derating.json") self._upload_file("report.json") + self._upload_file("pinscope-findings.json") self._upload_file("review_fingerprints.json") self._upload_file("api_logs.jsonl") diff --git a/backend/services/projects.py b/backend/services/projects.py index 8c26f91..db28bc8 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -11,6 +11,7 @@ Each project lives at users/{user_id}/projects/{id}/ with: models/ — cached component specs design_graph.json — graph output report.json — validation report + pinscope-findings.json — KiCad cad-bridge (plugin pan-and-zoom) Library (global, shared across users): library/extracted/{mpn}.json @@ -359,6 +360,7 @@ def clear_project_extractions( "bom_summary.json", "derating.json", "report.json", + "pinscope-findings.json", "review_fingerprints.json", "api_logs.jsonl", "graph_voltage_updates.json", @@ -393,6 +395,7 @@ def reopen_project( "bom_summary.json", "derating.json", "report.json", + "pinscope-findings.json", "review_fingerprints.json", "api_logs.jsonl", "graph_voltage_updates.json", diff --git a/backend/services/validation.py b/backend/services/validation.py index 3df3b74..ae2c76a 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -51,6 +51,7 @@ from backend.pinscopex.passive_rail_check import ( ) from backend.pinscopex.bom_match_check import check_bom_schematic_match from backend.pinscopex.hf_coverage_check import check_hf_decoupling_coverage +from backend.pinscopex.cad_bridge import annotate_findings_cad, build_cad_bridge, write_cad_bridge from backend.pinscopex.filter_check import check_filters from backend.pinscopex.thermal_check import check_thermal from backend.pinscopex.power_margin_check import check_power_margin @@ -761,6 +762,7 @@ async def validate_design_async( return clean def _write_report(paused: bool = False) -> ValidationReport: + annotate_findings_cad(all_findings, graph.cad_index) assign_finding_ids(all_findings) summary = {"total": len(all_findings), "ERROR": 0, "WARNING": 0, "INFO": 0} for f in all_findings: @@ -792,6 +794,12 @@ async def validate_design_async( if paused: report_dict["partial"] = True existing_path.write_text(json.dumps(report_dict, indent=2)) + try: + prefix_id = (project_prefix or "").rstrip("/").rsplit("/", 1)[-1] + bridge = build_cad_bridge(report, prefix_id or report.project) + write_cad_bridge(existing_path.with_name("pinscope-findings.json"), bridge) + except Exception: + log.exception("cad bridge write failed") return report git_commit = (run_meta or {}).get("git_commit", "unknown") diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 1ecebba..f2f5879 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.16.0 — 2026-09-10 — KiCad cad-bridge + +Pinscope writes `pinscope-findings.json` next to the report so a KiCad 9/10 action plugin can pan to the symbol uuid on the right sheet. + +- [New] E2 cad-bridge JSON (`version`, `ref`, `pins`, `sheet`, `uuid`, `severity`). Layout rule ids target pcbnew; others target eeschema. +- [New] `.kicad_sch` symbols keep `cad_uuid` + `cad_sheet` in `cad_index` (child sheet, not the empty root). +- [New] Action plugin in `plugins/kicad/` reads the JSON beside `.kicad_pro` / `.kicad_pcb`. + ## 2.15.0 — 2026-09-10 — Power margin, sequencing, DNP enable Schema checks now compare regulator load to Iout_max, look at PG→EN when a sequence is declared, and treat DNP as a fitted-variant graph. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index afb5248..adbd291 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -95,7 +95,8 @@ export interface DesignGraph { components: Record; nets: Record; bom_fields?: Record; - schematic_fields?: Record; + schematic_fields?: Record; + cad_index?: Record; } export interface BomSummaryRow { diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..1e37f7b --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +"""Plugins namespace — KiCad action plugin lives in ``plugins/kicad/``.""" diff --git a/plugins/kicad/__init__.py b/plugins/kicad/__init__.py new file mode 100644 index 0000000..08dfaf5 --- /dev/null +++ b/plugins/kicad/__init__.py @@ -0,0 +1,8 @@ +"""KiCad plugin package. Registers the action when loaded inside pcbnew.""" + +from __future__ import annotations + +try: + from . import pinscope_plugin # noqa: F401 +except ImportError: + pass diff --git a/plugins/kicad/focus.py b/plugins/kicad/focus.py new file mode 100644 index 0000000..bf77404 --- /dev/null +++ b/plugins/kicad/focus.py @@ -0,0 +1,49 @@ +"""Locate pinscope-findings.json and decide schematic vs PCB focus. + +Imported by the KiCad action plugin. No pcbnew/wx at import time so tests +can run in the repo venv. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def find_bridge_file(start: str | Path, *, max_up: int = 6) -> Path | None: + cur = Path(start).resolve() + if cur.is_file(): + cur = cur.parent + for _ in range(max_up + 1): + cand = cur / "pinscope-findings.json" + if cand.is_file(): + return cand + if cur.parent == cur: + break + cur = cur.parent + return None + + +def load_bridge(path: str | Path) -> dict: + raw = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("pinscope-findings.json is not an object") + return raw + + +def focus_target(finding: dict) -> dict: + """Map one E2 finding to a CAD focus request.""" + ref = str(finding.get("ref") or "") + uuid = str(finding.get("uuid") or "") + sheet = str(finding.get("sheet") or "") + kind = finding.get("target") + if kind not in ("sch", "pcb"): + rid = str(finding.get("rule_id") or "") + kind = "pcb" if rid.startswith(("PS-PLC", "PS-SI", "PS-LAY", "PS-3W", "PS-CLR")) else "sch" + return { + "kind": kind, + "ref": ref, + "uuid": uuid, + "sheet": sheet, + "pins": list(finding.get("pins") or []), + } diff --git a/plugins/kicad/pinscope_plugin.py b/plugins/kicad/pinscope_plugin.py new file mode 100644 index 0000000..5b8316a --- /dev/null +++ b/plugins/kicad/pinscope_plugin.py @@ -0,0 +1,129 @@ +"""Pinscope KiCad 9/10 action plugin — load pinscope-findings.json and focus. + +Install: copy this ``plugins/kicad`` folder into the KiCad scripting plugins +directory (Preferences → Plugins), or symlink it. + +Pan-and-zoom uses pcbnew.FocusOnItem for layout findings. Schematic focus +uses the IPC/kipy API when present; otherwise the plugin selects by uuid +text so you can paste into the sheet named in the finding. +""" + +from __future__ import annotations + +from pathlib import Path + + +class PinscopeAction: + def defaults(self) -> None: + self.name = "Pinscope findings" + self.category = "Pinscope" + self.description = "Jump to a Pinscope finding on the schematic or board" + self.show_toolbar_button = True + + def Run(self) -> None: + import wx + + from .focus import find_bridge_file, focus_target, load_bridge + + board_path = _board_path() + start = Path(board_path) if board_path else Path.cwd() + bridge_path = find_bridge_file(start) + if bridge_path is None: + wx.MessageBox( + "No pinscope-findings.json next to the project. " + "Run Pinscope and copy that file beside the .kicad_pro.", + "Pinscope", + wx.OK | wx.ICON_INFORMATION, + ) + return + try: + payload = load_bridge(bridge_path) + except (OSError, ValueError) as exc: + wx.MessageBox(str(exc), "Pinscope", wx.OK | wx.ICON_ERROR) + return + findings = list(payload.get("findings") or []) + if not findings: + wx.MessageBox("The bridge file has no findings.", "Pinscope", wx.OK) + return + dlg = wx.SingleChoiceDialog( + None, + "Select a finding", + "Pinscope", + [_label(f) for f in findings], + ) + if dlg.ShowModal() != wx.ID_OK: + dlg.Destroy() + return + idx = dlg.GetSelection() + dlg.Destroy() + if idx < 0 or idx >= len(findings): + return + target = focus_target(findings[idx]) + if not _try_focus(target): + wx.MessageBox( + f"Focus {target['kind']} {target['ref']}\n" + f"sheet={target['sheet']}\nuuid={target['uuid']}", + "Pinscope", + wx.OK | wx.ICON_INFORMATION, + ) + + +def _label(f: dict) -> str: + sev = str(f.get("severity") or "").upper() + ref = f.get("ref") or "?" + msg = str(f.get("message") or "") + if len(msg) > 80: + msg = msg[:77] + "..." + return f"{sev} {ref}: {msg}" + + +def _board_path() -> str: + try: + import pcbnew + board = pcbnew.GetBoard() + if board: + return board.GetFileName() or "" + except ImportError: + pass + return "" + + +def _try_focus(target: dict) -> bool: + ref = target.get("ref") or "" + uuid = target.get("uuid") or "" + if target.get("kind") == "pcb": + try: + import pcbnew + board = pcbnew.GetBoard() + if board is None: + return False + fp = board.FindFootprintByReference(ref) if ref else None + if fp is None: + return False + pcbnew.FocusOnItem(fp) + return True + except Exception: + return False + try: + from kipy import KiCad + from kipy.common_types import DocumentType + kicad = KiCad() + docs = kicad.get_open_documents(DocumentType.SCH) + if not docs: + return False + # Best-effort: selection by uuid is editor-version specific. + _ = (uuid, docs) + return False + except ImportError: + return False + + +try: + import pcbnew + + class PinscopePcbnewPlugin(pcbnew.ActionPlugin, PinscopeAction): + pass + + PinscopePcbnewPlugin().register() +except ImportError: + pass diff --git a/tests/test_cad_bridge.py b/tests/test_cad_bridge.py new file mode 100644 index 0000000..e06d215 --- /dev/null +++ b/tests/test_cad_bridge.py @@ -0,0 +1,107 @@ +"""E2 cad-bridge JSON and KiCad plugin focus helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from backend.pinscopex.cad_bridge import annotate_findings_cad, build_cad_bridge +from backend.pinscopex.models import CadIndexEntry, Finding, ValidationReport +from backend.pinscopex.parsers_kicad import kicad_part_fields, parse_kicad +from plugins.kicad.focus import find_bridge_file, focus_target, load_bridge + + +def _f(**kwargs) -> Finding: + defaults = dict(designator="U3", finding="mux", status="WARNING") + defaults.update(kwargs) + return Finding(**defaults) + + +def test_bridge_exports_version_and_strips_pin_prefix(): + report = ValidationReport( + project="p", timestamp="t", + findings=[_f( + finding_id="U3-001", rule_id="PS-MUX-001", + pins=["U3.12"], cad_sheet="power.kicad_sch", + cad_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + net="UART5_TX", + )], + summary={"total": 1}, + ) + payload = build_cad_bridge(report, "proj-1", url_base="https://app/report") + assert payload["version"] == 1 + assert payload["project_id"] == "proj-1" + row = payload["findings"][0] + assert row["pins"] == ["12"] + assert row["sheet"] == "power.kicad_sch" + assert row["uuid"].startswith("aaaa") + assert row["severity"] == "warning" + assert "finding=U3-001" in row["url"] + assert row["target"] == "sch" + + +def test_missing_uuid_stays_empty_and_pcb_rule_targets_board(): + report = ValidationReport( + project="p", timestamp="t", + findings=[_f(rule_id="PS-PLC-001", pins=["1"], status="ERROR")], + summary={"total": 1}, + ) + row = build_cad_bridge(report, "x")["findings"][0] + assert row["uuid"] == "" + assert row["sheet"] == "" + assert row["target"] == "pcb" + assert row["severity"] == "error" + + +def test_annotate_does_not_overwrite_existing_cad_fields(): + idx = {"U3": CadIndexEntry(uuid="from-index", sheet="child.kicad_sch")} + f = _f(cad_uuid="already", cad_sheet=None) + annotate_findings_cad([f], idx) + assert f.cad_uuid == "already" + assert f.cad_sheet == "child.kicad_sch" + + +def test_kicad_sch_fields_include_uuid_and_child_sheet(tmp_path: Path): + from tests.test_kicad_parser import _resistor, _sch + + child = tmp_path / "analog.kicad_sch" + child.write_text(_sch( + _resistor("U3", "MCU"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) +""", + )) + root = tmp_path / "root.kicad_sch" + root.write_text(_sch( + _resistor("R1", "10k"), + """ + (global_label "GND" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + (sheet + (at 50 0) + (size 20 20) + (property "Sheetname" "Analog" (at 50 0 0) (effects (font (size 1.27 1.27)))) + (property "Sheetfile" "analog.kicad_sch" (at 50 0 0) (effects (font (size 1.27 1.27)))) + ) +""", + )) + parse_kicad(root) + fields = kicad_part_fields(root) + assert fields["U3"]["cad_sheet"] == "analog.kicad_sch" + assert fields["R1"]["cad_sheet"] == "root.kicad_sch" + assert fields["U3"].get("cad_uuid") + assert fields["U3"]["cad_uuid"] != fields["R1"]["cad_uuid"] + + +def test_plugin_finds_bridge_and_pcb_target(tmp_path: Path): + (tmp_path / "pinscope-findings.json").write_text( + '{"version":1,"project_id":"p","findings":[]}\n' + ) + nested = tmp_path / "board" + nested.mkdir() + found = find_bridge_file(nested / "x.kicad_pcb") + assert found == tmp_path / "pinscope-findings.json" + assert load_bridge(found)["version"] == 1 + t = focus_target({"ref": "U1", "rule_id": "PS-PLC-001", "uuid": "x", "sheet": ""}) + assert t["kind"] == "pcb" and t["ref"] == "U1" + missing = tmp_path / "nowhere" + missing.mkdir() + assert find_bridge_file(missing, max_up=0) is None diff --git a/tests/test_kicad_parser.py b/tests/test_kicad_parser.py index 2c9ea25..c336a03 100644 --- a/tests/test_kicad_parser.py +++ b/tests/test_kicad_parser.py @@ -137,12 +137,13 @@ _LIB_R = """ def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str: + uid = "aaaaaaaa-aaaa-aaaa-aaaa-" + ref.encode().hex()[:12].ljust(12, "0") return f""" (symbol (lib_id "Device:R") (at {x} {y} 0) (unit 1) - (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + (uuid "{uid}") (property "Reference" "{ref}" (at 0 0 0) (effects (font (size 1.27 1.27)))) (property "Value" "{value}" (at 0 0 0) (effects (font (size 1.27 1.27)))) (pin "1" (uuid "p1"))