[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]
|
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]])
|
||||||
|
|
|
||||||
|
|
@ -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'):
|
||||||
|
verts = []
|
||||||
|
for poly in objs.to_polygons():
|
||||||
|
if poly.repetition is None:
|
||||||
|
verts.append(poly.vertices)
|
||||||
|
else:
|
||||||
|
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)}")
|
raise PatternError(f"Unsupported type for boolean operation: {type(objs)}")
|
||||||
verts = []
|
return [vertices for obj in objs for vertices in to_vertices(obj)]
|
||||||
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)
|
|
||||||
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
|
|
||||||
return verts
|
|
||||||
|
|
||||||
|
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 []
|
|
||||||
|
|
||||||
if not clip_verts:
|
|
||||||
if operation == 'intersection':
|
|
||||||
return []
|
return []
|
||||||
return [Polygon(vertices) for vertices in subject_verts]
|
subject_verts, clip_verts = clip_verts, []
|
||||||
|
|
||||||
|
if not clip_verts and op == pyclipper.CT_INTERSECTION:
|
||||||
|
return []
|
||||||
|
|
||||||
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 = []
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue