Source code for tidy3d.components.geometry.base

"""Abstract base classes for geometry."""

from __future__ import annotations

import functools
import pathlib
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sequence
from os import PathLike
from typing import TYPE_CHECKING, Any, Callable, Optional, Union

import autograd.numpy as np
import pydantic.v1 as pydantic
import shapely
from numpy._typing import ArrayLike, NDArray
from typing_extensions import Self

try:
    from matplotlib import patches
except ImportError:
    pass

from tidy3d.compat import _shapely_is_older_than
from tidy3d.components.autograd import (
    AutogradFieldMap,
    TracedCoordinate,
    TracedFloat,
    TracedSize,
    get_static,
)
from tidy3d.components.autograd.derivative_utils import DerivativeInfo
from tidy3d.components.base import Tidy3dBaseModel, cached_property
from tidy3d.components.geometry.bound_ops import bounds_intersection, bounds_union
from tidy3d.components.geometry.float_utils import increment_float
from tidy3d.components.transformation import ReflectionFromPlane, RotationAroundAxis
from tidy3d.components.types import (
    ArrayFloat2D,
    ArrayFloat3D,
    Ax,
    Axis,
    Bound,
    ClipOperationType,
    Coordinate,
    Coordinate2D,
    LengthUnit,
    MatrixReal4x4,
    PlanePosition,
    Shapely,
    Size,
    annotate_type,
)
from tidy3d.components.viz import (
    ARROW_LENGTH,
    PLOT_BUFFER,
    PlotParams,
    VisualizationSpec,
    add_ax_if_none,
    arrow_style,
    equal_aspect,
    plot_params_geometry,
    polygon_patch,
    set_default_labels_and_title,
)
from tidy3d.constants import LARGE_NUMBER, MICROMETER, RADIAN, fp_eps, inf
from tidy3d.exceptions import (
    SetupError,
    Tidy3dError,
    Tidy3dImportError,
    Tidy3dKeyError,
    ValidationError,
)
from tidy3d.log import log
from tidy3d.packaging import verify_packages_import

if TYPE_CHECKING:
    from gdstk import Cell
    from matplotlib.backend_bases import Event
    from matplotlib.patches import FancyArrowPatch

POLY_GRID_SIZE = 1e-12
POLY_TOLERANCE_RATIO = 1e-12
POLY_DISTANCE_TOLERANCE = 8e-12


_shapely_operations = {
    "union": shapely.union,
    "intersection": shapely.intersection,
    "difference": shapely.difference,
    "symmetric_difference": shapely.symmetric_difference,
}

_bit_operations = {
    "union": lambda a, b: a | b,
    "intersection": lambda a, b: a & b,
    "difference": lambda a, b: a & ~b,
    "symmetric_difference": lambda a, b: a != b,
}


[docs] class Geometry(Tidy3dBaseModel, ABC): """Abstract base class, defines where something exists in space.""" @cached_property def plot_params(self) -> PlotParams: """Default parameters for plotting a Geometry object.""" return plot_params_geometry
[docs] def inside(self, x: NDArray[float], y: NDArray[float], z: NDArray[float]) -> 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. """ def point_inside(x: float, y: float, z: float) -> bool: """Returns ``True`` if a single point ``(x, y, z)`` is inside.""" shapes_intersect = self.intersections_plane(z=z) loc = self.make_shapely_point(x, y) return any(shape.contains(loc) for shape in shapes_intersect) arrays = tuple(map(np.array, (x, y, z))) self._ensure_equal_shape(*arrays) inside = np.zeros((arrays[0].size,), dtype=bool) arrays_flat = map(np.ravel, arrays) for ipt, args in enumerate(zip(*arrays_flat)): inside[ipt] = point_inside(*args) return inside.reshape(arrays[0].shape)
@staticmethod def _ensure_equal_shape(*arrays: Any) -> None: """Ensure all input arrays have the same shape.""" shapes = {np.array(arr).shape for arr in arrays} if len(shapes) > 1: raise ValueError("All coordinate inputs (x, y, z) must have the same shape.")
[docs] @staticmethod def make_shapely_box(minx: float, miny: float, maxx: float, maxy: float) -> shapely.box: """Make a shapely box ensuring everything untraced.""" minx = get_static(minx) miny = get_static(miny) maxx = get_static(maxx) maxy = get_static(maxy) return shapely.box(minx, miny, maxx, maxy)
[docs] @staticmethod def make_shapely_point(minx: float, miny: float) -> shapely.Point: """Make a shapely Point ensuring everything untraced.""" minx = get_static(minx) miny = get_static(miny) return shapely.Point(minx, miny)
def _inds_inside_bounds( self, x: NDArray[float], y: NDArray[float], z: NDArray[float] ) -> tuple[slice, slice, slice]: """Return slices into the sorted input arrays that are inside the geometry bounds. Parameters ---------- x : np.ndarray[float] 1D array of point positions in x direction. y : np.ndarray[float] 1D array of point positions in y direction. z : np.ndarray[float] 1D array of point positions in z direction. Returns ------- Tuple[slice, slice, slice] Slices into each of the three arrays that are inside the geometry bounds. """ bounds = self.bounds inds_in = [] for dim, coords in enumerate([x, y, z]): inds = np.nonzero((bounds[0][dim] <= coords) * (coords <= bounds[1][dim]))[0] inds_in.append(slice(0, 0) if inds.size == 0 else slice(inds[0], inds[-1] + 1)) return tuple(inds_in)
[docs] def inside_meshgrid( self, x: NDArray[float], y: NDArray[float], z: NDArray[float] ) -> NDArray[bool]: """Perform ``self.inside`` on a set of sorted 1D coordinates. Applies meshgrid to the supplied coordinates before checking inside. Parameters ---------- x : np.ndarray[float] 1D array of point positions in x direction. y : np.ndarray[float] 1D array of point positions in y direction. z : np.ndarray[float] 1D array of point positions in z direction. Returns ------- np.ndarray[bool] Array with shape ``(x.size, y.size, z.size)``, which is ``True`` for every point that is inside the geometry. """ arrays = tuple(map(np.array, (x, y, z))) if any(arr.ndim != 1 for arr in arrays): raise ValueError("Each of the supplied coordinates (x, y, z) must be 1D.") shape = tuple(arr.size for arr in arrays) is_inside = np.zeros(shape, dtype=bool) inds_inside = self._inds_inside_bounds(*arrays) coords_inside = tuple(arr[ind] for ind, arr in zip(inds_inside, arrays)) coords_3d = np.meshgrid(*coords_inside, indexing="ij") is_inside[inds_inside] = self.inside(*coords_3d) return is_inside
[docs] @abstractmethod 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 high-quality visualization settings. 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>`_. """
[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[Shapely]: """Returns list of shapely geometries 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z 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 high-quality visualization settings. 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) origin = self.unpop_axis(position, (0, 0), axis=axis) normal = self.unpop_axis(1, (0, 0), axis=axis) to_2D = np.eye(4) if axis != 2: last, indices = self.pop_axis((0, 1, 2), axis) to_2D = to_2D[[*list(indices), last, 3]] return self.intersections_tilted_plane( normal, origin, to_2D, cleanup=cleanup, quad_segs=quad_segs )
[docs] def intersections_2dbox(self, plane: Box) -> list[Shapely]: """Returns list of shapely geometries representing the intersections of the geometry with a 2D box. 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>`_. """ log.warning( "'intersections_2dbox()' is deprecated and will be removed in the future. " "Use 'plane.intersections_with(...)' for the same functionality." ) return plane.intersections_with(self)
[docs] def intersects( self, other: Geometry, strict_inequality: tuple[bool, bool, bool] = [False, False, False] ) -> bool: """Returns ``True`` if two :class:`Geometry` have intersecting `.bounds`. Parameters ---------- other : :class:`Geometry` Geometry to check intersection with. strict_inequality : Tuple[bool, bool, bool] = [False, False, False] For each dimension, defines whether to include equality in the boundaries comparison. If ``False``, equality is included, and two geometries that only intersect at their boundaries will evaluate as ``True``. If ``True``, such geometries will evaluate as ``False``. Returns ------- bool Whether the rectangular bounding boxes of the two geometries intersect. """ self_bmin, self_bmax = self.bounds other_bmin, other_bmax = other.bounds for smin, omin, smax, omax, strict in zip( self_bmin, other_bmin, self_bmax, other_bmax, strict_inequality ): # are all of other's minimum coordinates less than self's maximum coordinate? in_minus = omin < smax if strict else omin <= smax # are all of other's maximum coordinates greater than self's minimum coordinate? in_plus = omax > smin if strict else omax >= smin # if either failed, return False if not all((in_minus, in_plus)): return False return True
[docs] def contains( self, other: Geometry, strict_inequality: tuple[bool, bool, bool] = [False, False, False] ) -> bool: """Returns ``True`` if the `.bounds` of ``other`` are contained within the `.bounds` of ``self``. Parameters ---------- other : :class:`Geometry` Geometry to check containment with. strict_inequality : Tuple[bool, bool, bool] = [False, False, False] For each dimension, defines whether to include equality in the boundaries comparison. If ``False``, equality will be considered as contained. If ``True``, ``other``'s bounds must be strictly within the bounds of ``self``. Returns ------- bool Whether the rectangular bounding box of ``other`` is contained within the bounding box of ``self``. """ self_bmin, self_bmax = self.bounds other_bmin, other_bmax = other.bounds for smin, omin, smax, omax, strict in zip( self_bmin, other_bmin, self_bmax, other_bmax, strict_inequality ): # are all of other's minimum coordinates greater than self's minimim coordinate? in_minus = omin > smin if strict else omin >= smin # are all of other's maximum coordinates less than self's maximum coordinate? in_plus = omax < smax if strict else omax <= smax # if either failed, return False if not all((in_minus, in_plus)): return False return True
[docs] def intersects_plane( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None ) -> bool: """Whether self intersects 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. Returns ------- bool Whether this geometry intersects the plane. """ axis, position = self.parse_xyz_kwargs(x=x, y=y, z=z) return self.intersects_axis_position(axis, position)
[docs] def intersects_axis_position(self, axis: int, position: float) -> bool: """Whether self intersects plane specified by a given position along a normal axis. Parameters ---------- axis : int = None Axis normal to the plane. position : float = None Position of plane along the normal axis. Returns ------- bool Whether this geometry intersects the plane. """ return self.bounds[0][axis] <= position <= self.bounds[1][axis]
@cached_property @abstractmethod 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)``. """
[docs] @staticmethod def bounds_intersection(bounds1: Bound, bounds2: Bound) -> Bound: """Return the bounds that are the intersection of two bounds.""" return bounds_intersection(bounds1, bounds2)
[docs] @staticmethod def bounds_union(bounds1: Bound, bounds2: Bound) -> Bound: """Return the bounds that are the union of two bounds.""" return bounds_union(bounds1, bounds2)
@cached_property def bounding_box(self) -> Box: """Returns :class:`Box` representation of the bounding box of a :class:`Geometry`. Returns ------- :class:`Box` Geometric object representing bounding box. """ return Box.from_bounds(*self.bounds) @cached_property def zero_dims(self) -> list[Axis]: """A list of axes along which the :class:`Geometry` is zero-sized based on its bounds.""" zero_dims = [] for dim in range(3): if self.bounds[1][dim] == self.bounds[0][dim]: zero_dims.append(dim) return zero_dims def _pop_bounds(self, axis: Axis) -> tuple[Coordinate2D, tuple[Coordinate2D, Coordinate2D]]: """Returns min and max bounds in plane normal to and tangential to ``axis``. Parameters ---------- axis : int Integer index into 'xyz' (0,1,2). Returns ------- Tuple[float, float], Tuple[Tuple[float, float], Tuple[float, float]] Bounds along axis and a tuple of bounds in the ordered planar coordinates. Packed as ``(zmin, zmax), ((xmin, ymin), (xmax, ymax))``. """ b_min, b_max = self.bounds zmin, (xmin, ymin) = self.pop_axis(b_min, axis=axis) zmax, (xmax, ymax) = self.pop_axis(b_max, axis=axis) return (zmin, zmax), ((xmin, ymin), (xmax, ymax)) @staticmethod def _get_center(pt_min: float, pt_max: float) -> float: """Returns center point based on bounds along dimension.""" if np.isneginf(pt_min) and np.isposinf(pt_max): return 0.0 if np.isneginf(pt_min) or np.isposinf(pt_max): raise SetupError( f"Bounds of ({pt_min}, {pt_max}) supplied along one dimension. " "We currently don't support a single ``inf`` value in bounds for ``Box``. " "To construct a semi-infinite ``Box``, " "please supply a large enough number instead of ``inf``. " "For example, a location extending outside of the " "Simulation domain (including PML)." ) return (pt_min + pt_max) / 2.0 @cached_property def _normal_2dmaterial(self) -> Axis: """Get the normal to the given geometry, checking that it is a 2D geometry.""" raise ValidationError("'Medium2D' is not compatible with this geometry class.") def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> Geometry: """Returns an updated geometry which has been transformed to fit within ``bounds`` along the ``axis`` direction.""" raise NotImplementedError( "'_update_from_bounds' is not compatible with this geometry class." )
[docs] @equal_aspect @add_ax_if_none def plot( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, plot_length_units: LengthUnit = None, viz_spec: VisualizationSpec = None, **patch_kwargs: Any, ) -> Ax: """Plot geometry cross section at single (x,y,z) coordinate. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. ax : matplotlib.axes._subplots.Axes = None Matplotlib axes to plot on, if not specified, one is created. plot_length_units : LengthUnit = None Specify units to use for axis labels, tick labels, and the title. viz_spec : VisualizationSpec = None Plotting parameters associated with a medium to use instead of defaults. **patch_kwargs Optional keyword arguments passed to the matplotlib patch plotting of structure. For details on accepted values, refer to `Matplotlib's documentation <https://tinyurl.com/2nf5c2fk>`_. Returns ------- matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ # find shapes that intersect self at plane axis, position = self.parse_xyz_kwargs(x=x, y=y, z=z) shapes_intersect = self.intersections_plane(x=x, y=y, z=z) plot_params = self.plot_params if viz_spec is not None: plot_params = plot_params.override_with_viz_spec(viz_spec) plot_params = plot_params.include_kwargs(**patch_kwargs) # for each intersection, plot the shape for shape in shapes_intersect: ax = self.plot_shape(shape, plot_params=plot_params, ax=ax) # clean up the axis display ax = self.add_ax_lims(axis=axis, ax=ax) ax.set_aspect("equal") # Add the default axis labels, tick labels, and title ax = Box.add_ax_labels_and_title(ax=ax, x=x, y=y, z=z, plot_length_units=plot_length_units) return ax
[docs] def plot_shape(self, shape: Shapely, plot_params: PlotParams, ax: Ax) -> Ax: """Defines how a shape is plotted on a matplotlib axes.""" if shape.geom_type in ( "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection", ): for sub_shape in shape.geoms: ax = self.plot_shape(shape=sub_shape, plot_params=plot_params, ax=ax) return ax _shape = Geometry.evaluate_inf_shape(shape) if _shape.geom_type == "LineString": xs, ys = zip(*_shape.coords) ax.plot(xs, ys, color=plot_params.facecolor, linewidth=plot_params.linewidth) elif _shape.geom_type == "Point": ax.scatter(shape.x, shape.y, color=plot_params.facecolor) else: patch = polygon_patch(_shape, **plot_params.to_kwargs()) ax.add_artist(patch) return ax
@staticmethod def _do_not_intersect( bounds_a: float, bounds_b: float, shape_a: Shapely, shape_b: Shapely ) -> bool: """Check whether two shapes intersect.""" # do a bounding box check to see if any intersection to do anything about if ( bounds_a[0] > bounds_b[2] or bounds_b[0] > bounds_a[2] or bounds_a[1] > bounds_b[3] or bounds_b[1] > bounds_a[3] ): return True # look more closely to see if intersected. if shape_b.is_empty or not shape_a.intersects(shape_b): return True return False @staticmethod def _get_plot_labels(axis: Axis) -> tuple[str, str]: """Returns planar coordinate x and y axis labels for cross section plots. Parameters ---------- axis : int Integer index into 'xyz' (0,1,2). Returns ------- str, str Labels of plot, packaged as ``(xlabel, ylabel)``. """ _, (xlabel, ylabel) = Geometry.pop_axis("xyz", axis=axis) return xlabel, ylabel def _get_plot_limits( self, axis: Axis, buffer: float = PLOT_BUFFER ) -> tuple[Coordinate2D, Coordinate2D]: """Gets planar coordinate limits for cross section plots. Parameters ---------- axis : int Integer index into 'xyz' (0,1,2). buffer : float = 0.3 Amount of space to add around the limits on the + and - sides. Returns ------- Tuple[float, float], Tuple[float, float] The x and y plot limits, packed as ``(xmin, xmax), (ymin, ymax)``. """ _, ((xmin, ymin), (xmax, ymax)) = self._pop_bounds(axis=axis) return (xmin - buffer, xmax + buffer), (ymin - buffer, ymax + buffer)
[docs] def add_ax_lims(self, axis: Axis, ax: Ax, buffer: float = PLOT_BUFFER) -> Ax: """Sets the x,y limits based on ``self.bounds``. Parameters ---------- axis : int Integer index into 'xyz' (0,1,2). ax : matplotlib.axes._subplots.Axes Matplotlib axes to add labels and limits on. buffer : float = 0.3 Amount of space to place around the limits on the + and - sides. Returns ------- matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ (xmin, xmax), (ymin, ymax) = self._get_plot_limits(axis=axis, buffer=buffer) # note: axes limits dont like inf values, so we need to evaluate them first if present xmin, xmax, ymin, ymax = self._evaluate_inf((xmin, xmax, ymin, ymax)) ax.set_xlim(xmin, xmax) ax.set_ylim(ymin, ymax) return ax
[docs] @staticmethod def add_ax_labels_and_title( ax: Ax, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, plot_length_units: LengthUnit = None, ) -> Ax: """Sets the axis labels, tick labels, and title based on ``axis`` and an optional ``plot_length_units`` argument. Parameters ---------- ax : matplotlib.axes._subplots.Axes Matplotlib axes to add labels and limits on. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. plot_length_units : LengthUnit = None When set to a supported ``LengthUnit``, plots will be produced with annotated axes and title with the proper units. Returns ------- matplotlib.axes._subplots.Axes The supplied matplotlib axes. """ axis, position = Box.parse_xyz_kwargs(x=x, y=y, z=z) axis_labels = Box._get_plot_labels(axis) ax = set_default_labels_and_title( axis_labels=axis_labels, axis=axis, position=position, ax=ax, plot_length_units=plot_length_units, ) return ax
@staticmethod def _evaluate_inf(array: ArrayLike) -> NDArray[np.floating]: """Processes values and evaluates any infs into large (signed) numbers.""" array = get_static(np.array(array)) return np.where(np.isinf(array), np.sign(array) * LARGE_NUMBER, array)
[docs] @staticmethod def evaluate_inf_shape(shape: Shapely) -> Shapely: """Returns a copy of shape with inf vertices replaced by large numbers if polygon.""" if not any(np.isinf(b) for b in shape.bounds): return shape def _processed_coords(coords: Sequence[tuple[Any, ...]]) -> list[tuple[float, ...]]: evaluated = Geometry._evaluate_inf(np.array(coords)) return [tuple(point) for point in evaluated.tolist()] if shape.geom_type == "Polygon": shell = _processed_coords(shape.exterior.coords) holes = [_processed_coords(g.coords) for g in shape.interiors] return shapely.Polygon(shell, holes) if shape.geom_type in {"Point", "LineString", "LinearRing"}: return shape.__class__(Geometry._evaluate_inf(np.array(shape.coords))) if shape.geom_type in { "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection", }: return shape.__class__([Geometry.evaluate_inf_shape(g) for g in shape.geoms]) return shape
[docs] @staticmethod def pop_axis(coord: tuple[Any, Any, Any], axis: int) -> tuple[Any, tuple[Any, Any]]: """Separates coordinate at ``axis`` index from coordinates on the plane tangent to ``axis``. Parameters ---------- coord : Tuple[Any, Any, Any] Tuple of three values in original coordinate system. axis : int Integer index into 'xyz' (0,1,2). Returns ------- Any, Tuple[Any, Any] The input coordinates are separated into the one along the axis provided and the two on the planar coordinates, like ``axis_coord, (planar_coord1, planar_coord2)``. """ plane_vals = list(coord) axis_val = plane_vals.pop(axis) return axis_val, tuple(plane_vals)
[docs] @staticmethod def unpop_axis(ax_coord: Any, plane_coords: tuple[Any, Any], axis: int) -> tuple[Any, Any, Any]: """Combine coordinate along axis with coordinates on the plane tangent to the axis. Parameters ---------- ax_coord : Any Value along axis direction. plane_coords : Tuple[Any, Any] Values along ordered planar directions. axis : int Integer index into 'xyz' (0,1,2). Returns ------- Tuple[Any, Any, Any] The three values in the xyz coordinate system. """ coords = list(plane_coords) coords.insert(axis, ax_coord) return tuple(coords)
[docs] @staticmethod def parse_xyz_kwargs(**xyz: Any) -> tuple[Axis, float]: """Turns x,y,z kwargs into index of the normal axis and position along that axis. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. Returns ------- int, float Index into xyz axis (0,1,2) and position along that axis. """ xyz_filtered = {k: v for k, v in xyz.items() if v is not None} if len(xyz_filtered) != 1: raise ValueError("exactly one kwarg in [x,y,z] must be specified.") axis_label, position = list(xyz_filtered.items())[0] axis = "xyz".index(axis_label) return axis, position
[docs] @staticmethod def parse_two_xyz_kwargs(**xyz: Any) -> list[tuple[Axis, float]]: """Turns x,y,z kwargs into indices of axes and the position along each axis. Parameters ---------- x : float = None Position in x direction, only two of x,y,z can be specified to define line. y : float = None Position in y direction, only two of x,y,z can be specified to define line. z : float = None Position in z direction, only two of x,y,z can be specified to define line. Returns ------- [(int, float), (int, float)] Index into xyz axis (0,1,2) and position along that axis. """ xyz_filtered = {k: v for k, v in xyz.items() if v is not None} assert len(xyz_filtered) == 2, "exactly two kwarg in [x,y,z] must be specified." xyz_list = list(xyz_filtered.items()) return [("xyz".index(axis_label), position) for axis_label, position in xyz_list]
[docs] @staticmethod def rotate_points(points: ArrayFloat3D, axis: Coordinate, angle: float) -> ArrayFloat3D: """Rotate a set of points in 3D. Parameters ---------- points : ArrayLike[float] Array of shape ``(3, ...)``. axis : Coordinate Axis of rotation angle : float Angle of rotation counter-clockwise around the axis (rad). """ rotation = RotationAroundAxis(axis=axis, angle=angle) return rotation.rotate_vector(points)
[docs] def reflect_points( self, points: ArrayFloat3D, polar_axis: Axis, angle_theta: float, angle_phi: float, ) -> ArrayFloat3D: """Reflect a set of points in 3D at a plane passing through the coordinate origin defined and normal to a given axis defined in polar coordinates (theta, phi) w.r.t. the ``polar_axis`` which can be 0, 1, or 2. Parameters ---------- points : ArrayLike[float] Array of shape ``(3, ...)``. polar_axis : Axis Cartesian axis w.r.t. which the normal axis angles are defined. angle_theta : float Polar angle w.r.t. the polar axis. angle_phi : float Azimuth angle around the polar axis. """ # Rotate such that the plane normal is along the polar_axis axis_theta, axis_phi = [0, 0, 0], [0, 0, 0] axis_phi[polar_axis] = 1 plane_axes = [0, 1, 2] plane_axes.pop(polar_axis) axis_theta[plane_axes[1]] = 1 points_new = self.rotate_points(points, axis_phi, -angle_phi) points_new = self.rotate_points(points_new, axis_theta, -angle_theta) # Flip the ``polar_axis`` coordinate of the points, which is now normal to the plane points_new[polar_axis, :] *= -1 # Rotate back points_new = self.rotate_points(points_new, axis_theta, angle_theta) points_new = self.rotate_points(points_new, axis_phi, angle_phi) return points_new
[docs] def volume(self, bounds: Bound = None) -> float: """Returns object's volume with optional bounds. Parameters ---------- bounds : Tuple[Tuple[float, float, float], Tuple[float, float, float]] = None Min and max bounds packaged as ``(minx, miny, minz), (maxx, maxy, maxz)``. Returns ------- float Volume in um^3. """ if not bounds: bounds = self.bounds return self._volume(bounds)
@abstractmethod def _volume(self, bounds: Bound) -> float: """Returns object's volume within given bounds."""
[docs] def surface_area(self, bounds: Bound = None) -> float: """Returns object's surface area with optional bounds. Parameters ---------- bounds : Tuple[Tuple[float, float, float], Tuple[float, float, float]] = None Min and max bounds packaged as ``(minx, miny, minz), (maxx, maxy, maxz)``. Returns ------- float Surface area in um^2. """ if not bounds: bounds = self.bounds return self._surface_area(bounds)
@abstractmethod def _surface_area(self, bounds: Bound) -> float: """Returns object's surface area within given bounds."""
[docs] def translated(self, x: float, y: float, z: float) -> Geometry: """Return a translated copy of this geometry. Parameters ---------- x : float Translation along x. y : float Translation along y. z : float Translation along z. Returns ------- :class:`Geometry` Translated copy of this geometry. """ return Transformed(geometry=self, transform=Transformed.translation(x, y, z))
[docs] def scaled(self, x: float = 1.0, y: float = 1.0, z: float = 1.0) -> Geometry: """Return a scaled copy of this geometry. Parameters ---------- x : float = 1.0 Scaling factor along x. y : float = 1.0 Scaling factor along y. z : float = 1.0 Scaling factor along z. Returns ------- :class:`Geometry` Scaled copy of this geometry. """ return Transformed(geometry=self, transform=Transformed.scaling(x, y, z))
[docs] def rotated(self, angle: float, axis: Union[Axis, Coordinate]) -> Geometry: """Return a rotated copy of this geometry. Parameters ---------- angle : float Rotation angle (in radians). axis : Union[int, Tuple[float, float, float]] Axis of rotation: 0, 1, or 2 for x, y, and z, respectively, or a 3D vector. Returns ------- :class:`Geometry` Rotated copy of this geometry. """ return Transformed(geometry=self, transform=Transformed.rotation(angle, axis))
[docs] def reflected(self, normal: Coordinate) -> Geometry: """Return a reflected copy of this geometry. Parameters ---------- normal : Tuple[float, float, float] The 3D normal vector of the plane of reflection. The plane is assumed to pass through the origin (0,0,0). Returns ------- :class:`Geometry` Reflected copy of this geometry. """ return Transformed(geometry=self, transform=Transformed.reflection(normal))
""" Field and coordinate transformations """
[docs] @staticmethod def car_2_sph(x: float, y: float, z: float) -> tuple[float, float, float]: """Convert Cartesian to spherical coordinates. Parameters ---------- x : float x coordinate relative to ``local_origin``. y : float y coordinate relative to ``local_origin``. z : float z coordinate relative to ``local_origin``. Returns ------- Tuple[float, float, float] r, theta, and phi coordinates relative to ``local_origin``. """ r = np.sqrt(x**2 + y**2 + z**2) theta = np.arccos(z / r) phi = np.arctan2(y, x) return r, theta, phi
[docs] @staticmethod def sph_2_car(r: float, theta: float, phi: float) -> tuple[float, float, float]: """Convert spherical to Cartesian coordinates. Parameters ---------- r : float radius. theta : float polar angle (rad) downward from x=y=0 line. phi : float azimuthal (rad) angle from y=z=0 line. Returns ------- Tuple[float, float, float] x, y, and z coordinates relative to ``local_origin``. """ r_sin_theta = r * np.sin(theta) x = r_sin_theta * np.cos(phi) y = r_sin_theta * np.sin(phi) z = r * np.cos(theta) return x, y, z
[docs] @staticmethod def sph_2_car_field( f_r: float, f_theta: float, f_phi: float, theta: float, phi: float ) -> tuple[complex, complex, complex]: """Convert vector field components in spherical coordinates to cartesian. Parameters ---------- f_r : float radial component of the vector field. f_theta : float polar angle component of the vector fielf. f_phi : float azimuthal angle component of the vector field. theta : float polar angle (rad) of location of the vector field. phi : float azimuthal angle (rad) of location of the vector field. Returns ------- Tuple[float, float, float] x, y, and z components of the vector field in cartesian coordinates. """ sin_theta = np.sin(theta) cos_theta = np.cos(theta) sin_phi = np.sin(phi) cos_phi = np.cos(phi) f_x = f_r * sin_theta * cos_phi + f_theta * cos_theta * cos_phi - f_phi * sin_phi f_y = f_r * sin_theta * sin_phi + f_theta * cos_theta * sin_phi + f_phi * cos_phi f_z = f_r * cos_theta - f_theta * sin_theta return f_x, f_y, f_z
[docs] @staticmethod def car_2_sph_field( f_x: float, f_y: float, f_z: float, theta: float, phi: float ) -> tuple[complex, complex, complex]: """Convert vector field components in cartesian coordinates to spherical. Parameters ---------- f_x : float x component of the vector field. f_y : float y component of the vector fielf. f_z : float z component of the vector field. theta : float polar angle (rad) of location of the vector field. phi : float azimuthal angle (rad) of location of the vector field. Returns ------- Tuple[float, float, float] radial (s), elevation (theta), and azimuthal (phi) components of the vector field in spherical coordinates. """ sin_theta = np.sin(theta) cos_theta = np.cos(theta) sin_phi = np.sin(phi) cos_phi = np.cos(phi) f_r = f_x * sin_theta * cos_phi + f_y * sin_theta * sin_phi + f_z * cos_theta f_theta = f_x * cos_theta * cos_phi + f_y * cos_theta * sin_phi - f_z * sin_theta f_phi = -f_x * sin_phi + f_y * cos_phi return f_r, f_theta, f_phi
[docs] @staticmethod def kspace_2_sph(ux: float, uy: float, axis: Axis) -> tuple[float, float]: """Convert normalized k-space coordinates to angles. Parameters ---------- ux : float normalized kx coordinate. uy : float normalized ky coordinate. axis : int axis along which the observation plane is oriented. Returns ------- Tuple[float, float] theta and phi coordinates relative to ``local_origin``. """ phi_local = np.arctan2(uy, ux) with np.errstate(invalid="ignore"): theta_local = np.arcsin(np.sqrt(ux**2 + uy**2)) # Spherical coordinates rotation matrix reference: # https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula#Matrix_notation if axis == 2: return theta_local, phi_local x = np.cos(theta_local) y = np.sin(theta_local) * np.cos(phi_local) z = np.sin(theta_local) * np.sin(phi_local) if axis == 1: x, y, z = y, x, z theta = np.arccos(z) phi = np.arctan2(y, x) return theta, phi
[docs] @staticmethod @verify_packages_import(["gdstk"]) def load_gds_vertices_gdstk( gds_cell: Cell, gds_layer: int, gds_dtype: Optional[int] = None, gds_scale: pydantic.PositiveFloat = 1.0, ) -> list[ArrayFloat2D]: """Load polygon vertices from a ``gdstk.Cell``. Parameters ---------- gds_cell : gdstk.Cell ``gdstk.Cell`` containing 2D geometric data. gds_layer : int Layer index in the ``gds_cell``. gds_dtype : int = None Data-type index in the ``gds_cell``. If ``None``, imports all data for this layer into the returned list. gds_scale : float = 1.0 Length scale used in GDS file in units of micrometer. For example, if gds file uses nanometers, set ``gds_scale=1e-3``. Must be positive. Returns ------- List[ArrayFloat2D] List of polygon vertices """ # apply desired scaling and load the polygon vertices if gds_dtype is not None: # if both layer and datatype are specified, let gdstk do the filtering for better # performance on large layouts all_vertices = [ polygon.scale(gds_scale).points for polygon in gds_cell.get_polygons(layer=gds_layer, datatype=gds_dtype) ] else: all_vertices = [ polygon.scale(gds_scale).points for polygon in gds_cell.get_polygons() if polygon.layer == gds_layer ] # make sure something got loaded, otherwise error if not all_vertices: raise Tidy3dKeyError( f"Couldn't load gds_cell, no vertices found at gds_layer={gds_layer} " f"with specified gds_dtype={gds_dtype}." ) return all_vertices
[docs] @staticmethod @verify_packages_import(["gdstk"]) def from_gds( gds_cell: Cell, axis: Axis, slab_bounds: tuple[float, float], gds_layer: int, gds_dtype: Optional[int] = None, gds_scale: pydantic.PositiveFloat = 1.0, dilation: float = 0.0, sidewall_angle: float = 0, reference_plane: PlanePosition = "middle", ) -> Geometry: """Import a ``gdstk.Cell`` and extrude it into a GeometryGroup. Parameters ---------- gds_cell : gdstk.Cell ``gdstk.Cell`` containing 2D geometric data. axis : int Integer index defining the extrusion axis: 0 (x), 1 (y), or 2 (z). slab_bounds: Tuple[float, float] Minimal and maximal positions of the extruded slab along ``axis``. gds_layer : int Layer index in the ``gds_cell``. gds_dtype : int = None Data-type index in the ``gds_cell``. If ``None``, imports all data for this layer into the returned list. gds_scale : float = 1.0 Length scale used in GDS file in units of micrometer. For example, if gds file uses nanometers, set ``gds_scale=1e-3``. Must be positive. dilation : float = 0.0 Dilation (positive) or erosion (negative) amount to be applied to the original polygons. sidewall_angle : float = 0 Angle of the extrusion sidewalls, away from the vertical direction, in radians. Positive (negative) values result in slabs larger (smaller) at the base than at the top. reference_plane : PlanePosition = "middle" Reference position of the (dilated/eroded) polygons along the slab axis. One of ``"middle"`` (polygons correspond to the center of the slab bounds), ``"bottom"`` (minimal slab bound position), or ``"top"`` (maximal slab bound position). This value has no effect if ``sidewall_angle == 0``. Returns ------- :class:`Geometry` Geometries created from the 2D data. """ import gdstk if not isinstance(gds_cell, gdstk.Cell): # Check if it might be a gdstk cell but gdstk is not found (should be caught by decorator) # or if it's an entirely different type. if "gdstk" in gds_cell.__class__.__name__.lower(): raise Tidy3dImportError( "Module 'gdstk' not found. It is required to import gdstk cells." ) raise Tidy3dImportError("Argument 'gds_cell' must be an instance of 'gdstk.Cell'.") gds_loader_fn = Geometry.load_gds_vertices_gdstk geometries = [] with log as consolidated_logger: for vertices in gds_loader_fn(gds_cell, gds_layer, gds_dtype, gds_scale): # buffer(0) is necessary to merge self-intersections shape = shapely.set_precision(shapely.Polygon(vertices).buffer(0), POLY_GRID_SIZE) try: geometries.append( from_shapely( shape, axis, slab_bounds, dilation, sidewall_angle, reference_plane ) ) except pydantic.ValidationError as error: consolidated_logger.warning(str(error)) except Tidy3dError as error: consolidated_logger.warning(str(error)) return geometries[0] if len(geometries) == 1 else GeometryGroup(geometries=geometries)
[docs] @staticmethod def from_shapely( shape: Shapely, axis: Axis, slab_bounds: tuple[float, float], dilation: float = 0.0, sidewall_angle: float = 0, reference_plane: PlanePosition = "middle", ) -> Geometry: """Convert a shapely primitive into a geometry instance by extrusion. Parameters ---------- shape : shapely.geometry.base.BaseGeometry Shapely primitive to be converted. It must be a linear ring, a polygon or a collection of any of those. axis : int Integer index defining the extrusion axis: 0 (x), 1 (y), or 2 (z). slab_bounds: Tuple[float, float] Minimal and maximal positions of the extruded slab along ``axis``. dilation : float Dilation of the polygon in the base by shifting each edge along its normal outwards direction by a distance; a negative value corresponds to erosion. sidewall_angle : float = 0 Angle of the extrusion sidewalls, away from the vertical direction, in radians. Positive (negative) values result in slabs larger (smaller) at the base than at the top. reference_plane : PlanePosition = "middle" Reference position of the (dilated/eroded) polygons along the slab axis. One of ``"middle"`` (polygons correspond to the center of the slab bounds), ``"bottom"`` (minimal slab bound position), or ``"top"`` (maximal slab bound position). This value has no effect if ``sidewall_angle == 0``. Returns ------- :class:`Geometry` Geometry extruded from the 2D data. """ return from_shapely(shape, axis, slab_bounds, dilation, sidewall_angle, reference_plane)
[docs] @verify_packages_import(["gdstk"]) def to_gdstk( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, gds_layer: pydantic.NonNegativeInt = 0, gds_dtype: pydantic.NonNegativeInt = 0, ) -> list: """Convert a Geometry object's planar slice to a .gds type polygon. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. gds_layer : int = 0 Layer index to use for the shapes stored in the .gds file. gds_dtype : int = 0 Data-type index to use for the shapes stored in the .gds file. Return ------ List List of `gdstk.Polygon`. """ import gdstk shapes = self.intersections_plane(x=x, y=y, z=z) polygons = [] for shape in shapes: for vertices in vertices_from_shapely(shape): if len(vertices) == 1: polygons.append(gdstk.Polygon(vertices[0], gds_layer, gds_dtype)) else: polygons.extend( gdstk.boolean( vertices[:1], vertices[1:], "not", layer=gds_layer, datatype=gds_dtype, ) ) return polygons
[docs] @verify_packages_import(["gdstk"]) def to_gds( self, cell: Cell, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, gds_layer: pydantic.NonNegativeInt = 0, gds_dtype: pydantic.NonNegativeInt = 0, ) -> None: """Append a Geometry object's planar slice to a .gds cell. Parameters ---------- cell : ``gdstk.Cell`` Cell object to which the generated polygons are added. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. gds_layer : int = 0 Layer index to use for the shapes stored in the .gds file. gds_dtype : int = 0 Data-type index to use for the shapes stored in the .gds file. """ import gdstk if not isinstance(cell, gdstk.Cell): if "gdstk" in cell.__class__.__name__.lower(): raise Tidy3dImportError( "Module 'gdstk' not found. It is required to export shapes to gdstk cells." ) raise Tidy3dImportError("Argument 'cell' must be an instance of 'gdstk.Cell'.") polygons = self.to_gdstk(x=x, y=y, z=z, gds_layer=gds_layer, gds_dtype=gds_dtype) if polygons: cell.add(*polygons)
[docs] @verify_packages_import(["gdstk"]) def to_gds_file( self, fname: PathLike, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, gds_layer: pydantic.NonNegativeInt = 0, gds_dtype: pydantic.NonNegativeInt = 0, gds_cell_name: str = "MAIN", ) -> None: """Export a Geometry object's planar slice to a .gds file. Parameters ---------- fname : PathLike Full path to the .gds file to save the :class:`Geometry` slice to. 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. gds_layer : int = 0 Layer index to use for the shapes stored in the .gds file. gds_dtype : int = 0 Data-type index to use for the shapes stored in the .gds file. gds_cell_name : str = 'MAIN' Name of the cell created in the .gds file to store the geometry. """ try: import gdstk except ImportError as e: raise Tidy3dImportError( "Python module 'gdstk' not found. To export geometries to .gds " "files, please install it." ) from e library = gdstk.Library() cell = library.new_cell(gds_cell_name) self.to_gds(cell, x=x, y=y, z=z, gds_layer=gds_layer, gds_dtype=gds_dtype) fname = pathlib.Path(fname) fname.parent.mkdir(parents=True, exist_ok=True) library.write_gds(fname)
def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradFieldMap: """Compute the adjoint derivatives for this object.""" raise NotImplementedError(f"Can't compute derivative for 'Geometry': '{type(self)}'.") def _as_union(self) -> list[Geometry]: """Return a list of geometries that, united, make up the given geometry.""" if isinstance(self, GeometryGroup): return self.geometries if isinstance(self, ClipOperation) and self.operation == "union": return (self.geometry_a, self.geometry_b) return (self,)
[docs] def __add__(self, other: Union[int, Geometry]) -> Union[Self, GeometryGroup]: """Union of geometries""" # This allows the user to write sum(geometries...) with the default start=0 if isinstance(other, int): return self if not isinstance(other, Geometry): return NotImplemented # type: ignore[return-value] return GeometryGroup(geometries=self._as_union() + other._as_union())
[docs] def __radd__(self, other: Union[int, Geometry]) -> Union[Self, GeometryGroup]: """Union of geometries""" # This allows the user to write sum(geometries...) with the default start=0 if isinstance(other, int): return self if not isinstance(other, Geometry): return NotImplemented # type: ignore[return-value] return GeometryGroup(geometries=other._as_union() + self._as_union())
[docs] def __or__(self, other: Geometry) -> GeometryGroup: """Union of geometries""" if not isinstance(other, Geometry): return NotImplemented return GeometryGroup(geometries=self._as_union() + other._as_union())
[docs] def __mul__(self, other: Geometry) -> ClipOperation: """Intersection of geometries""" if not isinstance(other, Geometry): return NotImplemented return ClipOperation(operation="intersection", geometry_a=self, geometry_b=other)
[docs] def __and__(self, other: Geometry) -> ClipOperation: """Intersection of geometries""" if not isinstance(other, Geometry): return NotImplemented return ClipOperation(operation="intersection", geometry_a=self, geometry_b=other)
[docs] def __sub__(self, other: Geometry) -> ClipOperation: """Difference of geometries""" if not isinstance(other, Geometry): return NotImplemented # type: ignore[return-value] return ClipOperation(operation="difference", geometry_a=self, geometry_b=other)
[docs] def __xor__(self, other: Geometry) -> ClipOperation: """Symmetric difference of geometries""" if not isinstance(other, Geometry): return NotImplemented return ClipOperation(operation="symmetric_difference", geometry_a=self, geometry_b=other)
[docs] def __pos__(self) -> Self: """No op""" return self
[docs] def __neg__(self) -> ClipOperation: """Opposite of a geometry""" return ClipOperation( operation="difference", geometry_a=Box(size=(inf, inf, inf)), geometry_b=self )
[docs] def __invert__(self) -> ClipOperation: """Opposite of a geometry""" return ClipOperation( operation="difference", geometry_a=Box(size=(inf, inf, inf)), geometry_b=self )
""" Abstract subclasses """
[docs] class Centered(Geometry, ABC): """Geometry with a well defined center.""" center: TracedCoordinate = pydantic.Field( (0.0, 0.0, 0.0), title="Center", description="Center of object in x, y, and z.", units=MICROMETER, ) @pydantic.validator("center", always=True) def _center_not_inf(cls, val: tuple[float, float, float]) -> tuple[float, float, float]: """Make sure center is not infinitiy.""" if any(np.isinf(v) for v in val): raise ValidationError("center can not contain td.inf terms.") return val
[docs] class SimplePlaneIntersection(Geometry, ABC): """A geometry where intersections with an axis aligned plane may be computed efficiently."""
[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. Checks special cases before relying on the complete computation. 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 high-quality visualization settings. 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>`_. """ # Check if normal is a special case, where the normal is aligned with an axis. if np.sum(np.isclose(normal, 0.0)) == 2: axis = np.argmax(np.abs(normal)).item() coord = "xyz"[axis] kwargs = {coord: origin[axis]} section = self.intersections_plane(cleanup=cleanup, quad_segs=quad_segs, **kwargs) # Apply transformation in the plane by removing row and column to_2D_in_plane = np.delete(np.delete(to_2D, 2, 0), axis, 1) def transform(p_array: NDArray) -> NDArray: return np.dot( np.hstack((p_array, np.ones((p_array.shape[0], 1)))), to_2D_in_plane.T )[:, :2] transformed_section = shapely.transform(section, transformation=transform) return transformed_section # Otherwise compute the arbitrary intersection return self._do_intersections_tilted_plane( normal=normal, origin=origin, to_2D=to_2D, quad_segs=quad_segs )
@abstractmethod 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. 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>`_. """
[docs] class Planar(SimplePlaneIntersection, Geometry, ABC): """Geometry with one ``axis`` that is slab-like with thickness ``height``.""" axis: Axis = pydantic.Field( 2, title="Axis", description="Specifies dimension of the planar axis (0,1,2) -> (x,y,z)." ) sidewall_angle: TracedFloat = pydantic.Field( 0.0, title="Sidewall angle", description="Angle of the sidewall. " "``sidewall_angle=0`` (default) specifies a vertical wall; " "``0<sidewall_angle<np.pi/2`` specifies a shrinking cross section " "along the ``axis`` direction; " "and ``-np.pi/2<sidewall_angle<0`` specifies an expanding cross section " "along the ``axis`` direction.", units=RADIAN, ) reference_plane: PlanePosition = pydantic.Field( "middle", title="Reference plane for cross section", description="The position of the plane where the supplied cross section are " "defined. The plane is perpendicular to the ``axis``. " "The plane is located at the ``bottom``, ``middle``, or ``top`` of the " "geometry with respect to the axis. " "E.g. if ``axis=1``, ``bottom`` refers to the negative side of the y-axis, and " "``top`` refers to the positive side of the y-axis.", )
[docs] @pydantic.validator("sidewall_angle", always=True) def validate_angle(cls, value: float) -> float: lower_bound = -np.pi / 2 upper_bound = np.pi / 2 if (value <= lower_bound) or (value >= upper_bound): # u03C0 is unicode for pi raise ValidationError(f"Sidewall angle ({value}) must be between -Ο€/2 and Ο€/2 rad.") return value
@property @abstractmethod def center_axis(self) -> float: """Gets the position of the center of the geometry in the out of plane dimension.""" @property @abstractmethod def length_axis(self) -> float: """Gets the length of the geometry along the out of plane dimension.""" @property def finite_length_axis(self) -> float: """Gets the length of the geometry along the out of plane dimension. If the length is td.inf, return ``LARGE_NUMBER`` """ return min(self.length_axis, LARGE_NUMBER) @property def reference_axis_pos(self) -> float: """Coordinate along the slab axis at the reference plane. Returns the axis coordinate corresponding to the selected reference_plane: - "bottom": lower bound of slab_bounds - "middle": center_axis - "top": upper bound of slab_bounds """ if self.reference_plane == "bottom": return self.slab_bounds[0] if self.reference_plane == "top": return self.slab_bounds[1] # default to middle return self.center_axis
[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[Shapely]: """Returns shapely geometry at plane specified by one non None value of x,y,z. Parameters ---------- x : float Position of plane in x direction, only one of x,y,z can be specified to define plane. y : float Position of plane in y direction, only one of x,y,z can be specified to define plane. z : float Position of plane in z 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 high-quality visualization settings. 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 [] if axis == self.axis: return self._intersections_normal(position, quad_segs=quad_segs) return self._intersections_side(position, axis)
@abstractmethod def _intersections_normal(self, z: float, quad_segs: Optional[int] = None) -> list: """Find shapely geometries intersecting planar 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 high-quality visualization settings. 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>`_. """ @abstractmethod def _intersections_side(self, position: float, axis: Axis) -> list[Shapely]: """Find shapely geometries intersecting planar geometry with axis orthogonal to plane. Parameters ---------- position : float Position along axis. 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>`_. """ def _order_axis(self, axis: int) -> int: """Order the axis as if self.axis is along z-direction. Parameters ---------- axis : int Integer index into the structure's planar axis. Returns ------- int New index of axis. """ axis_index = [0, 1] axis_index.insert(self.axis, 2) return axis_index[axis] def _order_by_axis(self, plane_val: Any, axis_val: Any, axis: int) -> tuple[Any, Any]: """Orders a value in the plane and value along axis in correct (x,y) order for plotting. Note: sometimes if axis=1 and we compute cross section values orthogonal to axis, they can either be x or y in the plots. This function allows one to figure out the ordering. Parameters ---------- plane_val : Any The value in the planar coordinate. axis_val : Any The value in the ``axis`` coordinate. axis : int Integer index into the structure's planar axis. Returns ------- ``(Any, Any)`` The two planar coordinates in this new coordinate system. """ vals = 3 * [plane_val] vals[self.axis] = axis_val _, (val_x, val_y) = self.pop_axis(vals, axis=axis) return val_x, val_y @cached_property def _tanq(self) -> float: """Value of ``tan(sidewall_angle)``. The (possibliy infinite) geometry offset is given by ``_tanq * length_axis``. """ return np.tan(self.sidewall_angle)
[docs] class Circular(Geometry): """Geometry with circular characteristics (specified by a radius).""" radius: pydantic.NonNegativeFloat = pydantic.Field( ..., title="Radius", description="Radius of geometry.", units=MICROMETER ) @pydantic.validator("radius", always=True) def _radius_not_inf(cls, val: float) -> float: """Make sure center is not infinitiy.""" if np.isinf(val): raise ValidationError("radius can not be td.inf.") return val def _intersect_dist(self, position: float, z0: float) -> float: """Distance between points on circle at z=position where center of circle at z=z0. Parameters ---------- position : float position along z. z0 : float center of circle in z. Returns ------- float Distance between points on the circle intersecting z=z, if no points, ``None``. """ dz = np.abs(z0 - position) if dz > self.radius: return None return 2 * np.sqrt(self.radius**2 - dz**2)
"""Primitive classes"""
[docs] class Box(SimplePlaneIntersection, Centered): """Rectangular prism. Also base class for :class:`.Simulation`, :class:`Monitor`, and :class:`Source`. Example ------- >>> b = Box(center=(1,2,3), size=(2,2,2)) """ size: TracedSize = pydantic.Field( ..., title="Size", description="Size in x, y, and z directions.", units=MICROMETER, )
[docs] @classmethod def from_bounds(cls, rmin: Coordinate, rmax: Coordinate, **kwargs: Any) -> Self: """Constructs a :class:`Box` from minimum and maximum coordinate bounds Parameters ---------- rmin : Tuple[float, float, float] (x, y, z) coordinate of the minimum values. rmax : Tuple[float, float, float] (x, y, z) coordinate of the maximum values. Example ------- >>> b = Box.from_bounds(rmin=(-1, -2, -3), rmax=(3, 2, 1)) """ center = tuple(cls._get_center(pt_min, pt_max) for pt_min, pt_max in zip(rmin, rmax)) size = tuple((pt_max - pt_min) for pt_min, pt_max in zip(rmin, rmax)) return cls(center=center, size=size, **kwargs)
@cached_property def _normal_axis(self) -> Axis: """Axis normal to the Box. Errors if box is not planar.""" if self.size.count(0.0) != 1: raise ValidationError( f"Tried to get 'normal_axis' of 'Box' that is not planar. Given 'size={self.size}.'" ) return self.size.index(0.0)
[docs] @classmethod def surfaces(cls, size: Size, center: Coordinate, **kwargs: Any) -> list[Self]: """Returns a list of 6 :class:`Box` instances corresponding to each surface of a 3D volume. The output surfaces are stored in the order [x-, x+, y-, y+, z-, z+], where x, y, and z denote which axis is perpendicular to that surface, while "-" and "+" denote the direction of the normal vector of that surface. If a name is provided, each output surface's name will be that of the provided name appended with the above symbols. E.g., if the provided name is "box", the x+ surfaces's name will be "box_x+". Parameters ---------- size : Tuple[float, float, float] Size of object in x, y, and z directions. center : Tuple[float, float, float] Center of object in x, y, and z. Example ------- >>> b = Box.surfaces(size=(1, 2, 3), center=(3, 2, 1)) """ if any(s == 0.0 for s in size): raise SetupError( "Can't generate surfaces for the given object because it has zero volume." ) bounds = Box(center=center, size=size).bounds # Set up geometry data and names for each surface: centers = [list(center) for _ in range(6)] sizes = [list(size) for _ in range(6)] surface_index = 0 for dim_index in range(3): for min_max_index in range(2): new_center = centers[surface_index] new_size = sizes[surface_index] new_center[dim_index] = bounds[min_max_index][dim_index] new_size[dim_index] = 0.0 centers[surface_index] = new_center sizes[surface_index] = new_size surface_index += 1 name_base = kwargs.pop("name", "") kwargs.pop("normal_dir", None) names = [] normal_dirs = [] for coord in "xyz": for direction in "-+": surface_name = name_base + "_" + coord + direction names.append(surface_name) normal_dirs.append(direction) # ignore surfaces that are infinitely far away del_idx = [] for idx, _size in enumerate(size): if _size == inf: del_idx.append(idx) del_idx = [[2 * i, 2 * i + 1] for i in del_idx] del_idx = [item for sublist in del_idx for item in sublist] def del_items(items: Iterable, indices: int) -> list: """Delete list items at indices.""" return [i for j, i in enumerate(items) if j not in indices] centers = del_items(centers, del_idx) sizes = del_items(sizes, del_idx) names = del_items(names, del_idx) normal_dirs = del_items(normal_dirs, del_idx) surfaces = [] for _cent, _size, _name, _normal_dir in zip(centers, sizes, names, normal_dirs): if "normal_dir" in cls.__dict__["__fields__"]: kwargs["normal_dir"] = _normal_dir if "name" in cls.__dict__["__fields__"]: kwargs["name"] = _name surface = cls(center=_cent, size=_size, **kwargs) surfaces.append(surface) return surfaces
[docs] @classmethod def surfaces_with_exclusion(cls, size: Size, center: Coordinate, **kwargs: Any) -> list[Self]: """Returns a list of 6 :class:`Box` instances corresponding to each surface of a 3D volume. The output surfaces are stored in the order [x-, x+, y-, y+, z-, z+], where x, y, and z denote which axis is perpendicular to that surface, while "-" and "+" denote the direction of the normal vector of that surface. If a name is provided, each output surface's name will be that of the provided name appended with the above symbols. E.g., if the provided name is "box", the x+ surfaces's name will be "box_x+". If ``kwargs`` contains an ``exclude_surfaces`` parameter, the returned list of surfaces will not include the excluded surfaces. Otherwise, the behavior is identical to that of ``surfaces()``. Parameters ---------- size : Tuple[float, float, float] Size of object in x, y, and z directions. center : Tuple[float, float, float] Center of object in x, y, and z. Example ------- >>> b = Box.surfaces_with_exclusion( ... size=(1, 2, 3), center=(3, 2, 1), exclude_surfaces=["x-"] ... ) """ exclude_surfaces = kwargs.pop("exclude_surfaces", None) surfaces = cls.surfaces(size=size, center=center, **kwargs) if "name" in cls.__dict__["__fields__"] and exclude_surfaces: surfaces = [surf for surf in surfaces if surf.name[-2:] not in exclude_surfaces] return surfaces
@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. Not used for Box geometry. 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 (x0, y0, z0), (x1, y1, z1) = self.bounds vertices = [ (x0, y0, z0), # 0 (x0, y0, z1), # 1 (x0, y1, z0), # 2 (x0, y1, z1), # 3 (x1, y0, z0), # 4 (x1, y0, z1), # 5 (x1, y1, z0), # 6 (x1, y1, z1), # 7 ] faces = [ (0, 1, 3, 2), # -x (4, 6, 7, 5), # +x (0, 4, 5, 1), # -y (2, 3, 7, 6), # +y (0, 2, 6, 4), # -z (1, 5, 7, 3), # +z ] 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
[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[Shapely]: """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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z 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. Not used for Box geometry. 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) Lz, (Lx, Ly) = self.pop_axis(self.size, axis=axis) dz = np.abs(z0 - position) if dz > Lz / 2 + fp_eps: return [] minx = x0 - Lx / 2 miny = y0 - Ly / 2 maxx = x0 + Lx / 2 maxy = y0 + Ly / 2 # handle case where the box vertices are identical if np.isclose(minx, maxx) and np.isclose(miny, maxy): return [self.make_shapely_point(minx, miny)] return [self.make_shapely_box(minx, miny, maxx, maxy)]
[docs] def inside(self, x: NDArray[float], y: NDArray[float], z: NDArray[float]) -> 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 Lx, Ly, Lz = self.size dist_x = np.abs(x - x0) dist_y = np.abs(y - y0) dist_z = np.abs(z - z0) return (dist_x <= Lx / 2) * (dist_y <= Ly / 2) * (dist_z <= Lz / 2)
[docs] def intersections_with( self, other: Shapely, cleanup: bool = True, quad_segs: Optional[int] = None ) -> list[Shapely]: """Returns list of shapely geometries representing the intersections of the geometry with this 2D box. Parameters ---------- other : Shapely Geometry to intersect with. 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 high-quality visualization settings. Returns ------- List[shapely.geometry.base.BaseGeometry] List of 2D shapes that intersect this 2D box. For more details refer to `Shapely's Documentation <https://shapely.readthedocs.io/en/stable/project.html>`_. """ # Verify 2D if self.size.count(0.0) != 1: raise ValidationError( "Intersections with other geometry are only calculated from a 2D box." ) # dont bother if the geometry doesn't intersect the self at all if not other.intersects(self): return [] # get list of Shapely shapes that intersect at the self normal_ind = self.size.index(0.0) dim = "xyz"[normal_ind] pos = self.center[normal_ind] xyz_kwargs = {dim: pos} shapes_plane = other.intersections_plane(cleanup=cleanup, quad_segs=quad_segs, **xyz_kwargs) # intersect all shapes with the input self bs_min, bs_max = (self.pop_axis(bounds, axis=normal_ind)[1] for bounds in self.bounds) shapely_box = self.make_shapely_box(bs_min[0], bs_min[1], bs_max[0], bs_max[1]) shapely_box = Geometry.evaluate_inf_shape(shapely_box) return [Geometry.evaluate_inf_shape(shape) & shapely_box for shape in shapes_plane]
[docs] def slightly_enlarged_copy(self) -> Box: """Box size slightly enlarged around machine precision.""" size = [increment_float(orig_length, 1) for orig_length in self.size] return self.updated_copy(size=size)
[docs] def padded_copy( self, x: Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None, y: Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None, z: Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None, ) -> Box: """Created a padded copy of a :class:`Box` instance. Parameters ---------- x : Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None Padding sizes at the left and right boundaries of the box along x-axis. y : Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None Padding sizes at the left and right boundaries of the box along y-axis. z : Optional[tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat]] = None Padding sizes at the left and right boundaries of the box along z-axis. Returns ------- Box Padded instance of :class:`Box`. """ # Validate that padding values are non-negative for axis_name, axis_padding in zip(("x", "y", "z"), (x, y, z)): if axis_padding is not None: if not isinstance(axis_padding, (tuple, list)) or len(axis_padding) != 2: raise ValueError(f"Padding for {axis_name}-axis must be a tuple of two values.") if any(p < 0 for p in axis_padding): raise ValueError( f"Padding values for {axis_name}-axis must be non-negative. Got {axis_padding}." ) rmin, rmax = self.bounds def bound_array(arrs: ArrayLike, idx: int) -> NDArray: return np.array([(a[idx] if a is not None else 0) for a in arrs]) # parse padding sizes for simulation drmin = bound_array((x, y, z), 0) drmax = bound_array((x, y, z), 1) rmin = np.array(rmin) - drmin rmax = np.array(rmax) + drmax return Box.from_bounds(rmin=rmin, rmax=rmax)
@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)``. """ size = self.size center = self.center coord_min = tuple(c - s / 2 for (s, c) in zip(size, center)) coord_max = tuple(c + s / 2 for (s, c) in zip(size, center)) return (coord_min, coord_max) @cached_property def geometry(self) -> Box: """:class:`Box` representation of self (used for subclasses of Box). Returns ------- :class:`Box` Instance of :class:`Box` representing self's geometry. """ return Box(center=self.center, size=self.size) @cached_property def zero_dims(self) -> list[Axis]: """A list of axes along which the :class:`Box` is zero-sized.""" return [dim for dim, size in enumerate(self.size) if size == 0] @cached_property def _normal_2dmaterial(self) -> Axis: """Get the normal to the given geometry, checking that it is a 2D geometry.""" if np.count_nonzero(self.size) != 2: raise ValidationError( "'Medium2D' requires exactly one of the 'Box' dimensions to have size zero." ) return self.size.index(0) def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> Box: """Returns an updated geometry which has been transformed to fit within ``bounds`` along the ``axis`` direction.""" new_center = list(self.center) new_center[axis] = (bounds[0] + bounds[1]) / 2 new_size = list(self.size) new_size[axis] = bounds[1] - bounds[0] return self.updated_copy(center=new_center, size=new_size) def _plot_arrow( self, direction: tuple[float, float, float], x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, color: Optional[str] = None, alpha: Optional[float] = None, bend_radius: Optional[float] = None, bend_axis: Axis = None, both_dirs: bool = False, ax: Ax = None, arrow_base: Coordinate = None, ) -> Ax: """Adds an arrow to the axis if with options if certain conditions met. Parameters ---------- direction: Tuple[float, float, float] Normalized vector describing the arrow direction. x : float = None Position of plotting plane in x direction. y : float = None Position of plotting plane in y direction. z : float = None Position of plotting plane in z direction. color : str = None Color of the arrow. alpha : float = None Opacity of the arrow (0, 1) bend_radius : float = None Radius of curvature for this arrow. bend_axis : Axis = None Axis of curvature of ``bend_radius``. both_dirs : bool = False If True, plots an arrow pointing in direction and one in -direction. arrow_base : :class:`.Coordinate` = None Custom base of the arrow. Uses the geometry's center if not provided. Returns ------- matplotlib.axes._subplots.Axes The matplotlib axes with the arrow added. """ plot_axis, _ = self.parse_xyz_kwargs(x=x, y=y, z=z) _, (dx, dy) = self.pop_axis(direction, axis=plot_axis) # conditions to check to determine whether to plot arrow, taking into account the # possibility of a custom arrow base arrow_intersecting_plane = len(self.intersections_plane(x=x, y=y, z=z)) > 0 center = self.center if arrow_base: arrow_intersecting_plane = arrow_intersecting_plane and any( a == b for a, b in zip(arrow_base, [x, y, z]) ) center = arrow_base _, (dx, dy) = self.pop_axis(direction, axis=plot_axis) components_in_plane = any(not np.isclose(component, 0) for component in (dx, dy)) # plot if arrow in plotting plane and some non-zero component can be displayed. if arrow_intersecting_plane and components_in_plane: _, (x0, y0) = self.pop_axis(center, axis=plot_axis) # Reasonable value for temporary arrow size. The correct size and direction # have to be calculated after all transforms have been set. That is why we # use a callback to do these calculations only at the drawing phase. xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() v_x = (xmax - xmin) / 10 v_y = (ymax - ymin) / 10 directions = (1.0, -1.0) if both_dirs else (1.0,) for sign in directions: arrow = patches.FancyArrowPatch( (x0, y0), (x0 + v_x, y0 + v_y), arrowstyle=arrow_style, color=color, alpha=alpha, zorder=np.inf, ) # Don't draw this arrow until it's been reshaped arrow.set_visible(False) callback = self._arrow_shape_cb( arrow, (x0, y0), (dx, dy), sign, bend_radius if bend_axis == plot_axis else None ) callback_id = ax.figure.canvas.mpl_connect("draw_event", callback) # Store a reference to the callback because mpl_connect does not. arrow.set_shape_cb = (callback_id, callback) ax.add_patch(arrow) return ax @staticmethod def _arrow_shape_cb( arrow: FancyArrowPatch, pos: tuple[float, float], direction: ArrayLike, sign: float, bend_radius: float | None, ) -> Callable[[Event], None]: def _cb(event: Event) -> None: # We only want to set the shape once, so we disconnect ourselves event.canvas.mpl_disconnect(arrow.set_shape_cb[0]) transform = arrow.axes.transData.transform scale_x = transform((1, 0))[0] - transform((0, 0))[0] scale_y = transform((0, 1))[1] - transform((0, 0))[1] scale = max(scale_x, scale_y) # <-- Hack: This is a somewhat arbitrary choice. arrow_length = ARROW_LENGTH * event.canvas.figure.get_dpi() / scale if bend_radius: v_norm = (direction[0] ** 2 + direction[1] ** 2) ** 0.5 vx_norm = direction[0] / v_norm vy_norm = direction[1] / v_norm bend_angle = -sign * arrow_length / bend_radius t_x = 1 - np.cos(bend_angle) t_y = np.sin(bend_angle) v_x = -bend_radius * (vx_norm * t_y - vy_norm * t_x) v_y = -bend_radius * (vx_norm * t_x + vy_norm * t_y) tangent_angle = np.arctan2(direction[1], direction[0]) arrow.set_connectionstyle( patches.ConnectionStyle.Angle3( angleA=180 / np.pi * tangent_angle, angleB=180 / np.pi * (tangent_angle + bend_angle), ) ) else: v_x = sign * arrow_length * direction[0] v_y = sign * arrow_length * direction[1] arrow.set_positions(pos, (pos[0] + v_x, pos[1] + v_y)) arrow.set_visible(True) arrow.draw(event.renderer) return _cb def _volume(self, bounds: Bound) -> float: """Returns object's volume within given bounds.""" volume = 1 for axis in range(3): min_bound = max(self.bounds[0][axis], bounds[0][axis]) max_bound = min(self.bounds[1][axis], bounds[1][axis]) volume *= max_bound - min_bound return volume def _surface_area(self, bounds: Bound) -> float: """Returns object's surface area within given bounds.""" min_bounds = list(self.bounds[0]) max_bounds = list(self.bounds[1]) in_bounds_factor = [2, 2, 2] length = [0, 0, 0] for axis in (0, 1, 2): if min_bounds[axis] < bounds[0][axis]: min_bounds[axis] = bounds[0][axis] in_bounds_factor[axis] -= 1 if max_bounds[axis] > bounds[1][axis]: max_bounds[axis] = bounds[1][axis] in_bounds_factor[axis] -= 1 length[axis] = max_bounds[axis] - min_bounds[axis] return ( length[0] * length[1] * in_bounds_factor[2] + length[1] * length[2] * in_bounds_factor[0] + length[2] * length[0] * in_bounds_factor[1] ) """ Autograd code """ def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradFieldMap: """Compute the adjoint derivatives for this object.""" # get gradients w.r.t. each of the 6 faces (in normal direction) vjps_faces = self._derivative_faces(derivative_info=derivative_info) # post-process these values to give the gradients w.r.t. center and size vjps_center_size = self._derivatives_center_size(vjps_faces=vjps_faces) # store only the gradients asked for in 'field_paths' derivative_map = {} for field_path in derivative_info.paths: field_name, *index = field_path if field_name in vjps_center_size: # if the vjp calls for a specific index into the tuple if index and len(index) == 1: index = int(index[0]) if field_path not in derivative_map: derivative_map[field_path] = vjps_center_size[field_name][index] # otherwise, just grab the whole array else: derivative_map[field_path] = vjps_center_size[field_name] return derivative_map @staticmethod def _derivatives_center_size(vjps_faces: Bound) -> dict[str, Coordinate]: """Derivatives with respect to the ``center`` and ``size`` fields in the ``Box``.""" vjps_faces_min, vjps_faces_max = np.array(vjps_faces) # post-process min and max face gradients into center and size vjp_center = vjps_faces_max - vjps_faces_min vjp_size = (vjps_faces_min + vjps_faces_max) / 2.0 return { "center": tuple(vjp_center.tolist()), "size": tuple(vjp_size.tolist()), } def _derivative_faces(self, derivative_info: DerivativeInfo) -> Bound: """Derivative with respect to normal position of 6 faces of ``Box``.""" axes_to_compute = (0, 1, 2) if len(derivative_info.paths[0]) > 1: axes_to_compute = tuple(info[1] for info in derivative_info.paths) # change in permittivity between inside and outside vjp_faces = np.zeros((2, 3)) for min_max_index, _ in enumerate((0, -1)): for axis in axes_to_compute: vjp_face = self._derivative_face( min_max_index=min_max_index, axis_normal=axis, derivative_info=derivative_info, ) # record vjp for this face vjp_faces[min_max_index, axis] = vjp_face return vjp_faces def _derivative_face( self, min_max_index: int, axis_normal: Axis, derivative_info: DerivativeInfo, ) -> float: """Compute the derivative w.r.t. shifting a face in the normal direction.""" interpolators = derivative_info.interpolators or derivative_info.create_interpolators() _, axis_perp = self.pop_axis((0, 1, 2), axis=axis_normal) # First, check if the face is outside the simulation domain in which case set the # face gradient to 0. bounds_normal, bounds_perp = self.pop_axis( np.array(derivative_info.bounds).T, axis=axis_normal ) coord_normal_face = bounds_normal[min_max_index] if min_max_index == 0: if coord_normal_face < derivative_info.simulation_bounds[0][axis_normal]: return 0.0 else: if coord_normal_face > derivative_info.simulation_bounds[1][axis_normal]: return 0.0 intersect_min, intersect_max = map(np.asarray, derivative_info.bounds_intersect) extents = intersect_max - intersect_min _, intersect_min_perp = self.pop_axis(np.array(intersect_min), axis=axis_normal) _, intersect_max_perp = self.pop_axis(np.array(intersect_max), axis=axis_normal) is_2d_map = [] for axis_idx in range(3): if axis_idx == axis_normal: continue is_2d_map.append(np.isclose(extents[axis_idx], 0.0)) if np.all(is_2d_map): return 0.0 is_2d = np.any(is_2d_map) sim_bounds_normal, sim_bounds_perp = self.pop_axis( np.array(derivative_info.simulation_bounds).T, axis=axis_normal ) # Build point grid adaptive_spacing = derivative_info.adaptive_vjp_spacing() def spacing_to_grid_points( spacing: float, min_coord: float, max_coord: float ) -> NDArray[float]: N = np.maximum(3, 1 + int((max_coord - min_coord) / spacing)) points = np.linspace(min_coord, max_coord, N) centers = 0.5 * (points[0:-1] + points[1:]) return centers def verify_integration_interval(bound: tuple[float, float]) -> bool: # assume the bounds should not be equal or else this integration interval # would be the flat dimension of a 2D geometry. return bound[1] > bound[0] def compute_integration_weight(grid_points: NDArray[float]) -> float: grid_spacing = grid_points[1] - grid_points[0] if grid_spacing == 0.0: integration_weight = 1.0 / len(grid_points) else: integration_weight = grid_points[1] - grid_points[0] return integration_weight if is_2d: # build 1D grid for sampling points along the face, which is an edge in the 2D case zero_dim = np.where(is_2d_map)[0][0] # zero dim is one of the perpendicular directions, so the other perpendicular direction # is the nonzero dimension nonzero_dim = 1 - zero_dim # clip at simulation bounds for integration dimension integration_bounds_perp = ( intersect_min_perp[nonzero_dim], intersect_max_perp[nonzero_dim], ) if not verify_integration_interval(integration_bounds_perp): return 0.0 grid_points_linear = spacing_to_grid_points( adaptive_spacing, integration_bounds_perp[0], integration_bounds_perp[1] ) integration_weight = compute_integration_weight(grid_points_linear) grid_points = np.repeat(np.expand_dims(grid_points_linear.copy(), 1), 3, axis=1) # set up grid points to pass into evaluate_gradient_at_points grid_points[:, axis_perp[nonzero_dim]] = grid_points_linear grid_points[:, axis_perp[zero_dim]] = intersect_min_perp[zero_dim] grid_points[:, axis_normal] = coord_normal_face else: # build 3D grid for sampling points along the face # clip at simulation bounds for each integration dimension integration_bounds_perp = ( (intersect_min_perp[0], intersect_max_perp[0]), (intersect_min_perp[1], intersect_max_perp[1]), ) if not np.all([verify_integration_interval(b) for b in integration_bounds_perp]): return 0.0 grid_points_perp_1 = spacing_to_grid_points( adaptive_spacing, integration_bounds_perp[0][0], integration_bounds_perp[0][1] ) grid_points_perp_2 = spacing_to_grid_points( adaptive_spacing, integration_bounds_perp[1][0], integration_bounds_perp[1][1] ) integration_weight = compute_integration_weight( grid_points_perp_1 ) * compute_integration_weight(grid_points_perp_2) mesh_perp1, mesh_perp2 = np.meshgrid(grid_points_perp_1, grid_points_perp_2) zip_perp_coords = np.array(list(zip(mesh_perp1.flatten(), mesh_perp2.flatten()))) grid_points = np.pad(zip_perp_coords.copy(), ((0, 0), (1, 0)), mode="constant") # set up grid points to pass into evaluate_gradient_at_points grid_points[:, axis_perp[0]] = zip_perp_coords[:, 0] grid_points[:, axis_perp[1]] = zip_perp_coords[:, 1] grid_points[:, axis_normal] = coord_normal_face normals = np.zeros_like(grid_points) perps1 = np.zeros_like(grid_points) perps2 = np.zeros_like(grid_points) normals[:, axis_normal] = -1 if (min_max_index == 0) else 1 perps1[:, axis_perp[0]] = 1 perps2[:, axis_perp[1]] = 1 gradient_at_points = derivative_info.evaluate_gradient_at_points( spatial_coords=grid_points, normals=normals, perps1=perps1, perps2=perps2, interpolators=interpolators, ) vjp_value = np.sum(integration_weight * np.real(gradient_at_points)) return vjp_value
"""Compound subclasses"""
[docs] class Transformed(Geometry): """Class representing a transformed geometry.""" geometry: annotate_type(GeometryType) = pydantic.Field( ..., title="Geometry", description="Base geometry to be transformed." ) transform: MatrixReal4x4 = pydantic.Field( np.eye(4).tolist(), title="Transform", description="Transform matrix applied to the base geometry.", ) @pydantic.validator("transform") def _transform_is_invertible(cls, val: MatrixReal4x4) -> MatrixReal4x4: # If the transform is not invertible, this will raise an error _ = np.linalg.inv(val) return val @pydantic.validator("geometry") def _geometry_is_finite(cls, val: GeometryType) -> GeometryType: if not np.isfinite(val.bounds).all(): raise ValidationError( "Transformations are only supported on geometries with finite dimensions. " "Try using a large value instead of 'inf' when creating geometries that undergo " "transformations." ) return val @pydantic.root_validator(skip_on_failure=True) def _apply_transforms(cls, values: dict[str, Any]) -> dict[str, Any]: while isinstance(values["geometry"], Transformed): inner = values["geometry"] values["geometry"] = inner.geometry values["transform"] = np.dot(values["transform"], inner.transform) return values @cached_property def inverse(self) -> MatrixReal4x4: """Inverse of this transform.""" return np.linalg.inv(self.transform) @staticmethod def _vertices_from_bounds(bounds: Bound) -> ArrayFloat2D: """Return the 8 vertices derived from bounds. The vertices are returned as homogeneous coordinates (with 4 components). Parameters ---------- bounds : Bound Bounds from which to derive the vertices. Returns ------- ArrayFloat2D Array with shape (4, 8) with all vertices from ``bounds``. """ (x0, y0, z0), (x1, y1, z1) = bounds return np.array( ( (x0, x0, x0, x0, x1, x1, x1, x1), (y0, y0, y1, y1, y0, y0, y1, y1), (z0, z1, z0, z1, z0, z1, z0, z1), (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), ) ) @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)``. """ # NOTE (Lucas): The bounds are overestimated because we don't want to calculate # precise TriangleMesh representations for GeometryGroup or ClipOperation. vertices = np.dot(self.transform, self._vertices_from_bounds(self.geometry.bounds))[:3] return (tuple(vertices.min(axis=1)), tuple(vertices.max(axis=1)))
[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 high-quality visualization settings. 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>`_. """ return self.geometry.intersections_tilted_plane( tuple(np.dot((normal[0], normal[1], normal[2], 0.0), self.transform)[:3]), tuple(np.dot(self.inverse, (origin[0], origin[1], origin[2], 1.0))[:3]), np.dot(to_2D, self.transform), cleanup=cleanup, quad_segs=quad_segs, )
[docs] def inside(self, x: NDArray[float], y: NDArray[float], z: NDArray[float]) -> 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. """ x = np.array(x) y = np.array(y) z = np.array(z) xyz = np.dot(self.inverse, np.vstack((x.flat, y.flat, z.flat, np.ones(x.size)))) if xyz.shape[1] == 1: # TODO: This "fix" is required because of a bug in PolySlab.inside (with non-zero sidewall angle) return self.geometry.inside(xyz[0][0], xyz[1][0], xyz[2][0]).reshape(x.shape) return self.geometry.inside(xyz[0], xyz[1], xyz[2]).reshape(x.shape)
def _volume(self, bounds: Bound) -> float: """Returns object's volume within given bounds.""" # NOTE (Lucas): Bounds are overestimated. vertices = np.dot(self.inverse, self._vertices_from_bounds(bounds))[:3] inverse_bounds = (tuple(vertices.min(axis=1)), tuple(vertices.max(axis=1))) return abs(np.linalg.det(self.transform)) * self.geometry.volume(inverse_bounds) def _surface_area(self, bounds: Bound) -> float: """Returns object's surface area within given bounds.""" log.warning("Surface area of transformed elements cannot be calculated.") return None
[docs] @staticmethod def translation(x: float, y: float, z: float) -> MatrixReal4x4: """Return a translation matrix. Parameters ---------- x : float Translation along x. y : float Translation along y. z : float Translation along z. Returns ------- numpy.ndarray Transform matrix with shape (4, 4). """ return np.array( [ (1.0, 0.0, 0.0, x), (0.0, 1.0, 0.0, y), (0.0, 0.0, 1.0, z), (0.0, 0.0, 0.0, 1.0), ], dtype=float, )
[docs] @staticmethod def scaling(x: float = 1.0, y: float = 1.0, z: float = 1.0) -> MatrixReal4x4: """Return a scaling matrix. Parameters ---------- x : float = 1.0 Scaling factor along x. y : float = 1.0 Scaling factor along y. z : float = 1.0 Scaling factor along z. Returns ------- numpy.ndarray Transform matrix with shape (4, 4). """ if np.isclose((x, y, z), 0.0).any(): raise Tidy3dError("Scaling factors cannot be zero in any dimensions.") return np.array( [ (x, 0.0, 0.0, 0.0), (0.0, y, 0.0, 0.0), (0.0, 0.0, z, 0.0), (0.0, 0.0, 0.0, 1.0), ], dtype=float, )
[docs] @staticmethod def rotation(angle: float, axis: Union[Axis, Coordinate]) -> MatrixReal4x4: """Return a rotation matrix. Parameters ---------- angle : float Rotation angle (in radians). axis : Union[int, Tuple[float, float, float]] Axis of rotation: 0, 1, or 2 for x, y, and z, respectively, or a 3D vector. Returns ------- numpy.ndarray Transform matrix with shape (4, 4). """ transform = np.eye(4) transform[:3, :3] = RotationAroundAxis(angle=angle, axis=axis).matrix return transform
[docs] @staticmethod def reflection(normal: Coordinate) -> MatrixReal4x4: """Return a reflection matrix. Parameters ---------- normal : Tuple[float, float, float] Normal of the plane of reflection. Returns ------- numpy.ndarray Transform matrix with shape (4, 4). """ transform = np.eye(4) transform[:3, :3] = ReflectionFromPlane(normal=normal).matrix return transform
[docs] @staticmethod def preserves_axis(transform: MatrixReal4x4, axis: Axis) -> bool: """Indicate if the transform preserves the orientation of a given axis. Parameters: transform: MatrixReal4x4 Transform matrix to check. axis : int Axis to check. Values 0, 1, or 2, to check x, y, or z, respectively. Returns ------- bool ``True`` if the transformation preserves the axis orientation, ``False`` otherwise. """ i = (axis + 1) % 3 j = (axis + 2) % 3 return np.isclose(transform[i, axis], 0) and np.isclose(transform[j, axis], 0)
@cached_property def _normal_2dmaterial(self) -> Axis: """Get the normal to the given geometry, checking that it is a 2D geometry.""" normal = self.geometry._normal_2dmaterial preserves_axis = Transformed.preserves_axis(self.transform, normal) if not preserves_axis: raise ValidationError( "'Medium2D' requires geometries of type 'Transformed' to " "perserve the axis normal to the 'Medium2D'." ) return normal def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> Transformed: """Returns an updated geometry which has been transformed to fit within ``bounds`` along the ``axis`` direction.""" min_bound = np.array([0, 0, 0, 1.0]) min_bound[axis] = bounds[0] max_bound = np.array([0, 0, 0, 1.0]) max_bound[axis] = bounds[1] new_bounds = [] new_bounds.append(np.dot(self.inverse, min_bound)[axis]) new_bounds.append(np.dot(self.inverse, max_bound)[axis]) new_geometry = self.geometry._update_from_bounds(bounds=new_bounds, axis=axis) return self.updated_copy(geometry=new_geometry)
[docs] class ClipOperation(Geometry): """Class representing the result of a set operation between geometries.""" operation: ClipOperationType = pydantic.Field( ..., title="Operation Type", description="Operation to be performed between geometries.", ) geometry_a: annotate_type(GeometryType) = pydantic.Field( ..., title="Geometry A", description="First operand for the set operation. It can be any geometry type, including " ":class:`GeometryGroup`.", ) geometry_b: annotate_type(GeometryType) = pydantic.Field( ..., title="Geometry B", description="Second operand for the set operation. It can also be any geometry type.", ) @pydantic.validator("geometry_a", "geometry_b", always=True) def _geometries_untraced(cls, val: GeometryType) -> GeometryType: """Make sure that ``ClipOperation`` geometries do not contain tracers.""" traced = val._strip_traced_fields() if traced: raise ValidationError( f"{val.type} contains traced fields {list(traced.keys())}. Note that " "'ClipOperation' does not currently support automatic differentiation." ) return val
[docs] @staticmethod def to_polygon_list(base_geometry: Shapely, cleanup: bool = False) -> list[Shapely]: """Return a list of valid polygons from a shapely geometry, discarding points, lines, and empty polygons, and empty triangles within polygons. Parameters ---------- base_geometry : shapely.geometry.base.BaseGeometry Base geometry for inspection. cleanup: bool = False If True, removes extremely small features from each polygon's boundary. This is useful for removing artifacts from 2D plots displayed to the user. Returns ------- List[shapely.geometry.base.BaseGeometry] Valid polygons retrieved from ``base geometry``. """ unfiltered_geoms = [] if base_geometry.geom_type == "GeometryCollection": unfiltered_geoms = [ p for geom in base_geometry.geoms for p in ClipOperation.to_polygon_list(geom, cleanup) ] if base_geometry.geom_type == "MultiPolygon": unfiltered_geoms = [p for p in base_geometry.geoms if not p.is_empty] if base_geometry.geom_type == "Polygon" and not base_geometry.is_empty: unfiltered_geoms = [base_geometry] geoms = [] if cleanup: # Optional: "clean" each of the polygons (by removing extremely small or thin features). for geom in unfiltered_geoms: geom_clean = cleanup_shapely_object(geom) if geom_clean.geom_type == "Polygon": geoms.append(geom_clean) if geom_clean.geom_type == "MultiPolygon": geoms += [p for p in geom_clean.geoms if not p.is_empty] # Ignore other types of shapely objects (points and lines) else: geoms = unfiltered_geoms return geoms
@property def _shapely_operation(self) -> Callable[[Shapely, Shapely], Shapely]: """Return a Shapely function equivalent to this operation.""" result = _shapely_operations.get(self.operation, None) if not result: raise ValueError( "'operation' must be one of 'union', 'intersection', 'difference', or " "'symmetric_difference'." ) return result @property def _bit_operation(self) -> Callable[[Any, Any], Any]: """Return a function equivalent to this operation using bit operators.""" result = _bit_operations.get(self.operation, None) if not result: raise ValueError( "'operation' must be one of 'union', 'intersection', 'difference', or " "'symmetric_difference'." ) return result
[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 high-quality visualization settings. 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>`_. """ a = self.geometry_a.intersections_tilted_plane( normal, origin, to_2D, cleanup=cleanup, quad_segs=quad_segs ) b = self.geometry_b.intersections_tilted_plane( normal, origin, to_2D, cleanup=cleanup, quad_segs=quad_segs ) geom_a = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in a]) geom_b = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in b]) return ClipOperation.to_polygon_list( self._shapely_operation(geom_a, geom_b), cleanup=cleanup, )
[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[Shapely]: """Returns list of shapely geometries 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z 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 high-quality visualization settings. Returns ------- List[shapely.geometry.base.BaseGeometry] List of 2D shapes that intersect plane. For more details refer to `Shapely's Documentaton <https://shapely.readthedocs.io/en/stable/project.html>`_. """ a = self.geometry_a.intersections_plane(x, y, z, cleanup=cleanup, quad_segs=quad_segs) b = self.geometry_b.intersections_plane(x, y, z, cleanup=cleanup, quad_segs=quad_segs) geom_a = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in a]) geom_b = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in b]) return ClipOperation.to_polygon_list( self._shapely_operation(geom_a, geom_b), cleanup=cleanup, )
@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)``. """ # Overestimates if self.operation == "difference": result = self.geometry_a.bounds elif self.operation == "intersection": bounds = (self.geometry_a.bounds, self.geometry_b.bounds) result = ( tuple(max(b[i] for b, _ in bounds) for i in range(3)), tuple(min(b[i] for _, b in bounds) for i in range(3)), ) if any(result[0][i] > result[1][i] for i in range(3)): result = ((0, 0, 0), (0, 0, 0)) else: bounds = (self.geometry_a.bounds, self.geometry_b.bounds) result = ( tuple(min(b[i] for b, _ in bounds) for i in range(3)), tuple(max(b[i] for _, b in bounds) for i in range(3)), ) return result
[docs] def inside(self, x: NDArray[float], y: NDArray[float], z: NDArray[float]) -> 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. """ inside_a = self.geometry_a.inside(x, y, z) inside_b = self.geometry_b.inside(x, y, z) return self._bit_operation(inside_a, inside_b)
[docs] def inside_meshgrid( self, x: NDArray[float], y: NDArray[float], z: NDArray[float] ) -> NDArray[bool]: """Faster way to check ``self.inside`` on a meshgrid. The input arrays are assumed sorted. Parameters ---------- x : np.ndarray[float] 1D array of point positions in x direction. y : np.ndarray[float] 1D array of point positions in y direction. z : np.ndarray[float] 1D array of point positions in z direction. Returns ------- np.ndarray[bool] Array with shape ``(x.size, y.size, z.size)``, which is ``True`` for every point that is inside the geometry. """ inside_a = self.geometry_a.inside_meshgrid(x, y, z) inside_b = self.geometry_b.inside_meshgrid(x, y, z) return self._bit_operation(inside_a, inside_b)
def _volume(self, bounds: Bound) -> float: """Returns object's volume within given bounds.""" # Overestimates if self.operation == "intersection": return min(self.geometry_a.volume(bounds), self.geometry_b.volume(bounds)) if self.operation == "difference": return self.geometry_a.volume(bounds) return self.geometry_a.volume(bounds) + self.geometry_b.volume(bounds) def _surface_area(self, bounds: Bound) -> float: """Returns object's surface area within given bounds.""" # Overestimates return self.geometry_a.surface_area(bounds) + self.geometry_b.surface_area(bounds) @cached_property def _normal_2dmaterial(self) -> Axis: """Get the normal to the given geometry, checking that it is a 2D geometry.""" normal_a = self.geometry_a._normal_2dmaterial normal_b = self.geometry_b._normal_2dmaterial if normal_a != normal_b: raise ValidationError( "'Medium2D' requires both geometries in the 'ClipOperation' to " "have exactly one dimension with zero size in common." ) plane_position_a = self.geometry_a.bounds[0][normal_a] plane_position_b = self.geometry_b.bounds[0][normal_b] if plane_position_a != plane_position_b: raise ValidationError( "'Medium2D' requires both geometries in the 'ClipOperation' to be co-planar." ) return normal_a def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> ClipOperation: """Returns an updated geometry which has been transformed to fit within ``bounds`` along the ``axis`` direction.""" new_geom_a = self.geometry_a._update_from_bounds(bounds=bounds, axis=axis) new_geom_b = self.geometry_b._update_from_bounds(bounds=bounds, axis=axis) return self.updated_copy(geometry_a=new_geom_a, geometry_b=new_geom_b)
[docs] class GeometryGroup(Geometry): """A collection of Geometry objects that can be called as a single geometry object.""" geometries: tuple[annotate_type(GeometryType), ...] = pydantic.Field( ..., title="Geometries", description="Tuple of geometries in a single grouping. " "Can provide significant performance enhancement in ``Structure`` when all geometries are " "assigned the same medium.", ) @pydantic.validator("geometries", always=True) def _geometries_not_empty( cls, val: tuple[annotate_type(GeometryType), ...] ) -> tuple[annotate_type(GeometryType), ...]: """make sure geometries are not empty.""" if not len(val) > 0: raise ValidationError("GeometryGroup.geometries must not be empty.") return val @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)``. """ bounds = tuple(geometry.bounds for geometry in self.geometries) return ( tuple(min(b[i] for b, _ in bounds) for i in range(3)), tuple(max(b[i] for _, b in bounds) for i in range(3)), )
[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 high-quality visualization settings. 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>`_. """ return [ intersection for geometry in self.geometries for intersection in geometry.intersections_tilted_plane( normal, origin, to_2D, cleanup=cleanup, quad_segs=quad_segs ) ]
[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[Shapely]: """Returns list of shapely geometries 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 y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z 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 high-quality visualization settings. 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 not self.intersects_plane(x, y, z): return [] return [ intersection for geometry in self.geometries for intersection in geometry.intersections_plane( x=x, y=y, z=z, cleanup=cleanup, quad_segs=quad_segs ) ]
[docs] def intersects_axis_position(self, axis: float, position: float) -> bool: """Whether self intersects plane specified by a given position along a normal axis. Parameters ---------- axis : int = None Axis normal to the plane. position : float = None Position of plane along the normal axis. Returns ------- bool Whether this geometry intersects the plane. """ return any(geom.intersects_axis_position(axis, position) for geom in self.geometries)
[docs] def inside(self, x: NDArray[float], y: NDArray[float], z: NDArray[float]) -> 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. """ individual_insides = (geometry.inside(x, y, z) for geometry in self.geometries) return functools.reduce(lambda a, b: a | b, individual_insides)
[docs] def inside_meshgrid( self, x: NDArray[float], y: NDArray[float], z: NDArray[float] ) -> NDArray[bool]: """Faster way to check ``self.inside`` on a meshgrid. The input arrays are assumed sorted. Parameters ---------- x : np.ndarray[float] 1D array of point positions in x direction. y : np.ndarray[float] 1D array of point positions in y direction. z : np.ndarray[float] 1D array of point positions in z direction. Returns ------- np.ndarray[bool] Array with shape ``(x.size, y.size, z.size)``, which is ``True`` for every point that is inside the geometry. """ individual_insides = (geom.inside_meshgrid(x, y, z) for geom in self.geometries) return functools.reduce(lambda a, b: a | b, individual_insides)
def _volume(self, bounds: Bound) -> float: """Returns object's volume within given bounds.""" individual_volumes = (geometry.volume(bounds) for geometry in self.geometries) return np.sum(individual_volumes) def _surface_area(self, bounds: Bound) -> float: """Returns object's surface area within given bounds.""" individual_areas = (geometry.surface_area(bounds) for geometry in self.geometries) return np.sum(individual_areas) @cached_property def _normal_2dmaterial(self) -> Axis: """Get the normal to the given geometry, checking that it is a 2D geometry.""" normals = {geom._normal_2dmaterial for geom in self.geometries} if len(normals) != 1: raise ValidationError( "'Medium2D' requires all geometries in the 'GeometryGroup' to " "share exactly one dimension with zero size." ) normal = list(normals)[0] positions = {geom.bounds[0][normal] for geom in self.geometries} if len(positions) != 1: raise ValidationError( "'Medium2D' requires all geometries in the 'GeometryGroup' to be co-planar." ) return normal def _update_from_bounds(self, bounds: tuple[float, float], axis: Axis) -> GeometryGroup: """Returns an updated geometry which has been transformed to fit within ``bounds`` along the ``axis`` direction.""" new_geometries = [ geometry._update_from_bounds(bounds=bounds, axis=axis) for geometry in self.geometries ] return self.updated_copy(geometries=new_geometries) def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradFieldMap: """Compute the adjoint derivatives for this object.""" grad_vjps = {} # create interpolators once for all geometries to avoid redundant field data conversions interpolators = derivative_info.interpolators or derivative_info.create_interpolators() for field_path in derivative_info.paths: _, index, *geo_path = field_path geo = self.geometries[index] # pass pre-computed interpolators if available geo_info = derivative_info.updated_copy( paths=[tuple(geo_path)], bounds=geo.bounds, bounds_intersect=self.bounds_intersection( geo.bounds, derivative_info.simulation_bounds ), eps_approx=True, deep=False, interpolators=interpolators, ) vjp_dict_geo = geo._compute_derivatives(geo_info) if len(vjp_dict_geo) != 1: raise AssertionError("Got multiple gradients for single geometry field.") grad_vjps[field_path] = vjp_dict_geo.popitem()[1] return grad_vjps
def cleanup_shapely_object(obj: Shapely, tolerance_ratio: float = POLY_TOLERANCE_RATIO) -> Shapely: """Remove small geometric features from the boundaries of a shapely object including inward and outward spikes, thin holes, and thin connections between larger regions. Parameters ---------- obj : shapely a shapely object (typically a ``Polygon`` or a ``MultiPolygon``) tolerance_ratio : float = ``POLY_TOLERANCE_RATIO`` Features on the boundaries of polygons will be discarded if they are smaller or narrower than ``tolerance_ratio`` multiplied by the size of the object. Returns ------- Shapely A new shapely object whose small features (eg. thin spikes or holes) are removed. Notes ----- This function does not attempt to delete overlapping, nearby, or collinear vertices. To solve that problem, use ``shapely.simplify()`` afterwards. """ if _shapely_is_older_than("2.1"): log.warning( "Using old versions of the shapely library (prior to v2.1) may cause " "plot errors. This can be solved by upgrading to Python 3.10 " "(or later) and reinstalling Tidy3d.", log_once=True, ) return obj if obj.is_empty: return obj centroid = obj.centroid object_size = min(obj.bounds[2] - obj.bounds[0], obj.bounds[3] - obj.bounds[1]) if object_size == 0.0: return shapely.Polygon([]) # In order to prevent numerical overflow or underflow errors, we first subtract # the centroid and divide by (rescale) the size of the object so it is not too big. normalized_obj = shapely.affinity.affine_transform( # https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations obj, matrix=[ 1 / object_size, 0.0, 0.0, 1 / object_size, -centroid.x / object_size, -centroid.y / object_size, ], ) # Important: Remove any self intersections beforehand using `shapely.make_valid()`. valid_obj = shapely.make_valid(normalized_obj, method="structure", keep_collapsed=False) # To get rid of small thin features, erode(shrink), dilate(expand), and erode again. eroded_obj = shapely.buffer( # This removes outward spikes valid_obj, distance=-tolerance_ratio, cap_style="square", # (optional parameter to reduce computation time) quad_segs=3, # (optional parameter to reduce computation time) ) dilated_obj = shapely.buffer( # This removes inward spikes and tiny holes eroded_obj, distance=2 * tolerance_ratio, cap_style="square", quad_segs=3, ) cleaned_obj = dilated_obj # Optional: Now shrink the polygon back to the original size. cleaned_obj = shapely.buffer( cleaned_obj, distance=-tolerance_ratio, cap_style="square", quad_segs=3, ) # Clean vertices of very close distances created during the erosion/dilation process. # The distance value is heuristic. cleaned_obj = cleaned_obj.simplify(POLY_DISTANCE_TOLERANCE, preserve_topology=True) # Revert to the original scale and position. rescaled_clean_obj = shapely.affinity.affine_transform( cleaned_obj, matrix=[ object_size, 0.0, 0.0, object_size, centroid.x, centroid.y, ], ) return rescaled_clean_obj from .utils import GeometryType, from_shapely, vertices_from_shapely # noqa: E402