[boolean] more work towards getting boolean ops working

This commit is contained in:
Jan Petykiewicz 2026-04-01 21:28:33 -07:00
commit 32744512e0
2 changed files with 197 additions and 5 deletions

View file

@ -6,6 +6,14 @@ from masque.pattern import Pattern
from masque.shapes.polygon import Polygon
from masque.repetition import Grid
from masque.library import Library
from masque.error import PatternError
def _poly_area(poly: Polygon) -> float:
verts = poly.vertices
x = verts[:, 0]
y = verts[:, 1]
return 0.5 * abs(numpy.dot(x, numpy.roll(y, -1)) - numpy.dot(y, numpy.roll(x, -1)))
def test_layer_as_polygons_basic() -> None:
pat = Pattern()
@ -69,6 +77,176 @@ def test_polygon_boolean_shortcut() -> None:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
poly.boolean(poly)
def test_boolean_intersection_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
result = boolean(
[Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])],
[Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])],
operation='intersection',
)
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_polygon_boolean_shortcut_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
poly = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
result = poly.boolean(
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_boolean_union_difference_and_xor_with_pyclipper() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect_a = Polygon([[0, 0], [2, 0], [2, 2], [0, 2]])
rect_b = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
union = boolean([rect_a], [rect_b], operation='union')
assert len(union) == 1
assert_allclose(union[0].get_bounds_single(), [[0, 0], [3, 3]], atol=1e-10)
assert_allclose(_poly_area(union[0]), 7, atol=1e-10)
difference = boolean([rect_a], [rect_b], operation='difference')
assert len(difference) == 1
assert_allclose(difference[0].get_bounds_single(), [[0, 0], [2, 2]], atol=1e-10)
assert_allclose(_poly_area(difference[0]), 3, atol=1e-10)
xor = boolean([rect_a], [rect_b], operation='xor')
assert len(xor) == 2
assert_allclose(sorted(_poly_area(poly) for poly in xor), [3, 3], atol=1e-10)
xor_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in xor)
assert xor_bounds == [((0.0, 0.0), (2.0, 2.0)), ((1.0, 1.0), (3.0, 3.0))]
def test_boolean_accepts_raw_vertices_and_single_shape_inputs() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
raw_result = boolean(
[numpy.array([[0, 0], [2, 0], [2, 2], [0, 2]])],
numpy.array([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(raw_result) == 1
assert_allclose(raw_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
assert_allclose(_poly_area(raw_result[0]), 1, atol=1e-10)
single_shape_result = boolean(
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
operation='intersection',
)
assert len(single_shape_result) == 1
assert_allclose(single_shape_result[0].get_bounds_single(), [[1, 1], [2, 2]], atol=1e-10)
def test_boolean_handles_multi_polygon_inputs() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
result = boolean(
[
Polygon([[0, 0], [2, 0], [2, 2], [0, 2]]),
Polygon([[10, 0], [12, 0], [12, 2], [10, 2]]),
],
[
Polygon([[1, 1], [3, 1], [3, 3], [1, 3]]),
Polygon([[11, 1], [13, 1], [13, 3], [11, 3]]),
],
operation='intersection',
)
assert len(result) == 2
assert_allclose(sorted(_poly_area(poly) for poly in result), [1, 1], atol=1e-10)
result_bounds = sorted(tuple(map(tuple, poly.get_bounds_single())) for poly in result)
assert result_bounds == [((1.0, 1.0), (2.0, 2.0)), ((11.0, 1.0), (12.0, 2.0))]
def test_boolean_difference_preserves_hole_area_via_bridged_polygon() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
result = boolean([outer], [hole], operation='difference')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
assert_allclose(_poly_area(result[0]), 64, atol=1e-10)
def test_boolean_nested_hole_and_island_case() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
outer = Polygon([[0, 0], [10, 0], [10, 10], [0, 10]])
hole = Polygon([[2, 2], [8, 2], [8, 8], [2, 8]])
island = Polygon([[4, 4], [6, 4], [6, 6], [4, 6]])
result = boolean([outer, island], [hole], operation='union')
assert len(result) == 1
assert_allclose(result[0].get_bounds_single(), [[0, 0], [10, 10]], atol=1e-10)
assert_allclose(_poly_area(result[0]), 100, atol=1e-10)
def test_boolean_empty_inputs_follow_set_semantics() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect = Polygon([[1, 1], [3, 1], [3, 3], [1, 3]])
union = boolean([], [rect], operation='union')
assert len(union) == 1
assert_allclose(union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
intersection = boolean([], [rect], operation='intersection')
assert intersection == []
difference = boolean([], [rect], operation='difference')
assert difference == []
xor = boolean([], [rect], operation='xor')
assert len(xor) == 1
assert_allclose(xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_union = boolean([rect], [], operation='union')
assert len(clip_empty_union) == 1
assert_allclose(clip_empty_union[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_intersection = boolean([rect], [], operation='intersection')
assert clip_empty_intersection == []
clip_empty_difference = boolean([rect], [], operation='difference')
assert len(clip_empty_difference) == 1
assert_allclose(clip_empty_difference[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
clip_empty_xor = boolean([rect], [], operation='xor')
assert len(clip_empty_xor) == 1
assert_allclose(clip_empty_xor[0].get_bounds_single(), [[1, 1], [3, 3]], atol=1e-10)
def test_boolean_invalid_inputs_raise_pattern_error() -> None:
pytest.importorskip("pyclipper")
from masque.utils.boolean import boolean
rect = Polygon([[0, 0], [1, 0], [1, 1], [0, 1]])
for bad in (123, object(), [123]):
with pytest.raises(PatternError, match='Unsupported type'):
boolean([rect], bad, operation='intersection')
def test_bridge_holes() -> None:
from masque.utils.boolean import _bridge_holes

View file

@ -106,15 +106,19 @@ def boolean(
) from None
op_map = {
'union': pyclipper.PT_UNION,
'intersection': pyclipper.PT_INTERSECTION,
'difference': pyclipper.PT_DIFFERENCE,
'xor': pyclipper.PT_XOR,
'union': pyclipper.CT_UNION,
'intersection': pyclipper.CT_INTERSECTION,
'difference': pyclipper.CT_DIFFERENCE,
'xor': pyclipper.CT_XOR,
}
def to_vertices(objs: Iterable[Any] | None) -> list[NDArray]:
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)}")
verts = []
for obj in objs:
if hasattr(obj, 'to_polygons'):
@ -140,6 +144,16 @@ def boolean(
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]
return []
if not clip_verts:
if operation == '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)
if clip_verts: