"""Optical System Visualization Module
This module provides tools for visualizing optical systems.
It utilizes Matplotlib to render optical components and ray tracing paths.
The `OpticViewer` class is the primary interface for generating these visualizations,
offering customization for ray properties, field of view, and display parameters.
Kramer Harrison, 2024
re-worked by Manuel Fragata Mendes, june 2025
"""
from __future__ import annotations
import numpy as np
import optiland.backend as be
from optiland.visualization.base import BaseViewer2D
from optiland.visualization.system.interaction import InteractionManager
from optiland.visualization.system.rays import Rays2D
from optiland.visualization.system.system import OpticalSystem
[docs]
class OpticViewer(BaseViewer2D):
"""A class used to visualize optical systems.
Args:
optic: The optical system to be visualized.
Attributes:
optic: The optical system to be visualized.
rays: An instance of Rays2D for ray tracing.
system: An instance of OpticalSystem for system representation.
Methods:
See :meth:`view` for visualizing the optical system with specified
parameters.
"""
def __init__(self, optic):
self.optic = optic
self.rays = Rays2D(optic)
self.system = OpticalSystem(optic, self.rays, projection="2d")
self.legend_artist_map = {}
[docs]
def view(
self,
fields="all",
wavelengths="primary",
num_rays=3,
distribution=None,
show_apertures=True,
hide_vignetted=False,
figsize=None,
xlim=None,
ylim=None,
title=None,
reference=None,
tooltip_format=None,
show_legend=True,
projection="YZ",
ax=None,
theme=None,
):
"""Visualizes the optical system.
Args:
fields (str, optional): The fields to be visualized.
Defaults to 'all'.
wavelengths (str, optional): The wavelengths to be visualized.
Defaults to 'primary'.
num_rays (int, optional): The number of rays to be visualized.
Defaults to 3.
distribution (str | None, optional): The distribution of rays.
Defaults to None, which selects a default based on projection.
show_apertures (bool, optional): If True, overlays aperture graphics
on the system view. Defaults to True.
hide_vignetted (bool, optional): If True, rays that vignette at any
surface are not shown. Defaults to False.
figsize (tuple, optional): The size of the figure.
Defaults to None, which uses the theme's default.
xlim (tuple, optional): The x-axis limits. Defaults to None.
ylim (tuple, optional): The y-axis limits. Defaults to None.
reference (str, optional): The reference rays to plot. Options
include "chief" and "marginal". Defaults to None.
projection (str, optional): The projection plane. Must be 'XY',
'XZ', or 'YZ'. Defaults to 'YZ'.
ax (matplotlib.axes.Axes, optional): The axes to plot on.
If None, a new figure and axes are created. Defaults to None.
"""
if projection not in ["XY", "XZ", "YZ"]:
raise ValueError("Invalid projection type. Must be 'XY', 'XZ', or 'YZ'.")
if distribution is None:
if projection == "XY":
distribution = "hexapolar"
elif projection == "XZ":
distribution = "line_x"
else:
distribution = "line_y"
theme = self._resolve_theme(theme)
fig, ax = self._make_figure(theme, figsize, ax)
interaction_manager = InteractionManager(fig, ax, self.optic, tooltip_format)
ray_artists = self.rays.plot(
ax,
fields=fields,
wavelengths=wavelengths,
num_rays=num_rays,
distribution=distribution,
reference=reference,
theme=theme,
projection=projection,
hide_vignetted=hide_vignetted,
)
for artist, ray_bundle in ray_artists.items():
interaction_manager.register_artist(artist, ray_bundle)
system_artists = self.system.plot(
ax, theme=theme, projection=projection, show_apertures=show_apertures
)
for artist, surface in system_artists.items():
interaction_manager.register_artist(artist, surface)
ax.axis("image")
if xlim is None or ylim is None:
auto_xlim, auto_ylim = self._default_axis_limits(projection)
xlim = xlim or auto_xlim
ylim = ylim or auto_ylim
self._apply_axes_style(ax, projection, theme, title=title, xlim=xlim, ylim=ylim)
# Return the figure, axes and interaction_manager
return fig, ax, interaction_manager
def _default_axis_limits(self, projection):
"""Compute default axis limits sized to the lens system rather than
the full ray extent.
For an infinite-conjugate (angle field) system, the object-side ray
segment is drawn from an arbitrary, often very distant, launch
point -- fine for aiming, but if left to matplotlib's autoscale it
dominates the view for wide-FOV systems, squeezing the actual lens
system down to an unreadable sliver. Sizing instead from the real
surfaces (z) and ``r_extent`` -- the same radially-symmetric,
per-surface ray extent ``OpticalSystem`` already uses to size lens
and mirror components (see ``system.py``) -- keeps the transverse
limits centered on the optical axis and guaranteed to cover
whatever radius the lens components are actually drawn at (an
asymmetric min/max of the traced rays' signed coordinates isn't
enough: a field that's only ever traced on one side of the axis
would leave the *other*, still-drawn side of the lens clipped),
while still leaving margin to see rays approaching and entering
the first surface.
Args:
projection (str): The projection plane, 'XY', 'XZ', or 'YZ'.
Returns:
tuple: ``(xlim, ylim)``, each either a ``(min, max)`` tuple or
``None`` if there isn't enough information to size that axis
(falls back to matplotlib's own autoscale).
"""
if projection == "XY":
# A single cross-section at one z -- no object-segment issue.
return None, None
# Axis limits are drawn in the global frame, so use the full 3-D
# vertex chain rather than the unfolded axial coordinate
# ``positions`` reports. A folded path can double back in z (first/
# last surface do not bound the span) and walk off-axis
# transversely (the vertical axis is not centered on 0), so both
# axes are sized from the min/max vertex coordinates. For a
# straight +z system every vertex has x = y = 0 and z increases
# monotonically, which reduces to the previous first-to-last-z,
# symmetric-about-the-axis behavior exactly.
vertices = be.to_numpy(self.optic.surfaces.vertices_gcs)
start_idx = 1 if self.optic.object_surface.is_infinite else 0
if start_idx >= vertices.shape[0] - 1:
return None, None
vertices = vertices[start_idx:]
z_min = float(vertices[:, 2].min())
z_max = float(vertices[:, 2].max())
z_margin = max(0.15 * (z_max - z_min), 1e-6)
auto_xlim = (z_min - z_margin, z_max + z_margin)
r_extent = be.to_numpy(self.rays.r_extent)[start_idx:]
r_extent = r_extent[np.isfinite(r_extent)]
if r_extent.size == 0:
return auto_xlim, None
r_max = float(r_extent.max())
if r_max <= 0:
return auto_xlim, None
# Vertical axis: transverse component of the drawn projection.
t_comp = 0 if projection == "XZ" else 1
t_min = float(vertices[:, t_comp].min())
t_max = float(vertices[:, t_comp].max())
r_margin = max(0.15 * max(r_max, t_max - t_min), 1e-6)
auto_ylim = (t_min - r_max - r_margin, t_max + r_max + r_margin)
return auto_xlim, auto_ylim