"""Concrete primitive geometrical objects."""
from __future__ import annotations
from math import isclose
from typing import Optional
import autograd.numpy as anp
import numpy as np
import pydantic.v1 as pydantic
import shapely
from tidy3d.components.autograd import AutogradFieldMap, TracedSize1D
from tidy3d.components.autograd.constants import PTS_PER_WVL_MAT_CYLINDER_DISCRETIZE
from tidy3d.components.autograd.derivative_utils import DerivativeInfo
from tidy3d.components.base import cached_property, skip_if_fields_missing
from tidy3d.components.types import Axis, Bound, Coordinate, MatrixReal4x4, Shapely
from tidy3d.constants import LARGE_NUMBER, MICROMETER
from tidy3d.exceptions import SetupError, ValidationError
from tidy3d.packaging import verify_packages_import
from . import base
from .polyslab import PolySlab
# for sampling conical frustum in visualization
_N_SAMPLE_CURVE_SHAPELY = 40
# for shapely circular shapes discretization in visualization
_N_SHAPELY_QUAD_SEGS = 200
# Default number of points to discretize polyslab in `Cylinder.to_polyslab()`
_N_PTS_CYLINDER_POLYSLAB = 51
[docs]
class Sphere(base.Centered, base.Circular):
    """Spherical geometry.
    Example
    -------
    >>> b = Sphere(center=(1,2,3), radius=2)
    """
[docs]
    def inside(
        self, x: np.ndarray[float], y: np.ndarray[float], z: np.ndarray[float]
    ) -> np.ndarray[bool]:
        """For input arrays ``x``, ``y``, ``z`` of arbitrary but identical shape, return an array
        with the same shape which is ``True`` for every point in zip(x, y, z) that is inside the
        volume of the :class:`Geometry`, and ``False`` otherwise.
        Parameters
        ----------
        x : np.ndarray[float]
            Array of point positions in x direction.
        y : np.ndarray[float]
            Array of point positions in y direction.
        z : np.ndarray[float]
            Array of point positions in z direction.
        Returns
        -------
        np.ndarray[bool]
            ``True`` for every point that is inside the geometry.
        """
        self._ensure_equal_shape(x, y, z)
        x0, y0, z0 = self.center
        dist_x = np.abs(x - x0)
        dist_y = np.abs(y - y0)
        dist_z = np.abs(z - z0)
        return (dist_x**2 + dist_y**2 + dist_z**2) <= (self.radius**2) 
[docs]
    def intersections_tilted_plane(
        self, normal: Coordinate, origin: Coordinate, to_2D: MatrixReal4x4
    ) -> list[Shapely]:
        """Return a list of shapely geometries at the plane specified by normal and origin.
        Parameters
        ----------
        normal : Coordinate
            Vector defining the normal direction to the plane.
        origin : Coordinate
            Vector defining the plane origin.
        to_2D : MatrixReal4x4
            Transformation matrix to apply to resulting shapes.
        Returns
        -------
        List[shapely.geometry.base.BaseGeometry]
            List of 2D shapes that intersect plane.
            For more details refer to
            `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_.
        """
        normal = np.array(normal)
        unit_normal = normal / (np.sum(normal**2) ** 0.5)
        projection = np.dot(np.array(origin) - np.array(self.center), unit_normal)
        if abs(projection) >= self.radius:
            return []
        radius = (self.radius**2 - projection**2) ** 0.5
        center = np.array(self.center) + projection * unit_normal
        v = np.zeros(3)
        v[np.argmin(np.abs(unit_normal))] = 1
        u = np.cross(unit_normal, v)
        u /= np.sum(u**2) ** 0.5
        v = np.cross(unit_normal, u)
        angles = np.linspace(0, 2 * np.pi, _N_SHAPELY_QUAD_SEGS * 4 + 1)[:-1]
        circ = center + np.outer(np.cos(angles), radius * u) + np.outer(np.sin(angles), radius * v)
        vertices = np.dot(np.hstack((circ, np.ones((angles.size, 1)))), to_2D.T)
        return [shapely.Polygon(vertices[:, :2])] 
[docs]
    def intersections_plane(
        self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None
    ):
        """Returns shapely geometry at plane specified by one non None value of x,y,z.
        Parameters
        ----------
        x : float = None
            Position of plane in x direction, only one of x,y,z can be specified to define plane.
        y : float = None
            Position of plane in x direction, only one of x,y,z can be specified to define plane.
        z : float = None
            Position of plane in x direction, only one of x,y,z can be specified to define plane.
        Returns
        -------
        List[shapely.geometry.base.BaseGeometry]
            List of 2D shapes that intersect plane.
            For more details refer to
            `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_.
        """
        axis, position = self.parse_xyz_kwargs(x=x, y=y, z=z)
        if not self.intersects_axis_position(axis, position):
            return []
        z0, (x0, y0) = self.pop_axis(self.center, axis=axis)
        intersect_dist = self._intersect_dist(position, z0)
        if not intersect_dist:
            return []
        return [shapely.Point(x0, y0).buffer(0.5 * intersect_dist, quad_segs=_N_SHAPELY_QUAD_SEGS)] 
    @cached_property
    def bounds(self) -> Bound:
        """Returns bounding box min and max coordinates.
        Returns
        -------
        Tuple[float, float, float], Tuple[float, float, float]
            Min and max bounds packaged as ``(minx, miny, minz), (maxx, maxy, maxz)``.
        """
        coord_min = tuple(c - self.radius for c in self.center)
        coord_max = tuple(c + self.radius for c in self.center)
        return (coord_min, coord_max)
    def _volume(self, bounds: Bound) -> float:
        """Returns object's volume within given bounds."""
        volume = 4.0 / 3.0 * np.pi * self.radius**3
        # a very loose upper bound on how much of sphere is in bounds
        for axis in range(3):
            if self.center[axis] <= bounds[0][axis] or self.center[axis] >= bounds[1][axis]:
                volume *= 0.5
        return volume
    def _surface_area(self, bounds: Bound) -> float:
        """Returns object's surface area within given bounds."""
        area = 4.0 * np.pi * self.radius**2
        # a very loose upper bound on how much of sphere is in bounds
        for axis in range(3):
            if self.center[axis] <= bounds[0][axis] or self.center[axis] >= bounds[1][axis]:
                area *= 0.5
        return area 
[docs]
class Cylinder(base.Centered, base.Circular, base.Planar):
    """Cylindrical geometry with optional sidewall angle along axis
    direction. When ``sidewall_angle`` is nonzero, the shape is a
    conical frustum or a cone.
    Example
    -------
    >>> c = Cylinder(center=(1,2,3), radius=2, length=5, axis=2)
    See Also
    --------
    **Notebooks**
    * `THz integrated demultiplexer/filter based on a ring resonator <../../../notebooks/THzDemultiplexerFilter.html>`_
    * `Photonic crystal waveguide polarization filter <../../../notebooks/PhotonicCrystalWaveguidePolarizationFilter.html>`_
    """
    # Provide more explanations on where radius is defined
    radius: TracedSize1D = pydantic.Field(
        ...,
        title="Radius",
        description="Radius of geometry at the ``reference_plane``.",
        units=MICROMETER,
    )
    length: TracedSize1D = pydantic.Field(
        ...,
        title="Length",
        description="Defines thickness of cylinder along axis dimension.",
        units=MICROMETER,
    )
    @pydantic.validator("length", always=True)
    @skip_if_fields_missing(["sidewall_angle", "reference_plane"])
    def _only_middle_for_infinite_length_slanted_cylinder(cls, val, values):
        """For a slanted cylinder of infinite length, ``reference_plane`` can only
        be ``middle``; otherwise, the radius at ``center`` is either td.inf or 0.
        """
        if isclose(values["sidewall_angle"], 0) or not np.isinf(val):
            return val
        if values["reference_plane"] != "middle":
            raise SetupError(
                "For a slanted cylinder here is of infinite length, "
                "defining the reference_plane other than 'middle' "
                "leads to undefined cylinder behaviors near 'center'."
            )
        return val
[docs]
    def to_polyslab(
        self, num_pts_circumference: int = _N_PTS_CYLINDER_POLYSLAB, **kwargs
    ) -> PolySlab:
        """Convert instance of ``Cylinder`` into a discretized version using ``PolySlab``.
        Parameters
        ----------
        num_pts_circumference : int = 51
            Number of points in the circumference of the discretized polyslab.
        **kwargs:
            Extra keyword arguments passed to ``PolySlab()``, such as ``dilation``.
        Returns
        -------
        PolySlab
            Extruded polygon representing a discretized version of the cylinder.
        """
        center_axis = self.center_axis
        length_axis = self.length_axis
        slab_bounds = (center_axis - length_axis / 2.0, center_axis + length_axis / 2.0)
        if num_pts_circumference < 3:
            raise ValueError("'PolySlab' from 'Cylinder' must have 3 or more radius points.")
        _, (x0, y0) = self.pop_axis(self.center, axis=self.axis)
        xs_, ys_ = self._points_unit_circle(num_pts_circumference=num_pts_circumference)
        xs = x0 + self.radius * xs_
        ys = y0 + self.radius * ys_
        vertices = anp.stack((xs, ys), axis=-1)
        return PolySlab(
            vertices=vertices,
            axis=self.axis,
            slab_bounds=slab_bounds,
            sidewall_angle=self.sidewall_angle,
            reference_plane=self.reference_plane,
            **kwargs,
        ) 
    def _points_unit_circle(
        self, num_pts_circumference: int = _N_PTS_CYLINDER_POLYSLAB
    ) -> np.ndarray:
        """Set of x and y points for the unit circle when discretizing cylinder as a polyslab."""
        angles = np.linspace(0, 2 * np.pi, num_pts_circumference, endpoint=False)
        xs = np.cos(angles)
        ys = np.sin(angles)
        return np.stack((xs, ys), axis=0)
    def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradFieldMap:
        """Compute the adjoint derivatives for this object."""
        # compute circumference discretization
        wvl0_min = derivative_info.wavelength_min
        wvl_mat = wvl0_min / np.max([1.0, np.max(np.sqrt(abs(derivative_info.eps_in)))])
        circumference = 2 * np.pi * self.radius
        wvls_in_circumference = circumference / wvl_mat
        num_pts_circumference = int(
            np.ceil(PTS_PER_WVL_MAT_CYLINDER_DISCRETIZE * wvls_in_circumference)
        )
        num_pts_circumference = max(3, num_pts_circumference)
        # construct equivalent polyslab and compute the derivatives
        polyslab = self.to_polyslab(num_pts_circumference=num_pts_circumference)
        # pass interpolators to PolySlab if available to avoid redundant conversions
        update_kwargs = {
            "paths": [("vertices",), ("slab_bounds", 0), ("slab_bounds", 1)],
            "deep": False,
        }
        if derivative_info.interpolators is not None:
            update_kwargs["interpolators"] = derivative_info.interpolators
        derivative_info_polyslab = derivative_info.updated_copy(**update_kwargs)
        vjps_polyslab = polyslab._compute_derivatives(derivative_info_polyslab)
        vjps_vertices_xs, vjps_vertices_ys = vjps_polyslab[("vertices",)].T
        vjp_top = vjps_polyslab[("slab_bounds", 0)]
        vjp_bot = vjps_polyslab[("slab_bounds", 1)]
        # transform polyslab vertices derivatives into Cylinder parameter derivatives
        xs_, ys_ = self._points_unit_circle(num_pts_circumference=num_pts_circumference)
        vjp_xs = np.sum(xs_ * vjps_vertices_xs)
        vjp_ys = np.sum(ys_ * vjps_vertices_ys)
        vjps = {}
        for path in derivative_info.paths:
            if path == ("length",):
                vjps[path] = vjp_top - vjp_bot
            elif path == ("radius",):
                vjps[path] = vjp_xs + vjp_ys
            elif "center" in path:
                _, center_index = path
                _, (index_x, index_y) = self.pop_axis((0, 1, 2), axis=self.axis)
                if center_index == index_x:
                    vjps[path] = np.sum(vjps_vertices_xs)
                elif center_index == index_y:
                    vjps[path] = np.sum(vjps_vertices_ys)
                else:
                    vjps[path] = vjp_top + vjp_bot
            else:
                raise NotImplementedError(
                    f"Differentiation with respect to 'Cylinder' '{path}' field not supported. "
                    "If you would like this feature added, please feel free to raise "
                    "an issue on the tidy3d front end repository."
                )
        return vjps
    @property
    def center_axis(self):
        """Gets the position of the center of the geometry in the out of plane dimension."""
        z0, _ = self.pop_axis(self.center, axis=self.axis)
        return z0
    @property
    def length_axis(self) -> float:
        """Gets the length of the geometry along the out of plane dimension."""
        return self.length
    @cached_property
    def _normal_2dmaterial(self) -> Axis:
        """Get the normal to the given geometry, checking that it is a 2D geometry."""
        if self.length != 0:
            raise ValidationError("'Medium2D' requires the 'Cylinder' length to be zero.")
        return self.axis
    def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> Cylinder:
        """Returns an updated geometry which has been transformed to fit within ``bounds``
        along the ``axis`` direction."""
        if axis != self.axis:
            raise ValueError(
                f"'_update_from_bounds' may only be applied along axis '{self.axis}', "
                f"but was given axis '{axis}'."
            )
        new_center = list(self.center)
        new_center[axis] = (bounds[0] + bounds[1]) / 2
        new_length = bounds[1] - bounds[0]
        return self.updated_copy(center=new_center, length=new_length)
    @verify_packages_import(["trimesh"])
    def _do_intersections_tilted_plane(
        self, normal: Coordinate, origin: Coordinate, to_2D: MatrixReal4x4
    ) -> list[Shapely]:
        """Return a list of shapely geometries at the plane specified by normal and origin.
        Parameters
        ----------
        normal : Coordinate
            Vector defining the normal direction to the plane.
        origin : Coordinate
            Vector defining the plane origin.
        to_2D : MatrixReal4x4
            Transformation matrix to apply to resulting shapes.
        Returns
        -------
        List[shapely.geometry.base.BaseGeometry]
            List of 2D shapes that intersect plane.
            For more details refer to
            `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_.
        """
        import trimesh
        z0, (x0, y0) = self.pop_axis(self.center, self.axis)
        half_length = self.finite_length_axis / 2
        z_top = z0 + half_length
        z_bot = z0 - half_length
        if np.isclose(self.sidewall_angle, 0):
            r_top = self.radius
            r_bot = self.radius
        else:
            r_top = self.radius_top
            r_bot = self.radius_bottom
            if r_top < 0 or np.isclose(r_top, 0):
                r_top = 0
                z_top = z0 + self._radius_z(z0) / self._tanq
            elif r_bot < 0 or np.isclose(r_bot, 0):
                r_bot = 0
                z_bot = z0 + self._radius_z(z0) / self._tanq
        angles = np.linspace(0, 2 * np.pi, _N_SHAPELY_QUAD_SEGS * 4 + 1)
        if r_bot > 0:
            x_bot = x0 + r_bot * np.cos(angles)
            y_bot = y0 + r_bot * np.sin(angles)
            x_bot[-1] = x0
            y_bot[-1] = y0
        else:
            x_bot = np.array([x0])
            y_bot = np.array([y0])
        if r_top > 0:
            x_top = x0 + r_top * np.cos(angles)
            y_top = y0 + r_top * np.sin(angles)
            x_top[-1] = x0
            y_top[-1] = y0
        else:
            x_top = np.array([x0])
            y_top = np.array([y0])
        x = np.hstack((x_bot, x_top))
        y = np.hstack((y_bot, y_top))
        z = np.hstack((np.full_like(x_bot, z_bot), np.full_like(x_top, z_top)))
        vertices = np.vstack(self.unpop_axis(z, (x, y), self.axis)).T
        if x_bot.shape[0] == 1:
            m = 1
            n = x_top.shape[0] - 1
            faces_top = [(m + n, m + i, m + (i + 1) % n) for i in range(n)]
            faces_side = [(m + (i + 1) % n, m + i, 0) for i in range(n)]
            faces = faces_top + faces_side
        elif x_top.shape[0] == 1:
            m = x_bot.shape[0]
            n = m - 1
            faces_bot = [(n, (i + 1) % n, i) for i in range(n)]
            faces_side = [(i, (i + 1) % n, m) for i in range(n)]
            faces = faces_bot + faces_side
        else:
            m = x_bot.shape[0]
            n = m - 1
            faces_bot = [(n, (i + 1) % n, i) for i in range(n)]
            faces_top = [(m + n, m + i, m + (i + 1) % n) for i in range(n)]
            faces_side_bot = [(i, (i + 1) % n, m + (i + 1) % n) for i in range(n)]
            faces_side_top = [(m + (i + 1) % n, m + i, i) for i in range(n)]
            faces = faces_bot + faces_top + faces_side_bot + faces_side_top
        mesh = trimesh.Trimesh(vertices, faces)
        section = mesh.section(plane_origin=origin, plane_normal=normal)
        if section is None:
            return []
        path, _ = section.to_2D(to_2D=to_2D)
        return path.polygons_full
    def _intersections_normal(self, z: float):
        """Find shapely geometries intersecting cylindrical geometry with axis normal to slab.
        Parameters
        ----------
        z : float
            Position along the axis normal to slab
        Returns
        -------
        List[shapely.geometry.base.BaseGeometry]
            List of 2D shapes that intersect plane.
            For more details refer to
            `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_.
        """
        static_self = self.to_static()
        # radius at z
        radius_offset = static_self._radius_z(z)
        if radius_offset <= 0:
            return []
        _, (x0, y0) = self.pop_axis(static_self.center, axis=self.axis)
        return [shapely.Point(x0, y0).buffer(radius_offset, quad_segs=_N_SHAPELY_QUAD_SEGS)]
    def _intersections_side(self, position, axis):
        """Find shapely geometries intersecting cylindrical geometry with axis orthogonal to length.
        When ``sidewall_angle`` is nonzero, so that it's in fact a conical frustum or cone, the
        cross section can contain hyperbolic curves. This is currently approximated by a polygon
        of many vertices.
        Parameters
        ----------
        position : float
            Position along axis direction.
        axis : int
            Integer index into 'xyz' (0, 1, 2).
        Returns
        -------
        List[shapely.geometry.base.BaseGeometry]
            List of 2D shapes that intersect plane.
            For more details refer to
            `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_.
        """
        # position in the local coordinate of the cylinder
        position_local = position - self.center[axis]
        # no intersection
        if abs(position_local) >= self.radius_max:
            return []
        # half of intersection length at the top and bottom
        intersect_half_length_max = np.sqrt(self.radius_max**2 - position_local**2)
        intersect_half_length_min = -LARGE_NUMBER
        if abs(position_local) < self.radius_min:
            intersect_half_length_min = np.sqrt(self.radius_min**2 - position_local**2)
        # the vertices on the max side of top/bottom
        # The two vertices are present in all scenarios.
        vertices_max = [
            self._local_to_global_side_cross_section([-intersect_half_length_max, 0], axis),
            self._local_to_global_side_cross_section([intersect_half_length_max, 0], axis),
        ]
        # Extending to a cone, the maximal height of the cone
        h_cone = (
            LARGE_NUMBER if isclose(self.sidewall_angle, 0) else self.radius_max / abs(self._tanq)
        )
        # The maximal height of the cross section
        height_max = min(
            (1 - abs(position_local) / self.radius_max) * h_cone, self.finite_length_axis
        )
        # more vertices to add for conical frustum shape
        vertices_frustum_right = []
        vertices_frustum_left = []
        if not (isclose(position, self.center[axis]) or isclose(self.sidewall_angle, 0)):
            # The y-coordinate for the additional vertices
            y_list = height_max * np.linspace(0, 1, _N_SAMPLE_CURVE_SHAPELY)
            # `abs()` to make sure np.sqrt(0-fp_eps) goes through
            x_list = np.sqrt(
                np.abs(self.radius_max**2 * (1 - y_list / h_cone) ** 2 - position_local**2)
            )
            for i in range(_N_SAMPLE_CURVE_SHAPELY):
                vertices_frustum_right.append(
                    self._local_to_global_side_cross_section([x_list[i], y_list[i]], axis)
                )
                vertices_frustum_left.append(
                    self._local_to_global_side_cross_section(
                        [
                            -x_list[_N_SAMPLE_CURVE_SHAPELY - i - 1],
                            y_list[_N_SAMPLE_CURVE_SHAPELY - i - 1],
                        ],
                        axis,
                    )
                )
        # the vertices on the min side of top/bottom
        vertices_min = []
        ## termination at the top/bottom
        if intersect_half_length_min > 0:
            vertices_min.append(
                self._local_to_global_side_cross_section(
                    [intersect_half_length_min, self.finite_length_axis], axis
                )
            )
            vertices_min.append(
                self._local_to_global_side_cross_section(
                    [-intersect_half_length_min, self.finite_length_axis], axis
                )
            )
        ## early termination
        else:
            vertices_min.append(self._local_to_global_side_cross_section([0, height_max], axis))
        return [
            shapely.Polygon(
                vertices_max + vertices_frustum_right + vertices_min + vertices_frustum_left
            )
        ]
[docs]
    def inside(
        self, x: np.ndarray[float], y: np.ndarray[float], z: np.ndarray[float]
    ) -> np.ndarray[bool]:
        """For input arrays ``x``, ``y``, ``z`` of arbitrary but identical shape, return an array
        with the same shape which is ``True`` for every point in zip(x, y, z) that is inside the
        volume of the :class:`Geometry`, and ``False`` otherwise.
        Parameters
        ----------
        x : np.ndarray[float]
            Array of point positions in x direction.
        y : np.ndarray[float]
            Array of point positions in y direction.
        z : np.ndarray[float]
            Array of point positions in z direction.
        Returns
        -------
        np.ndarray[bool]
            ``True`` for every point that is inside the geometry.
        """
        # radius at z
        self._ensure_equal_shape(x, y, z)
        z0, (x0, y0) = self.pop_axis(self.center, axis=self.axis)
        z, (x, y) = self.pop_axis((x, y, z), axis=self.axis)
        radius_offset = self._radius_z(z)
        positive_radius = radius_offset > 0
        dist_x = np.abs(x - x0)
        dist_y = np.abs(y - y0)
        dist_z = np.abs(z - z0)
        inside_radius = (dist_x**2 + dist_y**2) <= (radius_offset**2)
        inside_height = dist_z <= (self.finite_length_axis / 2)
        return positive_radius * inside_radius * inside_height 
    @cached_property
    def bounds(self) -> Bound:
        """Returns bounding box min and max coordinates.
        Returns
        -------
        Tuple[float, float, float], Tuple[float, float, float]
            Min and max bounds packaged as ``(minx, miny, minz), (maxx, maxy, maxz)``.
        """
        coord_min = [c - self.radius_max for c in self.center]
        coord_max = [c + self.radius_max for c in self.center]
        coord_min[self.axis] = self.center[self.axis] - self.length_axis / 2.0
        coord_max[self.axis] = self.center[self.axis] + self.length_axis / 2.0
        return (tuple(coord_min), tuple(coord_max))
    def _volume(self, bounds: Bound) -> float:
        """Returns object's volume within given bounds."""
        coord_min = max(self.bounds[0][self.axis], bounds[0][self.axis])
        coord_max = min(self.bounds[1][self.axis], bounds[1][self.axis])
        length = coord_max - coord_min
        volume = np.pi * self.radius_max**2 * length
        # a very loose upper bound on how much of the cylinder is in bounds
        for axis in range(3):
            if axis != self.axis:
                if self.center[axis] <= bounds[0][axis] or self.center[axis] >= bounds[1][axis]:
                    volume *= 0.5
        return volume
    def _surface_area(self, bounds: Bound) -> float:
        """Returns object's surface area within given bounds."""
        area = 0
        coord_min = self.bounds[0][self.axis]
        coord_max = self.bounds[1][self.axis]
        if coord_min < bounds[0][self.axis]:
            coord_min = bounds[0][self.axis]
        else:
            area += np.pi * self.radius_max**2
        if coord_max > bounds[1][self.axis]:
            coord_max = bounds[1][self.axis]
        else:
            area += np.pi * self.radius_max**2
        length = coord_max - coord_min
        area += 2.0 * np.pi * self.radius_max * length
        # a very loose upper bound on how much of the cylinder is in bounds
        for axis in range(3):
            if axis != self.axis:
                if self.center[axis] <= bounds[0][axis] or self.center[axis] >= bounds[1][axis]:
                    area *= 0.5
        return area
    @cached_property
    def radius_bottom(self) -> float:
        """radius of bottom"""
        return self._radius_z(self.center_axis - self.finite_length_axis / 2)
    @cached_property
    def radius_top(self) -> float:
        """radius of bottom"""
        return self._radius_z(self.center_axis + self.finite_length_axis / 2)
    @cached_property
    def radius_max(self) -> float:
        """max(radius of top, radius of bottom)"""
        return max(self.radius_bottom, self.radius_top)
    @cached_property
    def radius_min(self) -> float:
        """min(radius of top, radius of bottom). It can be negative for a large
        sidewall angle.
        """
        return min(self.radius_bottom, self.radius_top)
    def _radius_z(self, z: float):
        """Compute the radius of the cross section at the position z.
        Parameters
        ----------
        z : float
            Position along the axis normal to slab
        """
        if isclose(self.sidewall_angle, 0):
            return self.radius
        radius_middle = self.radius
        if self.reference_plane == "top":
            radius_middle += self.finite_length_axis / 2 * self._tanq
        elif self.reference_plane == "bottom":
            radius_middle -= self.finite_length_axis / 2 * self._tanq
        return radius_middle - (z - self.center_axis) * self._tanq
    def _local_to_global_side_cross_section(self, coords: list[float], axis: int) -> list[float]:
        """Map a point (x,y) from local to global coordinate system in the
        side cross section.
        The definition of the local: y=0 lies at the base if ``sidewall_angle>=0``,
        and at the top if ``sidewall_angle<0``; x=0 aligns with the corresponding
        ``self.center``. In both cases, y-axis is pointing towards the narrowing
        direction of cylinder.
        Parameters
        ----------
        axis : int
            Integer index into 'xyz' (0, 1, 2).
        coords : List[float, float]
            The value in the planar coordinate.
        Returns
        -------
        Tuple[float, float]
            The point in the global coordinate for plotting `_intersection_side`.
        """
        # For negative sidewall angle, quantities along axis direction usually needs a flipped sign
        axis_sign = 1
        if self.sidewall_angle < 0:
            axis_sign = -1
        lx_offset, ly_offset = self._order_by_axis(
            plane_val=coords[0],
            axis_val=axis_sign * (-self.finite_length_axis / 2 + coords[1]),
            axis=axis,
        )
        _, (x_center, y_center) = self.pop_axis(self.center, axis=axis)
        return [x_center + lx_offset, y_center + ly_offset]