Source code for sequences.surface_view

"""SurfaceView: a per-sequence proxy over a shared base ``Surface``.

A ``SurfaceView`` shares geometry, material, aperture and coating with a
base :class:`~optiland.surfaces.standard_surface.Surface` by reference, but
owns its own per-visit record buffers, direction and interaction model. It
implements the same duck-typed interface that ``_TracingCoordinator`` and
the ``BaseRays`` subclasses use (``reset``, ``geometry``, ``_trace_real``,
``_record_real``, ``_trace_paraxial``, ``_record_paraxial``), so it traces
through the existing, unforked tracing pipeline with zero changes to
``Surface`` or the rays classes.

Kramer Harrison, 2026
"""

from __future__ import annotations

import copy
from types import SimpleNamespace
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from optiland.materials import BaseMaterial
    from optiland.physical_apertures import BaseAperture
    from optiland.rays import ParaxialRays, RealRays
    from optiland.surfaces.standard_surface import Surface


[docs] def resolve_view_materials( base_surface: Surface, reverse: bool, interaction_override: str | None, ) -> tuple[BaseMaterial, BaseMaterial]: """Resolve a view's incident/exit materials from its base surface. Args: base_surface: The shared base surface. reverse: Whether this step traverses the surface in the reverse physical direction. interaction_override: ``"reflect"``, ``"refract"``, or ``None``. Returns: The ``(material_pre, material_post)`` pair for this view. """ if reverse: material_pre = base_surface.material_post material_post = base_surface.material_pre else: material_pre = base_surface.material_pre material_post = base_surface.material_post if interaction_override == "reflect": # A reflection never crosses into the far medium: the ray leaves on # the same side it arrived on. material_post = material_pre return material_pre, material_post
[docs] class SurfaceView: """A view of a base ``Surface`` within one step of a sequence. Geometry, aperture, and coating/BSDF objects are shared with the base surface by reference, so editing the base surface (or optimizing a variable on it) is immediately visible through every view. Record buffers, the interaction model instance, and the traversal direction are owned by the view. Args: base_surface: The shared base surface. reverse: Whether this step traverses the surface in the reverse physical direction. interaction_override: ``"reflect"``, ``"refract"``, or ``None`` to use the base surface's nominal interaction. previous_view: The preceding view in the sequence, or ``None`` if this is the first step. """ def __init__( self, base_surface: Surface, reverse: bool = False, interaction_override: str | None = None, previous_view: SurfaceView | None = None, ): self.base_surface = base_surface self.reverse = reverse self.interaction_override = interaction_override self.previous_view = previous_view self.interaction_model = copy.copy(base_surface.interaction_model) self.interaction_model.parent_surface = self if interaction_override == "reflect": self.interaction_model.is_reflective = True elif interaction_override == "refract": self.interaction_model.is_reflective = False self._rebind_coating() self.reset() def _rebind_coating(self) -> None: """Rebind polarized coatings to this view's resolved media.""" coating = getattr(self.interaction_model, "coating", None) if coating is None: return from optiland.coatings import FresnelCoating, ThinFilmCoating if isinstance(coating, FresnelCoating): self.interaction_model.coating = FresnelCoating( self.material_pre, self.material_post ) elif isinstance(coating, ThinFilmCoating): layers = [ (layer.material, layer.thickness_nm, layer.name) for layer in coating.stack.layers ] if self.reverse: layers = layers[::-1] self.interaction_model.coating = ThinFilmCoating( self.material_pre, self.material_post, layers=layers ) # -- Shared-by-reference passthrough properties ----------------------- @property def geometry(self): return self.base_surface.geometry @property def aperture(self) -> BaseAperture | None: return self.base_surface.aperture @property def semi_aperture(self) -> float | None: return self.base_surface.semi_aperture @property def is_stop(self) -> bool: return self.base_surface.is_stop @property def comment(self) -> str: return self.base_surface.comment @property def surface_type(self) -> str | None: return self.base_surface.surface_type @property def thickness(self) -> float: return self.base_surface.thickness # -- Owned, per-view material resolution ------------------------------- @property def material_pre(self) -> BaseMaterial: pre, _ = resolve_view_materials( self.base_surface, self.reverse, self.interaction_override ) return pre @property def material_post(self) -> BaseMaterial: _, post = resolve_view_materials( self.base_surface, self.reverse, self.interaction_override ) return post @property def previous_surface(self): """Anchor for ``BaseInteractionModel.material_pre``'s chain lookup. ``BaseInteractionModel.material_pre`` resolves as ``self.parent_surface.previous_surface.material_post``. Views resolve their materials directly from the base surface (see :func:`resolve_view_materials`) rather than by walking a physical chain, so this returns a stand-in object exposing exactly that already-resolved value — always taking the non-``None`` branch, which keeps the first-step case correct too. """ return SimpleNamespace(material_post=self.material_pre) # -- Tracing pipeline ---------------------------------------------------- # # Every kernel below dispatches to the *base surface's class*, bound to # ``self`` (the view). This is deliberate, not cosmetic: some Surface # subclasses override these kernels — ObjectSurface.trace() skips # localize/globalize entirely (the object is often at z=-inf, where that # transform is undefined), and ImageSurface/ObjectSurface override the # paraxial and/or real-ray kernels to no-ops. Reimplementing the generic # Surface physics inline here would silently diverge from those # overrides for object/image steps. Dispatching through # ``type(self.base_surface)`` reuses whatever kernel actually applies, # bound to the view so it reads/writes the view's own state (geometry, # material_pre/post, aperture, interaction_model, and record buffers all # resolve through the view's own attributes) — the same "ride the # existing coordinator" approach the base tracing pipeline itself uses.
[docs] def reset(self) -> None: """Resets the recorded information owned by this view.""" type(self.base_surface).reset(self)
[docs] def trace(self, rays): return type(self.base_surface).trace(self, rays)
@property def _coordinator(self): from optiland.surfaces.standard_surface import _TracingCoordinator try: return self.__coordinator except AttributeError: self.__coordinator = _TracingCoordinator() return self.__coordinator def _trace_paraxial(self, rays: ParaxialRays) -> ParaxialRays: return type(self.base_surface)._trace_paraxial(self, rays) def _trace_real(self, rays: RealRays) -> RealRays: return type(self.base_surface)._trace_real(self, rays) def _record_paraxial(self, rays: ParaxialRays) -> None: type(self.base_surface)._record_paraxial(self, rays) def _record_real(self, rays: RealRays) -> None: type(self.base_surface)._record_real(self, rays) def __repr__(self) -> str: direction = "reverse" if self.reverse else "forward" override = self.interaction_override or "nominal" return ( f"SurfaceView(base={self.base_surface!r}, {direction}, " f"interaction={override})" )