1212import pytest
1313import shapely
1414import trimesh
15+ from shapely .geometry import (
16+ GeometryCollection ,
17+ LineString ,
18+ MultiLineString ,
19+ MultiPoint ,
20+ MultiPolygon ,
21+ Point ,
22+ Polygon ,
23+ )
1524
1625import tidy3d as td
1726from tidy3d .compat import _shapely_is_older_than
2231 SnapLocation ,
2332 SnappingSpec ,
2433 flatten_groups ,
34+ flatten_shapely_geometries ,
2535 snap_box_to_grid ,
2636 traverse_geometries ,
2737)
@@ -1137,7 +1147,14 @@ def test_subdivide():
11371147@pytest .mark .parametrize ("snap_location" , [SnapLocation .Boundary , SnapLocation .Center ])
11381148@pytest .mark .parametrize (
11391149 "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+ ],
11411158)
11421159def test_snap_box_to_grid (snap_location , snap_behavior ):
11431160 """ "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):
11581175 new_box = snap_box_to_grid (grid , box , snap_spec )
11591176
11601177 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 ]
11671250
11681251
11691252def test_triangulation_with_collinear_vertices ():
@@ -1431,3 +1514,105 @@ def test_trim_dims_and_bounds_edge():
14311514 assert np .all (np .array (expected_trimmed_bounds ) == np .array (trimmed_bounds )), (
14321515 "Unexpected trimmed bounds"
14331516 )
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