Compare commits
No commits in common. "d366db5a62f59c1c126bfc317f3acdbc5780757b" and "75a91147098db84588932ae843e33f9b71c986ec" have entirely different histories.
d366db5a62
...
75a9114709
6 changed files with 9 additions and 272 deletions
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue