diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 4de6dbb..5680694 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -84,7 +84,7 @@ def ell( raise BuildError('Empty port list passed to `ell()`') if ccw is None: - if spacing is not None and not numpy.allclose(spacing, 0): + if spacing is not None and not numpy.isclose(spacing, 0): raise BuildError('Spacing must be 0 or None when ccw=None') spacing = 0 elif spacing is None: @@ -132,17 +132,8 @@ def ell( if spacing is None: ch_offsets = numpy.zeros_like(y_order) else: - spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) steps = numpy.zeros_like(y_order) - if spacing_arr.size == 1: - steps[1:] = spacing_arr[0] - elif spacing_arr.size == len(ports) - 1: - steps[1:] = spacing_arr - else: - raise BuildError( - f'spacing must be scalar or have length {len(ports) - 1} for {len(ports)} ports; ' - f'got length {spacing_arr.size}' - ) + steps[1:] = spacing ch_offsets = numpy.cumsum(steps)[y_ind] x_start = rot_offsets[:, 0] diff --git a/masque/test/test_boolean.py b/masque/test/test_boolean.py index 1e44e4d..bf5d33d 100644 --- a/masque/test/test_boolean.py +++ b/masque/test/test_boolean.py @@ -6,14 +6,6 @@ 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() @@ -77,176 +69,6 @@ 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 diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 309dab6..0ad6e80 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -1,11 +1,7 @@ -import numpy -import pytest from numpy.testing import assert_equal, assert_allclose from numpy import pi from ..builder import Builder -from ..builder.utils import ell -from ..error import BuildError from ..library import Library from ..pattern import Pattern from ..ports import Port @@ -133,31 +129,3 @@ def test_dead_plug_best_effort() -> None: # P2 rot pi + transform rot -pi = 0 assert b.ports['B'].rotation is not None assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) - - -def test_ell_validates_spacing_length() -> None: - ports = { - 'A': Port((0, 0), 0), - 'B': Port((0, 1), 0), - 'C': Port((0, 2), 0), - } - - with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): - ell(ports, True, 'min_extension', 5, spacing=[1, 2, 3]) - - with pytest.raises(BuildError, match='spacing must be scalar or have length 2'): - ell(ports, True, 'min_extension', 5, spacing=[]) - - -def test_ell_handles_array_spacing_when_ccw_none() -> None: - ports = { - 'A': Port((0, 0), 0), - 'B': Port((0, 1), 0), - } - - scalar = ell(ports, None, 'min_extension', 5, spacing=0) - array_zero = ell(ports, None, 'min_extension', 5, spacing=numpy.array([0, 0])) - assert scalar == array_zero - - with pytest.raises(BuildError, match='Spacing must be 0 or None'): - ell(ports, None, 'min_extension', 5, spacing=numpy.array([1, 0])) diff --git a/masque/test/test_utils.py b/masque/test/test_utils.py index ddab9cd..0511a24 100644 --- a/masque/test/test_utils.py +++ b/masque/test/test_utils.py @@ -5,7 +5,7 @@ from numpy.testing import assert_equal, assert_allclose from numpy import pi import pytest -from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, normalize_mirror, DeferredDict +from ..utils import remove_duplicate_vertices, remove_colinear_vertices, poly_contains_points, rotation_matrix_2d, apply_transforms, DeferredDict from ..file.utils import tmpfile from ..utils.curves import bezier from ..error import PatternError @@ -94,28 +94,6 @@ def test_apply_transforms_advanced() -> None: assert_allclose(combined[0], [0, 10, pi / 2, 1, 1], atol=1e-10) -def test_apply_transforms_empty_inputs() -> None: - empty_outer = apply_transforms(numpy.empty((0, 5)), [[1, 2, 0, 0, 1]]) - assert empty_outer.shape == (0, 5) - - empty_inner = apply_transforms([[1, 2, 0, 0, 1]], numpy.empty((0, 5))) - assert empty_inner.shape == (0, 5) - - both_empty_tensor = apply_transforms(numpy.empty((0, 5)), numpy.empty((0, 5)), tensor=True) - assert both_empty_tensor.shape == (0, 0, 5) - - -def test_normalize_mirror_validates_length() -> None: - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror(()) - - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror((True,)) - - with pytest.raises(ValueError, match='2-item sequence'): - normalize_mirror((True, False, True)) - - def test_bezier_validates_weight_length() -> None: with pytest.raises(PatternError, match='one entry per control point'): bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1]) diff --git a/masque/utils/boolean.py b/masque/utils/boolean.py index 5181fc5..78c24e2 100644 --- a/masque/utils/boolean.py +++ b/masque/utils/boolean.py @@ -106,19 +106,15 @@ def boolean( ) from None op_map = { - 'union': pyclipper.CT_UNION, - 'intersection': pyclipper.CT_INTERSECTION, - 'difference': pyclipper.CT_DIFFERENCE, - 'xor': pyclipper.CT_XOR, + 'union': pyclipper.PT_UNION, + 'intersection': pyclipper.PT_INTERSECTION, + 'difference': pyclipper.PT_DIFFERENCE, + 'xor': pyclipper.PT_XOR, } - def to_vertices(objs: Iterable[Any] | Any | None) -> list[NDArray]: + def to_vertices(objs: Iterable[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'): @@ -144,16 +140,6 @@ 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: diff --git a/masque/utils/transform.py b/masque/utils/transform.py index 7b39122..ed0453b 100644 --- a/masque/utils/transform.py +++ b/masque/utils/transform.py @@ -50,10 +50,7 @@ def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]: `angle_to_rotate` in radians """ - if len(mirrored) != 2: - raise ValueError(f'mirrored must be a 2-item sequence, got length {len(mirrored)}') - - mirrored_x, mirrored_y = (bool(value) for value in mirrored) + mirrored_x, mirrored_y = mirrored mirror_x = (mirrored_x != mirrored_y) # XOR angle = numpy.pi if mirrored_y else 0 return mirror_x, angle @@ -114,11 +111,6 @@ def apply_transforms( if inner.shape[1] == 4: inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) - if outer.shape[0] == 0 or inner.shape[0] == 0: - if tensor: - return numpy.empty((outer.shape[0], inner.shape[0], 5)) - return numpy.empty((0, 5)) - # If mirrored, flip y's xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir[outer[:, 3].astype(bool), :, 1] *= -1