[boolean] correctly handle repeated polygons

This commit is contained in:
Jan Petykiewicz 2026-09-14 21:57:50 -07:00
commit 848fd95e2c
2 changed files with 61 additions and 34 deletions

View file

@ -15,6 +15,40 @@ def _poly_area(poly: Polygon) -> float:
y = verts[:, 1] y = verts[:, 1]
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1))) return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
@pytest.mark.parametrize('repeated_clip', [False, True])
@pytest.mark.parametrize('nested', [False, True])
def test_boolean_expands_repetitions(repeated_clip: bool, nested: bool) -> None:
from masque import boolean
from masque.repetition import Arbitrary
from masque.shapes import RectCollection
repeated = RectCollection([[0, 0, 2, 2]], repetition=Arbitrary([[0, 0], [10, 0]]))
clip = Polygon([[10, 0], [12, 0], [12, 2], [10, 2]])
subject, other = (clip, repeated) if repeated_clip else (repeated, clip)
result = boolean([[subject]] if nested else subject, [other], operation='intersection')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[10, 0], [12, 2]])
assert _poly_area(result[0]) == 4
assert result[0].repetition is None
assert_allclose(repeated.rects, [[0, 0, 2, 2]])
assert_allclose(repeated.repetition.displacements, [[0, 0], [10, 0]])
@pytest.mark.parametrize('operation', ['union', 'difference', 'xor'])
def test_boolean_single_set_normalizes_overlaps(operation: str) -> None:
from masque import boolean
subject = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]], repetition=Grid(a_vector=(1, 0), a_count=2))
for clips in (None, []):
result = boolean(subject, clips, operation=operation)
assert len(result) == 1
assert _poly_area(result[0]) == 6
if operation != 'difference':
result = boolean([], subject, operation=operation)
assert len(result) == 1
assert _poly_area(result[0]) == 6
def test_layer_as_polygons_basic() -> None: def test_layer_as_polygons_basic() -> None:
pat = Pattern() pat = Pattern()
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]]) pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])

View file

@ -89,8 +89,8 @@ def boolean(
Perform a boolean operation on two sets of polygons. Perform a boolean operation on two sets of polygons.
Args: Args:
subjects: List of subjects (Polygons or vertex arrays). subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded.
clips: List of clips (Polygons or vertex arrays). clips: Clips (shapes or vertex arrays). Shape repetitions are expanded.
operation: The boolean operation to perform. operation: The boolean operation to perform.
scale: Scaling factor for integer conversion (pyclipper uses integers). scale: Scaling factor for integer conversion (pyclipper uses integers).
@ -115,44 +115,31 @@ def boolean(
def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]:
if objs is None: if objs is None:
return [] return []
if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon): if isinstance(objs, numpy.ndarray):
objs = (objs,) return [objs]
elif not isinstance(objs, Iterable): if hasattr(objs, 'to_polygons'):
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
verts = [] verts = []
for obj in objs: for poly in objs.to_polygons():
if hasattr(obj, 'to_polygons'): if poly.repetition is None:
for p in obj.to_polygons(): verts.append(poly.vertices)
verts.append(p.vertices)
elif isinstance(obj, numpy.ndarray):
verts.append(obj)
elif isinstance(obj, Polygon):
verts.append(obj.vertices)
else: else:
# Try to iterate if it's an iterable of shapes verts.extend(poly.vertices + dd for dd in poly.repetition.displacements)
try:
for sub in obj:
if hasattr(sub, 'to_polygons'):
for p in sub.to_polygons():
verts.append(p.vertices)
elif isinstance(sub, Polygon):
verts.append(sub.vertices)
except TypeError:
raise PatternError(f"Unsupported type for boolean operation: {type(obj)}") from None
return verts return verts
if isinstance(objs, str | bytes) or not isinstance(objs, Iterable):
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
return [vertices for obj in objs for vertices in to_vertices(obj)]
op = op_map[operation.lower()]
subject_verts = to_vertices(subjects) subject_verts = to_vertices(subjects)
clip_verts = to_vertices(clips) clip_verts = to_vertices(clips)
if not subject_verts: if not subject_verts:
if operation in ('union', 'xor'): if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts:
return [Polygon(vertices) for vertices in clip_verts]
return [] return []
subject_verts, clip_verts = clip_verts, []
if not clip_verts: if not clip_verts and op == pyclipper.CT_INTERSECTION:
if operation == 'intersection':
return [] return []
return [Polygon(vertices) for vertices in subject_verts]
pc = pyclipper.Pyclipper() pc = pyclipper.Pyclipper()
pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True) pc.AddPaths(pyclipper.scale_to_clipper(subject_verts, scale), pyclipper.PT_SUBJECT, True)
@ -160,7 +147,13 @@ def boolean(
pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True) pc.AddPaths(pyclipper.scale_to_clipper(clip_verts, scale), pyclipper.PT_CLIP, True)
# Use GetPolyTree to distinguish between outers and holes # Use GetPolyTree to distinguish between outers and holes
polytree = pc.Execute2(op_map[operation.lower()], pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO) polytree = pc.Execute2(op, pyclipper.PFT_NONZERO, pyclipper.PFT_NONZERO)
return _polytree_to_polygons(polytree, scale)
def _polytree_to_polygons(polytree: Any, scale: float) -> list[Polygon]:
"""Convert a Clipper result, bridging holes for masque's polygon representation."""
import pyclipper # noqa: PLC0415
result_polygons = [] result_polygons = []