12
12
import pytest
13
13
import shapely
14
14
import trimesh
15
+ from shapely .geometry import (
16
+ GeometryCollection ,
17
+ LineString ,
18
+ MultiLineString ,
19
+ MultiPoint ,
20
+ MultiPolygon ,
21
+ Point ,
22
+ Polygon ,
23
+ )
15
24
16
25
import tidy3d as td
17
26
from tidy3d .compat import _shapely_is_older_than
22
31
SnapLocation ,
23
32
SnappingSpec ,
24
33
flatten_groups ,
34
+ flatten_shapely_geometries ,
25
35
snap_box_to_grid ,
26
36
traverse_geometries ,
27
37
)
@@ -1137,7 +1147,14 @@ def test_subdivide():
1137
1147
@pytest .mark .parametrize ("snap_location" , [SnapLocation .Boundary , SnapLocation .Center ])
1138
1148
@pytest .mark .parametrize (
1139
1149
"snap_behavior" ,
1140
- [SnapBehavior .Off , SnapBehavior .Closest , SnapBehavior .Expand , SnapBehavior .Contract ],
1150
+ [
1151
+ SnapBehavior .Off ,
1152
+ SnapBehavior .Closest ,
1153
+ SnapBehavior .Expand ,
1154
+ SnapBehavior .Contract ,
1155
+ SnapBehavior .StrictExpand ,
1156
+ SnapBehavior .StrictContract ,
1157
+ ],
1141
1158
)
1142
1159
def test_snap_box_to_grid (snap_location , snap_behavior ):
1143
1160
""" "Test that all combinations of SnappingSpec correctly modify a test box without error."""
@@ -1158,12 +1175,78 @@ def test_snap_box_to_grid(snap_location, snap_behavior):
1158
1175
new_box = snap_box_to_grid (grid , box , snap_spec )
1159
1176
1160
1177
if snap_behavior != SnapBehavior .Off and snap_location == SnapLocation .Boundary :
1161
- # Check that the box boundary slightly off from 0.1 was correctly snapped to 0.1
1162
- assert math .isclose (new_box .bounds [0 ][1 ], xyz [1 ])
1163
- # Check that the box boundary slightly off from 0.3 was correctly snapped to 0.3
1164
- assert math .isclose (new_box .bounds [1 ][1 ], xyz [3 ])
1165
- # Check that the box boundary outside the grid was snapped to the smallest grid coordinate
1166
- assert math .isclose (new_box .bounds [0 ][2 ], xyz [0 ])
1178
+ # Strict behaviors have different snapping rules, so skip these specific assertions
1179
+ if snap_behavior not in (SnapBehavior .StrictExpand , SnapBehavior .StrictContract ):
1180
+ # Check that the box boundary slightly off from 0.1 was correctly snapped to 0.1
1181
+ assert math .isclose (new_box .bounds [0 ][1 ], xyz [1 ])
1182
+ # Check that the box boundary slightly off from 0.3 was correctly snapped to 0.3
1183
+ assert math .isclose (new_box .bounds [1 ][1 ], xyz [3 ])
1184
+ # Check that the box boundary outside the grid was snapped to the smallest grid coordinate
1185
+ assert math .isclose (new_box .bounds [0 ][2 ], xyz [0 ])
1186
+
1187
+
1188
+ def test_snap_box_to_grid_strict_behaviors ():
1189
+ """Test StrictExpand and StrictContract behaviors specifically."""
1190
+ xyz = np .linspace (0 , 1 , 11 ) # Grid points at 0.0, 0.1, 0.2, ..., 1.0
1191
+ coords = td .Coords (x = xyz , y = xyz , z = xyz )
1192
+ grid = td .Grid (boundaries = coords )
1193
+
1194
+ # Test StrictExpand: should always move endpoints outwards, even if coincident
1195
+ box_coincident = td .Box (
1196
+ center = (0.1 , 0.2 , 0.3 ), size = (0 , 0 , 0 )
1197
+ ) # Centered exactly on grid points
1198
+ snap_spec_strict_expand = SnappingSpec (
1199
+ location = [SnapLocation .Boundary ] * 3 , behavior = [SnapBehavior .StrictExpand ] * 3
1200
+ )
1201
+
1202
+ expanded_box = snap_box_to_grid (grid , box_coincident , snap_spec_strict_expand )
1203
+
1204
+ # StrictExpand should move bounds outwards even when already on grid
1205
+ assert expanded_box .bounds [0 ][0 ] < 0.1 # Left bound moved left from 0.1
1206
+ assert expanded_box .bounds [1 ][0 ] > 0.1 # Right bound moved right from 0.1
1207
+ assert expanded_box .bounds [0 ][1 ] < 0.2 # Bottom bound moved down from 0.2
1208
+ assert expanded_box .bounds [1 ][1 ] > 0.2 # Top bound moved up from 0.2
1209
+
1210
+ # Test StrictContract: should always move endpoints inwards, even if coincident
1211
+ box_large = td .Box (center = (0.5 , 0.5 , 0.5 ), size = (0.4 , 0.4 , 0.4 )) # Spans multiple grid cells
1212
+ snap_spec_strict_contract = SnappingSpec (
1213
+ location = [SnapLocation .Boundary ] * 3 , behavior = [SnapBehavior .StrictContract ] * 3
1214
+ )
1215
+
1216
+ contracted_box = snap_box_to_grid (grid , box_large , snap_spec_strict_contract )
1217
+
1218
+ # StrictContract should make the box smaller than the original
1219
+ assert contracted_box .size [0 ] < box_large .size [0 ]
1220
+ assert contracted_box .size [1 ] < box_large .size [1 ]
1221
+ assert contracted_box .size [2 ] < box_large .size [2 ]
1222
+
1223
+ # Test edge case: box coincident with grid boundaries
1224
+ box_on_grid = td .Box (
1225
+ center = (0.15 , 0.25 , 0.35 ), size = (0.1 , 0.1 , 0.1 )
1226
+ ) # Boundaries at 0.1,0.2 and 0.2,0.3
1227
+
1228
+ # Regular Expand shouldn't change a box already coincident with grid
1229
+ snap_spec_regular_expand = SnappingSpec (
1230
+ location = [SnapLocation .Boundary ] * 3 , behavior = [SnapBehavior .Expand ] * 3
1231
+ )
1232
+ regular_expanded = snap_box_to_grid (grid , box_on_grid , snap_spec_regular_expand )
1233
+ assert np .allclose (regular_expanded .bounds , box_on_grid .bounds ) # Should be unchanged
1234
+
1235
+ # StrictExpand should still expand even when coincident
1236
+ strict_expanded = snap_box_to_grid (grid , box_on_grid , snap_spec_strict_expand )
1237
+ assert not np .allclose (strict_expanded .bounds , box_on_grid .bounds ) # Should be changed
1238
+ assert strict_expanded .size [0 ] > box_on_grid .size [0 ] # Should be larger
1239
+
1240
+ # Test with margin parameter for strict behaviors
1241
+ snap_spec_strict_expand_margin = SnappingSpec (
1242
+ location = [SnapLocation .Boundary ] * 3 ,
1243
+ behavior = [SnapBehavior .StrictExpand ] * 3 ,
1244
+ margin = (1 , 1 , 1 ), # Consider 1 additional grid point when expanding
1245
+ )
1246
+
1247
+ margin_expanded = snap_box_to_grid (grid , box_coincident , snap_spec_strict_expand_margin )
1248
+ # With margin=1, should expand even further than without margin
1249
+ assert margin_expanded .size [0 ] >= expanded_box .size [0 ]
1167
1250
1168
1251
1169
1252
def test_triangulation_with_collinear_vertices ():
@@ -1431,3 +1514,105 @@ def test_trim_dims_and_bounds_edge():
1431
1514
assert np .all (np .array (expected_trimmed_bounds ) == np .array (trimmed_bounds )), (
1432
1515
"Unexpected trimmed bounds"
1433
1516
)
1517
+
1518
+
1519
+ def test_flatten_shapely_geometries ():
1520
+ """Test the flatten_shapely_geometries utility function comprehensively."""
1521
+ # Test 1: Single polygon (should be wrapped in list and returned)
1522
+ single_polygon = Polygon ([(0 , 0 ), (1 , 0 ), (1 , 1 ), (0 , 1 )])
1523
+ result = flatten_shapely_geometries (single_polygon )
1524
+ assert len (result ) == 1
1525
+ assert result [0 ] == single_polygon
1526
+
1527
+ # Test 2: List of polygons (should return as-is)
1528
+ poly1 = Polygon ([(0 , 0 ), (1 , 0 ), (1 , 1 ), (0 , 1 )])
1529
+ poly2 = Polygon ([(2 , 0 ), (3 , 0 ), (3 , 1 ), (2 , 1 )])
1530
+ polygon_list = [poly1 , poly2 ]
1531
+ result = flatten_shapely_geometries (polygon_list )
1532
+ assert len (result ) == 2
1533
+ assert result == polygon_list
1534
+
1535
+ # Test 3: MultiPolygon (should be flattened)
1536
+ multi_polygon = MultiPolygon ([poly1 , poly2 ])
1537
+ result = flatten_shapely_geometries (multi_polygon )
1538
+ assert len (result ) == 2
1539
+ assert result [0 ] == poly1
1540
+ assert result [1 ] == poly2
1541
+
1542
+ # Test 4: Empty geometries (should be filtered out)
1543
+ empty_polygon = Polygon ()
1544
+ mixed_list = [poly1 , empty_polygon , poly2 ]
1545
+ result = flatten_shapely_geometries (mixed_list )
1546
+ assert len (result ) == 2
1547
+ assert empty_polygon not in result
1548
+
1549
+ # Test 5: GeometryCollection (should be recursively flattened)
1550
+ line = LineString ([(0 , 0 ), (1 , 1 )])
1551
+ point = Point (0 , 0 )
1552
+ collection = GeometryCollection ([poly1 , line , point , poly2 ])
1553
+ result = flatten_shapely_geometries (collection )
1554
+ assert len (result ) == 2 # Only polygons kept by default
1555
+ assert poly1 in result
1556
+ assert poly2 in result
1557
+
1558
+ # Test 6: Custom keep_types parameter
1559
+ result_with_lines = flatten_shapely_geometries (collection , keep_types = (Polygon , LineString ))
1560
+ assert len (result_with_lines ) == 3 # 2 polygons + 1 line
1561
+ assert poly1 in result_with_lines
1562
+ assert poly2 in result_with_lines
1563
+ assert line in result_with_lines
1564
+
1565
+ # Test 7: Nested collections and multi-geometries
1566
+ line1 = LineString ([(0 , 0 ), (1 , 1 )])
1567
+ line2 = LineString ([(2 , 2 ), (3 , 3 )])
1568
+ multi_line = MultiLineString ([line1 , line2 ])
1569
+ nested_collection = GeometryCollection (
1570
+ [
1571
+ collection , # Contains poly1, line, point, poly2
1572
+ multi_line ,
1573
+ poly1 ,
1574
+ ]
1575
+ )
1576
+ result = flatten_shapely_geometries (nested_collection )
1577
+ assert len (result ) == 3 # poly1 (from collection), poly2 (from collection), poly1 (direct)
1578
+
1579
+ # Test 8: MultiPoint (should be handled)
1580
+ point1 = Point (0 , 0 )
1581
+ point2 = Point (1 , 1 )
1582
+ multi_point = MultiPoint ([point1 , point2 ])
1583
+ result = flatten_shapely_geometries (multi_point , keep_types = (Point ,))
1584
+ assert len (result ) == 2
1585
+ assert point1 in result
1586
+ assert point2 in result
1587
+
1588
+ # Test 9: MultiLineString (should be handled)
1589
+ result = flatten_shapely_geometries (multi_line , keep_types = (LineString ,))
1590
+ assert len (result ) == 2
1591
+ assert line1 in result
1592
+ assert line2 in result
1593
+
1594
+ # Test 10: Mixed empty and non-empty geometries
1595
+ empty_multi = MultiPolygon ([])
1596
+ mixed_with_empty = [poly1 , empty_multi , empty_polygon , poly2 ]
1597
+ result = flatten_shapely_geometries (mixed_with_empty )
1598
+ assert len (result ) == 2
1599
+ assert poly1 in result
1600
+ assert poly2 in result
1601
+
1602
+ # Test 11: Deeply nested structure
1603
+ inner_collection = GeometryCollection ([poly1 , line ])
1604
+ outer_multi = MultiPolygon ([poly2 ])
1605
+ deep_collection = GeometryCollection ([inner_collection , outer_multi ])
1606
+ result = flatten_shapely_geometries (deep_collection )
1607
+ assert len (result ) == 2
1608
+ assert poly1 in result
1609
+ assert poly2 in result
1610
+
1611
+ # Test 12: All geometry types filtered out
1612
+ points_and_lines = GeometryCollection ([Point (0 , 0 ), LineString ([(0 , 0 ), (1 , 1 )])])
1613
+ result = flatten_shapely_geometries (points_and_lines ) # Default keeps only Polygons
1614
+ assert len (result ) == 0
1615
+
1616
+ # Test 13: Edge case - single empty geometry
1617
+ result = flatten_shapely_geometries (empty_polygon )
1618
+ assert len (result ) == 0
0 commit comments