tools/epro2/kicad: PCB Phase-2 — POUR → (zone), CoreBoard unconnected -43%

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:27:33 +08:00
parent eee1a9b97e
commit adc5dc5e1b
4 changed files with 245 additions and 1 deletions

View File

@@ -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: