Say which connections were not routed

freeroute exited 0 and said nothing when it dropped connections, which
cost real diagnosis time: on rc4 two simple two-pin nets came back with no
copper and no explanation. The grid now records each connection it could
not route, the exact check adds each it rejects, and the CLI lists them on
stderr (the SES is still written; the exit code stays 0, as the JAR's).

A connection whose ends both sit in one zone of its net is listed apart,
as left to the zone fill: freeroute does not route through a zone it
cannot trust to be solid, but KiCad's fill often joins those pads anyway.
This commit is contained in:
2026-09-27 12:34:20 -06:00
parent c49c79604d
commit cdb3c7b568
6 changed files with 95 additions and 5 deletions
+14
View File
@@ -198,6 +198,20 @@ def main(argv: list[str] | None = None) -> int:
)
ses_text = write_ses(dsn, result)
missing = [u for u in result.unrouted if not u[3]]
in_zone = [u for u in result.unrouted if u[3]]
if missing:
# the SES is still written; say plainly what it is missing
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
for net, a, b, _zone in missing:
print(f" {net}: {a} to {b}", file=sys.stderr)
if in_zone:
print(
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
file=sys.stderr,
)
for net, a, b, zone in in_zone:
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
+13 -1
View File
@@ -159,7 +159,11 @@ def route_board_exact(
# Verify connection by connection, so one conflicting segment of a
# 25-pin supply net costs that one connection, not the whole net.
for net_no, wires, vias in sorted(result.connections, key=lambda c: c[0]):
rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections)
for (net_no, wires, vias), ends in sorted(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0]
):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half))
if any(
@@ -169,6 +173,7 @@ def route_board_exact(
for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no)
if acc is None:
@@ -180,6 +185,7 @@ def route_board_exact(
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
acc.owners.append(oid)
accepted_before_recovery = set(accepted)
# a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer)
# but was not accepted — whether the grid failed to route it or its exact
# copper conflicted.
@@ -214,6 +220,12 @@ def route_board_exact(
)
clean = RouteResult(half_width)
# what is still missing: what the grid could not route, plus what the
# exact check rejected, less any net shove or the diagonal pass recovered
clean.failed = [
*result.failed,
*(r for r in rejected if not (r[0] in accepted and r[0] not in accepted_before_recovery)),
]
for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias
+46 -4
View File
@@ -59,7 +59,7 @@ class RouteResult:
``routed_net_numbers`` are the nets that got any wire or via.
"""
__slots__ = ("wires", "vias", "half_width", "unrouted", "connections")
__slots__ = ("wires", "vias", "half_width", "unrouted", "connections", "connection_ends", "failed")
def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
@@ -69,6 +69,11 @@ class RouteResult:
self.unrouted = 0
#: the same copper per routed connection: (net_no, wires, vias)
self.connections: list[tuple[int, list[tuple[int, list[IntPoint]]], list[IntPoint]]] = []
#: what each routed connection joins, and the zone both ends sit in
#: (or ""), parallel to ``connections``
self.connection_ends: list[tuple[str, str, str]] = []
#: connections left unrouted: (net_no, one end, the other end)
self.failed: list[tuple[int, str, str]] = []
def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path))
@@ -523,6 +528,16 @@ class GridRouter:
covered = sum((*cell, zone.layer) in self._keepout for cell in cells)
return covered >= coverage * len(cells)
def _left_to_zone(self, conn) -> str:
"""The zone both ends of an unrouted connection sit in, if any: its
fill may well join them, even when it is not trusted for routing."""
for zone in self._every_zone_by_net.get(conn.net_no, []):
if any(_pin_touches_zone(p, zone) for p in conn.a_group[0]) and any(
_pin_touches_zone(p, zone) for p in conn.b_group[0]
):
return zone.name or "zone"
return ""
def _group_ends(self, group, net_no: int, conns) -> dict[Node, Pin | None]:
"""End nodes of a group, each mapped to its pin (None for a zone cell)."""
ends: dict[Node, Pin | None] = {}
@@ -668,6 +683,7 @@ class GridRouter:
def route(self) -> RouteResult:
"""Route all ratsnest connections, using rip-up-and-retry across passes."""
conns = self._build_connections()
self._conns = conns
if not conns:
return RouteResult(self.half_width)
@@ -709,6 +725,10 @@ class GridRouter:
def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {}
self._every_zone_by_net: dict[int, list] = {}
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos:
self._every_zone_by_net.setdefault(zone.net_nos[0], []).append(zone)
for zone in self.board.get_conduction_areas():
if zone.layer in self.layers and zone.net_nos and self._zone_is_solid(zone):
zones_by_net.setdefault(zone.net_nos[0], []).append(zone)
@@ -732,9 +752,10 @@ class GridRouter:
by_net.setdefault(net_no, []).append(pin)
return by_net
def _snapshot(self, conns: list[_Connection]) -> list[tuple[int, list, list]]:
def _snapshot(self, conns: list[_Connection]) -> list[tuple]:
return [
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias))
(c.net_no, [(layer, list(pts)) for layer, pts in c.wires], list(c.vias),
(*_ends_of(c), self._left_to_zone(c)))
for c in conns
if c.routed
]
@@ -742,12 +763,19 @@ class GridRouter:
def _result_from(self, snapshot, unrouted: int) -> RouteResult:
result = RouteResult(self.half_width)
result.unrouted = unrouted
for net_no, wires, vias in snapshot:
for net_no, wires, vias, ends in snapshot:
for layer, points in wires:
result.add_wire(net_no, layer, points)
for location in vias:
result.add_via(net_no, location)
result.connections.append((net_no, wires, vias))
result.connection_ends.append(ends)
routed = {ends[:2] + (net_no,) for net_no, _w, _v, ends in snapshot}
result.failed = [
(c.net_no, *_ends_of(c), self._left_to_zone(c))
for c in self._conns
if _ends_of(c) + (c.net_no,) not in routed
]
return result
@@ -755,6 +783,20 @@ def _first(items):
return items[0] if items else None
def _group_label(group) -> str:
pins, zones = group
parts = [f"{p.component_ref}-{p.name}" for p in pins[:3]]
if len(pins) > 3:
parts.append(f"+{len(pins) - 3} pins")
parts += [f"zone {z.name}" for z in zones[:1]]
return "/".join(parts) or "?"
def _ends_of(conn) -> tuple[str, str]:
"""A readable description of what a connection joins."""
return _group_label(conn.a_group), _group_label(conn.b_group)
def _pin_touches_zone(pin: Pin, zone) -> bool:
"""A pad on the zone's layer whose centre or a corner lies in the zone."""
if zone.layer not in pin.layers or zone.polygon is None:
+3
View File
@@ -126,6 +126,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
name = numbers_to_names.get(net_no, str(net_no))
for loc in locations:
result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale))
result.unrouted = [
(numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", [])
]
return result
+3
View File
@@ -57,6 +57,9 @@ class RoutingResult:
"""The complete routing output for a board, grouped by net."""
nets: list[RoutedNet] = field(default_factory=list)
#: connections the router could not make: (net name, one end, other end,
#: the zone both ends sit in or "") -- a zone's fill may join the latter
unrouted: list[tuple[str, str, str, str]] = field(default_factory=list)
def net(self, name: str, *, create: bool = False) -> RoutedNet | None:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+16
View File
@@ -148,3 +148,19 @@ def test_a_pour_other_nets_can_cross_is_not_trusted_to_connect():
result, _, _ = route_dsn_board_exact(dsn)
net = next(iter(result.routed_net_numbers))
assert result.result.wires.get(net) # P1 and P2 got a track between them
def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys):
# a keepout wall across the board on both layers: A and B cannot be joined
from freeroute.cli import main
wall = " ".join(
f"(keepout \"wall\" (polygon {layer} 0 9000 1000 11000 1000 11000 -21000 9000 -21000))"
for layer in ("F.Cu", "B.Cu")
)
dsn = tmp_path / "wall.dsn"
dsn.write_text(_dsn("(component P (place A 4000 -10000 front 0) (place B 16000 -10000 front 0))",
"(net N (pins A-1 B-1))", wall))
assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0
err = capsys.readouterr().err
assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err