From c91ca1994c6d25afcc988d2186399adc25c50267 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 17 Oct 2026 09:35:45 -0600 Subject: [PATCH 1/2] --smooth: turn grid staircases into octilinear runs after routing A last stage on the finished result, wire by wire: a run of legs between two points becomes the octilinear shape joining them when its copper is clear, the furthest point first; right-angle corners left are chamfered; straight runs are merged. Ends, vias, points where another wire of the net meets the wire, and legs across own-net pads stay. Every new leg and corner is checked on the exact shapes against other nets, pads, keepouts and the board edge, slanted legs both ways round as the verifier counts a pair. A net whose pads end up joined differently is put back, and the whole smoothing is dropped if the finished check finds more violations than before. --smooth-report adds track length per layer. --- src/rpmroute/cli.py | 25 +- src/rpmroute/route/smooth.py | 529 +++++++++++++++++++++++++++++++++++ tests/route/test_smooth.py | 148 ++++++++++ tests/test_cli.py | 1 + 4 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 src/rpmroute/route/smooth.py create mode 100644 tests/route/test_smooth.py diff --git a/src/rpmroute/cli.py b/src/rpmroute/cli.py index 7830fc3..e7da63f 100644 --- a/src/rpmroute/cli.py +++ b/src/rpmroute/cli.py @@ -25,6 +25,7 @@ from rpmroute.route.pipeline import ( build_rooms_routing_result, build_routing_result, ) +from rpmroute.route.smooth import smooth from rpmroute.route.verify import verify_result from rpmroute.ses import write_ses @@ -37,7 +38,7 @@ ENGINE_OPTIONS: dict[str, frozenset[str]] = { }), "exact": frozenset({ "shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", - "negotiate", "rollback", "tree", "window", "vias", "via_cost", + "negotiate", "rollback", "tree", "window", "vias", "via_cost", "smooth", }), "room": frozenset({"shove", "pack"}), } @@ -59,6 +60,7 @@ _FLAG_NAMES = { "window": "--window-reroute", "vias": "--reduce-vias", "via_cost": "--via-cost", + "smooth": "--smooth", } @@ -188,6 +190,19 @@ def build_parser() -> argparse.ArgumentParser: "board, where routes may share space at a price that rises where they compete; " "the contested cells then cost extra in the real routing (default 0: off)", ) + parser.add_argument( + "--smooth", + action="store_true", + help="exact engine: after routing, turn grid staircases into straight and 45-degree " + "runs and chamfer right-angle corners; same nets, ends, vias and layers, and every " + "new piece of copper checked on exact shapes against the board edge, other nets " + "and keepouts", + ) + parser.add_argument( + "--smooth-report", + action="store_true", + help="with --smooth (implies it): also print track length per layer before and after", + ) parser.add_argument( "--no-rollback", dest="rollback", @@ -326,6 +341,7 @@ def main(argv: list[str] | None = None) -> int: ("window", args.window_reroute is not None), ("vias", args.reduce_vias is not None), ("via_cost", args.via_cost is not None), + ("smooth", args.smooth or args.smooth_report), ) if asked ] @@ -418,6 +434,13 @@ def main(argv: list[str] | None = None) -> int: else: result = build_routing_result(dsn, seed=args.seed, **options) + if args.smooth or args.smooth_report: + report = smooth(dsn, result, edge_clearance_mm=args.edge_clearance) + print(f"rpmroute: {report.line()}", file=sys.stderr) + if args.smooth_report: + for row in report.table(): + print(row, file=sys.stderr) + 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]] diff --git a/src/rpmroute/route/smooth.py b/src/rpmroute/route/smooth.py new file mode 100644 index 0000000..4c0d3f0 --- /dev/null +++ b/src/rpmroute/route/smooth.py @@ -0,0 +1,529 @@ +"""Smoothing: a last stage that turns grid staircases into octilinear runs. + +The grid search moves one cell at a time along the axes, so a route that +heads off at an angle comes out as a staircase of short legs. Nothing goes +back over a path once it connects. This stage does, wire by wire, on the +finished result: + +1. a run of legs between two of its points is replaced by the octilinear + shape joining them (one straight leg, or a straight leg and a 45-degree + one), the furthest such point first; +2. the right-angle corners left are cut to 45-degree chamfers; +3. legs running on in the same direction are merged. + +Same nets, same layers, same vias. A wire keeps its ends, every via of its +net, every point where another wire of its net ends on it, and any leg +across a pad of its own net that it does not end on; only what lies between +those moves. Every new leg and corner is checked against the other nets' +copper, pads and keepouts on the exact shapes the exact engine uses, and +against the board edge; a wire is changed only when all of its new copper +is clear. Fixed wiring from the DSN is not part of the result and is never +touched; it is checked against like any other copper. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import itertools +import math + +from rpmroute.board import build_board +from rpmroute.board.search_tree import ShapeSearchTree, TreeShape +from rpmroute.dsn.model import DsnBoard +from rpmroute.geometry import IntBox, IntPoint, overlaps_2d, segment_box, segment_slab +from rpmroute.geometry.polyline import corner_box +from rpmroute.ses.model import RoutingResult + +from .exact_router import insert_static_copper +from .pipeline import _dsn_units_per_um, _mm_to_board, _rule_clearance_dsn, _via_half_board + +#: how many points ahead a staircase collapse looks from each point +LOOKAHEAD = 64 + + +@dataclass +class Shape: + """What a set of wires looks like: legs, right-angle corners, length.""" + + segments: int = 0 + right_angles: int = 0 + length_mm: dict[str, float] = field(default_factory=dict) + + @property + def total_mm(self) -> float: + return sum(self.length_mm.values()) + + +@dataclass +class SmoothReport: + before: Shape + after: Shape + wires_changed: int = 0 + wires: int = 0 + #: True when the finished check found more violations than before and + #: the smoothing was thrown away (never expected; a guard) + reverted: bool = False + + def line(self) -> str: + b, a = self.before, self.after + if self.reverted: + return "smoothing reverted: it would have added a clearance violation" + return ( + f"smoothed {self.wires_changed} of {self.wires} wires: segments {b.segments} -> " + f"{a.segments}, right-angle corners {b.right_angles} -> {a.right_angles}, " + f"length {b.total_mm:.1f} -> {a.total_mm:.1f} mm" + ) + + def table(self) -> list[str]: + rows = [f" {'layer':10} {'before mm':>10} {'after mm':>10}"] + for layer in sorted(set(self.before.length_mm) | set(self.after.length_mm)): + rows.append( + f" {layer:10} {self.before.length_mm.get(layer, 0.0):>10.1f} " + f"{self.after.length_mm.get(layer, 0.0):>10.1f}" + ) + return rows + + +def _shape_of(wires, mm_per_unit: float) -> Shape: + """``wires``: (layer name, points) pairs in board units.""" + out = Shape() + for layer, pts in wires: + out.segments += max(len(pts) - 1, 0) + out.length_mm[layer] = out.length_mm.get(layer, 0.0) + mm_per_unit * sum( + math.hypot(q.x - p.x, q.y - p.y) for p, q in zip(pts, pts[1:], strict=False) + ) + for a, b, c in zip(pts, pts[1:], pts[2:], strict=False): + if (b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) == 0: + out.right_angles += 1 + return out + + +def _sign(v: int) -> int: + return (v > 0) - (v < 0) + + +def _octilinear(a: IntPoint, b: IntPoint) -> bool: + dx, dy = b.x - a.x, b.y - a.y + return dx == 0 or dy == 0 or abs(dx) == abs(dy) + + +def _candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]: + """The octilinear ways from ``a`` to ``b``: straight when the two lie on + an axis or a diagonal, else the 45-degree leg first or last.""" + if _octilinear(a, b): + return [[a, b]] + dx, dy = b.x - a.x, b.y - a.y + m = min(abs(dx), abs(dy)) + sx, sy = _sign(dx), _sign(dy) + return [ + [a, IntPoint(a.x + sx * m, a.y + sy * m), b], + [a, IntPoint(b.x - sx * m, b.y - sy * m), b], + ] + + +def _dedupe(pts: list[IntPoint]) -> list[IntPoint]: + out: list[IntPoint] = [] + for p in pts: + if not out or (out[-1].x, out[-1].y) != (p.x, p.y): + out.append(p) + return out + + +def _merge_collinear(pts: list[IntPoint], keep: set[tuple[int, int]]) -> list[IntPoint]: + """Drop points where the wire runs straight on, except the ones in ``keep``.""" + pts = _dedupe(pts) + out = pts[:1] + for i in range(1, len(pts) - 1): + a, b, c = out[-1], pts[i], pts[i + 1] + straight = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x) == 0 and ( + (b.x - a.x) * (c.x - b.x) + (b.y - a.y) * (c.y - b.y) > 0 + ) + if straight and (b.x, b.y) not in keep: + continue + out.append(b) + if len(pts) > 1: + out.append(pts[-1]) + return out + + +def _on_segment(p: IntPoint, a: IntPoint, b: IntPoint, reach: int) -> IntPoint | None: + """The point of axis leg ``a``-``b`` within ``reach`` of ``p`` (across the + leg), or None. Slanted legs only count ``p`` that lies exactly on them.""" + if a.x == b.x: + lo, hi = sorted((a.y, b.y)) + if abs(p.x - a.x) <= reach and lo <= p.y <= hi: + return IntPoint(a.x, p.y) + return None + if a.y == b.y: + lo, hi = sorted((a.x, b.x)) + if abs(p.y - a.y) <= reach and lo <= p.x <= hi: + return IntPoint(p.x, a.y) + return None + cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) + within = min(a.x, b.x) <= p.x <= max(a.x, b.x) and min(a.y, b.y) <= p.y <= max(a.y, b.y) + return IntPoint(p.x, p.y) if cross == 0 and within else None + + +class _Smoother: + def __init__(self, tree, outline, edge: int, clearance: int) -> None: + self.tree = tree + self.outline = outline + self.edge = edge + self.clearance = clearance + + def _inside(self, box: IntBox) -> bool: + return self.outline is None or self.outline.box_clear(box, self.edge) + + def leg_clear(self, a: IntPoint, b: IntPoint, half: int, net: int, layer: int) -> bool: + if a.x == b.x or a.y == b.y: + box = segment_box(a, b, half) + return self._inside(box) and not self.tree.clearance_conflict( + box, net, layer, self.clearance + ) + copper = segment_slab(a, b, half) + if not self._inside(copper.bounding_box()): + return False + # Both ways round, as the finished check counts a pair: this leg's + # copper grown by the clearance against the other's copper, and the + # other's clearance region (a box grown square, so wider at its + # corners) against this leg's copper. + grown = segment_slab(a, b, half + self.clearance) + for shape in self.tree.overlapping(copper.bounding_box().offset(self.clearance)): + if shape.net_no == net or not shape.on_layer(layer): + continue + if shape.blocks not in ("all", "wires"): + continue + theirs = shape.exact if shape.exact is not None else shape.tile + if not shape.clearance: + if overlaps_2d(copper, theirs): + return False + elif overlaps_2d(grown, theirs) or overlaps_2d( + shape.clearance_region(self.clearance), copper + ): + return False + return True + + def corner_clear(self, c: IntPoint, half: int, net: int, layer: int) -> bool: + box = corner_box(c, half) + return self._inside(box) and not self.tree.clearance_conflict( + box, net, layer, self.clearance + ) + + def path_clear(self, path: list[IntPoint], half: int, net: int, layer: int) -> bool: + """New legs and new corners of ``path`` (its ends are kept points).""" + return all( + self.leg_clear(p, q, half, net, layer) for p, q in zip(path, path[1:], strict=False) + ) and all(self.corner_clear(c, half, net, layer) for c in path[1:-1]) + + def collapse(self, pts: list[IntPoint], half: int, net: int, layer: int) -> list[IntPoint]: + """Step 1 on one section (both ends kept): from each point, jump to + the furthest later point an octilinear path reaches cleanly.""" + out = [pts[0]] + i, n = 0, len(pts) + while i < n - 1: + jumped = False + for j in range(min(n - 1, i + LOOKAHEAD), i + 1, -1): + for path in _candidates(pts[i], pts[j]): + if self.path_clear(path, half, net, layer): + out.extend(path[1:]) + i, jumped = j, True + break + if jumped: + break + if not jumped: + out.append(pts[i + 1]) + i += 1 + return out + + def miter( + self, pts: list[IntPoint], half: int, net: int, layer: int, keep: set[tuple[int, int]] + ) -> list[IntPoint]: + """Step 2: cut each right-angle corner between two axis legs to a + 45-degree chamfer, as long a one as fits, at least a track width.""" + pts = list(pts) + i = 1 + while i < len(pts) - 1: + a, c, b = pts[i - 1], pts[i], pts[i + 1] + if (c.x, c.y) in keep or not ( + (a.x == c.x or a.y == c.y) and (c.x == b.x or c.y == b.y) + ): + i += 1 + continue + ux, uy = _sign(c.x - a.x), _sign(c.y - a.y) + vx, vy = _sign(b.x - c.x), _sign(b.y - c.y) + if ux * vx + uy * vy != 0: + i += 1 + continue + len_in = abs(c.x - a.x) + abs(c.y - a.y) + len_out = abs(b.x - c.x) + abs(b.y - c.y) + # leave half of a leg for the corner at its other end, unless that + # end stays where it is anyway + cap_in = len_in if (a.x, a.y) in keep or i == 1 else len_in // 2 + cap_out = len_out if (b.x, b.y) in keep or i == len(pts) - 2 else len_out // 2 + d = min(cap_in, cap_out) + done = False + while d >= 2 * half: + p = IntPoint(c.x - ux * d, c.y - uy * d) + q = IntPoint(c.x + vx * d, c.y + vy * d) + corners = [x for x in (p, q) if (x.x, x.y) not in ((a.x, a.y), (b.x, b.y))] + if self.leg_clear(p, q, half, net, layer) and all( + self.corner_clear(x, half, net, layer) for x in corners + ): + pts[i : i + 1] = [p, q] + pts = _dedupe(pts) + done = True + break + d = d * 3 // 4 if d * 3 // 4 < d else d - 1 + i += 2 if done else 1 + return pts + + +def smooth( + dsn: DsnBoard, result: RoutingResult, *, edge_clearance_mm: float | None = None +) -> SmoothReport: + """Smooth ``result``'s wires in place; return what changed.""" + board = build_board(dsn) + scale = max(dsn.resolution.value, 1) + clearance = round(_rule_clearance_dsn(dsn) * scale) + edge = _mm_to_board(dsn, scale, edge_clearance_mm) or 0 + mm_per_unit = 1 / (_dsn_units_per_um(dsn) * 1000 * scale) + signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal] + layer_index = {layer.name: i for i, layer in enumerate(dsn.layers)} + via_layers = frozenset(signal_layers) + net_numbers = {n.name: i + 1 for i, n in enumerate(dsn.nets)} + via_half = _via_half_board(dsn, scale) + + tree = ShapeSearchTree() + owner = itertools.count(1) + insert_static_copper(tree, board, signal_layers, owner) + + def to_points(wire) -> list[IntPoint]: + c = wire.coords + return [IntPoint(round(c[i] * scale), round(c[i + 1] * scale)) for i in range(0, len(c) - 1, 2)] + + # own-net pads per (net, layer): legs across one the wire does not end on stay + pads: dict[tuple[int, int], list[IntBox]] = {} + for pin in board.get_pins(): + if pin.shape is None or not pin.net_nos: + continue + box = pin.shape.bounding_box() + for layer in pin.layers: + pads.setdefault((pin.net_nos[0], layer), []).append(box) + + # every routed wire and via into the tree, each wire under owners of its own + wires: list[tuple[int, object, int, int, list[IntPoint], list[int]]] = [] + for net in result.nets: + net_no = net_numbers.get(net.name) + if net_no is None: + continue + for wire in net.wires: + layer = layer_index.get(wire.layer) + if layer is None or layer not in signal_layers or wire.fixed: + continue + half = max(round(wire.width * scale / 2), 1) + pts = to_points(wire) + wires.append((net_no, wire, layer, half, pts, _insert(tree, owner, pts, half, net_no, layer))) + for via in net.vias: + vh = via_half or max(round(max((w.width for w in net.wires), default=0) * scale / 2), 1) + x, y = round(via.x * scale), round(via.y * scale) + tree.insert(TreeShape( + next(owner), net_no, via_layers, IntBox(x - vh, y - vh, x + vh, y + vh), routed=True, + )) + + before_pairs = len(tree.violation_pairs(clearance)) + before = _shape_of( + [(dsn.layers[w[2]].name, w[4]) for w in wires], mm_per_unit + ) + + # the points of each net another of its wires or a via meets + joints: dict[int, list[IntPoint]] = {} + for net in result.nets: + net_no = net_numbers.get(net.name) + if net_no is None: + continue + for via in net.vias: + joints.setdefault(net_no, []).append(IntPoint(round(via.x * scale), round(via.y * scale))) + for net_no, _wire, _layer, _half, pts, _owners in wires: + joints.setdefault(net_no, []).extend((pts[0], pts[-1])) + + pins_of: dict[int, list] = {} + for pin in board.get_pins(): + if pin.shape is not None and pin.net_nos: + pins_of.setdefault(pin.net_nos[0], []).append(pin) + vias_of = { + net_numbers[net.name]: [IntPoint(round(v.x * scale), round(v.y * scale)) for v in net.vias] + for net in result.nets + if net.name in net_numbers + } + by_net: dict[int, list[int]] = {} + for k, w in enumerate(wires): + by_net.setdefault(w[0], []).append(k) + + smoother = _Smoother(tree, board.outline, edge, clearance) + changed = 0 + originals = [] + for net_no, ks in by_net.items(): + groups_before = _pad_groups( + [wires[k] for k in ks], pins_of.get(net_no, []), vias_of.get(net_no, []) + ) + undo = [] + for k in ks: + _, wire, layer, half, pts, owners = wires[k] + if len(pts) < 3: + continue + keep_pts, keep = _kept_points( + pts, joints.get(net_no, []), half, pads.get((net_no, layer), []) + ) + new = _merge_collinear(keep_pts, keep) + out: list[IntPoint] = [new[0]] + start = 0 + for i in range(1, len(new)): + if (new[i].x, new[i].y) in keep or i == len(new) - 1: + section = smoother.collapse(new[start : i + 1], half, net_no, layer) + out.extend(section[1:]) + start = i + out = smoother.miter(_merge_collinear(out, keep), half, net_no, layer, keep) + out = _merge_collinear(out, keep) + if [(p.x, p.y) for p in out] == [(p.x, p.y) for p in pts]: + continue + # the wire's own old copper is the same net and never in the way, + # so it can go once the new copper is in + for oid in owners: + tree.remove_owner(oid) + new_owners = _insert(tree, owner, out, half, net_no, layer) + undo.append((k, wires[k], list(wire.coords))) + wire.coords = [v / scale for p in out for v in (p.x, p.y)] + wires[k] = (net_no, wire, layer, half, out, new_owners) + if not undo: + continue + groups_after = _pad_groups( + [wires[k] for k in ks], pins_of.get(net_no, []), vias_of.get(net_no, []) + ) + if groups_after != groups_before: + # a pad lost (or gained) its way to the rest of the net: keep the + # net as it was routed + for k, old, coords in reversed(undo): + for oid in wires[k][5]: + tree.remove_owner(oid) + old[1].coords = coords + wires[k] = (*old[:5], _insert(tree, owner, old[4], old[3], net_no, old[2])) + continue + originals.extend((old[1], coords) for _k, old, coords in undo) + changed += len(undo) + + report = SmoothReport( + before=before, + after=_shape_of([(dsn.layers[w[2]].name, w[4]) for w in wires], mm_per_unit), + wires_changed=changed, + wires=len(wires), + ) + if changed and len(tree.violation_pairs(clearance)) > before_pairs: + for wire, coords in originals: + wire.coords = coords + report.after, report.wires_changed, report.reverted = before, 0, True + return report + + +def _insert(tree, owner, pts, half, net_no, layer) -> list[int]: + """A wire's copper into ``tree`` as the exact engine models it: legs (on + their exact slab when slanted) and a square at each inner corner.""" + on = frozenset({layer}) + owners = [] + for a, b in zip(pts, pts[1:], strict=False): + oid = next(owner) + if a.x == b.x or a.y == b.y: + tree.insert(TreeShape(oid, net_no, on, segment_box(a, b, half), routed=True)) + else: + slab = segment_slab(a, b, half) + tree.insert(TreeShape( + oid, net_no, on, slab.bounding_box(), routed=True, exact=slab, seg=("slab", a, b, half), + )) + owners.append(oid) + for c in pts[1:-1]: + oid = next(owner) + tree.insert(TreeShape(oid, net_no, on, corner_box(c, half), routed=True)) + owners.append(oid) + return owners + + +def _kept_points( + pts: list[IntPoint], joints: list[IntPoint], half: int, own_pads: list[IntBox] +) -> tuple[list[IntPoint], set[tuple[int, int]]]: + """The wire with a point added wherever another wire of its net or a via + meets it, and the set of points that must stay: those, its ends, and the + ends of any leg across an own-net pad it does not end on.""" + keep = {(pts[0].x, pts[0].y), (pts[-1].x, pts[-1].y)} + out = [pts[0]] + for a, b in zip(pts, pts[1:], strict=False): + hits = [] + for j in joints: + p = _on_segment(j, a, b, half) + if p is not None: + hits.append(p) + keep.add((p.x, p.y)) + hits.sort(key=lambda p: abs(p.x - a.x) + abs(p.y - a.y)) + out.extend(h for h in hits if (h.x, h.y) not in ((a.x, a.y), (b.x, b.y))) + out.append(b) + end_pads = [box for box in own_pads if _in(pts[0], box) or _in(pts[-1], box)] + for box in own_pads: + if any(box is e for e in end_pads): + continue + for a, b in zip(out, out[1:], strict=False): + if segment_box(a, b, 0).overlaps(box): + keep.add((a.x, a.y)) + keep.add((b.x, b.y)) + return _dedupe(out), keep + + +def _near(p: IntPoint, pts: list[IntPoint], reach: int) -> bool: + """Whether ``p`` lies on the copper of the wire through ``pts`` (``reach`` + its half width), squared at the corners as the copper is modelled.""" + for a, b in zip(pts, pts[1:], strict=False): + dx, dy = b.x - a.x, b.y - a.y + n2 = dx * dx + dy * dy + t = 0.0 if n2 == 0 else max(0.0, min(1.0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / n2)) + cx, cy = a.x + t * dx, a.y + t * dy + if math.hypot(p.x - cx, p.y - cy) <= reach + 1: + return True + return False + + +def _pad_groups(wires, pins, vias) -> set[frozenset[int]]: + """Which of a net's pads its copper joins, as groups of pad indices: a + wire's end on a pad, on another wire, or on a via; a via on a pad.""" + parent = list(range(len(pins) + len(wires) + len(vias))) + + def find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def join(x: int, y: int) -> None: + parent[find(x)] = find(y) + + boxes = [p.shape.bounding_box() for p in pins] + w0, v0 = len(pins), len(pins) + len(wires) + for i, (_n, _w, layer, half, pts, _o) in enumerate(wires): + for end in (pts[0], pts[-1]): + for j, pin in enumerate(pins): + if layer in pin.layers and _in(end, boxes[j]): + join(w0 + i, j) + for k, (_n2, _w2, layer2, half2, pts2, _o2) in enumerate(wires): + if k != i and layer2 == layer and _near(end, pts2, half2): + join(w0 + i, w0 + k) + for v, loc in enumerate(vias): + if (end.x, end.y) == (loc.x, loc.y) or _near(loc, pts, half): + join(w0 + i, v0 + v) + for v, loc in enumerate(vias): + for j in range(len(pins)): + if _in(loc, boxes[j]): + join(v0 + v, j) + groups: dict[int, set[int]] = {} + for j in range(len(pins)): + groups.setdefault(find(j), set()).add(j) + return {frozenset(g) for g in groups.values()} + + +def _in(p: IntPoint, box: IntBox) -> bool: + return box.ll.x <= p.x <= box.ur.x and box.ll.y <= p.y <= box.ur.y diff --git a/tests/route/test_smooth.py b/tests/route/test_smooth.py new file mode 100644 index 0000000..9b97f13 --- /dev/null +++ b/tests/route/test_smooth.py @@ -0,0 +1,148 @@ +"""Smoothing: staircases become octilinear runs, corners are chamfered, and +nothing it keeps (ends, vias, joints, pads it crosses) moves.""" + +from __future__ import annotations + +from rpmroute.dsn import parse_dsn +from rpmroute.route.smooth import smooth +from rpmroute.route.verify import verify_result +from rpmroute.ses.model import RoutedVia, RoutedWire, RoutingResult + +VIA = "Via[0-1]_600:300_um" + + +def _board(extra_pads: str = "", extra_places: str = "", nets: str = "") -> str: + """A 30 x 30 mm two-layer board, units um, 0.2 mm track and clearance. + A1 at (2, -2) mm and A2 at (22, -22) mm are net SIG.""" + return f"""(pcb "smooth.dsn" + (parser (string_quote ") (space_in_quoted_tokens on) (host_cad "t") (host_version "1")) + (resolution um 10) + (unit um) + (structure + (layer F.Cu (type signal) (property (index 0))) + (layer B.Cu (type signal) (property (index 1))) + (boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0)) + (via "{VIA}") + (rule (width 200) (clearance 200)) + ) + (library + (padstack P (shape (rect F.Cu -300 -300 300 300)) (attach off)) + (padstack "{VIA}" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)) + (image PAD (pin P 1 0 0)) + {extra_pads} + ) + (placement + (component PAD (place A1 2000 -2000 front 0) (place A2 22000 -22000 front 0) {extra_places}) + ) + (network + (net SIG (pins A1-1 A2-1)) + {nets} + (class default (rule (width 200) (clearance 200))) + ) + (wiring) +) +""" + + +def _staircase(a, b, step): + """Points from ``a`` to ``b`` (DSN um) in alternating x and y steps.""" + (x, y), (bx, by) = a, b + pts = [(x, y)] + sx = step if bx > x else -step + sy = step if by > y else -step + while (x, y) != (bx, by): + if x != bx: + x = x + sx if abs(bx - x) > step else bx + pts.append((x, y)) + if y != by: + y = y + sy if abs(by - y) > step else by + pts.append((x, y)) + return pts + + +def _wire(pts, layer="F.Cu", fixed=False): + return RoutedWire(layer=layer, width=200, coords=[v for p in pts for v in p], fixed=fixed) + + +def _points(wire): + c = wire.coords + return [(round(c[i]), round(c[i + 1])) for i in range(0, len(c), 2)] + + +def _result(*wires, vias=(), net="SIG"): + result = RoutingResult() + for w in wires: + result.add_wire(net, w) + for x, y in vias: + result.net(net, create=True).vias.append(RoutedVia(padstack=VIA, x=x, y=y)) + return result + + +def test_a_staircase_becomes_one_diagonal(): + dsn = parse_dsn(_board()) + stair = _staircase((2000, -2000), (22000, -22000), 1000) + result = _result(_wire(stair)) + report = smooth(dsn, result) + pts = _points(result.nets[0].wires[0]) + assert pts == [(2000, -2000), (22000, -22000)] + assert report.wires_changed == 1 and report.after.right_angles == 0 + assert report.after.total_mm < report.before.total_mm + assert verify_result(dsn, result).clean + + +def test_a_pad_in_the_way_is_gone_round(): + # another net's pad sits on the diagonal shortcut of an L-shaped route + dsn = parse_dsn(_board(extra_places="(place B1 12000 -12000 front 0) (place B2 28000 -28000 front 0)", + nets="(net OTHER (pins B1-1 B2-1))")) + result = _result(_wire([(2000, -2000), (22000, -2000), (22000, -22000)])) + report = smooth(dsn, result) + pts = _points(result.nets[0].wires[0]) + assert pts[0] == (2000, -2000) and pts[-1] == (22000, -22000) + assert report.wires_changed == 1 # the corner is chamfered, short of the pad + assert verify_result(dsn, result).clean + + +def test_a_joint_and_a_via_stay(): + dsn = parse_dsn(_board(extra_places="(place A3 22000 -2000 front 0)", + nets="").replace("(pins A1-1 A2-1)", "(pins A1-1 A2-1 A3-1)")) + stair = _staircase((2000, -2000), (22000, -22000), 1000) + joint = stair[11] # A3's leg ends here, on the staircase + leg = _wire([(22000, -2000), (joint[0], -2000), joint]) + via = stair[25] + result = _result(_wire(stair), leg, vias=[via]) + smooth(dsn, result) + pts = _points(result.nets[0].wires[0]) + assert joint in pts and via in pts + assert pts[0] == (2000, -2000) and pts[-1] == (22000, -22000) + assert verify_result(dsn, result).clean + + +def test_the_board_edge_is_kept_clear_of(): + # a staircase hugging the top edge: a chamfer may not push copper over it + dsn = parse_dsn(_board(extra_places="(place A3 22000 -500 front 0)").replace( + "(pins A1-1 A2-1)", "(pins A1-1 A3-1)")) + wire = _wire([(2000, -2000), (2000, -500), (22000, -500)]) + result = _result(wire) + smooth(dsn, result, edge_clearance_mm=0.3) + for _x, y in _points(result.nets[0].wires[0]): + assert -y >= 300 + 100 # half width + edge clearance from y = 0 + + +def test_fixed_wiring_is_left_alone(): + dsn = parse_dsn(_board()) + stair = _staircase((2000, -2000), (22000, -22000), 1000) + result = _result(_wire(stair, fixed=True)) + report = smooth(dsn, result) + assert _points(result.nets[0].wires[0]) == stair + assert report.wires == 0 + + +def test_a_detour_with_nothing_in_its_way_is_straightened(): + dsn = parse_dsn(_board(extra_places="(place A3 22000 -2000 front 0)").replace( + "(pins A1-1 A2-1)", "(pins A3-1 A2-1)")) + # a U out to x = 12 mm and back, with nothing between its ends + result = _result(_wire([(22000, -2000), (12000, -2000), (12000, -22000), (22000, -22000)])) + report = smooth(dsn, result) + assert _points(result.nets[0].wires[0]) == [(22000, -2000), (22000, -22000)] + assert (report.before.right_angles, report.after.right_angles) == (2, 0) + assert verify_result(dsn, result).clean diff --git a/tests/test_cli.py b/tests/test_cli.py index 1aecde5..cdaabd0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -190,6 +190,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "window": ["--window-reroute", "1"], "vias": ["--reduce-vias"], "via_cost": ["--via-cost", "40"], + "smooth": ["--smooth"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: From 34200f27ffb6af4a7be9a0458617dad5f0f8cc13 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 17 Oct 2026 11:15:47 -0600 Subject: [PATCH 2/2] --smooth: no 45-degree leg shorter than two track widths A short diagonal at a wire's end is a kink beside its pad, and the pour refilled round it left a copper sliver (KiCad copper_sliver) on the TerraHawk lights node. Small offsets now stay square jogs; chamfers get the same floor. --- src/rpmroute/route/smooth.py | 15 ++++++++++++++- tests/route/test_smooth.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/rpmroute/route/smooth.py b/src/rpmroute/route/smooth.py index 4c0d3f0..b377378 100644 --- a/src/rpmroute/route/smooth.py +++ b/src/rpmroute/route/smooth.py @@ -39,6 +39,9 @@ from .pipeline import _dsn_units_per_um, _mm_to_board, _rule_clearance_dsn, _via #: how many points ahead a staircase collapse looks from each point LOOKAHEAD = 64 +#: a 45-degree leg at least this many track widths long: a shorter one is a +#: kink beside its end's pad, where a refilled pour leaves a copper sliver +MIN_DIAGONAL_WIDTHS = 2 @dataclass @@ -121,6 +124,13 @@ def _candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]: ] +def _too_short(a: IntPoint, b: IntPoint, half: int) -> bool: + """A slanted leg shorter than MIN_DIAGONAL_WIDTHS track widths.""" + if a.x == b.x or a.y == b.y: + return False + return (b.x - a.x) ** 2 + (b.y - a.y) ** 2 < (MIN_DIAGONAL_WIDTHS * 2 * half) ** 2 + + def _dedupe(pts: list[IntPoint]) -> list[IntPoint]: out: list[IntPoint] = [] for p in pts: @@ -211,6 +221,8 @@ class _Smoother: def path_clear(self, path: list[IntPoint], half: int, net: int, layer: int) -> bool: """New legs and new corners of ``path`` (its ends are kept points).""" + if any(_too_short(p, q, half) for p, q in zip(path, path[1:], strict=False)): + return False return all( self.leg_clear(p, q, half, net, layer) for p, q in zip(path, path[1:], strict=False) ) and all(self.corner_clear(c, half, net, layer) for c in path[1:-1]) @@ -262,7 +274,8 @@ class _Smoother: cap_out = len_out if (b.x, b.y) in keep or i == len(pts) - 2 else len_out // 2 d = min(cap_in, cap_out) done = False - while d >= 2 * half: + # the chamfer's leg is d * sqrt(2) long + while d * d * 2 >= (MIN_DIAGONAL_WIDTHS * 2 * half) ** 2: p = IntPoint(c.x - ux * d, c.y - uy * d) q = IntPoint(c.x + vx * d, c.y + vy * d) corners = [x for x in (p, q) if (x.x, x.y) not in ((a.x, a.y), (b.x, b.y))] diff --git a/tests/route/test_smooth.py b/tests/route/test_smooth.py index 9b97f13..9b5f3ce 100644 --- a/tests/route/test_smooth.py +++ b/tests/route/test_smooth.py @@ -146,3 +146,17 @@ def test_a_detour_with_nothing_in_its_way_is_straightened(): assert _points(result.nets[0].wires[0]) == [(22000, -2000), (22000, -22000)] assert (report.before.right_angles, report.after.right_angles) == (2, 0) assert verify_result(dsn, result).clean + + +def test_a_jog_too_small_for_a_long_diagonal_stays_square(): + # a 0.1 mm jog halfway: its 45-degree leg would be shorter than two track + # widths, a kink beside the copper that leaves pour slivers + dsn = parse_dsn(_board(extra_places="(place A3 2100 -22000 front 0)").replace( + "(pins A1-1 A2-1)", "(pins A1-1 A3-1)")) + wire = [(2000, -2000), (2000, -12000), (2100, -12000), (2100, -22000)] + result = _result(_wire(wire)) + smooth(dsn, result) + pts = _points(result.nets[0].wires[0]) + for a, b in zip(pts, pts[1:], strict=False): + if a[0] != b[0] and a[1] != b[1]: + assert (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 >= (2 * 200) ** 2