Add TileShape and Simplex convex-shape core
Ports geometry/planar/TileShape.java (the border-line-based containment, area, and centre-of-gravity logic) and Simplex.java (a convex region as the intersection of directed half-planes). Corners are exact intersections of consecutive border lines; point containment uses exact side_of. The remove_redundant_lines normalization — dropping lines that do not contribute and detecting emptiness — is ported line-for-line. Supporting additions: Line.compare_to/__lt__ (angular sort order), Line.fast_equals, Line.side_of_intersection, Line.translate (perpendicular offset), IntDirection.determinant, and IntBox.to_simplex. offset is approximate (rounded translated lines, as upstream); enlarge clips to the enlarged bounding box pending the IntOctagon port. Since there is no JVM oracle, tests assert invariants: corners lie exactly on their border lines (exact side_of == 0), IntBox -> Simplex preserves the region over a sampled grid, intersection is contained in both operands and a point is in the result iff in both, and get_instance normalization drops redundant lines and detects empty half-plane pairs.
This commit is contained in:
parent
d4aa4c729d
commit
1599d181b5
@ -9,8 +9,9 @@ needs, with the exact-arithmetic types preserved. Arithmetic model per type:
|
||||
``int`` (affine ``x/z, y/z``; ``z == 0`` is a point at infinity).
|
||||
* :class:`FloatPoint` — approximate ``float`` (distances, rounding, speed paths).
|
||||
|
||||
The convex-shape hierarchy beyond :class:`IntBox` (``TileShape`` / ``Simplex`` /
|
||||
``IntOctagon`` and polygon ``split_to_convex``) is a later phase.
|
||||
The convex-shape layer adds :class:`TileShape` (abstract) and :class:`Simplex`
|
||||
(half-plane convex region). ``IntOctagon``, ``Polyline`` and polygon
|
||||
``split_to_convex`` follow in later chunks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,6 +23,8 @@ from .limits import CRIT_DOUBLE, CRIT_INT
|
||||
from .line import Line
|
||||
from .point import IntPoint, Point, RationalPoint, point, rational_point
|
||||
from .side import Side, Signum
|
||||
from .simplex import Simplex
|
||||
from .tile import TileShape
|
||||
from .vector import IntVector, RationalVector, Vector
|
||||
|
||||
__all__ = [
|
||||
@ -42,4 +45,6 @@ __all__ = [
|
||||
"IntDirection",
|
||||
"Line",
|
||||
"IntBox",
|
||||
"TileShape",
|
||||
"Simplex",
|
||||
]
|
||||
|
||||
@ -230,6 +230,26 @@ class IntBox:
|
||||
|
||||
return [FloatPoint(c.x, c.y) for c in self.corners()]
|
||||
|
||||
def to_simplex(self):
|
||||
"""Return this box as a :class:`~freeroute.geometry.simplex.Simplex`.
|
||||
|
||||
Four directed border lines whose common right side is the box interior
|
||||
(ports ``IntBox.to_Simplex``).
|
||||
"""
|
||||
from .direction import IntDirection
|
||||
from .line import Line
|
||||
from .simplex import Simplex
|
||||
|
||||
if self.is_empty():
|
||||
return Simplex(())
|
||||
lines = (
|
||||
Line.from_point_direction(self.ll, IntDirection(1, 0)), # bottom, +x
|
||||
Line.from_point_direction(self.ur, IntDirection(0, 1)), # right, +y
|
||||
Line.from_point_direction(self.ur, IntDirection(-1, 0)), # top, -x
|
||||
Line.from_point_direction(self.ll, IntDirection(0, -1)), # left, -y
|
||||
)
|
||||
return Simplex(lines)
|
||||
|
||||
@staticmethod
|
||||
def empty() -> IntBox:
|
||||
return IntBox(CRIT_INT, CRIT_INT, -CRIT_INT, -CRIT_INT)
|
||||
|
||||
@ -109,6 +109,10 @@ class IntDirection(Direction):
|
||||
nx, ny = table[n]
|
||||
return IntDirection(nx, ny)
|
||||
|
||||
def determinant(self, other: IntDirection) -> int:
|
||||
"""Exact determinant of the two direction vectors."""
|
||||
return self.x * other.y - self.y * other.x
|
||||
|
||||
def compare_to(self, other: Direction) -> int:
|
||||
"""Angular comparison with the positive x-axis (exact).
|
||||
|
||||
|
||||
@ -113,6 +113,16 @@ class Line:
|
||||
|
||||
is_equal_or_opposite = overlaps
|
||||
|
||||
def fast_equals(self, other: Line) -> bool:
|
||||
"""Same line and same direction; fast path for integer lines."""
|
||||
dx1 = other.a.x - self.a.x
|
||||
dy1 = other.a.y - self.a.y
|
||||
dx2 = self.b.x - self.a.x
|
||||
dy2 = self.b.y - self.a.y
|
||||
if dx1 * dy2 - dx2 * dy1 != 0:
|
||||
return False
|
||||
return self.direction().equals(other.direction())
|
||||
|
||||
# --- translation --------------------------------------------------------
|
||||
|
||||
def translate_by(self, vector: Vector) -> Line:
|
||||
@ -120,6 +130,74 @@ class Line:
|
||||
return self
|
||||
return Line(self.a.translate_by(vector), self.b.translate_by(vector))
|
||||
|
||||
def translate(self, dist: float) -> Line:
|
||||
"""Translate the line perpendicular by ``dist`` (left if positive).
|
||||
|
||||
Ports ``Line.translate`` — approximate (the offset endpoint is rounded to
|
||||
integer coordinates), used by ``Simplex.offset``.
|
||||
"""
|
||||
v = self.direction().get_vector()
|
||||
vxvx = v.x * v.x
|
||||
vyvy = v.y * v.y
|
||||
length = math.sqrt(vxvx + vyvy)
|
||||
if vxvx <= vyvy:
|
||||
rel_x = _round_half_up(dist * length / v.y)
|
||||
new_a = IntPoint(self.a.x - rel_x, self.a.y)
|
||||
else:
|
||||
rel_y = _round_half_up(dist * length / v.x)
|
||||
new_a = IntPoint(self.a.x, self.a.y + rel_y)
|
||||
return Line.from_point_direction(new_a, self.direction())
|
||||
|
||||
# --- ordering (by direction angle) -------------------------------------
|
||||
|
||||
def compare_to(self, other: Line) -> int:
|
||||
"""Order lines by the angle of their direction with the +x axis (exact).
|
||||
|
||||
Ports ``Line.compareTo`` — the ordering ``Simplex`` relies on to sort its
|
||||
half-plane border lines counter-clockwise.
|
||||
"""
|
||||
dx1 = self.b.x - self.a.x
|
||||
dy1 = self.b.y - self.a.y
|
||||
dx2 = other.b.x - other.a.x
|
||||
dy2 = other.b.y - other.a.y
|
||||
if dy1 > 0:
|
||||
if dy2 < 0:
|
||||
return -1
|
||||
if dy2 == 0:
|
||||
return 1 if dx2 > 0 else -1
|
||||
elif dy1 < 0:
|
||||
if dy2 >= 0:
|
||||
return 1
|
||||
else: # dy1 == 0
|
||||
if dx1 > 0:
|
||||
if dy2 != 0 or dx2 < 0:
|
||||
return -1
|
||||
return 0
|
||||
if dy2 > 0 or (dy2 == 0 and dx2 > 0):
|
||||
return 1
|
||||
if dy2 < 0:
|
||||
return -1
|
||||
return 0
|
||||
determinant = dx2 * dy1 - dy2 * dx1
|
||||
if determinant > 0:
|
||||
return 1
|
||||
if determinant < 0:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
def __lt__(self, other: Line) -> bool:
|
||||
return self.compare_to(other) < 0
|
||||
|
||||
def side_of_intersection(self, line_1: Line, line_2: Line) -> Side:
|
||||
"""Side of this line relative to the intersection of ``line_1`` and
|
||||
``line_2`` (exact fallback after a fast float test). Ports
|
||||
``Line.side_of_intersection``."""
|
||||
approx = line_1.intersection_approx(line_2)
|
||||
result = self.side_of_float(approx, 1.0)
|
||||
if result == Side.COLLINEAR:
|
||||
result = self.side_of(line_1.intersection(line_2))
|
||||
return result
|
||||
|
||||
# --- intersection (exact) ----------------------------------------------
|
||||
|
||||
def intersection(self, other: Line) -> Point:
|
||||
@ -166,6 +244,10 @@ class Line:
|
||||
)
|
||||
|
||||
|
||||
def _round_half_up(value: float) -> int:
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def _fast_intersection(
|
||||
a: IntPoint, oa: IntPoint, delta_1: IntVector, delta_2: IntVector
|
||||
) -> Point | None:
|
||||
|
||||
338
src/freeroute/geometry/simplex.py
Normal file
338
src/freeroute/geometry/simplex.py
Normal file
@ -0,0 +1,338 @@
|
||||
"""``Simplex`` — a convex region as the intersection of directed half-planes.
|
||||
|
||||
Ports ``geometry/planar/Simplex.java``. A simplex is defined by an array of
|
||||
directed :class:`~freeroute.geometry.line.Line`s sorted by direction angle; the
|
||||
region is the intersection of the *right* half-plane of each line. Corners are
|
||||
the exact intersections of consecutive border lines
|
||||
(:meth:`~freeroute.geometry.line.Line.intersection`).
|
||||
|
||||
**Arithmetic model: exact where it matters.** Corners and all point-containment
|
||||
tests are exact (exact line intersection + exact ``side_of``). ``offset`` is
|
||||
approximate (it rounds translated lines, as upstream ``Line.translate`` does).
|
||||
``bounding_box`` is computed from the approximate corners then floored/ceiled to
|
||||
integers (matching the source).
|
||||
|
||||
The normalization :meth:`_remove_redundant_lines` is the delicate heart — it
|
||||
drops border lines that do not contribute to the shape and detects emptiness; it
|
||||
is ported line-for-line from ``Simplex.remove_redundant_lines``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .float_point import FloatPoint
|
||||
from .line import Line
|
||||
from .point import Point
|
||||
from .side import Side
|
||||
from .tile import TileShape
|
||||
|
||||
__all__ = ["Simplex"]
|
||||
|
||||
|
||||
class Simplex(TileShape):
|
||||
"""Convex tile defined by directed border lines (right half-plane each)."""
|
||||
|
||||
__slots__ = ("_arr", "_corners", "_float_corners", "_bbox")
|
||||
|
||||
def __init__(self, lines) -> None:
|
||||
self._arr: tuple[Line, ...] = tuple(lines)
|
||||
self._corners: list[Point | None] | None = None
|
||||
self._float_corners: list[FloatPoint | None] | None = None
|
||||
self._bbox = None
|
||||
|
||||
# --- construction -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def get_instance(lines) -> Simplex:
|
||||
"""Build a normalized simplex from directed lines (sorted, deduped)."""
|
||||
arr = list(lines)
|
||||
if not arr:
|
||||
return Simplex(())
|
||||
arr.sort()
|
||||
return Simplex(arr)._remove_redundant_lines()
|
||||
|
||||
@staticmethod
|
||||
def empty() -> Simplex:
|
||||
return Simplex(())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Simplex(<{len(self._arr)} lines>)"
|
||||
|
||||
# --- accessors ----------------------------------------------------------
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return len(self._arr) == 0
|
||||
|
||||
def border_line_count(self) -> int:
|
||||
return len(self._arr)
|
||||
|
||||
def border_line(self, no: int) -> Line:
|
||||
return self._arr[_clamp(no, len(self._arr))]
|
||||
|
||||
def corner(self, no: int) -> Point:
|
||||
"""Exact intersection of border line ``no-1`` and ``no``."""
|
||||
n = len(self._arr)
|
||||
no = _clamp(no, n)
|
||||
if self._corners is None:
|
||||
self._corners = [None] * n
|
||||
if self._corners[no] is None:
|
||||
prev = self._arr[n - 1] if no == 0 else self._arr[no - 1]
|
||||
self._corners[no] = self._arr[no].intersection(prev)
|
||||
return self._corners[no]
|
||||
|
||||
def corner_approx(self, no: int) -> FloatPoint:
|
||||
n = len(self._arr)
|
||||
if n == 0:
|
||||
return None
|
||||
no = _clamp(no, n)
|
||||
if self._float_corners is None:
|
||||
self._float_corners = [None] * n
|
||||
if self._float_corners[no] is None:
|
||||
prev = self._arr[n - 1] if no == 0 else self._arr[no - 1]
|
||||
self._float_corners[no] = self._arr[no].intersection_approx(prev)
|
||||
return self._float_corners[no]
|
||||
|
||||
def corner_is_bounded(self, no: int) -> bool:
|
||||
n = len(self._arr)
|
||||
if n == 1:
|
||||
return False
|
||||
no = _clamp(no, n)
|
||||
prev_no = n - 1 if no == 0 else no - 1
|
||||
prev_dir = self._arr[prev_no].direction().get_vector()
|
||||
curr_dir = self._arr[no].direction().get_vector()
|
||||
return prev_dir.determinant(curr_dir) > 0
|
||||
|
||||
def is_bounded(self) -> bool:
|
||||
n = len(self._arr)
|
||||
if n == 0:
|
||||
return True
|
||||
if n < 3:
|
||||
return False
|
||||
return all(self.corner_is_bounded(i) for i in range(n))
|
||||
|
||||
def dimension(self) -> int:
|
||||
arr = self._arr
|
||||
n = len(arr)
|
||||
if n == 0:
|
||||
return -1
|
||||
if n > 4:
|
||||
return 2
|
||||
if n == 1:
|
||||
return 2 # half-plane
|
||||
if n == 2:
|
||||
return 1 if arr[0].overlaps(arr[1]) else 2
|
||||
if n == 3:
|
||||
if arr[0].overlaps(arr[1]) or arr[0].overlaps(arr[2]) or arr[1].overlaps(arr[2]):
|
||||
return 1
|
||||
intersection = arr[1].intersection(arr[2])
|
||||
side = arr[0].side_of(intersection)
|
||||
if side == Side.ON_THE_RIGHT:
|
||||
return 2
|
||||
if side == Side.ON_THE_LEFT:
|
||||
return -1 # empty, not normalized
|
||||
return 0 # all three lines meet in a point
|
||||
# n == 4: check opposing collinear pairs
|
||||
collinear_0_2 = arr[0].overlaps(arr[2])
|
||||
collinear_1_3 = arr[1].overlaps(arr[3])
|
||||
if collinear_0_2 and collinear_1_3:
|
||||
return 0
|
||||
if collinear_0_2 or collinear_1_3:
|
||||
return 1
|
||||
return 2
|
||||
|
||||
# --- transforms ---------------------------------------------------------
|
||||
|
||||
def translate_by(self, vector) -> Simplex:
|
||||
if vector.is_zero():
|
||||
return self
|
||||
return Simplex([line.translate_by(vector) for line in self._arr])
|
||||
|
||||
def offset(self, width: float) -> Simplex:
|
||||
"""Offset every border line outward (``width > 0``) or inward.
|
||||
|
||||
Approximate (rounds translated lines), ported from ``Simplex.offset``.
|
||||
"""
|
||||
if width == 0:
|
||||
return self
|
||||
new_arr = [line.translate(-width) for line in self._arr]
|
||||
result = Simplex(new_arr)
|
||||
if width < 0:
|
||||
result = result._remove_redundant_lines()
|
||||
return result
|
||||
|
||||
def enlarge(self, offset: float) -> Simplex:
|
||||
"""Enlarge, clipped to the enlarged bounding region to stay bounded.
|
||||
|
||||
Upstream clips against the enlarged bounding *octagon*; pending the
|
||||
IntOctagon port this uses the enlarged bounding *box* (a coarser but
|
||||
valid bound), which is documented as a slight over-approximation.
|
||||
"""
|
||||
if offset == 0:
|
||||
return self
|
||||
offset_simplex = self.offset(offset)
|
||||
bbox = self.bounding_box()
|
||||
if bbox.is_empty():
|
||||
return Simplex(())
|
||||
clip = bbox.offset(offset).to_simplex()
|
||||
return offset_simplex.intersection(clip)
|
||||
|
||||
# --- intersection -------------------------------------------------------
|
||||
|
||||
def intersection(self, other) -> Simplex:
|
||||
"""Intersection with another :class:`Simplex` or an ``IntBox``."""
|
||||
from .box import IntBox
|
||||
|
||||
if isinstance(other, IntBox):
|
||||
other = other.to_simplex()
|
||||
if self.is_empty() or other.is_empty():
|
||||
return Simplex(())
|
||||
merged = list(self._arr) + list(other._arr)
|
||||
merged.sort()
|
||||
return Simplex(merged)._remove_redundant_lines()
|
||||
|
||||
def intersects(self, other) -> bool:
|
||||
return not self.intersection(other).is_empty()
|
||||
|
||||
# --- bounding -----------------------------------------------------------
|
||||
|
||||
def bounding_box(self):
|
||||
from .box import IntBox
|
||||
|
||||
if len(self._arr) == 0:
|
||||
return IntBox.empty()
|
||||
if self._bbox is None:
|
||||
llx = lly = math.inf
|
||||
urx = ury = -math.inf
|
||||
for i in range(len(self._arr)):
|
||||
c = self.corner_approx(i)
|
||||
llx = min(llx, c.x)
|
||||
lly = min(lly, c.y)
|
||||
urx = max(urx, c.x)
|
||||
ury = max(ury, c.y)
|
||||
self._bbox = IntBox(math.floor(llx), math.floor(lly), math.ceil(urx), math.ceil(ury))
|
||||
return self._bbox
|
||||
|
||||
# --- conversions --------------------------------------------------------
|
||||
|
||||
def is_int_box(self) -> bool:
|
||||
for i, line in enumerate(self._arr):
|
||||
if not line.is_orthogonal():
|
||||
return False
|
||||
if not self.corner_is_bounded(i):
|
||||
return False
|
||||
return len(self._arr) > 0
|
||||
|
||||
def to_int_box(self):
|
||||
return self.bounding_box()
|
||||
|
||||
def simplify(self):
|
||||
if self.is_empty():
|
||||
return Simplex(())
|
||||
if self.is_int_box():
|
||||
return self.bounding_box()
|
||||
return self
|
||||
|
||||
def to_simplex(self) -> Simplex:
|
||||
return self
|
||||
|
||||
# --- normalization (ported from remove_redundant_lines) ----------------
|
||||
|
||||
def _remove_redundant_lines(self) -> Simplex: # noqa: C901 - faithful port
|
||||
arr = self._arr
|
||||
if not arr:
|
||||
return self
|
||||
line_arr: list[Line] = [arr[0]]
|
||||
prev = line_arr[0]
|
||||
for i in range(1, len(arr)):
|
||||
if not arr[i].fast_equals(prev):
|
||||
line_arr.append(arr[i])
|
||||
prev = line_arr[-1]
|
||||
new_length = len(line_arr)
|
||||
# pad so index assignments below never run off the end
|
||||
line_arr = line_arr + [None] * (len(arr) - new_length)
|
||||
intersection_sides: list[Side | None] = [None] * len(arr)
|
||||
|
||||
try_again = new_length > 2
|
||||
index_of_last_removed_line = new_length
|
||||
while try_again:
|
||||
try_again = False
|
||||
prev_ind = new_length - 1
|
||||
prev_line = line_arr[prev_ind]
|
||||
curr_line = line_arr[0]
|
||||
ind = 0
|
||||
while ind < new_length:
|
||||
next_ind = 0 if ind == new_length - 1 else ind + 1
|
||||
next_line = line_arr[next_ind]
|
||||
|
||||
remove_line = False
|
||||
prev_dir = prev_line.direction()
|
||||
next_dir = next_line.direction()
|
||||
det = prev_dir.determinant(next_dir)
|
||||
if det != 0:
|
||||
if intersection_sides[ind] is None:
|
||||
intersection_sides[ind] = curr_line.side_of_intersection(
|
||||
prev_line, next_line
|
||||
)
|
||||
if det > 0:
|
||||
remove_line = intersection_sides[ind] != Side.ON_THE_LEFT
|
||||
else:
|
||||
if intersection_sides[ind] == Side.ON_THE_LEFT:
|
||||
curr_dir = curr_line.direction()
|
||||
if prev_dir.determinant(curr_dir) > 0:
|
||||
new_length = 0
|
||||
try_again = False
|
||||
break
|
||||
else: # parallel
|
||||
if prev_line.side_of(next_line.a) == Side.ON_THE_LEFT:
|
||||
new_length = 0
|
||||
try_again = False
|
||||
break
|
||||
|
||||
if remove_line:
|
||||
try_again = True
|
||||
new_length -= 1
|
||||
for i in range(ind, new_length):
|
||||
line_arr[i] = line_arr[i + 1]
|
||||
intersection_sides[i] = intersection_sides[i + 1]
|
||||
if new_length < 3:
|
||||
try_again = False
|
||||
break
|
||||
if ind == 0:
|
||||
prev_ind = new_length - 1
|
||||
intersection_sides[prev_ind] = None
|
||||
next_ind = 0 if ind >= new_length else ind
|
||||
intersection_sides[next_ind] = None
|
||||
ind -= 1
|
||||
index_of_last_removed_line = ind
|
||||
else:
|
||||
prev_line = curr_line
|
||||
prev_ind = ind
|
||||
curr_line = next_line
|
||||
if not try_again and ind >= index_of_last_removed_line:
|
||||
break
|
||||
ind += 1
|
||||
|
||||
if new_length == 2 and line_arr[0].is_parallel(line_arr[1]):
|
||||
if line_arr[0].direction().equals(line_arr[1].direction()):
|
||||
# one of the two parallel lines is redundant
|
||||
if line_arr[1].side_of(line_arr[0].a) == Side.ON_THE_LEFT:
|
||||
line_arr[0] = line_arr[1]
|
||||
new_length -= 1
|
||||
elif line_arr[1].side_of(line_arr[0].a) == Side.ON_THE_LEFT:
|
||||
# opposite directions that do not overlap: empty
|
||||
new_length = 0
|
||||
|
||||
if new_length == len(arr):
|
||||
return self
|
||||
if new_length == 0:
|
||||
return Simplex(())
|
||||
return Simplex(line_arr[:new_length])
|
||||
|
||||
|
||||
def _clamp(no: int, count: int) -> int:
|
||||
if no < 0:
|
||||
return 0
|
||||
if no >= count:
|
||||
return count - 1
|
||||
return no
|
||||
151
src/freeroute/geometry/tile.py
Normal file
151
src/freeroute/geometry/tile.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""``TileShape`` — abstract convex shape bounded by straight border lines.
|
||||
|
||||
Ports the concrete (non-abstract) behaviour of ``geometry/planar/TileShape.java``
|
||||
that is defined purely in terms of the border lines and corners: point
|
||||
containment, area, centre of gravity, and border tests. Concrete subclasses
|
||||
(:class:`~freeroute.geometry.simplex.Simplex`) supply ``border_line_count``,
|
||||
``border_line``, ``corner`` and ``corner_approx``.
|
||||
|
||||
**Arithmetic model:** containment and ``contains_on_border`` are **exact**
|
||||
(they use the exact :meth:`~freeroute.geometry.line.Line.side_of`); ``area`` and
|
||||
``centre_of_gravity`` are approximate (``float``), as upstream.
|
||||
|
||||
A convex tile is the intersection of the right half-planes of its directed
|
||||
border lines: a point is inside-or-on-border iff it is not on the strict left of
|
||||
any border line, and strictly inside iff it is on the strict right of every one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .float_point import FloatPoint
|
||||
from .point import Point
|
||||
from .side import Side
|
||||
|
||||
__all__ = ["TileShape"]
|
||||
|
||||
|
||||
class TileShape:
|
||||
"""Abstract convex shape; subclasses provide the border-line accessors."""
|
||||
|
||||
# --- accessors subclasses must provide ---------------------------------
|
||||
|
||||
def border_line_count(self) -> int: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def border_line(self, no: int): # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def corner(self, no: int) -> Point: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def corner_approx(self, no: int) -> FloatPoint:
|
||||
return self.corner(no).to_float()
|
||||
|
||||
def corner_is_bounded(self, no: int) -> bool: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def is_empty(self) -> bool: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def is_bounded(self) -> bool: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
def dimension(self) -> int: # pragma: no cover - abstract
|
||||
raise NotImplementedError
|
||||
|
||||
# --- point containment (exact) -----------------------------------------
|
||||
|
||||
def is_outside(self, point: Point) -> bool:
|
||||
"""True if ``point`` is neither inside nor on the border."""
|
||||
line_count = self.border_line_count()
|
||||
if line_count == 0:
|
||||
return True
|
||||
for i in range(line_count):
|
||||
if self.border_line(i).side_of(point) == Side.ON_THE_LEFT:
|
||||
return True
|
||||
return False
|
||||
|
||||
def contains(self, point: Point) -> bool:
|
||||
"""True if ``point`` is inside or on the border (exact)."""
|
||||
return not self.is_outside(point)
|
||||
|
||||
def contains_inside(self, point: Point) -> bool:
|
||||
"""True if ``point`` is strictly interior (on the right of every line)."""
|
||||
line_count = self.border_line_count()
|
||||
if line_count == 0:
|
||||
return False
|
||||
for i in range(line_count):
|
||||
if self.border_line(i).side_of(point) != Side.ON_THE_RIGHT:
|
||||
return False
|
||||
return True
|
||||
|
||||
def contains_on_border_line_no(self, point: Point) -> int:
|
||||
"""Index of a border line containing ``point``, or -1 if not on border."""
|
||||
line_count = self.border_line_count()
|
||||
if line_count == 0:
|
||||
return -1
|
||||
containing = -1
|
||||
for i in range(line_count):
|
||||
side = self.border_line(i).side_of(point)
|
||||
if side == Side.ON_THE_LEFT:
|
||||
return -1
|
||||
if side == Side.COLLINEAR:
|
||||
containing = i
|
||||
return containing
|
||||
|
||||
def contains_on_border(self, point: Point) -> bool:
|
||||
return self.contains_on_border_line_no(point) >= 0
|
||||
|
||||
def contains_tile(self, other: TileShape) -> bool:
|
||||
"""True if every corner of ``other`` is contained in this shape."""
|
||||
return all(
|
||||
self.contains(other.corner(i)) for i in range(other.border_line_count())
|
||||
)
|
||||
|
||||
# --- measures (approximate) --------------------------------------------
|
||||
|
||||
def centre_of_gravity(self) -> FloatPoint:
|
||||
"""Arithmetic mean of the (approximate) corners."""
|
||||
n = self.border_line_count()
|
||||
x = 0.0
|
||||
y = 0.0
|
||||
for i in range(n):
|
||||
c = self.corner_approx(i)
|
||||
x += c.x
|
||||
y += c.y
|
||||
return FloatPoint(x / n, y / n)
|
||||
|
||||
def area(self) -> float:
|
||||
"""Absolute polygon area (0 if degenerate, ``inf`` if unbounded)."""
|
||||
if not self.is_bounded():
|
||||
return float("inf")
|
||||
if self.dimension() < 2:
|
||||
return 0.0
|
||||
n = self.border_line_count()
|
||||
result = 0.0
|
||||
prev_corner = self.corner_approx(n - 2)
|
||||
curr_corner = self.corner_approx(n - 1)
|
||||
for i in range(n):
|
||||
next_corner = self.corner_approx(i)
|
||||
result += curr_corner.x * (next_corner.y - prev_corner.y)
|
||||
prev_corner = curr_corner
|
||||
curr_corner = next_corner
|
||||
return 0.5 * abs(result)
|
||||
|
||||
def circumference(self) -> float:
|
||||
if not self.is_bounded():
|
||||
return float("inf")
|
||||
n = self.border_line_count()
|
||||
result = 0.0
|
||||
prev_corner = self.corner_approx(n - 1)
|
||||
for i in range(n):
|
||||
curr_corner = self.corner_approx(i)
|
||||
result += curr_corner.distance(prev_corner)
|
||||
prev_corner = curr_corner
|
||||
return result
|
||||
|
||||
# --- convex decomposition (trivial for a tile) -------------------------
|
||||
|
||||
def split_to_convex(self) -> list[TileShape]:
|
||||
"""A convex tile is already convex — returns ``[self]``."""
|
||||
return [self]
|
||||
188
tests/geometry/test_simplex.py
Normal file
188
tests/geometry/test_simplex.py
Normal file
@ -0,0 +1,188 @@
|
||||
"""Invariant tests for TileShape / Simplex.
|
||||
|
||||
There is no external oracle (no JVM; FreeRouting ships no unit tests for
|
||||
``geometry.planar``), so these assert properties that must hold regardless of
|
||||
implementation, per the phase-2 brief:
|
||||
|
||||
* corners lie exactly on their two border lines (exact ``Line.side_of == 0``),
|
||||
* ``IntBox -> Simplex -> region`` preserves the region,
|
||||
* ``Simplex.intersection`` is contained in both operands and a point is in the
|
||||
result iff it is in both,
|
||||
* ``get_instance`` normalization drops redundant lines without changing the
|
||||
region.
|
||||
|
||||
Source: ``geometry/planar/{TileShape,Simplex}.java``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from freeroute.geometry import IntBox, IntPoint, Line, Side, Simplex
|
||||
|
||||
|
||||
def _grid(lo: int, hi: int):
|
||||
for x in range(lo, hi + 1):
|
||||
for y in range(lo, hi + 1):
|
||||
yield IntPoint(x, y)
|
||||
|
||||
|
||||
def right_triangle() -> Simplex:
|
||||
# legs of length 10 along the axes; hypotenuse x + y = 10
|
||||
return Simplex.get_instance(
|
||||
[
|
||||
Line.from_coords(0, 0, 10, 0),
|
||||
Line.from_coords(10, 0, 0, 10),
|
||||
Line.from_coords(0, 10, 0, 0),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def unit_box_simplex() -> Simplex:
|
||||
return IntBox(0, 0, 10, 10).to_simplex()
|
||||
|
||||
|
||||
# --- corners lie exactly on their border lines ------------------------------
|
||||
|
||||
|
||||
def test_corners_are_exact_on_border_lines():
|
||||
for shape in (unit_box_simplex(), right_triangle()):
|
||||
n = shape.border_line_count()
|
||||
for i in range(n):
|
||||
corner = shape.corner(i)
|
||||
prev = shape.border_line(n - 1 if i == 0 else i - 1)
|
||||
curr = shape.border_line(i)
|
||||
# the corner is the exact intersection of these two lines
|
||||
assert curr.side_of(corner) == Side.COLLINEAR
|
||||
assert prev.side_of(corner) == Side.COLLINEAR
|
||||
|
||||
|
||||
# --- IntBox -> Simplex -> region round trip ---------------------------------
|
||||
|
||||
|
||||
def test_box_to_simplex_preserves_region():
|
||||
box = IntBox(-3, 2, 7, 11)
|
||||
s = box.to_simplex()
|
||||
assert s.bounding_box() == box
|
||||
for p in _grid(-6, 14):
|
||||
assert box.contains(p) == s.contains(p)
|
||||
|
||||
|
||||
def test_box_to_simplex_is_a_box_and_simplifies_back():
|
||||
box = IntBox(0, 0, 10, 10)
|
||||
s = box.to_simplex()
|
||||
assert s.is_int_box()
|
||||
assert s.simplify() == box
|
||||
|
||||
|
||||
# --- containment semantics --------------------------------------------------
|
||||
|
||||
|
||||
def test_triangle_containment():
|
||||
tri = right_triangle()
|
||||
assert tri.contains(IntPoint(0, 0)) # corner
|
||||
assert tri.contains(IntPoint(5, 5)) # on hypotenuse
|
||||
assert tri.contains_inside(IntPoint(2, 2))
|
||||
assert not tri.contains_inside(IntPoint(5, 5)) # on border, not inside
|
||||
assert not tri.contains(IntPoint(6, 6)) # x+y=12 > 10
|
||||
assert tri.contains_on_border(IntPoint(3, 0)) # on the bottom edge
|
||||
|
||||
|
||||
def test_triangle_area_and_bounds():
|
||||
tri = right_triangle()
|
||||
assert tri.is_bounded()
|
||||
assert tri.dimension() == 2
|
||||
assert tri.area() == 50.0
|
||||
assert tri.bounding_box() == IntBox(0, 0, 10, 10)
|
||||
|
||||
|
||||
# --- intersection invariants ------------------------------------------------
|
||||
|
||||
|
||||
def test_intersection_contained_in_both_and_iff():
|
||||
a = IntBox(0, 0, 12, 8).to_simplex()
|
||||
b = right_triangle() # x,y >= 0, x+y <= 10
|
||||
inter = a.intersection(b)
|
||||
assert not inter.is_empty()
|
||||
for p in _grid(-3, 15):
|
||||
in_both = a.contains(p) and b.contains(p)
|
||||
in_inter = inter.contains(p)
|
||||
assert in_inter == in_both
|
||||
# every corner of the intersection lies inside both operands
|
||||
for i in range(inter.border_line_count()):
|
||||
c = inter.corner(i)
|
||||
assert a.contains(c)
|
||||
assert b.contains(c)
|
||||
|
||||
|
||||
def test_intersection_of_two_boxes_matches_box_intersection():
|
||||
a_box = IntBox(0, 0, 10, 10)
|
||||
b_box = IntBox(4, -2, 20, 6)
|
||||
inter = a_box.to_simplex().intersection(b_box.to_simplex())
|
||||
expected = a_box.intersection(b_box)
|
||||
for p in _grid(-5, 22):
|
||||
assert inter.contains(p) == expected.contains(p)
|
||||
|
||||
|
||||
def test_disjoint_intersection_is_empty():
|
||||
a = IntBox(0, 0, 3, 3).to_simplex()
|
||||
b = IntBox(10, 10, 13, 13).to_simplex()
|
||||
assert a.intersection(b).is_empty()
|
||||
assert not a.intersects(b)
|
||||
|
||||
|
||||
def test_intersection_is_idempotent_with_self():
|
||||
tri = right_triangle()
|
||||
inter = tri.intersection(tri)
|
||||
for p in _grid(-3, 13):
|
||||
assert inter.contains(p) == tri.contains(p)
|
||||
|
||||
|
||||
# --- normalization (get_instance drops redundant lines) ---------------------
|
||||
|
||||
|
||||
def test_get_instance_removes_redundant_line():
|
||||
# a unit box plus a far-away redundant half-plane that does not cut it.
|
||||
# A DOWN-directed vertical line has its interior to the east, so this is
|
||||
# x >= -100, which contains the whole box and is therefore redundant.
|
||||
box_lines = list(IntBox(0, 0, 10, 10).to_simplex()._arr)
|
||||
redundant = Line.from_coords(-100, 1, -100, 0) # x >= -100
|
||||
s = Simplex.get_instance(box_lines + [redundant])
|
||||
assert s.border_line_count() == 4 # redundant line dropped
|
||||
for p in _grid(-5, 15):
|
||||
assert s.contains(p) == IntBox(0, 0, 10, 10).contains(p)
|
||||
|
||||
|
||||
def test_get_instance_detects_empty_from_opposing_halfplanes():
|
||||
# x >= 5 (DOWN line, interior east) and x <= 0 (UP line, interior west)
|
||||
# cannot both hold.
|
||||
ge5 = Line.from_coords(5, 1, 5, 0) # DOWN -> interior x >= 5
|
||||
le0 = Line.from_coords(0, 0, 0, 1) # UP -> interior x <= 0
|
||||
s = Simplex.get_instance([ge5, le0])
|
||||
assert s.is_empty()
|
||||
|
||||
|
||||
def test_translate_preserves_shape():
|
||||
tri = right_triangle()
|
||||
from freeroute.geometry import IntVector
|
||||
|
||||
moved = tri.translate_by(IntVector(100, 50))
|
||||
for p in _grid(-3, 13):
|
||||
assert tri.contains(p) == moved.contains(IntPoint(p.x + 100, p.y + 50))
|
||||
|
||||
|
||||
def test_offset_outward_enlarges_region():
|
||||
box = IntBox(0, 0, 10, 10).to_simplex()
|
||||
bigger = box.offset(2)
|
||||
# every point of the original box is still contained after outward offset
|
||||
for p in _grid(0, 10):
|
||||
assert bigger.contains(p)
|
||||
# a point 2 outside the original right edge is now contained
|
||||
assert bigger.contains(IntPoint(12, 5))
|
||||
assert not box.contains(IntPoint(12, 5))
|
||||
|
||||
|
||||
def test_empty_simplex_predicates():
|
||||
e = Simplex.empty()
|
||||
assert e.is_empty()
|
||||
assert e.dimension() == -1
|
||||
assert e.is_outside(IntPoint(0, 0))
|
||||
assert not e.contains(IntPoint(0, 0))
|
||||
Loading…
x
Reference in New Issue
Block a user