From 8380318d3cfe77c7a4ebe28617a20377404fb13e Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 13 Jul 2026 12:46:02 -0600 Subject: [PATCH] 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. --- src/freeroute/geometry/__init__.py | 4 ++ src/freeroute/geometry/octagon.py | 98 ++++++++++++++++++++++++++++++ tests/geometry/test_octagon.py | 92 ++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 src/freeroute/geometry/octagon.py create mode 100644 tests/geometry/test_octagon.py diff --git a/src/freeroute/geometry/__init__.py b/src/freeroute/geometry/__init__.py index 4756bc7..cb9f673 100644 --- a/src/freeroute/geometry/__init__.py +++ b/src/freeroute/geometry/__init__.py @@ -21,6 +21,7 @@ from .direction import Direction, IntDirection from .float_point import FloatPoint from .limits import CRIT_DOUBLE, CRIT_INT from .line import Line +from .octagon import ceil_sqrt2, overlaps_2d, segment_octagon from .point import IntPoint, Point, RationalPoint, point, rational_point from .polygon import Polygon from .polygon_shape import PolygonShape @@ -56,4 +57,7 @@ __all__ = [ "PolylineShape", "segment_box", "shove_segment", + "ceil_sqrt2", + "segment_octagon", + "overlaps_2d", ] diff --git a/src/freeroute/geometry/octagon.py b/src/freeroute/geometry/octagon.py new file mode 100644 index 0000000..02e97ca --- /dev/null +++ b/src/freeroute/geometry/octagon.py @@ -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 diff --git a/tests/geometry/test_octagon.py b/tests/geometry/test_octagon.py new file mode 100644 index 0000000..795b46f --- /dev/null +++ b/tests/geometry/test_octagon.py @@ -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))