Add Polyline/PolylineShape: the swept copper of a trace

Ports the trace-relevant subset of geometry/planar/{Polyline,PolylineShape,
LineSegment}: a Polyline is a trace centreline (integer corners); a
PolylineShape is that centreline swept by the half-width, returned as exact
convex IntBox tiles (one flush box per orthogonal segment plus a square at
each interior corner to cover the turn). Exact for orthogonal segments; a
conservative bounding box for a diagonal one (never under-reports an
overlap). 45-degree/IntOctagon caps are deferred.
This commit is contained in:
Ryan Malloy 2026-07-13 02:16:02 -06:00
parent f01c44d807
commit 2d394872e3
3 changed files with 154 additions and 0 deletions

View File

@ -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",
]

View File

@ -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)

View File

@ -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)