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:
parent
7f81c1f4ce
commit
8380318d3c
@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
98
src/freeroute/geometry/octagon.py
Normal file
98
src/freeroute/geometry/octagon.py
Normal 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
|
||||||
92
tests/geometry/test_octagon.py
Normal file
92
tests/geometry/test_octagon.py
Normal 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))
|
||||||
Loading…
x
Reference in New Issue
Block a user