258 lines
7.9 KiB
Python
258 lines
7.9 KiB
Python
from pathlib import Path
|
|
|
|
import numpy
|
|
from numpy.testing import assert_equal, assert_allclose
|
|
from numpy import pi
|
|
import pytest
|
|
|
|
from ..utils import (
|
|
DeferredDict,
|
|
apply_transforms,
|
|
normalize_mirror,
|
|
poly_contains_points,
|
|
remove_colinear_vertices,
|
|
remove_duplicate_vertices,
|
|
rotation_matrix_2d,
|
|
)
|
|
from ..file.utils import tmpfile
|
|
from ..utils.curves import bezier, circular_arc, euler_bend, euler_spiral
|
|
from ..error import PatternError
|
|
|
|
|
|
def test_remove_duplicate_vertices() -> None:
|
|
# Closed path (default)
|
|
v = [[0, 0], [1, 1], [1, 1], [2, 2], [0, 0]]
|
|
v_clean = remove_duplicate_vertices(v, closed_path=True)
|
|
# The last [0,0] is a duplicate of the first [0,0] if closed_path=True
|
|
assert_equal(v_clean, [[0, 0], [1, 1], [2, 2]])
|
|
|
|
# Open path
|
|
v_clean_open = remove_duplicate_vertices(v, closed_path=False)
|
|
assert_equal(v_clean_open, [[0, 0], [1, 1], [2, 2], [0, 0]])
|
|
|
|
|
|
def test_remove_duplicate_vertices_tolerance_defaults_to_exact_match() -> None:
|
|
v = [[0, 0], [1, 1], [1 + 1e-13, 1], [2, 2], [1e-13, 0]]
|
|
|
|
assert_allclose(remove_duplicate_vertices(v, closed_path=True), v, atol=0, rtol=0)
|
|
assert_allclose(
|
|
remove_duplicate_vertices(v, closed_path=True, tolerance=1e-12),
|
|
[[0, 0], [1 + 1e-13, 1], [2, 2]],
|
|
atol=0,
|
|
rtol=0,
|
|
)
|
|
|
|
|
|
def test_remove_duplicate_vertices_rejects_negative_tolerance() -> None:
|
|
with pytest.raises(ValueError, match='non-negative'):
|
|
remove_duplicate_vertices([[0, 0]], tolerance=-1)
|
|
|
|
|
|
def test_remove_colinear_vertices() -> None:
|
|
v = [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [1, 1], [0, 0]]
|
|
v_clean = remove_colinear_vertices(v, closed_path=True)
|
|
# [1, 0] is between [0, 0] and [2, 0]
|
|
# [2, 1] is between [2, 0] and [2, 2]
|
|
# [1, 1] is between [2, 2] and [0, 0]
|
|
assert_equal(v_clean, [[0, 0], [2, 0], [2, 2]])
|
|
|
|
|
|
def test_remove_colinear_vertices_exhaustive() -> None:
|
|
v = [[0, 0], [10, 0], [0, 0]]
|
|
v_clean = remove_colinear_vertices(v, closed_path=False, preserve_uturns=True)
|
|
assert len(v_clean) == 3
|
|
|
|
v_collapsed = remove_colinear_vertices(v, closed_path=False, preserve_uturns=False)
|
|
assert len(v_collapsed) == 2
|
|
|
|
v = [[0, 0], [10, 0], [5, 0]]
|
|
v_clean = remove_colinear_vertices(v, closed_path=True, preserve_uturns=False)
|
|
assert len(v_clean) == 2
|
|
|
|
|
|
def test_poly_contains_points() -> None:
|
|
v = [[0, 0], [10, 0], [10, 10], [0, 10]]
|
|
pts = [[5, 5], [-1, -1], [10, 10], [11, 5]]
|
|
inside = poly_contains_points(v, pts)
|
|
assert_equal(inside, [True, False, True, False])
|
|
|
|
|
|
def test_rotation_matrix_2d() -> None:
|
|
m = rotation_matrix_2d(pi / 2)
|
|
assert_allclose(m, [[0, -1], [1, 0]], atol=1e-10)
|
|
|
|
|
|
def test_rotation_matrix_non_manhattan() -> None:
|
|
# 45 degrees
|
|
m = rotation_matrix_2d(pi / 4)
|
|
s = numpy.sqrt(2) / 2
|
|
assert_allclose(m, [[s, -s], [s, s]], atol=1e-10)
|
|
|
|
|
|
def test_apply_transforms() -> None:
|
|
# cumulative [x_offset, y_offset, rotation (rad), mirror_x (0 or 1)]
|
|
t1 = [10, 20, 0, 0]
|
|
t2 = [[5, 0, 0, 0], [0, 5, 0, 0]]
|
|
combined = apply_transforms(t1, t2)
|
|
assert_equal(combined, [[15, 20, 0, 0, 1], [10, 25, 0, 0, 1]])
|
|
|
|
|
|
def test_apply_transforms_advanced() -> None:
|
|
# Ox4: (x, y, rot, mir)
|
|
# Outer: mirror x (axis 0), then rotate 90 deg CCW
|
|
# apply_transforms logic for mirror uses y *= -1 (which is axis 0 mirror)
|
|
outer = [0, 0, pi / 2, 1]
|
|
|
|
# Inner: (10, 0, 0, 0)
|
|
inner = [10, 0, 0, 0]
|
|
|
|
combined = apply_transforms(outer, inner)
|
|
# 1. mirror inner y if outer mirrored: (10, 0) -> (10, 0)
|
|
# 2. rotate by outer rotation (pi/2): (10, 0) -> (0, 10)
|
|
# 3. add outer offset (0, 0) -> (0, 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:
|
|
with pytest.raises(PatternError, match='one entry per control point'):
|
|
bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1])
|
|
|
|
with pytest.raises(PatternError, match='one entry per control point'):
|
|
bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2, 3])
|
|
|
|
|
|
def test_bezier_accepts_exact_weight_count() -> None:
|
|
samples = bezier([[0, 0], [1, 1]], [0, 0.5, 1], weights=[1, 2])
|
|
assert_allclose(samples, [[0, 0], [2 / 3, 2 / 3], [1, 1]], atol=1e-10)
|
|
|
|
|
|
def _endpoint_tangent(xy: numpy.ndarray) -> float:
|
|
dxy = xy[-1] - xy[-2]
|
|
return numpy.arctan2(dxy[1], dxy[0])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('switchover_angle', 'total_angle'),
|
|
[
|
|
(pi / 8, pi / 4),
|
|
(pi / 8, pi / 2),
|
|
(pi / 4, pi),
|
|
],
|
|
)
|
|
def test_euler_bend_supports_total_angle(switchover_angle: float, total_angle: float) -> None:
|
|
xy = euler_bend(switchover_angle, num_points=2000, total_angle=total_angle)
|
|
|
|
assert_allclose(xy[0], [0, 0], atol=1e-12)
|
|
assert_allclose(_endpoint_tangent(xy), -total_angle, atol=1e-3)
|
|
|
|
|
|
def test_euler_bend_180_degrees_with_90_degree_circular_middle() -> None:
|
|
xy = euler_bend(pi / 4, num_points=2000, total_angle=pi)
|
|
|
|
assert_allclose(_endpoint_tangent(xy), -pi, atol=1e-3)
|
|
assert abs(xy[-1][0]) < 1e-3
|
|
assert xy[-1][1] < 0
|
|
|
|
|
|
def test_euler_bend_rejects_too_large_switchover_angle() -> None:
|
|
with pytest.raises(PatternError, match='total_angle / 2'):
|
|
euler_bend(pi / 2, total_angle=pi / 2)
|
|
|
|
|
|
def test_euler_spiral_and_circular_arc_helpers_match_endpoint_tangent() -> None:
|
|
xy_spiral = euler_spiral(pi / 4, num_points=1000)
|
|
assert_allclose(_endpoint_tangent(xy_spiral), -pi / 4, atol=1e-3)
|
|
|
|
xy_arc = circular_arc(
|
|
10,
|
|
pi / 2,
|
|
num_points=1000,
|
|
start_angle=_endpoint_tangent(xy_spiral),
|
|
start=xy_spiral[-1],
|
|
)
|
|
assert_allclose(_endpoint_tangent(xy_arc), -3 * pi / 4, atol=2e-3)
|
|
|
|
|
|
def test_deferred_dict_accessors_resolve_values_once() -> None:
|
|
calls = 0
|
|
|
|
def make_value() -> int:
|
|
nonlocal calls
|
|
calls += 1
|
|
return 7
|
|
|
|
deferred = DeferredDict[str, int]()
|
|
deferred["x"] = make_value
|
|
|
|
assert deferred.get("missing", 9) == 9
|
|
assert deferred.get("x") == 7
|
|
assert list(deferred.values()) == [7]
|
|
assert list(deferred.items()) == [("x", 7)]
|
|
assert calls == 1
|
|
|
|
|
|
def test_deferred_dict_mutating_accessors_preserve_value_semantics() -> None:
|
|
calls = 0
|
|
|
|
def make_value() -> int:
|
|
nonlocal calls
|
|
calls += 1
|
|
return 7
|
|
|
|
deferred = DeferredDict[str, int]()
|
|
|
|
assert deferred.setdefault("x", 5) == 5
|
|
assert deferred["x"] == 5
|
|
|
|
assert deferred.setdefault("y", make_value) == 7
|
|
assert deferred["y"] == 7
|
|
assert calls == 1
|
|
|
|
copy_deferred = deferred.copy()
|
|
assert isinstance(copy_deferred, DeferredDict)
|
|
assert copy_deferred["x"] == 5
|
|
assert copy_deferred["y"] == 7
|
|
assert calls == 1
|
|
|
|
assert deferred.pop("x") == 5
|
|
assert deferred.pop("missing", 9) == 9
|
|
assert deferred.popitem() == ("y", 7)
|
|
|
|
|
|
def test_tmpfile_cleans_up_on_exception(tmp_path: Path) -> None:
|
|
target = tmp_path / "out.txt"
|
|
temp_path = None
|
|
|
|
try:
|
|
with tmpfile(target) as stream:
|
|
temp_path = Path(stream.name)
|
|
stream.write(b"hello")
|
|
raise RuntimeError("boom")
|
|
except RuntimeError:
|
|
pass
|
|
|
|
assert temp_path is not None
|
|
assert not target.exists()
|
|
assert not temp_path.exists()
|