diff --git a/src/freeroute/geometry/__init__.py b/src/freeroute/geometry/__init__.py index b0d45f7..950d640 100644 --- a/src/freeroute/geometry/__init__.py +++ b/src/freeroute/geometry/__init__.py @@ -24,6 +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 .side import Side, Signum from .simplex import Simplex from .tile import TileShape @@ -51,4 +52,7 @@ __all__ = [ "Simplex", "Polygon", "PolygonShape", + "Polyline", + "PolylineShape", + "segment_box", ] diff --git a/src/freeroute/geometry/polyline.py b/src/freeroute/geometry/polyline.py new file mode 100644 index 0000000..303679c --- /dev/null +++ b/src/freeroute/geometry/polyline.py @@ -0,0 +1,106 @@ +"""``Polyline`` and ``PolylineShape`` — the swept-line shape a trace occupies. + +Ports the trace-relevant subset of ``geometry/planar/{Polyline,PolylineShape, +LineSegment}.java``. A :class:`Polyline` is the centreline of a trace (a sequence +of integer corners); a :class:`PolylineShape` is that centreline swept by the +trace half-width — the copper region — as a set of exact convex tiles. + +**Arithmetic model: exact, integer.** The router that uses this routes +*orthogonally* (axis-aligned segments), so each swept segment is an exact +:class:`~freeroute.geometry.box.IntBox` (centreline offset by the half-width, +squared at the ends). The 45-degree case (which needs ``IntOctagon`` caps to +stay exact) is deferred; ``segment_box`` returns the axis-aligned bounding box +for a diagonal segment, which is a *conservative* cover (it never under-reports +an overlap, so the no-clearance-violation check stays sound). +""" + +from __future__ import annotations + +from .box import IntBox +from .point import IntPoint + +__all__ = ["Polyline", "PolylineShape", "segment_box"] + + +def segment_box(a: IntPoint, b: IntPoint, half_width: int) -> IntBox: + """The copper :class:`IntBox` of the segment ``a``-``b`` at ``half_width``. + + The centreline is offset perpendicular by ``half_width`` with **flush** ends + (the box spans exactly the endpoints in the travel direction). Ends are flush + rather than squared so an end-cap never protrudes toward a neighbouring item + (which would report a false clearance violation and let end-caps eat into + pads). Interior turns are covered separately by corner boxes in + :meth:`PolylineShape.tiles`. Exact for orthogonal segments; a conservative + bounding box for a diagonal one. + """ + lo_x, hi_x = (a.x, b.x) if a.x <= b.x else (b.x, a.x) + lo_y, hi_y = (a.y, b.y) if a.y <= b.y else (b.y, a.y) + if a.y == b.y: # horizontal: flush in x, offset in y + return IntBox(lo_x, lo_y - half_width, hi_x, hi_y + half_width) + if a.x == b.x: # vertical: flush in y, offset in x + return IntBox(lo_x - half_width, lo_y, hi_x + half_width, hi_y) + # diagonal / general: conservative bounding box offset all round + return IntBox(lo_x - half_width, lo_y - half_width, hi_x + half_width, hi_y + half_width) + + +def corner_box(corner: IntPoint, half_width: int) -> IntBox: + """A ``half_width`` square centred on a corner, covering the mitred turn.""" + return IntBox( + corner.x - half_width, + corner.y - half_width, + corner.x + half_width, + corner.y + half_width, + ) + + +class Polyline: + """A trace centreline: a sequence of integer corners.""" + + __slots__ = ("corners",) + + def __init__(self, corners: list[IntPoint]) -> None: + self.corners: list[IntPoint] = list(corners) + + def __len__(self) -> int: + return len(self.corners) + + def is_empty(self) -> bool: + return len(self.corners) < 2 + + def corner_count(self) -> int: + return len(self.corners) + + def segments(self): + """Yield consecutive ``(a, b)`` corner pairs (the trace segments).""" + yield from zip(self.corners, self.corners[1:], strict=False) + + def bounding_box(self) -> IntBox: + if not self.corners: + return IntBox.empty() + xs = [c.x for c in self.corners] + ys = [c.y for c in self.corners] + return IntBox(min(xs), min(ys), max(xs), max(ys)) + + +class PolylineShape: + """The copper region of a :class:`Polyline` swept by a half-width.""" + + __slots__ = ("polyline", "half_width") + + def __init__(self, polyline: Polyline, half_width: int) -> None: + self.polyline = polyline + self.half_width = max(half_width, 0) + + def tiles(self) -> list[IntBox]: + """The swept copper as exact convex tiles. + + One flush :class:`IntBox` per segment, plus a square at each *interior* + corner to cover the turn (the endpoints are pad anchors and need no cap). + """ + corners = self.polyline.corners + tiles = [segment_box(a, b, self.half_width) for a, b in self.polyline.segments()] + tiles += [corner_box(c, self.half_width) for c in corners[1:-1]] + return tiles + + def bounding_box(self) -> IntBox: + return self.polyline.bounding_box().offset(self.half_width) diff --git a/tests/geometry/test_polyline.py b/tests/geometry/test_polyline.py new file mode 100644 index 0000000..6a27351 --- /dev/null +++ b/tests/geometry/test_polyline.py @@ -0,0 +1,44 @@ +"""Tests for Polyline / PolylineShape (the swept trace copper).""" + +from __future__ import annotations + +from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, segment_box + + +def test_polyline_segments(): + pl = Polyline([IntPoint(0, 0), IntPoint(10, 0), IntPoint(10, 10)]) + segs = list(pl.segments()) + assert len(segs) == 2 + assert pl.corner_count() == 3 + assert not pl.is_empty() + assert Polyline([IntPoint(0, 0)]).is_empty() + + +def test_segment_box_horizontal_is_flush_in_x(): + box = segment_box(IntPoint(0, 0), IntPoint(100, 0), 10) + # flush in the travel direction (x), offset by half-width in y + assert box == IntBox(0, -10, 100, 10) + + +def test_segment_box_vertical_is_flush_in_y(): + box = segment_box(IntPoint(0, 0), IntPoint(0, 100), 10) + assert box == IntBox(-10, 0, 10, 100) + + +def test_polyline_shape_tiles_include_corner_squares(): + pl = Polyline([IntPoint(0, 0), IntPoint(100, 0), IntPoint(100, 100)]) + tiles = PolylineShape(pl, 10).tiles() + # 2 segment boxes + 1 interior-corner square + assert len(tiles) == 3 + # the corner square is centred on the turn at (100, 0) + assert IntBox(90, -10, 110, 10) in tiles + + +def test_polyline_shape_bounding_box(): + pl = Polyline([IntPoint(0, 0), IntPoint(100, 0)]) + assert PolylineShape(pl, 5).bounding_box() == IntBox(-5, -5, 105, 5) + + +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)