Source code for tidy3d.components.geometry.primitives

"""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]