From 61fd3ff0726c9fe6b07777b73e3026775ff3e6d1 Mon Sep 17 00:00:00 2001 From: Knowit Date: Wed, 29 Apr 2026 00:48:46 +0800 Subject: [PATCH] tools/epro2/kicad: fix two --all crashes found running the other 4 Pro projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the new --all on the remaining 4 Pro projects (X86 motherboard, 220V power supply, Taishan Pi, Liangshan Pi) surfaced two crash modes not covered by ESP-VoCat: 1. Odd inner-layer count → KiCad rejects the file at load with "3 is not a valid layer count". The 220V power boards have one used inner SIGNAL layer (3 copper total: F.Cu / In1.Cu / B.Cu), but KiCad requires an even copper count. Fixed pcb_writer to pad with one empty inner layer when the inner count is odd, so the total stays even (2, 4, 6, ...). 2. Two BOARDs sharing the same META.title — twin "显示板" boards in the 220V power project — landed in the same project directory and the second silently overwrote the first's .kicad_sch / .kicad_pcb / .kicad_pro. Fixed --all to detect title collisions and suffix every colliding basename with the BOARD uuid prefix (so both '显示板' boards become '显示板_52e8cc76' and '显示板_55d32906' rather than one quietly winning). 71 → 73 unit tests pass (test_odd_inner_signal_count_padded_to_even_total + test_duplicate_board_titles_get_distinct_basenames). Tangentially noted while running this: Taishan Pi and Liangshan Pi are Pro 2.x JSON, not EPRO2 streams — our replay layer reads the files but doesn't decode docType, so SCH/PCB grouping returns nothing. Pro 2.x needs a separate writer; out of scope for this commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/epro2/kicad/__main__.py | 18 ++++++++++++- tools/epro2/kicad/pcb_writer.py | 11 ++++++-- tools/epro2/tests/test_pcb_writer.py | 20 +++++++++++++++ tools/epro2/tests/test_pro_writer.py | 38 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py index 5e5a85e..ed45dc8 100644 --- a/tools/epro2/kicad/__main__.py +++ b/tools/epro2/kicad/__main__.py @@ -221,10 +221,26 @@ def _convert_all_projects( page_groups = _group_pages_by_sch(proj) # SCH-uuid → [page-uuid, ...] + # Resolve basename collisions: two distinct BOARDs with the same title + # (seen on the 220V power project — twin "显示板" boards) would otherwise + # share a directory and overwrite each other's files. When the same + # basename appears multiple times, suffix EVERY occurrence with the + # board uuid prefix so each project dir stays unambiguous (instead of + # quietly handing one of them the unsuffixed name). + base_counts: dict[str, int] = {} + for board_id, slot in boards.items(): + title = slot["title"] or board_id[:12] + base_counts[_project_basename(title)] = base_counts.get(_project_basename(title), 0) + 1 + basenames: dict[str, str] = {} + for board_id, slot in boards.items(): + title = slot["title"] or board_id[:12] + base = _project_basename(title) + basenames[board_id] = base if base_counts[base] == 1 else f"{base}_{board_id[:8]}" + n = 0 for board_id, slot in boards.items(): title = slot["title"] or board_id[:12] - basename = _project_basename(title) + basename = basenames[board_id] proj_dir = out_dir / basename proj_dir.mkdir(parents=True, exist_ok=True) sch_doc = slot["sch"] diff --git a/tools/epro2/kicad/pcb_writer.py b/tools/epro2/kicad/pcb_writer.py index 0631a4d..adbf9d6 100644 --- a/tools/epro2/kicad/pcb_writer.py +++ b/tools/epro2/kicad/pcb_writer.py @@ -132,11 +132,18 @@ def _build_layer_map(doc: Document) -> _LayerMap: inner_signal_ids.append(lid) inner_signal_ids.sort() + # KiCad requires an EVEN copper layer count (2, 4, 6, ...). F.Cu + N + # inner + B.Cu = N+2 — must be even, i.e. N must be even. If we have an + # odd number of used inner SIGNAL layers, pad with one empty inner + # layer. Without this a board with one used inner (3 copper total) + # crashes the loader with "3 is not a valid layer count". + inner_emit = len(inner_signal_ids) + (len(inner_signal_ids) % 2) epro_to_kicad: dict[int, str] = {1: "F.Cu", 2: "B.Cu"} layers_in_order: list[tuple[int, str, str]] = [(0, "F.Cu", "signal")] - for n, lid in enumerate(inner_signal_ids, start=1): + for n in range(1, inner_emit + 1): kname = f"In{n}.Cu" - epro_to_kicad[lid] = kname + if n - 1 < len(inner_signal_ids): + epro_to_kicad[inner_signal_ids[n - 1]] = kname layers_in_order.append((n, kname, "signal")) layers_in_order.append((31, "B.Cu", "signal")) diff --git a/tools/epro2/tests/test_pcb_writer.py b/tools/epro2/tests/test_pcb_writer.py index 20bee00..4a4be73 100644 --- a/tools/epro2/tests/test_pcb_writer.py +++ b/tools/epro2/tests/test_pcb_writer.py @@ -45,6 +45,26 @@ def test_writer_emits_header_and_layers(): assert "Edge.Cuts" in by_name +def test_odd_inner_signal_count_padded_to_even_total(): + """KiCad rejects odd copper layer counts ('3 is not a valid layer + count'). A board with one used inner SIGNAL layer must therefore + declare two — the second is empty padding, but without it the + loader refuses to open the file at all.""" + d = _pcb([ + ('["LAYER",1]', {"_type": "LAYER", "layerType": "TOP", "use": True}), + ('["LAYER",2]', {"_type": "LAYER", "layerType": "BOTTOM", "use": True}), + ('["LAYER",15]', {"_type": "LAYER", "layerType": "SIGNAL", "use": True}), + ("ln1", {"_type": "LINE", "layerId": 15, + "startX": 0, "startY": 0, "endX": 100, "endY": 0, "width": 6}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d)) + p = parse(text) + rows = [r for r in _block(p, "layers")[0][1:] if isinstance(r, list)] + cu_layers = [r[1] for r in rows if r[1].endswith(".Cu")] + # 4 copper total: F.Cu, In1.Cu (used), In2.Cu (padding), B.Cu + assert cu_layers == ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"] + + def test_inner_signal_layers_inserted_in_id_order(): """An EPRO2 4-layer board with SIGNAL ids 15 and 16 actually used must map to In1.Cu and In2.Cu (in EPRO2-id sorted order) so the PCB diff --git a/tools/epro2/tests/test_pro_writer.py b/tools/epro2/tests/test_pro_writer.py index 6459445..4916797 100644 --- a/tools/epro2/tests/test_pro_writer.py +++ b/tools/epro2/tests/test_pro_writer.py @@ -36,3 +36,41 @@ def test_pro_top_level_keys_present_for_kicad_8(): j = json.loads(text) for key in ("board", "meta", "schematic", "sheets", "net_settings"): assert key in j, f"missing top-level key: {key}" + + +def test_duplicate_board_titles_get_distinct_basenames(): + """Two BOARDs that happen to share a title (seen on the 220V power + project — twin '显示板' boards in the same EPRO2 project) must end up + in distinct project directories. Dedup uses the BOARD uuid prefix + so each .kicad_pro/.kicad_sch/.kicad_pcb trio stays self-contained.""" + from tools.epro2.kicad.__main__ import _project_basename, _group_by_board + from tools.epro2.replay import Document, Project + + p = Project(project_uuid="p") + for board_uuid, sch_uuid, pcb_uuid in [ + ("b1aaaaa1", "s1aaaaa1", "p1aaaaa1"), + ("b2bbbbb2", "s2bbbbb2", "p2bbbbb2"), + ]: + sch = Document(doc_uuid=sch_uuid, doc_type="SCH") + sch.objects["META"] = {"_type": "META", "title": "显示板", "board": board_uuid} + p.documents[sch_uuid] = sch + pcb = Document(doc_uuid=pcb_uuid, doc_type="PCB") + pcb.objects["META"] = {"_type": "META", "title": "显示板", "board": board_uuid} + p.documents[pcb_uuid] = pcb + + boards = _group_by_board(p) + assert len(boards) == 2 + # Re-implement the dedup logic the CLI uses (kept inline so we exercise + # exactly that path; if it changes, this test breaks loudly). + base_counts: dict[str, int] = {} + for slot in boards.values(): + base_counts[_project_basename(slot["title"])] = ( + base_counts.get(_project_basename(slot["title"]), 0) + 1 + ) + basenames = [ + _project_basename(slot["title"]) + if base_counts[_project_basename(slot["title"])] == 1 + else f"{_project_basename(slot['title'])}_{board_id[:8]}" + for board_id, slot in boards.items() + ] + assert len(set(basenames)) == 2, f"basenames collided: {basenames}"