diff --git a/backend/pinscopex/parsers_kicad.py b/backend/pinscopex/parsers_kicad.py index 36a6d36..3b64e30 100644 --- a/backend/pinscopex/parsers_kicad.py +++ b/backend/pinscopex/parsers_kicad.py @@ -259,6 +259,45 @@ def _rotate(px: float, py: float, deg: float) -> tuple[float, float]: return px * c + py * s, -px * s + py * c +def _mirror_axes(sym: Any) -> tuple[bool, bool]: + """KiCad ``(mirror x)`` / ``(mirror y)`` — flip symbol-local axes.""" + m = _kid(sym, "mirror") + if not m: + return False, False + axes = {str(item) for item in m[1:]} + if not axes: + # Legacy bare ``(mirror)`` — treat as X flip (historical eeschema). + return True, False + return ("x" in axes), ("y" in axes) + + +def _point_on_segment( + p: tuple[int, int], + a: tuple[int, int], + b: tuple[int, int], + tol: int = 2, +) -> bool: + """True if snapped point ``p`` lies on segment ``ab`` (inclusive).""" + ax, ay = a + bx, by = b + px, py = p + if px < min(ax, bx) - tol or px > max(ax, bx) + tol: + return False + if py < min(ay, by) - tol or py > max(ay, by) + tol: + return False + dx, dy = bx - ax, by - ay + len2 = dx * dx + dy * dy + if len2 == 0: + return abs(px - ax) <= tol and abs(py - ay) <= tol + # Distance from p to infinite line, then clamp to segment. + t = ((px - ax) * dx + (py - ay) * dy) / len2 + if t < -0.01 or t > 1.01: + return False + qx = ax + t * dx + qy = ay + t * dy + return (px - qx) ** 2 + (py - qy) ** 2 <= tol * tol + + def _lib_pins(sym: Any) -> dict[tuple[int, str], tuple[float, float]]: """(unit, pin_number) -> (x, y) in symbol space. unit 0 = common.""" out: dict[tuple[int, str], tuple[float, float]] = {} @@ -345,6 +384,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: dsu = _DSU() labels: dict[tuple[int, int], tuple[str, str]] = {} power_pts: list[tuple[tuple[int, int], str]] = [] + wire_segs: list[tuple[tuple[int, int], tuple[int, int]]] = [] def prop(sym: Any, key: str) -> str: for p in _kids(sym, "property"): @@ -359,20 +399,26 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: if prev is None or _KIND_RANK[kind] >= _KIND_RANK[prev[1]]: labels[pt] = (name, kind) + def apply_sym_xy(px: float, py: float, rot: float, mx: bool, my: bool) -> tuple[float, float]: + rx, ry = _rotate(px, py, rot) + if mx: + rx = -rx + if my: + ry = -ry + return rx, ry + for sym in _kids(tree, "symbol"): lib_id = _val(sym, "lib_id") ix, iy, rot = _at(sym) unit = int(_fnum(_val(sym, "unit") or "1") or 1) - mirror = bool(_kid(sym, "mirror")) + mx, my = _mirror_axes(sym) ref = prop(sym, "Reference") if ref.startswith("#"): # power flag / graphic val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1] lp = lib_pins.get(lib_id, {}) xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0) - px, py = _rotate(xy[0], xy[1], rot) - if mirror: - px = -px + px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my) pt = _snap(ix + px, iy + py) dsu.add(pt) if val: @@ -404,9 +450,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: if not num: continue xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0) - px, py = _rotate(xy[0], xy[1], rot) - if mirror: - px = -px + px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my) pt = _snap(ix + px, iy + py) pin_at[(ref, num)] = pt dsu.add(pt) @@ -426,6 +470,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: coords.append(pt) for a, b in zip(coords, coords[1:]): dsu.union(a, b) + wire_segs.append((a, b)) return if tag == "label": name = str(node[1]) if len(node) > 1 else "" @@ -471,6 +516,30 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet: for pt, _name in power_pts: dsu.add(pt) + # Pins / labels / power on the middle of a wire share that net. + attach_pts = list(pin_at.values()) + list(labels.keys()) + [pt for pt, _ in power_pts] + for pt in attach_pts: + for a, b in wire_segs: + if _point_on_segment(pt, a, b): + dsu.union(pt, a) + dsu.union(pt, b) + + # KiCad semantics: same-name global labels and power symbols are one net + # even when not geometrically connected. Same-name local labels merge + # within a single sheet. + by_name: dict[tuple[str, str], list[tuple[int, int]]] = {} + for pt, (name, kind) in labels.items(): + if kind in ("global", "local", "hier"): + by_name.setdefault((kind, name), []).append(pt) + for pt, name in power_pts: + by_name.setdefault(("global", name), []).append(pt) + for pts in by_name.values(): + if len(pts) < 2: + continue + head = pts[0] + for p in pts[1:]: + dsu.union(head, p) + root_name: dict[tuple[int, int], str] = {} root_kind: dict[tuple[int, int], str] = {} for pt, (name, kind) in labels.items(): diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 6356f83..8fd3a06 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.26.4 — 2026-09-11 — KiCad power nets merge + +`.kicad_sch` connectivity treated every `GND` power flag as its own island (`GND_`, `GND__`…), so the reviewer invented swapped rails and floating pins on good designs. + +- [Fixed] Same-name power symbols and global/local labels merge within a sheet (KiCad semantics). +- [Fixed] Pins sitting mid-wire attach to that net; `mirror x`/`y` handled correctly. +- [Test] Disconnected GND flags, local labels, mid-segment wire. + ## 2.26.3 — 2026-09-11 — KiCad project zip upload One zip (or every `.kicad_sch`) covers hierarchical sheets. If the zip has `bom.csv` and `.kicad_pcb`, those are taken too. Pipeline re-reads companions from storage. diff --git a/tests/test_kicad_parser.py b/tests/test_kicad_parser.py index c336a03..5f6c3f7 100644 --- a/tests/test_kicad_parser.py +++ b/tests/test_kicad_parser.py @@ -132,6 +132,13 @@ _LIB_R = """ (number "2" (effects (font (size 1.27 1.27)))) ) ) + (symbol "power:GND" + (power) + (pin power_in (at 0 0 270) (length 0) + (name "GND" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + ) ) """ @@ -152,10 +159,83 @@ def _resistor(ref: str, value: str, x: float = 0, y: float = 0) -> str: """ +def _power_gnd(x: float, y: float, suffix: str = "1") -> str: + uid = f"bbbbbbbb-bbbb-bbbb-bbbb-{suffix.zfill(12)}" + return f""" + (symbol + (lib_id "power:GND") + (at {x} {y} 0) + (unit 1) + (uuid "{uid}") + (property "Reference" "#{suffix}" (at 0 0 0) (effects (font (size 1.27 1.27)))) + (property "Value" "GND" (at 0 0 0) (effects (font (size 1.27 1.27)))) + (pin "1" (uuid "pgnd{suffix}")) + ) +""" + + def _sch(*body: str) -> str: return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n" +def test_power_symbols_same_name_merge_without_wires(tmp_path: Path): + """KiCad power flags are global: two GND symbols share one net even if islands.""" + from backend.pinscopex.parsers import parse_netlist_any + + p = tmp_path / "power.kicad_sch" + # R1 and R2 far apart, each with GND on pin 1, no wires between them. + p.write_text(_sch( + _resistor("R1", "10k", x=0, y=0), + _power_gnd(0, 3.81, "1"), + _resistor("R2", "10k", x=100, y=0), + _power_gnd(100, 3.81, "2"), + )) + _parts, nets, fmt = parse_netlist_any(p) + assert fmt == "kicad_sch" + assert "GND" in nets + assert ("R1", "1") in nets["GND"] + assert ("R2", "1") in nets["GND"] + gnd_like = [n for n in nets if n.rstrip("_") == "GND"] + assert gnd_like == ["GND"], gnd_like + + +def test_local_labels_same_name_merge_on_same_sheet(tmp_path: Path): + from backend.pinscopex.parsers import parse_netlist_any + + p = tmp_path / "local.kicad_sch" + p.write_text(_sch( + _resistor("R1", "10k", x=0, y=0), + """ + (label "NETA" (at 0 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1")) +""", + _resistor("R2", "10k", x=100, y=0), + """ + (label "NETA" (at 100 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2")) +""", + )) + _parts, nets, _fmt = parse_netlist_any(p) + assert ("R1", "1") in nets["NETA"] + assert ("R2", "1") in nets["NETA"] + + +def test_pin_on_mid_wire_segment_connects(tmp_path: Path): + from backend.pinscopex.parsers import parse_netlist_any + + p = tmp_path / "midwire.kicad_sch" + # Horizontal wire from (-10,3.81) to (10,3.81); R1 pin1 at (0,3.81) sits mid-segment. + p.write_text(_sch( + _resistor("R1", "10k", x=0, y=0), + _resistor("R2", "10k", x=10, y=0), + """ + (wire (pts (xy -10 3.81) (xy 10 3.81))) + (global_label "SIG" (at -10 3.81 0) (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) +""", + )) + _parts, nets, _fmt = parse_netlist_any(p) + assert ("R1", "1") in nets["SIG"] + assert ("R2", "1") in nets["SIG"] + + def test_parse_single_sheet_kicad_sch(tmp_path: Path): from backend.pinscopex.parsers import parse_netlist_any