1"""
2Tools for triangular grids.
3"""
4
5import numpy as np
6
7from matplotlib import _api
8from matplotlib.tri import Triangulation
9
10
11class TriAnalyzer:
12 """
13 Define basic tools for triangular mesh analysis and improvement.
14
15 A TriAnalyzer encapsulates a `.Triangulation` object and provides basic
16 tools for mesh analysis and mesh improvement.
17
18 Attributes
19 ----------
20 scale_factors
21
22 Parameters
23 ----------
24 triangulation : `~matplotlib.tri.Triangulation`
25 The encapsulated triangulation to analyze.
26 """
27
28 def __init__(self, triangulation):
29 _api.check_isinstance(Triangulation, triangulation=triangulation)
30 self._triangulation = triangulation
31
32 @property
33 def scale_factors(self):
34 """
35 Factors to rescale the triangulation into a unit square.
36
37 Returns
38 -------
39 (float, float)
40 Scaling factors (kx, ky) so that the triangulation
41 ``[triangulation.x * kx, triangulation.y * ky]``
42 fits exactly inside a unit square.
43 """
44 compressed_triangles = self._triangulation.get_masked_triangles()
45 node_used = (np.bincount(np.ravel(compressed_triangles),
46 minlength=self._triangulation.x.size) != 0)
47 return (1 / np.ptp(self._triangulation.x[node_used]),
48 1 / np.ptp(self._triangulation.y[node_used]))
49
50 def circle_ratios(self, rescale=True):
51 """
52 Return a measure of the triangulation triangles flatness.
53
54 The ratio of the incircle radius over the circumcircle radius is a
55 widely used indicator of a triangle flatness.
56 It is always ``<= 0.5`` and ``== 0.5`` only for equilateral
57 triangles. Circle ratios below 0.01 denote very flat triangles.
58
59 To avoid unduly low values due to a difference of scale between the 2
60 axis, the triangular mesh can first be rescaled to fit inside a unit
61 square with `scale_factors` (Only if *rescale* is True, which is
62 its default value).
63
64 Parameters
65 ----------
66 rescale : bool, default: True
67 If True, internally rescale (based on `scale_factors`), so that the
68 (unmasked) triangles fit exactly inside a unit square mesh.
69
70 Returns
71 -------
72 masked array
73 Ratio of the incircle radius over the circumcircle radius, for
74 each 'rescaled' triangle of the encapsulated triangulation.
75 Values corresponding to masked triangles are masked out.
76
77 """
78 # Coords rescaling
79 if rescale:
80 (kx, ky) = self.scale_factors
81 else:
82 (kx, ky) = (1.0, 1.0)
83 pts = np.vstack([self._triangulation.x*kx,
84 self._triangulation.y*ky]).T
85 tri_pts = pts[self._triangulation.triangles]
86 # Computes the 3 side lengths
87 a = tri_pts[:, 1, :] - tri_pts[:, 0, :]
88 b = tri_pts[:, 2, :] - tri_pts[:, 1, :]
89 c = tri_pts[:, 0, :] - tri_pts[:, 2, :]
90 a = np.hypot(a[:, 0], a[:, 1])
91 b = np.hypot(b[:, 0], b[:, 1])
92 c = np.hypot(c[:, 0], c[:, 1])
93 # circumcircle and incircle radii
94 s = (a+b+c)*0.5
95 prod = s*(a+b-s)*(a+c-s)*(b+c-s)
96 # We have to deal with flat triangles with infinite circum_radius
97 bool_flat = (prod == 0.)
98 if np.any(bool_flat):
99 # Pathologic flow
100 ntri = tri_pts.shape[0]
101 circum_radius = np.empty(ntri, dtype=np.float64)
102 circum_radius[bool_flat] = np.inf
103 abc = a*b*c
104 circum_radius[~bool_flat] = abc[~bool_flat] / (
105 4.0*np.sqrt(prod[~bool_flat]))
106 else:
107 # Normal optimized flow
108 circum_radius = (a*b*c) / (4.0*np.sqrt(prod))
109 in_radius = (a*b*c) / (4.0*circum_radius*s)
110 circle_ratio = in_radius/circum_radius
111 mask = self._triangulation.mask
112 if mask is None:
113 return circle_ratio
114 else:
115 return np.ma.array(circle_ratio, mask=mask)
116
117 def get_flat_tri_mask(self, min_circle_ratio=0.01, rescale=True):
118 """
119 Eliminate excessively flat border triangles from the triangulation.
120
121 Returns a mask *new_mask* which allows to clean the encapsulated
122 triangulation from its border-located flat triangles
123 (according to their :meth:`circle_ratios`).
124 This mask is meant to be subsequently applied to the triangulation
125 using `.Triangulation.set_mask`.
126 *new_mask* is an extension of the initial triangulation mask
127 in the sense that an initially masked triangle will remain masked.
128
129 The *new_mask* array is computed recursively; at each step flat
130 triangles are removed only if they share a side with the current mesh
131 border. Thus, no new holes in the triangulated domain will be created.
132
133 Parameters
134 ----------
135 min_circle_ratio : float, default: 0.01
136 Border triangles with incircle/circumcircle radii ratio r/R will
137 be removed if r/R < *min_circle_ratio*.
138 rescale : bool, default: True
139 If True, first, internally rescale (based on `scale_factors`) so
140 that the (unmasked) triangles fit exactly inside a unit square
141 mesh. This rescaling accounts for the difference of scale which
142 might exist between the 2 axis.
143
144 Returns
145 -------
146 array of bool
147 Mask to apply to encapsulated triangulation.
148 All the initially masked triangles remain masked in the
149 *new_mask*.
150
151 Notes
152 -----
153 The rationale behind this function is that a Delaunay
154 triangulation - of an unstructured set of points - sometimes contains
155 almost flat triangles at its border, leading to artifacts in plots
156 (especially for high-resolution contouring).
157 Masked with computed *new_mask*, the encapsulated
158 triangulation would contain no more unmasked border triangles
159 with a circle ratio below *min_circle_ratio*, thus improving the
160 mesh quality for subsequent plots or interpolation.
161 """
162 # Recursively computes the mask_current_borders, true if a triangle is
163 # at the border of the mesh OR touching the border through a chain of
164 # invalid aspect ratio masked_triangles.
165 ntri = self._triangulation.triangles.shape[0]
166 mask_bad_ratio = self.circle_ratios(rescale) < min_circle_ratio
167
168 current_mask = self._triangulation.mask
169 if current_mask is None:
170 current_mask = np.zeros(ntri, dtype=bool)
171 valid_neighbors = np.copy(self._triangulation.neighbors)
172 renum_neighbors = np.arange(ntri, dtype=np.int32)
173 nadd = -1
174 while nadd != 0:
175 # The active wavefront is the triangles from the border (unmasked
176 # but with a least 1 neighbor equal to -1
177 wavefront = (np.min(valid_neighbors, axis=1) == -1) & ~current_mask
178 # The element from the active wavefront will be masked if their
179 # circle ratio is bad.
180 added_mask = wavefront & mask_bad_ratio
181 current_mask = added_mask | current_mask
182 nadd = np.sum(added_mask)
183
184 # now we have to update the tables valid_neighbors
185 valid_neighbors[added_mask, :] = -1
186 renum_neighbors[added_mask] = -1
187 valid_neighbors = np.where(valid_neighbors == -1, -1,
188 renum_neighbors[valid_neighbors])
189
190 return np.ma.filled(current_mask, True)
191
192 def _get_compressed_triangulation(self):
193 """
194 Compress (if masked) the encapsulated triangulation.
195
196 Returns minimal-length triangles array (*compressed_triangles*) and
197 coordinates arrays (*compressed_x*, *compressed_y*) that can still
198 describe the unmasked triangles of the encapsulated triangulation.
199
200 Returns
201 -------
202 compressed_triangles : array-like
203 the returned compressed triangulation triangles
204 compressed_x : array-like
205 the returned compressed triangulation 1st coordinate
206 compressed_y : array-like
207 the returned compressed triangulation 2nd coordinate
208 tri_renum : int array
209 renumbering table to translate the triangle numbers from the
210 encapsulated triangulation into the new (compressed) renumbering.
211 -1 for masked triangles (deleted from *compressed_triangles*).
212 node_renum : int array
213 renumbering table to translate the point numbers from the
214 encapsulated triangulation into the new (compressed) renumbering.
215 -1 for unused points (i.e. those deleted from *compressed_x* and
216 *compressed_y*).
217
218 """
219 # Valid triangles and renumbering
220 tri_mask = self._triangulation.mask
221 compressed_triangles = self._triangulation.get_masked_triangles()
222 ntri = self._triangulation.triangles.shape[0]
223 if tri_mask is not None:
224 tri_renum = self._total_to_compress_renum(~tri_mask)
225 else:
226 tri_renum = np.arange(ntri, dtype=np.int32)
227
228 # Valid nodes and renumbering
229 valid_node = (np.bincount(np.ravel(compressed_triangles),
230 minlength=self._triangulation.x.size) != 0)
231 compressed_x = self._triangulation.x[valid_node]
232 compressed_y = self._triangulation.y[valid_node]
233 node_renum = self._total_to_compress_renum(valid_node)
234
235 # Now renumbering the valid triangles nodes
236 compressed_triangles = node_renum[compressed_triangles]
237
238 return (compressed_triangles, compressed_x, compressed_y, tri_renum,
239 node_renum)
240
241 @staticmethod
242 def _total_to_compress_renum(valid):
243 """
244 Parameters
245 ----------
246 valid : 1D bool array
247 Validity mask.
248
249 Returns
250 -------
251 int array
252 Array so that (`valid_array` being a compressed array
253 based on a `masked_array` with mask ~*valid*):
254
255 - For all i with valid[i] = True:
256 valid_array[renum[i]] = masked_array[i]
257 - For all i with valid[i] = False:
258 renum[i] = -1 (invalid value)
259 """
260 renum = np.full(np.size(valid), -1, dtype=np.int32)
261 n_valid = np.sum(valid)
262 renum[valid] = np.arange(n_valid, dtype=np.int32)
263 return renum