"""Concrete primitive geometrical objects."""
from __future__ import annotations
from math import isclose
from typing import TYPE_CHECKING, Any
import autograd.numpy as anp
import numpy as np
import shapely
from pydantic import Field, PrivateAttr, model_validator
from tidy3d.components.autograd import TracedSize1D, get_static
from tidy3d.components.base import cached_property
from tidy3d.components.geometry import base
from tidy3d.components.geometry.mesh import TriangleMesh
from tidy3d.components.geometry.polyslab import PolySlab
from tidy3d.config import config
from tidy3d.constants import LARGE_NUMBER, MICROMETER
from tidy3d.exceptions import SetupError, ValidationError
from tidy3d.log import log
from tidy3d.packaging import verify_packages_import
if TYPE_CHECKING:
from typing import Optional
from shapely.geometry.base import BaseGeometry
from tidy3d.compat import Self
from tidy3d.components.autograd import AutogradFieldMap
from tidy3d.components.autograd.derivative_utils import DerivativeInfo
from tidy3d.components.types import Axis, Bound, Coordinate, MatrixReal4x4, Shapely
# for sampling conical frustum in visualization
_N_SAMPLE_CURVE_SHAPELY = 40
# for shapely circular shapes discretization in visualization
_N_SHAPELY_QUAD_SEGS_VISUALIZATION = 200
# Default number of points to discretize polyslab in `Cylinder.to_polyslab()`
_N_PTS_CYLINDER_POLYSLAB = 51
_MAX_ICOSPHERE_SUBDIVISIONS = 7 # this would have 164K vertices and 328K faces
_DEFAULT_EDGE_FRACTION = 0.25
def _base_icosahedron() -> tuple[np.ndarray, np.ndarray]:
"""Return vertices and faces of a unit icosahedron."""
phi = (1.0 + np.sqrt(5.0)) / 2.0
vertices = np.array(
[
(-1, phi, 0),
(1, phi, 0),
(-1, -phi, 0),
(1, -phi, 0),
(0, -1, phi),
(0, 1, phi),
(0, -1, -phi),
(0, 1, -phi),
(phi, 0, -1),
(phi, 0, 1),
(-phi, 0, -1),
(-phi, 0, 1),
],
dtype=float,
)
vertices /= np.linalg.norm(vertices, axis=1)[:, None]
faces = np.array(
[
(0, 11, 5),
(0, 5, 1),
(0, 1, 7),
(0, 7, 10),
(0, 10, 11),
(1, 5, 9),
(5, 11, 4),
(11, 10, 2),
(10, 7, 6),
(7, 1, 8),
(3, 9, 4),
(3, 4, 2),
(3, 2, 6),
(3, 6, 8),
(3, 8, 9),
(4, 9, 5),
(2, 4, 11),
(6, 2, 10),
(8, 6, 7),
(9, 8, 1),
],
dtype=int,
)
return vertices, faces
_ICOSAHEDRON_VERTS, _ICOSAHEDRON_FACES = _base_icosahedron()
def discretization_wavelength(derivative_info: DerivativeInfo, geometry_label: str) -> float:
"""Choose reference wavelength for surface discretization."""
wvl0_min = derivative_info.wavelength_min
wvl_mat = wvl0_min / np.max([1.0, np.max(np.sqrt(abs(derivative_info.eps_in)))])
grid_cfg = config.adjoint
min_wvl_mat = grid_cfg.min_wvl_fraction * wvl0_min
if wvl_mat < min_wvl_mat:
log.warning(
f"The minimum wavelength inside the {geometry_label} material is {wvl_mat:.3e} μm, which would "
f"create a large number of discretization points for computing the gradient. "
f"To prevent performance degradation, the discretization wavelength has "
f"been clipped to {min_wvl_mat:.3e} μm.",
log_once=True,
)
return max(wvl_mat, min_wvl_mat)
[docs]
class Sphere(base.Centered, base.Circular):
"""Spherical geometry.
Example
-------
>>> b = Sphere(center=(1,2,3), radius=2)
"""
radius: TracedSize1D = Field(
title="Radius",
description="Radius of geometry.",
json_schema_extra={"units": MICROMETER},
)
_icosphere_cache: dict[int, tuple[np.ndarray, float]] = PrivateAttr(default_factory=dict)
[docs]
@verify_packages_import(["trimesh"])
def to_triangle_mesh(
self,
*,
max_edge_length: Optional[float] = None,
subdivisions: Optional[int] = None,
) -> TriangleMesh:
"""Approximate the sphere surface with a ``TriangleMesh``.
Parameters
----------
max_edge_length : float = None
Maximum edge length for triangulation in micrometers.
subdivisions : int = None
Number of subdivisions for icosphere generation.
Returns
-------
TriangleMesh
Triangle mesh approximation of the sphere surface.
"""
triangles, _ = self._triangulated_surface(
max_edge_length=max_edge_length, subdivisions=subdivisions
)
return TriangleMesh.from_triangles(triangles)
[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:`~tidy3d.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,
cleanup: bool = True,
quad_segs: Optional[int] = None,
) -> 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.
cleanup : bool = True
If True, removes extremely small features from each polygon's boundary.
quad_segs : Optional[int] = None
Number of segments used to discretize circular shapes. If ``None``, uses
``_N_SHAPELY_QUAD_SEGS_VISUALIZATION`` for high-quality visualization.
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>`_.
"""
if quad_segs is None:
quad_segs = _N_SHAPELY_QUAD_SEGS_VISUALIZATION
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, 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,
cleanup: bool = True,
quad_segs: Optional[int] = None,
) -> list[BaseGeometry]:
"""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.
cleanup : bool = True
If True, removes extremely small features from each polygon's boundary.
quad_segs : Optional[int] = None
Number of segments used to discretize circular shapes. If ``None``, uses
``_N_SHAPELY_QUAD_SEGS_VISUALIZATION`` for high-quality visualization.
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>``.
"""
if quad_segs is None:
quad_segs = _N_SHAPELY_QUAD_SEGS_VISUALIZATION
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=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]
@classmethod
def unit_sphere_triangles(
cls,
*,
target_edge_length: Optional[float] = None,
subdivisions: Optional[int] = None,
) -> np.ndarray:
"""Return unit sphere triangles discretized via an icosphere."""
unit_tris = UNIT_SPHERE._unit_sphere_triangles(
target_edge_length=target_edge_length,
subdivisions=subdivisions,
copy_result=True,
)
return unit_tris
def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradFieldMap:
"""Compute adjoint derivatives using smooth sphere surface samples."""
valid_paths = {("radius",), *{("center", i) for i in range(3)}}
for path in derivative_info.paths:
if path not in valid_paths:
raise ValueError(
f"No derivative defined w.r.t. 'Sphere' field '{path}'. "
"Supported fields are 'radius' and 'center'."
)
if not derivative_info.paths:
return {}
grid_cfg = config.adjoint
radius = float(get_static(self.radius))
if radius == 0.0:
log.warning(
"Sphere gradients cannot be computed for zero radius; gradients are zero.",
log_once=True,
)
return dict.fromkeys(derivative_info.paths, 0.0)
wvl_mat = discretization_wavelength(derivative_info, "sphere")
target_edge = max(wvl_mat / grid_cfg.points_per_wavelength, np.finfo(float).eps)
triangles, _ = self._triangulated_surface(max_edge_length=target_edge)
triangles = triangles.astype(grid_cfg.gradient_dtype_float, copy=False)
sim_min, sim_max = (
np.asarray(arr, dtype=grid_cfg.gradient_dtype_float)
for arr in derivative_info.simulation_bounds
)
tol = config.adjoint.edge_clip_tolerance
sim_extents = sim_max - sim_min
collapsed_indices = np.flatnonzero(np.isclose(sim_extents, 0.0, atol=tol))
if collapsed_indices.size:
if collapsed_indices.size > 1:
return dict.fromkeys(derivative_info.paths, 0.0)
axis_idx = int(collapsed_indices[0])
plane_value = float(sim_min[axis_idx])
return self._compute_derivatives_collapsed_axis(
derivative_info=derivative_info,
axis_idx=axis_idx,
plane_value=plane_value,
)
trimesh_obj = TriangleMesh._triangles_to_trimesh(triangles)
vertices = np.asarray(trimesh_obj.vertices, dtype=grid_cfg.gradient_dtype_float)
center = np.asarray(self.center, dtype=grid_cfg.gradient_dtype_float)
verts_centered = vertices - center
norms = np.linalg.norm(verts_centered, axis=1, keepdims=True)
norms = np.where(norms == 0, 1, norms)
normals = verts_centered / norms
if vertices.size == 0:
return dict.fromkeys(derivative_info.paths, 0.0)
# get vertex weights
faces = np.asarray(trimesh_obj.faces, dtype=int)
face_areas = np.asarray(trimesh_obj.area_faces, dtype=grid_cfg.gradient_dtype_float)
weights = np.zeros(len(vertices), dtype=grid_cfg.gradient_dtype_float)
np.add.at(weights, faces[:, 0], face_areas / 3.0)
np.add.at(weights, faces[:, 1], face_areas / 3.0)
np.add.at(weights, faces[:, 2], face_areas / 3.0)
perp1, perp2 = self._tangent_basis_from_normals(normals)
valid_axes = np.abs(sim_max - sim_min) > tol
inside_mask = np.all(
vertices[:, valid_axes] >= (sim_min - tol)[valid_axes], axis=1
) & np.all(vertices[:, valid_axes] <= (sim_max + tol)[valid_axes], axis=1)
if not np.any(inside_mask):
return dict.fromkeys(derivative_info.paths, 0.0)
points = vertices[inside_mask]
normals_sel = normals[inside_mask]
perp1_sel = perp1[inside_mask]
perp2_sel = perp2[inside_mask]
weights_sel = weights[inside_mask]
interpolators = derivative_info.interpolators
if interpolators is None:
interpolators = derivative_info.create_interpolators(
dtype=grid_cfg.gradient_dtype_float
)
g = derivative_info.evaluate_gradient_at_points(
points,
normals_sel,
perp1_sel,
perp2_sel,
interpolators,
)
weighted = (weights_sel * g).real
grad_center = np.sum(weighted[:, None] * normals_sel, axis=0)
grad_radius = np.sum(weighted)
vjps: AutogradFieldMap = {}
for path in derivative_info.paths:
if path == ("radius",):
vjps[path] = float(grad_radius)
else:
_, idx = path
vjps[path] = float(grad_center[idx])
return vjps
def _compute_derivatives_collapsed_axis(
self,
derivative_info: DerivativeInfo,
axis_idx: int,
plane_value: float,
) -> AutogradFieldMap:
"""Delegate collapsed-axis gradients to a Cylinder cross section."""
tol = config.adjoint.edge_clip_tolerance
radius = float(self.radius)
center = np.asarray(self.center, dtype=float)
delta = plane_value - center[axis_idx]
radius_sq = radius**2 - delta**2
if radius_sq <= tol**2:
return dict.fromkeys(derivative_info.paths, 0.0)
radius_plane = float(np.sqrt(max(radius_sq, 0.0)))
if radius_plane <= tol:
return dict.fromkeys(derivative_info.paths, 0.0)
cyl_paths: set[tuple[str, int | None]] = set()
need_radius = False
for path in derivative_info.paths:
if path == ("radius",) or path == ("center", axis_idx):
cyl_paths.add(("radius",))
need_radius = True
elif path[0] == "center" and path[1] != axis_idx:
cyl_paths.add(("center", path[1]))
if not cyl_paths:
return dict.fromkeys(derivative_info.paths, 0.0)
cyl_center = center.copy()
cyl_center[axis_idx] = plane_value
cylinder = Cylinder(
center=tuple(cyl_center),
radius=radius_plane,
length=discretization_wavelength(derivative_info, "sphere") * 2.0,
axis=axis_idx,
)
bounds_min = list(cyl_center)
bounds_max = list(cyl_center)
for dim in range(3):
if dim == axis_idx:
continue
bounds_min[dim] = center[dim] - radius_plane
bounds_max[dim] = center[dim] + radius_plane
bounds = (tuple(bounds_min), tuple(bounds_max))
sim_min_arr, sim_max_arr = (
np.asarray(arr, dtype=float) for arr in derivative_info.simulation_bounds
)
intersect_min = tuple(max(bounds[0][i], sim_min_arr[i]) for i in range(3))
intersect_max = tuple(min(bounds[1][i], sim_max_arr[i]) for i in range(3))
if any(lo > hi for lo, hi in zip(intersect_min, intersect_max)):
return dict.fromkeys(derivative_info.paths, 0.0)
derivative_info_cyl = derivative_info.updated_copy(
paths=list(cyl_paths),
bounds=bounds,
bounds_intersect=(intersect_min, intersect_max),
)
vjps_cyl = cylinder._compute_derivatives(derivative_info_cyl)
result = dict.fromkeys(derivative_info.paths, 0.0)
vjp_radius = float(vjps_cyl.get(("radius",), 0.0)) if need_radius else 0.0
for path in derivative_info.paths:
if path == ("radius",):
result[path] = vjp_radius * (radius / radius_plane)
elif path == ("center", axis_idx):
result[path] = vjp_radius * (delta / radius_plane)
elif path[0] == "center" and path[1] != axis_idx:
result[path] = float(vjps_cyl.get(("center", path[1]), 0.0))
return result
def _edge_length_on_unit_sphere(
self, max_edge_length: Optional[float] = _DEFAULT_EDGE_FRACTION
) -> Optional[float]:
"""Convert ``max_edge_length`` in μm to unit-sphere coordinates."""
max_edge_length = _DEFAULT_EDGE_FRACTION if max_edge_length is None else max_edge_length
radius = float(self.radius)
if radius <= 0.0:
return None
return max_edge_length / radius
def _triangulated_surface(
self,
*,
max_edge_length: Optional[float] = None,
subdivisions: Optional[int] = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Return physical and unit triangles for the surface discretization. Pass either max_edge_length or subdivisions."""
max_edge_length_unit = None
if subdivisions is None:
max_edge_length_unit = self._edge_length_on_unit_sphere(max_edge_length)
unit_tris = self._unit_sphere_triangles(
target_edge_length=max_edge_length_unit,
subdivisions=subdivisions,
copy_result=False,
)
radius = float(get_static(self.radius))
center = np.asarray(self.center, dtype=float)
dtype = config.adjoint.gradient_dtype_float
physical = radius * unit_tris + center
return physical.astype(dtype, copy=False), unit_tris.astype(dtype, copy=False)
def _unit_sphere_triangles(
self,
*,
target_edge_length: Optional[float] = None,
subdivisions: Optional[int] = None,
copy_result: bool = True,
) -> np.ndarray:
"""Return cached unit-sphere triangles with optional copying. Pass either target_edge_length or subdivisions."""
if target_edge_length is not None and subdivisions is not None:
raise ValueError("Specify either target_edge_length OR subdivisions, not both.")
if subdivisions is None:
subdivisions = self._subdivisions_for_edge(target_edge_length)
triangles, _ = self._icosphere_data(subdivisions)
return np.array(triangles, copy=copy_result)
def _subdivisions_for_edge(self, target_edge_length: Optional[float]) -> int:
if target_edge_length is None or target_edge_length <= 0.0:
return 0
for subdiv in range(_MAX_ICOSPHERE_SUBDIVISIONS + 1):
_, max_edge = self._icosphere_data(subdiv)
if max_edge <= target_edge_length:
return subdiv
log.warning(
f"Requested sphere mesh edge length {target_edge_length:.3e} μm requires more than "
f"{_MAX_ICOSPHERE_SUBDIVISIONS} subdivisions. "
"Clipping to the finest available mesh.",
log_once=True,
)
return _MAX_ICOSPHERE_SUBDIVISIONS
@staticmethod
def _tangent_basis_from_normals(normals: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Construct orthonormal tangential bases for each normal vector (vectorized)."""
dtype = normals.dtype
tol = np.finfo(dtype).eps
# Normalize normals (in case they are not perfectly unit length).
n_norm = np.linalg.norm(normals, axis=1)
n = normals / np.maximum(n_norm, tol)[:, None]
# Pick a reference axis least aligned with each normal: argmin(|nx|,|ny|,|nz|).
ref_idx = np.argmin(np.abs(n), axis=1)
ref = np.zeros_like(n)
ref[np.arange(n.shape[0]), ref_idx] = 1.0
basis1 = np.cross(n, ref)
b1_norm = np.linalg.norm(basis1, axis=1)
basis1 = basis1 / np.maximum(b1_norm, tol)[:, None]
basis2 = np.cross(n, basis1)
b2_norm = np.linalg.norm(basis2, axis=1)
basis2 = basis2 / np.maximum(b2_norm, tol)[:, None]
return basis1, basis2
def _icosphere_data(self, subdivisions: int) -> tuple[np.ndarray, float]:
cache = self._icosphere_cache
if subdivisions in cache:
return cache[subdivisions]
vertices = np.asarray(_ICOSAHEDRON_VERTS, dtype=float)
faces = np.asarray(_ICOSAHEDRON_FACES, dtype=int)
if subdivisions > 0:
vertices = vertices.copy()
faces = faces.copy()
for _ in range(subdivisions):
vertices, faces = TriangleMesh.subdivide_faces(vertices, faces)
norms = np.linalg.norm(vertices, axis=1, keepdims=True)
norms = np.where(norms == 0.0, 1.0, norms)
vertices = vertices / norms
triangles = vertices[faces]
max_edge = self._max_edge_length(triangles)
cache[subdivisions] = (triangles, max_edge)
return triangles, max_edge
@staticmethod
def _max_edge_length(triangles: np.ndarray) -> float:
v = triangles
edges = np.stack(
[
v[:, 1] - v[:, 0],
v[:, 2] - v[:, 1],
v[:, 0] - v[:, 2],
],
axis=1,
)
return float(np.linalg.norm(edges, axis=2).max())
UNIT_SPHERE = Sphere(center=(0.0, 0.0, 0.0), radius=1.0)
[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 = Field(
title="Radius",
description="Radius of geometry at the ``reference_plane``.",
json_schema_extra={"units": MICROMETER},
)
length: TracedSize1D = Field(
title="Length",
description="Defines thickness of cylinder along axis dimension.",
json_schema_extra={"units": MICROMETER},
)
@model_validator(mode="after")
def _only_middle_for_infinite_length_slanted_cylinder(self: Self) -> Self:
"""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(self.sidewall_angle, 0) or not np.isinf(self.length):
return self
if self.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 self
[docs]
def to_polyslab(
self, num_pts_circumference: int = _N_PTS_CYLINDER_POLYSLAB, **kwargs: Any
) -> 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
wvl_mat = discretization_wavelength(derivative_info, "cylinder")
circumference = 2 * np.pi * self.radius
wvls_in_circumference = circumference / wvl_mat
grid_cfg = config.adjoint
num_pts_circumference = int(np.ceil(grid_cfg.points_per_wavelength * 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)
# build PolySlab derivative paths based on requested Cylinder paths
ps_paths = set()
for path in derivative_info.paths:
if path == ("length",):
ps_paths.update({("slab_bounds", 0), ("slab_bounds", 1)})
elif path == ("radius",):
ps_paths.add(("vertices",))
elif "center" in path:
_, center_index = path
_, (index_x, index_y) = self.pop_axis((0, 1, 2), axis=self.axis)
if center_index in (index_x, index_y):
ps_paths.add(("vertices",))
else:
ps_paths.update({("slab_bounds", 0), ("slab_bounds", 1)})
elif path == ("sidewall_angle",):
ps_paths.add(("sidewall_angle",))
# pass interpolators to PolySlab if available to avoid redundant conversions
update_kwargs = {
"paths": list(ps_paths),
"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 = {}
for path in derivative_info.paths:
if path == ("length",):
vjp_top = vjps_polyslab.get(("slab_bounds", 0), 0.0)
vjp_bot = vjps_polyslab.get(("slab_bounds", 1), 0.0)
vjps[path] = vjp_top - vjp_bot
elif path == ("radius",):
# transform polyslab vertices derivatives into radius derivative
xs_, ys_ = self._points_unit_circle(num_pts_circumference=num_pts_circumference)
if ("vertices",) not in vjps_polyslab:
vjps[path] = 0.0
else:
vjps_vertices_xs, vjps_vertices_ys = vjps_polyslab[("vertices",)].T
vjp_xs = np.sum(xs_ * vjps_vertices_xs)
vjp_ys = np.sum(ys_ * vjps_vertices_ys)
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:
if ("vertices",) not in vjps_polyslab:
vjps[path] = 0.0
else:
vjps_vertices_xs = vjps_polyslab[("vertices",)][:, 0]
vjps[path] = np.sum(vjps_vertices_xs)
elif center_index == index_y:
if ("vertices",) not in vjps_polyslab:
vjps[path] = 0.0
else:
vjps_vertices_ys = vjps_polyslab[("vertices",)][:, 1]
vjps[path] = np.sum(vjps_vertices_ys)
else:
vjp_top = vjps_polyslab.get(("slab_bounds", 0), 0.0)
vjp_bot = vjps_polyslab.get(("slab_bounds", 1), 0.0)
vjps[path] = vjp_top + vjp_bot
elif path == ("sidewall_angle",):
# direct mapping: cylinder angle equals polyslab angle
vjps[path] = vjps_polyslab.get(("sidewall_angle",), 0.0)
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) -> Any:
"""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=tuple(new_center), length=new_length)
@verify_packages_import(["trimesh"])
def _do_intersections_tilted_plane(
self,
normal: Coordinate,
origin: Coordinate,
to_2D: MatrixReal4x4,
quad_segs: Optional[int] = None,
) -> 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.
quad_segs : Optional[int] = None
Number of segments used to discretize circular shapes. If ``None``, uses
``_N_SHAPELY_QUAD_SEGS_VISUALIZATION`` for high-quality visualization.
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
if quad_segs is None:
quad_segs = _N_SHAPELY_QUAD_SEGS_VISUALIZATION
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, 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, quad_segs: Optional[int] = None
) -> list[BaseGeometry]:
"""Find shapely geometries intersecting cylindrical geometry with axis normal to slab.
Parameters
----------
z : float
Position along the axis normal to slab
quad_segs : Optional[int] = None
Number of segments used to discretize circular shapes. If ``None``, uses
``_N_SHAPELY_QUAD_SEGS_VISUALIZATION`` for high-quality visualization.
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>`_.
"""
if quad_segs is None:
quad_segs = _N_SHAPELY_QUAD_SEGS_VISUALIZATION
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=quad_segs)]
def _intersections_side(self, position: float, axis: int) -> list[BaseGeometry]:
"""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:`~tidy3d.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) -> 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]