Add PolygonShape.split_to_convex (polygon to convex tiles)

Ports geometry/planar/Polygon.java (corner de-duplication and collinear
removal, winding number) and PolygonShape.java, including the recursive
split_to_convex that decomposes a simple polygon into convex Simplex
tiles by dividing at concave corners along minimal axis-parallel lines.

Orientation and convexity tests are exact; the division-point search is
approximate (float line evaluation, split point rounded to an integer
corner) as upstream. The concave-corner search starts deterministically
at corner 0 rather than a seeded PRNG; this only affects which valid
decomposition is produced.

Supporting additions: Simplex.from_corners (convex polygon to simplex)
and Line.function_value_approx / function_in_y_value_approx.

Invariant tests over L, plus, staircase and square polygons: tile areas
sum to the polygon area (no gaps, no overlap), a point is in the polygon
iff in some tile, and no point is strictly inside more than one tile
(interiors disjoint). Also covers Polygon normalization and orientation.
This commit is contained in:
2026-07-11 18:41:07 -06:00
parent 1599d181b5
commit 68ab16a7cf
6 changed files with 525 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""``Polygon`` — a normalized corner list (no duplicate or collinear corners).
Ports ``geometry/planar/Polygon.java``. Used as the corner normalizer behind
:class:`~freeroute.geometry.polygon_shape.PolygonShape`.
**Arithmetic model:** corner de-duplication and collinearity removal are
**exact** (exact ``Point.side_of``); the winding-number test is approximate
(sum of ``angle_approx``), exactly as upstream.
"""
from __future__ import annotations
import math
from .point import Point
from .side import Side
__all__ = ["Polygon"]
class Polygon:
"""A list of corners with no two consecutive equal and no three collinear."""
__slots__ = ("corners",)
def __init__(self, points) -> None:
corners: list[Point] = list(points)
corner_removed = True
while corner_removed:
corner_removed = False
if not corners:
break
# remove consecutive duplicate points
deduped: list[Point] = [corners[0]]
for pt in corners[1:]:
if pt != deduped[-1]:
deduped.append(pt)
if len(deduped) != len(corners):
corner_removed = True
corners = deduped
# remove a point collinear with its previous and next point
if len(corners) >= 3:
for i in range(1, len(corners) - 1):
if corners[i].side_of(corners[i - 1], corners[i + 1]) == Side.COLLINEAR:
del corners[i]
corner_removed = True
break
self.corners: list[Point] = corners
def corner_array(self) -> list[Point]:
return list(self.corners)
def revert_corners(self) -> Polygon:
return Polygon(list(reversed(self.corners)))
def winding_number_after_closing(self) -> int:
"""Winding number of the closed polygon: >0 counter-clockwise, <0 clockwise."""
arr = self.corners
if len(arr) < 2:
return 0
first_side = arr[1].difference_by(arr[0])
prev_side = first_side
corner_count = len(arr)
if arr[0] == arr[corner_count - 1]:
corner_count -= 1
angle_sum = 0.0
for i in range(1, corner_count + 1):
if i == corner_count - 1:
next_side = arr[0].difference_by(arr[i])
elif i == corner_count:
next_side = first_side
else:
next_side = arr[i + 1].difference_by(arr[i])
angle_sum += prev_side.angle_approx(next_side)
prev_side = next_side
angle_sum /= 2.0 * math.pi
return round(angle_sum)