"""Surface Group
This module contains the SurfaceGroup class, which represents a group of
surfaces in an optical system. The SurfaceGroup class provides methods for
tracing rays through the surfaces, adding and removing surfaces, and
converting the group to and from a dictionary for serialization.
Kramer Harrison, 2024
"""
from __future__ import annotations
import copy
from contextlib import suppress
from copy import deepcopy
from functools import cached_property
from typing import TYPE_CHECKING
import optiland.backend as be
from optiland.coatings import BaseCoatingPolarized
from optiland.paraxial_path import (
ParaxialPath,
build_paraxial_path,
require_global_z_geometry,
transverse_basis,
)
from optiland.surfaces.factories.surface_factory import SurfaceFactory
from optiland.surfaces.standard_surface import Surface
if TYPE_CHECKING:
from optiland._types import SurfaceType
from optiland.materials import BaseMaterial
[docs]
class SurfaceGroup:
"""Represents a group of surfaces in an optical system.
Attributes:
surfaces (list): List of surfaces in the group.
_last_thickness (float): The thickness of the last surface added.
"""
def __init__(self, surfaces: list[Surface] | None = None):
"""Initializes a new instance of the SurfaceGroup class.
Args:
surfaces (List, optional): List of surfaces to initialize the
group with. Defaults to None.
"""
if surfaces is None:
self._surfaces = []
else:
self._surfaces = surfaces
self._update_surface_links()
self.surface_factory = SurfaceFactory(self)
def __deepcopy__(self, memo: dict) -> SurfaceGroup:
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
for k, v in self.__dict__.items():
object.__setattr__(result, k, copy.deepcopy(v, memo))
result._rewire_observers()
return result
def _update_surface_links(self):
with suppress(KeyError):
self.__dict__.pop("surfaces")
surfaces = self._surfaces
if surfaces:
surfaces[0].previous_surface = None
if len(surfaces) > 1:
for idx, surface in enumerate(surfaces[1:]):
surface.previous_surface = surfaces[idx]
def _rewire_observers(self) -> None:
"""Re-establish material-change callbacks across the surface chain.
Called after deepcopy so downstream surfaces are notified when an
upstream surface's material changes.
"""
for i in range(len(self._surfaces) - 1):
self._surfaces[i + 1].subscribe(
self._surfaces[i + 1]._on_upstream_material_change
)
def __add__(self, other: SurfaceGroup) -> SurfaceGroup:
"""Add two SurfaceGroup objects together.
Drops the image surface of ``self`` (last element) and the object
surface of ``other`` (first element), then stitches the remaining
surfaces so that ``other``'s first physical surface is placed at the
position immediately after ``self``'s last surface (accounting for
its thickness).
``other`` is never mutated; a deep copy of its surfaces is taken
before any coordinate adjustments are applied.
Note:
``self``'s last surface is treated as the image-plane marker and
is dropped from the combined group. For correct composition,
append a flat (radius = ∞) image surface to ``self`` before
calling ``__add__``. If ``self``'s last surface is a real
optical element with finite thickness, its propagation space is
still honoured via the junction-z calculation even though the
surface itself is not retained.
Raises:
UnsupportedParaxialGeometryError: If either operand's beam path
is folded off global +z (or entered along another
direction), before anything is copied or shifted.
Composition places ``other`` by shifting global z only,
which is meaningless for a folded operand. A laterally
translated system whose axis remains global +z stays
supported.
"""
# Preflight-atomic: both operands must be global-z compatible
# before any copy or coordinate shift happens.
require_global_z_geometry(
self._surfaces, "surface-group composition (left operand)"
)
require_global_z_geometry(
other._surfaces, "surface-group composition (right operand)"
)
# Deep-copy other's surfaces so the original is never mutated and the
# combined group does not share mutable objects with other.
other_copies = [deepcopy(s) for s in other._surfaces]
# Junction z: the position after self's last surface (= self's image plane).
# Using last.z + last.thickness correctly handles the case where the last
# surface has a finite propagation space before the focal plane.
last = self._surfaces[-1]
last_z = float(last.geometry.cs.z)
last_thickness = last.thickness
if hasattr(last_thickness, "item"):
last_thickness = last_thickness.item()
if be.isinf(be.array(last_thickness)):
junction_z = last_z
else:
junction_z = last_z + float(last_thickness)
# Compute the global shift for other's surfaces so that other[0]
# (the object plane) coincides with junction_z.
object_distance = float(other_copies[0].geometry.cs.z)
if be.isfinite(be.array(object_distance)):
offset = junction_z - object_distance
else:
offset = junction_z
# Shift other's physical surfaces (index 1 onwards) into the global frame.
for surf in other_copies[1:]:
surf.geometry.cs.z = be.array(float(surf.geometry.cs.z) + offset)
# Remove stop from other if self already has one (preserving self's stop).
# Otherwise, we keep other's stop surface as the system stop.
self_has_stop = any(surf.is_stop for surf in self._surfaces)
if self_has_stop:
for surface in other_copies:
surface.is_stop = False
# Drop self's image surface (last) and other's object surface (first).
return SurfaceGroup(self._surfaces[:-1] + other_copies[1:])
@cached_property
def surfaces(self):
return tuple(item for item in self._surfaces)
def __getitem__(self, index):
return self._surfaces[index]
def __iter__(self):
return iter(self._surfaces)
def __len__(self):
return len(self._surfaces)
[docs]
def index(self, value):
"""Return the first index of the specified surface."""
return self._surfaces.index(value)
[docs]
def clear(self):
"""Clears the list of surfaces."""
self._surfaces = []
self._update_surface_links()
@property
def x(self):
"""np.array: x intersection points on all surfaces"""
return be.stack([surf.x for surf in self.surfaces if be.size(surf.x) > 0])
@property
def y(self):
"""np.array: y intersection points on all surfaces"""
return be.stack([surf.y for surf in self.surfaces if be.size(surf.y) > 0])
@property
def z(self):
"""np.array: z intersection points on all surfaces"""
return be.stack([surf.z for surf in self.surfaces if be.size(surf.z) > 0])
@property
def L(self):
"""np.array: x direction cosines on all surfaces"""
return be.stack([surf.L for surf in self.surfaces if be.size(surf.L) > 0])
@property
def M(self):
"""np.array: y direction cosines on all surfaces"""
return be.stack([surf.M for surf in self.surfaces if be.size(surf.M) > 0])
@property
def N(self):
"""np.array: z direction cosines on all surfaces"""
return be.stack([surf.N for surf in self.surfaces if be.size(surf.N) > 0])
@property
def opd(self):
"""np.array: optical path difference recorded on all surfaces"""
return be.stack([surf.opd for surf in self.surfaces if be.size(surf.opd) > 0])
@property
def u(self):
"""np.array: paraxial ray angles on all surfaces"""
return be.stack([surf.u for surf in self.surfaces if be.size(surf.u) > 0])
@property
def intensity(self):
"""np.array: ray intensities on all surfaces"""
return be.stack(
[surf.intensity for surf in self.surfaces if be.size(surf.intensity) > 0]
)
[docs]
def build_paraxial_path(self) -> ParaxialPath:
"""Build the shared folded-path metadata for the current geometry.
The path is a per-operation snapshot -- geometry is mutable, so it
is rebuilt rather than cached. High-level operations should build it
once and pass it through their call chain instead of re-deriving
frames, directions and parity in each consumer.
"""
return build_paraxial_path(list(self.surfaces))
@property
def positions(self):
"""np.array: signed unfolded axial positions of surface vertices.
The axial coordinate is the one the paraxial model is written in: a
1-D scalar coordinate along the unfolded optical axis, with each
reflection reversing the direction of travel (so spacings after an
odd number of mirrors are negative). While every leg of the beam
path runs along ±z entered along +z -- straight systems and mirrors
at normal incidence -- that is exactly the global z of each vertex,
and this property returns it unchanged.
A mirror that folds the beam off the z axis (or an entry along any
other direction, including -z) breaks that equivalence: two vertices
on opposite sides of a 90° fold can share a z, so their spacing
would read as zero. For those systems the coordinate is continued as
signed cumulative vertex-to-vertex path length along the beam, which
is what the surrounding first-order machinery -- pupil locations,
paraxial ray heights, solves -- needs to stay correct through a
fold.
This is an unfolded axial scalar, never a Cartesian coordinate: use
``global_z_positions`` for the global z component of the vertices,
and ``vertices_gcs`` for full three-dimensional vertex positions.
"""
return self.build_paraxial_path().axial_positions.reshape(-1, 1)
@property
def global_z_positions(self):
"""np.array: global z coordinates of the surface vertices.
Unlike :attr:`positions` this is a real-space coordinate, never an
unfolded one. Use it for anything that has to place something in the
global frame (drawing limits, reference spheres); use ``positions``
for first-order calculations.
"""
positions = be.array(
[surf.geometry.cs.position_in_gcs[2] for surf in self.surfaces]
)
return positions.reshape(-1, 1)
@property
def vertices_gcs(self):
"""np.array: full 3-D surface vertex positions in global coordinates.
Shape ``(num_surfaces, 3)``. Use this whenever a consumer needs a
real-space point; ``positions`` is an unfolded axial scalar and
``global_z_positions`` is only the z component of these vertices.
"""
path = self.build_paraxial_path()
rows = [
be.array([vertex[0], vertex[1], vertex[2]]) for vertex in path.vertices_gcs
]
return be.stack(rows) if rows else be.array([])
def _entry_frame(self, path: ParaxialPath | None = None):
"""Object-space entry frame, or ``None`` on the canonical z axis.
``None`` is the common case -- every leg on ±z, entered along +z
through global x = y = 0 -- and keeps z-based callers (the paraxial
ray aimer, the angle-field launch seeds) on their existing code path
bit-for-bit. For any other aiming geometry (folded, off-axis entry,
-z entry, or a system rigidly translated off the z axis) it returns
``(anchor, axial, direction, u, v)``: the first physical surface's
vertex and its axial coordinate, the unit entry direction, and the
transverse basis completing it.
Object-space constructs live on the entry line, anchored at the
first physical surface's vertex -- a decentered first vertex shifts
the pupil line with it. The entrance pupil is the stop imaged into
object space, so a launch ray aims at its apparent, unfolded
position ``anchor + (axial_pupil - axial) * direction``, never at a
refolded downstream point; the fold mirrors then carry the ray onto
the physical stop. The basis gauge (and its pole near ±y entry) is
documented on :func:`optiland.paraxial_path.transverse_basis`.
Args:
path: Optional prebuilt path to reuse (avoids a second walk).
"""
if path is None:
if len(self.surfaces) < 2:
return None
path = self.build_paraxial_path()
if path.num_surfaces < 2 or path.legacy_aiming_compatible:
return None
return path.entry_frame()
@staticmethod
def _transverse_basis(direction):
"""Deterministic transverse pair ``(u, v)`` completing ``direction``.
Delegates to :func:`optiland.paraxial_path.transverse_basis`; see
there for the gauge convention and the pole near ±y.
"""
return transverse_basis(direction)
@property
def radii(self):
"""np.array: radii of curvature of all surfaces"""
return be.array([surf.geometry.radius for surf in self.surfaces])
@property
def conic(self):
"""be.array: conic constant of all surfaces"""
values = []
for surf in self.surfaces:
try:
values.append(surf.geometry.k)
except AttributeError:
values.append(0)
return be.array(values)
@property
def stop_index(self):
"""int: the index of the aperture stop surface"""
for index, surface in enumerate(self.surfaces):
if surface.is_stop:
return index
raise ValueError(
"No stop surface found. Exactly one surface must be marked as "
"the aperture stop, either by passing is_stop=True to "
"lens.add_surface(...) or by setting "
"lens.surface_group.stop_index = <index>."
)
@stop_index.setter
def stop_index(self, index: int):
if index < 1 or index > len(self.surfaces) - 2:
raise ValueError(
f"Invalid stop index, got {index}. The stop must be an "
f"optical surface, so its index must lie between 1 and "
f"{len(self.surfaces) - 2} (index 0 is the object surface "
f"and index {len(self.surfaces) - 1} is the image surface)."
)
for idx, surf in enumerate(self.surfaces):
surf.is_stop = index == idx
@property
def num_surfaces(self):
"""int: the number of surfaces"""
return len(self.surfaces)
@property
def uses_polarization(self):
"""bool: True if any surface uses polarization, False otherwise"""
for surf in self.surfaces:
if isinstance(surf.interaction_model.coating, BaseCoatingPolarized):
return True
return False
@property
def total_track(self):
"""float: the span of the unfolded signed axial surface coordinates.
This is the extent of :attr:`positions` over the physical surfaces
(object excluded) -- the track length of the scalar paraxial system.
For straight systems it equals the global-z span. For a folded
system it is the span along the unfolded axis (the length the
equivalent unfolded system would have), which is what the
``total_track`` optimization operand constrains. Use
:attr:`global_z_span` for the extent of the vertices in global z.
"""
if self.num_surfaces < 2:
raise ValueError(
f"Cannot compute the total track: the system has "
f"{self.num_surfaces} surface(s), and at least 2 are "
"required. Add surfaces with lens.add_surface(...)."
)
z = self.positions[1:]
return be.max(z) - be.min(z)
@property
def global_z_span(self):
"""float: the extent of the surface vertices in global z.
The old (pre-fold-aware) meaning of ``total_track``: the span of the
global z coordinates of the physical surface vertices. For straight
systems this equals :attr:`total_track`; for folded systems it is
the bounding extent in z, not a track length along the beam.
"""
if self.num_surfaces < 2:
raise ValueError(
f"Cannot compute the global z span: the system has "
f"{self.num_surfaces} surface(s), and at least 2 are "
"required. Add surfaces with lens.add_surface(...)."
)
z = self.global_z_positions[1:]
return be.max(z) - be.min(z)
[docs]
def n(self, wavelength):
"""Get the refractive indices of the surfaces.
Args:
wavelength (float or str, optional): The wavelength for which to
calculate the refractive indices.
Returns:
numpy.ndarray: The refractive indices of the surfaces.
"""
n = []
for surface in self.surfaces:
n.append(be.atleast_1d(surface.material_post.n(wavelength)))
return be.ravel(be.array(n))
[docs]
def get_thickness(self, surface_number):
"""Calculate the thickness between two surfaces.
Args:
surface_number (int): The index of the first surface.
Returns:
float: The thickness between the two surfaces.
"""
t = self.positions
return t[surface_number + 1] - t[surface_number]
[docs]
def trace(self, rays, skip=0, record=True):
"""Trace the given rays through the surfaces.
Args:
rays (BaseRays): List of rays to be traced.
skip (int, optional): Number of surfaces to skip before tracing.
Defaults to 0.
record (bool, optional): Whether to store per-surface snapshots of
the ray state (positions, directions, intensity, OPD). The
snapshots feed analyses and visualization but keep eight
full-size arrays alive per surface; pass False when only the
rays returned at the image are needed. Defaults to True.
"""
self.reset()
for surface in self.surfaces[skip:]:
surface.trace(rays, record=record)
return rays
[docs]
def add(
self,
new_surface=None,
surface_type: SurfaceType = "standard",
comment="",
index=None,
is_stop=False,
material: str | BaseMaterial = "air",
**kwargs,
):
"""Adds a new surface to the list of surfaces.
Args:
new_surface (Surface, optional): The new surface to add. If not
provided, a new surface will be created based on the other
arguments.
surface_type (str, optional): The type of surface to create.
comment (str, optional): A comment for the surface. Defaults to ''.
index (int, optional): The index at which to insert the new
surface. If not provided, the surface will be appended to the
end of the list.
is_stop (bool, optional): Indicates if the surface is the aperture.
material (str, optional): The material of the surface.
Default is 'air'.
**kwargs: Additional keyword arguments for surface-specific
parameters such as radius, conic, dx, dy, rx, ry, rz, aperture,
bsdf, x, y, z.
Raises:
ValueError: If a new surface is provided and no index is given.
IndexError: If the index is out of bounds for insertion, or negative.
"""
if new_surface is None:
if index is None:
raise ValueError(
"No index was given for the new surface. Pass the "
"position it should occupy, e.g. "
"lens.add_surface(index=1, radius=50, thickness=5)."
)
new_surface = self.surface_factory.create_surface(
surface_type,
comment,
index,
is_stop,
material,
**kwargs,
)
# Used for surface positioning
new_surface.thickness = kwargs.get("thickness", 0.0)
self.surface_factory.material_factory.last_material = new_surface.material_post
if index is None:
self._surfaces.append(new_surface)
self._update_surface_links()
index = len(self._surfaces) - 1
else:
if index < 0:
raise IndexError(
f"Cannot add a surface at index {index}: surface indices "
"are non-negative, counting from the object surface at "
"index 0."
)
if index > len(self._surfaces):
raise IndexError(
f"Cannot add a surface at index {index}: the system "
f"currently has {len(self._surfaces)} surface(s), so the "
f"highest valid index is {len(self._surfaces)}. Surfaces "
"must be added in order, starting with the object "
"surface at index 0."
)
if index == 0 and len(self.surfaces) > 0:
raise ValueError(
"Cannot add a surface at index 0: index 0 is the object "
"surface and it already exists. Insert at index 1 or "
"later, or remove the existing object surface first."
)
# Whether an interior insertion will rebuild downstream cs.z from
# cumulative thicknesses (only in relative-coordinate mode).
will_rebuild = not self.surface_factory.use_absolute_cs and index < len(
self._surfaces
)
if will_rebuild:
# Preflight-atomic: validate the would-be chain before the
# list is mutated, so a rejected insertion leaves the group
# exactly as it was.
require_global_z_geometry(
self._surfaces[:index] + [new_surface] + self._surfaces[index:],
"surface insertion into a relative-coordinate chain",
)
self._surfaces.insert(index, new_surface)
self._update_surface_links()
# Update coordinate systems if surface was inserted
if will_rebuild:
self._update_coordinate_systems(start_index=index)
if new_surface.is_stop:
for idx, surface in enumerate(self._surfaces):
surface.is_stop = idx == index
[docs]
def remove(self, index):
"""Remove a surface from the list of surfaces.
Cannot remove the object surface (index 0).
If relative coordinate positioning is active (use_absolute_cs=False),
this may trigger an update of subsequent surface positions.
Args:
index (int): The index of the surface to remove.
Raises:
ValueError: If attempting to remove the object surface (index 0).
IndexError: If the index is out of bounds for the current list of surfaces.
"""
if index == 0:
raise ValueError("Cannot remove object surface (index 0).")
if not (0 < index < len(self.surfaces)):
raise IndexError(
f"Index {index} is out of bounds for removing from list of "
f"{len(self.surfaces)} surfaces."
)
num_surfaces_before_removal = len(self.surfaces)
will_rebuild = (
not self.surface_factory.use_absolute_cs
and index < num_surfaces_before_removal - 1
)
if will_rebuild:
# Preflight-atomic: validate the would-be chain before the list
# is mutated, so a rejected removal leaves the group unchanged.
require_global_z_geometry(
self._surfaces[:index] + self._surfaces[index + 1 :],
"surface removal from a relative-coordinate chain",
)
del self._surfaces[index]
if will_rebuild:
self._update_coordinate_systems(start_index=index)
self._update_surface_links()
[docs]
def reset(self):
"""Resets all the surfaces in the collection.
This method iterates over each surface in the collection and calls
its `reset` method.
"""
for surface in self.surfaces:
surface.reset()
[docs]
def set_fresnel_coatings(self):
"""Set Fresnel coatings on all surfaces in the group."""
for surface in self.surfaces[1:-1]:
if surface.material_pre != surface.material_post:
surface.set_fresnel_coating()
[docs]
def to_dict(self):
"""Convert the surface group to a dictionary.
Returns:
dict: The surface group as a dictionary.
"""
return {"surfaces": [surface.to_dict() for surface in self.surfaces]}
[docs]
@classmethod
def from_dict(cls, data):
"""Create a surface group from a dictionary.
Args:
data (dict): The dictionary to create the surface group from.
Returns:
SurfaceGroup: The surface group created from the dictionary.
"""
return cls(
[Surface.from_dict(surface_data) for surface_data in data["surfaces"]],
)
def _update_coordinate_systems(self, start_index):
"""Updates the coordinate systems of surfaces from start_index.
This method is called when a surface is added, removed, or modified
in a way that might affect the positions of subsequent surfaces,
but only if absolute coordinate positioning (use_absolute_cs=True)
is not being used by the coordinate system factory.
It recalculates the z-coordinate of each surface based on the
z-coordinate and 'thickness' attribute of the preceding surface.
Args:
start_index (int): The index of the surface from which to start
updating coordinate systems. The surface at
`start_index` itself will be updated if it's
not the object surface (index 0) and has a predecessor.
If `start_index` is 0, updates effectively begin
for surface 1 based on surface 0.
Raises:
UnsupportedParaxialGeometryError: If the current chain's beam
path is folded off global +z (or entered along another
direction), before any coordinate is rewritten.
Reconstructing every downstream position as predecessor
``z + thickness`` is only meaningful while the beam path
runs along global +z; on a folded chain it would collapse
the fold onto the z axis.
"""
if not self._surfaces:
return
# Defensive gate for every entry point (insertion and removal
# preflight the would-be chain before mutating the list; direct or
# future callers land here). Ordinary relative-coordinate
# construction is unaffected: chains classified straight pass
# through, and a single surface defines no path to misread.
if len(self._surfaces) >= 2:
require_global_z_geometry(
self._surfaces,
"relative-coordinate reconstruction "
"(SurfaceGroup._update_coordinate_systems)",
)
effective_start_index = max(start_index, 1) # No update to object surface
for i in range(effective_start_index, len(self._surfaces)):
current_surface = self._surfaces[i]
if i == 1: # first surface lies at z=0.0 by definition
new_z = 0.0
else:
prev_surface = self._surfaces[i - 1]
thickness = prev_surface.thickness
if hasattr(thickness, "item"):
thickness = thickness.item()
if be.isinf(thickness):
raise ValueError(
f"Coordinate system update failed due to infinite "
f"thickness at surface {start_index - 1}"
)
prev_z = prev_surface.geometry.cs.z
if hasattr(prev_z, "item"):
prev_z = prev_z.item()
new_z = float(prev_z) + thickness
current_surface.geometry.cs.z = be.array(float(new_z))
[docs]
def flip(
self,
start_index: int = 0,
end_index: int = 0,
):
"""Flips a segment of the surfaces in the group.
The function will swap the materials on the Object and Image surface if both
`start_index` and `end_index` are zero. Subgroups can be swapped by passing the
index of the first surface and the index of the surface after the last surface
of the group (standard Python slicing). Note that only "sensible" results are
obtained when the material before and after the subgroup is the same (for
example, air).
Args:
start_index (int, optional): The starting index of the segment of
surfaces to flip. Defaults to 0 (include object surface).
end_index (int, optional): The ending index (exclusive for positive,
inclusive for negative slice behavior) of the segment of surfaces
to flip. Defaults to 0 (up to, and including, the image surface).
Raises:
RuntimeError: If either `start_index` or `end_index` is zero, but not both.
UnsupportedParaxialGeometryError: If the beam path is folded off
global +z (or entered along another direction), before any
surface order, material, coordinate or thickness is touched.
Flipping derives new positions and thicknesses from global z
differences, which is meaningless on a folded chain. This
guard protects the direct ``SurfaceGroup.flip()`` API as
well as ``OpticUpdater.flip()``.
"""
n_surfaces_total = len(self._surfaces)
if (start_index == 0 or end_index == 0) and not (
start_index == 0 and end_index == 0
):
raise RuntimeError(
"Cannot flip object surface or image surface without flipping both"
)
# Preflight-atomic: reject before list reversal, material swaps,
# coordinate changes or thickness updates.
require_global_z_geometry(self._surfaces, "SurfaceGroup.flip")
flip_object_image_media = start_index == 0 and end_index == 0
if flip_object_image_media:
start_index = 1
end_index = len(self.surfaces) - 1
if start_index < 0:
start_index = n_surfaces_total + start_index
if end_index < 0:
actual_slice_end_index = n_surfaces_total + end_index
else:
actual_slice_end_index = end_index
if start_index >= actual_slice_end_index:
# No surfaces to flip or invalid range
self.reset()
return
original_indices_in_segment = list(range(start_index, actual_slice_end_index))
if not original_indices_in_segment:
self.reset()
return
# Extract the segment, reverse it, and place it back
segment_to_reverse = self._surfaces[start_index:actual_slice_end_index]
z_positions = be.ravel(
be.array([surf.geometry.cs.z for surf in segment_to_reverse])
)
segment_to_reverse.reverse()
self._surfaces[start_index:actual_slice_end_index] = segment_to_reverse
# Ignore thickness attribute, determine new thickness based on z-coordinate of
# surfaces.
new_thickness = be.flip(
be.diff(z_positions, prepend=be.array([z_positions[0]]))
)
new_thickness[-1] = (
self._surfaces[actual_slice_end_index].geometry.cs.z - z_positions[-1]
)
new_z = (
be.flip(be.diff(z_positions, append=be.array([z_positions[-1]]))).cumsum(0)
+ z_positions[0]
)
for surf, thickness, z in zip(
segment_to_reverse, new_thickness, new_z, strict=True
):
surf.flip()
surf.geometry.cs.z = z
surf.thickness = thickness
# Special handling: flip materials on object and image surfaces if flip() called
# without arguments
if flip_object_image_media:
self.surfaces[0].material_post, self.surfaces[-1].material_post = (
self.surfaces[-1].material_post,
self.surfaces[0].material_post,
)
self._update_surface_links()
self.reset()