zero2geospatial3d-sdk 0.1.0__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 (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,267 @@
1
+ // @ts-check
2
+ // Terrain derivatives of a DEM grid ({ cols, rows, cell, minX, maxY, values },
3
+ // row-major from the north-west): curvature, topographic position,
4
+ // ruggedness, flow accumulation and wetness, and aspect classes.
5
+ // Edges are handled by repeating the border cell; NaN cells stay NaN.
6
+
7
+ const clampIndex = (v, n) => (v < 0 ? 0 : v >= n ? n - 1 : v);
8
+
9
+ /**
10
+ * The part of a grid inside an area, coarsened by whole cells until it has
11
+ * at most maxCells (block means, NaN ignored).
12
+ */
13
+ export function cropGrid(g, area, maxCells = 4_000_000) {
14
+ const c0 = Math.max(0, Math.floor((area.minX - g.minX) / g.cell + 0.5));
15
+ const c1 = Math.min(g.cols - 1, Math.ceil((area.maxX - g.minX) / g.cell - 0.5));
16
+ const r0 = Math.max(0, Math.floor((g.maxY - area.maxY) / g.cell + 0.5));
17
+ const r1 = Math.min(g.rows - 1, Math.ceil((g.maxY - area.minY) / g.cell - 0.5));
18
+ if (c1 < c0 || r1 < r0) throw new Error('The area does not overlap the terrain.');
19
+ const w = c1 - c0 + 1, hgt = r1 - r0 + 1;
20
+ const k = Math.max(1, Math.ceil(Math.sqrt((w * hgt) / maxCells)));
21
+ const cols = Math.ceil(w / k), rows = Math.ceil(hgt / k);
22
+ const values = new Float32Array(cols * rows);
23
+ for (let r = 0; r < rows; r++) {
24
+ for (let c = 0; c < cols; c++) {
25
+ let s = 0, n = 0;
26
+ for (let dr = 0; dr < k; dr++) {
27
+ const rr = r0 + r * k + dr;
28
+ if (rr > r1) break;
29
+ for (let dc = 0; dc < k; dc++) {
30
+ const cc = c0 + c * k + dc;
31
+ if (cc > c1) break;
32
+ const v = g.values[rr * g.cols + cc];
33
+ if (Number.isFinite(v)) { s += v; n++; }
34
+ }
35
+ }
36
+ values[r * cols + c] = n ? s / n : NaN;
37
+ }
38
+ }
39
+ // Cell centres of the coarse grid: the centre of each k x k block.
40
+ const cell = g.cell * k;
41
+ return { cols, rows, cell, minX: g.minX + (c0 + (k - 1) / 2) * g.cell, maxY: g.maxY - (r0 + (k - 1) / 2) * g.cell, values, factor: k };
42
+ }
43
+
44
+ /**
45
+ * General curvature (Zevenbergen and Thorne 1987) in 1/100 m, the
46
+ * convention of most GIS: positive on convex ground (ridges, crests),
47
+ * negative on concave ground (valleys, hollows).
48
+ */
49
+ export function curvature(g) {
50
+ const { cols, rows, cell, values: z } = g;
51
+ const out = new Float32Array(cols * rows);
52
+ const L2 = cell * cell;
53
+ for (let r = 0; r < rows; r++) {
54
+ const ru = clampIndex(r - 1, rows), rd = clampIndex(r + 1, rows);
55
+ for (let c = 0; c < cols; c++) {
56
+ const cl = clampIndex(c - 1, cols), cr = clampIndex(c + 1, cols);
57
+ const z5 = z[r * cols + c];
58
+ const D = ((z[r * cols + cl] + z[r * cols + cr]) / 2 - z5) / L2;
59
+ const E = ((z[ru * cols + c] + z[rd * cols + c]) / 2 - z5) / L2;
60
+ out[r * cols + c] = -2 * (D + E) * 100;
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** Summed-area table (with NaN cells counted as missing) for window means. */
67
+ function integral(g) {
68
+ const { cols, rows, values: z } = g;
69
+ const W = cols + 1;
70
+ const sum = new Float64Array(W * (rows + 1));
71
+ const cnt = new Float64Array(W * (rows + 1));
72
+ for (let r = 0; r < rows; r++) {
73
+ let rs = 0, rc = 0;
74
+ for (let c = 0; c < cols; c++) {
75
+ const v = z[r * cols + c];
76
+ if (Number.isFinite(v)) { rs += v; rc++; }
77
+ sum[(r + 1) * W + c + 1] = sum[r * W + c + 1] + rs;
78
+ cnt[(r + 1) * W + c + 1] = cnt[r * W + c + 1] + rc;
79
+ }
80
+ }
81
+ return { sum, cnt, W };
82
+ }
83
+
84
+ /**
85
+ * Topographic position index (Weiss 2001): elevation minus the mean
86
+ * elevation of a square window of radius `radius` cells around it.
87
+ * Positive on hilltops and ridges, negative in valleys, near 0 on flat
88
+ * ground and even slopes.
89
+ */
90
+ export function tpi(g, radius) {
91
+ const { cols, rows, values: z } = g;
92
+ const R = Math.max(1, Math.round(radius));
93
+ const { sum, cnt, W } = integral(g);
94
+ const out = new Float32Array(cols * rows);
95
+ for (let r = 0; r < rows; r++) {
96
+ const a = Math.max(0, r - R), b = Math.min(rows - 1, r + R);
97
+ for (let c = 0; c < cols; c++) {
98
+ const v = z[r * cols + c];
99
+ if (!Number.isFinite(v)) { out[r * cols + c] = NaN; continue; }
100
+ const l = Math.max(0, c - R), rt = Math.min(cols - 1, c + R);
101
+ const s = sum[(b + 1) * W + rt + 1] - sum[a * W + rt + 1] - sum[(b + 1) * W + l] + sum[a * W + l];
102
+ const n = cnt[(b + 1) * W + rt + 1] - cnt[a * W + rt + 1] - cnt[(b + 1) * W + l] + cnt[a * W + l];
103
+ // Mean of the neighbours, the cell itself left out.
104
+ out[r * cols + c] = n > 1 ? v - (s - v) / (n - 1) : 0;
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** Terrain ruggedness index (Riley et al. 1999): root of the summed squared differences to the 8 neighbours, in metres. */
111
+ export function ruggedness(g) {
112
+ const { cols, rows, values: z } = g;
113
+ const out = new Float32Array(cols * rows);
114
+ for (let r = 0; r < rows; r++) {
115
+ for (let c = 0; c < cols; c++) {
116
+ const v = z[r * cols + c];
117
+ if (!Number.isFinite(v)) { out[r * cols + c] = NaN; continue; }
118
+ let s = 0;
119
+ for (let dr = -1; dr <= 1; dr++) {
120
+ for (let dc = -1; dc <= 1; dc++) {
121
+ if (!dr && !dc) continue;
122
+ const n = z[clampIndex(r + dr, rows) * cols + clampIndex(c + dc, cols)];
123
+ if (Number.isFinite(n)) s += (n - v) * (n - v);
124
+ }
125
+ }
126
+ out[r * cols + c] = Math.sqrt(s);
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** Aspect classes: 0 flat (slope under flatDeg), 1 N, 2 NE, ... 8 NW. */
133
+ export function aspectClasses(slope, aspect, flatDeg = 1) {
134
+ const out = new Float32Array(slope.length);
135
+ for (let i = 0; i < slope.length; i++) {
136
+ if (!Number.isFinite(slope[i])) { out[i] = NaN; continue; }
137
+ if (slope[i] < flatDeg || aspect[i] < 0) { out[i] = 0; continue; }
138
+ out[i] = 1 + (Math.floor((aspect[i] + 22.5) / 45) % 8);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ // ---------------------------------------------------------------- flow
144
+ /** Binary min-heap of cell indices keyed by a Float64 priority. */
145
+ class Heap {
146
+ constructor(capacity) {
147
+ this.idx = new Int32Array(capacity);
148
+ this.key = new Float64Array(capacity);
149
+ this.n = 0;
150
+ }
151
+
152
+ push(i, k) {
153
+ let p = this.n++;
154
+ while (p > 0) {
155
+ const q = (p - 1) >> 1;
156
+ if (this.key[q] <= k) break;
157
+ this.idx[p] = this.idx[q]; this.key[p] = this.key[q];
158
+ p = q;
159
+ }
160
+ this.idx[p] = i; this.key[p] = k;
161
+ }
162
+
163
+ pop() {
164
+ const top = this.idx[0];
165
+ const li = this.idx[--this.n], lk = this.key[this.n];
166
+ let p = 0;
167
+ for (;;) {
168
+ let q = 2 * p + 1;
169
+ if (q >= this.n) break;
170
+ if (q + 1 < this.n && this.key[q + 1] < this.key[q]) q++;
171
+ if (this.key[q] >= lk) break;
172
+ this.idx[p] = this.idx[q]; this.key[p] = this.key[q];
173
+ p = q;
174
+ }
175
+ this.idx[p] = li; this.key[p] = lk;
176
+ return top;
177
+ }
178
+ }
179
+
180
+ const DC = [-1, 0, 1, -1, 1, -1, 0, 1], DR = [-1, -1, -1, 0, 0, 1, 1, 1];
181
+
182
+ /**
183
+ * Flow accumulation in cells (each cell counts itself) by D8 steepest
184
+ * descent on a depression-filled surface: priority flood from the edges
185
+ * (Barnes et al. 2014) with a small rise across flats so every cell drains
186
+ * to the edge. NaN cells are outlets, like the edge; water that reaches an
187
+ * edge cell leaves the DEM there instead of running along the edge (the
188
+ * ground beyond it is unknown).
189
+ * @returns {{ acc: Float32Array, filled: Float32Array, order: Int32Array }}
190
+ */
191
+ export function flowAccumulation(g) {
192
+ const { cols, rows, cell, values: z } = g;
193
+ const N = cols * rows;
194
+ const filled = new Float64Array(N);
195
+ const done = new Uint8Array(N);
196
+ const isEdge = new Uint8Array(N);
197
+ const order = new Int32Array(N); // cells in pop order: every cell after the cell it drains to
198
+ let popped = 0;
199
+ const heap = new Heap(N);
200
+ const eps = 1e-5 * Math.max(1, cell);
201
+ for (let i = 0; i < N; i++) {
202
+ const r = (i / cols) | 0, c = i - r * cols;
203
+ if (!Number.isFinite(z[i])) { done[i] = 1; filled[i] = -Infinity; continue; }
204
+ let edge = r === 0 || c === 0 || r === rows - 1 || c === cols - 1;
205
+ for (let k = 0; k < 8 && !edge; k++) if (!Number.isFinite(z[(r + DR[k]) * cols + c + DC[k]])) edge = true;
206
+ if (edge) { filled[i] = z[i]; done[i] = 1; isEdge[i] = 1; heap.push(i, z[i]); }
207
+ }
208
+ while (heap.n) {
209
+ const i = heap.pop();
210
+ order[popped++] = i;
211
+ const r = (i / cols) | 0, c = i - r * cols;
212
+ for (let k = 0; k < 8; k++) {
213
+ const rr = r + DR[k], cc = c + DC[k];
214
+ if (rr < 0 || cc < 0 || rr >= rows || cc >= cols) continue;
215
+ const j = rr * cols + cc;
216
+ if (done[j]) continue;
217
+ done[j] = 1;
218
+ filled[j] = Math.max(z[j], filled[i] + eps);
219
+ heap.push(j, filled[j]);
220
+ }
221
+ }
222
+ // Receiver: steepest descent on the filled surface (diagonals over √2 cells).
223
+ const acc = new Float32Array(N);
224
+ for (let n = 0; n < popped; n++) acc[order[n]] = 1;
225
+ for (let n = popped - 1; n >= 0; n--) {
226
+ const i = order[n];
227
+ const r = (i / cols) | 0, c = i - r * cols;
228
+ let best = -1, drop = 0;
229
+ for (let k = 0; k < 8; k++) {
230
+ const rr = r + DR[k], cc = c + DC[k];
231
+ if (rr < 0 || cc < 0 || rr >= rows || cc >= cols) continue;
232
+ const j = rr * cols + cc;
233
+ if (!Number.isFinite(z[j]) || (isEdge[i] && isEdge[j])) continue;
234
+ const d = (filled[i] - filled[j]) / (DC[k] && DR[k] ? Math.SQRT2 : 1);
235
+ if (d > drop) { drop = d; best = j; }
236
+ }
237
+ if (best >= 0) acc[best] += acc[i];
238
+ }
239
+ for (let i = 0; i < N; i++) if (!Number.isFinite(z[i])) acc[i] = NaN;
240
+ return { acc, filled: Float32Array.from(filled, (v) => (Number.isFinite(v) ? v : NaN)), order: order.subarray(0, popped) };
241
+ }
242
+
243
+ /**
244
+ * Topographic wetness index ln(a / tan β) (Beven and Kirkby 1979): a is the
245
+ * upslope area per unit contour width (accumulated cells x cell size), β the
246
+ * local slope. High on flat ground that collects water.
247
+ */
248
+ export function wetness(g, slopeDeg, acc = flowAccumulation(g).acc) {
249
+ const out = new Float32Array(acc.length);
250
+ const minTan = Math.tan((0.1 * Math.PI) / 180);
251
+ for (let i = 0; i < acc.length; i++) {
252
+ if (!Number.isFinite(acc[i]) || !Number.isFinite(slopeDeg[i])) { out[i] = NaN; continue; }
253
+ const tanB = Math.max(minTan, Math.tan((slopeDeg[i] * Math.PI) / 180));
254
+ out[i] = Math.log((acc[i] * g.cell) / tanB);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /** Value at the p-th percentile (0..1) of the finite values, from a sample. */
260
+ export function percentile(values, p, sample = 200000) {
261
+ const step = Math.max(1, Math.floor(values.length / sample));
262
+ const s = [];
263
+ for (let i = 0; i < values.length; i += step) if (Number.isFinite(values[i])) s.push(values[i]);
264
+ if (!s.length) return NaN;
265
+ s.sort((a, b) => a - b);
266
+ return s[Math.min(s.length - 1, Math.max(0, Math.round(p * (s.length - 1))))];
267
+ }
@@ -0,0 +1,279 @@
1
+ // @ts-check
2
+ // Terrain grid operations. A grid is { cols, rows, cell, minX, maxY, values }
3
+ // with values row-major from the north-west cell; (minX, maxY) is the centre
4
+ // of that cell in local metres (x east, y north).
5
+
6
+ export function makeGrid(cols, rows, cell, minX, maxY, values = new Float32Array(cols * rows)) {
7
+ return { cols, rows, cell, minX, maxY, values };
8
+ }
9
+
10
+ export function cellCentre(g, c, r) {
11
+ return [g.minX + c * g.cell, g.maxY - r * g.cell];
12
+ }
13
+
14
+ /** Bilinear height at local (x, y); clamped at the edges. */
15
+ export function sample(g, x, y) {
16
+ const fc = Math.max(0, Math.min(g.cols - 1, (x - g.minX) / g.cell));
17
+ const fr = Math.max(0, Math.min(g.rows - 1, (g.maxY - y) / g.cell));
18
+ const c0 = Math.min(g.cols - 2, Math.floor(fc));
19
+ const r0 = Math.min(g.rows - 2, Math.floor(fr));
20
+ if (g.cols < 2 || g.rows < 2) return g.values[0] || 0;
21
+ const tx = fc - c0;
22
+ const ty = fr - r0;
23
+ const v = g.values;
24
+ const i = r0 * g.cols + c0;
25
+ const a = v[i] * (1 - tx) + v[i + 1] * tx;
26
+ const b = v[i + g.cols] * (1 - tx) + v[i + g.cols + 1] * tx;
27
+ return a * (1 - ty) + b * ty;
28
+ }
29
+
30
+ /** Horn (1981) slope (degrees) and aspect (degrees clockwise from north, facing downslope; -1 for flat). */
31
+ export function slopeAspect(g) {
32
+ const { cols, rows, cell, values: z } = g;
33
+ const slope = new Float32Array(cols * rows);
34
+ const aspect = new Float32Array(cols * rows);
35
+ const at = (c, r) => z[Math.max(0, Math.min(rows - 1, r)) * cols + Math.max(0, Math.min(cols - 1, c))];
36
+ for (let r = 0; r < rows; r++) {
37
+ for (let c = 0; c < cols; c++) {
38
+ const a = at(c - 1, r - 1), b = at(c, r - 1), cc = at(c + 1, r - 1);
39
+ const d = at(c - 1, r), f = at(c + 1, r);
40
+ const gg = at(c - 1, r + 1), h = at(c, r + 1), i = at(c + 1, r + 1);
41
+ const dzdx = ((cc + 2 * f + i) - (a + 2 * d + gg)) / (8 * cell); // towards east
42
+ const dzdy = ((a + 2 * b + cc) - (gg + 2 * h + i)) / (8 * cell); // towards north
43
+ const k = r * cols + c;
44
+ slope[k] = Math.atan(Math.hypot(dzdx, dzdy)) * 180 / Math.PI;
45
+ if (dzdx === 0 && dzdy === 0) { aspect[k] = -1; continue; }
46
+ // Downslope direction is -gradient; bearing clockwise from north.
47
+ let az = Math.atan2(-dzdx, -dzdy) * 180 / Math.PI;
48
+ if (az < 0) az += 360;
49
+ aspect[k] = az;
50
+ }
51
+ }
52
+ return { slope, aspect };
53
+ }
54
+
55
+ /** Horn slope (degrees) and aspect (degrees, -1 flat) of one cell. */
56
+ export function slopeAspectAt(g, c, r) {
57
+ const { cols, rows, cell, values: z } = g;
58
+ const at = (cc, rr) => z[Math.max(0, Math.min(rows - 1, rr)) * cols + Math.max(0, Math.min(cols - 1, cc))];
59
+ const dzdx = ((at(c + 1, r - 1) + 2 * at(c + 1, r) + at(c + 1, r + 1)) - (at(c - 1, r - 1) + 2 * at(c - 1, r) + at(c - 1, r + 1))) / (8 * cell);
60
+ const dzdy = ((at(c - 1, r - 1) + 2 * at(c, r - 1) + at(c + 1, r - 1)) - (at(c - 1, r + 1) + 2 * at(c, r + 1) + at(c + 1, r + 1))) / (8 * cell);
61
+ const slope = Math.atan(Math.hypot(dzdx, dzdy)) * 180 / Math.PI;
62
+ if (dzdx === 0 && dzdy === 0) return { slope, aspect: -1 };
63
+ let aspect = Math.atan2(-dzdx, -dzdy) * 180 / Math.PI;
64
+ if (aspect < 0) aspect += 360;
65
+ return { slope, aspect };
66
+ }
67
+
68
+ /**
69
+ * Hillshade 0..1 for a light from azimuth/altitude (degrees), in one pass
70
+ * without slope/aspect arrays (a 16 M-cell DEM would need 128 MB for them).
71
+ */
72
+ export function hillshade(g, azimuthDeg = 315, altitudeDeg = 45, zFactor = 1) {
73
+ const { cols, rows, cell, values: z } = g;
74
+ const out = new Float32Array(cols * rows);
75
+ const zen = (90 - altitudeDeg) * Math.PI / 180;
76
+ const az = azimuthDeg * Math.PI / 180;
77
+ // Unit vector towards the light in (east, north, up).
78
+ const lx = Math.sin(zen) * Math.sin(az), ly = Math.sin(zen) * Math.cos(az), lz = Math.cos(zen);
79
+ const k = zFactor / (8 * cell);
80
+ for (let r = 0; r < rows; r++) {
81
+ const ru = r > 0 ? r - 1 : 0, rd = r < rows - 1 ? r + 1 : rows - 1;
82
+ for (let c = 0; c < cols; c++) {
83
+ const cl = c > 0 ? c - 1 : 0, cr = c < cols - 1 ? c + 1 : cols - 1;
84
+ const a = z[ru * cols + cl], b = z[ru * cols + c], cc = z[ru * cols + cr];
85
+ const d = z[r * cols + cl], f = z[r * cols + cr];
86
+ const gg = z[rd * cols + cl], hh = z[rd * cols + c], i = z[rd * cols + cr];
87
+ const dzdx = ((cc + 2 * f + i) - (a + 2 * d + gg)) * k; // towards east
88
+ const dzdy = ((a + 2 * b + cc) - (gg + 2 * hh + i)) * k; // towards north
89
+ // Surface normal (-dz/dx, -dz/dy, 1), normalised, dotted with the light.
90
+ out[r * cols + c] = Math.max(0, (-dzdx * lx - dzdy * ly + lz) / Math.sqrt(dzdx * dzdx + dzdy * dzdy + 1));
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Contour line segments by marching squares.
98
+ * @returns {{ level: number, segments: number[] }[]} segments as flat [x1, y1, x2, y2, ...]
99
+ */
100
+ export function contours(g, interval, { base = 0, maxLevels = 400 } = {}) {
101
+ const { cols, rows, values: z } = g;
102
+ let lo = Infinity, hi = -Infinity;
103
+ for (const v of z) { if (v < lo) lo = v; if (v > hi) hi = v; }
104
+ if (!(interval > 0) || !Number.isFinite(lo)) return [];
105
+ // One pass over the cells: each cell emits segments only for the levels
106
+ // between its lowest and highest corner, so the cost does not grow with
107
+ // the number of levels (a 4096 x 4096 DEM stays interactive).
108
+ const kFirst = Math.ceil((lo - base) / interval);
109
+ const kLast = Math.min(Math.floor((hi - base) / interval), kFirst + maxLevels - 1);
110
+ const segs = new Map();
111
+ const { cell, minX, maxY } = g;
112
+ const pts = [];
113
+ for (let r = 0; r < rows - 1; r++) {
114
+ const ya = maxY - r * cell;
115
+ const yb = ya - cell;
116
+ for (let c = 0; c < cols - 1; c++) {
117
+ const v0 = z[r * cols + c], v1 = z[r * cols + c + 1];
118
+ const v2 = z[(r + 1) * cols + c + 1], v3 = z[(r + 1) * cols + c];
119
+ const cmin = Math.min(v0, v1, v2, v3);
120
+ const cmax = Math.max(v0, v1, v2, v3);
121
+ const k0 = Math.max(kFirst, Math.ceil((cmin - base) / interval));
122
+ const k1 = Math.min(kLast, Math.floor((cmax - base) / interval));
123
+ if (k1 < k0) continue;
124
+ const xa = minX + c * cell;
125
+ const xb = xa + cell;
126
+ for (let k = k0; k <= k1; k++) {
127
+ const level = base + k * interval;
128
+ pts.length = 0;
129
+ if ((v0 < level) !== (v1 < level)) { const t = (level - v0) / (v1 - v0); pts.push(xa + cell * t, ya); }
130
+ if ((v1 < level) !== (v2 < level)) { const t = (level - v1) / (v2 - v1); pts.push(xb, ya - cell * t); }
131
+ if ((v2 < level) !== (v3 < level)) { const t = (level - v2) / (v3 - v2); pts.push(xb - cell * t, yb); }
132
+ if ((v3 < level) !== (v0 < level)) { const t = (level - v3) / (v0 - v3); pts.push(xa, yb + cell * t); }
133
+ if (pts.length !== 4 && pts.length !== 8) continue; // 8: a saddle, two segments
134
+ let arr = segs.get(k);
135
+ if (!arr) { arr = []; segs.set(k, arr); }
136
+ for (const p of pts) arr.push(p);
137
+ }
138
+ }
139
+ }
140
+ return [...segs.keys()].sort((a, b) => a - b).map((k) => ({ level: base + k * interval, segments: segs.get(k) }));
141
+ }
142
+
143
+ /** Sample heights along a polyline every `step` metres: [{ d, x, y, z }]. */
144
+ export function profile(g, points, step = null) {
145
+ const s = step || g.cell / 2;
146
+ const out = [];
147
+ let dist = 0;
148
+ for (let i = 0; i < points.length - 1; i++) {
149
+ const [x0, y0] = points[i];
150
+ const [x1, y1] = points[i + 1];
151
+ const len = Math.hypot(x1 - x0, y1 - y0);
152
+ const n = Math.max(1, Math.ceil(len / s));
153
+ for (let k = i === 0 ? 0 : 1; k <= n; k++) {
154
+ const t = k / n;
155
+ const x = x0 + (x1 - x0) * t;
156
+ const y = y0 + (y1 - y0) * t;
157
+ out.push({ d: dist + len * t, x, y, z: sample(g, x, y) });
158
+ }
159
+ dist += len;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Cells under a water level. With a seed (local x, y) only cells connected to
166
+ * it (4-neighbours) flood, like water rising from that point; without one,
167
+ * every cell below the level floods (a plain "bathtub").
168
+ * @returns {{ depth: Float32Array, floodedCells: number, area: number, volume: number, maxDepth: number }}
169
+ */
170
+ export function flood(g, level, seed = null) {
171
+ const { cols, rows, cell, values: z } = g;
172
+ const depth = new Float32Array(cols * rows).fill(NaN);
173
+ let count = 0, volume = 0, maxDepth = 0;
174
+ const mark = (i) => {
175
+ const d = level - z[i];
176
+ depth[i] = d;
177
+ count++;
178
+ volume += d * cell * cell;
179
+ if (d > maxDepth) maxDepth = d;
180
+ };
181
+ if (seed) {
182
+ const c0 = Math.round((seed[0] - g.minX) / cell);
183
+ const r0 = Math.round((g.maxY - seed[1]) / cell);
184
+ if (c0 < 0 || r0 < 0 || c0 >= cols || r0 >= rows || z[r0 * cols + c0] >= level) {
185
+ return { depth, floodedCells: 0, area: 0, volume: 0, maxDepth: 0 };
186
+ }
187
+ const seen = new Uint8Array(cols * rows);
188
+ const stack = [r0 * cols + c0];
189
+ seen[stack[0]] = 1;
190
+ while (stack.length) {
191
+ const i = /** @type {number} */ (stack.pop());
192
+ mark(i);
193
+ const c = i % cols, r = (i - c) / cols;
194
+ const next = [c > 0 ? i - 1 : -1, c < cols - 1 ? i + 1 : -1, r > 0 ? i - cols : -1, r < rows - 1 ? i + cols : -1];
195
+ for (const j of next) {
196
+ if (j >= 0 && !seen[j] && z[j] < level) { seen[j] = 1; stack.push(j); }
197
+ }
198
+ }
199
+ } else {
200
+ for (let i = 0; i < z.length; i++) if (z[i] < level) mark(i);
201
+ }
202
+ return { depth, floodedCells: count, area: count * cell * cell, volume, maxDepth };
203
+ }
204
+
205
+ export function pointInRing(x, y, ring) {
206
+ let inside = false;
207
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
208
+ const [xi, yi] = ring[i];
209
+ const [xj, yj] = ring[j];
210
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi || 1e-12) + xi) inside = !inside;
211
+ }
212
+ return inside;
213
+ }
214
+
215
+ /**
216
+ * Cut and fill to bring the ground inside a polygon to a flat design level.
217
+ * @returns {{ cut: number, fill: number, net: number, area: number, delta: Float32Array }}
218
+ * cut/fill in m3; delta = design - ground per cell (NaN outside)
219
+ */
220
+ export function cutFill(g, ring, designLevel) {
221
+ const { cols, rows, cell, values: z } = g;
222
+ const delta = new Float32Array(cols * rows).fill(NaN);
223
+ let cut = 0, fill = 0, n = 0;
224
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
225
+ for (const [x, y] of ring) { minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }
226
+ const c0 = Math.max(0, Math.floor((minX - g.minX) / cell));
227
+ const c1 = Math.min(cols - 1, Math.ceil((maxX - g.minX) / cell));
228
+ const r0 = Math.max(0, Math.floor((g.maxY - maxY) / cell));
229
+ const r1 = Math.min(rows - 1, Math.ceil((g.maxY - minY) / cell));
230
+ for (let r = r0; r <= r1; r++) {
231
+ for (let c = c0; c <= c1; c++) {
232
+ const [x, y] = cellCentre(g, c, r);
233
+ if (!pointInRing(x, y, ring)) continue;
234
+ const i = r * cols + c;
235
+ if (!Number.isFinite(z[i])) continue; // no DEM data: nothing to cut or fill
236
+ const d = designLevel - z[i];
237
+ delta[i] = d;
238
+ n++;
239
+ if (d > 0) fill += d * cell * cell; else cut -= d * cell * cell;
240
+ }
241
+ }
242
+ return { cut, fill, net: fill - cut, area: n * cell * cell, delta };
243
+ }
244
+
245
+ /**
246
+ * Design level that balances cut and fill inside the polygon (bisection
247
+ * between the lowest and highest ground inside it). A polygon smaller than
248
+ * a cell holds no cell centre: the ground at its centre is the balance.
249
+ */
250
+ export function balancedLevel(g, ring) {
251
+ let lo = Infinity, hi = -Infinity;
252
+ const { delta } = cutFill(g, ring, 0); // -ground inside, NaN outside
253
+ for (const d of delta) { if (Number.isFinite(d)) { if (-d < lo) lo = -d; if (-d > hi) hi = -d; } }
254
+ if (!(lo <= hi)) {
255
+ let x = 0, y = 0;
256
+ for (const p of ring) { x += p[0]; y += p[1]; }
257
+ const c = Math.max(0, Math.min(g.cols - 1, Math.round((x / ring.length - g.minX) / g.cell)));
258
+ const r = Math.max(0, Math.min(g.rows - 1, Math.round((g.maxY - y / ring.length) / g.cell)));
259
+ return g.values[r * g.cols + c];
260
+ }
261
+ for (let i = 0; i < 40; i++) {
262
+ const mid = (lo + hi) / 2;
263
+ const { net } = cutFill(g, ring, mid);
264
+ if (net > 0) hi = mid; else lo = mid;
265
+ }
266
+ return (lo + hi) / 2;
267
+ }
268
+
269
+ export function gridStats(values) {
270
+ let min = Infinity, max = -Infinity, sum = 0, n = 0;
271
+ for (const v of values) {
272
+ if (!Number.isFinite(v)) continue;
273
+ if (v < min) min = v;
274
+ if (v > max) max = v;
275
+ sum += v;
276
+ n++;
277
+ }
278
+ return { min, max, mean: n ? sum / n : NaN, count: n };
279
+ }