Skip to content

2D layout: annular aperture (RadialAperture with r_min) causes polygon fill artifacts in Lens2D #538

Description

@nevillebonwit-eyeqtech

Description

When a surface has a RadialAperture with r_min > 0 (annular aperture) and is part of a lens group, the 2D layout drawing (Optic.draw() and the Optiland GUI) produces visual artifacts: a spurious diagonal line across the lens body connecting the edges of the annular gap.

A secondary effect occurs when annular and non-annular surfaces share the same lens group (all n > 1): adjacent non-annular surface polygons show missing chunks because the NaN gap propagates into their shared polygon boundary.

The root cause is that Surface2D._compute_sag() sets coordinates to NaN where aperture.clip() blocks rays (i.e., r < r_min). This works correctly for standalone surface line plots (ax.plot() handles NaN as line breaks), but Lens2D._plot_lenses() passes the NaN-contaminated coordinate arrays directly into matplotlib.patches.Polygon, which has undefined fill behavior with NaN vertices.

Steps to Reproduce

Case 1: Diagonal line artifact (annular surfaces in their own group)

import numpy as np
from optiland import optic
from optiland.materials import IdealMaterial
from optiland.physical_apertures import RadialAperture

lens = optic.Optic()

# Surface 0: object
lens.add_surface(index=0, radius=np.inf, thickness=5)

# Surface 1: curved surface, stop
lens.add_surface(index=1, radius=7.72, thickness=0.55,
                 material=IdealMaterial(n=1.369),
                 aperture=RadialAperture(r_max=5.5),
                 is_stop=True)

# Surface 2: closes first lens group (n=1.0)
lens.add_surface(index=2, radius=6.5, thickness=0.25,
                 material=IdealMaterial(n=1.0),
                 aperture=RadialAperture(r_max=5.5))

# Surface 3: annular aperture (r_min=5.0) -- triggers the bug
lens.add_surface(index=3, radius=11.5, thickness=0.01,
                 material=IdealMaterial(n=1.38),
                 aperture=RadialAperture(r_max=11.0, r_min=5.0))

# Surface 4: closes second lens group (n=1.0)
lens.add_surface(index=4, radius=11.5, thickness=10,
                 material=IdealMaterial(n=1.0),
                 aperture=RadialAperture(r_max=11.0, r_min=5.0))

# Surface 5: image
lens.add_surface(index=5)

lens.set_aperture(aperture_type='EPD', value=3.0)
lens.set_field_type('angle')
lens.add_field(y=0)
lens.add_wavelength(value=0.55, is_primary=True)

lens.draw()

Case 2: Missing chunks (annular + non-annular in same group)

Change surfaces 1-4 to all have n > 1 so they form a single lens group:

# Same as above, but change surfaces 2 and 4 material:
# Surface 2: n=1.332 instead of 1.0 (stays in group)
# Surface 4: n=1.332 instead of 1.0 (stays in group)

With all surfaces in one group, the max aperture (11.0 from the annular surfaces) inflates all surfaces, and the NaN gap from the annular aperture creates missing chunks in the polygon connecting the non-annular surface to the annular surface.

Expected Behavior

The annular surfaces should render as two clean arcs (left and right halves) with a gap in the center where r < r_min. The filled polygon between them should only cover the annular region. Adjacent non-annular surfaces should be unaffected.

Actual Behavior

  • Case 1: Spurious diagonal line connecting the left and right arcs across the NaN gap (visible on one side of the layout)
  • Case 2: Missing chunks in adjacent non-annular surface polygons, plus the diagonal line

Root Cause

File: optiland/visualization/system/surface.py, lines 104-116

# handle physical apertures for line cross-sections
if self.surf.aperture:
    ...
    self.surf.aperture.clip(rays)
    ...
    x[rays.i == 0] = be.nan   # <-- NaN injection (line 114)

File: optiland/visualization/system/lens.py, line 187+

def _plot_lenses(self, ax, sags, theme=None, projection="YZ"):
    for k in range(len(sags) - 1):
        x1, y1, z1 = sags[k]
        x2, y2, z2 = sags[k + 1]
        # NaN values from annular aperture are concatenated directly
        x = be.concatenate([x1, be.flip(x2)])
        ...
        # Polygon with NaN vertices has undefined matplotlib fill behavior
        polygon = Polygon(vertices, ...)

The NaN values work correctly for ax.plot() (line breaks) but cause undefined fill behavior in matplotlib.patches.Polygon.

Suggested Fix

In Lens2D._plot_lenses(), before creating the Polygon, filter out NaN vertices and split into separate polygons for each contiguous non-NaN segment:

# Split concatenated vertices at NaN boundaries
valid = ~np.isnan(vertices[:, 0]) & ~np.isnan(vertices[:, 1])
segments = np.split(np.arange(len(valid)), np.where(~valid)[0])
for seg in segments:
    seg = seg[valid[seg]]  # remove NaN indices
    if len(seg) > 2:
        polygon = Polygon(vertices[seg], closed=True, ...)
        ax.add_patch(polygon)

Note: this is a sketch -- the actual implementation may need to handle the polygon closure at the NaN boundaries more carefully to produce clean annular fill regions.

Additional Note: Hardcoded 128-Point Resolution

Surface2D._compute_sag() uses a hardcoded 128 points for the 2D cross-section sampling (lines 97-101). It would be useful to expose this as a configurable parameter through the draw() API, e.g. lens.draw(num_sag_points=256). This would allow users to trade rendering speed for smoother surface profiles, especially for surfaces with small features or annular apertures.

Environment

  • Optiland: latest via pip (March 2026)
  • Python 3.13, Windows 11
  • matplotlib backend: QtAgg (Optiland GUI) and Agg (scripted)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions