Add KiCad cad-bridge JSON and an action plugin for finding focus.

Write pinscope-findings.json from the report, keep symbol uuid and child-sheet on the graph, and let the pcbnew plugin jump to a footprint or show the schematic uuid when IPC focus is unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 22:40:28 +02:00
co-authored by Cursor
parent 78013bd404
commit aa375349bd
16 changed files with 445 additions and 4 deletions
+90
View File
@@ -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 {})
+11
View File
@@ -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,
)
+7
View File
@@ -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 --------------------------------------------------
+8 -2
View File
@@ -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":
+11
View File
@@ -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
+1
View File
@@ -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")
+3
View File
@@ -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",
+8
View File
@@ -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")