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