Fix KiCad .kicad_sch power/label net merging.
Disconnected GND power flags were named GND_/GND__/… so the reviewer flagged swapped rails and floating pins on correctly wired designs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -259,6 +259,45 @@ def _rotate(px: float, py: float, deg: float) -> tuple[float, float]:
|
|||||||
return px * c + py * s, -px * s + py * c
|
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]]:
|
def _lib_pins(sym: Any) -> dict[tuple[int, str], tuple[float, float]]:
|
||||||
"""(unit, pin_number) -> (x, y) in symbol space. unit 0 = common."""
|
"""(unit, pin_number) -> (x, y) in symbol space. unit 0 = common."""
|
||||||
out: dict[tuple[int, str], tuple[float, float]] = {}
|
out: dict[tuple[int, str], tuple[float, float]] = {}
|
||||||
@@ -345,6 +384,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
|
|||||||
dsu = _DSU()
|
dsu = _DSU()
|
||||||
labels: dict[tuple[int, int], tuple[str, str]] = {}
|
labels: dict[tuple[int, int], tuple[str, str]] = {}
|
||||||
power_pts: list[tuple[tuple[int, int], 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:
|
def prop(sym: Any, key: str) -> str:
|
||||||
for p in _kids(sym, "property"):
|
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]]:
|
if prev is None or _KIND_RANK[kind] >= _KIND_RANK[prev[1]]:
|
||||||
labels[pt] = (name, kind)
|
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"):
|
for sym in _kids(tree, "symbol"):
|
||||||
lib_id = _val(sym, "lib_id")
|
lib_id = _val(sym, "lib_id")
|
||||||
ix, iy, rot = _at(sym)
|
ix, iy, rot = _at(sym)
|
||||||
unit = int(_fnum(_val(sym, "unit") or "1") or 1)
|
unit = int(_fnum(_val(sym, "unit") or "1") or 1)
|
||||||
mirror = bool(_kid(sym, "mirror"))
|
mx, my = _mirror_axes(sym)
|
||||||
ref = prop(sym, "Reference")
|
ref = prop(sym, "Reference")
|
||||||
if ref.startswith("#"):
|
if ref.startswith("#"):
|
||||||
# power flag / graphic
|
# power flag / graphic
|
||||||
val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1]
|
val = prop(sym, "Value") or lib_id.rsplit(":", 1)[-1]
|
||||||
lp = lib_pins.get(lib_id, {})
|
lp = lib_pins.get(lib_id, {})
|
||||||
xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0)
|
xy = lp.get((unit, "1")) or lp.get((0, "1")) or (0.0, 0.0)
|
||||||
px, py = _rotate(xy[0], xy[1], rot)
|
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
|
||||||
if mirror:
|
|
||||||
px = -px
|
|
||||||
pt = _snap(ix + px, iy + py)
|
pt = _snap(ix + px, iy + py)
|
||||||
dsu.add(pt)
|
dsu.add(pt)
|
||||||
if val:
|
if val:
|
||||||
@@ -404,9 +450,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
|
|||||||
if not num:
|
if not num:
|
||||||
continue
|
continue
|
||||||
xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0)
|
xy = lp.get((unit, num)) or lp.get((0, num)) or (0.0, 0.0)
|
||||||
px, py = _rotate(xy[0], xy[1], rot)
|
px, py = apply_sym_xy(xy[0], xy[1], rot, mx, my)
|
||||||
if mirror:
|
|
||||||
px = -px
|
|
||||||
pt = _snap(ix + px, iy + py)
|
pt = _snap(ix + px, iy + py)
|
||||||
pin_at[(ref, num)] = pt
|
pin_at[(ref, num)] = pt
|
||||||
dsu.add(pt)
|
dsu.add(pt)
|
||||||
@@ -426,6 +470,7 @@ def _parse_kicad_sch_sheet(tree: Any) -> _SchSheet:
|
|||||||
coords.append(pt)
|
coords.append(pt)
|
||||||
for a, b in zip(coords, coords[1:]):
|
for a, b in zip(coords, coords[1:]):
|
||||||
dsu.union(a, b)
|
dsu.union(a, b)
|
||||||
|
wire_segs.append((a, b))
|
||||||
return
|
return
|
||||||
if tag == "label":
|
if tag == "label":
|
||||||
name = str(node[1]) if len(node) > 1 else ""
|
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:
|
for pt, _name in power_pts:
|
||||||
dsu.add(pt)
|
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_name: dict[tuple[int, int], str] = {}
|
||||||
root_kind: dict[tuple[int, int], str] = {}
|
root_kind: dict[tuple[int, int], str] = {}
|
||||||
for pt, (name, kind) in labels.items():
|
for pt, (name, kind) in labels.items():
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ _LIB_R = """
|
|||||||
(number "2" (effects (font (size 1.27 1.27))))
|
(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:
|
def _sch(*body: str) -> str:
|
||||||
return "(kicad_sch (version 20250114) (uuid \"11111111-1111-1111-1111-111111111111\")" + _LIB_R + "".join(body) + "\n)\n"
|
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):
|
def test_parse_single_sheet_kicad_sch(tmp_path: Path):
|
||||||
from backend.pinscopex.parsers import parse_netlist_any
|
from backend.pinscopex.parsers import parse_netlist_any
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user