装 kicad 8.0.9 (apt PPA) 后跑 kicad-cli sch erc 校验我们 emit 的
.kicad_sch 文件,发现 9/9 sheets 一开始全部报 "Failed to load schematic
file" — 父节点解析就挂掉。Bisect 找到两个语法 bug:
1. **(pin_numbers (hide no)) 不被 KiCad 8 接受**
KiCad 8 lib_symbols 里 `pin_numbers` 是 token-form,不接受 (hide
yes/no) 子块。要么省略整个 block 默认 visible,要么 `(pin_numbers
hide)` 表示隐藏。原来的 `(hide no)` 风格是 KiCad 7 旧语法。
Fix: tools/epro2/kicad/sym_writer.py 删掉 (pin_numbers (hide no))
行;KiCad 默认 visible 行为正是我们想要的。
2. **String 里的字面 \n / \r / \t 让 KiCad 解析器中止**
ESP-VoCat 的 Overview sheet 有 TEXT "Battary\n3.7V 700mAH"(多行
电池标签),EPRO2 里以**字面 0x0a 字符**存储。我们把它原样 emit
成 "..." 包住的字符串 → KiCad reader 在 quoted string 内遇到 \n
就报 parse error 不给 message。
Fix: tools/epro2/kicad/sexpr.py 在 str escape 路径加 \n / \r / \t
转义;reader 加 \r 解码(roundtrip 用)。
修完后:
9/9 sheets parse OK in KiCad 8.0.9
ERC 跑通,9 个 sheet 共 2793 violations,分布:
1372 endpoint_off_grid (49%, cosmetic — 30-mil EPRO2 grid 不
snap KiCad 默认 50-mil grid)
571 lib_symbol_issues (20%, cosmetic — facere 库未注册到
user library table;库已 embed 在
.kicad_sch 内联可用)
444 wire_dangling (16%, real — wire 端点没精确对齐 pin)
406 pin_not_connected (15%, 同上的另一面)
Cosmetic 占 70%,real connectivity 30%,下个 phase 处理:
- grid 校准(把 coord 精确 round 到统一 grid 上)
- pin tip 端点匹配(KiCad 需要 wire 端点 == pin (at) 字段对应的
绝对坐标,浮点必须精确相等)
- 生成 sym-lib-table 注册 facere 库(消 lib_symbol_issues)
测试:
+ test_string_escapes_newlines_and_tabs
+ test_lib_symbol_omits_pin_numbers_block
reader 加 \r 解码
41/41 通过(39 旧 + 2 新)。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
5.7 KiB
Python
138 lines
5.7 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_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"]
|