[Arc] add angle_ref to enable focus-based arcs

This commit is contained in:
Jan Petykiewicz 2026-06-15 18:40:51 -07:00
commit 51c7fa9add
3 changed files with 204 additions and 26 deletions

View file

@ -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'{numpy.rad2deg(self.angles)}' angles = f'{numpy.rad2deg(self.angles)}'
rotation = f'{numpy.rad2deg(self.rotation):g}' if self.rotation != 0 else '' rotation = f'{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]]

View file

@ -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)

View file

@ -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)