[Arc] add angle_ref to enable focus-based arcs
This commit is contained in:
parent
e4a52b2c90
commit
51c7fa9add
3 changed files with 204 additions and 26 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
import copy
|
import copy
|
||||||
import functools
|
import functools
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
import numpy
|
import numpy
|
||||||
from numpy import pi
|
from numpy import pi
|
||||||
|
|
@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
|
||||||
from ..traits import PositionableImpl
|
from ..traits import PositionableImpl
|
||||||
|
|
||||||
|
|
||||||
|
@functools.total_ordering
|
||||||
|
class ArcAngleRef(Enum):
|
||||||
|
Center = 'center'
|
||||||
|
FocusPos = 'focus_pos'
|
||||||
|
FocusNeg = 'focus_neg'
|
||||||
|
|
||||||
|
def __lt__(self, other: Any) -> bool:
|
||||||
|
if self.__class__ is not other.__class__:
|
||||||
|
return self.__class__.__name__ < other.__class__.__name__
|
||||||
|
order = {
|
||||||
|
ArcAngleRef.Center: 0,
|
||||||
|
ArcAngleRef.FocusPos: 1,
|
||||||
|
ArcAngleRef.FocusNeg: 2,
|
||||||
|
}
|
||||||
|
return order[self] < order[other]
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
class Arc(PositionableImpl, Shape):
|
class Arc(PositionableImpl, Shape):
|
||||||
"""
|
"""
|
||||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
By default the rays exit from its center, but they can optionally exit from one of the
|
||||||
|
foci of the nominal ellipse. It has a position, two radii, a start and stop angle,
|
||||||
|
a rotation, and a width.
|
||||||
|
|
||||||
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
The radii define an ellipse; the ring is formed with radii +/- width/2.
|
||||||
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
The rotation gives the angle from x-axis, counterclockwise, to the first (x) radius.
|
||||||
The start and stop angle are measured counterclockwise from the first (x) radius.
|
The start and stop angle are measured counterclockwise from the first (x) radius.
|
||||||
"""
|
"""
|
||||||
__slots__ = (
|
__slots__ = (
|
||||||
'_radii', '_angles', '_width', '_rotation',
|
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||||
# Inherited
|
# Inherited
|
||||||
'_offset', '_repetition', '_annotations',
|
'_offset', '_repetition', '_annotations',
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape):
|
||||||
_width: float
|
_width: float
|
||||||
""" Width of the arc """
|
""" Width of the arc """
|
||||||
|
|
||||||
|
_angle_ref: ArcAngleRef
|
||||||
|
""" Origin used by start/stop rays """
|
||||||
|
|
||||||
|
AngleRef = ArcAngleRef
|
||||||
|
|
||||||
# radius properties
|
# radius properties
|
||||||
@property
|
@property
|
||||||
def radii(self) -> NDArray[numpy.float64]:
|
def radii(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape):
|
||||||
def stop_angle(self, val: float) -> None:
|
def stop_angle(self, val: float) -> None:
|
||||||
self.angles = (self.angles[0], val)
|
self.angles = (self.angles[0], val)
|
||||||
|
|
||||||
|
# Angle reference property
|
||||||
|
@property
|
||||||
|
def angle_ref(self) -> ArcAngleRef:
|
||||||
|
"""
|
||||||
|
Origin used to interpret start and stop angle rays.
|
||||||
|
"""
|
||||||
|
return self._angle_ref
|
||||||
|
|
||||||
|
@angle_ref.setter
|
||||||
|
def angle_ref(self, val: ArcAngleRef | str) -> None:
|
||||||
|
self._angle_ref = ArcAngleRef(val)
|
||||||
|
|
||||||
# Rotation property
|
# Rotation property
|
||||||
@property
|
@property
|
||||||
def rotation(self) -> float:
|
def rotation(self) -> float:
|
||||||
|
|
@ -159,6 +196,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
rotation: float = 0,
|
rotation: float = 0,
|
||||||
repetition: Repetition | None = None,
|
repetition: Repetition | None = None,
|
||||||
annotations: annotations_t = None,
|
annotations: annotations_t = None,
|
||||||
|
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||||
raw: bool = False,
|
raw: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if raw:
|
if raw:
|
||||||
|
|
@ -170,6 +208,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
self._width = width
|
self._width = width
|
||||||
self._offset = offset
|
self._offset = offset
|
||||||
self._rotation = rotation
|
self._rotation = rotation
|
||||||
|
self._angle_ref = ArcAngleRef(angle_ref)
|
||||||
self._repetition = repetition
|
self._repetition = repetition
|
||||||
self._annotations = annotations
|
self._annotations = annotations
|
||||||
else:
|
else:
|
||||||
|
|
@ -178,6 +217,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
self.width = width
|
self.width = width
|
||||||
self.offset = offset
|
self.offset = offset
|
||||||
self.rotation = rotation
|
self.rotation = rotation
|
||||||
|
self.angle_ref = angle_ref
|
||||||
self.repetition = repetition
|
self.repetition = repetition
|
||||||
self.annotations = annotations
|
self.annotations = annotations
|
||||||
|
|
||||||
|
|
@ -199,6 +239,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
and numpy.array_equal(self.angles, other.angles)
|
and numpy.array_equal(self.angles, other.angles)
|
||||||
and self.width == other.width
|
and self.width == other.width
|
||||||
and self.rotation == other.rotation
|
and self.rotation == other.rotation
|
||||||
|
and self.angle_ref == other.angle_ref
|
||||||
and self.repetition == other.repetition
|
and self.repetition == other.repetition
|
||||||
and annotations_eq(self.annotations, other.annotations)
|
and annotations_eq(self.annotations, other.annotations)
|
||||||
)
|
)
|
||||||
|
|
@ -215,6 +256,8 @@ class Arc(PositionableImpl, Shape):
|
||||||
return tuple(self.radii) < tuple(other.radii)
|
return tuple(self.radii) < tuple(other.radii)
|
||||||
if not numpy.array_equal(self.angles, other.angles):
|
if not numpy.array_equal(self.angles, other.angles):
|
||||||
return tuple(self.angles) < tuple(other.angles)
|
return tuple(self.angles) < tuple(other.angles)
|
||||||
|
if self.angle_ref != other.angle_ref:
|
||||||
|
return self.angle_ref < other.angle_ref
|
||||||
if not numpy.array_equal(self.offset, other.offset):
|
if not numpy.array_equal(self.offset, other.offset):
|
||||||
return tuple(self.offset) < tuple(other.offset)
|
return tuple(self.offset) < tuple(other.offset)
|
||||||
if self.rotation != other.rotation:
|
if self.rotation != other.rotation:
|
||||||
|
|
@ -370,6 +413,11 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def mirror(self, axis: int = 0) -> 'Arc':
|
def mirror(self, axis: int = 0) -> 'Arc':
|
||||||
|
if self.angle_ref != ArcAngleRef.Center:
|
||||||
|
x_major = self.radius_x > self.radius_y
|
||||||
|
y_major = self.radius_y > self.radius_x
|
||||||
|
if (axis == 0 and y_major) or (axis == 1 and x_major):
|
||||||
|
self._swap_focus_ref()
|
||||||
self.rotation *= -1
|
self.rotation *= -1
|
||||||
self.rotation += axis * pi
|
self.rotation += axis * pi
|
||||||
self.angles *= -1
|
self.angles *= -1
|
||||||
|
|
@ -381,6 +429,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||||
|
angle_ref = self.angle_ref
|
||||||
if self.radius_x < self.radius_y:
|
if self.radius_x < self.radius_y:
|
||||||
radii = self.radii / self.radius_x
|
radii = self.radii / self.radius_x
|
||||||
scale = self.radius_x
|
scale = self.radius_x
|
||||||
|
|
@ -391,23 +440,26 @@ class Arc(PositionableImpl, Shape):
|
||||||
scale = self.radius_y
|
scale = self.radius_y
|
||||||
rotation = self.rotation + pi / 2
|
rotation = self.rotation + pi / 2
|
||||||
angles = self.angles - pi / 2
|
angles = self.angles - pi / 2
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
delta_angle = angles[1] - angles[0]
|
delta_angle = angles[1] - angles[0]
|
||||||
start_angle = angles[0] % (2 * pi)
|
start_angle = angles[0] % (2 * pi)
|
||||||
if start_angle >= pi:
|
if start_angle >= pi:
|
||||||
start_angle -= pi
|
start_angle -= pi
|
||||||
rotation += pi
|
rotation += pi
|
||||||
|
angle_ref = _swapped_focus_ref(angle_ref)
|
||||||
|
|
||||||
norm_angles = (start_angle, start_angle + delta_angle)
|
norm_angles = (start_angle, start_angle + delta_angle)
|
||||||
rotation %= 2 * pi
|
rotation %= 2 * pi
|
||||||
width = self.width
|
width = self.width
|
||||||
|
|
||||||
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value),
|
return ((type(self), tuple(radii.tolist()), norm_angles, width / norm_value, angle_ref.value),
|
||||||
(self.offset, scale / norm_value, rotation, False),
|
(self.offset, scale / norm_value, rotation, False),
|
||||||
lambda: Arc(
|
lambda: Arc(
|
||||||
radii=radii * norm_value,
|
radii=radii * norm_value,
|
||||||
angles=norm_angles,
|
angles=norm_angles,
|
||||||
width=width * norm_value,
|
width=width * norm_value,
|
||||||
|
angle_ref=angle_ref,
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
def get_cap_edges(self) -> NDArray[numpy.float64]:
|
||||||
|
|
@ -418,27 +470,16 @@ class Arc(PositionableImpl, Shape):
|
||||||
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
[[x2, y2], [x3, y3]]], would create this arc from its corresponding ellipse.
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
a_ranges = self._angles_to_parameters()
|
||||||
|
|
||||||
mins = []
|
cuts = []
|
||||||
maxs = []
|
for index in range(2):
|
||||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
edge = []
|
||||||
wh = sgn * self.width / 2
|
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||||
rx = self.radius_x + wh
|
wh = sgn * self.width / 2
|
||||||
ry = self.radius_y + wh
|
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||||
|
cuts.append(edge)
|
||||||
sin_r = numpy.sin(self.rotation)
|
return numpy.array(cuts) + self.offset
|
||||||
cos_r = numpy.cos(self.rotation)
|
|
||||||
sin_a = numpy.sin(aa)
|
|
||||||
cos_a = numpy.cos(aa)
|
|
||||||
|
|
||||||
# arc endpoints
|
|
||||||
xn, xp = sorted(rx * cos_r * cos_a - ry * sin_r * sin_a)
|
|
||||||
yn, yp = sorted(rx * sin_r * cos_a + ry * cos_r * sin_a)
|
|
||||||
|
|
||||||
mins.append([xn, yn])
|
|
||||||
maxs.append([xp, yp])
|
|
||||||
return numpy.array([mins, maxs]) + self.offset
|
|
||||||
|
|
||||||
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -459,7 +500,7 @@ class Arc(PositionableImpl, Shape):
|
||||||
rx = self.radius_x + wh
|
rx = self.radius_x + wh
|
||||||
ry = self.radius_y + wh
|
ry = self.radius_y + wh
|
||||||
|
|
||||||
a0, a1 = (numpy.arctan2(rx * numpy.sin(ai), ry * numpy.cos(ai)) for ai in self.angles)
|
a0, a1 = (self._angle_to_parameter(ai, rx, ry) for ai in self.angles)
|
||||||
sign = numpy.sign(d_angle)
|
sign = numpy.sign(d_angle)
|
||||||
if sign != numpy.sign(a1 - a0):
|
if sign != numpy.sign(a1 - a0):
|
||||||
a1 += sign * 2 * pi
|
a1 += sign * 2 * pi
|
||||||
|
|
@ -467,9 +508,93 @@ class Arc(PositionableImpl, Shape):
|
||||||
aa.append((a0, a1))
|
aa.append((a0, a1))
|
||||||
return numpy.array(aa, dtype=float)
|
return numpy.array(aa, dtype=float)
|
||||||
|
|
||||||
|
def _angle_to_parameter(self, angle: float, rx: float, ry: float) -> float:
|
||||||
|
"""
|
||||||
|
Convert an angle-reference ray to the ellipse parameter for one boundary edge.
|
||||||
|
|
||||||
|
Center-referenced arcs convert the ray angle from polar coordinates about the origin.
|
||||||
|
Focus-referenced arcs solve the forward ray/ellipse intersection from the selected
|
||||||
|
nominal focus and return the parameter `t` for `[rx*cos(t), ry*sin(t)]`.
|
||||||
|
"""
|
||||||
|
if self.angle_ref == ArcAngleRef.Center:
|
||||||
|
return numpy.arctan2(rx * numpy.sin(angle), ry * numpy.cos(angle))
|
||||||
|
|
||||||
|
focus = self._focus_point()
|
||||||
|
if rx <= 0 or ry <= 0:
|
||||||
|
raise PatternError('Focus-referenced arc boundary radii must be positive')
|
||||||
|
|
||||||
|
fx, fy = focus
|
||||||
|
origin_position = fx * fx / (rx * rx) + fy * fy / (ry * ry)
|
||||||
|
if origin_position >= 1:
|
||||||
|
raise PatternError('Focus-referenced arc ray origin must be inside both arc boundary ellipses')
|
||||||
|
|
||||||
|
dx = numpy.cos(angle)
|
||||||
|
dy = numpy.sin(angle)
|
||||||
|
aa = dx * dx / (rx * rx) + dy * dy / (ry * ry)
|
||||||
|
bb = 2 * (fx * dx / (rx * rx) + fy * dy / (ry * ry))
|
||||||
|
cc = origin_position - 1
|
||||||
|
determinant = bb * bb - 4 * aa * cc
|
||||||
|
if determinant < 0:
|
||||||
|
raise PatternError('Focus-referenced arc ray does not intersect boundary ellipse')
|
||||||
|
|
||||||
|
roots = numpy.array((
|
||||||
|
(-bb - numpy.sqrt(determinant)) / (2 * aa),
|
||||||
|
(-bb + numpy.sqrt(determinant)) / (2 * aa),
|
||||||
|
))
|
||||||
|
positive_roots = roots[roots > 0]
|
||||||
|
if positive_roots.size != 1:
|
||||||
|
raise PatternError('Focus-referenced arc ray must have exactly one forward boundary intersection')
|
||||||
|
|
||||||
|
point = focus + positive_roots[0] * numpy.array((dx, dy))
|
||||||
|
return numpy.arctan2(point[1] / ry, point[0] / rx)
|
||||||
|
|
||||||
|
def _focus_point(self) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return the selected nominal focus in the arc's unrotated local coordinates.
|
||||||
|
|
||||||
|
`FocusPos` and `FocusNeg` select opposite directions along the major axis. Circles
|
||||||
|
have coincident foci, so both focus modes intentionally collapse to the center.
|
||||||
|
"""
|
||||||
|
if self.angle_ref == ArcAngleRef.Center or self.radius_x == self.radius_y:
|
||||||
|
return numpy.zeros(2)
|
||||||
|
|
||||||
|
sign = 1 if self.angle_ref == ArcAngleRef.FocusPos else -1
|
||||||
|
if self.radius_x > self.radius_y:
|
||||||
|
return numpy.array((sign * numpy.sqrt(self.radius_x * self.radius_x - self.radius_y * self.radius_y), 0.0))
|
||||||
|
return numpy.array((0.0, sign * numpy.sqrt(self.radius_y * self.radius_y - self.radius_x * self.radius_x)))
|
||||||
|
|
||||||
|
def _point_on_edge(self, rx: float, ry: float, tt: float) -> NDArray[numpy.float64]:
|
||||||
|
"""
|
||||||
|
Return a rotated local-space point on a boundary ellipse, before applying offset.
|
||||||
|
"""
|
||||||
|
sin_r = numpy.sin(self.rotation)
|
||||||
|
cos_r = numpy.cos(self.rotation)
|
||||||
|
return numpy.array((
|
||||||
|
rx * numpy.cos(tt) * cos_r - ry * numpy.sin(tt) * sin_r,
|
||||||
|
rx * numpy.cos(tt) * sin_r + ry * numpy.sin(tt) * cos_r,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _swap_focus_ref(self) -> None:
|
||||||
|
"""
|
||||||
|
Swap `focus_pos` and `focus_neg`, leaving center-referenced arcs unchanged.
|
||||||
|
"""
|
||||||
|
self.angle_ref = _swapped_focus_ref(self.angle_ref)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||||
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
rotation = f' r°{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else ''
|
||||||
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}>'
|
angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else ''
|
||||||
|
return f'<Arc o{self.offset} r{self.radii}{angles} w{self.width:g}{rotation}{angle_ref}>'
|
||||||
|
|
||||||
|
|
||||||
|
def _swapped_focus_ref(angle_ref: ArcAngleRef) -> ArcAngleRef:
|
||||||
|
"""
|
||||||
|
Return the opposite focus reference, or center for center-referenced arcs.
|
||||||
|
"""
|
||||||
|
if angle_ref == ArcAngleRef.FocusPos:
|
||||||
|
return ArcAngleRef.FocusNeg
|
||||||
|
if angle_ref == ArcAngleRef.FocusNeg:
|
||||||
|
return ArcAngleRef.FocusPos
|
||||||
|
return angle_ref
|
||||||
|
|
||||||
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
_array2x2_t = tuple[tuple[float, float], tuple[float, float]]
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,14 @@ def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_focus_arc_bounds_match_polygonized_geometry() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, rotation=pi / 4,
|
||||||
|
offset=(100, 200), angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
bounds = arc.get_bounds_single()
|
||||||
|
poly_bounds = arc.to_polygons(num_vertices=8192)[0].get_bounds_single()
|
||||||
|
assert_allclose(bounds, poly_bounds, atol=1e-3)
|
||||||
|
|
||||||
|
|
||||||
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
def test_curve_polygonizers_clamp_large_max_arclen() -> None:
|
||||||
for shape in (
|
for shape in (
|
||||||
Circle(radius=10),
|
Circle(radius=10),
|
||||||
|
|
@ -128,6 +136,19 @@ def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
||||||
arc.to_polygons(num_vertices=24)
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_arc_rejects_focus_outside_inner_boundary() -> None:
|
||||||
|
arc = Arc(radii=(10, 5), angles=(0, 1), width=6, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
with pytest.raises(PatternError, match='inside both arc boundary ellipses'):
|
||||||
|
arc.to_polygons(num_vertices=24)
|
||||||
|
|
||||||
|
|
||||||
|
def test_focus_arc_max_arclen_limits_segments() -> None:
|
||||||
|
arc = Arc(radii=(10, 6), angles=(-0.25, 1.1), width=1, angle_ref=Arc.AngleRef.FocusNeg)
|
||||||
|
v = arc.to_polygons(max_arclen=2)[0].vertices
|
||||||
|
dist = numpy.sqrt(numpy.sum(numpy.diff(v, axis=0, append=v[:1]) ** 2, axis=1))
|
||||||
|
assert numpy.all(dist <= 2.000001)
|
||||||
|
|
||||||
|
|
||||||
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
def test_ellipse_integer_radii_scale_cleanly() -> None:
|
||||||
ellipse = Ellipse(radii=(10, 20))
|
ellipse = Ellipse(radii=(10, 20))
|
||||||
ellipse.scale_by(0.5)
|
ellipse.scale_by(0.5)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,30 @@ def test_arc_to_polygons() -> None:
|
||||||
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
assert_allclose(bounds, [[0, 0], [11, 11]], atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_focus_to_polygons() -> None:
|
||||||
|
a = Arc(radii=(10, 6), angles=(-0.4, 0.7), width=1, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
polys = a.to_polygons(num_vertices=32)
|
||||||
|
assert len(polys) == 1
|
||||||
|
|
||||||
|
focus = numpy.array([8.0, 0.0])
|
||||||
|
cuts = a.get_cap_edges()
|
||||||
|
for angle, cut in zip(a.angles, cuts, strict=True):
|
||||||
|
direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
|
||||||
|
for point in cut:
|
||||||
|
delta = point - focus
|
||||||
|
assert_allclose(direction[0] * delta[1] - direction[1] * delta[0], 0, atol=1e-10)
|
||||||
|
assert numpy.dot(direction, delta) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_arc_circle_focus_matches_center() -> None:
|
||||||
|
center = Arc(radii=(10, 10), angles=(0, pi / 2), width=2)
|
||||||
|
focus = Arc(radii=(10, 10), angles=(0, pi / 2), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
|
||||||
|
assert_allclose(focus.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
center.to_polygons(num_vertices=32)[0].vertices,
|
||||||
|
atol=1e-10)
|
||||||
|
|
||||||
|
|
||||||
def test_shape_mirror() -> None:
|
def test_shape_mirror() -> None:
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||||
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
e.mirror(0) # Mirror across x axis (axis 0): in-place relative to offset
|
||||||
|
|
@ -91,6 +115,14 @@ def test_shape_mirror() -> None:
|
||||||
# For Arc, mirror(0) negates rotation and angles
|
# For Arc, mirror(0) negates rotation and angles
|
||||||
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
assert_allclose(a.angles, [0, -pi / 4], atol=1e-10)
|
||||||
|
|
||||||
|
a = Arc(radii=(10, 5), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
a.mirror(1)
|
||||||
|
assert a.angle_ref == Arc.AngleRef.FocusNeg
|
||||||
|
|
||||||
|
a = Arc(radii=(5, 10), angles=(0, pi / 4), width=2, angle_ref=Arc.AngleRef.FocusPos)
|
||||||
|
a.mirror(0)
|
||||||
|
assert a.angle_ref == Arc.AngleRef.FocusNeg
|
||||||
|
|
||||||
|
|
||||||
def test_shape_flip_across() -> None:
|
def test_shape_flip_across() -> None:
|
||||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue