Files
rpmroute/src/freeroute/geometry/simplex.py
T
rsp2k 1599d181b5 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.
2026-07-11 18:36:20 -06:00

339 lines
12 KiB
Python

"""``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