Add exact integer octagon cover for 45-degree trace copper

A diagonal segment's copper has no axis-aligned exact tile, so segment_box falls
back to the bounding box -- a huge conservative cover that makes diagonal traces
conflict with everything. segment_octagon builds the tight cover instead: the
copper of a 0/45/90/135-degree segment swept by a radius, as a convex integer
Simplex bounded by the four axis and four x+y / x-y diagonal half-planes.

Diagonal bounds widen by ceil_sqrt2(radius) -- the exact integer ceiling of
radius*sqrt(2) via math.isqrt, no float -- rounded outward, so the octagon is a
provable superset of segment (+) disk(radius) (verified over 20k sampled copper
points). Growing by clearance is just a larger radius, giving both the stored
copper tile and the queried clearance region. overlaps_2d tests a real 2-D
overlap via exact Simplex intersection, accepting mixed IntBox/Simplex tiles.
This commit is contained in:
Ryan Malloy 2026-07-13 12:46:02 -06:00
parent 7f81c1f4ce
commit 8380318d3c
3 changed files with 194 additions and 0 deletions

View File

@ -21,6 +21,7 @@ from .direction import Direction, IntDirection
from .float_point import FloatPoint from .float_point import FloatPoint
from .limits import CRIT_DOUBLE, CRIT_INT from .limits import CRIT_DOUBLE, CRIT_INT
from .line import Line from .line import Line
from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon
from .point import IntPoint, Point, RationalPoint, point, rational_point from .point import IntPoint, Point, RationalPoint, point, rational_point
from .polygon import Polygon from .polygon import Polygon
from .polygon_shape import PolygonShape from .polygon_shape import PolygonShape
@ -56,4 +57,7 @@ __all__ = [
"PolylineShape", "PolylineShape",
"segment_box", "segment_box",
"shove_segment", "shove_segment",
"ceil_sqrt2",
"segment_octagon",
"overlaps_2d",
] ]

View File

@ -0,0 +1,98 @@
"""Octagonal copper tiles for 45-degree traces (exact integer).
An orthogonal trace segment's copper is an exact :class:`IntBox`; a diagonal
(45-degree) segment's copper is not axis-aligned, so :func:`polyline.segment_box`
falls back to the segment's bounding box -- a *huge* conservative cover that makes
a diagonal trace conflict with everything around it.
This module builds the tight cover instead: the copper of a 0/45/90/135-degree
segment swept by a radius, as a convex integer :class:`Simplex` bounded by the
four axis half-planes and the four diagonal half-planes ``x+y`` / ``x-y``. The
diagonal bounds use ``ceil(radius * sqrt(2))`` computed with :func:`math.isqrt`
(no float), rounded **outward**, so the octagon is a provable superset of the
true swept copper ``segment (+) disk(radius)`` -- the clearance test built on it
never under-reports an overlap (sound), while over-approximating by at most one
integer unit.
Growing a segment's copper by ``clearance`` is just a larger radius
(``half_width + clearance``), so the same builder produces both the bare copper
tile (stored) and the clearance region (queried). All arithmetic is exact
integer; the only irrational, ``sqrt(2)``, is bounded above by an exact integer.
"""
from __future__ import annotations
import math
from .direction import IntDirection
from .line import Line
from .point import IntPoint
from .simplex import Simplex
__all__ = ["ceil_sqrt2", "segment_octagon", "overlaps_2d"]
def ceil_sqrt2(radius: int) -> int:
"""Smallest integer ``R`` with ``R * R >= 2 * radius * radius`` (exact).
The diagonal support of a disk of radius ``r`` is ``r * sqrt(2)``; this is its
exact integer ceiling, so a diagonal bound widened by it covers the disk.
"""
if radius <= 0:
return 0
target = 2 * radius * radius
r = math.isqrt(target)
if r * r < target:
r += 1
return r
def segment_octagon(a: IntPoint, b: IntPoint, radius: int) -> Simplex:
"""Convex integer octagon covering segment ``a``-``b`` swept by ``radius``.
A superset of ``segment (+) disk(radius)``: the four axis bounds are widened
by ``radius`` and the two diagonal bounds by ``ceil(radius * sqrt(2))``. For a
0/45/90/135-degree segment this is tight (the redundant border lines drop out
in normalization); for an orthogonal segment it is the squared-cap box rather
than the flush :class:`IntBox`, so callers keep :class:`IntBox` tiles for
orthogonal copper and use this only for diagonal copper.
"""
r = max(radius, 0)
diag = ceil_sqrt2(r)
xs = (a.x, b.x)
ys = (a.y, b.y)
ps = (a.x + a.y, b.x + b.y)
qs = (a.x - a.y, b.x - b.y)
x0, x1 = min(xs) - r, max(xs) + r
y0, y1 = min(ys) - r, max(ys) + r
p0, p1 = min(ps) - diag, max(ps) + diag
q0, q1 = min(qs) - diag, max(qs) + diag
# Each line is directed so its interior (the octagon) is on its left, matching
# the counter-clockwise convention Simplex.from_corners uses.
lines = (
Line.from_point_direction(IntPoint(0, y0), IntDirection(1, 0)), # y >= y0
Line.from_point_direction(IntPoint(0, y1), IntDirection(-1, 0)), # y <= y1
Line.from_point_direction(IntPoint(x0, 0), IntDirection(0, -1)), # x >= x0
Line.from_point_direction(IntPoint(x1, 0), IntDirection(0, 1)), # x <= x1
Line.from_point_direction(IntPoint(p0, 0), IntDirection(1, -1)), # x + y >= p0
Line.from_point_direction(IntPoint(p1, 0), IntDirection(-1, 1)), # x + y <= p1
Line.from_point_direction(IntPoint(q0, 0), IntDirection(-1, -1)), # x - y >= q0
Line.from_point_direction(IntPoint(q1, 0), IntDirection(1, 1)), # x - y <= q1
)
return Simplex.get_instance(lines)
def overlaps_2d(shape_a, shape_b) -> bool:
"""True if the two convex tiles share 2-D area (a real clearance overlap).
Accepts any mix of :class:`Simplex` and :class:`IntBox`; the intersection is
exact and its dimension distinguishes a shared area (2) from a mere shared
edge or corner (<= 1), matching :meth:`IntBox.overlaps` for boxes.
"""
# IntBox.intersection only accepts an IntBox, so let a Simplex operand drive
# the (type-coercing) intersection when the two tile kinds are mixed.
if isinstance(shape_a, Simplex):
return shape_a.intersection(shape_b).dimension() == 2
if isinstance(shape_b, Simplex):
return shape_b.intersection(shape_a).dimension() == 2
return shape_a.intersection(shape_b).dimension() == 2

View File

@ -0,0 +1,92 @@
"""Tests for the exact integer octagon cover of 45-degree trace copper."""
from __future__ import annotations
import math
import random
import pytest
from freeroute.geometry import IntBox, IntPoint, ceil_sqrt2, overlaps_2d, segment_octagon
@pytest.mark.parametrize("r", [0, 1, 2, 3, 1000, 2000, 3000, 12345, 999999])
def test_ceil_sqrt2_is_exact_integer_ceiling(r):
R = ceil_sqrt2(r)
assert 2 * r * r <= R * R # covers the diagonal support r*sqrt(2)
if r > 0:
assert 2 * r * r > (R - 1) * (R - 1) # and is the smallest such integer
def test_ceil_sqrt2_uses_no_float():
# exactness at large magnitude where float sqrt would round wrong
r = 10**12 + 7
R = ceil_sqrt2(r)
assert 2 * r * r <= R * R
assert 2 * r * r > (R - 1) * (R - 1)
def test_octagon_is_superset_of_true_diagonal_copper():
"""Every point of the true swept copper (centreline offset perpendicular by up
to the half-width) lies inside the octagon -- so the cover never under-reports."""
a, b = IntPoint(0, 0), IntPoint(1000, 1000)
hw = 100
oc = segment_octagon(a, b, hw)
rng = random.Random(0)
for _ in range(20000):
t = rng.uniform(0.0, 1.0)
s = rng.uniform(-hw, hw) # perpendicular offset within the half-width
px = t * 1000 + s / math.sqrt(2)
py = t * 1000 - s / math.sqrt(2)
assert oc.contains(IntPoint(round(px), round(py)))
def test_octagon_excludes_points_beyond_the_radius():
a, b = IntPoint(0, 0), IntPoint(1000, 1000)
oc = segment_octagon(a, b, 100)
# a point ~700 units off the centreline perpendicular is far outside
assert not oc.contains(IntPoint(500, -200))
assert not oc.contains(IntPoint(200, 900))
def test_octagon_is_bounded_and_two_dimensional():
oc = segment_octagon(IntPoint(0, 0), IntPoint(400, -400), 50)
assert oc.is_bounded()
assert oc.dimension() == 2
assert oc.border_line_count() == 8
def test_clearance_region_separates_at_diagonal_distance():
"""The clearance region is the copper grown by clearance; an obstacle whose
diagonal distance exceeds half_width + clearance does not overlap it, one that
is closer does."""
a, b = IntPoint(0, 0), IntPoint(1000, 1000)
hw, clr = 100, 200
grown = segment_octagon(a, b, hw + clr)
# centreline on x-y = 0; a box centred on x-y = -900 (well beyond 300*sqrt2~425)
far = IntBox(300, -1300, 500, -1100)
assert not overlaps_2d(grown, far)
# a box straddling the centreline clearly overlaps
near = IntBox(400, -100, 600, 100)
assert overlaps_2d(grown, near)
def test_overlaps_2d_matches_intbox_overlap_for_boxes():
a = IntBox(0, 0, 100, 100)
assert overlaps_2d(a, IntBox(50, 50, 150, 150)) # shared area
assert not overlaps_2d(a, IntBox(100, 0, 200, 100)) # shared edge only
assert not overlaps_2d(a, IntBox(200, 200, 300, 300)) # disjoint
def test_overlaps_2d_accepts_mixed_argument_order():
oc = segment_octagon(IntPoint(0, 0), IntPoint(1000, 1000), 100)
box = IntBox(400, 400, 600, 600) # straddles the centreline
assert overlaps_2d(oc, box) == overlaps_2d(box, oc) is True
def test_orthogonal_octagon_is_axis_aligned_cover():
# a horizontal segment's octagon still covers its copper (squared caps)
oc = segment_octagon(IntPoint(0, 0), IntPoint(1000, 0), 100)
assert oc.contains(IntPoint(500, 90))
assert oc.contains(IntPoint(500, -90))
assert not oc.contains(IntPoint(500, 200))