[boolean] correctly handle repeated polygons
This commit is contained in:
parent
eb81101672
commit
848fd95e2c
2 changed files with 61 additions and 34 deletions
|
|
@ -15,6 +15,40 @@ def _poly_area(poly: Polygon) -> float:
|
|||
y = verts[:, 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:
|
||||
pat = Pattern()
|
||||
pat.polygon((1, 0), [[0, 0], [1, 0], [1, 1], [0, 1]])
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@ def boolean(
|
|||
Perform a boolean operation on two sets of polygons.
|
||||
|
||||
Args:
|
||||
subjects: List of subjects (Polygons or vertex arrays).
|
||||
clips: List of clips (Polygons or vertex arrays).
|
||||
subjects: Subjects (shapes or vertex arrays). Shape repetitions are expanded.
|
||||
clips: Clips (shapes or vertex arrays). Shape repetitions are expanded.
|
||||
operation: The boolean operation to perform.
|
||||
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]:
|
||||
if objs is None:
|
||||
return []
|
||||
if hasattr(objs, 'to_polygons') or isinstance(objs, numpy.ndarray | Polygon):
|
||||
objs = (objs,)
|
||||
elif not isinstance(objs, Iterable):
|
||||
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
|
||||
if isinstance(objs, numpy.ndarray):
|
||||
return [objs]
|
||||
if hasattr(objs, 'to_polygons'):
|
||||
verts = []
|
||||
for obj in objs:
|
||||
if hasattr(obj, 'to_polygons'):
|
||||
for p in obj.to_polygons():
|
||||
verts.append(p.vertices)
|
||||
elif isinstance(obj, numpy.ndarray):
|
||||
verts.append(obj)
|
||||
elif isinstance(obj, Polygon):
|
||||
verts.append(obj.vertices)
|
||||
for poly in objs.to_polygons():
|
||||
if poly.repetition is None:
|
||||
verts.append(poly.vertices)
|
||||
else:
|
||||
# Try to iterate if it's an iterable of shapes
|
||||
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
|
||||
verts.extend(poly.vertices + dd for dd in poly.repetition.displacements)
|
||||
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)
|
||||
clip_verts = to_vertices(clips)
|
||||
|
||||
if not subject_verts:
|
||||
if operation in ('union', 'xor'):
|
||||
return [Polygon(vertices) for vertices in clip_verts]
|
||||
if op not in (pyclipper.CT_UNION, pyclipper.CT_XOR) or not clip_verts:
|
||||
return []
|
||||
subject_verts, clip_verts = clip_verts, []
|
||||
|
||||
if not clip_verts:
|
||||
if operation == 'intersection':
|
||||
if not clip_verts and op == pyclipper.CT_INTERSECTION:
|
||||
return []
|
||||
return [Polygon(vertices) for vertices in subject_verts]
|
||||
|
||||
pc = pyclipper.Pyclipper()
|
||||
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)
|
||||
|
||||
# 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 = []
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue