diff --git a/CHANGELOG.md b/CHANGELOG.md index f662527b38..97717f0a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `Geometry.plot()` and `Structure().plot()` now accept the `transpose=True` argument which swaps the horizontal and vertical axes of the plot. + ### Changed - By default, batch downloads will skip files that already exist locally. To force re-downloading and replace existing files, pass the `replace_existing=True` argument to `Batch.load()`, `Batch.download()`, or `BatchData.load()`. - The `BatchData.load_sim_data()` function now overwrites any previously downloaded simulation files (instead of skipping them). diff --git a/tests/test_components/test_geometry.py b/tests/test_components/test_geometry.py index 6fb6f6ae3d..3c17d25e27 100644 --- a/tests/test_components/test_geometry.py +++ b/tests/test_components/test_geometry.py @@ -87,14 +87,15 @@ _, AX = plt.subplots() -@pytest.mark.parametrize("component", GEO_TYPES) -def test_plot(component): - _ = component.plot(z=0, ax=AX) +@pytest.mark.parametrize("component, transpose", zip(GEO_TYPES, [True, False])) +def test_plot(component, transpose): + _ = component.plot(z=0, ax=AX, transpose=transpose) plt.close() -def test_plot_with_units(): - _ = BOX.plot(z=0, ax=AX, plot_length_units="nm") +@pytest.mark.parametrize("transpose", [True, False]) +def test_plot_with_units(transpose): + _ = BOX.plot(z=0, ax=AX, plot_length_units="nm", transpose=transpose) plt.close() @@ -768,13 +769,12 @@ def test_geometry_touching_intersections_plane(x0): def test_pop_axis(): - b = td.Box(size=(1, 1, 1)) for axis in range(3): coords = (1, 2, 3) - Lz, (Lx, Ly) = b.pop_axis(coords, axis=axis) - _coords = b.unpop_axis(Lz, (Lx, Ly), axis=axis) + Lz, (Lx, Ly) = td.Box.pop_axis(coords, axis=axis) + _coords = td.Box.unpop_axis(Lz, (Lx, Ly), axis=axis) assert all(c == _c for (c, _c) in zip(coords, _coords)) - _Lz, (_Lx, _Ly) = b.pop_axis(_coords, axis=axis) + _Lz, (_Lx, _Ly) = td.Box.pop_axis(_coords, axis=axis) assert Lz == _Lz assert Lx == _Lx assert Ly == _Ly @@ -939,7 +939,8 @@ def test_to_gds(geometry, tmp_path): assert len(cell.polygons) == 0 -def test_custom_surface_geometry(tmp_path): +@pytest.mark.parametrize("transpose", [True, False]) +def test_custom_surface_geometry(transpose, tmp_path): # create tetrahedron STL vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]]) faces = np.array([[1, 2, 3], [0, 3, 2], [0, 1, 3], [0, 2, 1]]) @@ -972,9 +973,13 @@ def test_custom_surface_geometry(tmp_path): assert np.isclose(geom.volume(), 1 / 6) # test intersections - assert shapely.equals(geom.intersections_plane(x=0), shapely.Polygon([[0, 0], [0, 1], [1, 0]])) assert shapely.equals( - geom.intersections_plane(z=0.5), shapely.Polygon([[0, 0], [0, 0.5], [0.5, 0]]) + geom.intersections_plane(x=0), + shapely.Polygon([[0, 0], [0, 1], [1, 0]]), + ) + assert shapely.equals( + geom.intersections_plane(z=0.5), + shapely.Polygon([[0, 0], [0, 0.5], [0.5, 0]]), ) # test inside @@ -983,7 +988,7 @@ def test_custom_surface_geometry(tmp_path): # test plot _, ax = plt.subplots() - _ = geom.plot(z=0.1, ax=ax) + _ = geom.plot(z=0.1, ax=ax, transpose=transpose) plt.close() # test inconsistent winding @@ -1033,7 +1038,7 @@ def test_custom_surface_geometry(tmp_path): boundary_spec=td.BoundarySpec.all_sides(td.PML()), ) _, ax = plt.subplots() - _ = sim.plot(y=0, ax=ax) + _ = sim.plot(y=0, ax=ax, transpose=transpose) plt.close() # allow small triangles diff --git a/tests/test_components/test_utils.py b/tests/test_components/test_utils.py new file mode 100644 index 0000000000..cfeacf2e0d --- /dev/null +++ b/tests/test_components/test_utils.py @@ -0,0 +1,29 @@ +"""Tests objects shared by multiple components.""" + +from __future__ import annotations + +import random + +import pytest +from shapely.geometry import Point + +from tidy3d.components.utils import pop_axis_and_swap, shape_swap_xy, unpop_axis_and_swap + + +@pytest.mark.parametrize("transpose", [True, False]) +def test_pop_axis_and_swap(transpose): + for axis in range(3): + coords = (1, 2, 3) + Lz, (Lx, Ly) = pop_axis_and_swap(coords, axis=axis, transpose=transpose) + _coords = unpop_axis_and_swap(Lz, (Lx, Ly), axis=axis, transpose=transpose) + assert all(c == _c for (c, _c) in zip(coords, _coords)) + _Lz, (_Lx, _Ly) = pop_axis_and_swap(_coords, axis=axis, transpose=transpose) + assert Lz == _Lz + assert Lx == _Lx + assert Ly == _Ly + + +def test_shape_swap_xy(): + p_orig = Point(random.random(), random.random()) + p_new = shape_swap_xy(p_orig) + assert (p_new.coords[0][0], p_new.coords[0][1]) == (p_orig.coords[0][1], p_orig.coords[0][0]) diff --git a/tidy3d/components/data/unstructured/triangular.py b/tidy3d/components/data/unstructured/triangular.py index 32d54f259b..d83970cd6f 100644 --- a/tidy3d/components/data/unstructured/triangular.py +++ b/tidy3d/components/data/unstructured/triangular.py @@ -23,6 +23,7 @@ SpatialDataArray, ) from tidy3d.components.types import ArrayLike, Ax, Axis, Bound +from tidy3d.components.utils import pop_axis_and_swap from tidy3d.components.viz import add_ax_if_none, equal_aspect, plot_params_grid from tidy3d.constants import inf from tidy3d.exceptions import DataError @@ -570,9 +571,10 @@ def does_cover(self, bounds: Bound) -> bool: """ Plotting """ - @property - def _triangulation_obj(self) -> Triangulation: + def _triangulation_obj(self, transpose: bool = False) -> Triangulation: """Matplotlib triangular representation of the grid to use in plotting.""" + if transpose: + return Triangulation(self.points[:, 1], self.points[:, 0], self.cells) return Triangulation(self.points[:, 0], self.points[:, 1], self.cells) @equal_aspect @@ -589,6 +591,7 @@ def plot( shading: Literal["gourand", "flat"] = "gouraud", cbar_kwargs: Optional[dict] = None, pcolor_kwargs: Optional[dict] = None, + transpose: bool = False, ) -> Ax: """Plot the data field and/or the unstructured grid. @@ -616,6 +619,8 @@ def plot( Additional parameters passed to colorbar object. pcolor_kwargs: Dict = {} Additional parameters passed to ax.tripcolor() + transpose : bool = False + Swap horizontal and vertical axes. (This overrides the default ascending axis order) Returns ------- @@ -639,7 +644,7 @@ def plot( f"{self._values_coords_dict} before plotting." ) plot_obj = ax.tripcolor( - self._triangulation_obj, + self._triangulation_obj(transpose=transpose), self.values.data.ravel(), shading=shading, cmap=cmap, @@ -657,14 +662,15 @@ def plot( # plot grid if requested if grid: ax.triplot( - self._triangulation_obj, + self._triangulation_obj(transpose=transpose), color=plot_params_grid.edgecolor, linewidth=plot_params_grid.linewidth, ) # set labels and titles - ax_labels = ["x", "y", "z"] - normal_axis_name = ax_labels.pop(self.normal_axis) + normal_axis_name, ax_labels = pop_axis_and_swap( + "xyz", self.normal_axis, transpose=transpose + ) ax.set_xlabel(ax_labels[0]) ax.set_ylabel(ax_labels[1]) ax.set_title(f"{normal_axis_name} = {self.normal_pos}") diff --git a/tidy3d/components/geometry/base.py b/tidy3d/components/geometry/base.py index 7157a79d5a..f1bf5bbc0a 100644 --- a/tidy3d/components/geometry/base.py +++ b/tidy3d/components/geometry/base.py @@ -39,6 +39,7 @@ Size, annotate_type, ) +from tidy3d.components.utils import pop_axis_and_swap, shape_swap_xy from tidy3d.components.viz import ( ARROW_LENGTH, PLOT_BUFFER, @@ -263,15 +264,19 @@ def intersections_plane( 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]] + 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) def intersections_2dbox(self, plane: Box) -> list[Shapely]: """Returns list of shapely geometries representing the intersections of the geometry with a 2D box. + Parameters + ---------- + plane : Box + Plane specification. + Returns ------- List[shapely.geometry.base.BaseGeometry] @@ -442,13 +447,18 @@ def zero_dims(self) -> list[Axis]: zero_dims.append(dim) return zero_dims - def _pop_bounds(self, axis: Axis) -> tuple[Coordinate2D, tuple[Coordinate2D, Coordinate2D]]: + def _pop_bounds( + self, axis: Axis, transpose: bool = False + ) -> 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). + transpose : bool = False + Optional: Swap the coordinates in the plane. (This overrides the + default ascending axis order.) Returns ------- @@ -457,8 +467,8 @@ def _pop_bounds(self, axis: Axis) -> tuple[Coordinate2D, tuple[Coordinate2D, Coo 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) + zmin, (xmin, ymin) = pop_axis_and_swap(b_min, axis=axis, transpose=transpose) + zmax, (xmax, ymax) = pop_axis_and_swap(b_max, axis=axis, transpose=transpose) return (zmin, zmax), ((xmin, ymin), (xmax, ymax)) @staticmethod @@ -499,6 +509,7 @@ def plot( ax: Ax = None, plot_length_units: LengthUnit = None, viz_spec: VisualizationSpec = None, + transpose: bool = False, **patch_kwargs, ) -> Ax: """Plot geometry cross section at single (x,y,z) coordinate. @@ -517,6 +528,9 @@ def plot( 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. + transpose : bool = False + Swap horizontal and vertical axes. (This overrides the default + ascending axis order.) **patch_kwargs Optional keyword arguments passed to the matplotlib patch plotting of structure. For details on accepted values, refer to @@ -527,7 +541,6 @@ def plot( 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) @@ -539,16 +552,24 @@ def plot( # for each intersection, plot the shape for shape in shapes_intersect: - ax = self.plot_shape(shape, plot_params=plot_params, ax=ax) + ax = self.plot_shape(shape, plot_params=plot_params, ax=ax, transpose=transpose) # clean up the axis display - ax = self.add_ax_lims(axis=axis, ax=ax) + ax = self.add_ax_lims(axis=axis, ax=ax, transpose=transpose) 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) + ax = Box.add_ax_labels_and_title( + ax=ax, x=x, y=y, z=z, plot_length_units=plot_length_units, transpose=transpose + ) return ax - def plot_shape(self, shape: Shapely, plot_params: PlotParams, ax: Ax) -> Ax: + def plot_shape( + self, + shape: Shapely, + plot_params: PlotParams, + ax: Ax, + transpose: bool = False, + ) -> Ax: """Defines how a shape is plotted on a matplotlib axes.""" if shape.geom_type in ( "MultiPoint", @@ -562,12 +583,17 @@ def plot_shape(self, shape: Shapely, plot_params: PlotParams, ax: Ax) -> Ax: return ax _shape = Geometry.evaluate_inf_shape(shape) + if transpose: + _shape = shape_swap_xy(_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) + xcrds, ycrds = shape.x, shape.y + if transpose: # shape.x and shape.y might be infinite, so shape_swap_xy(shape) won't + xcrds, ycrds = ycrds, xcrds # work. Instead we must swap coordinates manually. + ax.scatter(xcrds, ycrds, color=plot_params.facecolor) else: patch = polygon_patch(_shape, **plot_params.to_kwargs()) ax.add_artist(patch) @@ -593,24 +619,27 @@ def _do_not_intersect(bounds_a, bounds_b, shape_a, shape_b): return False @staticmethod - def _get_plot_labels(axis: Axis) -> tuple[str, str]: + def _get_plot_labels(axis: Axis, transpose: bool = False) -> 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). + transpose : bool = False + Optional: Swap horizontal and vertical plot labels. + (This overrides the default ascending axis order.) Returns ------- str, str Labels of plot, packaged as ``(xlabel, ylabel)``. """ - _, (xlabel, ylabel) = Geometry.pop_axis("xyz", axis=axis) + _, (xlabel, ylabel) = pop_axis_and_swap("xyz", axis=axis, transpose=transpose) return xlabel, ylabel def _get_plot_limits( - self, axis: Axis, buffer: float = PLOT_BUFFER + self, axis: Axis, buffer: float = PLOT_BUFFER, transpose: bool = False ) -> tuple[Coordinate2D, Coordinate2D]: """Gets planar coordinate limits for cross section plots. @@ -620,17 +649,22 @@ def _get_plot_limits( Integer index into 'xyz' (0,1,2). buffer : float = 0.3 Amount of space to add around the limits on the + and - sides. + transpose : bool = False + Optional: Swap horizontal and vertical axis limits. + (This overrides the default ascending axis order.) 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) + _, ((xmin, ymin), (xmax, ymax)) = self._pop_bounds(axis=axis, transpose=transpose) return (xmin - buffer, xmax + buffer), (ymin - buffer, ymax + buffer) - def add_ax_lims(self, axis: Axis, ax: Ax, buffer: float = PLOT_BUFFER) -> Ax: - """Sets the x,y limits based on ``self.bounds``. + def add_ax_lims( + self, axis: Axis, ax: Ax, buffer: float = PLOT_BUFFER, transpose: bool = False + ) -> Ax: + """Sets the horizontal and vertical axis limits based on ``self.bounds``. Parameters ---------- @@ -640,13 +674,18 @@ def add_ax_lims(self, axis: Axis, ax: Ax, buffer: float = PLOT_BUFFER) -> Ax: Matplotlib axes to add labels and limits on. buffer : float = 0.3 Amount of space to place around the limits on the + and - sides. + transpose : bool = False + Optional: Swap horizontal and vertical axis limits. + (This overrides the default ascending axis order.) Returns ------- matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ - (xmin, xmax), (ymin, ymax) = self._get_plot_limits(axis=axis, buffer=buffer) + (xmin, xmax), (ymin, ymax) = self._get_plot_limits( + axis=axis, buffer=buffer, transpose=transpose + ) # 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)) @@ -662,6 +701,7 @@ def add_ax_labels_and_title( y: Optional[float] = None, z: Optional[float] = None, plot_length_units: LengthUnit = None, + transpose: bool = False, ) -> Ax: """Sets the axis labels, tick labels, and title based on ``axis`` and an optional ``plot_length_units`` argument. @@ -679,6 +719,9 @@ def add_ax_labels_and_title( plot_length_units : LengthUnit = None When set to a supported ``LengthUnit``, plots will be produced with annotated axes and title with the proper units. + transpose : bool = False + Optional: Swap horizontal and vertical axis labels. + (This overrides the default ascending axis order.) Returns ------- @@ -686,7 +729,7 @@ def add_ax_labels_and_title( The supplied matplotlib axes. """ axis, position = Box.parse_xyz_kwargs(x=x, y=y, z=z) - axis_labels = Box._get_plot_labels(axis) + axis_labels = Box._get_plot_labels(axis, transpose=transpose) ax = set_default_labels_and_title( axis_labels=axis_labels, axis=axis, @@ -1567,7 +1610,6 @@ def intersections_tilted_plane( For more details refer to `Shapely's Documentation `_. """ - # 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() @@ -2033,6 +2075,7 @@ def _do_intersections_tilted_plane( if section is None: return [] path, _ = section.to_2D(to_2D=to_2D) + return path.polygons_full def intersections_plane( @@ -2116,7 +2159,6 @@ def intersections_with(self, other): For more details refer to `Shapely's Documentation `_. """ - # Verify 2D if self.size.count(0.0) != 1: raise ValidationError( @@ -2203,6 +2245,7 @@ def _plot_arrow( both_dirs: bool = False, ax: Ax = None, arrow_base: Coordinate = None, + transpose: bool = False, ) -> Ax: """Adds an arrow to the axis if with options if certain conditions met. @@ -2228,6 +2271,9 @@ def _plot_arrow( 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. + transpose : bool = False + Swap horizontal and vertical axes. + (This overrides the default ascending axis order.) Returns ------- @@ -2236,7 +2282,7 @@ def _plot_arrow( """ plot_axis, _ = self.parse_xyz_kwargs(x=x, y=y, z=z) - _, (dx, dy) = self.pop_axis(direction, axis=plot_axis) + _, (dx, dy) = pop_axis_and_swap(direction, axis=plot_axis, transpose=transpose) # conditions to check to determine whether to plot arrow, taking into account the # possibility of a custom arrow base @@ -2248,12 +2294,12 @@ def _plot_arrow( ) center = arrow_base - _, (dx, dy) = self.pop_axis(direction, axis=plot_axis) + _, (dx, dy) = pop_axis_and_swap(direction, axis=plot_axis, transpose=transpose) 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) + _, (x0, y0) = pop_axis_and_swap(center, axis=plot_axis, transpose=transpose) # 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 diff --git a/tidy3d/components/geometry/mesh.py b/tidy3d/components/geometry/mesh.py index 391ae01be8..edeb6bf0be 100644 --- a/tidy3d/components/geometry/mesh.py +++ b/tidy3d/components/geometry/mesh.py @@ -574,7 +574,6 @@ def intersections_plane( For more details refer to `Shapely's Documentaton `_. """ - if self.mesh_dataset is None: return [] @@ -660,6 +659,7 @@ def plot( y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, + transpose: bool = False, **patch_kwargs, ) -> Ax: """Plot geometry cross section at single (x,y,z) coordinate. @@ -674,6 +674,8 @@ def plot( 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. + transpose : bool = False + Swap horizontal and vertical axes. (This overrides the default ascending axis order) **patch_kwargs Optional keyword arguments passed to the matplotlib patch plotting of structure. For details on accepted values, refer to @@ -691,4 +693,4 @@ def plot( "A 'PermittivityMonitor' can be used to check that the mesh is loaded correctly." ) - return base.Geometry.plot(self, x=x, y=y, z=z, ax=ax, **patch_kwargs) + return base.Geometry.plot(self, x=x, y=y, z=z, ax=ax, transpose=transpose, **patch_kwargs) diff --git a/tidy3d/components/geometry/polyslab.py b/tidy3d/components/geometry/polyslab.py index 41a7a2ebf3..1f0646d8bc 100644 --- a/tidy3d/components/geometry/polyslab.py +++ b/tidy3d/components/geometry/polyslab.py @@ -629,6 +629,7 @@ def _do_intersections_tilted_plane( y = np.hstack((self.base_polygon[:, 1], self.top_polygon[:, 1])) z = np.hstack((np.full(n, self.slab_bounds[0]), np.full(n, self.slab_bounds[1]))) vertices = np.vstack(self.unpop_axis(z, (x, y), self.axis)).T + mesh = trimesh.Trimesh(vertices, faces) section = mesh.section(plane_origin=origin, plane_normal=normal) @@ -652,14 +653,16 @@ def _intersections_normal(self, z: float): For more details refer to `Shapely's Documentation `_. """ - if math.isclose(self.sidewall_angle, 0): - return [self.make_shapely_polygon(self.reference_polygon)] + if math.isclose(self.sidewall_angle, 0): + vertices = self.reference_polygon + return [self.make_shapely_polygon(vertices)] z0 = self.center_axis z_local = z - z0 # distance to the middle dist = -z_local * self._tanq vertices_z = self._shift_vertices(self.middle_polygon, dist)[0] - return [self.make_shapely_polygon(vertices_z)] + vertices = vertices_z + return [self.make_shapely_polygon(vertices)] def _intersections_side(self, position, axis) -> list: """Find shapely geometries intersecting planar geometry with axis orthogonal to slab. @@ -694,12 +697,12 @@ def _intersections_side(self, position, axis) -> list: For more details refer to `Shapely's Documentation `_. """ - # find out all z_i where the plane will intersect the vertex z0 = self.center_axis z_base = z0 - self.finite_length_axis / 2 axis_ordered = self._order_axis(axis) + height_list = self._find_intersecting_height(position, axis_ordered) polys = [] diff --git a/tidy3d/components/geometry/primitives.py b/tidy3d/components/geometry/primitives.py index 193dd8466a..2d909da949 100644 --- a/tidy3d/components/geometry/primitives.py +++ b/tidy3d/components/geometry/primitives.py @@ -486,7 +486,6 @@ def _intersections_normal(self, z: float): For more details refer to `Shapely's Documentation `_. """ - static_self = self.to_static() # radius at z @@ -494,7 +493,6 @@ def _intersections_normal(self, z: float): if radius_offset <= 0: return [] - _, (x0, y0) = self.pop_axis(static_self.center, axis=self.axis) return [shapely.Point(x0, y0).buffer(radius_offset, quad_segs=_N_SHAPELY_QUAD_SEGS)] @@ -735,7 +733,11 @@ def _radius_z(self, z: float): return radius_middle - (z - self.center_axis) * self._tanq - def _local_to_global_side_cross_section(self, coords: list[float], axis: int) -> list[float]: + def _local_to_global_side_cross_section( + self, + coords: list[float], + axis: int, + ) -> list[float]: """Map a point (x,y) from local to global coordinate system in the side cross section. diff --git a/tidy3d/components/geometry/utils.py b/tidy3d/components/geometry/utils.py index 8c166fdd21..1ca467bc0b 100644 --- a/tidy3d/components/geometry/utils.py +++ b/tidy3d/components/geometry/utils.py @@ -20,6 +20,7 @@ PlanePosition, Shapely, ) +from tidy3d.components.utils import shape_swap_xy from tidy3d.constants import fp_eps from tidy3d.exceptions import SetupError, Tidy3dError @@ -42,6 +43,7 @@ def merging_geometries_on_plane( geometries: list[GeometryType], plane: Box, property_list: list[Any], + transpose: bool = False, ) -> list[tuple[Any, Shapely]]: """Compute list of shapes on plane. Overlaps are removed or merged depending on provided property_list. @@ -54,13 +56,14 @@ def merging_geometries_on_plane( Plane specification. property_list : List = None Property value for each structure. + transpose : bool = False + Optional: Swap the coordinates in the plane. (This overrides the default ascending axis order.) Returns ------- List[Tuple[Any, shapely]] List of shapes and their property value on the plane after merging. """ - if len(geometries) != len(property_list): raise SetupError( "Number of provided property values is not equal to the number of geometries." @@ -73,6 +76,8 @@ def merging_geometries_on_plane( # Append each of them and their property information to the list of shapes for shape in shapes_plane: + if transpose: + shape = shape_swap_xy(shape) shapes.append((prop, shape, shape.bounds)) background_shapes = [] diff --git a/tidy3d/components/geometry/utils_2d.py b/tidy3d/components/geometry/utils_2d.py index 46b238accd..9931940b49 100644 --- a/tidy3d/components/geometry/utils_2d.py +++ b/tidy3d/components/geometry/utils_2d.py @@ -172,7 +172,6 @@ def to_multipolygon(shapely_geometry) -> shapely.MultiPolygon: # If the 2D structure overlaps completely with all previously tested structures above then there is no more work to do if not geom_shapely: break - intersection_res = shapely.intersection(geom_shapely, mp_structure[0]) intersection_mp = to_multipolygon(intersection_res) difference_res = shapely.difference(geom_shapely, mp_structure[0]) @@ -194,7 +193,6 @@ def to_multipolygon(shapely_geometry) -> shapely.MultiPolygon: break if not mp_structure_below[0]: continue - intersection_res = shapely.intersection(above_intersection, mp_structure_below[0]) intersection_mp = to_multipolygon(intersection_res) above_difference = to_multipolygon( diff --git a/tidy3d/components/structure.py b/tidy3d/components/structure.py index e7168180c5..711cda43dd 100644 --- a/tidy3d/components/structure.py +++ b/tidy3d/components/structure.py @@ -152,6 +152,7 @@ def plot( y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, + transpose: bool = False, **patch_kwargs, ) -> Ax: """Plot structure's geometric cross section at single (x,y,z) coordinate. @@ -166,6 +167,8 @@ def plot( 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. + transpose : bool = False + Swap horizontal and vertical axes. (This overrides the default ascending axis order.) **patch_kwargs Optional keyword arguments passed to the matplotlib patch plotting of structure. For details on accepted values, refer to @@ -176,7 +179,9 @@ def plot( matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ - return self.geometry.plot(x=x, y=y, z=z, ax=ax, viz_spec=self.viz_spec, **patch_kwargs) + return self.geometry.plot( + x=x, y=y, z=z, ax=ax, viz_spec=self.viz_spec, transpose=transpose, **patch_kwargs + ) class Structure(AbstractStructure): diff --git a/tidy3d/components/utils.py b/tidy3d/components/utils.py new file mode 100644 index 0000000000..1b23de2daa --- /dev/null +++ b/tidy3d/components/utils.py @@ -0,0 +1,106 @@ +"""Utilities shared by multiple components go here.""" + +from __future__ import annotations + +from typing import Any, Optional + +import shapely + +from tidy3d.components.types import Shapely +from tidy3d.log import log + + +def pop_axis_and_swap( + coord: tuple[Any, Any, Any], axis: int, transpose: bool = False +) -> tuple[Any, tuple[Any, Any]]: + """ + ``pop_axis_and_swap()`` is identical to ``Geometry.pop_axis()``, except that it accepts + an additional ``transpose`` argument which reverses the output order. Examples: + + ``pop_axis_and_swap(("x", "y", "z"), 1, transpose=False)`` -> ``("y", ("x", "z"))`` + ``pop_axis_and_swap(("x", "y", "z"), 1, transpose=True)`` -> ``("y", ("z", "x"))`` + + Parameters + ---------- + coord : Tuple[Any, Any, Any] + Tuple of three values in original coordinate system. + axis : int + Integer index into 'xyz' (0,1,2). + transpose : bool = False + Optional: Swap the order of the data from the two remaining axes in the output tuple. + + 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) + if transpose: + plane_vals = [plane_vals[1], plane_vals[0]] + return axis_val, tuple(plane_vals) + + +def unpop_axis_and_swap( + ax_coord: Any, + plane_coords: tuple[Any, Any], + axis: int, + transpose: bool = False, +) -> tuple[Any, Any, Any]: + """ + ``unpop_axis_and_swap()`` is identical to ``Geompetry.unpop_axis()``, except that + it accepts an additional ``transpose`` argument which reverses the order of + ``plane_coords`` before sending them to ``unpop_axis()``. For example: + + ``unpop_axis_and_swap("y", ("x", "z"), 1, transpose=False)`` --> ``("x", "y", "z")`` + ``unpop_axis_and_swap("y", ("x", "z"), 1, transpose=True)`` --> ``("z", "y", "x")`` + + This function is the inverse of ``pop_axis_and_swap()``. For example: + ``unpop_axis_and_swap("y", ("z", "x"), 1, transpose=True)`` --> ``("x", "y", "z")`` + + 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). + transpose : bool = False + Optional: Swap the order of the entries in plane_coords[]. + (This overrides the default ascending axis order.) + + Returns + ------- + Tuple[Any, Any, Any] + The three values in the xyz coordinate system. + """ + coords = list(plane_coords) + if transpose: + coords = [coords[1], coords[0]] + coords.insert(axis, ax_coord) + return tuple(coords) + + +def shape_swap_xy(shape: Shapely) -> Shapely: + """Create a new version of a shapely object with the X and Y coordinates swapped. + IMPORTANT: This does not work if any of the coordinates are infinite.""" + # Define the transformation matrix for swapping X and Y coords. For details, see: + # https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations + transform_matrix = (0, 1, 1, 0, 0, 0) + shape_new = shapely.affinity.affine_transform(shape, transform_matrix) + return shape_new + + +def warn_untested_argument(cls_name: Optional[str], func_name: str, arg: str, val: str): + """Generic warning message if a function has never been manually tested with ``arg=val``. (This" + is typically used for plot functions where manual tests and visual confirmation is needed.)""" + prefix = "" + if cls_name: + prefix = cls_name + "." + log.warning( + f"UNTESTED! The `{prefix}{func_name}()` function has not yet been tested with `{arg}={val}`.", + log_once=True, + )