Skip to content

Commit 3a467ab

Browse files
jewettaijfcyaugenst-flex
authored andcommitted
plots of objects defined by shape intersection logic no longer display thin line artifacts. VERSION 2 (issue #2560)
1 parent cc8fef1 commit 3a467ab

File tree

5 files changed

+198
-17
lines changed

5 files changed

+198
-17
lines changed

CHANGELOG.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Add support for `np.unwrap` in `tidy3d.plugins.autograd`.
1212
- Add Nunley variant to germanium material library based on Nunley et al. 2016 data.
1313

14-
### Changed
15-
- Switched to an analytical gradient calculation for spatially-varying pole-residue models (`CustomPoleResidue`).
16-
17-
### Changed
18-
- Significantly improved performance of the `tidy3d.plugins.autograd.grey_dilation` morphological operation and its gradient calculation. The new implementation is orders of magnitude faster, especially for large arrays and kernel sizes.
19-
2014
### Fixed
2115
- Arrow lengths are now scaled consistently in the X and Y directions, and their lengths no longer exceed the height of the plot window.
2216
- Bug in `PlaneWave` defined with a negative `angle_theta` which would lead to wrong injection.
17+
- Plots of objects defined by shape intersection logic will no longer display thin line artifacts.
2318

2419
### Changed
20+
- Switched to an analytical gradient calculation for spatially-varying pole-residue models (`CustomPoleResidue`).
21+
- Significantly improved performance of the `tidy3d.plugins.autograd.grey_dilation` morphological operation and its gradient calculation. The new implementation is orders of magnitude faster, especially for large arrays and kernel sizes.
2522
- `GaussianBeam` and `AstigmaticGaussianBeam` default `num_freqs` reset to 1 (it was set to 3 in v2.8.0) and a warning is issued for a broadband, angled beam for which `num_freqs` may not be sufficiently large.
2623
- Set the maximum `num_freqs` to 20 for all broadband sources (we have been warning about the introduction of this hard limit for a while).
2724

tests/test_components/test_geometry.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import trimesh
1515

1616
import tidy3d as td
17+
from tidy3d.compat import _shapely_is_older_than
18+
from tidy3d.components.geometry.base import cleanup_shapely_object
1719
from tidy3d.components.geometry.mesh import AREA_SIZE_THRESHOLD
1820
from tidy3d.components.geometry.utils import (
1921
SnapBehavior,
@@ -1249,3 +1251,50 @@ def wrong_shape_height_func(x, y):
12491251
error_message = str(excinfo.value)
12501252
assert f"shape {expected_shape}" in error_message
12511253
assert "shape (3, 3)" in error_message
1254+
1255+
1256+
def test_cleanup_shapely_object():
1257+
if _shapely_is_older_than("2.1"):
1258+
# (Old versions of shapely don't support `shapely.make_valid()` with the correct arguments.
1259+
# However older alternatives like `.buffer(0)` are not as robust. `.buffer(0)` is likely
1260+
# to generate polygons which look correct, but have extra vertices, causing test to fail.
1261+
# So if `shapely.make_valid()` is not supported, the safest thing to do is skip this test.)
1262+
pytest.skip("This test requires `shapely` version 2.1 or later")
1263+
1264+
# Test 1: A square containing a triangular hole, and an infinitley thin hole.
1265+
square_with_spikes_5x5 = np.array(
1266+
(
1267+
(0, 0),
1268+
(5, 0),
1269+
(5, 0),
1270+
(5, 10), # this vertex creates an outward spike and should be removed
1271+
(5, 5),
1272+
(0, 5 - 1e-13), # this vertex should be rounded to (0, 5)
1273+
(3, 3), # this vertex creates an inward spike and should be removed
1274+
(0, 5 + 1e-13), # this vertex should be removed because it duplicates (0, 5 - 1e-13)
1275+
)
1276+
)
1277+
triangle_empty_tails = np.array(((1, 1), (3, 1), (2, 2), (2.5, 2.5), (0.5, 0.5)))
1278+
triangle_collinear = np.array(((4, 2), (3, 3), (2, 4), (4, 2))) # has zero area
1279+
# NOTE: Test will fail for self intersecting polys like: ((0,0), (1,1), (2,-1), (3,1), (4,0))
1280+
# Now build a shapely polygon with the 4 small polygons enclosed by big_square_5x5
1281+
exterior_coords = square_with_spikes_5x5
1282+
interior_coords_list = [
1283+
triangle_empty_tails,
1284+
triangle_collinear, # this triangle should be eliminated
1285+
]
1286+
# Test using a non-empty exterior polygon (big_square_5x5)
1287+
orig_polygon = shapely.Polygon(exterior_coords, interior_coords_list)
1288+
new_polygon = cleanup_shapely_object(orig_polygon, tolerance_ratio=1e-12)
1289+
# Delete any nearby or overlapping vertices (cleanup_shapely_object() does not do this).
1290+
new_polygon = shapely.simplify(new_polygon, tolerance=1e-10)
1291+
# Now `new_polygon` should only contain the coordinates of the square (with a duplicate at end).
1292+
assert len(new_polygon.exterior.coords) == 5 # squares have 4 vertices but shapely adds 1
1293+
assert len(new_polygon.interiors) == 1 # only the "triangle_empty_tails" interior hole survives
1294+
assert len(new_polygon.interiors[0].coords) == 4 # triangles have 3 vertices but shapely adds 1
1295+
# Test 2: An infinitely thin triangle exterior with some holes (which should be deleted)
1296+
exterior_coords = triangle_collinear # has zero area
1297+
orig_polygon = shapely.Polygon(exterior_coords)
1298+
new_polygon = cleanup_shapely_object(orig_polygon, tolerance_ratio=1e-12)
1299+
new_polygon = shapely.simplify(new_polygon, tolerance=1e-10)
1300+
assert len(new_polygon.exterior.coords) == 0 # empty / collinear polygons should get deleted

tidy3d/compat.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,23 @@
22

33
from __future__ import annotations
44

5+
import importlib
6+
7+
from packaging.version import parse as parse_version
8+
59
try:
610
from xarray.structure import alignment
711
except ImportError:
812
from xarray.core import alignment
913

14+
15+
_SHAPELY_VERSION = parse_version(importlib.metadata.version("shapely"))
16+
17+
18+
def _shapely_is_older_than(version: str) -> bool:
19+
if _SHAPELY_VERSION < parse_version(version):
20+
return True
21+
return False
22+
23+
1024
__all__ = ["alignment"]

tidy3d/components/geometry/base.py

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
except ImportError:
1818
pass
1919

20+
from tidy3d.compat import _shapely_is_older_than
2021
from tidy3d.components.autograd import AutogradFieldMap, TracedCoordinate, TracedSize, get_static
2122
from tidy3d.components.autograd.derivative_utils import DerivativeInfo, integrate_within_bounds
2223
from tidy3d.components.base import Tidy3dBaseModel, cached_property
@@ -63,6 +64,7 @@
6364
from .bound_ops import bounds_intersection, bounds_union
6465

6566
POLY_GRID_SIZE = 1e-12
67+
POLY_TOLERANCE_RATIO = 1e-12
6668

6769

6870
_shapely_operations = {
@@ -2862,27 +2864,45 @@ def _geometries_untraced(cls, val):
28622864
return val
28632865

28642866
@staticmethod
2865-
def to_polygon_list(base_geometry: Shapely) -> list[Shapely]:
2867+
def to_polygon_list(base_geometry: Shapely, cleanup: bool = False) -> list[Shapely]:
28662868
"""Return a list of valid polygons from a shapely geometry, discarding points, lines, and
2867-
empty polygons.
2869+
empty polygons, and empty triangles within polygons.
28682870
28692871
Parameters
28702872
----------
28712873
base_geometry : shapely.geometry.base.BaseGeometry
28722874
Base geometry for inspection.
2875+
cleanup: bool = False
2876+
If True, removes extremely small features from each polygon's boundary.
2877+
This is useful for removing artifacts from 2D plots displayed to the user.
28732878
28742879
Returns
28752880
-------
28762881
List[shapely.geometry.base.BaseGeometry]
28772882
Valid polygons retrieved from ``base geometry``.
28782883
"""
2884+
unfiltered_geoms = []
28792885
if base_geometry.geom_type == "GeometryCollection":
2880-
return [p for geom in base_geometry.geoms for p in ClipOperation.to_polygon_list(geom)]
2886+
unfiltered_geoms = [
2887+
p for geom in base_geometry.geoms for p in ClipOperation.to_polygon_list(geom)
2888+
]
28812889
if base_geometry.geom_type == "MultiPolygon":
2882-
return [p for p in base_geometry.geoms if not p.is_empty]
2890+
unfiltered_geoms = [p for p in base_geometry.geoms if not p.is_empty]
28832891
if base_geometry.geom_type == "Polygon" and not base_geometry.is_empty:
2884-
return [base_geometry]
2885-
return []
2892+
unfiltered_geoms = [base_geometry]
2893+
geoms = []
2894+
if cleanup:
2895+
# Optional: "clean" each of the polygons (by removing extremely small or thin features).
2896+
for geom in unfiltered_geoms:
2897+
geom_clean = cleanup_shapely_object(geom)
2898+
if geom_clean.geom_type == "Polygon":
2899+
geoms.append(geom_clean)
2900+
if geom_clean.geom_type == "MultiPolygon":
2901+
geoms += [p for p in geom_clean.geoms if not p.is_empty]
2902+
# Ignore other types of shapely objects (points and lines)
2903+
else:
2904+
geoms = unfiltered_geoms
2905+
return geoms
28862906

28872907
@property
28882908
def _shapely_operation(self) -> Callable[[Shapely, Shapely], Shapely]:
@@ -2931,7 +2951,10 @@ def intersections_tilted_plane(
29312951
b = self.geometry_b.intersections_tilted_plane(normal, origin, to_2D)
29322952
geom_a = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in a])
29332953
geom_b = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in b])
2934-
return ClipOperation.to_polygon_list(self._shapely_operation(geom_a, geom_b))
2954+
return ClipOperation.to_polygon_list(
2955+
self._shapely_operation(geom_a, geom_b),
2956+
cleanup=True,
2957+
)
29352958

29362959
def intersections_plane(
29372960
self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None
@@ -2958,7 +2981,10 @@ def intersections_plane(
29582981
b = self.geometry_b.intersections_plane(x, y, z)
29592982
geom_a = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in a])
29602983
geom_b = shapely.unary_union([Geometry.evaluate_inf_shape(g) for g in b])
2961-
return ClipOperation.to_polygon_list(self._shapely_operation(geom_a, geom_b))
2984+
return ClipOperation.to_polygon_list(
2985+
self._shapely_operation(geom_a, geom_b),
2986+
cleanup=True,
2987+
)
29622988

29632989
@cached_property
29642990
def bounds(self) -> Bound:
@@ -3293,4 +3319,92 @@ def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradField
32933319
return grad_vjps
32943320

32953321

3322+
def cleanup_shapely_object(obj: Shapely, tolerance_ratio: float = POLY_TOLERANCE_RATIO) -> Shapely:
3323+
"""Remove small geometric features from the boundaries of a shapely object including
3324+
inward and outward spikes, thin holes, and thin connections between larger regions.
3325+
3326+
Parameters
3327+
----------
3328+
obj : shapely
3329+
a shapely object (typically a ``Polygon`` or a ``MultiPolygon``)
3330+
tolerance_ratio : float = ``POLY_TOLERANCE_RATIO``
3331+
Features on the boundaries of polygons will be discarded if they are smaller
3332+
or narrower than ``tolerance_ratio`` multiplied by the size of the object.
3333+
3334+
Returns
3335+
-------
3336+
Shapely
3337+
A new shapely object whose small features (eg. thin spikes or holes) are removed.
3338+
3339+
Notes
3340+
-----
3341+
This function does not attempt to delete overlapping, nearby, or collinear vertices.
3342+
To solve that problem, use ``shapely.simplify()`` afterwards.
3343+
"""
3344+
if _shapely_is_older_than("2.1"):
3345+
log.warning(
3346+
"Using old versions of the shapely library (prior to v2.1) may cause "
3347+
"plot errors. This can be solved by upgrading to Python 3.10 "
3348+
"(or later) and reinstalling Tidy3d.",
3349+
log_once=True,
3350+
)
3351+
return obj
3352+
if obj.is_empty:
3353+
return obj
3354+
centroid = obj.centroid
3355+
object_size = min(obj.bounds[2] - obj.bounds[0], obj.bounds[3] - obj.bounds[1])
3356+
if object_size == 0.0:
3357+
return shapely.Polygon([])
3358+
# In order to prevent numerical overflow or underflow errors, we first subtract
3359+
# the centroid and divide by (rescale) the size of the object so it is not too big.
3360+
normalized_obj = shapely.affinity.affine_transform(
3361+
# https://shapely.readthedocs.io/en/stable/manual.html#affine-transformations
3362+
obj,
3363+
matrix=[
3364+
1 / object_size,
3365+
0.0,
3366+
0.0,
3367+
1 / object_size,
3368+
-centroid.x / object_size,
3369+
-centroid.y / object_size,
3370+
],
3371+
)
3372+
# Important: Remove any self intersections beforehand using `shapely.make_valid()`.
3373+
valid_obj = shapely.make_valid(normalized_obj, method="structure", keep_collapsed=False)
3374+
# To get rid of small thin features, erode(shrink), dilate(expand), and erode again.
3375+
eroded_obj = shapely.buffer( # This removes outward spikes
3376+
valid_obj,
3377+
distance=-tolerance_ratio,
3378+
cap_style="square", # (optional parameter to reduce computation time)
3379+
quad_segs=3, # (optional parameter to reduce computation time)
3380+
)
3381+
dilated_obj = shapely.buffer( # This removes inward spikes and tiny holes
3382+
eroded_obj,
3383+
distance=2 * tolerance_ratio,
3384+
cap_style="square",
3385+
quad_segs=3,
3386+
)
3387+
cleaned_obj = dilated_obj
3388+
# Optional: Now shrink the polygon back to the original size.
3389+
cleaned_obj = shapely.buffer(
3390+
cleaned_obj,
3391+
distance=-tolerance_ratio,
3392+
cap_style="square",
3393+
quad_segs=3,
3394+
)
3395+
# Revert to the original scale and position.
3396+
rescaled_clean_obj = shapely.affinity.affine_transform(
3397+
cleaned_obj,
3398+
matrix=[
3399+
object_size,
3400+
0.0,
3401+
0.0,
3402+
object_size,
3403+
centroid.x,
3404+
centroid.y,
3405+
],
3406+
)
3407+
return rescaled_clean_obj
3408+
3409+
32963410
from .utils import GeometryType, from_shapely, vertices_from_shapely # noqa: E402

tidy3d/components/viz/descartes.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ def __init__(self, context):
3535
@property
3636
def exterior(self):
3737
"""Get polygon exterior."""
38-
return getattr(self.context, "exterior", None) or self.context[0]
38+
value = getattr(self.context, "exterior", None)
39+
if value is None:
40+
value = self.context[0]
41+
return value
3942

4043
@property
4144
def interiors(self):
@@ -53,9 +56,13 @@ def polygon_path(polygon):
5356
def coding(obj):
5457
# The codes will be all "LINETO" commands, except for "MOVETO"s at the
5558
# beginning of each subpath
56-
n = len(getattr(obj, "coords", None) or obj)
59+
crds = getattr(obj, "coords", None)
60+
if crds is None:
61+
crds = obj
62+
n = len(crds)
5763
vals = ones(n, dtype=Path.code_type) * Path.LINETO
58-
vals[0] = Path.MOVETO
64+
if len(vals) > 0:
65+
vals[0] = Path.MOVETO
5966
return vals
6067

6168
ptype = polygon.geom_type

0 commit comments

Comments
 (0)