Source code for visualization.system.optic_viewer

"""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.lens import Lens2D
from optiland.visualization.system.rays import Rays2D
from optiland.visualization.system.surface import Surface2D
from optiland.visualization.system.system import OpticalSystem
from optiland.visualization.system.utils import transform


[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, show=True, ): """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. show (bool, optional): If True (default), calls plt.show(). Set False for headless use (e.g. saving to file, CI environments) or when embedding in an existing figure via ``ax``. """ 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" is_gui_embedding = ax is not None 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) if show and not is_gui_embedding: import matplotlib.pyplot as plt plt.show() # 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:] # Resolve bounds from the components that will actually be drawn. In # particular, a ray's first blocking hit can be far outside a finite # physical aperture and must not enlarge the auto-fit region. surfaces = [] for component in self.system.components: if isinstance(component, Surface2D): surfaces.append(component) elif isinstance(component, Lens2D): surfaces.extend(component.surfaces) r_extent = ( np.asarray([float(be.to_numpy(surface.extent)) for surface in surfaces]) if surfaces else be.to_numpy(self.rays.r_extent)[start_idx:] ) # A tilted image marker can extend past the first/last vertex along Z. # Include its transformed outline, without tracing any additional rays. z_bounds = [vertices[:, 2].ravel()] for surface in surfaces: x, y, z = surface._compute_sag(projection) _, _, z = transform(x, y, z, surface.surf, is_global=False) z = be.to_numpy(z) z_bounds.append(z[np.isfinite(z)]) z_bounds = np.concatenate(z_bounds) z_min, z_max = float(z_bounds.min()), float(z_bounds.max()) z_margin = max(0.15 * (z_max - z_min), 1e-6) auto_xlim = (z_min - z_margin, z_max + z_margin) 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