diff --git a/optika/_tests/test_mixins.py b/optika/_tests/test_mixins.py index b7fcf34..e13bd59 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 6945e1e..44be104 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), ), @@ -27,6 +28,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 1b75052..5f3f985 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): @@ -249,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", @@ -295,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/apertures/_apertures.py b/optika/apertures/_apertures.py index 6724ea4..f30acd1 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,58 @@ 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: + + 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 d410993..c6ae0a8 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 636fcbd..7774812 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 774fff5..cc3176b 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,56 @@ 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: + + if axis is None: # pragma: nocover + raise ValueError("`axis` cannot be None.") + + super()._write_to_dxf( + dxf=dxf, + unit=unit, + transformation=transformation, + ) + + mask = self.unvignetted[{axis: ~0}] + + position = self.position + + position = position[mask] + + if transformation is not None: + position = transformation(position) + + 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): + + 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 ed1b9e1..07ec6a8 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 06bf79a..e9a63db 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 3aef309..850dafb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "named-arrays~=1.0", "pymupdf", "joblib", + "ezdxf", ] dynamic = ["version"]