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:
@@ -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
|
||||
|
||||
@@ -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 = [<one_shape>]``.
|
||||
|
||||
- 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)
|
||||
|
||||
Reference in New Issue
Block a user