From 1682edc50905f14d777df12b25e19b58c3a7e31f Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 21 Mar 2026 12:41:46 -0600 Subject: [PATCH 1/4] Added `AbstractSequentialSystem.to_dxf()` a function that can export the system to a DXF file for interfacing with CAD programs. --- optika/_tests/test_mixins.py | 44 +++++++++++++++++++++ optika/_tests/test_surfaces.py | 1 + optika/_tests/test_systems.py | 1 + optika/apertures/_apertures.py | 60 +++++++++++++++++++++++++++++ optika/apertures/_apertures_test.py | 1 + optika/mixins.py | 45 ++++++++++++++++++++++ optika/rays/_ray_vectors.py | 59 ++++++++++++++++++++++++++++ optika/surfaces.py | 39 +++++++++++++++++++ optika/systems.py | 34 ++++++++++++++++ pyproject.toml | 1 + 10 files changed, 285 insertions(+) diff --git a/optika/_tests/test_mixins.py b/optika/_tests/test_mixins.py index b7fcf344..3a94b511 100644 --- a/optika/_tests/test_mixins.py +++ b/optika/_tests/test_mixins.py @@ -1,6 +1,7 @@ import pytest import abc import dataclasses +import pathlib import numpy as np import matplotlib.axes import matplotlib.pyplot as plt @@ -227,3 +228,46 @@ class TestRollable( AbstractTestRollable, ): pass + + +class AbstractTestDxfWritable( + abc.ABC, +): + + @pytest.mark.parametrize( + argnames="file", + argvalues=[ + pathlib.Path("test_dwg.dxf"), + ] + ) + @pytest.mark.parametrize( + argnames="unit", + argvalues=[ + u.mm, + ], + ) + @pytest.mark.parametrize( + argnames="transformation", + argvalues=[ + None, + na.transformations.Cartesian3dRotationY(23 * u.deg), + ] + ) + def test_to_dxf( + self, + a: optika.mixins.DxfWritable, + file: pathlib.Path, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation, + ): + a.to_dxf( + file=file, + unit=unit, + transformation=transformation, + ) + + assert file.is_file() + + assert file.stat().st_size > 0 + + file.unlink() diff --git a/optika/_tests/test_surfaces.py b/optika/_tests/test_surfaces.py index 6945e1ea..bc60c6a8 100644 --- a/optika/_tests/test_surfaces.py +++ b/optika/_tests/test_surfaces.py @@ -27,6 +27,7 @@ class AbstractTestAbstractSurface( + test_mixins.AbstractTestDxfWritable, test_mixins.AbstractTestPlottable, test_mixins.AbstractTestPrintable, test_mixins.AbstractTestTransformable, diff --git a/optika/_tests/test_systems.py b/optika/_tests/test_systems.py index 1b75052c..002ddcf0 100644 --- a/optika/_tests/test_systems.py +++ b/optika/_tests/test_systems.py @@ -55,6 +55,7 @@ def test_image( class AbstractTestAbstractSequentialSystem( + test_mixins.AbstractTestDxfWritable, AbstractTestAbstractSystem, ): def test_object(self, a: optika.systems.AbstractSequentialSystem): diff --git a/optika/apertures/_apertures.py b/optika/apertures/_apertures.py index 6724ea4a..ba4bd6cb 100644 --- a/optika/apertures/_apertures.py +++ b/optika/apertures/_apertures.py @@ -8,6 +8,7 @@ import astropy.units as u import named_arrays as na import optika +from ezdxf.addons.r12writer import R12FastStreamWriter __all__ = [ "AbstractAperture", @@ -28,6 +29,7 @@ @dataclasses.dataclass(eq=False, repr=False) class AbstractAperture( + optika.mixins.DxfWritable, optika.mixins.Printable, optika.mixins.Plottable, optika.mixins.Transformable, @@ -166,6 +168,64 @@ def plot( **kwargs, ) + def _write_to_dxf( + self, + dxf: R12FastStreamWriter, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + sag: None | optika.sags.AbstractSag = None, + **kwargs, + ) -> None: + + if self.transformation is not None: + if transformation is not None: + transformation = transformation @ self.transformation + else: + transformation = self.transformation + + super()._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + wire = self.wire() + + wire = wire.broadcast_to(wire.shape) + + unit_wire = na.unit_normalized(wire) + if not unit_wire.is_equivalent(unit): + return + + if sag is not None: + wire.z = sag(wire) + + if transformation is not None: + wire = transformation(wire) + + wire = na.nominal(wire.broadcasted) + + x = na.as_named_array(wire.x) + y = na.as_named_array(wire.y) + z = na.as_named_array(wire.z) + + for index in wire.ndindex(axis_ignored="wire"): + + vertices = np.stack( + arrays=[ + x[index].ndarray, + y[index].ndarray, + z[index].ndarray, + ], + axis=~0 + ) + + vertices = vertices.to_value(unit) + + dxf.add_polyline( + vertices=vertices, + ) + @dataclasses.dataclass(eq=False, repr=False) class CircularAperture( diff --git a/optika/apertures/_apertures_test.py b/optika/apertures/_apertures_test.py index d410993b..c6ae0a8c 100644 --- a/optika/apertures/_apertures_test.py +++ b/optika/apertures/_apertures_test.py @@ -26,6 +26,7 @@ class AbstractTestAbstractAperture( + test_mixins.AbstractTestDxfWritable, test_mixins.AbstractTestPrintable, test_mixins.AbstractTestPlottable, test_mixins.AbstractTestTransformable, diff --git a/optika/mixins.py b/optika/mixins.py index 636fcbdd..77748124 100644 --- a/optika/mixins.py +++ b/optika/mixins.py @@ -4,11 +4,14 @@ from typing import Any import abc import dataclasses +import pathlib import numpy as np import numpy.typing as npt import matplotlib.axes import astropy.units as u import named_arrays as na +import ezdxf.addons +from ezdxf.addons.r12writer import R12FastStreamWriter __all__ = [ "Shaped", @@ -243,3 +246,45 @@ def transformation(self) -> na.transformations.AbstractTransformation: return super().transformation @ na.transformations.Cartesian3dRotationZ( angle=self.roll ) + + +@dataclasses.dataclass(eq=False, repr=False) +class DxfWritable(abc.ABC): + + def to_dxf( + self, + file: pathlib.Path, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + ): + + with ezdxf.addons.r12writer(file) as dxf: + self._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + @abc.abstractmethod + def _write_to_dxf( + self, + dxf: R12FastStreamWriter, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + **kwargs, + ) -> None: + """ + Write a representation of this object to a DXF file. + + Parameters + ---------- + dxf + The stream representing the open DXF file. + unit + The length units to use for this file. + transformation + An additional transformation to apply to the coordinate system + before writing to the DXF file. + kwargs + Additional keyword arguments passed to subclass implementations. + """ diff --git a/optika/rays/_ray_vectors.py b/optika/rays/_ray_vectors.py index 774fff5d..4c31344f 100644 --- a/optika/rays/_ray_vectors.py +++ b/optika/rays/_ray_vectors.py @@ -5,6 +5,8 @@ import numpy as np import astropy.units as u import named_arrays as na +from ezdxf.addons.r12writer import R12FastStreamWriter +from .. import mixins __all__ = [ "AbstractRayVectorArray", @@ -22,6 +24,7 @@ @dataclasses.dataclass(eq=False, repr=False) class AbstractRayVectorArray( + mixins.DxfWritable, na.AbstractSpectralPositionalVectorArray, ): """An interface describing an ensemble of lights rays.""" @@ -166,6 +169,62 @@ def __array_ufunc__( if method == "__call__": return self.__array_add__(*inputs, **kwargs) + def _write_to_dxf( + self, + dxf: R12FastStreamWriter, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + axis: None | str = None, + **kwargs, + ) -> None: + + shape = self.shape + + if axis is None: + if len(shape) == 1: + axis = next(iter(shape)) + else: + raise ValueError( + f"if `axis` is `None`, the shape of {type(self)} should have" + f"only one axis, got {self.shape=}." + ) + + super()._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + mask = self.unvignetted[{axis: ~0}] + + position = self.position.broadcasted + + position = position[mask] + + if transformation is not None: + position = transformation(position) + + x = position.x + y = position.y + z = position.z + + for index in x.ndindex(axis_ignored=axis): + + vertices = np.stack( + arrays=[ + x[index].ndarray, + y[index].ndarray, + z[index].ndarray, + ], + axis=~0, + ) + + vertices = vertices.to_value(unit) + + dxf.add_polyline( + vertices=vertices, + ) + @dataclasses.dataclass(eq=False, repr=False) class RayVectorArray( diff --git a/optika/surfaces.py b/optika/surfaces.py index ed1b9e10..07ec6a86 100644 --- a/optika/surfaces.py +++ b/optika/surfaces.py @@ -9,8 +9,10 @@ import dataclasses import numpy.typing as npt import matplotlib.axes +from astropy import units as u import named_arrays as na import optika +from ezdxf.addons.r12writer import R12FastStreamWriter __all__ = [ "AbstractSurface", @@ -41,6 +43,7 @@ @dataclasses.dataclass(eq=False, repr=False) class AbstractSurface( + optika.mixins.DxfWritable, optika.mixins.Plottable, optika.mixins.Printable, optika.mixins.Transformable, @@ -240,6 +243,42 @@ def plot( return result + def _write_to_dxf( + self, + dxf: R12FastStreamWriter, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + **kwargs, + ) -> None: + + if self.transformation is not None: + if transformation is not None: + transformation = transformation @ self.transformation + else: + transformation = self.transformation + + super()._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + if self.aperture is not None: + self.aperture._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + sag=self.sag, + ) + + if self.aperture_mechanical is not None: + self.aperture_mechanical._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + sag=self.sag, + ) + @dataclasses.dataclass(eq=False, repr=False) class Surface( diff --git a/optika/systems.py b/optika/systems.py index 06bf79af..e9a63db9 100644 --- a/optika/systems.py +++ b/optika/systems.py @@ -16,6 +16,7 @@ import matplotlib.pyplot as plt import named_arrays as na import optika +from ezdxf.addons.r12writer import R12FastStreamWriter __all__ = [ "AbstractSystem", @@ -26,6 +27,7 @@ @dataclasses.dataclass(eq=False, repr=False) class AbstractSystem( + optika.mixins.DxfWritable, optika.mixins.Plottable, optika.mixins.Printable, optika.mixins.Transformable, @@ -1150,6 +1152,38 @@ def spot_diagram( return fig, ax + def _write_to_dxf( + self, + dxf: R12FastStreamWriter, + unit: u.Unit, + transformation: None | na.transformations.AbstractTransformation = None, + **kwargs, + ) -> None: + + if self.transformation is not None: + if transformation is not None: + transformation = transformation @ self.transformation + else: + transformation = self.transformation + + surfaces = self.surfaces_all + + for surface in surfaces: + surface._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + rays = self.raytrace().outputs + + rays._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + axis=self.axis_surface, + ) + @dataclasses.dataclass(eq=False, repr=False) class SequentialSystem( diff --git a/pyproject.toml b/pyproject.toml index 3aef3090..850dafb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "named-arrays~=1.0", "pymupdf", "joblib", + "ezdxf", ] dynamic = ["version"] From ac722c2e086cb4fb91ad7e0894ead878a86d4fb7 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 21 Mar 2026 12:50:11 -0600 Subject: [PATCH 2/4] black --- optika/_tests/test_mixins.py | 4 ++-- optika/apertures/_apertures.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/optika/_tests/test_mixins.py b/optika/_tests/test_mixins.py index 3a94b511..e13bd59e 100644 --- a/optika/_tests/test_mixins.py +++ b/optika/_tests/test_mixins.py @@ -238,7 +238,7 @@ class AbstractTestDxfWritable( argnames="file", argvalues=[ pathlib.Path("test_dwg.dxf"), - ] + ], ) @pytest.mark.parametrize( argnames="unit", @@ -251,7 +251,7 @@ class AbstractTestDxfWritable( argvalues=[ None, na.transformations.Cartesian3dRotationY(23 * u.deg), - ] + ], ) def test_to_dxf( self, diff --git a/optika/apertures/_apertures.py b/optika/apertures/_apertures.py index ba4bd6cb..2c72924d 100644 --- a/optika/apertures/_apertures.py +++ b/optika/apertures/_apertures.py @@ -217,7 +217,7 @@ def _write_to_dxf( y[index].ndarray, z[index].ndarray, ], - axis=~0 + axis=~0, ) vertices = vertices.to_value(unit) From 31f3c0f68f621d5d531f0255a62ff98d55c44880 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 21 Mar 2026 13:25:23 -0600 Subject: [PATCH 3/4] coverage --- optika/_tests/test_surfaces.py | 1 + optika/_tests/test_systems.py | 10 +++++++++- optika/rays/_ray_vectors.py | 12 ++---------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/optika/_tests/test_surfaces.py b/optika/_tests/test_surfaces.py index bc60c6a8..44be104b 100644 --- a/optika/_tests/test_surfaces.py +++ b/optika/_tests/test_surfaces.py @@ -20,6 +20,7 @@ sag=optika.sags.SphericalSag(radius=1000 * u.mm), material=optika.materials.Mirror(), aperture=optika.apertures.RectangularAperture(half_width=10 * u.mm), + aperture_mechanical=optika.apertures.RectangularAperture(11 * u.mm), transformation=na.transformations.Cartesian3dTranslation(z=100 * u.mm), rulings=optika.rulings.Rulings(spacing=1 * u.um, diffraction_order=1), ), diff --git a/optika/_tests/test_systems.py b/optika/_tests/test_systems.py index 002ddcf0..5f3f985e 100644 --- a/optika/_tests/test_systems.py +++ b/optika/_tests/test_systems.py @@ -250,6 +250,13 @@ def test_spot_diagram(self, a: optika.systems.AbstractSequentialSystem): ), ] +_transformations = [ + None, + None, + na.transformations.Cartesian3dTranslation(x=100 * u.mm), + na.transformations.Cartesian3dRotationZ(23 * u.deg), +] + _surfaces = [ optika.surfaces.Surface( name="mirror", @@ -296,8 +303,9 @@ def test_spot_diagram(self, a: optika.systems.AbstractSequentialSystem): surfaces=_surfaces, sensor=_sensor, grid_input=_grid_input, + transformation=transform, ) - for obj in _objects + for obj, transform in zip(_objects, _transformations) ], ) class TestSequentialSystem(AbstractTestAbstractSequentialSystem): diff --git a/optika/rays/_ray_vectors.py b/optika/rays/_ray_vectors.py index 4c31344f..04dd91fa 100644 --- a/optika/rays/_ray_vectors.py +++ b/optika/rays/_ray_vectors.py @@ -178,16 +178,8 @@ def _write_to_dxf( **kwargs, ) -> None: - shape = self.shape - - if axis is None: - if len(shape) == 1: - axis = next(iter(shape)) - else: - raise ValueError( - f"if `axis` is `None`, the shape of {type(self)} should have" - f"only one axis, got {self.shape=}." - ) + if axis is None: # pragma: nocover + raise ValueError("`axis` cannot be None.") super()._write_to_dxf( dxf=dxf, From 2ae49ae2a9d4a296b6af1ba66973a5cc9622b00f Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 21 Mar 2026 13:44:00 -0600 Subject: [PATCH 4/4] fixes --- optika/apertures/_apertures.py | 6 ------ optika/rays/_ray_vectors.py | 10 ++++++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/optika/apertures/_apertures.py b/optika/apertures/_apertures.py index 2c72924d..f30acd14 100644 --- a/optika/apertures/_apertures.py +++ b/optika/apertures/_apertures.py @@ -177,12 +177,6 @@ def _write_to_dxf( **kwargs, ) -> None: - if self.transformation is not None: - if transformation is not None: - transformation = transformation @ self.transformation - else: - transformation = self.transformation - super()._write_to_dxf( dxf=dxf, unit=unit, diff --git a/optika/rays/_ray_vectors.py b/optika/rays/_ray_vectors.py index 04dd91fa..cc3176b2 100644 --- a/optika/rays/_ray_vectors.py +++ b/optika/rays/_ray_vectors.py @@ -189,16 +189,18 @@ def _write_to_dxf( mask = self.unvignetted[{axis: ~0}] - position = self.position.broadcasted + position = self.position position = position[mask] if transformation is not None: position = transformation(position) - x = position.x - y = position.y - z = position.z + position = na.nominal(position.broadcasted) + + x = na.as_named_array(position.x) + y = na.as_named_array(position.y) + z = na.as_named_array(position.z) for index in x.ndindex(axis_ignored=axis):