[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
|
||||
import copy
|
||||
import functools
|
||||
from enum import Enum
|
||||
|
||||
import numpy
|
||||
from numpy import pi
|
||||
|
|
@ -13,18 +14,37 @@ from ..utils import is_scalar, annotations_t, annotations_lt, annotations_eq, re
|
|||
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
|
||||
class Arc(PositionableImpl, Shape):
|
||||
"""
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays which exit from its
|
||||
center. It has a position, two radii, a start and stop angle, a rotation, and a width.
|
||||
An elliptical arc, formed by cutting off an elliptical ring with two rays.
|
||||
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 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.
|
||||
"""
|
||||
__slots__ = (
|
||||
'_radii', '_angles', '_width', '_rotation',
|
||||
'_radii', '_angles', '_width', '_rotation', '_angle_ref',
|
||||
# Inherited
|
||||
'_offset', '_repetition', '_annotations',
|
||||
)
|
||||
|
|
@ -41,6 +61,11 @@ class Arc(PositionableImpl, Shape):
|
|||
_width: float
|
||||
""" Width of the arc """
|
||||
|
||||
_angle_ref: ArcAngleRef
|
||||
""" Origin used by start/stop rays """
|
||||
|
||||
AngleRef = ArcAngleRef
|
||||
|
||||
# radius properties
|
||||
@property
|
||||
def radii(self) -> NDArray[numpy.float64]:
|
||||
|
|
@ -113,6 +138,18 @@ class Arc(PositionableImpl, Shape):
|
|||
def stop_angle(self, val: float) -> None:
|
||||
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
|
||||
@property
|
||||
def rotation(self) -> float:
|
||||
|
|
@ -159,6 +196,7 @@ class Arc(PositionableImpl, Shape):
|
|||
rotation: float = 0,
|
||||
repetition: Repetition | None = None,
|
||||
annotations: annotations_t = None,
|
||||
angle_ref: ArcAngleRef | str = ArcAngleRef.Center,
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
if raw:
|
||||
|
|
@ -170,6 +208,7 @@ class Arc(PositionableImpl, Shape):
|
|||
self._width = width
|
||||
self._offset = offset
|
||||
self._rotation = rotation
|
||||
self._angle_ref = ArcAngleRef(angle_ref)
|
||||
self._repetition = repetition
|
||||
self._annotations = annotations
|
||||
else:
|
||||
|
|
@ -178,6 +217,7 @@ class Arc(PositionableImpl, Shape):
|
|||
self.width = width
|
||||
self.offset = offset
|
||||
self.rotation = rotation
|
||||
self.angle_ref = angle_ref
|
||||
self.repetition = repetition
|
||||
self.annotations = annotations
|
||||
|
||||
|
|
@ -199,6 +239,7 @@ class Arc(PositionableImpl, Shape):
|
|||
and numpy.array_equal(self.angles, other.angles)
|
||||
and self.width == other.width
|
||||
and self.rotation == other.rotation
|
||||
and self.angle_ref == other.angle_ref
|
||||
and self.repetition == other.repetition
|
||||
and annotations_eq(self.annotations, other.annotations)
|
||||
)
|
||||
|
|
@ -215,6 +256,8 @@ class Arc(PositionableImpl, Shape):
|
|||
return tuple(self.radii) < tuple(other.radii)
|
||||
if not numpy.array_equal(self.angles, 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):
|
||||
return tuple(self.offset) < tuple(other.offset)
|
||||
if self.rotation != other.rotation:
|
||||
|
|
@ -370,6 +413,11 @@ class Arc(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
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 += axis * pi
|
||||
self.angles *= -1
|
||||
|
|
@ -381,6 +429,7 @@ class Arc(PositionableImpl, Shape):
|
|||
return self
|
||||
|
||||
def normalized_form(self, norm_value: float) -> normalized_shape_tuple:
|
||||
angle_ref = self.angle_ref
|
||||
if self.radius_x < self.radius_y:
|
||||
radii = self.radii / self.radius_x
|
||||
scale = self.radius_x
|
||||
|
|
@ -391,23 +440,26 @@ class Arc(PositionableImpl, Shape):
|
|||
scale = self.radius_y
|
||||
rotation = self.rotation + pi / 2
|
||||
angles = self.angles - pi / 2
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
delta_angle = angles[1] - angles[0]
|
||||
start_angle = angles[0] % (2 * pi)
|
||||
if start_angle >= pi:
|
||||
start_angle -= pi
|
||||
rotation += pi
|
||||
angle_ref = _swapped_focus_ref(angle_ref)
|
||||
|
||||
norm_angles = (start_angle, start_angle + delta_angle)
|
||||
rotation %= 2 * pi
|
||||
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),
|
||||
lambda: Arc(
|
||||
radii=radii * norm_value,
|
||||
angles=norm_angles,
|
||||
width=width * norm_value,
|
||||
angle_ref=angle_ref,
|
||||
))
|
||||
|
||||
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.
|
||||
```
|
||||
"""
|
||||
a_ranges = cast('_array2x2_t', self._angles_to_parameters())
|
||||
a_ranges = self._angles_to_parameters()
|
||||
|
||||
mins = []
|
||||
maxs = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
wh = sgn * self.width / 2
|
||||
rx = self.radius_x + wh
|
||||
ry = self.radius_y + wh
|
||||
|
||||
sin_r = numpy.sin(self.rotation)
|
||||
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
|
||||
cuts = []
|
||||
for index in range(2):
|
||||
edge = []
|
||||
for aa, sgn in zip(a_ranges, (-1, +1), strict=True):
|
||||
wh = sgn * self.width / 2
|
||||
edge.append(self._point_on_edge(self.radius_x + wh, self.radius_y + wh, aa[index]))
|
||||
cuts.append(edge)
|
||||
return numpy.array(cuts) + self.offset
|
||||
|
||||
def _angles_to_parameters(self) -> NDArray[numpy.float64]:
|
||||
"""
|
||||
|
|
@ -459,7 +500,7 @@ class Arc(PositionableImpl, Shape):
|
|||
rx = self.radius_x + 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)
|
||||
if sign != numpy.sign(a1 - a0):
|
||||
a1 += sign * 2 * pi
|
||||
|
|
@ -467,9 +508,93 @@ class Arc(PositionableImpl, Shape):
|
|||
aa.append((a0, a1))
|
||||
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:
|
||||
angles = f' a°{numpy.rad2deg(self.angles)}'
|
||||
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]]
|
||||
|
|
|
|||
|
|
@ -111,6 +111,14 @@ def test_rotated_arc_bounds_match_polygonized_geometry() -> None:
|
|||
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:
|
||||
for shape in (
|
||||
Circle(radius=10),
|
||||
|
|
@ -128,6 +136,19 @@ def test_arc_polygonization_rejects_nan_implied_arclen() -> None:
|
|||
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:
|
||||
ellipse = Ellipse(radii=(10, 20))
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
|
@ -91,6 +115,14 @@ def test_shape_mirror() -> None:
|
|||
# For Arc, mirror(0) negates rotation and angles
|
||||
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:
|
||||
e = Ellipse(radii=(10, 5), offset=(10, 20), rotation=pi / 4)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue