From adc5dc5e1bf52c6c3e5404b9d9875d06b348edc8 Mon Sep 17 00:00:00 2001 From: Knowit Date: Wed, 29 Apr 2026 00:27:33 +0800 Subject: [PATCH] =?UTF-8?q?tools/epro2/kicad:=20PCB=20Phase-2=20=E2=80=94?= =?UTF-8?q?=20POUR=20=E2=86=92=20(zone),=20CoreBoard=20unconnected=20-43%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 left 75-358 unconnected_items per board (DRC), dominated by GND/AGND/POWER nets that EPRO2 routes through copper pour, not discrete traces. Phase-2 lands those: - pcb_writer._decode_zone_path handles the three POUR.path encodings seen in ESP-VoCat: rectangle (['R', x, y, w, h, ...]), circle (['CIRCLE', cx, cy, r]) approximated as a 36-segment polygon, and polyline (numeric pairs with 'L'/'ARC' verb tokens). - Each POUR on a copper layer turns into a (zone (polygon ...) ...) block plus a (filled_polygon ...) that mirrors the boundary. Why mirror, not auto-fill: kicad-cli pcb drc does NOT run the zone filler before checking — only the KiCad GUI does. Without a pre-computed (filled_polygon ...), DRC sees zones as empty regions and reports the entire net as unconnected. Mirroring the boundary as the fill is "connectivity-correct, clearance-imprecise" — KiCad users can still hit Edit > Fill Zones to refine thermals and pad clearances. We chose this over reading EPRO2's POURED.pourFill (the editor's own post-fill polygons) because POURED paths use ARC tokens we'd need to fully decode, and the user-drawn POUR boundary is already the authoritative "intended copper" region. ESP-VoCat DRC totals: 883 → 730 unconnected_items (-17% project-wide). CoreBoard, the 4-layer board with the most pour coverage, drops 358 → 205 (-43%). Other boards see no movement because their unconnected items are non-pour issues — pads outside the user-drawn POUR rectangle, or internal $1N nets via vias on the wrong net (separate problem, separate fix). 65 → 68 unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- log.md | 60 +++++++++++++ tools/epro2/kicad/__main__.py | 3 +- tools/epro2/kicad/pcb_writer.py | 122 +++++++++++++++++++++++++++ tools/epro2/tests/test_pcb_writer.py | 61 ++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) diff --git a/log.md b/log.md index dada8e8..743a570 100644 --- a/log.md +++ b/log.md @@ -4,6 +4,66 @@ --- +## 2026-04-29 02:00 PCB Phase-2:POUR → KiCad zone,CoreBoard unconnected -43% + +**Claude 会话** + +接 `e614044`。Phase-1 PCB 6 板都解析了但 DRC 报很多 unconnected——大头是 GND/AGND 走 POUR 覆铜没导出来。补 zone 导出。 + +### 做的 + +`pcb_writer.py` 加 `_decode_zone_path` 处理 EPRO2 POUR.path 三种形态: +- `[['R', x, y, w, h, ...]]` — 矩形(实测最常见,CoreBoard/MicBoard 全是这个) +- `[['CIRCLE', cx, cy, r]]` — 圆形(按 36 段近似成 polygon) +- `[[x1, y1, 'L', x2, y2, ...]]` — polyline(ARC token 跳过 4 个 token,按弦近似) + +每个 POUR 输出 `(zone (net N) (net_name "..." ) (layer "F.Cu") (polygon (pts ...)) (filled_polygon (pts ...)))`。 + +### 关键坑:必须 emit `(filled_polygon)` 块 + +第一次只发 `(zone)` 带 `(polygon)` 边界的版本,DRC 完全不变——`kicad-cli pcb drc` **不**自动跑 zone fill,只有 GUI 的"填充覆铜"会跑。所以 file 必须自己声明已填充。简单做法:用 boundary polygon 当 filled_polygon(= "整个 pour 区域都是铜",忽略 pad clearance/thermal)。 + +### ESP-VoCat 6 板 DRC 对比(unconnected_items count) + +| 板 | before zones | after zones | Δ | +|---|---:|---:|---:| +| BaseBoard | 227 | 227 | 0 | +| CoreBoard | 358 | **205** | **-43%** | +| MicBoard | 75 | 75 | 0 | +| LCD-BD | 43 | 43 | 0 | +| Mainboard | 179 | 179 | 0 | +| Sub-board | 1 | 1 | 0 | +| **TOTAL** | **883** | **730** | **-17%** | + +### 为什么只 CoreBoard 改善明显 + +抽样 MicBoard 残留 75 unconnected: +- AGND 94 个 item 里很多 pad 在 zone boundary 之外——POUR 矩形是 (72.3, 112.3)→(126.8, 126.0),但 AGND pad 在 y=107 上方 +- 大量 `$1N1865` 这种内部网——根因是 via 没绑对网(不是 POUR 能解决的) + +EPRO2 用户画 POUR 时通常只覆元件密集区,不覆全板;外围 trace 自己接。zone 解决"靠 pour 接到 GND"的 pad,但解不了"trace 路由不通"或"via 网漂移"。 + +CoreBoard zones=7(4-layer,GND+POWER+AGND 各一对),覆盖面广,效果明显。其它板 zones 多是 2 个,覆盖小。 + +### 决策(Why) + +- **filled_polygon = boundary 不做 clearance/thermal 计算**:自己实现 zone fill 算法工作量爆炸(KiCad 实现是 C++ 几千行)。boundary fill 是"连通性正确,clearance 不精确"——KiCad GUI 一键 refill 即可矫正。这条路保留 EPRO2 user-drawn boundary 作为 single source of truth。 +- **不读 POURED.pourFill**:POURED 是 EPRO2 自己 fill 算法的输出,path 含 ARC 难解析、坐标系跟 POUR 不一定对齐。boundary 直接用更可靠。 +- **ARC 在 polyline 里按弦近似**:跟 Phase-1 ARC 处理一致,KiCad 解析得了,几何稍偏(不会比 POUR 不导更糟)。 +- **不强行优化 MicBoard 那种 zone 之外的 pad**:那是 EPRO2 source 本身的连通方式(trace + via),不是 zone 能修的。 + +### 测试 + +65 → 68 单测全过:rectangle path → 4 corners + filled_polygon mirror / circle → 36-seg polygon / 非 copper layer skip。 + +### 下一步建议 + +- **ARC 圆心反推**(中等工作量):消 invalid_outline 警告 + zone polyline 里 ARC 段更准。需要三点定圆。 +- **schematic + PCB 同时跑**(小工作量):CLI 加 `--all` 同时输出两套,目录配对。 +- **`.kicad_pro` 项目文件**(小工作量):双击就能打开 KiCad GUI,schematic 和 PCB 自动配对。 + +--- + ## 2026-04-29 01:30 KiCad 导出 Phase 3 PCB:6/6 .kicad_pcb 全部 kicad-cli 通过 **Claude 会话** diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py index ad8d217..d3e79ca 100644 --- a/tools/epro2/kicad/__main__.py +++ b/tools/epro2/kicad/__main__.py @@ -179,7 +179,8 @@ def _convert_all_pcb(proj: Project, out_dir: Path, pr: ProjectRelations) -> int: print( f" {out_path.name}: nets={stats.nets} fps={stats.footprints} " f"fps_unresolved={stats.footprints_unresolved} " - f"segments={stats.segments} vias={stats.vias} edge={stats.edge_cuts}" + f"segments={stats.segments} vias={stats.vias} " + f"zones={stats.zones} edge={stats.edge_cuts}" ) n += 1 return n diff --git a/tools/epro2/kicad/pcb_writer.py b/tools/epro2/kicad/pcb_writer.py index 936f59e..0631a4d 100644 --- a/tools/epro2/kicad/pcb_writer.py +++ b/tools/epro2/kicad/pcb_writer.py @@ -80,6 +80,7 @@ class WriteStats: segments: int = 0 vias: int = 0 edge_cuts: int = 0 + zones: int = 0 skipped: int = 0 @@ -193,6 +194,79 @@ def _is_copper(layer_name: str | None) -> bool: return bool(layer_name) and (layer_name.endswith(".Cu")) +def _decode_zone_path( + raw, + *, + origin_mm: tuple[float, float], + circle_segments: int = 36, +) -> list[tuple[float, float]]: + """EPRO2 POUR.path → list of (x_mm, y_mm) outer-boundary points. + + Three encodings observed in the wild (counts on ESP-VoCat: R 12 / CIRCLE + 4 / polyline 3). All are wrapped in an extra outer list, i.e. + ``path = []``. + + - rectangle: ``['R', x, y, w, h, r1, r2]`` — corner-radii ignored + - circle : ``['CIRCLE', cx, cy, radius]`` — sampled to N segments + - polyline : ``[x1, y1, 'L', x2, y2, ...]`` + """ + ox, oy = origin_mm + if not isinstance(raw, list) or not raw: + return [] + shape = raw[0] + if not isinstance(shape, list) or not shape: + return [] + head = shape[0] + if isinstance(head, str) and head.upper() == "R": + if len(shape) < 5: + return [] + try: + x = float(shape[1]); y = float(shape[2]) + w = float(shape[3]); h = float(shape[4]) + except (TypeError, ValueError): + return [] + x0 = ox + x * MIL_TO_MM + y0 = oy + y * MIL_TO_MM + x1 = x0 + w * MIL_TO_MM + y1 = y0 + h * MIL_TO_MM + return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] + if isinstance(head, str) and head.upper() == "CIRCLE": + if len(shape) < 4: + return [] + try: + cx = float(shape[1]); cy = float(shape[2]); r = float(shape[3]) + except (TypeError, ValueError): + return [] + cx_mm = ox + cx * MIL_TO_MM + cy_mm = oy + cy * MIL_TO_MM + r_mm = r * MIL_TO_MM + return [ + (cx_mm + r_mm * math.cos(2 * math.pi * i / circle_segments), + cy_mm + r_mm * math.sin(2 * math.pi * i / circle_segments)) + for i in range(circle_segments) + ] + # Polyline: walk numeric pairs, skip 'L'/'ARC' tokens (ARC chord-approx). + pts: list[tuple[float, float]] = [] + i = 0 + while i < len(shape): + tok = shape[i] + if isinstance(tok, str): + if tok.upper() == "ARC": + # Skip [radius, endX, endY] params; chord approximation drops + # arc curvature but keeps the polygon closed enough for fill. + i += 4 if len(shape) >= i + 4 else 1 + continue + i += 1 + continue + try: + x = float(shape[i]); y = float(shape[i + 1]) + pts.append((ox + x * MIL_TO_MM, oy + y * MIL_TO_MM)) + i += 2 + except (TypeError, ValueError, IndexError): + i += 1 + return pts + + def _build_net_map(doc: Document) -> dict[str, int]: """Assign integer net ids stable for this PCB. Net id 0 is reserved for "no net" (KiCad convention).""" @@ -441,6 +515,53 @@ def write_pcb( ]) stats.edge_cuts += 1 + # Zones (POUR): copper pour outlines on signal layers. We emit just the + # boundary polygon and let KiCad's auto-filler do the actual copper + # generation — kicad-cli pcb drc fills before checking, so that's enough + # to resolve the GND/POWER pins that are routed through pour copper + # rather than discrete traces (the dominant source of "unconnected_items" + # before we exported zones). + zone_blocks: list = [] + for oid, obj in doc.objects.items(): + if obj.get("_type") != "POUR": + continue + kicad_layer = _kicad_layer(obj.get("layerId"), layer_map) + if not _is_copper(kicad_layer): + continue + outline = _decode_zone_path(obj.get("path"), origin_mm=board_origin_mm) + if len(outline) < 3: + stats.skipped += 1 + continue + net_name = str(obj.get("netName") or "") + net_id = net_map.get(net_name, 0) + pts_block: list = [Sym("pts")] + [[Sym("xy"), x, y] for x, y in outline] + # `(filled_polygon)` echoes the boundary so kicad-cli treats the + # zone as already filled. Without this, kicad-cli pcb drc skips + # zones entirely (it never runs the auto-filler) and reports the + # entire GND/POWER net as unconnected. The user's POUR-drawn + # shape is also the truthful "intended copper area" — KiCad + # users can refill in the GUI to refine clearances/thermals. + filled_pts: list = [Sym("pts")] + [[Sym("xy"), x, y] for x, y in outline] + zone_blocks.append([ + Sym("zone"), + [Sym("net"), net_id], + [Sym("net_name"), net_name], + [Sym("layer"), kicad_layer], + [Sym("uuid"), _new_uuid()], + [Sym("hatch"), Sym("edge"), 0.5], + [Sym("connect_pads"), [Sym("clearance"), 0.2]], + [Sym("min_thickness"), 0.2], + [Sym("filled_areas_thickness"), Sym("no")], + [Sym("fill"), Sym("yes"), + [Sym("thermal_gap"), 0.5], + [Sym("thermal_bridge_width"), 0.5]], + [Sym("polygon"), pts_block], + [Sym("filled_polygon"), + [Sym("layer"), kicad_layer], + filled_pts], + ]) + stats.zones += 1 + # Vias for oid, obj in doc.objects.items(): if obj.get("_type") != "VIA": @@ -485,6 +606,7 @@ def write_pcb( *graphic_blocks, *track_blocks, *via_blocks, + *zone_blocks, ] write_pcb.last_stats = stats # type: ignore[attr-defined] return to_sexpr(pcb, pretty=True) diff --git a/tools/epro2/tests/test_pcb_writer.py b/tools/epro2/tests/test_pcb_writer.py index 23d70c8..20bee00 100644 --- a/tools/epro2/tests/test_pcb_writer.py +++ b/tools/epro2/tests/test_pcb_writer.py @@ -153,6 +153,67 @@ def test_zero_length_segment_skipped(): assert getattr(write_pcb, "last_stats").skipped == 1 +def test_pour_rectangle_emits_zone_with_filled_polygon(): + """An EPRO2 POUR with rectangle path on a copper layer must turn into + a (zone) with both a (polygon ...) boundary and a (filled_polygon ...) + that mirrors it. Without the filled_polygon, kicad-cli pcb drc never + runs the zone filler and reports the entire net as unconnected.""" + d = _pcb([ + ('["NET","GND"]', {"_type": "NET"}), + ("p1", {"_type": "POUR", "layerId": 1, "netName": "GND", + "path": [["R", 0, 0, 1000, 1000, 0, 0]]}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + zones = _block(p, "zone") + assert len(zones) == 1 + z = zones[0] + layer = next(c for c in z if isinstance(c, list) and c[0] == "layer") + assert layer[1] == "F.Cu" + net_name = next(c for c in z if isinstance(c, list) and c[0] == "net_name") + assert net_name[1] == "GND" + poly = next(c for c in z if isinstance(c, list) and c[0] == "polygon") + pts = next(c for c in poly if isinstance(c, list) and c[0] == "pts") + xys = [c for c in pts if isinstance(c, list) and c[0] == "xy"] + assert len(xys) == 4 # rectangle has 4 corners + filled = next(c for c in z if isinstance(c, list) and c[0] == "filled_polygon") + fpts = next(c for c in filled if isinstance(c, list) and c[0] == "pts") + assert len([c for c in fpts if isinstance(c, list) and c[0] == "xy"]) == 4 + + +def test_pour_circle_path_sampled_to_polygon(): + """Circular POURs on copper layers must be approximated as a polygon — + KiCad zones don't accept (circle ...) primitives, so the fill region + needs explicit (xy) points around the circumference.""" + d = _pcb([ + ('["NET","GND"]', {"_type": "NET"}), + ("p1", {"_type": "POUR", "layerId": 1, "netName": "GND", + "path": [["CIRCLE", 0, 0, 100]]}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + z = _block(p, "zone")[0] + poly = next(c for c in z if isinstance(c, list) and c[0] == "polygon") + pts = next(c for c in poly if isinstance(c, list) and c[0] == "pts") + xys = [c for c in pts if isinstance(c, list) and c[0] == "xy"] + # 36 segments by default — enough to approximate a circle for fill + assert len(xys) >= 12 + + +def test_pour_on_non_copper_layer_skipped(): + """POURs only make sense as copper zones; an EPRO2 POUR mistakenly + landed on a silk layer must NOT emit (zone ...) since KiCad zones + are copper-only and the file would be semantically wrong.""" + d = _pcb([ + ('["NET","GND"]', {"_type": "NET"}), + ("p1", {"_type": "POUR", "layerId": 3, "netName": "GND", + "path": [["R", 0, 0, 100, 100]]}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + assert _block(p, "zone") == [] + + def test_non_pcb_doc_rejected(): d = Document(doc_uuid="x", doc_type="SCH_PAGE") try: