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:
parent
f01c44d807
commit
2d394872e3
@ -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",
|
||||
]
|
||||
|
||||
106
src/freeroute/geometry/polyline.py
Normal file
106
src/freeroute/geometry/polyline.py
Normal 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)
|
||||
44
tests/geometry/test_polyline.py
Normal file
44
tests/geometry/test_polyline.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user