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.
339 lines
12 KiB
Python
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
|