Skip to content

remove thin triangles from polygon boundaries (issue #2560) #2581

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Arrow lengths are now scaled consistently in the X and Y directions,
and their lengths no longer exceed the height of the plot window.
- Removed pathologically thin triangles from the boundaries of clipped polygons
displayed in 2D plots. Such triangles are often generated when displaying the
intersections between closely aligned objects, causing visual glitches.

## [2.9.0rc1] - 2025-06-10

Expand Down
107 changes: 107 additions & 0 deletions tests/test_components/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
import gdstk
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
import pydantic.v1 as pydantic
import pytest
import shapely
import trimesh

import tidy3d as td
from tidy3d.components.geometry.base import (
_triangle_thicknesses,
cleanup_shapely_polygon,
cleanup_simple_polygon,
)
from tidy3d.components.geometry.mesh import AREA_SIZE_THRESHOLD
from tidy3d.components.geometry.utils import (
SnapBehavior,
Expand Down Expand Up @@ -1249,3 +1255,104 @@ def wrong_shape_height_func(x, y):
error_message = str(excinfo.value)
assert f"shape {expected_shape}" in error_message
assert "shape (3, 3)" in error_message


def test_triangle_thickness():
tolerance = 1e-07
coords_tr1 = np.array((((0.0, 0.0),), ((0.0, 0.0),), ((0.0, 0.0),))) # edge case
coords_tr2 = np.array((((0.0, 0.0),), ((3.0, 1.0),), ((2.0, -1.0),)))
coords_tr3 = np.array((((0.0, 0.0),), ((1.0, 1.0),), ((1.0, 2.0),)))
coords_tr4 = np.array((((2.0, -1.0),), ((1.0, 3.0),), ((3.0, 0.0),)))
coords_tr5 = np.array((((0.0, 0.0),), ((-1.0, 1.0),), ((100.0, 2.0),)))
# create an Nx3x2 array storing all the coordinates
all_coords = np.hstack([coords_tr1, coords_tr2, coords_tr3, coords_tr4, coords_tr5])
# r1, r2, r3 = coordinates of the first, second, and third vertex from each triangle (size Nx2)
r1, r2, r3 = all_coords[0], all_coords[1], all_coords[2]
thicknesses = _triangle_thicknesses(r1, r2, r3)
# 1st triangle thickness:
assert math.isclose(thicknesses[0][0], 0.0, abs_tol=tolerance)
# 2nd triangle thickness:
assert math.isclose(thicknesses[1][0], 1.5811388300841898, abs_tol=tolerance)
# 3rd triangle thickness:
assert math.isclose(thicknesses[2][0], 0.4472135954999579, abs_tol=tolerance)
# 4th triangle thickness:
assert math.isclose(thicknesses[3][0], 1.212678125181665, abs_tol=tolerance)
# 5th triangle thickness:
assert math.isclose(thicknesses[4][0], 1.0098514936405245, abs_tol=tolerance)


def test_cleanup_simple_polygon():
def test_with_cycling(
coords: npt.ArrayLike, correct_num_verts: int, min_thickness: float = 1e-12
) -> None:
# Polygon geometry should not be affected by cyclic permutations of vertices.
# This function considers all cyclic permutations of polygon vertices,
# and verifies that the number of vertices after cleanup remains correct.
# (This has caught some subtle errors.)
n = len(coords)
coords_before = coords
for i in range(n):
coords_before = np.roll(coords, axis=0, shift=i) # cyclic shift by i
coords_after = cleanup_simple_polygon(coords_before, min_thickness=min_thickness)
assert len(coords_after) == correct_num_verts
if correct_num_verts > 0:
# Now test the behavior of the optional "repeat_first" argument.
coords_after = cleanup_simple_polygon(
coords_before, min_thickness=min_thickness, repeat_first=True
)
assert len(coords_after) == correct_num_verts + 1
assert np.array_equal(coords_after[0], coords_after[-1])

point = np.array([[0, 0]])
test_with_cycling(point, 0) # polygons with less than 3 vertices should be cleared
line_segment = np.array(((0, 0), (1, 0)))
test_with_cycling(line_segment, 0) # polygons with less than 3 vertices should be cleared
pointlike = np.array(((0, 0), (0, 0), (0, 0)))
test_with_cycling(pointlike, 0) # zero-area polygons should be cleared (have no vertices)
pointlike_repeats = np.array(((0, 0), (0, 0), (0, 0), (0, 0), (0, 0)))
test_with_cycling(pointlike_repeats, 0) # zero-area polygons should have no vertices
triangle_collinear = np.array(((0, 0), (1, 1), (2, 2)))
test_with_cycling(triangle_collinear, 0) # zero-area polygons should have no vertices
triangle_collinear_repeats = np.array(((0, 0), (1, 1), (1, 1), (2, 2), (2, 2), (2, 2)))
test_with_cycling(triangle_collinear_repeats, 0) # zero-area polygons should have no vertices
triangle_empty_tails = np.array(((1, 1), (3, 1), (2, 2), (3, 3), (0, 0)))
test_with_cycling(triangle_empty_tails, 3) # triangles have 3 vertices
square_empty_tail1 = np.array(((0, 0), (1, 0), (1, 1), (0, 1), (0, -100)))
test_with_cycling(square_empty_tail1, 4) # squares have 4 vertices
square_thin_tail2 = np.array(((0, 0), (1, 0), (1, 1), (0, 1), (-1, -100)))
test_with_cycling(square_thin_tail2, 4, min_thickness=0.01) # squares have 4 vertices
test_with_cycling(square_thin_tail2, 5) # (or 5 if we keep the thin tail)
square_thin_tail3 = np.array(((0, 0), (1, 0), (1, 1), (0, 1), (-1, -100), (-1, -101)))
test_with_cycling(square_thin_tail2, 4, min_thickness=0.01) # squares have 4 vertices
test_with_cycling(square_thin_tail3, 6) # (or 6 if we keep the thin tail)
square_repeats = np.array(
((0, 0), (1, 0), (1, 0), (1, 1), (1, 1), (1, 1), (0, 1), (0, 1), (0, 0))
)
test_with_cycling(square_repeats, 4) # squares have 4 vertices
square_colinear = np.array(
((0, 0), (3, 0), (3, 1), (3, 2), (3, 3), (3, 3), (3, 3), (1.5, 3), (0, 3))
)
test_with_cycling(square_colinear, 4) # squares have 4 vertices


def test_cleanup_shapely_polygon():
big_square_5x5 = np.array(((0, 0), (3, 0), (5, 0), (5, 0), (5, 10), (5, 5), (0, 5)))
triangle_empty_tails = np.array(((1, 1), (3, 1), (2, 2), (2.5, 2.5), (0.5, 0.5)))
triangle_collinear = np.array(((4, 2), (3, 3), (2, 4), (4, 2)))
# Build a shapely polygon with the 4 small polygons enclosed by big_square_5x5
exterior_coords = big_square_5x5
interior_coords_list = [
triangle_empty_tails,
triangle_collinear,
]
# test using a non-empty exterior polygon (big_square_5x5)
orig_polygon = shapely.Polygon(exterior_coords, interior_coords_list)
new_polygon = cleanup_shapely_polygon(orig_polygon)
assert len(new_polygon.exterior.coords) == 5 # squares have 4 vertices (+1 for end duplicate)
assert len(new_polygon.interiors) == 1 # only the "triangle_empty_tails" survives
# test using an infinitely thin exterior polygon
exterior_coords = triangle_collinear
orig_polygon = shapely.Polygon(exterior_coords, interior_coords_list)
new_polygon = cleanup_shapely_polygon(orig_polygon)
assert len(new_polygon.exterior.coords) == 0 # polygons with zero area should get deleted
assert len(new_polygon.interiors) == 0 # delete interior polygons if exterior has zero area
158 changes: 153 additions & 5 deletions tidy3d/components/geometry/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import Any, Callable, Optional, Union

import autograd.numpy as np
import numpy.typing as npt
import pydantic.v1 as pydantic
import shapely
import xarray as xr
Expand Down Expand Up @@ -2864,7 +2865,7 @@ def _geometries_untraced(cls, val):
@staticmethod
def to_polygon_list(base_geometry: Shapely) -> list[Shapely]:
"""Return a list of valid polygons from a shapely geometry, discarding points, lines, and
empty polygons.
empty polygons, and empty triangles within polygons.

Parameters
----------
Expand All @@ -2876,13 +2877,26 @@ def to_polygon_list(base_geometry: Shapely) -> list[Shapely]:
List[shapely.geometry.base.BaseGeometry]
Valid polygons retrieved from ``base geometry``.
"""
unfiltered_geoms: list[Shapely] = []
if base_geometry.geom_type == "GeometryCollection":
return [p for geom in base_geometry.geoms for p in ClipOperation.to_polygon_list(geom)]
unfiltered_geoms = [
p for geom in base_geometry.geoms for p in ClipOperation.to_polygon_list(geom)
]
if base_geometry.geom_type == "MultiPolygon":
return [p for p in base_geometry.geoms if not p.is_empty]
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:
return [base_geometry]
return []
unfiltered_geoms = [base_geometry]
unfiltered_geoms = [base_geometry]
# Now "clean" each of the polygons (by removing empty boundary triangles).
geoms: list[Shapely] = []
for geom in unfiltered_geoms:
if isinstance(geom, shapely.Polygon):
polygon = cleanup_shapely_polygon(geom)
if not polygon.is_empty:
geoms.append(polygon)
else:
geoms.append(geom)
return geoms

@property
def _shapely_operation(self) -> Callable[[Shapely, Shapely], Shapely]:
Expand Down Expand Up @@ -3293,4 +3307,138 @@ def _compute_derivatives(self, derivative_info: DerivativeInfo) -> AutogradField
return grad_vjps


def remove_repeated_polygon_vertices(
crds: npt.ArrayLike,
) -> npt.ArrayLike:
"""
Removes repeated consecutive coordinates from the boundary of a polygon. To do that it
removes repeated consecutive rows from a 2D numpy array (including first and last row).

Parameters
----------
crds : npt.ArrayLike
An Nx2 numpy array containing the coordinates of the N points on the polygon's boundary.

Returns
-------
npt.ArrayLixke
Coordinates for a polygon with thin duplicate vertices removed.
"""
if len(crds) < 2:
return crds
diff_rows = np.diff(crds, axis=0) # compute the displacement vector from crds[i] to crds[i+1]
are_rows_identical = (diff_rows == 0).all(axis=1) # Check where the differences are all zeros
# Insert False at beginning so that the boolean array matches the original array indexing.
rows_to_delete_mask = np.insert(are_rows_identical, 0, False)
rows_to_keep_mask = ~rows_to_delete_mask
new_crds = crds[rows_to_keep_mask]
# Edge case: Polygons are cyclic. So if the first and last vertices are identical, omit the
# last vertex. (Note: This breaks compatibility with shapely, but we will fix that later.)
if len(new_crds) > 0 and np.all(np.equal(new_crds[0], new_crds[-1])):
return new_crds[:-1] # Omit the last row of `crds`
return new_crds


def cleanup_simple_polygon(
crds: npt.ArrayLike,
min_thickness: float = 1e-12,
repeat_first: bool = False,
) -> npt.ArrayLike:
"""
Remove vertices on the boundary of polygons that are collinear (or almost collinear) with their
neighbors (if the triangles they belong to are thinner than the ``min_thickness`` parameter).
Returns a 2D array with the coordinates of the remaining vertices which were not deleted.

Parameters
----------
crds : npt.ArrayLike
An Nx2 numpy array containing the coordinates of the N points on the polygon's boundary.
min_thickness : float = 1e-12
Vertices bordering triangles whose thickness falls below this parameter are discarded.
repeat_first: bool = False
Optional: Duplicate the first vertex at the end of the array to create a closed curve.
Set to True if you want to be to be compatible with the shapely library.
Returns
-------
npt.ArrayLike
Coordinates for a polygon with thin triangles removed.
"""
crds = remove_repeated_polygon_vertices(crds) # Eliminate zero-length line segments
crds_next = np.roll(crds, axis=0, shift=-1)
crds_prev = np.roll(crds, axis=0, shift=+1)
triangle_thicknesses = _triangle_thicknesses(crds_prev, crds, crds_next)
# Select the indices from `crds` for vertices that we want to keep.
good_indices = np.argwhere(triangle_thicknesses > min_thickness)[:, 0] # keep these vertices
assert len(good_indices) == 0 or len(good_indices) >= 3 # polygon must have >= 3 vertices
if repeat_first and len(good_indices) > 0:
# The shapely library assumes the first and last vertices are identical
good_indices = np.append(good_indices, good_indices[0]) # copy first index to the end.
return crds[good_indices]


def _triangle_thicknesses(r1: npt.ArrayLike, r2: npt.ArrayLike, r3: npt.ArrayLike) -> float:
"""
Computes the thicknesses of N triangles, whose coordinates are arranged in 3 Nx2 arrays.
Parameters
----------
r1 : npt.ArrayLike
An Nx2 array of the coordinates of the 1st vertex from the N triangles
r2 : npt.ArrayLike
An Nx2 array of the coordinates of the 2nd vertex from the N triangles
r3 : npt.ArrayLike
An Nx2 array of the coordinates of the 3rd vertex from the N triangles
Returns
-------
npt.ArrayLike
An Nx1 array of the thicknesses of all N triangles.
"""
n = len(r1)
r12 = r1 - r2
r23 = r2 - r3
r31 = r3 - r1
len12_sq = np.sum(r12 * r12, axis=1, keepdims=True)
len23_sq = np.sum(r23 * r23, axis=1, keepdims=True)
len31_sq = np.sum(r31 * r31, axis=1, keepdims=True)
cross_prod = np.cross(
np.hstack([r2 - r1, np.zeros((n, 1))]),
np.hstack([r3 - r1, np.zeros((n, 1))]),
axis=-1,
)
area_parallelogram = np.abs(cross_prod)[:, 2, np.newaxis] # = 2x triangle area
longest_side_sq = np.max(np.hstack([len12_sq, len23_sq, len31_sq]), axis=1, keepdims=True)
longest_side_sq[longest_side_sq == 0] = 1.0 # replace 0s with 1s to avoid 0/0 division errors
longest_side = np.sqrt(longest_side_sq)
return area_parallelogram / longest_side


def cleanup_shapely_polygon(p: shapely.Polygon, min_thickness: float = 1e-12) -> shapely.Polygon:
"""Remove pathologically thin triangles from the boundaries of a shapely polygon.
Parameters
----------
p : shapely.Polygon
Vector defining the normal direction to the plane.
min_thickness : float = 1e-13
Triangles whose thickness (in any direction) falls below this parameter are discarded.
Returns
-------
shapely.Polygon
A new polygon with thin triangles removed.
"""
# remove thin triangles from the exterior boundary of the polygon.
exterior_coords = cleanup_simple_polygon(
np.asarray(p.exterior.coords), min_thickness=min_thickness, repeat_first=True
)
# remove thin triangles from each of the interior boundares.
interior_coords_list = []
if len(exterior_coords) < 3:
return shapely.Polygon([], [])
for interior_ring in p.interiors:
interior_coords = cleanup_simple_polygon(
np.asarray(interior_ring.coords), min_thickness=min_thickness, repeat_first=True
)
if len(interior_coords) >= 3:
interior_coords_list.append(interior_coords)
return shapely.Polygon(exterior_coords, interior_coords_list)


from .utils import GeometryType, from_shapely, vertices_from_shapely # noqa: E402
Loading