diff --git a/optika/apertures/_apertures.py b/optika/apertures/_apertures.py index 71f36074..3a85c90b 100644 --- a/optika/apertures/_apertures.py +++ b/optika/apertures/_apertures.py @@ -593,6 +593,40 @@ def wire(self, num: None | int = None) -> na.Cartesian3dVectorArray: result = self.transformation(result) return result + def grid( + self, + normalized: na.AbstractCartesian2dVectorArray, + ) -> na.Cartesian3dVectorArray: + """ + Map a grid of normalized coordinates in :math:`[-1, 1]` onto points + filling the interior of the aperture. + + Unlike :meth:`wire`, which samples the boundary, this samples the + interior, and is useful for filling the aperture with rays. The + ``x`` component is mapped to the radius (vertex to rim) and the ``y`` + component to the polar angle (``angle_start`` to ``angle_stop``). + + Parameters + ---------- + normalized + A grid of normalized coordinates, where each component is in the + interval :math:`[-1, 1]`. + """ + unit_radius = na.unit(self.radius) + radius = self.radius * (normalized.x + 1) / 2 + angle = ( + self.angle_start + + (self.angle_stop - self.angle_start) * (normalized.y + 1) / 2 + ) + result = na.Cartesian3dVectorArray( + x=radius * np.cos(angle), + y=radius * np.sin(angle), + z=0 * unit_radius if unit_radius is not None else 0, + ) + if self.transformation is not None: + result = self.transformation(result) + return result + @dataclasses.dataclass(eq=False, repr=False) class EllipticalAperture( @@ -985,6 +1019,34 @@ def vertices(self): result.z = result.z * unit return result + def grid( + self, + normalized: na.AbstractCartesian2dVectorArray, + ) -> na.Cartesian3dVectorArray: + """ + Map a grid of normalized coordinates in :math:`[-1, 1]` onto points + filling the interior of the aperture. + + Unlike :meth:`wire`, which samples the boundary, this samples the + interior, and is useful for filling the aperture with rays. + + Parameters + ---------- + normalized + A grid of normalized coordinates, where each component is in the + interval :math:`[-1, 1]`. + """ + half_width = na.asanyarray(self.half_width, like=na.Cartesian2dVectorArray()) + unit = na.unit(half_width.x) + result = na.Cartesian3dVectorArray( + x=half_width.x * normalized.x, + y=half_width.y * normalized.y, + z=0 * unit if unit is not None else 0, + ) + if self.transformation is not None: + result = self.transformation(result) + return result + @dataclasses.dataclass(eq=False, repr=False) class AbstractRegularPolygonalAperture( diff --git a/optika/apertures/_apertures_test.py b/optika/apertures/_apertures_test.py index bdf01db7..11a45ab8 100644 --- a/optika/apertures/_apertures_test.py +++ b/optika/apertures/_apertures_test.py @@ -373,6 +373,27 @@ def test_decentered(self, a: optika.apertures.RectangularAperture): assert not np.any(b(point_outside)) +def test_grid_transformation(): + # grid() must apply the aperture's transformation to the sampled points + shift = na.transformations.Cartesian3dTranslation(x=5 * u.mm) + center = na.Cartesian2dVectorArray(x=0.0, y=0.0) + for aperture in ( + optika.apertures.RectangularAperture( + half_width=na.Cartesian2dVectorArray(10, 4) * u.mm, + transformation=shift, + ), + optika.apertures.CircularSectorAperture( + radius=125 * u.mm, + angle_start=90 * u.deg, + angle_stop=180 * u.deg, + transformation=shift, + ), + ): + grid = aperture.grid(center) + assert isinstance(grid, na.AbstractCartesian3dVectorArray) + assert np.all(aperture(grid)) + + class AbstractTestAbstractRegularPolygonalAperture( AbstractTestAbstractPolygonalAperture, ):