Skip to content

Commit f614a6e

Browse files
committed
fix: Bug in PolySlab's intersections_plane method in cases where the plane is coincident with PolySlab side faces
1 parent 9f1893a commit f614a6e

File tree

3 files changed

+103
-17
lines changed

3 files changed

+103
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Cropped adjoint monitor sizes in 2D simulations to planar geometry intersection.
2828
- Fixed `Batch.download()` silently succeeding when background downloads fail (e.g., gzip extraction errors).
2929
- Handling of zero values when using `sim_data.plot_field` with `scale=dB`.
30+
- Fixed `intersections_plane` method in `PolySlab`, which sometimes missed vertices for planes coincident with `PolySlab` side faces.
3031

3132
## [2.10.0] - 2025-12-18
3233

tests/test_components/test_geometry.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,75 @@ def test_polyslab_intersection_inf_bounds():
941941
assert poly.intersections_plane(x=0)[0] == shapely.box(-1, -LARGE_NUMBER, 1, 0)
942942

943943

944+
def test_polyslab_intersection_with_coincident_plane():
945+
"""Test if intersection returns the correct shape when the plane is coincident with the side face."""
946+
poly = td.PolySlab(
947+
vertices=[[500.0, -7500.0], [500.0, 7500.0], [-500.0, 7500.0], [-500.0, -7500.0]],
948+
slab_bounds=[0, 50],
949+
axis=2,
950+
)
951+
# Each case should give one side face of the polyslab
952+
expected_x_face = shapely.box(-7500, 0, 7500, 50) # y-extent × z-extent
953+
expected_y_face = shapely.box(-500, 0, 500, 50) # x-extent × z-extent
954+
955+
assert poly.intersections_plane(x=-500) == [expected_x_face]
956+
assert poly.intersections_plane(x=500) == [expected_x_face]
957+
assert poly.intersections_plane(y=-7500) == [expected_y_face]
958+
assert poly.intersections_plane(y=7500) == [expected_y_face]
959+
960+
961+
def test_polyslab_intersection_rotated_square():
962+
"""Test PolySlab plane intersection with a rotated square (diamond shape)."""
963+
# Create a diamond by rotating a square 45 degrees
964+
size = 2.0
965+
angle = np.pi / 4
966+
base_vertices = np.array(
967+
[[-size / 2, -size / 2], [size / 2, -size / 2], [size / 2, size / 2], [-size / 2, size / 2]]
968+
)
969+
cos_a, sin_a = np.cos(angle), np.sin(angle)
970+
rotation = np.array([[cos_a, -sin_a], [sin_a, cos_a]])
971+
rotated = base_vertices @ rotation.T
972+
rotated = rotated - rotated.min(axis=0) + 0.5 # shift to positive quadrant
973+
vertices = [tuple(v) for v in rotated]
974+
975+
polyslab = td.PolySlab(vertices=vertices, slab_bounds=(0, 3), axis=2)
976+
977+
all_verts = np.array(vertices)
978+
left_tip_x = all_verts[:, 0].min()
979+
bottom_tip_y = all_verts[:, 1].min()
980+
x_center = (all_verts[:, 0].min() + all_verts[:, 0].max()) / 2
981+
982+
# Test 1: Cut at z=1.5 (middle of slab) - should give full diamond
983+
cross_section = polyslab.intersections_plane(z=1.5)
984+
assert len(cross_section) == 1
985+
assert np.isclose(cross_section[0].area, 4.0)
986+
987+
# Test 2: Cut through center at x=x_center - should give rectangle
988+
cross_section = polyslab.intersections_plane(x=x_center)
989+
assert len(cross_section) == 1
990+
assert cross_section[0].area > 0
991+
992+
# Test 3: Cut at left corner tip (tangent touch) - should give degenerate shape
993+
cross_section = polyslab.intersections_plane(x=left_tip_x)
994+
assert len(cross_section) == 1
995+
assert np.isclose(cross_section[0].area, 0.0)
996+
997+
# Test 4: Cut near left corner (slightly inside) - should give small shape
998+
cross_section = polyslab.intersections_plane(x=left_tip_x + 0.3)
999+
assert len(cross_section) == 1
1000+
assert cross_section[0].area > 0
1001+
1002+
# Test 5: Cut at bottom corner (tangent touch) - should give degenerate shape
1003+
cross_section = polyslab.intersections_plane(y=bottom_tip_y)
1004+
assert len(cross_section) == 1
1005+
assert np.isclose(cross_section[0].area, 0.0)
1006+
1007+
# Test 6: Cut at z=0 (bottom boundary) - should give full diamond
1008+
cross_section = polyslab.intersections_plane(z=0)
1009+
assert len(cross_section) == 1
1010+
assert np.isclose(cross_section[0].area, 4.0)
1011+
1012+
9441013
def test_from_shapely():
9451014
ring = shapely.LinearRing([(-16, 9), (-8, 9), (-12, 2)])
9461015
poly = shapely.Polygon([(-2, 0), (-10, 0), (-6, 7)])

tidy3d/components/geometry/polyslab.py

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -858,23 +858,22 @@ def _find_intersecting_ys_angle_vertical(
858858
x_vertices_f, _ = vertices_f.T
859859
x_vertices_axis, _ = vertices_axis.T
860860

861-
# find which segments intersect
862-
f_left_to_intersect = x_vertices_f <= position
863-
orig_right_to_intersect = x_vertices_axis > position
864-
intersects_b = np.logical_and(f_left_to_intersect, orig_right_to_intersect)
861+
# Find which segments intersect:
862+
# 1. Strictly crossing: one endpoint strictly left, one strictly right
863+
# 2. Touching: exactly one endpoint on the plane (xor), which excludes
864+
# edges lying entirely on the plane (both endpoints at position).
865+
orig_on_plane = np.isclose(x_vertices_axis, position, rtol=_IS_CLOSE_RTOL)
866+
f_on_plane = np.roll(orig_on_plane, shift=-1)
867+
crosses_b = (x_vertices_axis > position) & (x_vertices_f < position)
868+
crosses_f = (x_vertices_axis < position) & (x_vertices_f > position)
865869

866-
f_right_to_intersect = x_vertices_f > position
867-
orig_left_to_intersect = x_vertices_axis <= position
868-
intersects_f = np.logical_and(f_right_to_intersect, orig_left_to_intersect)
869-
870-
# exclude vertices at the position if exclude_on_vertices is True
871870
if exclude_on_vertices:
872-
intersects_on = np.isclose(x_vertices_axis, position, rtol=_IS_CLOSE_RTOL)
873-
intersects_f_on = np.isclose(x_vertices_f, position, rtol=_IS_CLOSE_RTOL)
874-
intersects_both_off = np.logical_not(np.logical_or(intersects_on, intersects_f_on))
875-
intersects_f &= intersects_both_off
876-
intersects_b &= intersects_both_off
877-
intersects_segment = np.logical_or(intersects_b, intersects_f)
871+
# exclude vertices at the position
872+
not_touching = np.logical_not(orig_on_plane | f_on_plane)
873+
intersects_segment = (crosses_b | crosses_f) & not_touching
874+
else:
875+
single_touch = np.logical_xor(orig_on_plane, f_on_plane)
876+
intersects_segment = crosses_b | crosses_f | single_touch
878877

879878
iverts_b = vertices_axis[intersects_segment]
880879
iverts_f = vertices_f[intersects_segment]
@@ -893,10 +892,27 @@ def _find_intersecting_ys_angle_vertical(
893892
ints_y = np.array(ints_y)
894893
ints_angle = np.array(ints_angle)
895894

896-
sort_index = np.argsort(ints_y)
897-
ints_y_sort = ints_y[sort_index]
895+
# Get rid of duplicate intersection points (vertices counted twice if directly on position)
896+
ints_y_sort, sort_index = np.unique(ints_y, return_index=True)
898897
ints_angle_sort = ints_angle[sort_index]
899898

899+
# For tangent touches (vertex on plane, both neighbors on same side),
900+
# add y-value back to form a degenerate pair
901+
if not exclude_on_vertices:
902+
n = len(vertices_axis)
903+
for idx in np.where(orig_on_plane)[0]:
904+
prev_on = orig_on_plane[(idx - 1) % n]
905+
next_on = orig_on_plane[(idx + 1) % n]
906+
if not prev_on and not next_on:
907+
prev_side = x_vertices_axis[(idx - 1) % n] > position
908+
next_side = x_vertices_axis[(idx + 1) % n] > position
909+
if prev_side == next_side:
910+
ints_y_sort = np.append(ints_y_sort, vertices_axis[idx, 1])
911+
ints_angle_sort = np.append(ints_angle_sort, 0)
912+
913+
sort_index = np.argsort(ints_y_sort)
914+
ints_y_sort = ints_y_sort[sort_index]
915+
ints_angle_sort = ints_angle_sort[sort_index]
900916
return ints_y_sort, ints_angle_sort
901917

902918
def _find_intersecting_ys_angle_slant(

0 commit comments

Comments
 (0)