11"""
22Operations for making a triangular mesh out of the polygons of a dataset.
33"""
4- from typing import cast
5-
64import numpy
5+ import pandas
6+ import shapely
77import xarray
88from shapely .geometry import LineString , MultiPoint , Polygon
99
1010Vertex = tuple [float , float ]
11- Triangle = tuple [int , int , int ]
11+ VertexTriangle = tuple [Vertex , Vertex , Vertex ]
12+ IndexTriangle = tuple [int , int , int ]
1213
1314
1415def triangulate_dataset (
1516 dataset : xarray .Dataset ,
16- ) -> tuple [list [ Vertex ], list [ Triangle ], list [ int ] ]:
17+ ) -> tuple [numpy . ndarray , numpy . ndarray , numpy . ndarray ]:
1718 """
1819 Triangulate the polygon cells of a dataset
1920
@@ -28,17 +29,22 @@ def triangulate_dataset(
2829
2930 Returns
3031 -------
31- tuple of vertices, triangles, and cell indexes.
32- A tuple of three lists is returned,
32+ tuple of vertices, triangles, and `cell_indices`
33+ A tuple of three numpy arrays is returned,
3334 containing vertices, triangles, and cell indexes respectively.
3435
35- Each vertex is a tuple of (x, y) or (lon, lat) coordinates.
36+ `vertices` is a numpy array of shape (V, 2)
37+ where V is the number of unique vertices in the dataset.
38+ The vertex coordinates are in (x, y) or (lon, lat) order.
39+
40+ `triangles` is a numpy array of shape (T, 3)
41+ where T is the number of triangles in the dataset.
42+ Each triangle is a set of three vertex indices.
43+
44+ `cell_indices` is a numpy list of length T.
45+ Each entry indicates which polygon from the dataset a triangle is a part of.
3646
37- Each triangle is a tuple of three integers,
38- indicating which vertices make up the triangle.
3947
40- The cell indexes tie the triangles to the original cell polygon,
41- allowing you to plot data on the triangle mesh.
4248
4349 Examples
4450 --------
@@ -87,79 +93,160 @@ def triangulate_dataset(
8793 """
8894 polygons = dataset .ems .polygons
8995
90- # Getting all the vertices is easy - extract them from the polygons.
91- # By going through a set, this will deduplicate the vertices.
92- # Back to a list and we have a stable order
93- vertices : list [Vertex ] = list ({
94- vertex
95- for polygon in polygons
96- if polygon is not None
97- for vertex in polygon .exterior .coords
98- })
99-
100- # This maps between a vertex tuple and its index.
101- # Vertex positions are (probably) floats. For grid datasets, where cells
102- # are implicitly defined by their centres, be careful to compute cell
103- # vertices in consistent ways. Float equality is tricky!
104- vertex_indexes = {vertex : index for index , vertex in enumerate (vertices )}
105-
106- # Each cell polygon needs to be triangulated,
107- # while also recording the convention native index of the cell,
108- # so that we can later correlate cell data with the triangles.
109- polygons_with_index = [
110- (polygon , index )
111- for index , polygon in enumerate (polygons )
112- if polygon is not None ]
113- triangles_with_index = list (
114- (tuple (vertex_indexes [vertex ] for vertex in triangle_coords ), dataset_index )
115- for polygon , dataset_index in polygons_with_index
116- for triangle_coords in _triangulate_polygon (polygon )
117- )
118- triangles : list [Triangle ] = [tri for tri , index in triangles_with_index ] # type: ignore
119- indexes = [index for tri , index in triangles_with_index ]
120-
121- return (vertices , triangles , indexes )
122-
123-
124- def _triangulate_polygon (polygon : Polygon ) -> list [tuple [Vertex , Vertex , Vertex ]]:
96+ # Find all the unique coordinates and assign them each a unique index
97+ all_coords = shapely .get_coordinates (polygons )
98+ vertex_index = pandas .MultiIndex .from_arrays (all_coords .T ).drop_duplicates ()
99+ vertex_series = pandas .Series (numpy .arange (len (vertex_index )), index = vertex_index )
100+ vertex_coords = numpy .array (vertex_index .to_list ())
101+
102+ polygon_length = shapely .get_num_coordinates (polygons )
103+
104+ # Count the total number of triangles.
105+ # A polygon with n sides can be decomposed in to n-2 triangles,
106+ # however polygon_length counts an extra vertex because of the closed rings.
107+ total_triangles = numpy .sum (polygon_length [numpy .nonzero (polygon_length )] - 3 )
108+
109+ # Pre-allocate some numpy arrays that will store the triangle data.
110+ # This is much faster than building up these data structures iteratively.
111+ face_indices = numpy .empty (total_triangles , dtype = int )
112+ triangle_coords = numpy .empty ((total_triangles , 3 , 2 ), dtype = float )
113+ # This is the index of which face we will populate next in the above arrays
114+ current_face = 0
115+
116+ def _add_triangles (face_index : int , vertex_triangles : numpy .ndarray ) -> None :
117+ """
118+ Append some triangles to the face_indices and triangle_coords arrays.
119+
120+ Parameters
121+ ----------
122+ face_index : int
123+ The face index of all the triangles
124+ vertex_triangles : numpy.ndarray
125+ The triangles for this face as an (n, 3, 2) array,
126+ where n is the number of triangles in the face.
127+ """
128+ nonlocal current_face
129+ current_length = len (vertex_triangles )
130+ face_indices [current_face :current_face + current_length ] = face_index
131+ triangle_coords [current_face :current_face + current_length ] = vertex_triangles
132+ current_face += current_length
133+
134+ # Find all concave polygons by comparing each polygon to its convex hull.
135+ # A convex polygon is its own convex hull,
136+ # while the convex hull of a concave polygon
137+ # will always have fewer vertices than the original polygon.
138+ # Comparing the number of vertices is a shortcut.
139+ convex_hulls = shapely .convex_hull (polygons )
140+ convex_hull_length = shapely .get_num_coordinates (convex_hulls )
141+ polygon_is_concave = numpy .flatnonzero (convex_hull_length != polygon_length )
142+
143+ # Categorize each polygon by length, skipping concave polygons.
144+ # We will handle them separately.
145+ polygon_length [polygon_is_concave ] = 0
146+ unique_lengths = numpy .unique (polygon_length )
147+
148+ # Triangulate polygons in batches of identical sizes.
149+ # This allows the coordinates to be manipulated efficiently.
150+ for unique_length in unique_lengths :
151+ if unique_length == 0 :
152+ # Any `None` polygons will have a length of 0,
153+ # and any concave polygons have been set to 0.
154+ continue
155+
156+ same_length_face_indices = numpy .flatnonzero (polygon_length == unique_length )
157+ same_length_polygons = polygons [same_length_face_indices ]
158+ vertex_triangles = _triangulate_polygons_by_length (same_length_polygons )
159+
160+ for face_index , triangles in zip (same_length_face_indices , vertex_triangles ):
161+ _add_triangles (face_index , triangles )
162+
163+ # Triangulate each concave polygon using a slower manual method.
164+ # Anecdotally concave polygons are very rare,
165+ # so using a slower method isn't an issue.
166+ for face_index in polygon_is_concave :
167+ polygon = polygons [face_index ]
168+ triangles = _triangulate_concave_polygon (polygon )
169+ _add_triangles (face_index , triangles )
170+
171+ # Check that we have handled each triangle we expected.
172+ assert current_face == total_triangles
173+
174+ # Make a DataFrame. By manually constructing Series the data in the
175+ # underlying numpy arrays will be used in place.
176+ face_triangle_df = pandas .DataFrame ({
177+ 'face_indices' : pandas .Series (face_indices ),
178+ 'x0' : pandas .Series (triangle_coords [:, 0 , 0 ]),
179+ 'y0' : pandas .Series (triangle_coords [:, 0 , 1 ]),
180+ 'x1' : pandas .Series (triangle_coords [:, 1 , 0 ]),
181+ 'y1' : pandas .Series (triangle_coords [:, 1 , 1 ]),
182+ 'x2' : pandas .Series (triangle_coords [:, 2 , 0 ]),
183+ 'y2' : pandas .Series (triangle_coords [:, 2 , 1 ]),
184+ }, copy = False )
185+
186+ joined_df = face_triangle_df \
187+ .join (vertex_series .rename ('v0' ), on = ['x0' , 'y0' ])\
188+ .join (vertex_series .rename ('v1' ), on = ['x1' , 'y1' ])\
189+ .join (vertex_series .rename ('v2' ), on = ['x2' , 'y2' ])
190+
191+ faces = joined_df ['face_indices' ].to_numpy ()
192+ triangles = joined_df [['v0' , 'v1' , 'v2' ]].to_numpy ()
193+
194+ return vertex_coords , triangles , faces
195+
196+
197+ def _triangulate_polygons_by_length (polygons : numpy .ndarray ) -> numpy .ndarray :
125198 """
126- Triangulate a polygon .
199+ Triangulate a list of convex polygons of equal length .
127200
128- .. note::
201+ Parameters
202+ ----------
203+ polygons : numpy.ndarray of shapely.Polygon
204+ The polygons to triangulate.
205+ These must all have the same number of vertices
206+ and must all be convex.
129207
130- This currently only supports simple polygons - polygons that do not
131- intersect themselves and do not have holes.
208+ Returns
209+ -------
210+ numpy.ndarray
211+ A numpy array of shape (# polygons, # triangles, 3, 2),
212+ where `# polygons` is the length of `polygons`
213+ and `# triangles` is the number of triangles each polygon is decomposed in to.
214+ """
215+ vertex_count = len (polygons [0 ].exterior .coords ) - 1
216+
217+ # An array of shape (len(polygons), vertex_count, 2)
218+ coordinates = shapely .get_coordinates (shapely .get_exterior_ring (polygons ))
219+ coordinates = coordinates .reshape ((len (polygons ), vertex_count + 1 , 2 ))
220+ coordinates = coordinates [:, :- 1 , :]
221+
222+ # Arrays of shape (len(polygons), vertex_count - 2, 2)
223+ v0 = numpy .repeat (
224+ coordinates [:, 0 , :].reshape ((- 1 , 1 , 2 )),
225+ repeats = vertex_count - 2 ,
226+ axis = 1 )
227+ v1 = coordinates [:, 1 :- 1 ]
228+ v2 = coordinates [:, 2 :]
229+
230+ # An array of shape (len(polygons), vertex_count - 2, 3, 2)
231+ triangles : numpy .ndarray = numpy .stack ([v0 , v1 , v2 ], axis = 2 )
232+ return triangles
132233
133- Examples
134- --------
135234
136- .. code-block:: python
235+ def _triangulate_concave_polygon (polygon : Polygon ) -> numpy .ndarray :
236+ """
237+ Triangulate a single convex polygon.
137238
138- >>> polygon = Polygon([(0, 0), (2, 0), (2, 2), (1, 3), (0, 2), (0, 0)])
139- >>> for triangle in triangulate_polygon(polygon):
140- ... print(triangle.wkt)
141- POLYGON ((0 0, 2 0, 2 2, 0 0))
142- POLYGON ((0 0, 2 2, 1 3, 0 0))
143- POLYGON ((0 0, 1 3, 0 2, 0 0))
239+ Parameters
240+ ----------
241+ polygon : shapely.Polygon
242+ The polygon to triangulate.
144243
145- See Also
146- --------
147- :func:`triangulate_dataset`,
148- `Polygon triangulation <https://en.wikipedia.org/wiki/Polygon_triangulation>`_
244+ Returns
245+ -------
246+ numpy.ndarray
247+ A numpy array of shape (# triangles, 3, 2),
248+ where `# triangles` is the number of triangles the polygon is decomposed in to.
149249 """
150- # The 'ear clipping' method used below is correct for all polygons, but not
151- # performant. If the polygon is convex we can use a shortcut method.
152- if polygon .equals (polygon .convex_hull ):
153- # Make a fan triangulation. For a polygon with n vertices the triangles
154- # will have vertices:
155- # (1, 2, 3), (1, 3, 4), (1, 4, 5), ... (1, n-1, n)
156- exterior_vertices = numpy .array (polygon .exterior .coords )[:- 1 ]
157- num_triangles = len (exterior_vertices ) - 2
158- v0 = numpy .broadcast_to (exterior_vertices [0 ], (num_triangles , 2 ))
159- v1 = exterior_vertices [1 :- 1 ]
160- v2 = exterior_vertices [2 :]
161- return list (zip (map (tuple , v0 ), map (tuple , v1 ), map (tuple , v2 )))
162-
163250 # This is the 'ear clipping' method of polygon triangulation.
164251 # In any simple polygon, there is guaranteed to be at least two 'ears'
165252 # - three neighbouring vertices whos diagonal is inside the polygon.
@@ -171,7 +258,12 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
171258 # Most polygons will be either squares, convex quadrilaterals, or convex
172259 # polygons.
173260
174- triangles : list [tuple [Vertex , Vertex , Vertex ]] = []
261+ # A triangle with n vertices will have n - 2 triangles.
262+ # Because the exterior is a closed loop, we need to subtract 3.
263+ triangle_count = len (polygon .exterior .coords ) - 3
264+ triangles = numpy .empty ((triangle_count , 3 , 2 ))
265+ triangle_index = 0
266+
175267 # Note that shapely polygons with n vertices will be closed, and thus have
176268 # n+1 coordinates. We trim that superfluous coordinate off in the next line
177269 while len (polygon .exterior .coords ) > 4 :
@@ -192,7 +284,8 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
192284 # that ear will result in two disconnected polygons.
193285 and exterior .intersection (diagonal ).equals (multipoint )
194286 ):
195- triangles .append ((coords [i ], coords [i + 1 ], coords [i + 2 ]))
287+ triangles [triangle_index ] = coords [i :i + 3 ]
288+ triangle_index += 1
196289 polygon = Polygon (coords [:i + 1 ] + coords [i + 2 :])
197290 break
198291 else :
@@ -201,8 +294,7 @@ def _triangulate_polygon(polygon: Polygon) -> list[tuple[Vertex, Vertex, Vertex]
201294 f"Could not find interior diagonal for polygon! { polygon .wkt } " )
202295
203296 # The trimmed polygon is now a triangle. Add it to the list and we are done!
297+ triangles [triangle_index ] = polygon .exterior .coords [:- 1 ]
298+ assert (triangle_index + 1 ) == triangle_count
204299
205- triangles .append (cast (
206- tuple [Vertex , Vertex , Vertex ],
207- tuple (map (tuple , polygon .exterior .coords [:- 1 ]))))
208300 return triangles
0 commit comments