Compare commits

..

No commits in common. "d366db5a62f59c1c126bfc317f3acdbc5780757b" and "75a91147098db84588932ae843e33f9b71c986ec" have entirely different histories.

6 changed files with 9 additions and 272 deletions

View file

@ -84,7 +84,7 @@ def ell(
raise BuildError('Empty port list passed to `ell()`') raise BuildError('Empty port list passed to `ell()`')
if ccw is None: 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') raise BuildError('Spacing must be 0 or None when ccw=None')
spacing = 0 spacing = 0
elif spacing is None: elif spacing is None:
@ -132,17 +132,8 @@ def ell(
if spacing is None: if spacing is None:
ch_offsets = numpy.zeros_like(y_order) ch_offsets = numpy.zeros_like(y_order)
else: else:
spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1)
steps = numpy.zeros_like(y_order) steps = numpy.zeros_like(y_order)
if spacing_arr.size == 1: steps[1:] = spacing
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}'
)
ch_offsets = numpy.cumsum(steps)[y_ind] ch_offsets = numpy.cumsum(steps)[y_ind]
x_start = rot_offsets[:, 0] x_start = rot_offsets[:, 0]

View file

@ -6,14 +6,6 @@ from masque.pattern import Pattern
from masque.shapes.polygon import Polygon from masque.shapes.polygon import Polygon
from masque.repetition import Grid from masque.repetition import Grid
from masque.library import Library 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: def test_layer_as_polygons_basic() -> None:
pat = Pattern() pat = Pattern()
@ -77,176 +69,6 @@ def test_polygon_boolean_shortcut() -> None:
with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"): with pytest.raises(ImportError, match="Boolean operations require 'pyclipper'"):
poly.boolean(poly) 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: def test_bridge_holes() -> None:
from masque.utils.boolean import _bridge_holes from masque.utils.boolean import _bridge_holes

View file

@ -1,11 +1,7 @@
import numpy
import pytest
from numpy.testing import assert_equal, assert_allclose from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
from ..builder import Builder from ..builder import Builder
from ..builder.utils import ell
from ..error import BuildError
from ..library import Library from ..library import Library
from ..pattern import Pattern from ..pattern import Pattern
from ..ports import Port from ..ports import Port
@ -133,31 +129,3 @@ def test_dead_plug_best_effort() -> None:
# P2 rot pi + transform rot -pi = 0 # P2 rot pi + transform rot -pi = 0
assert b.ports['B'].rotation is not None assert b.ports['B'].rotation is not None
assert_allclose(b.ports['B'].rotation, 0, atol=1e-10) 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]))

View file

@ -5,7 +5,7 @@ from numpy.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
import pytest 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 ..file.utils import tmpfile
from ..utils.curves import bezier from ..utils.curves import bezier
from ..error import PatternError 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) 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: def test_bezier_validates_weight_length() -> None:
with pytest.raises(PatternError, match='one entry per control point'): with pytest.raises(PatternError, match='one entry per control point'):
bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1]) bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1])

View file

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

View file

@ -50,10 +50,7 @@ def normalize_mirror(mirrored: Sequence[bool]) -> tuple[bool, float]:
`angle_to_rotate` in radians `angle_to_rotate` in radians
""" """
if len(mirrored) != 2: mirrored_x, mirrored_y = mirrored
raise ValueError(f'mirrored must be a 2-item sequence, got length {len(mirrored)}')
mirrored_x, mirrored_y = (bool(value) for value in mirrored)
mirror_x = (mirrored_x != mirrored_y) # XOR mirror_x = (mirrored_x != mirrored_y) # XOR
angle = numpy.pi if mirrored_y else 0 angle = numpy.pi if mirrored_y else 0
return mirror_x, angle return mirror_x, angle
@ -114,11 +111,6 @@ def apply_transforms(
if inner.shape[1] == 4: if inner.shape[1] == 4:
inner = numpy.pad(inner, ((0, 0), (0, 1)), constant_values=1.0) 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 # If mirrored, flip y's
xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm xy_mir = numpy.tile(inner[:, :2], (outer.shape[0], 1, 1)) # dims are outer, inner, xyrm
xy_mir[outer[:, 3].astype(bool), :, 1] *= -1 xy_mir[outer[:, 3].astype(bool), :, 1] *= -1