diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 00c5714..fe258c4 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -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'' + angle_ref = f' ref={self.angle_ref.value}' if self.angle_ref != ArcAngleRef.Center else '' + return f'' + + +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]] diff --git a/masque/test/test_shape_advanced.py b/masque/test/test_shape_advanced.py index 689df2a..8e35841 100644 --- a/masque/test/test_shape_advanced.py +++ b/masque/test/test_shape_advanced.py @@ -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) diff --git a/masque/test/test_shapes.py b/masque/test/test_shapes.py index b19d6bc..e453f7c 100644 --- a/masque/test/test_shapes.py +++ b/masque/test/test_shapes.py @@ -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)