diff --git a/src/freeroute/geometry/__init__.py b/src/freeroute/geometry/__init__.py index 950d640..4756bc7 100644 --- a/src/freeroute/geometry/__init__.py +++ b/src/freeroute/geometry/__init__.py @@ -24,7 +24,7 @@ from .line import Line from .point import IntPoint, Point, RationalPoint, point, rational_point from .polygon import Polygon from .polygon_shape import PolygonShape -from .polyline import Polyline, PolylineShape, segment_box +from .polyline import Polyline, PolylineShape, segment_box, shove_segment from .side import Side, Signum from .simplex import Simplex from .tile import TileShape @@ -55,4 +55,5 @@ __all__ = [ "Polyline", "PolylineShape", "segment_box", + "shove_segment", ] diff --git a/src/freeroute/geometry/polyline.py b/src/freeroute/geometry/polyline.py index 303679c..b5ccbb4 100644 --- a/src/freeroute/geometry/polyline.py +++ b/src/freeroute/geometry/polyline.py @@ -53,6 +53,51 @@ def corner_box(corner: IntPoint, half_width: int) -> IntBox: ) +def shove_segment(corners: list[IntPoint], k: int, dx: int, dy: int) -> list[IntPoint]: + """Displace segment ``k`` (``corners[k]``-``corners[k+1]``) by ``(dx, dy)``. + + ``(dx, dy)`` must be perpendicular to the segment (parallel to its adjacent + segments), so the trace stays orthogonal. The two endpoints ``corners[0]`` + and ``corners[-1]`` (pad anchors) are kept fixed: if the moved segment is the + first or last, a jog corner is inserted so the trace still starts/ends on its + pad; interior neighbours simply resize. Collinear/duplicate corners are + dropped. This is the geometric shove primitive. + """ + n = len(corners) + if not (0 <= k < n - 1): + return list(corners) + new_a = IntPoint(corners[k].x + dx, corners[k].y + dy) + new_b = IntPoint(corners[k + 1].x + dx, corners[k + 1].y + dy) + result: list[IntPoint] = [] + if k == 0: + result.append(corners[0]) # keep the start pad; jog to the moved run + result.append(new_a) + else: + result.extend(corners[:k]) # neighbour segment resizes to reach new_a + result.append(new_a) + if k + 1 == n - 1: + result.append(new_b) + result.append(corners[-1]) # jog back to the end pad + else: + result.append(new_b) + result.extend(corners[k + 2 :]) + return _dedupe_collinear(result) + + +def _dedupe_collinear(corners: list[IntPoint]) -> list[IntPoint]: + out: list[IntPoint] = [] + for p in corners: + if out and out[-1].x == p.x and out[-1].y == p.y: + continue + if len(out) >= 2: + a, b = out[-2], out[-1] + if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0: + out[-1] = p + continue + out.append(p) + return out + + class Polyline: """A trace centreline: a sequence of integer corners.""" diff --git a/tests/geometry/test_polyline.py b/tests/geometry/test_polyline.py index 6a27351..c6fc5b9 100644 --- a/tests/geometry/test_polyline.py +++ b/tests/geometry/test_polyline.py @@ -2,7 +2,14 @@ from __future__ import annotations -from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, segment_box +from freeroute.geometry import ( + IntBox, + IntPoint, + Polyline, + PolylineShape, + segment_box, + shove_segment, +) def test_polyline_segments(): @@ -42,3 +49,33 @@ def test_polyline_shape_bounding_box(): def test_diagonal_segment_box_is_conservative_bbox(): box = segment_box(IntPoint(0, 0), IntPoint(50, 50), 10) assert box == IntBox(-10, -10, 60, 60) + + +# --- shove primitive -------------------------------------------------------- + + +def test_shove_single_segment_inserts_jogs_and_keeps_pads(): + # a horizontal pad-to-pad trace shoved up by 30 keeps its endpoints + corners = [IntPoint(0, 0), IntPoint(100, 0)] + out = shove_segment(corners, 0, 0, 30) + assert [(p.x, p.y) for p in out] == [(0, 0), (0, 30), (100, 30), (100, 0)] + assert out[0] == corners[0] and out[-1] == corners[1] # pads fixed + + +def test_shove_interior_segment_resizes_neighbours(): + # the middle horizontal run of a U shoved up 20; verticals resize, pads fixed + corners = [IntPoint(0, 0), IntPoint(0, 50), IntPoint(100, 50), IntPoint(100, 0)] + out = shove_segment(corners, 1, 0, 20) + assert [(p.x, p.y) for p in out] == [(0, 0), (0, 70), (100, 70), (100, 0)] + + +def test_shove_stays_orthogonal(): + corners = [IntPoint(0, 0), IntPoint(0, 40), IntPoint(80, 40), IntPoint(80, 0)] + out = shove_segment(corners, 1, 0, 15) + for a, b in zip(out, out[1:], strict=False): + assert a.x == b.x or a.y == b.y # every segment axis-aligned + + +def test_shove_out_of_range_is_noop(): + corners = [IntPoint(0, 0), IntPoint(100, 0)] + assert shove_segment(corners, 5, 0, 30) == corners