bluemesh2d 0.1.1.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. bluemesh2d/_version.py +24 -0
  2. bluemesh2d/aabb_tree/findball.py +121 -0
  3. bluemesh2d/aabb_tree/findtria.py +299 -0
  4. bluemesh2d/aabb_tree/maketree.py +147 -0
  5. bluemesh2d/aabb_tree/mapvert.py +72 -0
  6. bluemesh2d/aabb_tree/queryset.py +83 -0
  7. bluemesh2d/aabb_tree/scantree.py +124 -0
  8. bluemesh2d/dependencies.py +83 -0
  9. bluemesh2d/feedback.py +142 -0
  10. bluemesh2d/geom_util/boundary_util.py +216 -0
  11. bluemesh2d/geom_util/getiso.py +194 -0
  12. bluemesh2d/geom_util/poly_util.py +795 -0
  13. bluemesh2d/geom_util/proj_util.py +250 -0
  14. bluemesh2d/geomesh_util/border_util.py +283 -0
  15. bluemesh2d/geomesh_util/depth_field.py +333 -0
  16. bluemesh2d/geomesh_util/grd_util.py +1000 -0
  17. bluemesh2d/geomesh_util/interpolation_mesh.py +318 -0
  18. bluemesh2d/geomesh_util/merge_circumcenters.py +268 -0
  19. bluemesh2d/geomesh_util/water_polygon.py +406 -0
  20. bluemesh2d/hfun_util/build_hfun.py +589 -0
  21. bluemesh2d/hfun_util/hfun_dispersion.py +96 -0
  22. bluemesh2d/hfun_util/lfshfn.py +110 -0
  23. bluemesh2d/hfun_util/limhfn.py +65 -0
  24. bluemesh2d/hfun_util/make_constant_hfun.py +32 -0
  25. bluemesh2d/hfun_util/make_depth_hfun.py +51 -0
  26. bluemesh2d/hfun_util/smooth_and_precomput.py +188 -0
  27. bluemesh2d/hfun_util/trihfn.py +84 -0
  28. bluemesh2d/hjac_util/limgrad.py +105 -0
  29. bluemesh2d/mesh_ball/cdtbal1.py +38 -0
  30. bluemesh2d/mesh_ball/cdtbal2.py +104 -0
  31. bluemesh2d/mesh_ball/inv_2x2.py +61 -0
  32. bluemesh2d/mesh_ball/inv_3x3.py +72 -0
  33. bluemesh2d/mesh_ball/pwrbal2.py +134 -0
  34. bluemesh2d/mesh_ball/tribal2.py +27 -0
  35. bluemesh2d/mesh_cost/relhfn.py +62 -0
  36. bluemesh2d/mesh_cost/triang.py +59 -0
  37. bluemesh2d/mesh_cost/triarea.py +51 -0
  38. bluemesh2d/mesh_cost/trideg.py +44 -0
  39. bluemesh2d/mesh_cost/triscr.py +43 -0
  40. bluemesh2d/mesh_file/bnd_util.py +664 -0
  41. bluemesh2d/mesh_file/loadmsh.py +165 -0
  42. bluemesh2d/mesh_file/ugrid.py +308 -0
  43. bluemesh2d/mesh_util/cfmtri.py +118 -0
  44. bluemesh2d/mesh_util/deltri.py +131 -0
  45. bluemesh2d/mesh_util/idxtri.py +53 -0
  46. bluemesh2d/mesh_util/isfeat.py +90 -0
  47. bluemesh2d/mesh_util/minlen.py +46 -0
  48. bluemesh2d/mesh_util/setset.py +49 -0
  49. bluemesh2d/mesh_util/tricon.py +90 -0
  50. bluemesh2d/mesh_util/tridiv.py +187 -0
  51. bluemesh2d/meshgen.py +202 -0
  52. bluemesh2d/ortho_merge/constants.py +27 -0
  53. bluemesh2d/ortho_merge/geometry.py +64 -0
  54. bluemesh2d/ortho_merge/ortho_merge_iter.py +812 -0
  55. bluemesh2d/ortho_merge/orthogonalize.py +2989 -0
  56. bluemesh2d/pipeline.py +277 -0
  57. bluemesh2d/poly_data/airfoil.msh +958 -0
  58. bluemesh2d/poly_data/channel.msh +212 -0
  59. bluemesh2d/poly_data/islands.msh +13819 -0
  60. bluemesh2d/poly_data/lake.msh +612 -0
  61. bluemesh2d/poly_data/river.msh +690 -0
  62. bluemesh2d/poly_test/inpoly.py +105 -0
  63. bluemesh2d/poly_test/inpoly_mat.py +104 -0
  64. bluemesh2d/refine.py +972 -0
  65. bluemesh2d/smood.py +623 -0
  66. bluemesh2d/smooth.py +522 -0
  67. bluemesh2d/tricost.py +426 -0
  68. bluemesh2d/tridemo.py +723 -0
  69. bluemesh2d/triread.py +53 -0
  70. bluemesh2d-0.1.1.dev0.dist-info/METADATA +139 -0
  71. bluemesh2d-0.1.1.dev0.dist-info/RECORD +74 -0
  72. bluemesh2d-0.1.1.dev0.dist-info/WHEEL +5 -0
  73. bluemesh2d-0.1.1.dev0.dist-info/licenses/LICENSE +674 -0
  74. bluemesh2d-0.1.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,406 @@
1
+ """Stage 1: bathymetry raster -> water-domain polygon (working CRS)."""
2
+ from __future__ import annotations
3
+
4
+
5
+ from ..feedback import (
6
+ _NullFeedback,
7
+ _available_ram_bytes,
8
+ _check,
9
+ _warn_if_ram_risk,
10
+ )
11
+ from ..geom_util.proj_util import _raster_crs, bundled_raster_data_env
12
+
13
+
14
+ def _flag_fixed_vertices(p, ext_boundary, tol):
15
+ """Return a PolygonZ copy of ``p`` with Z=1 on vertices on ``ext_boundary``.
16
+
17
+ Vertices farther than ``tol`` from the extent boundary get Z=0. The Z
18
+ flag travels inside the polygon geometry to stage 3, which keeps the
19
+ flagged vertices fixed while resampling. The first and last vertex of
20
+ each fixed run (the junctions with the coastline) are additionally
21
+ projected exactly onto the extent boundary -- a sub-``tol`` move -- so
22
+ the run ends at the true intersection; no other vertex is moved.
23
+ """
24
+ import numpy as np
25
+ from shapely.geometry import Point, Polygon
26
+
27
+ def ring3(coords):
28
+ pts = np.asarray(coords, dtype=float)[:, :2]
29
+ closed = len(pts) > 1 and np.allclose(pts[0], pts[-1])
30
+ core = pts[:-1] if closed else pts
31
+ n = len(core)
32
+ flag = np.array(
33
+ [ext_boundary.distance(Point(x, y)) <= tol for x, y in core])
34
+ out = [(x, y, 1.0 if f else 0.0)
35
+ for (x, y), f in zip(core, flag)]
36
+ if n >= 2:
37
+ for i in range(n):
38
+ if flag[i] and (not flag[i - 1] or not flag[(i + 1) % n]):
39
+ q = ext_boundary.interpolate(
40
+ ext_boundary.project(Point(core[i])))
41
+ out[i] = (q.x, q.y, 1.0)
42
+ if closed:
43
+ out.append(out[0])
44
+ return out
45
+
46
+ return Polygon(ring3(p.exterior.coords),
47
+ [ring3(r.coords) for r in p.interiors])
48
+
49
+
50
+ def _corner_vertices(geom, min_deviation_deg=30.0):
51
+ """Vertices of a polygon outline where the direction turns sharply.
52
+
53
+ Used for the generated (buffered raster extent) clip domain, which has
54
+ no user-drawn vertices: its geometric corners -- where the boundary
55
+ direction deviates by at least ``min_deviation_deg`` -- play the role
56
+ the extent polygon's original vertices play, and are kept fixed.
57
+ """
58
+ import numpy as np
59
+
60
+ corners = []
61
+ polys = geom.geoms if hasattr(geom, "geoms") else [geom]
62
+ for p in polys:
63
+ if p.geom_type != "Polygon":
64
+ continue
65
+ for ring in [p.exterior] + list(p.interiors):
66
+ pts = np.asarray(ring.coords, dtype=float)[:, :2]
67
+ if len(pts) > 1 and np.allclose(pts[0], pts[-1]):
68
+ pts = pts[:-1]
69
+ if len(pts) < 3:
70
+ continue
71
+ prev = np.roll(pts, 1, axis=0)
72
+ nxt = np.roll(pts, -1, axis=0)
73
+ a1 = np.arctan2(*(pts - prev).T[::-1])
74
+ a2 = np.arctan2(*(nxt - pts).T[::-1])
75
+ dev = np.abs((a2 - a1 + np.pi) % (2.0 * np.pi) - np.pi)
76
+ corners.extend(pts[dev >= np.radians(min_deviation_deg)])
77
+ return np.asarray(corners, dtype=float).reshape(-1, 2)
78
+
79
+
80
+ def _valid_parts(parts):
81
+ """Repair invalid polygon parts (2D) and flatten any multi results.
82
+
83
+ The raw ``coast ∩ domain`` intersection can be invalid (self-touching
84
+ rings); it must be repaired BEFORE the Z flags are attached, because the
85
+ usual repair (``buffer(0)``) drops Z coordinates.
86
+ """
87
+ out = []
88
+ for p in parts:
89
+ if not p.is_valid:
90
+ p = p.buffer(0)
91
+ if p.geom_type == "Polygon":
92
+ if not p.is_empty:
93
+ out.append(p)
94
+ elif hasattr(p, "geoms"):
95
+ out.extend(g for g in p.geoms
96
+ if g.geom_type == "Polygon" and not g.is_empty)
97
+ return out
98
+
99
+
100
+ def _prune_nonoriginal_fixed(p, keep_xy, tol=1e-3, unflag_only=False):
101
+ """Demote fixed (Z=1) vertices that are neither run endpoints nor listed.
102
+
103
+ ``keep_xy`` holds the extent polygon's *original* vertex coordinates
104
+ (working CRS). With ``unflag_only=False`` (extent-polygon case) the other
105
+ fixed vertices -- densification points sitting on straight extent edges
106
+ -- are deleted from the ring; the output keeps just the original extent
107
+ vertices plus the coastline-junction points of each fixed run. With
108
+ ``unflag_only=True`` (generated/buffered domain, whose shape lives in
109
+ those vertices) they are kept geometrically but set free (Z=0), so only
110
+ the coastline junctions stay fixed. Free (Z=0) vertices are never
111
+ touched.
112
+ """
113
+ import numpy as np
114
+ from shapely.geometry import Polygon
115
+
116
+ keep_xy = np.asarray(keep_xy, dtype=float).reshape(-1, 2)
117
+
118
+ def ring3(coords):
119
+ pts = np.asarray(coords, dtype=float)
120
+ closed = len(pts) > 1 and np.allclose(pts[0], pts[-1])
121
+ core = pts[:-1] if closed else pts
122
+ n = len(core)
123
+ flag = core[:, 2] > 0.5
124
+ keep = np.ones(n, dtype=bool)
125
+ for i in range(n):
126
+ if not flag[i]:
127
+ continue
128
+ if not flag[i - 1] or not flag[(i + 1) % n]:
129
+ continue # run endpoint: coastline junction, always kept
130
+ if keep_xy.size:
131
+ d2 = np.sum((keep_xy - core[i, :2]) ** 2, axis=1)
132
+ if d2.min() <= tol * tol:
133
+ continue # original extent vertex, kept
134
+ if unflag_only:
135
+ core[i, 2] = 0.0
136
+ else:
137
+ keep[i] = False
138
+ core = core[keep]
139
+ out = [tuple(c) for c in core]
140
+ if closed and out:
141
+ out.append(out[0])
142
+ return out
143
+
144
+ return Polygon(ring3(p.exterior.coords),
145
+ [ring3(r.coords) for r in p.interiors])
146
+
147
+
148
+ def _contour_read_budget():
149
+ """Max grid cells to read for contouring, sized to available RAM.
150
+
151
+ Contour extraction works on float copies (~4x the band), so the peak is
152
+ ~16 bytes/cell; keep it under ~15% of available RAM, with a hard ceiling
153
+ so contourpy stays responsive and a floor so small machines still work.
154
+ """
155
+ avail = _available_ram_bytes() or 4_000_000_000
156
+ return int(min(120_000_000, max(4_000_000, 0.15 * avail / 16.0)))
157
+
158
+
159
+ def _read_band_for_contour(src, bbox, max_cells):
160
+ """Read band 1 clipped to ``bbox`` and decimated to fit ``max_cells``.
161
+
162
+ Only the requested window is read (never the full raster), and it is
163
+ down-sampled on read when still too large -- so an arbitrarily large
164
+ bathymetry raster is contoured without materialising it in memory.
165
+
166
+ Parameters
167
+ ----------
168
+ src : rasterio dataset
169
+ bbox : tuple or None
170
+ ``(xmin, ymin, xmax, ymax)`` in the raster CRS, or ``None`` for the
171
+ whole raster.
172
+ max_cells : int
173
+ Upper bound on the number of cells actually read.
174
+
175
+ Returns
176
+ -------
177
+ zdat : ndarray (rows, cols)
178
+ lon, lat : ndarray
179
+ Pixel x/y coordinates of the columns/rows (raster CRS).
180
+ step : int
181
+ Decimation factor applied (1 = full resolution of the window).
182
+ clipped : bool
183
+ Whether a bbox window was used.
184
+ """
185
+ import numpy as np
186
+ from rasterio.transform import Affine
187
+ from rasterio.windows import Window
188
+
189
+ W, H = src.width, src.height
190
+ if bbox is not None:
191
+ xmin, ymin, xmax, ymax = bbox
192
+ inv = ~src.transform
193
+ cols, rows = [], []
194
+ for x in (xmin, xmax):
195
+ for y in (ymin, ymax):
196
+ c, r = inv * (x, y)
197
+ cols.append(c)
198
+ rows.append(r)
199
+ col_off = max(0, int(np.floor(min(cols))) - 2)
200
+ row_off = max(0, int(np.floor(min(rows))) - 2)
201
+ col_end = min(W, int(np.ceil(max(cols))) + 2)
202
+ row_end = min(H, int(np.ceil(max(rows))) + 2)
203
+ if col_end <= col_off or row_end <= row_off:
204
+ raise RuntimeError("The extent polygon does not overlap the raster.")
205
+ else:
206
+ col_off, row_off, col_end, row_end = 0, 0, W, H
207
+
208
+ win = Window(col_off, row_off, col_end - col_off, row_end - row_off)
209
+ win_w, win_h = int(win.width), int(win.height)
210
+ step = 1
211
+ if win_w * win_h > max_cells:
212
+ step = int(np.ceil((win_w * win_h / float(max_cells)) ** 0.5))
213
+ out_w = max(1, win_w // step)
214
+ out_h = max(1, win_h // step)
215
+
216
+ zdat = src.read(1, window=win, out_shape=(out_h, out_w))
217
+ t = src.window_transform(win) * Affine.scale(win_w / out_w, win_h / out_h)
218
+ # rasterio transforms are corner-referenced (t*(0,0) = top-left corner of
219
+ # the first cell), but the contouring places zdat[i,j] AT (lon[j], lat[i]).
220
+ # For Area rasters the value is the cell CENTRE, so offset by half a cell
221
+ # (Point rasters: offset 0) to avoid shifting the extracted coastline.
222
+ from bluemesh2d.geomesh_util.depth_field import _cell_center_offset
223
+ off = _cell_center_offset(src)
224
+ lon = (t * (np.arange(out_w) + off, np.zeros(out_w)))[0]
225
+ lat = (t * (np.zeros(out_h), np.arange(out_h) + off))[1]
226
+ return zdat, lon, lat, step, (bbox is not None)
227
+
228
+
229
+ def extract_water_polygon(raster_path, coast_zmax=2.0, domain_buffer=-0.05,
230
+ deep_zmax=None, extent_geom=None, keep_largest=True,
231
+ return_domain=False, invert_z=False,
232
+ nodata_value=None, feedback=None):
233
+ """Extract the water domain from a raster.
234
+
235
+ The water region is ``z <= coast_zmax``; if `deep_zmax` is given (an
236
+ elevation, e.g. -300), only the *band* between the two levels is kept
237
+ (``deep_zmax < z <= coast_zmax``). The domain extent is `extent_geom`
238
+ (a shapely polygon in the raster CRS) if provided -- the raster is then
239
+ clipped to it *before* the contour extraction and `domain_buffer` is
240
+ ignored -- otherwise the raster's data extent, grown (>0) or shrunk (<0)
241
+ by `domain_buffer` with ``buffer_area``. With `keep_largest` (default),
242
+ only the single largest water polygon is returned, dropping small
243
+ disconnected water bodies.
244
+
245
+ Parameters
246
+ ----------
247
+ raster_path : str
248
+ Path to the bathymetry raster (elevation, positive up).
249
+ coast_zmax : float, optional
250
+ Wet threshold (m); the water region is ``z <= coast_zmax``. Default
251
+ is 2.0.
252
+ domain_buffer : float, optional
253
+ Buffer factor applied to the raster's data extent (>0 grows, <0
254
+ shrinks), ignored when `extent_geom` is given. Default is -0.05.
255
+ deep_zmax : float or None, optional
256
+ Deep elevation level (m); if given, only the band
257
+ ``deep_zmax < z <= coast_zmax`` is kept. Default is ``None``.
258
+ extent_geom : shapely.geometry.base.BaseGeometry or None, optional
259
+ Domain extent polygon in the raster CRS; if given, the raster is
260
+ clipped to it before contour extraction. Default is ``None``.
261
+ keep_largest : bool, optional
262
+ If ``True`` (default), keep only the single largest water polygon.
263
+ return_domain : bool, optional
264
+ If ``True``, also return the domain polygon actually used for the
265
+ clip (the extent polygon, or the buffered raster extent), in the
266
+ working CRS -- exactly the geometry the water polygon's cut lies on,
267
+ e.g. for flagging fixed vertices. Default is ``False``.
268
+ invert_z : bool, optional
269
+ Treat the raster as depth (positive down) by flipping its sign before
270
+ the coastline logic. Default ``False`` (elevation, positive up).
271
+ nodata_value : float or None, optional
272
+ Elevation (positive up) assigned to nodata / non-finite pixels.
273
+ ``None`` (default) treats them as deep water so the sea (stored as
274
+ nodata) is always included regardless of ``coast_zmax``.
275
+ feedback : object or None, optional
276
+ Feedback sink exposing ``pushInfo``/``isCanceled`` (see
277
+ :class:`_NullFeedback`); a no-op sink is used if ``None``.
278
+
279
+ Returns
280
+ -------
281
+ poly : shapely.geometry.Polygon or shapely.geometry.MultiPolygon
282
+ Water domain polygon in the working CRS.
283
+ utm_crs : pyproj.CRS
284
+ Working CRS. Equal to `raster_crs` when the raster is already
285
+ projected (e.g. UTM); otherwise a local UTM CRS.
286
+ raster_crs : pyproj.CRS
287
+ CRS of the input raster.
288
+ domain_u : shapely geometry, only when ``return_domain=True``
289
+ Clip domain in the working CRS.
290
+ """
291
+ feedback = feedback or _NullFeedback()
292
+ import numpy as np
293
+ import pyproj
294
+ import rasterio
295
+ from shapely.geometry import MultiPolygon
296
+
297
+ from bluemesh2d.geom_util.proj_util import get_local_utm_crs, reproject_geometry
298
+ from bluemesh2d.geom_util.poly_util import buffer_area
299
+ from bluemesh2d.geom_util.getiso import getiso_polygon
300
+
301
+ feedback.pushInfo("Reading raster ...")
302
+ # Read only the study window and decimate on read when the raster is too
303
+ # large, so an oversized bathymetry raster is clipped BEFORE (and while)
304
+ # reading -- never materialised in full.
305
+ max_cells = _contour_read_budget()
306
+ with bundled_raster_data_env(), rasterio.open(raster_path) as src:
307
+ from bluemesh2d.geom_util.proj_util import require_georeferenced
308
+ require_georeferenced(src)
309
+ raster_crs = _raster_crs(src)
310
+ nodata = src.nodata
311
+ bbox = tuple(extent_geom.bounds) if extent_geom is not None else None
312
+ zdat, lon, lat, step, clipped = _read_band_for_contour(
313
+ src, bbox, max_cells)
314
+ _check(feedback)
315
+
316
+ # optional Z reversal so the coastline logic (water = z <= coast_zmax)
317
+ # sees elevation positive up
318
+ zdat = np.asarray(zdat, dtype=float)
319
+ bad = ~np.isfinite(zdat)
320
+ if nodata is not None:
321
+ bad |= (zdat == nodata)
322
+ if invert_z:
323
+ zdat = -zdat
324
+ if bad.any():
325
+ # nodata pixels default to (deep) water, so they stay below the
326
+ # coastline level whatever its sign -- the usual raster convention
327
+ # where the sea is stored as nodata. A user-set ``nodata_value`` (an
328
+ # elevation, positive up) overrides this.
329
+ zdat[bad] = -1.0e30 if nodata_value is None else float(nodata_value)
330
+
331
+ if clipped:
332
+ feedback.pushInfo(
333
+ f"Raster clipped to the extent polygon: {zdat.shape[1]} x "
334
+ f"{zdat.shape[0]} px")
335
+ if domain_buffer:
336
+ feedback.pushInfo(
337
+ "Domain buffer is ignored when an extent polygon is provided.")
338
+ if step > 1:
339
+ feedback.pushInfo(
340
+ f"Raster too large to read at full resolution; contouring at "
341
+ f"1/{step} resolution ({zdat.shape[1]} x {zdat.shape[0]} px). "
342
+ "Provide a (smaller) extent polygon for a full-resolution "
343
+ "coastline.")
344
+ feedback.setProgress(15)
345
+
346
+ utm_crs = get_local_utm_crs(raster_crs, lon, lat)
347
+
348
+ feedback.pushInfo("Extracting coastline ...")
349
+ coast = MultiPolygon(getiso_polygon(lon, lat, zdat, zmax=coast_zmax))
350
+ if deep_zmax is not None:
351
+ # keep only the band between the two levels: deep_zmax < z <= coast_zmax
352
+ if deep_zmax >= coast_zmax:
353
+ raise RuntimeError(
354
+ f"The deep level ({deep_zmax}) must be below the coastline "
355
+ f"level ({coast_zmax}).")
356
+ feedback.pushInfo(f"Clipping to depth band {deep_zmax} < z <= {coast_zmax} ...")
357
+ deep = getiso_polygon(lon, lat, zdat, zmax=deep_zmax)
358
+ if deep:
359
+ coast = coast.difference(MultiPolygon(deep))
360
+ _check(feedback)
361
+ feedback.setProgress(60)
362
+
363
+ feedback.pushInfo("Building domain extent ...")
364
+ if extent_geom is not None:
365
+ domain = extent_geom
366
+ else:
367
+ domain = MultiPolygon(
368
+ getiso_polygon(lon, lat, zdat, zmax=float(np.max(zdat)) + 1.0))
369
+ if domain_buffer:
370
+ domain = buffer_area(domain, domain_buffer)
371
+ _check(feedback)
372
+ feedback.setProgress(85)
373
+
374
+ if utm_crs == raster_crs: # projected input: work natively in the tif CRS
375
+ domain_u = domain
376
+ poly = coast.intersection(domain_u)
377
+ else:
378
+ domain_u = reproject_geometry(domain, raster_crs, utm_crs)
379
+ poly = reproject_geometry(coast, raster_crs, utm_crs).intersection(
380
+ domain_u)
381
+ if poly.is_empty:
382
+ raise RuntimeError(
383
+ "No water polygon found: the coastline/domain intersection is empty. "
384
+ "Check the raster and the coastline level.")
385
+
386
+ # the intersection may be a Polygon, MultiPolygon or GeometryCollection
387
+ if poly.geom_type == "Polygon":
388
+ parts = [poly]
389
+ else:
390
+ parts = [g for g in poly.geoms if g.geom_type == "Polygon"]
391
+ if not parts:
392
+ raise RuntimeError("No polygonal water region in the intersection.")
393
+ feedback.pushInfo(f"Water region(s): {len(parts)}")
394
+ if keep_largest and len(parts) > 1:
395
+ poly = max(parts, key=lambda g: g.area)
396
+ feedback.pushInfo(
397
+ f"Keeping only the largest region ({poly.area / 1e6:.0f} km2).")
398
+ if return_domain:
399
+ return poly, utm_crs, raster_crs, domain_u
400
+ return poly, utm_crs, raster_crs
401
+
402
+
403
+ # ===========================================================================
404
+ # Stage 2: raster -> gradient-limited mesh-size (hfun) raster
405
+ # ===========================================================================
406
+