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>
190 lines
8.1 KiB
Python
190 lines
8.1 KiB
Python
"""Symbol writer regression: synthetic SYMBOL doc → KiCad lib symbol entry."""
|
|
|
|
from tools.epro2.kicad._sexpr_reader import parse
|
|
from tools.epro2.kicad.sch_writer import write_sch_page
|
|
from tools.epro2.kicad.sexpr import to_sexpr
|
|
from tools.epro2.kicad.sym_writer import write_lib_symbol
|
|
from tools.epro2.project_relations import ProjectRelations
|
|
from tools.epro2.replay import Document, Project
|
|
|
|
|
|
def _sym_doc(uuid: str, part_id: str, primitives: list[tuple[str, dict]]) -> Document:
|
|
d = Document(doc_uuid=uuid, doc_type="SYMBOL")
|
|
d.objects[part_id] = {"_type": "PART", "BBOX": [-10, 10, 10, -10], "title": part_id}
|
|
for k, v in primitives:
|
|
d.objects[k] = v
|
|
return d
|
|
|
|
|
|
def _block(parsed, name):
|
|
return [c for c in parsed if isinstance(c, list) and c and c[0] == name]
|
|
|
|
|
|
def test_write_lib_symbol_emits_outer_wrapper_and_body():
|
|
d = _sym_doc("sym1", "MyPart.1", [])
|
|
entry = write_lib_symbol(d)
|
|
assert entry is not None
|
|
text = to_sexpr(entry)
|
|
p = parse(text)
|
|
assert p[0] == "symbol"
|
|
assert p[1] == "facere:MyPart.1"
|
|
# Properties: Reference / Value / Footprint / Datasheet
|
|
props = _block(p, "property")
|
|
by_name = {x[1]: x[2] for x in props}
|
|
assert by_name["Reference"] == "U"
|
|
assert by_name["Value"] == "MyPart.1"
|
|
assert by_name["Footprint"] == ""
|
|
# Inner body wrapper named <part>_1_1
|
|
inner = _block(p, "symbol")
|
|
assert len(inner) == 1
|
|
assert inner[0][1] == "MyPart.1_1_1"
|
|
|
|
|
|
def test_lib_symbol_omits_pin_numbers_block():
|
|
"""KiCad 8 rejects (pin_numbers (hide no)). When pins should be visible
|
|
we must omit the pin_numbers block entirely (default = visible)."""
|
|
d = _sym_doc("sym1", "MyPart.1", [])
|
|
entry = write_lib_symbol(d)
|
|
text = to_sexpr(entry)
|
|
parsed = parse(text)
|
|
assert not _block(parsed, "pin_numbers"), \
|
|
"pin_numbers block should be omitted (KiCad 8 syntax)"
|
|
|
|
|
|
def test_pin_renders_with_attr_pulled_metadata():
|
|
d = _sym_doc("sym1", "MyPart.1", [
|
|
("e5", {"_type": "PIN", "partId": "MyPart.1",
|
|
"x": -15, "y": 0, "length": 10, "rotation": 0}),
|
|
("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "VCC"}),
|
|
("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}),
|
|
("e8", {"_type": "ATTR", "parentId": "e5", "key": "Pin Type", "value": "POWER_IN"}),
|
|
])
|
|
entry = write_lib_symbol(d)
|
|
text = to_sexpr(entry)
|
|
parsed = parse(text)
|
|
inner_body = next(c for c in parsed if isinstance(c, list) and c and c[0] == "symbol")
|
|
pins = [c for c in inner_body if isinstance(c, list) and c and c[0] == "pin"]
|
|
assert len(pins) == 1
|
|
pin = pins[0]
|
|
assert pin[1] == "power_in" # electrical type mapped
|
|
assert pin[2] == "line" # shape
|
|
name_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "name")
|
|
num_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "number")
|
|
assert name_block[1] == "VCC"
|
|
assert num_block[1] == "1"
|
|
|
|
|
|
def test_rect_poly_circle_text_emit_correct_shapes():
|
|
d = _sym_doc("sym1", "MyPart.1", [
|
|
("r1", {"_type": "RECT", "partId": "MyPart.1",
|
|
"dotX1": -10, "dotY1": -5, "dotX2": 10, "dotY2": 5}),
|
|
("p1", {"_type": "POLY", "partId": "MyPart.1",
|
|
"points": [{"x": 0, "y": 0}, {"x": 5, "y": 0}, {"x": 5, "y": 5}],
|
|
"closed": True}),
|
|
("c1", {"_type": "CIRCLE", "partId": "MyPart.1",
|
|
"centerX": 0, "centerY": 0, "radius": 3}),
|
|
("t1", {"_type": "TEXT", "partId": "MyPart.1",
|
|
"x": 0, "y": -5, "value": "label"}),
|
|
])
|
|
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")
|
|
types = [c[0] for c in inner if isinstance(c, list)]
|
|
assert "rectangle" in types
|
|
assert "polyline" in types
|
|
assert "circle" in types
|
|
assert "text" in types
|
|
|
|
|
|
def test_non_symbol_doc_returns_none():
|
|
d = Document(doc_uuid="x", doc_type="SCH_PAGE")
|
|
assert write_lib_symbol(d) is None
|
|
|
|
|
|
def test_sch_writer_embeds_lib_symbols_via_project_relations():
|
|
sym = _sym_doc("sym1", "MyPart.1", [
|
|
("e5", {"_type": "PIN", "partId": "MyPart.1", "x": 0, "y": 0, "length": 10}),
|
|
("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "1"}),
|
|
("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}),
|
|
])
|
|
sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE")
|
|
sch.objects["e1"] = {"_type": "COMPONENT", "partId": "MyPart.1", "x": 0, "y": 0, "rotation": 0}
|
|
sch.objects["e2"] = {"_type": "COMPONENT", "partId": "GhostPart.99", "x": 50, "y": 50}
|
|
|
|
p = Project(project_uuid="p")
|
|
p.documents["sym1"] = sym
|
|
p.documents["sch1"] = sch
|
|
pr = ProjectRelations.build(p)
|
|
|
|
text = write_sch_page(sch, project_relations=pr)
|
|
parsed = parse(text)
|
|
lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols")
|
|
embedded = [c for c in lib[1:] if isinstance(c, list) and c[0] == "symbol"]
|
|
assert len(embedded) == 1 # MyPart found, GhostPart missed
|
|
assert embedded[0][1] == "facere:MyPart.1"
|
|
stats = getattr(write_sch_page, "last_stats")
|
|
assert stats.lib_symbols_embedded == 1
|
|
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}
|
|
text = write_sch_page(sch) # no pr passed
|
|
parsed = parse(text)
|
|
lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols")
|
|
# Phase-1 stub: just `(lib_symbols)` with no children
|
|
assert lib == ["lib_symbols"]
|