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:
2026-08-19 03:30:03 -05:00
parent 545eee7217
commit 38ea024fdc
3 changed files with 665 additions and 0 deletions
+20
View File
@@ -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,
)
+325
View File
@@ -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