from collections import OrderedDict
from functools import cached_property

import numpy as np

from .coating import SimpleCoating
from .obscuration import ObscNegation, ObscCircle, ObscAnnulus
from .constants import globalCoordSys, vacuum
from .coordTransform import CoordTransform
from .rayVector import RayVector


class Optic:
    """The base class for all varieties of batoid optics and optical systems.

    An `Optic` can include anything from a single reflective or refractive
    surface to an entire telescope including multiple mirrors and/or lenses.

    Parameters
    ----------
    name : str, optional
        An name for this optic
    inMedium : `batoid.Medium`, optional
        Medium in which approaching rays reside.  Default: vacuum.
    outMedium : `batoid.Medium`, optional
        Medium in which rays will leave this optic.  Default: vacuum.
    coordSys : `batoid.CoordSys`, optional
        Coordinate system indicating the position and orientation of this
        optic's vertex with respect to the global coordinate system.  Default:
        the global coordinate system.
    skip : bool, optional
        Whether or not to skip this optic when tracing.  This can be useful if
        you want to trace only through part of a compound optic.  Default:
        False.
    **kwargs : other
        Other attributes to add as object attributes.
    """
    def __init__(self, name=None, coordSys=globalCoordSys, inMedium=vacuum,
                 outMedium=vacuum, skip=False, **kwargs):
        self.name = name
        self.coordSys = coordSys
        self.inMedium = inMedium
        self.outMedium = outMedium
        self.skip = False
        kwargs.pop('itemDict', None)
        self.__dict__.update(**kwargs)

    def _repr_helper(self):
        out = ""
        if self.name is not None:
            out += ", name={!r}".format(self.name)
        out += ", coordSys={!r}".format(self.coordSys)
        if self.inMedium != vacuum:
            out += ", inMedium={!r}".format(self.inMedium)
        if self.outMedium != vacuum:
            out += ", outMedium={!r}".format(self.outMedium)
        return out

    @classmethod
    def fromYaml(cls, filename):
        """Load an `Optic` (commonly a complete telescope) from the given yaml
        file.  This is the most common way to create an `Optic`.  If the file
        is not initially found, then look in the ``batoid.datadir`` directory
        and subdirectories for the first matching filename and use that.

        Look in ``batoid.datadir`` for examples of how to format a batoid optic
        yaml file.

        Parameters
        ----------
        filename : str
            Name of yaml file to load

        Returns
        -------
        `Optic`
        """
        import os
        import yaml
        try:
            with open(filename) as f:
                config = yaml.safe_load(f)
        except FileNotFoundError:
            import glob
            from . import datadir
            filenames = glob.glob(os.path.join(datadir, "**", "*.yaml"))
            for candidate in filenames:
                if os.path.basename(candidate) == filename:
                    with open(candidate) as f:
                        config = yaml.safe_load(f)
                    break
            else:
                raise FileNotFoundError(filename)
        from .parse import parse_optic
        return parse_optic(config['opticalSystem'])


class Interface(Optic):
    """An `Optic` representing a single surface.  Almost always one of the
    concrete subclasses (`Mirror`, `RefractiveInterface`, `Baffle`, `Detector`)
    should be instantiated, depending on whether rays should reflect, refract,
    vignette/pass-through, or stop at this surface.

    Parameters
    ----------
    surface : `batoid.Surface`
        The surface instance for this Interface.
    obscuration : `batoid.Obscuration`, optional
        `batoid.Obscuration` instance indicating which x,y coordinates are
        obscured/unobscured for rays intersecting this optic.  Default: None,
        which means don't apply any ray obscuration.
    **kwargs :
        Other parameters to forward to ``Optic.__init__``
    """
    def __init__(self, surface, obscuration=None, **kwargs):
        Optic.__init__(self, **kwargs)
        self.path = [self.name]
        self.surface = surface
        self.obscuration = obscuration

        # Stealing inRadius and outRadius from self.obscuration.  These are
        # required for the draw methods.
        self.inRadius = self.R_inner
        self.outRadius = None
        if self.obscuration is not None:
            if isinstance(self.obscuration, ObscNegation):
                if isinstance(self.obscuration.original, ObscCircle):
                    self.outRadius = self.obscuration.original.radius
                elif isinstance(self.obscuration.original, ObscAnnulus):
                    self.outRadius = self.obscuration.original.outer
                    self.inRadius = self.obscuration.original.inner
            elif isinstance(self.obscuration, ObscCircle):
                self.outRadius = self.obscuration.radius
            elif isinstance(self.obscuration, ObscAnnulus):
                self.outRadius = self.obscuration.outer
                self.inRadius = self.obscuration.inner
        if self.inRadius is None:
            self.inRadius = self.R_inner
        if self.outRadius is None:
            self.outRadius = self.R_outer

    def __hash__(self):
        return hash((self.__class__.__name__, self.surface, self.obscuration,
                     self.name, self.inMedium, self.outMedium, self.coordSys))

    def draw3d(self, ax, plotly=False, **kwargs):
        """Draw this interface on a mplot3d axis.

        Parameters
        ----------
        ax : mplot3d.Axis or plotly.graph_objs.Figure
            Axis on which to draw this optic.
        plotly : bool
            Set True to use plotly API instead of ipyvolume/matplotlib API
        """
        if self.outRadius is None:
            return
        transform = CoordTransform(self.coordSys, globalCoordSys)
        # Going to draw 4 objects here: inner circle, outer circle, sag along
        # x=0, sag along y=0 inner circle
        if self.inRadius != 0.0:
            th = np.linspace(0, 2*np.pi, 100)
            cth, sth = np.cos(th), np.sin(th)
            x = self.inRadius * cth
            y = self.inRadius * sth
            z = self.surface.sag(x, y)
            x, y, z = transform.applyForwardArray(x, y, z)
            if plotly:
                ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
            else:
                ax.plot(x, y, z, **kwargs)

        #outer circle
        th = np.linspace(0, 2*np.pi, 100)
        cth, sth = np.cos(th), np.sin(th)
        x = self.outRadius * cth
        y = self.outRadius * sth
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        if plotly:
            ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
        else:
            ax.plot(x, y, z, **kwargs)

        #next, a line at X=0
        y = np.linspace(-self.outRadius, -self.inRadius)
        x = np.zeros_like(y)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        if plotly:
            ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
        else:
            ax.plot(x, y, z, **kwargs)
        y = np.linspace(self.inRadius, self.outRadius)
        x = np.zeros_like(y)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        if plotly:
            ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
        else:
            ax.plot(x, y, z, **kwargs)

        #next, a line at Y=0
        x = np.linspace(-self.outRadius, -self.inRadius)
        y = np.zeros_like(x)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        if plotly:
            ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
        else:
            ax.plot(x, y, z, **kwargs)
        x = np.linspace(self.inRadius, self.outRadius)
        y = np.zeros_like(x)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        if plotly:
            ax.add_scatter3d(x=x, y=y, z=z, **kwargs)
        else:
            ax.plot(x, y, z, **kwargs)

    def get3dmesh(self):
        if self.outRadius is None:
            return
        transform = CoordTransform(self.coordSys, globalCoordSys)
        # Going to draw 4 objects here: inner circle, outer circle, sag along
        # x=0, sag along y=0 inner circle
        xs = []
        ys = []
        zs = []
        if self.inRadius != 0.0:
            th = np.linspace(0, 2*np.pi, 100)
            cth, sth = np.cos(th), np.sin(th)
            x = self.inRadius * cth
            y = self.inRadius * sth
            z = self.surface.sag(x, y)
            x, y, z = transform.applyForwardArray(x, y, z)
            xs.extend(x)
            ys.extend(y)
            zs.extend(z)
            # Separate by adding a nan
            xs.append(np.nan)
            ys.append(np.nan)
            zs.append(np.nan)

        #outer circle
        th = np.linspace(0, 2*np.pi, 100)
        cth, sth = np.cos(th), np.sin(th)
        x = self.outRadius * cth
        y = self.outRadius * sth
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        xs.extend(x)
        ys.extend(y)
        zs.extend(z)
        xs.append(np.nan)
        ys.append(np.nan)
        zs.append(np.nan)

        #next, a line at X=0
        y = np.linspace(-self.outRadius, -self.inRadius)
        x = np.zeros_like(y)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        xs.extend(x)
        ys.extend(y)
        zs.extend(z)
        xs.append(np.nan)
        ys.append(np.nan)
        zs.append(np.nan)

        y = np.linspace(self.inRadius, self.outRadius)
        x = np.zeros_like(y)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        xs.extend(x)
        ys.extend(y)
        zs.extend(z)
        xs.append(np.nan)
        ys.append(np.nan)
        zs.append(np.nan)

        #next, a line at Y=0
        x = np.linspace(-self.outRadius, -self.inRadius)
        y = np.zeros_like(x)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        xs.extend(x)
        ys.extend(y)
        zs.extend(z)
        xs.append(np.nan)
        ys.append(np.nan)
        zs.append(np.nan)

        x = np.linspace(self.inRadius, self.outRadius)
        y = np.zeros_like(x)
        z = self.surface.sag(x, y)
        x, y, z = transform.applyForwardArray(x, y, z)
        xs.extend(x)
        ys.extend(y)
        zs.extend(z)
        xs.append(np.nan)
        ys.append(np.nan)
        zs.append(np.nan)

        return np.array(xs), np.array(ys), np.array(zs)

    def getXZSlice(self, nslice=0):
        """Calculate global coordinates for an (x,z) slice through this
        interface.

        The calculation is split into two half slices: xlocal <= 0 and xlocal
        >= 0.  When the inner radius is zero, these half slices are merged into
        one.  Otherwise, the two half slices are returned separately.

        If the local coordinate system involves any rotation the resulting
        slice may not be calculated correctly since we are really slicing in
        (xlocal, zlocal) then transforming these to (xglobal, zglobal).

        Parameters
        ----------
        nslice : int
            Use the specified number of points on each half slice. When zero,
            the value will be calculated automatically (and will be 2 for
            planar surfaces).

        Returns
        -------
        tuple
            Tuple (xz1, xz2) of 1D arrays where xz1=[x1, z1] is the xlocal <= 0
            half slice and xz2=[x2, z2] is the xlocal >= 0 half slice.
        """
        from .surface import Plane
        slice = []
        if self.outRadius is None:
            return slice
        if nslice <= 0:
            if isinstance(self.surface, Plane):
                nslice = 2
            else:
                nslice = 50
        # Calculate (x,z) slice in local coordinates for x <= 0.
        x = np.linspace(-self.outRadius, -self.inRadius, nslice)
        y = np.zeros_like(x)
        z = self.surface.sag(x, y)
        # Transform slice to global coordinates.
        transform = CoordTransform(self.coordSys, globalCoordSys)
        xneg, yneg, zneg = transform.applyForwardArray(x, y, z)
        if np.any(yneg != 0):
            print('WARNING: getXZSlice used for rotated surface "{0}".'
                .format(self.name)
            )
        # Calculate (x,z) slice in local coordinates for x >= 0.
        x *= -1
        x = x[::-1]
        z[:] = self.surface.sag(x, y)
        # Transform slice to global coordinates.
        xpos, ypos, zpos = transform.applyForwardArray(x, y, z)
        if np.any(ypos != 0):
            print('WARNING: getXZSlice used for rotated surface "{0}".'
                .format(self.name)
            )
        slice.append(np.stack((xpos, zpos), axis=0))
        # Combine x <= 0 and x >= 0 half slices when inner = 0.
        if self.inRadius == 0:
            assert xneg[-1] == xpos[0] and zneg[-1] == zpos[0]
            return (
                np.stack((
                    np.hstack((xneg, xpos[1:])),
                    np.hstack((zneg, zpos[1:]))
                ), axis=0),
            )
        else:
            return (
                np.stack((xneg, zneg), axis=0),
                np.stack((xpos, zpos), axis=0)
            )

    def draw2d(self, ax, **kwargs):
        """Draw this interface on a 2d matplotlib axis.
        May not work if elements are non-circular or not axis-aligned.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Axis on which to draw this optic.
        """
        slice = self.getXZSlice()
        for x, z in slice:
            ax.plot(x, z, **kwargs)

    def trace(self, rv, reverse=False):
        """Trace ray through this optical element.

        Parameters
        ----------
        rv : `batoid.RayVector`
            Input rays to trace, transforming in place.
        reverse : bool
            Trace through optical element in reverse?  Default: False

        Returns
        -------
        `batoid.RayVector`
            Reference to transformed input rays.

        Notes
        -----
        This operation is performed in place; the return value is a reference to
        the transformed input `RayVector`.

        The transformed rays will be expressed in the local coordinate system of
        the Optic.  See `RayVector.toCoordSys` to express rays in a different
        coordinate system.

        Also, you may need to reverse the directions of rays if using this
        method with ``reverse=True``.
        """
        # refract, reflect, pass-through - depending on subclass
        self.interact(rv, reverse=reverse)

        if self.obscuration is not None:
            self.obscuration.obscure(rv)

        return rv

    def traceFull(self, rv, reverse=False):
        """Trace rays through this optical element, returning a full history of
        all surface intersections.

        Parameters
        ----------
        rv : `batoid.RayVector`
            Input rays to trace
        reverse : bool
            Trace through optical element in reverse?  Default: False

        Returns
        -------
        OrderedDict of dict
            There will be one key-value pair for every Interface traced
            through (which for this class, is just a single Interface).  The
            values will be dicts with key-value pairs:

            ``'name'``
                name of Interface (str)
            ``'in'``
                the incoming rays to that Interface (RayVector)
            ``'out'``
                the outgoing rays from that Interface (RayVector)

        Notes
        -----
        Pay careful attention to the coordinate systems of the returned rays.
        These will generally differ from the original input coordinate system.
        To transform to another coordinate system, see `RayVector.toCoordSys`.
        """
        result = OrderedDict()
        if not self.skip:
            result[self.name] = {
                'name':self.name,
                'in':rv,
                'out':self.trace(rv.copy(), reverse=reverse)
            }
        return result

    def traceSplit(self, rv, minFlux=1e-3, reverse=False, _verbose=False):
        """Trace rays through this optical element, splitting the return values
        into rays that continue propagating in the "forward" direction, and
        those that were reflected into the "reverse" direction.  Fluxes of
        output rays are proportional to reflection/transmission coefficients of
        the interface (which may depend on wavelength and incidence angle).

        Parameters
        ----------
        rv : batoid.RayVector
            Input rays to trace
        minFlux : float
            Minimum flux value of rays to continue propagating.
            Default: 1e-3.
        reverse : bool
            Trace through optic in reverse?  Default: False.

        Returns
        -------
        forwardRays : list of batoid.RayVector.
            Each item in list comes from one distinct path through the optic
            exiting in the forward direction.  The exact path traversed is
            accessible from the ``.path`` attribute of the item.
        reverseRays : list of batoid.RayVector.
            Each item in list comes from one distinct path through the optic
            exiting in the reverse direction.  The exact path traversed is
            accessible from the ``.path`` attribute of the item.

        Notes
        -----
        Returned rays will be expressed in the local coordinate system of the
        Optic.  See `RayVector.toCoordSys` to express rays in a different
        coordinate system.
        """
        if _verbose:
            s = "forward" if not reverse else "reverse"
            strtemplate = ("traceSplit {}       {:30s} "
                           "flux = {:18.8f}   nphot = {:10d}")
            print(strtemplate.format(s, self.name, np.sum(rv.flux), len(rv)))
        if self.skip:
            if reverse:
                return None, rv
            else:
                return rv, None
        refracted, reflected = self.rSplit(rv, reverse=reverse)

        # For now, apply obscuration equally forwards and backwards
        if self.obscuration is not None:
            if refracted is not None:
                self.obscuration.obscure(refracted)
            if reflected is not None:
                self.obscuration.obscure(reflected)
        if not hasattr(rv, 'path'):
            if refracted is not None:
                refracted.path = [self.name]
            if reflected is not None:
                reflected.path = [self.name]
        else:
            currentPath = list(rv.path)
            if refracted is not None:
                refracted.path = currentPath+[self.name]
            if reflected is not None:
                reflected.path = currentPath+[self.name]

        rForward, rReverse = [refracted], [reflected]
        if reverse:
            rForward, rReverse = rReverse, rForward
        if isinstance(self, Mirror):
            rForward, rReverse = rReverse, rForward
        return rForward, rReverse

    def clearObscuration(self, unless=()):
        if self.name not in unless:
            self.obscuration = None

    def interact(self, rv, reverse=False):
        # intersect independent of `reverse`
        return self.surface.intersect(rv, coordSys=self.coordSys)

    def intersect(self, rv, reverse=False):
        return self.surface.intersect(rv, coordSys=self.coordSys)

    def __eq__(self, other):
        if not self.__class__ == other.__class__:
            return False
        return (
            self.surface == other.surface and
            self.obscuration == other.obscuration and
            self.name == other.name and
            self.inMedium == other.inMedium and
            self.outMedium == other.outMedium and
            self.coordSys == other.coordSys
        )

    def __ne__(self, other):
        return not (self == other)

    def __repr__(self):
        out = "{!s}({!r}".format(self.__class__.__name__, self.surface)
        if self.obscuration is not None:
            out += ", obscuration={!r}".format(self.obscuration)
        out += Optic._repr_helper(self)
        out +=")"
        return out

    def withGlobalShift(self, shift):
        """Return a new `Interface` with its coordinate system shifted.

        Parameters
        ----------
        shift : array (3,)
            The coordinate shift, relative to the global coordinate system, to
            apply to self.coordSys

        Returns
        -------
        `Interface`
            Shifted interface.
        """
        ret = self.__class__.__new__(self.__class__)
        newDict = dict(self.__dict__)
        newDict['coordSys'] = self.coordSys.shiftGlobal(shift)
        if 'stopSurface' in newDict:
            newDict['stopSurface'] = (
                newDict['stopSurface'].withGlobalShift(shift)
            )
        del newDict['surface']
        ret.__init__(
            self.surface,
            **newDict
        )
        return ret

    def withLocalShift(self, shift):
        """Return a new `Interface` with its coordinate system shifted.

        Parameters
        ----------
        shift : array (3,)
            The coordinate shift, relative to the local coordinate system, to
            apply to self.coordSys

        Returns
        -------
        `Interface`
            Shifted interface.
        """
        # Clearer to apply the rotated global shift.
        return self.withGlobalShift(self.coordSys.rot@shift)

    def withGlobalRotation(self, rot, rotCenter=None, coordSys=None):
        """Return a new `Interface` with its coordinate system rotated.

        Parameters
        ----------
        rot : array (3,3)
            Rotation matrix wrt to the global coordinate system to apply.
        rotCenter : array (3,)
            Point about which to rotate.  Default: None means use [0,0,0]
        coordSys : `batoid.CoordSys`
            Coordinate system of rotCenter above.  Default: None means use
            the global coordinate system.

        Returns
        -------
        `Interface`
            Rotated interface.
        """
        if rotCenter is None:
            rotCenter = [0,0,0]
        if coordSys is None:
            coordSys = globalCoordSys
        ret = self.__class__.__new__(self.__class__)
        newDict = dict(self.__dict__)
        newDict['coordSys'] = self.coordSys.rotateGlobal(
            rot, rotCenter, coordSys
        )
        if 'stopSurface' in newDict:
            newDict['stopSurface'] = (
                newDict['stopSurface'].withGlobalRotation(
                    rot, rotCenter, coordSys
                )
            )
        del newDict['surface']
        ret.__init__(
            self.surface,
            **newDict
        )
        return ret

    def withLocalRotation(self, rot, rotCenter=None, coordSys=None):
        """Return a new `Interface` with its coordinate system rotated.

        Parameters
        ----------
        rot : array (3,3)
            Rotation matrix wrt to the local coordinate system to apply.
        rotCenter : array (3,)
            Point about which to rotate.  Default: None means use [0,0,0]
        coordSys : `batoid.CoordSys`
            Coordinate system of rotCenter above.  Default: None means use
            self.coordSys.

        Returns
        -------
        `Interface`
            Rotated interface.
        """
        if rotCenter is None:
            rotCenter = [0,0,0]
        if coordSys is None:
            coordSys = self.coordSys
        ret = self.__class__.__new__(self.__class__)
        newDict = dict(self.__dict__)
        newDict['coordSys'] = self.coordSys.rotateLocal(
            rot, rotCenter, coordSys
        )
        if 'stopSurface' in newDict:
            newDict['stopSurface'] = newDict['stopSurface'].withLocalRotation(
                rot, rotCenter, coordSys
            )
        del newDict['surface']
        ret.__init__(
            self.surface,
            **newDict
        )
        return ret

    def withSurface(self, surface):
        """Return a new `Interface` with its surface attribute replaced.

        Parameters
        ----------
        surface : `batoid.Surface`
            New replacement surface.

        Returns
        -------
        `Interface`
            Interface with new surface.
        """
        ret = self.__class__.__new__(self.__class__)
        newDict = dict(self.__dict__)
        del newDict['surface']
        ret.__init__(surface, **newDict)
        return ret

    def withPerturbedSurface(self, perturbation):
        """Return a new `Interface` with its surface attribute perturbed by
        adding the given perturbation to the original surface.

        Parameters
        ----------
        perturbation : `batoid.Surface`
            Perturbation to apply to the surface.

        Returns
        -------
        `Interface`
            Interface with perturbed surface.
        """
        return self.withSurface(self.surface + perturbation)

    @cached_property
    def R_outer(self):
        """Radius defined for outer edge of interface.
        """
        obsc = self.obscuration
        if obsc is not None:
            if isinstance(obsc, ObscNegation):
                obsc = obsc.original
            if isinstance(obsc, ObscAnnulus):
                return obsc.outer
            if isinstance(obsc, ObscCircle):
                return obsc.radius
        return None

    @cached_property
    def R_inner(self):
        """Radius defined for inner edge of interface.
        """
        obsc = self.obscuration
        if obsc is not None:
            if isinstance(obsc, ObscNegation):
                obsc = obsc.original
            if isinstance(obsc, ObscAnnulus):
                return obsc.inner
            if isinstance(obsc, ObscCircle):
                return 0.0
        return None


class RefractiveInterface(Interface):
    """Specialization for refractive interfaces.

    Rays will interact with this surface by refracting through it.
    """
    def __init__(self, *args, **kwargs):
        Interface.__init__(self, *args, **kwargs)
        self.forwardCoating = SimpleCoating(
            reflectivity=0.0, transmissivity=1.0
        )
        self.reverseCoating = SimpleCoating(
            reflectivity=0.0, transmissivity=1.0
        )

    def interact(self, rv, reverse=False):
        if reverse:
            m1, m2 = self.outMedium, self.inMedium
        else:
            m1, m2 = self.inMedium, self.outMedium
        return self.surface.refract(rv, m1, m2, coordSys=self.coordSys)

    def refract(self, rv, reverse=False):
        return self.interact(rv, reverse=reverse)

    def rSplit(self, rv, reverse=False):
        # always return in order: refracted, reflected
        if reverse:
            m1 = self.outMedium
            m2 = self.inMedium
            coating = self.forwardCoating
        else:
            m1 = self.inMedium
            m2 = self.outMedium
            coating = self.reverseCoating

        return self.surface.rSplit(rv, m1, m2, coating, coordSys=self.coordSys)


class Mirror(Interface):
    """Specialization for reflective interfaces.

    Rays will interact with this surface by reflecting off of it.
    """
    def __init__(self, *args, **kwargs):
        Interface.__init__(self, *args, **kwargs)
        self.forwardCoating = SimpleCoating(
            reflectivity=1.0, transmissivity=0.0
        )
        self.reverseCoating = SimpleCoating(
            reflectivity=1.0, transmissivity=0.0
        )

    def interact(self, rv, reverse=False):
        # reflect is independent of reverse
        return self.surface.reflect(rv, coordSys=self.coordSys)

    def reflect(self, rv, reverse=False):
        return self.interact(rv, reverse=reverse)

    def rSplit(self, rv, reverse=False):
        # reflect is independent of reverse
        return None, self.surface.reflect(rv, coordSys=self.coordSys)


class Detector(Interface):
    """Specialization for detector interfaces.

    Rays will interact with this surface by passing through it.  Usually,
    however, this is the last `Optic` in a `CompoundOptic`, so tracing will
    stop with this `Interface`.
    """
    def __init__(self, *args, **kwargs):
        Interface.__init__(self, *args, **kwargs)
        self.forwardCoating = SimpleCoating(
            reflectivity=0.0, transmissivity=1.0
        )
        self.reverseCoating = None

    def rSplit(self, rv, reverse=False):
        assert reverse == False
        return self.surface.rSplit(
            rv, self.inMedium, self.outMedium, self.forwardCoating,
            coordSys=self.coordSys
        )


class Baffle(Interface):
    """Specialization for baffle interfaces.

    Rays will interact with this optic by passing straight through.  However,
    the vignetting calculation will still be applied at this `Interface`.
    """
    def __init__(self, *args, **kwargs):
        Interface.__init__(self, *args, **kwargs)
        self.forwardCoating = SimpleCoating(
            reflectivity=0.0, transmissivity=1.0
        )
        self.reverseCoating = SimpleCoating(
            reflectivity=0.0, transmissivity=1.0
        )

    def rSplit(self, rv, reverse=False):
        return self.surface.intersect(rv, self.coordSys), None


class OPDScreen(Interface):
    """An `Optic` defined by a surface and an Optical Path Difference function.

    Notes
    -----
    We use a `batoid.Surface` object for both the intersection surface and the
    OPD.  For the OPD, the `surface.sag` method should return the desired
    optical path difference (in meters) to add to traversing rays.

    Rays traversing an OPD screen are deflected according to:

    cosx2 = cosx1 + d(OPD)/dx
    cosy2 = cosy1 + d(OPD)/dy

    Where cosx1, cosx2 are direction cosines relative to the surface normal.

    Parameters
    ----------
    surface : `batoid.Surface`
        The intersection surface for the phase screen.
    screen : `batoid.Surface`
        The OPD to add upon traversal, in meters.
    **kwargs : dict
        Other keyword arguments to `Interface`.
    """
    def __init__(self, surface, screen, **kwargs):
        Interface.__init__(self, surface, **kwargs)
        self.screen = screen
        assert self.inMedium == self.outMedium
        # Not coatings available.

    def __repr__(self):
        out = "OPDScreen({!r}, {!r}".format(self.surface, self.screen)
        if self.obscuration is not None:
            out += ", obscuration={!r}".format(self.obscuration)
        out += Optic._repr_helper(self)
        out +=")"
        return out

    def __hash__(self):
        return hash((
            Interface.__hash__(self),
            self.screen
        ))

    def __eq__(self, rhs):
        if Interface.__eq__(self, rhs):
            return self.screen == rhs.screen
        return False

    def interact(self, rv, reverse=False):
        # Should reverse be different somehow?
        return self.surface.refractScreen(
            rv,
            self.screen,
            coordSys=self.coordSys
        )

    def refract(self, rv, reverse=False):
        return self.interact(rv, reverse=reverse)

    def rSplit(self, rv, reverse=False):
        # Can only go forward for now.
        return self.surface.interact(rv), None


class CompoundOptic(Optic):
    """An `Optic` containing two or more `Optic` s as subitems.

    Ray traces will be carried out sequentially for the subitems.

    Parameters
    ----------
    items : list of `Optic`
        Subitems making up this compound optic.
    **kwargs :
        Other parameters to forward to Optic.__init__
    """
    def __init__(self, items, **kwargs):
        Optic.__init__(self, **kwargs)
        self.items = tuple(items)
        self.path = [name for item in self.items for name in item.path]

    @cached_property
    def itemDict(self):
        """Dictionary access of the entire hierarchy of subitems of this
        `CompoundOptic`.

        The key for the first level is just the name of the `CompoundOptic`,
        e.g., ``optic.itemDict['SubaruHSC']``.  The next level is accessed by
        appending a ``.``, e.g., ``optic.itemDict['SubaruHSC.HSC']`` and so on:
        ``optic.itemDict['SubaruHSC.HSC.ADC']``
        ``optic.itemDict['SubaruHSC.HSC.ADC.ADC1']``
        ``optic.itemDict['SubaruHSC.HSC.ADC.ADC1.ADC1_entrance']``

        Note: It's also possible to access subitems using the [] operator
        directly: ``optic['SubaruHSC.PM']``
        """
        out = {}
        out[self.name] = self
        for item in self.items:
            out[self.name+'.'+item.name] = item
            if hasattr(item, 'itemDict'):
                for k, v in item.itemDict.items():
                    out[self.name+'.'+k] = v
        return out

    def __getitem__(self, key):
        """Dictionary access to the entire hierarchy of subitems of this
        `CompoundOptic`.

        Either access through the fully-qualified name
        (``optic['LSST.LSSTCamera.L1']``) or by partially-qualified name
        (``optic['LSSTCamera.L1']`` or even ``optic['L1']``).  Note that
        partially-qualified name access is only available for unique
        partially-qualified names.
        """
        try:
            item = self.itemDict[key]
        except KeyError:
            # try accessing by local name instead of fully-qualified name.
            try:
                item = self.itemDict[self._names[key]]
            except KeyError:
                raise ValueError("Cannot find item {}".format(key))
        return item

    @cached_property
    def _names(self):
        """Dictionary of short names to fully-qualified names."""
        nameDict = {}
        duplicates = set()
        for k in self.itemDict.keys():
            tokens = k.split('.')
            shortNames = [tokens[-1]]
            for token in reversed(tokens[:-1]):
                shortNames.append('.'.join([token, shortNames[-1]]))
            for shortName in shortNames:
                if shortName in nameDict:
                    duplicates.add(shortName)
                else:
                    nameDict[shortName] = k
        for name in duplicates:
            del nameDict[name]
        return nameDict

    def trace(self, rv, reverse=False, path=None):
        """Recursively trace through all subitems of this `CompoundOptic`.

        Parameters
        ----------
        rv : `batoid.RayVector`
            Input rays to trace, transforming in place.
        reverse : bool
            Trace through optical element in reverse?  Default: False
        path : list of names of Interfaces.
            Trace through the optical system in this order, as opposed to the
            natural order.  Useful for investigating particular ghost images.
            Note that items included in path will not be skipped, even if their
            skip attribute is true.

        Returns
        -------
        `batoid.RayVector`
            Reference to transformed input rays.

        Notes
        -----
        This operation is performed in place; the return value is a reference to
        the transformed input `RayVector`.

        Returned rays will be expressed in the local coordinate system of the
        last element of the CompoundOptic.  See `RayVector.toCoordSys` to
        express rays in a different coordinate system.

        Also, you may need to reverse the directions of rays if using this
        method with ``reverse=True``.
        """
        if path is None:
            items = self.items if not reverse else reversed(self.items)
            for item in items:
                if not item.skip:
                    item.trace(rv, reverse=reverse)
        else:
            # establish nominal order of elements by building dict
            # of name -> order
            i = 0
            nominalOrder = {}
            for name in path:
                if name not in nominalOrder.keys():
                    nominalOrder[name] = i
                    i += 1
            direction = "forward"
            for i in range(len(path)):
            # for i in range(len(path)-1):
                currentName = path[i]
                item = self[currentName]
                # logic to decide when to reverse direction
                if i == len(path)-1:
                    if nominalOrder[path[i]] == 0:
                        nextDirection = "reverse"
                    else:
                        nextDirection = direction
                else:
                    nextName = path[i+1]
                    if nominalOrder[nextName] < nominalOrder[currentName]:
                        nextDirection = "reverse"
                    else:
                        nextDirection = "forward"
                if direction == nextDirection:
                    item.trace(rv, reverse=(direction=="reverse"))
                else:
                    direction = nextDirection
                    item.surface.reflect(rv, coordSys=item.coordSys)
                    if item.obscuration:
                        item.obscuration.obscure(rv)
        return rv

    def traceFull(self, rv, reverse=False, path=None):
        """Recursively trace rays through this `CompoundOptic`, returning a full
        history of all surface intersections.

        Parameters
        ----------
        rv : `batoid.RayVector`
            Input rays to trace
        reverse : bool
            Trace through optical element in reverse?  Default: False
        path : list of names of Interfaces.
            Trace through the optical system in this order, as opposed to the
            natural order.  Useful for investigating particular ghost images.
            Note that items included in path will not be skipped, even if their
            skip attribute is true.

        Returns
        -------
        OrderedDict of dict
            There will be one key-value pair for every Interface traced
            through.  The values will be dicts with key-value pairs:

            ``'name'``
                name of Interface (str)
            ``'in'``
                the incoming rays to that Interface (RayVector)
            ``'out'``
                the outgoing rays from that Interface (RayVector)
        Notes
        -----
        Pay careful attention to the coordinate systems of the returned rays.
        These will generally differ from the original input coordinate system.
        To transform to another coordinate system, see `RayVector.toCoordSys`.
        """
        result = OrderedDict()
        if path is None:
            if not self.skip:
                rv_in = rv
                items = self.items if not reverse else reversed(self.items)
                for item in items:
                    tf = item.traceFull(rv_in, reverse=reverse)
                    for k, v in tf.items():
                        result[k] = v
                        rv_in = v['out']
        else:
            # establish nominal order of elements by building dict
            # of name -> order
            i = 0
            nominalOrder = {}
            for name in path:
                if name not in nominalOrder.keys():
                    nominalOrder[name] = i
                    i += 1
            direction = "forward"
            rv_in = rv
            # Do the actual tracing here.
            for i in range(len(path)):
                currentName = path[i]
                item = self[currentName]
                # logic to decide when to reverse direction
                if i == len(path)-1:
                    if nominalOrder[path[i]] == 0:
                        nextDirection = "reverse"
                    else:
                        nextDirection = direction
                else:
                    nextName = path[i+1]
                    if nominalOrder[nextName] < nominalOrder[currentName]:
                        nextDirection = "reverse"
                    else:
                        nextDirection = "forward"
                # trace
                if direction == nextDirection:
                    rv_out = item.trace(
                        rv_in.copy(),
                        reverse=(direction=="reverse")
                    )
                else:
                    direction = nextDirection
                    rv_out = item.surface.reflect(
                        rv_in.copy(), coordSys=item.coordSys
                    )
                    if item.obscuration:
                        item.obscuration.obscure(rv_out)
                # determine output key
                key = item.name+'_0'
                j = 1
                while key in result:
                    key = item.name+'_{}'.format(j)
                    j += 1
                # output
                result[key] = {
                    'name':item.name,
                    'in':rv_in.copy(),
                    'out':rv_out.copy()
                }
                rv_in = rv_out
        return result

    def traceSplit(self, rv, minFlux=1e-3, reverse=False, _verbose=False):
        """Recursively trace rays through this `CompoundOptic`, splitting at
        each surface.

        The return values are rays that continuing in the "forward" direction,
        and those reflected into the "reverse" direction.  Fluxes of output
        rays are proportional to reflection/transmission coefficients of the
        interface (which may depend on wavelength and incidence angle).  Note
        that traceSplit is applied recursively, so a single call on a
        `CompoundOptic` may result in many combinations of
        reflections/refractions being applied internally before rays are either
        deleted by falling below the minFlux or reach the entrace/exit of the
        `CompoundOptic`.

        Parameters
        ----------
        rv : `batoid.RayVector`
            Input rays to trace
        minFlux : float
            Minimum flux value of rays to continue propagating.
            Default: 1e-3.
        reverse : bool
            Trace through optic in reverse?  Default: False.

        Returns
        -------
        forwardRays : list of batoid.RayVector.
            Each item in list comes from one distinct path through the optic
            exiting in the forward direction.  The exact path traversed is
            accessible from the ``.path`` attribute of the item.
        reverseRays : list of batoid.RayVector.
            Each item in list comes from one distinct path through the optic
            exiting in the reverse direction.  The exact path traversed is
            accessible from the ``.path`` attribute of the item.

        Notes
        -----
        Returned forward (reverse) rays will be expressed in the local
        coordinate system of the last (first) element of the CompoundOptic.
        See `RayVector.toCoordSys` to express rays in a different coordinate system.
        """
        if _verbose:
            s = "reverse" if reverse else "forward"
            strtemplate = ("traceSplit {}       {:30s} "
                           "flux = {:18.8f}   nphot = {:10d}")
            print(strtemplate.format(s, self.name, np.sum(rv.flux), len(rv)))
        if self.skip:
            if reverse:
                return None, rv
            else:
                return rv, None

        if not reverse:
            workQueue = [(rv, "forward", 0)]
        else:
            workQueue = [(rv, "reverse", len(self.items)-1)]

        outRForward = []
        outRReverse = []

        while workQueue:
            rays, direction, opticIndex = workQueue.pop()
            optic = self.items[opticIndex]
            rForward, rReverse = optic.traceSplit(
                rays, minFlux=minFlux, reverse=(direction=="reverse"),
                _verbose=_verbose
            )
            #  Clear away any None's returned
            rForward = [i for i in rForward if i]
            rReverse = [i for i in rReverse if i]

            # Remove vignetted rays and rays with flux below threshold
            for rList in [rForward, rReverse]:
                for i, rr in enumerate(rList):
                    w = ~rr.vignetted & (rr.flux >= minFlux)
                    rList[i] = RayVector(
                        rr.x[w], rr.y[w], rr.z[w],
                        rr.vx[w], rr.vy[w], rr.vz[w],
                        rr.t[w], rr.wavelength[w], rr.flux[w],
                        rr.vignetted[w], rr.failed[w],
                        rr.coordSys
                    )
                    rList[i].path = rr.path

            for rr in rForward:
                if len(rr) > 0:
                    if opticIndex == len(self.items)-1:
                        outRForward.append(rr)
                    else:
                        workQueue.append((rr, "forward", opticIndex+1))
            for rr in rReverse:
                if len(rr) > 0:
                    if opticIndex == 0:
                        outRReverse.append(rr)
                    else:
                        workQueue.append((rr, "reverse", opticIndex-1))
        return outRForward, outRReverse

    def draw3d(self, ax, **kwargs):
        """Recursively draw this `CompoundOptic` on a mplot3d axis by drawing
        all subitems.

        Parameters
        ----------
        ax : mplot3d.Axis or plotly.graph_objs.Figure
            Axis on which to draw this optic.
        plotly : bool
            Set True to use plotly API instead of ipyvolume/matplotlib API
        """
        for item in self.items:
            item.draw3d(ax, **kwargs)

    def get3dmesh(self):
        xs = []
        ys = []
        zs = []
        for item in self.items:
            x, y, z = item.get3dmesh()
            xs.append(x)
            ys.append(y)
            zs.append(z)
        return np.hstack(xs), np.hstack(ys), np.hstack(zs)

    def draw2d(self, ax, **kwargs):
        """Draw a 2D slice of this `CompoundOptic` in the (x,z) plane.

        Calls draw2d recursively on each of our items, with the actual
        drawing taking place in Interface and (optionally) Lens instances.

        The kwargs are passed to the drawing commands, except for the
        optional keyword 'only' which restricts drawing to only instances
        that are subclasses of a specified type or types.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Axis on which to draw this optic.
        only : list
            A list of types to draw, e.g., `Mirror`, or `Lens`.
            Default: None means to draw all types.
        """
        only = kwargs.pop('only', None)
        for item in self.items:
            item_class = item.__class__
            if issubclass(item_class, CompoundOptic):
                item.draw2d(ax, only=only, **kwargs)
            elif only is None or issubclass(item_class, only):
                item.draw2d(ax, **kwargs)

    def clearObscuration(self, unless=()):
        for item in self.items:
            item.clearObscuration(unless=unless)

    def __eq__(self, other):
        if not self.__class__ == other.__class__:
            return False
        return (self.items == other.items and
                self.name == other.name and
                self.inMedium == other.inMedium and
                self.outMedium == other.outMedium and
                self.coordSys == other.coordSys)

    def __ne__(self, other):
        return not (self == other)

    def __repr__(self):
        out = "{!s}([".format(self.__class__.__name__)
        for item in self.items[:-1]:
            out += "{!r}, ".format(item)
        out += "{!r}]".format(self.items[-1])
        out += Optic._repr_helper(self)
        out += ")"
        return out

    def __hash__(self):
        return hash((self.__class__.__name__, self.items,
                     self.name, self.inMedium, self.outMedium, self.coordSys))

    def withGlobalShift(self, shift):
        """Return a new `CompoundOptic` with its coordinate system shifted (and
        the coordinate systems of all subitems)

        Parameters
        ----------
        shift : array (3,)
            The coordinate shift, relative to the global coordinate system, to
            apply to self.coordSys

        Returns
        -------
        `CompoundOptic`
            Shifted optic.
        """
        newItems = [item.withGlobalShift(shift) for item in self.items]
        ret = self.__class__.__new__(self.__class__)

        newDict = dict(self.__dict__)
        newDict['coordSys'] = self.coordSys.shiftGlobal(shift)
        del newDict['items']
        if 'stopSurface' in newDict:
            newDict['stopSurface'] = (
                newDict['stopSurface'].withGlobalShift(shift)
            )
        ret.__init__(
            newItems,
            **newDict
        )
        return ret

    def withLocalShift(self, shift):
        """Return a new `CompoundOptic` with its coordinate system shifted.

        Parameters
        ----------
        shift : array (3,)
            The coordinate shift, relative to the local coordinate system, to
            apply to self.coordSys

        Returns
        -------
        `CompoundOptic`
            Shifted interface.
        """
        # Clearer to apply the rotated global shift.
        return self.withGlobalShift(self.coordSys.rot@shift)

    def withGloballyShiftedOptic(self, name, shift):
        """Return a new `CompoundOptic` with the coordinate system of one of
        its subitems shifted.

        Parameters
        ----------
        name : str
            The subitem to shift.
        shift : array (3,)
            The coordinate shift, relative to the global coordinate system, to
            apply to the subitem's coordSys.

        Returns
        -------
        `CompoundOptic`
            Optic with shifted subitem.
        """
        # If name is one of items.names, the we use withGlobalShift, and we're
        # done.  If not, then we need to recurse down to whichever item
        # contains name.  Verify that name is in self.itemDict, but first
        # convert partially qualified name to fully qualified.
        name = self._names.get(name, name)
        if name not in self.itemDict:
            raise ValueError("Optic {} not found".format(name))
        if name == self.name:
            return self.withGlobalShift(shift)
        # Clip off leading token
        assert name[:len(self.name)+1] == \
            self.name+".", name[:len(self.name)+1]+" != "+self.name+"."
        name = name[len(self.name)+1:]
        newItems = []
        newDict = dict(self.__dict__)
        del newDict['items']
        for i, item in enumerate(self.items):
            if name.startswith(item.name):
                if name == item.name:
                    newItems.append(item.withGlobalShift(shift))
                else:
                    newItems.append(item.withGloballyShiftedOptic(name, shift))
                newItems.extend(self.items[i+1:])
                return self.__class__(
                    newItems,
                    **newDict
                )
            newItems.append(item)
        raise RuntimeError(
            "Error in withGloballyShiftedOptic!, Shouldn't get here!"
        )

    def withLocallyShiftedOptic(self, name, shift):
        """Return a new `CompoundOptic` with the coordinate system of one of
        its subitems shifted.

        Parameters
        ----------
        name : str
            The subitem to shift.
        shift : array (3,)
            The coordinate shift, relative to the local coordinate system of
            that subitem, to apply to the subitem's coordSys.

        Returns
        -------
        `CompoundOptic`
            Optic with shifted subitem.
        """
        # Just apply the rotated global shift.
        name = self._names.get(name, name)
        if name not in self.itemDict:
            raise ValueError("Optic {} not found".format(name))
        coordSys = self.itemDict[name].coordSys
        return self.withGloballyShiftedOptic(name, coordSys.rot@shift)

    def withGlobalRotation(self, rot, rotCenter=None, coordSys=None):
        """Return a new `CompoundOptic` with its coordinate system rotated.

        Parameters
        ----------
        rot : array (3,3)
            Rotation matrix wrt to the global coordinate system to apply.
        rotCenter : array (3,)
            Point about which to rotate.  Default: None means use [0,0,0]
        coordSys : `batoid.CoordSys`
            Coordinate system of rotCenter above.  Default: None means use
            the global coordinate system.

        Returns
        -------
        `CompoundOptic`
            Rotated optic.
        """
        if rotCenter is None:
            rotCenter = [0,0,0]
        if coordSys is None:
            coordSys = globalCoordSys

        newItems = []
        for item in self.items:
            newItems.append(
                item.withGlobalRotation(
                    rot,
                    rotCenter,
                    coordSys
                )
            )

        ret = self.__class__.__new__(self.__class__)
        newDict = dict(self.__dict__)
        newDict['coordSys'] = self.coordSys.rotateGlobal(
            rot, rotCenter, coordSys
        )
        del newDict['items']
        if 'stopSurface' in newDict:
            newDict['stopSurface'] = (
                newDict['stopSurface'].withGlobalRotation(
                    rot, rotCenter, coordSys
                )
            )
        ret.__init__(
            newItems,
            **newDict
        )
        return ret

    def withLocalRotation(self, rot, rotCenter=None, coordSys=None):
        """Return a new `CompoundOptic` with its coordinate system rotated.

        Parameters
        ----------
        rot : array (3,3)
            Rotation matrix wrt to the local coordinate system to apply.
        rotCenter : array (3,)
            Point about which to rotate.  Default: None means use [0,0,0]
        coordSys : `batoid.CoordSys`
            Coordinate system of rotCenter above.  Default: None means use
            self.coordSys.

        Returns
        -------
        `CompoundOptic`
            Rotated optic.
        """
        if rotCenter is None:
            rotCenter = [0,0,0]
        if coordSys is None:
            coordSys = self.coordSys
        # Convert to global rotation
        return self.withGlobalRotation(
            self.coordSys.rot@rot@self.coordSys.rot.T,
            rotCenter,
            coordSys
        )

    def withLocallyRotatedOptic(self, name, rot, rotCenter=None,
                                coordSys=None):
        """Return a new `CompoundOptic` with the coordinate system of one of
        its subitems rotated.

        Parameters
        ----------
        name : str
            The subitem to rotate.
        rot : array (3,3)
            Rotation matrix wrt to the subitem's local coordinate system to
            apply.
        rotCenter : array (3,)
            Point about which to rotate.  Default: None means use [0,0,0]
        coordSys : `batoid.CoordSys`
            Coordinate system of rotCenter above.  Default: None means use the
            coordinate system of the subitem being rotated.

        Returns
        -------
        `CompoundOptic`
            Optic with rotated subitem.
        """
        # If name is one of items.names, the we use withLocalRotation, and
        # we're done.  If not, then we need to recurse down to whichever item
        # contains name.  Verify that name is in self.itemDict, but first
        # convert partially qualified name to fully qualified.
        name = self._names.get(name, name)
        if name not in self.itemDict:
            raise ValueError("Optic {} not found".format(name))
        if rotCenter is None:
            rotCenter = [0,0,0]
        if coordSys is None:
            coordSys = self.itemDict[name].coordSys
        if name == self.name:
            return self.withLocalRotation(rot, rotCenter, coordSys)
        # Clip off leading token
        assert name[:len(self.name)+1] == \
            self.name+".", name[:len(self.name)+1]+" != "+self.name+"."
        name = name[len(self.name)+1:]
        newItems = []
        newDict = dict(self.__dict__)
        del newDict['items']
        for i, item in enumerate(self.items):
            if name.startswith(item.name):
                if name == item.name:
                    newItems.append(item.withLocalRotation(
                        rot, rotCenter, coordSys
                    ))
                else:
                    newItems.append(item.withLocallyRotatedOptic(
                        name, rot, rotCenter, coordSys
                    ))
                newItems.extend(self.items[i+1:])
                return self.__class__(
                    newItems,
                    **newDict
                )
            newItems.append(item)
        raise RuntimeError(
            "Error in withLocallyRotatedOptic!, Shouldn't get here!"
        )

    def withSurface(self, name, surface):
        """Return a new `CompoundOptic` with one of its subitem's surfaces
        attribute replaced.

        Parameters
        ----------
        name : str
            Which subitem's surface to replace.
        surface : `batoid.Surface`
            New replacement surface.

        Returns
        -------
        `CompoundOptic`
            Optic with new surface.
        """
        name = self._names.get(name, name)
        if name in self._names:
            name = self._names[name]
        if name not in self.itemDict:
            raise ValueError("Optic {} not found".format(name))
        # name is fully qualified, so clip off leading token
        assert name[:len(self.name)+1] == \
            self.name+".", name[:len(self.name)+1]+" != "+self.name+"."
        name = name[len(self.name)+1:]
        newItems = []
        newDict = dict(self.__dict__)
        del newDict['items']
        for i, item in enumerate(self.items):
            if name.startswith(item.name):
                if name == item.name:
                    newItems.append(item.withSurface(surface))
                else:
                    newItems.append(item.withSurface(name, surface))
                newItems.extend(self.items[i+1:])
                return self.__class__(
                    newItems,
                    **newDict
                )
            newItems.append(item)
        raise RuntimeError("Error in withSurface.  Shouldn't get here!")

    def withPerturbedSurface(self, name, perturbation):
        """Return a new `CompoundOptic` with one of its subitem's surfaces
        attribute perturbed by adding the given perturbation to the original
        surface.

        Parameters
        ----------
        name : str
            Which subitem's surface to replace.
        perturbation : `batoid.Surface`
            Surface to add to the original surface.

        Returns
        -------
        `CompoundOptic`
            Optic with perturbed surface.
        """
        name = self._names.get(name, name)
        return self.withSurface(
            name,
            self.itemDict[name].surface + perturbation
        )

    def withInsertedOptic(self, before, item):
        """Return a new `CompoundOptic` with the given item inserted before
        the given subitem.

        Parameters
        ----------
        before : str
            Subitem before which to insert the new item.
        item : `batoid.Optic`
            New item to insert.

        Returns
        -------
        `CompoundOptic`
            Optic with inserted item.
        """
        before = self._names.get(before, before)
        if before not in self.itemDict:
            raise ValueError("Optic {} not found".format(before))
        newItems = []
        newDict = dict(self.__dict__)
        for k in ['items', 'itemDict', '_names', 'R_inner', 'R_outer']:
            newDict.pop(k, None)
        for i, it in enumerate(self.items):
            if self._names[it.name] == self._names[before]:
                newItems.append(item)
            newItems.append(it)
        return self.__class__(
            newItems,
            **newDict
        )

    def withRemovedOptic(self, item):
        """Return a new `CompoundOptic` with the given subitem removed.

        Parameters
        ----------
        item : str
            Subitem to delete.

        Returns
        -------
        `CompoundOptic`
            Optic without removed item.
        """
        item = self._names.get(item, item)
        if item not in self.itemDict:
            raise ValueError("Optic {} not found".format(item))
        newItems = []
        newDict = dict(self.__dict__)
        for k in ['items', 'itemDict', '_names', 'R_inner', 'R_outer']:
            newDict.pop(k, None)
        for it in self.items:
            if self._names[it.name] != self._names[item]:
                newItems.append(it)
        return self.__class__(
            newItems,
            **newDict
        )


class Lens(CompoundOptic):
    """An lens with two `Interface` s as front and back surfaces.

    Parameters
    ----------
    items : list of `batoid.Optic`, len (2,)
        Subitems making up this compound optic.
    **kwargs :
        Other parameters to forward to CompoundOptic.__init__
    """
    def __init__(self, items, **kwargs):
        CompoundOptic.__init__(self, items, **kwargs)
        assert len(items) == 2
        assert items[0].outMedium == items[1].inMedium

    def draw2d(self, ax, **kwargs):
        """Specialized draw2d for `Lens` instances.

        If the optional keyword 'only' equals `Lens`, then fill the area
        between the lens refractive interfaces using the remaining specified
        kwargs (fc, ec, alpha, ...)

        Otherwise, call draw2d on each of our refractive interfaces.

        The optional labelpos and fontdict kwargs are used to draw a label at
        the specified x position (in global coords), using the specified font
        properties.
        """
        only = kwargs.pop('only', None)
        if only == Lens:
            labelpos = kwargs.pop('labelpos', None)
            fontdict = kwargs.pop('fontdict', None)
            if len(self.items) != 2:
                raise RuntimeError(
                    'Cannot draw lens "{0}" with {1} surfaces.'.format(
                        self.name, len(self.items)
                    )
                )
            # Calculate the global coordinates of slices through our two
            # interfaces.
            slice1 = self.items[0].getXZSlice()
            slice2 = self.items[1].getXZSlice()
            # Fill the area between these slices.
            all_z = []
            for (x1, z1), (x2, z2) in zip(slice1, slice2):
                x = np.hstack((x1, x2[::-1], x1[:1]))
                z = np.hstack((z1, z2[::-1], z1[:1]))
                all_z.append(z)
                ax.fill(x, z, **kwargs)
            # Draw an optional label for this lens.
            if labelpos is not None:
                zlabel = np.mean(all_z)
                ax.text(
                    labelpos, zlabel, self.name, fontdict=fontdict,
                    horizontalalignment='center', verticalalignment='center')
        else:
            super(Lens, self).draw2d(ax, only=only, **kwargs)


def getGlobalRays(traceFull, start=None, end=None, globalSys=globalCoordSys):
    """Calculate an array of ray vertices in global coordinates.

    Parameters
    ----------
    traceFull : OrderedDict
        Array of per-surface ray-tracing output from traceFull()
    start : str or None
        Name of the first surface to include in the output, or use the first
        surface in the model when None.
    end : str or None
        Name of the last surface to include in the output, or use the last
        surface in the model when None.
    globalSys : `batoid.CoordSys`
        Global coordinate system to use.

    Returns
    -------
    tuple
        Tuple (xyz, raylen) of arrays with shapes (nray, 3, nsurf + 1) and
        (nray,).  The xyz array contains the global coordinates of each
        ray vertex, with raylen giving the number of visible (not vignetted)
        vertices for each ray.
    """
    names = list(traceFull.keys())
    if start is None:
        start = names[0]
    if end is None:
        end = names[-1]
    try:
        istart = names.index(start)
    except ValueError:
        raise ValueError('No such start surface "{0}".'.format(start))
    try:
        iend = names.index(end)
    except ValueError:
        raise ValueError('No such end surface "{0}".'.format(end))
    nsurf = iend - istart + 1
    if nsurf <= 0:
        raise ValueError('Expected start < end.')
    nray = len(traceFull[start]['in'])
    # Allocate an array for all ray vertices in global coords.
    xyz = np.empty((nray, 3, nsurf + 1))
    # First point on each ray is where it enters the start surface.
    transform = CoordTransform(traceFull[start]['in'].coordSys, globalSys)
    xyz[:, :, 0] = np.stack(
        transform.applyForwardArray(*traceFull[start]['in'].r.T), axis=1
    )
    # Keep track of the number of visible points on each ray.
    raylen = np.ones(nray, dtype=int)
    for i, name in enumerate(names[istart:iend+1]):
        surface = traceFull[name]
        # Add a point for where each ray leaves this surface.
        transform = CoordTransform(surface['out'].coordSys, globalSys)
        xyz[:, :, i + 1] = np.stack(
            transform.applyForwardArray(*surface['out'].r.T), axis=1
        )
        # Keep track of rays which are still visible.
        visible = ~surface['out'].vignetted
        raylen[visible] += 1
    return xyz, raylen


def drawTrace3d(ax, traceFull, start=None, end=None, plotly=False, **kwargs):
    """Draw 3D rays in global coordinates on the specified axis.

    Parameters
    ----------
    ax : mplot3d.Axis or plotly.graph_objs.Figure
        Axis on which to draw rays.
    traceFull : OrderedDict
        Array of per-surface ray-tracing output from traceFull()
    start : str or None
        Name of the first surface to include in the output, or use the first
        surface in the model when None.
    end : str or None
        Name of the last surface to include in the output, or use the last
        surface in the model when None.
    plotly : bool
        Set True to use plotly API instead of ipyvolume/matplotlib API
    globalSys : `batoid.CoordSys`
        Global coordinate system to use.
    """
    xyz, raylen = getGlobalRays(traceFull, start, end)
    for line, nline in zip(xyz, raylen):
        if plotly:
            ax.add_scatter3d(
                x=line[0, :nline],
                y=line[1, :nline],
                z=line[2, :nline],
                **kwargs
            )
        else:
            ax.plot(line[0, :nline], line[1, :nline], line[2, :nline], **kwargs)


def drawTrace2d(ax, traceFull, start=None, end=None, **kwargs):
    """Draw 2D rays in global coordinates on the specified axis.

    Parameters
    ----------
    ax : matplotlib.axes.Axes
        Axis on which to draw rays.
    traceFull : OrderedDict
        Array of per-surface ray-tracing output from traceFull()
    start : str or None
        Name of the first surface to include in the output, or use the first
        surface in the model when None.
    end : str or None
        Name of the last surface to include in the output, or use the last
        surface in the model when None.
    globalSys : `batoid.CoordSys`
        Global coordinate system to use.
    """
    xyz, raylen = getGlobalRays(traceFull, start, end)
    lines = []
    for line, nline in zip(xyz, raylen):
        lines.extend([line[0, :nline], line[2, :nline]])
    ax.plot(*lines, **kwargs)
