tools/epro2/kicad: fix two structural ERC bugs — wire_dangling -88%, pin_not_connected -52%

Bisect found two semantics mismatches between EPRO2 and KiCad that cause
the 850 real-connectivity ERC violations on the ESP-VoCat ref project:

1. sym_writer was emitting lib coords without negating Y, but KiCad lib
   uses Y-up and re-flips Y on placement (Y-down schematic). So vertically
   arranged pins ended up at Y-mirrored absolute positions and wires that
   reach the geometric pin tip in EPRO2 missed the rendered pin tip in
   KiCad. Fix: lib_y = -epro2_y, lib_rot = (360 - rot) % 360 for pin/text.

2. sch_writer was treating each LINE as an isolated wire — but EPRO2
   binds segments into nets by NAME (WIRE.NET attr), not just geometry.
   Multi-segment nets like GND/VBUS show up as N disconnected stubs to
   KiCad. Fix: per-LINE, look up lineGroup → WIRE → NET attr and emit a
   `(label "<NET>")` at the LINE's start. Same-named labels on distinct
   physical wires is how KiCad's ERC recognizes a multi-segment net.

ESP-VoCat 9 sheets:
  wire_dangling           444 →  52  (-88%)
  pin_not_connected       406 → 196  (-52%)
  real connectivity total 850 → 248  (-71%)

Why we did NOT round to grid (the obvious-looking fix): EPRO2 places
some pins on a 10-mil pitch (e.g. magnetic socket); rounding to KiCad's
default 50-mil ERC grid would collapse those pins. The 248 residual is
fundamentally cross-sheet — single-sheet ERC can't see a net's other
endpoints on sibling sheets — and is a Phase-3 (hierarchical sheet)
problem, not a per-sheet one.

41 → 46 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 23:43:11 +08:00
parent 5e63924474
commit 54f0173947
6 changed files with 197 additions and 15 deletions

View File

@@ -127,6 +127,58 @@ def test_sch_writer_embeds_lib_symbols_via_project_relations():
assert stats.lib_symbols_missing == 1
def test_pin_y_negated_for_kicad_lib_y_up_convention():
"""KiCad lib uses Y-up; the schematic uses Y-down and KiCad re-flips Y on
placement. To make the rendered placement land where EPRO2's wire ends
expect it, sym_writer must NEGATE Y for lib coords. Without this,
vertically arranged pins land at Y-mirrored positions and ERC reports
pin_not_connected even when wire and pin share an X coord."""
d = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1",
"x": -20, "y": 10, "length": 20, "rotation": 0}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
pin = next(c for c in inner if isinstance(c, list) and c[0] == "pin")
at = next(c for c in pin if isinstance(c, list) and c[0] == "at")
# EPRO2 y=10 → lib y=-10 mil = -0.254 mm
assert at[1] == -20 * 0.0254
assert at[2] == -10 * 0.0254
def test_pin_rotation_mirrored_to_compensate_y_flip():
"""Pin angle in lib must mirror across X-axis (rot' = 360-rot mod 360)
so that after KiCad's lib→sch Y-flip the pin extends in the same
direction it does in EPRO2."""
d = _sym_doc("sym1", "MyPart.1", [
("e5", {"_type": "PIN", "partId": "MyPart.1",
"x": 0, "y": 0, "length": 10, "rotation": 90}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
pin = next(c for c in inner if isinstance(c, list) and c[0] == "pin")
at = next(c for c in pin if isinstance(c, list) and c[0] == "at")
assert at[3] == 270.0 # 90 → 270
def test_rect_y_negated():
d = _sym_doc("sym1", "MyPart.1", [
("r1", {"_type": "RECT", "partId": "MyPart.1",
"dotX1": -10, "dotY1": -5, "dotX2": 10, "dotY2": 5}),
])
entry = write_lib_symbol(d)
parsed = parse(to_sexpr(entry))
inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol")
rect = next(c for c in inner if isinstance(c, list) and c[0] == "rectangle")
start = next(c for c in rect if isinstance(c, list) and c[0] == "start")
end = next(c for c in rect if isinstance(c, list) and c[0] == "end")
# Y inputs were -5 and 5 → after negation: 5 and -5
assert start[2] == 5 * 0.0254
assert end[2] == -5 * 0.0254
def test_sch_writer_without_project_relations_emits_empty_lib_symbols():
sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE")
sch.objects["e1"] = {"_type": "COMPONENT", "partId": "X.1", "x": 0, "y": 0}