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:
@@ -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
|
||||
Reference in New Issue
Block a user