geom: Shapely-backed region layer
Booleans, decomposition, area, simplicity, hull, bounds, mitred offset and vertex cleaning. Booleans go to GEOS, which is the reason Shapely was chosen. The decomposition does not. BOSL2 region_parts counts by nesting parity, not connectivity: a path takes a level from how many others contain the midpoint of its first edge, even levels are outer boundaries, their odd children are holes. SECTION_PARTS == 1 is an exact assertion, and Shapely agreeing with that count is a coincidence that holds for well-formed input and not otherwise, so the decomposition is transcribed and both the part count and the area derive from it. is_region_simple is treated as a manifold precondition rather than a diagnostic. An outline that touches itself measures perfectly and cannot be tessellated, so it must fail here and not at export. Developed against Shapely 2.1.2 / GEOS 3.13.1, matching CT 100. Boolean results on near-degenerate geometry can shift between GEOS releases; if the oracle ever disagrees by one part after an upgrade, look there first. 39 tests, all arithmetic on rectangles. Mutation run found a real gap: nothing distinguished on-boundary from outside in the nesting probe until a shared-edge case was added. Eight mutations now caught. Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
@@ -60,3 +60,23 @@ from .rounding import ( # noqa: F401
|
||||
round_corners,
|
||||
segs,
|
||||
)
|
||||
from .region import ( # noqa: F401
|
||||
Region,
|
||||
area,
|
||||
as_region,
|
||||
clean_region,
|
||||
difference,
|
||||
from_shapely,
|
||||
hull_region,
|
||||
intersection,
|
||||
is_path_simple,
|
||||
is_region_simple,
|
||||
nparts,
|
||||
offset_path,
|
||||
point_in_polygon,
|
||||
pointlist_bounds,
|
||||
region_area,
|
||||
region_parts,
|
||||
to_shapely,
|
||||
union,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Region operations: the boolean and measurement layer ``sb-join`` sits on.
|
||||
|
||||
A *region* is BOSL2's: a list of closed paths, where nesting determines solid
|
||||
from void. That representation is kept rather than replaced by Shapely
|
||||
geometries, because it is what the reference passes around and what the
|
||||
generators' call sites expect.
|
||||
|
||||
**Why the decomposition is transcribed rather than delegated.** BOSL2's
|
||||
``region_parts`` counts by nesting *parity*, not connectivity: each path takes a
|
||||
level from how many other paths contain the midpoint of its first edge, and
|
||||
even-level paths are outer boundaries with their odd-level children as holes.
|
||||
``SECTION_PARTS == 1`` is an exact assertion in the oracle, and Shapely's
|
||||
component count agreeing with BOSL2's parity count is a coincidence that holds
|
||||
for well-formed input and not otherwise. Reproducing the decomposition means the
|
||||
part count and the area come from the same reading of the geometry.
|
||||
|
||||
Booleans are delegated to GEOS, which is the reason for choosing Shapely. The
|
||||
results are converted back to path lists so nothing downstream needs to know.
|
||||
|
||||
Pinned against BOSL2 ``92d697c2856de2fed93a33e858068589cefc2898``. Developed
|
||||
against Shapely 2.1.2 / GEOS 3.13.1, matching CT 100; boolean results on
|
||||
near-degenerate geometry can differ between GEOS releases, so that pairing is
|
||||
worth keeping in step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from shapely.geometry import MultiPoint, MultiPolygon, Polygon
|
||||
from shapely.geometry.base import BaseGeometry
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from .primitives import SB_EPS, sb_signed_area
|
||||
from .rounding import EPSILON, path_merge_collinear
|
||||
|
||||
Point = Tuple[float, float]
|
||||
Path = Sequence[Point]
|
||||
Region = List[List[Point]]
|
||||
|
||||
# Large enough that a mitred offset is never silently bevelled at the angles
|
||||
# these profiles use, which run to fairly sharp apexes.
|
||||
_MITRE_LIMIT = 1e6
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Point-in-polygon (geometry.scad: point_in_polygon, winding number)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def point_in_polygon(pt: Point, poly: Path, eps: float = EPSILON) -> int:
|
||||
"""
|
||||
1 inside, 0 on the boundary, -1 outside.
|
||||
|
||||
On-boundary is its own answer rather than folded into inside or outside,
|
||||
because ``region_parts`` treats a point on a boundary as contained and the
|
||||
distinction changes nesting levels.
|
||||
"""
|
||||
n = len(poly)
|
||||
for i in range(n):
|
||||
a, b = poly[i], poly[(i + 1) % n]
|
||||
abx, aby = b[0] - a[0], b[1] - a[1]
|
||||
l2 = abx * abx + aby * aby
|
||||
if l2 < SB_EPS:
|
||||
if math.hypot(pt[0] - a[0], pt[1] - a[1]) <= eps:
|
||||
return 0
|
||||
continue
|
||||
t = ((pt[0] - a[0]) * abx + (pt[1] - a[1]) * aby) / l2
|
||||
t = max(0.0, min(1.0, t))
|
||||
if math.hypot(pt[0] - (a[0] + t * abx), pt[1] - (a[1] + t * aby)) <= eps:
|
||||
return 0
|
||||
|
||||
wind = 0
|
||||
for i in range(n):
|
||||
a, b = poly[i], poly[(i + 1) % n]
|
||||
if a[1] <= pt[1] < b[1] or b[1] <= pt[1] < a[1]:
|
||||
cross = ((b[0] - a[0]) * (pt[1] - a[1])
|
||||
- (b[1] - a[1]) * (pt[0] - a[0]))
|
||||
if a[1] < b[1]:
|
||||
wind += 1 if cross > 0 else 0
|
||||
else:
|
||||
wind -= 1 if cross < 0 else 0
|
||||
return 1 if wind != 0 else -1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Decomposition (regions.scad: region_parts)
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def _cw(path: Path) -> List[Point]:
|
||||
return list(path) if sb_signed_area(path) < 0 else list(reversed(path))
|
||||
|
||||
|
||||
def _ccw(path: Path) -> List[Point]:
|
||||
return list(path) if sb_signed_area(path) >= 0 else list(reversed(path))
|
||||
|
||||
|
||||
def region_parts(rgn: Sequence[Path]) -> List[List[List[Point]]]:
|
||||
"""
|
||||
Split a region into connected pieces, each ``[outer, *holes]``.
|
||||
|
||||
The outer boundary comes back clockwise and its holes counter-clockwise,
|
||||
matching the reference, because ``region_area`` depends on that convention
|
||||
to make holes subtract.
|
||||
"""
|
||||
paths = [list(p) for p in rgn]
|
||||
n = len(paths)
|
||||
if n == 0:
|
||||
return []
|
||||
|
||||
inside = []
|
||||
for i in range(n):
|
||||
pt = ((paths[i][0][0] + paths[i][1][0]) / 2.0,
|
||||
(paths[i][0][1] + paths[i][1][1]) / 2.0)
|
||||
inside.append([0 if i == j else
|
||||
(1 if point_in_polygon(pt, paths[j]) >= 0 else 0)
|
||||
for j in range(n)])
|
||||
|
||||
level = [sum(row) for row in inside]
|
||||
|
||||
out: List[List[List[Point]]] = []
|
||||
for i in range(n):
|
||||
if level[i] % 2 != 0:
|
||||
continue
|
||||
holes = [j for j in range(n)
|
||||
if level[j] == level[i] + 1 and inside[j][i] == 1]
|
||||
out.append([_cw(paths[i])] + [_ccw(paths[j]) for j in holes])
|
||||
return out
|
||||
|
||||
|
||||
def region_area(rgn: Sequence[Path]) -> float:
|
||||
"""Total enclosed area, holes subtracted."""
|
||||
return -sum(sb_signed_area(poly)
|
||||
for part in region_parts(rgn) for poly in part)
|
||||
|
||||
|
||||
def nparts(rgn: Sequence[Path]) -> int:
|
||||
"""Number of connected solids. Empty reads as zero, as the reference does."""
|
||||
return 0 if len(rgn) == 0 else len(region_parts(rgn))
|
||||
|
||||
|
||||
def area(rgn: Sequence[Path]) -> float:
|
||||
"""Empty-safe area: a legitimately empty result reads as zero."""
|
||||
return 0.0 if len(rgn) == 0 else region_area(rgn)
|
||||
|
||||
|
||||
def as_region(x) -> Region:
|
||||
"""
|
||||
Accept a bare path where a region is expected.
|
||||
|
||||
BOSL2's boolean functions return a bare ``[]`` when a result is empty, which
|
||||
is not a valid region; every measurement goes through here so an empty
|
||||
result reads as zero rather than raising.
|
||||
"""
|
||||
if not x:
|
||||
return []
|
||||
first = x[0]
|
||||
if isinstance(first, (tuple, list)) and len(first) == 2 \
|
||||
and isinstance(first[0], (int, float)):
|
||||
return [list(x)]
|
||||
return [list(p) for p in x]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Shapely bridge
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def to_shapely(rgn: Sequence[Path]) -> BaseGeometry:
|
||||
"""Build a Shapely geometry from the reference's own decomposition."""
|
||||
polys = []
|
||||
for part in region_parts(rgn):
|
||||
shell = part[0]
|
||||
holes = part[1:]
|
||||
p = Polygon(shell, holes)
|
||||
if not p.is_valid:
|
||||
p = p.buffer(0)
|
||||
if not p.is_empty:
|
||||
polys.append(p)
|
||||
if not polys:
|
||||
return Polygon()
|
||||
return polys[0] if len(polys) == 1 else MultiPolygon(
|
||||
[g for p in polys for g in (p.geoms if isinstance(p, MultiPolygon) else [p])])
|
||||
|
||||
|
||||
def from_shapely(geom: BaseGeometry) -> Region:
|
||||
"""
|
||||
Flatten a Shapely geometry back to a list of closed paths.
|
||||
|
||||
Shapely repeats the first coordinate to close a ring; the reference's paths
|
||||
are implicitly closed, so the duplicate is dropped.
|
||||
"""
|
||||
if geom.is_empty:
|
||||
return []
|
||||
geoms = geom.geoms if isinstance(geom, MultiPolygon) else [geom]
|
||||
out: Region = []
|
||||
for g in geoms:
|
||||
if not isinstance(g, Polygon) or g.is_empty:
|
||||
continue
|
||||
out.append([(x, y) for x, y in list(g.exterior.coords)[:-1]])
|
||||
for ring in g.interiors:
|
||||
out.append([(x, y) for x, y in list(ring.coords)[:-1]])
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Booleans
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def union(regions: Sequence[Sequence[Path]]) -> Region:
|
||||
shapes = [to_shapely(r) for r in regions if len(r) > 0]
|
||||
if not shapes:
|
||||
return []
|
||||
return from_shapely(unary_union(shapes))
|
||||
|
||||
|
||||
def difference(a: Sequence[Path], b: Sequence[Path]) -> Region:
|
||||
if len(a) == 0:
|
||||
return []
|
||||
if len(b) == 0:
|
||||
return as_region(a)
|
||||
return from_shapely(to_shapely(a).difference(to_shapely(b)))
|
||||
|
||||
|
||||
def intersection(a: Sequence[Path], b: Sequence[Path]) -> Region:
|
||||
if len(a) == 0 or len(b) == 0:
|
||||
return []
|
||||
return from_shapely(to_shapely(a).intersection(to_shapely(b)))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Simplicity -- the manifold precondition
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def is_path_simple(path: Path, eps: float = EPSILON) -> bool:
|
||||
"""A closed path that neither crosses nor touches itself away from its seam."""
|
||||
pts = list(path)
|
||||
if len(pts) < 3:
|
||||
return False
|
||||
ring = Polygon(pts + [pts[0]]).exterior
|
||||
return bool(ring.is_simple)
|
||||
|
||||
|
||||
def is_region_simple(rgn: Sequence[Path], eps: float = EPSILON) -> bool:
|
||||
"""
|
||||
Every path simple, and no two paths meeting at all -- touching included.
|
||||
|
||||
This is not a cosmetic check. A section that touches itself at a point is a
|
||||
valid 2D outline and cannot be tessellated, so it measures perfectly and
|
||||
then fails to extrude into a sealed solid.
|
||||
"""
|
||||
paths = [list(p) for p in rgn]
|
||||
for p in paths:
|
||||
if not is_path_simple(p, eps):
|
||||
return False
|
||||
rings = [Polygon(p + [p[0]]).exterior for p in paths]
|
||||
for i in range(len(rings)):
|
||||
for j in range(i + 1, len(rings)):
|
||||
if rings[i].intersects(rings[j]):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Hull, bounds, offset, cleanup
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def hull_region(rgn: Sequence[Path]) -> List[Point]:
|
||||
"""Convex hull of every point in the region."""
|
||||
pts = [tuple(p) for path in rgn for p in path]
|
||||
if len(pts) < 3:
|
||||
return list(pts)
|
||||
hull = MultiPoint(pts).convex_hull
|
||||
if isinstance(hull, Polygon):
|
||||
return [(x, y) for x, y in list(hull.exterior.coords)[:-1]]
|
||||
# Collinear input degenerates to a line or a point; return it unchanged
|
||||
# rather than inventing an area the caller would then measure.
|
||||
return list(pts)
|
||||
|
||||
|
||||
def pointlist_bounds(pts: Sequence[Point]) -> List[Point]:
|
||||
"""``[[min_x, min_y], [max_x, max_y]]``, as the reference returns it."""
|
||||
xs = [p[0] for p in pts]
|
||||
ys = [p[1] for p in pts]
|
||||
return [(min(xs), min(ys)), (max(xs), max(ys))]
|
||||
|
||||
|
||||
def offset_path(path: Path, delta: float, closed: bool = True) -> List[Point]:
|
||||
"""
|
||||
Mitred offset of a closed path. Positive ``delta`` grows it.
|
||||
|
||||
Mitred rather than rounded: BOSL2's ``offset`` with ``delta`` keeps corners
|
||||
sharp, and the ring envelope's corners are rounded afterwards by an explicit
|
||||
``round_corners`` call, not by the offset itself. A rounded join here would
|
||||
round them twice and by the wrong rule.
|
||||
"""
|
||||
pts = _ccw(path)
|
||||
poly = Polygon(pts)
|
||||
if not poly.is_valid:
|
||||
poly = poly.buffer(0)
|
||||
grown = poly.buffer(delta, join_style="mitre", mitre_limit=_MITRE_LIMIT)
|
||||
if grown.is_empty:
|
||||
return []
|
||||
if isinstance(grown, MultiPolygon):
|
||||
grown = max(grown.geoms, key=lambda g: g.area)
|
||||
return [(x, y) for x, y in list(grown.exterior.coords)[:-1]]
|
||||
|
||||
|
||||
def clean_region(rgn: Sequence[Path], eps: float = EPSILON) -> Region:
|
||||
"""
|
||||
Drop duplicate and collinear vertices from every path, discarding any path
|
||||
left with fewer than three.
|
||||
|
||||
Exact butt joints and zero-radius fillets produce coincident or perfectly
|
||||
collinear vertices. They are harmless in 2D but leave zero-area triangles
|
||||
the tessellator cannot resolve, so a section that measures perfectly can
|
||||
still fail to extrude. Cleaning once, at the end, removes that entire class
|
||||
of failure.
|
||||
"""
|
||||
out: Region = []
|
||||
for path in rgn:
|
||||
merged = path_merge_collinear(path, closed=True, eps=eps)
|
||||
if len(merged) >= 3:
|
||||
out.append(merged)
|
||||
return out
|
||||
Reference in New Issue
Block a user