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,630 @@
1
+ // @ts-check
2
+ // Application state and the glue between scene, analyses and panels.
3
+ import * as THREE from 'three';
4
+ import { Viewer, LAYER_OCCLUDER } from './scene/viewer.js';
5
+ import { Terrain } from './scene/terrain.js';
6
+ import { createLayer, splitKey, ExtrudedLayer, SurfaceLayer } from './scene/objects.js';
7
+ import { hideOverlay, setOverlayOpacity, resultTexture, useOverlay } from './scene/overlay.js';
8
+ import { Analyst, frameForHour, setShadowFrame, setDiurnalFrame } from './analysis/runners.js';
9
+ import { WindStreamlines } from './scene/wind_streamlines.js';
10
+ import { diurnalPointProfile } from './core/microclimate.js';
11
+ import { solarPosition, sunVector } from './core/solar.js';
12
+ import { parseEpw, typicalDoy } from './core/weather.js';
13
+ import { makeGrid, gridStats, slopeAspect, slopeAspectAt } from './core/terrain_math.js';
14
+ import { rampStops, rampColor, fmt } from './core/classify.js';
15
+ import { applyRule, defaultRule } from './ui/colorize.js';
16
+ import { Tools } from './ui/tools.js';
17
+ import { reportBlock } from './ui/building_report.js';
18
+ import { h, clear, toast } from './ui/dom.js';
19
+ import { drawDiurnalChart, drawSunPathDiagram } from './ui/charts.js';
20
+ import { qgisOnline, qgisSelection, selectInQgis } from './ui/qgis_link.js';
21
+
22
+ export class App {
23
+ constructor() {
24
+ this.viewer = new Viewer(/** @type {HTMLElement} */ (document.getElementById('view')));
25
+ this.manifest = null;
26
+ this.terrain = null;
27
+ this.layers = [];
28
+ this.rules = new Map(); // layer -> { color, height }
29
+ this.selection = new Map(); // layer -> Set(feature index)
30
+ this.results = [];
31
+ this.activeResult = null;
32
+ this.weather = null;
33
+ this.site = { lat: 0, lon: 0, utcOffset: 0 };
34
+ const now = new Date();
35
+ this.date = { month: now.getMonth() + 1, day: Math.min(28, now.getDate()) };
36
+ this.time = 12;
37
+ this.analysisArea = { mode: 'scene', rect: null, radius: 400 };
38
+ this.cellSize = 0; // 0 = automatic
39
+ this.slopeMode = 'lowest'; // building reference ground on slopes
40
+ this.busy = false;
41
+ this.listeners = new Set();
42
+ this.qgis = false;
43
+ this.syncSelection = true;
44
+ this._selSeq = -1;
45
+ this.facadeMesh = null;
46
+ this.markers = new THREE.Group();
47
+ this.viewer.helpers.add(this.markers);
48
+ this.streamlines = new WindStreamlines(this);
49
+ this.plantedTrees = []; // Array of [x, y, radius, height]
50
+ this.plantedTreesGroup = new THREE.Group();
51
+ this.viewer.world.add(this.plantedTreesGroup);
52
+ this.drawnRoads = []; // Array of polylines [[x, y], ...]
53
+ }
54
+
55
+ on(fn) { this.listeners.add(fn); }
56
+ emit(what) { for (const fn of this.listeners) fn(what); }
57
+
58
+ // ------------------------------------------------------------ loading
59
+ async load(sceneUrl) {
60
+ this.status('Loading scene …');
61
+ const base = sceneUrl.slice(0, sceneUrl.lastIndexOf('/') + 1);
62
+ const res = await fetch(sceneUrl, { cache: 'no-store' });
63
+ if (!res.ok) throw new Error(`Scene not found (${res.status}). Export it from QGIS with "Open in 3D".`);
64
+ const m = await res.json();
65
+ this.manifest = m;
66
+ this.base = base;
67
+ this.site = { lat: m.geo.lat, lon: m.geo.lon, utcOffset: m.geo.utcOffset };
68
+ const g = m.grid;
69
+ let values = new Float32Array(g.cols * g.rows);
70
+ let mask = null;
71
+ if (m.terrain) {
72
+ this.status('Loading terrain …');
73
+ const buf = await (await fetch(base + m.terrain.file, { cache: 'no-store' })).arrayBuffer();
74
+ values = new Float32Array(buf);
75
+ if (values.length !== g.cols * g.rows) throw new Error('terrain.bin does not match the grid in scene.json');
76
+ // Cells where the DEM has no data (e.g. a DEM clipped to a polygon).
77
+ if (m.terrain.mask) {
78
+ mask = new Uint8Array(await (await fetch(base + m.terrain.mask, { cache: 'no-store' })).arrayBuffer());
79
+ if (mask.length !== values.length) throw new Error('terrain_mask.bin does not match the grid in scene.json');
80
+ }
81
+ }
82
+ this.terrain = new Terrain(this.viewer, makeGrid(g.cols, g.rows, g.cell, g.minX, g.maxY, values), mask);
83
+ let index = 0;
84
+ for (const entry of m.layers) {
85
+ index++;
86
+ this.status(`Loading ${entry.name} (${entry.count.toLocaleString('en-US')}) …`);
87
+ await new Promise((r) => setTimeout(r, 0));
88
+ const data = await (await fetch(base + entry.file, { cache: 'no-store' })).json();
89
+ const layer = createLayer(this, entry, index, data);
90
+ if (!layer) continue;
91
+ this.layers.push(layer);
92
+ this.viewer.world.add(layer.group);
93
+ this.rules.set(layer, { color: defaultRule(), height: { mode: 'source', field: '', factor: 1, value: 10 } });
94
+ if (entry.scenario === 'B') layer.setVisible(false);
95
+ }
96
+ if (m.weather) {
97
+ try {
98
+ const text = await (await fetch(base + m.weather.file, { cache: 'no-store' })).text();
99
+ this.setWeather(parseEpw(text), m.weather.source);
100
+ } catch (e) {
101
+ toast(`Weather file not usable: ${e.message}`, 'warn');
102
+ }
103
+ }
104
+ const box = new THREE.Box3().setFromObject(this.viewer.world);
105
+ this.viewer.frame(box);
106
+ this.updateSun();
107
+ this.qgis = await qgisOnline();
108
+ if (this.qgis) setInterval(() => this.pollSelection(), 1500);
109
+ this.tools = new Tools(this);
110
+ this.analyst = new Analyst(this);
111
+ this.status(`Ready · ${this.layers.reduce((s, l) => s + l.count, 0).toLocaleString('en-US')} features`);
112
+ this.emit('loaded');
113
+ }
114
+
115
+ setWeather(w, source = '') {
116
+ this.weather = w;
117
+ if (w?.location && Number.isFinite(w.location.utcOffset)) this.site.utcOffset = w.location.utcOffset;
118
+ this.weatherSource = source || w?.location?.city || 'EPW';
119
+ this.updateSun();
120
+ this.emit('weather');
121
+ }
122
+
123
+ // ------------------------------------------------------------ sun
124
+ sunPosition() {
125
+ const doy = typicalDoy(this.date.month, this.date.day);
126
+ return solarPosition(doy, this.time, this.site.lat, this.site.lon, this.site.utcOffset);
127
+ }
128
+
129
+ updateSun() {
130
+ const p = this.sunPosition();
131
+ this.viewer.setSun(sunVector(p.elevation, p.azimuth));
132
+ // A shadow study follows the clock: show the moment closest to it.
133
+ const r = this.activeResult;
134
+ if ((r?.kind === 'shadowstudy' || r?.kind === 'diurnalstudy') && r.date.month === this.date.month && r.date.day === this.date.day) {
135
+ const k = frameForHour(r, this.time);
136
+ if (k >= 0 && k !== r.frame) {
137
+ if (r.kind === 'shadowstudy') setShadowFrame(r, k);
138
+ else setDiurnalFrame(r, k);
139
+ this.showResult(r);
140
+ }
141
+ }
142
+ this.emit('sun');
143
+ }
144
+
145
+ // ------------------------------------------------------------ status
146
+ status(text, progress = null) {
147
+ const el = document.getElementById('status-text');
148
+ if (el) el.textContent = text;
149
+ const bar = /** @type {HTMLElement|null} */ (document.getElementById('status-progress'));
150
+ if (bar) {
151
+ bar.hidden = progress === null;
152
+ if (progress !== null) /** @type {HTMLElement} */ (bar.firstElementChild).style.width = `${Math.round(progress * 100)}%`;
153
+ }
154
+ }
155
+
156
+ // ------------------------------------------------------------ analysis area
157
+ sceneArea() {
158
+ const e = this.manifest.extent;
159
+ return { minX: e.minX, maxX: e.maxX, minY: e.minY, maxY: e.maxY };
160
+ }
161
+
162
+ area() {
163
+ const scene = this.sceneArea();
164
+ const a = this.analysisArea;
165
+ let out = scene;
166
+ if (a.mode === 'rect' && a.rect) out = a.rect;
167
+ else if (a.mode === 'view') {
168
+ const t = this.viewer.controls.target;
169
+ out = { minX: t.x - a.radius, maxX: t.x + a.radius, minY: -t.z - a.radius, maxY: -t.z + a.radius };
170
+ }
171
+ return { minX: Math.max(scene.minX, out.minX), maxX: Math.min(scene.maxX, out.maxX),
172
+ minY: Math.max(scene.minY, out.minY), maxY: Math.min(scene.maxY, out.maxY) };
173
+ }
174
+
175
+ cell(perSide = 1024) {
176
+ const a = this.area();
177
+ const side = Math.max(a.maxX - a.minX, a.maxY - a.minY);
178
+ const chosen = this.cellSize > 0 ? this.cellSize : this.analyst.defaultCell(a, perSide);
179
+ // Never more than 4096 cells a side: a 1 m cell over a 20 km area would be 400 M cells.
180
+ const floor = Math.ceil((side / 4096) * 2) / 2;
181
+ if (chosen < floor && this.cellSize > 0 && !this._cellWarned) {
182
+ this._cellWarned = true;
183
+ toast(`${chosen} m cells are too fine for this area; using ${floor} m. Draw a smaller area for more detail.`);
184
+ }
185
+ return Math.max(chosen, floor);
186
+ }
187
+
188
+ showAreaOutline() {
189
+ const a = this.area();
190
+ this.tools.outline('analysis-area', [[a.minX, a.minY], [a.maxX, a.minY], [a.maxX, a.maxY], [a.minX, a.maxY], [a.minX, a.minY]]);
191
+ }
192
+
193
+ // ------------------------------------------------------------ running analyses
194
+ async run(label, fn) {
195
+ if (this.busy) { toast('Another analysis is running.'); return null; }
196
+ if (this.terrain.exaggeration !== 1) {
197
+ this.setExaggeration(1);
198
+ toast('Vertical exaggeration reset to 1 for the analysis.');
199
+ }
200
+ this.busy = true;
201
+ this.emit('busy');
202
+ const t0 = performance.now();
203
+ this.showAreaOutline();
204
+ try {
205
+ this.status(`${label} …`, 0);
206
+ const results = await fn((f) => this.status(`${label} … ${Math.round(f * 100)} %`, f));
207
+ for (const r of results) {
208
+ r.id = `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
209
+ r.created = new Date();
210
+ r.seconds = (performance.now() - t0) / 1000;
211
+ this.maskNoData(r);
212
+ for (const f of r.fields || []) f.layer.setResultField(f.name, f.values);
213
+ }
214
+ this.results.unshift(...results); // keep the analysis' own order, newest run on top
215
+ if (results.length) this.showResult(results[0]);
216
+ this.status(`${label} done in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
217
+ this.emit('results');
218
+ return results;
219
+ } catch (e) {
220
+ console.error(e);
221
+ this.status(`${label} failed: ${e.message}`);
222
+ toast(`${label} failed: ${e.message}`, 'error');
223
+ return null;
224
+ } finally {
225
+ this.busy = false;
226
+ this.emit('busy');
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Ground cells over holes in the DEM (a DEM clipped to a polygon) carry no
232
+ * result: NaN there, so they are neither drawn nor sent to QGIS. Roofs of
233
+ * buildings standing there keep their values.
234
+ */
235
+ maskNoData(r) {
236
+ const t = this.terrain;
237
+ const g = r?.grid;
238
+ if (!t?.mask || !g || !(r.values instanceof Float32Array) || r.values.length !== g.cols * g.rows) return;
239
+ for (let row = 0; row < g.rows; row++) {
240
+ const y = g.maxY - row * g.cell;
241
+ for (let c = 0; c < g.cols; c++) {
242
+ const i = row * g.cols + c;
243
+ if (r.ids?.[i] || !Number.isFinite(r.values[i])) continue;
244
+ if (!t.hasData(g.minX + c * g.cell, y)) r.values[i] = NaN;
245
+ }
246
+ }
247
+ }
248
+
249
+ showResult(r) {
250
+ if (this.activeResult && this.activeResult !== r) this.activeResult.onHide?.();
251
+ this.activeResult = r;
252
+ r?.onShow?.();
253
+ if (r?.grid) useOverlay(resultTexture(r), r.grid, { roofs: !r.groundOnly });
254
+ else hideOverlay();
255
+ this._showFacade(r?.facade && r.showFacade !== false ? r.facade : null);
256
+ this._showMarker(r?.marker);
257
+ if (r?.kind === 'wind' && r.showStreamlines !== false && r.grid) {
258
+ this.streamlines.show({
259
+ grid: { ...r.grid, minX: r.grid.minX - 0.5 * r.grid.cell, maxY: r.grid.maxY + 0.5 * r.grid.cell },
260
+ values: r.ratio || r.values,
261
+ ids: r.ids,
262
+ windFromDeg: r.wind?.fromDeg ?? 270,
263
+ speed10: r.wind?.speed10 ?? 4
264
+ });
265
+ } else {
266
+ this.streamlines.hide();
267
+ }
268
+ this.renderLegend();
269
+ this.viewer.requestRender();
270
+ this.emit('active');
271
+ }
272
+
273
+ hideResult() {
274
+ this.activeResult?.onHide?.();
275
+ this.activeResult = null;
276
+ hideOverlay();
277
+ this._showFacade(null);
278
+ this._showMarker(null);
279
+ this.streamlines.hide();
280
+ this.renderLegend();
281
+ this.viewer.requestRender();
282
+ this.emit('active');
283
+ }
284
+
285
+ setOverlayOpacity(v) {
286
+ setOverlayOpacity(v);
287
+ this.viewer.requestRender();
288
+ }
289
+
290
+ _showMarker(m) {
291
+ for (const c of [...this.markers.children]) {
292
+ this.markers.remove(c);
293
+ /** @type {any} */ (c).geometry?.dispose();
294
+ }
295
+ if (!m) return;
296
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1.6, 16, 12), new THREE.MeshBasicMaterial({ color: 0xff4f2e, depthTest: false }));
297
+ mesh.position.set(m.x, m.y, m.z);
298
+ mesh.renderOrder = 30;
299
+ this.markers.add(mesh);
300
+ }
301
+
302
+ _showFacade(f) {
303
+ if (this.facadeMesh) {
304
+ this.viewer.helpers.remove(this.facadeMesh);
305
+ this.facadeMesh = null;
306
+ }
307
+ if (!f || !f.count) return;
308
+ this.facadeMesh = this.facadeMeshFor(f);
309
+ this.viewer.helpers.add(this.facadeMesh);
310
+ }
311
+
312
+ /** Facade tiles of a result, built once and kept on it. */
313
+ facadeMeshFor(f) {
314
+ if (f._mesh) return f._mesh;
315
+ const geom = new THREE.PlaneGeometry(1, 1);
316
+ const mat = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 });
317
+ const mesh = new THREE.InstancedMesh(geom, mat, f.count);
318
+ const m = new THREE.Matrix4();
319
+ const q = new THREE.Quaternion();
320
+ const z = new THREE.Vector3(0, 0, 1);
321
+ const n = new THREE.Vector3();
322
+ const p = new THREE.Vector3();
323
+ const s = new THREE.Vector3(f.spacing * 0.94, f.spacing * 0.94, 1);
324
+ const stops = rampStops(f.ramp);
325
+ const c = [0, 0, 0];
326
+ const col = new THREE.Color();
327
+ for (let i = 0; i < f.count; i++) {
328
+ n.set(f.normals[i * 3], f.normals[i * 3 + 1], f.normals[i * 3 + 2]);
329
+ q.setFromUnitVectors(z, n);
330
+ p.set(f.positions[i * 3], f.positions[i * 3 + 1], f.positions[i * 3 + 2]).addScaledVector(n, 0.08);
331
+ m.compose(p, q, s);
332
+ mesh.setMatrixAt(i, m);
333
+ rampColor(stops, (f.values[i] - f.min) / Math.max(1e-9, f.max - f.min), c);
334
+ col.setRGB(c[0], c[1], c[2], THREE.SRGBColorSpace);
335
+ mesh.setColorAt(i, col);
336
+ }
337
+ mesh.instanceMatrix.needsUpdate = true;
338
+ if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
339
+ mesh.computeBoundingSphere();
340
+ f._mesh = mesh;
341
+ return mesh;
342
+ }
343
+
344
+ /** Legend for the active result, else for the coloured layer. */
345
+ renderLegend() {
346
+ const el = document.getElementById('legend');
347
+ if (!el) return;
348
+ clear(el);
349
+ const r = this.activeResult;
350
+ let legend = null;
351
+ if (r) {
352
+ if (r.legend) legend = { title: r.title, items: r.legend };
353
+ else if (r.display?.categorical || !r.display || !(r.grid || r.facade)) legend = null; // nothing drawn by colour
354
+ else {
355
+ const stops = rampStops(r.display.ramp);
356
+ const css = stops.map((s, i) => `rgb(${s.map((x) => Math.round(x * 255)).join(',')}) ${(100 * i) / (stops.length - 1)}%`).join(',');
357
+ el.appendChild(h('div', { class: 'legend-title', text: r.title }));
358
+ el.appendChild(h('div', { class: 'legend-ramp', style: { background: `linear-gradient(90deg, ${css})` } }));
359
+ el.appendChild(h('div', { class: 'legend-scale' }, h('span', { text: fmt(r.display.min, r.unit) }), h('span', { text: fmt(r.display.max, r.unit) })));
360
+ if (r.facade) el.appendChild(h('div', { class: 'legend-note', text: 'Facades shown as coloured tiles.' }));
361
+ el.hidden = false;
362
+ return;
363
+ }
364
+ } else if (this.layerLegend) legend = this.layerLegend;
365
+ if (!legend) { el.hidden = true; return; }
366
+ el.appendChild(h('div', { class: 'legend-title', text: legend.title }));
367
+ for (const it of legend.items.slice(0, 20)) {
368
+ el.appendChild(h('div', { class: 'legend-item' }, h('span', { class: 'swatch', style: { background: it.color } }), h('span', { text: it.label })));
369
+ }
370
+ el.hidden = false;
371
+ }
372
+
373
+ // ------------------------------------------------------------ colouring
374
+ recolor(layer) {
375
+ const rule = this.rules.get(layer);
376
+ const sel = this.selection.get(layer);
377
+ const { legend } = applyRule(layer, rule.color, sel && sel.size ? sel : null);
378
+ if (rule.color.mode !== 'source') { this.layerLegend = legend; this.renderLegend(); }
379
+ }
380
+
381
+ /** Facade tiles sit on building walls: drop the cached ones when the geometry moves. */
382
+ _dropFacadeCache() {
383
+ for (const r of this.results) {
384
+ const m = r.facade?._mesh;
385
+ if (!m) continue;
386
+ this.viewer.helpers.remove(m);
387
+ m.geometry.dispose();
388
+ m.material.dispose();
389
+ delete r.facade._mesh;
390
+ }
391
+ this.facadeMesh = null;
392
+ }
393
+
394
+ setSlopeMode(mode) {
395
+ this.slopeMode = mode;
396
+ this._dropFacadeCache();
397
+ for (const l of this.layers) if (l instanceof ExtrudedLayer && !l.isWall) l.setSlopeMode(mode);
398
+ if (this.activeResult?.facade) this._showFacade(null);
399
+ this.analyst?.cache.clear();
400
+ this.emit('slope');
401
+ }
402
+
403
+ setExaggeration(ex) {
404
+ this._dropFacadeCache();
405
+ this.terrain.setExaggeration(ex);
406
+ for (const l of this.layers) l.updateElevation();
407
+ if (this.activeResult?.facade) this._showFacade(null);
408
+ this.analyst?.cache.clear();
409
+ this.emit('exaggeration');
410
+ }
411
+
412
+ // ------------------------------------------------------------ what-if trees
413
+ addPlantedTree(x, y, height = 10, radius = 3.5) {
414
+ this.plantedTrees.push([x, y, radius, height]);
415
+ const gy = this.terrain ? this.terrain.groundY(x, y) : 0;
416
+ const treeGroup = new THREE.Group();
417
+ treeGroup.position.set(x, gy, -y);
418
+
419
+ const trunkH = Math.max(0.8, height - radius);
420
+ const trunkGeom = new THREE.CylinderGeometry(0.25, 0.35, trunkH, 8);
421
+ trunkGeom.translate(0, trunkH / 2, 0);
422
+ const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5a3d28, roughness: 0.9 });
423
+ const trunk = new THREE.Mesh(trunkGeom, trunkMat);
424
+ trunk.castShadow = true;
425
+ trunk.receiveShadow = true;
426
+ trunk.layers.enable(LAYER_OCCLUDER);
427
+ treeGroup.add(trunk);
428
+
429
+ const crownGeom = new THREE.IcosahedronGeometry(radius, 1);
430
+ crownGeom.translate(0, height - radius * 0.8, 0);
431
+ const crownMat = new THREE.MeshStandardMaterial({ color: 0x3d7e3e, roughness: 0.8 });
432
+ const crown = new THREE.Mesh(crownGeom, crownMat);
433
+ crown.castShadow = true;
434
+ crown.receiveShadow = true;
435
+ crown.layers.enable(LAYER_OCCLUDER);
436
+ treeGroup.add(crown);
437
+
438
+ treeGroup.layers.enable(LAYER_OCCLUDER);
439
+ this.plantedTreesGroup.add(treeGroup);
440
+
441
+ this.analyst?.cache.clear();
442
+ this.viewer.requestRender();
443
+ this.emit('trees');
444
+ }
445
+
446
+ clearPlantedTrees() {
447
+ this.plantedTrees = [];
448
+ for (const child of [...this.plantedTreesGroup.children]) {
449
+ this.plantedTreesGroup.remove(child);
450
+ child.traverse?.((obj) => {
451
+ obj.geometry?.dispose();
452
+ if (obj.material) {
453
+ if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
454
+ else obj.material.dispose();
455
+ }
456
+ });
457
+ }
458
+ this.analyst?.cache.clear();
459
+ this.viewer.requestRender();
460
+ this.emit('trees');
461
+ }
462
+
463
+ // ------------------------------------------------------------ picking and selection
464
+ onClick(e) {
465
+ if (this.compare?.mode) return; // panels share one camera; picking is for the single view
466
+ const key = this.viewer.pickKey(e.clientX, e.clientY);
467
+ if (key) {
468
+ const { layerIndex, id } = splitKey(key);
469
+ const layer = this.layers.find((l) => l.index === layerIndex);
470
+ if (layer) {
471
+ this.select(layer, [id - 1], e.shiftKey);
472
+ this.inspect(layer, id - 1, e);
473
+ return;
474
+ }
475
+ }
476
+ const p = this.tools.groundAt(e);
477
+ if (p) {
478
+ for (const layer of this.layers) {
479
+ if (layer instanceof SurfaceLayer && layer.visible) {
480
+ const f = layer.featureAt(p.local[0], p.local[1]);
481
+ if (f >= 0) { this.inspect(layer, f, e, p); return; }
482
+ }
483
+ }
484
+ this.inspectGround(p);
485
+ }
486
+ if (!e.shiftKey) this.clearSelection();
487
+ }
488
+
489
+ select(layer, indices, add = false) {
490
+ if (!add) this.clearSelection(layer);
491
+ const set = this.selection.get(layer) || new Set();
492
+ for (const i of indices) { if (add && set.has(i)) set.delete(i); else set.add(i); }
493
+ this.selection.set(layer, set);
494
+ this.recolor(layer);
495
+ if (this.qgis && this.syncSelection && layer instanceof ExtrudedLayer) {
496
+ selectInQgis(layer, [...set].map((i) => i + 1)).catch(() => {});
497
+ }
498
+ this.emit('selection');
499
+ }
500
+
501
+ clearSelection(except = null) {
502
+ for (const [layer, set] of this.selection) {
503
+ if (layer === except || !set.size) continue;
504
+ set.clear();
505
+ this.recolor(layer);
506
+ }
507
+ this.emit('selection');
508
+ }
509
+
510
+ selectedIndices(layer) {
511
+ return [...(this.selection.get(layer) || [])];
512
+ }
513
+
514
+ async pollSelection() {
515
+ if (!this.syncSelection) return;
516
+ try {
517
+ const sel = await qgisSelection();
518
+ if (sel.seq === this._selSeq) return;
519
+ this._selSeq = sel.seq;
520
+ for (const layer of this.layers) {
521
+ const ids = sel.layers?.[layer.entry.id];
522
+ if (!ids) continue;
523
+ this.selection.set(layer, new Set(ids.map((i) => i - 1)));
524
+ this.recolor(layer);
525
+ }
526
+ this.emit('selection');
527
+ } catch { /* the plugin may have closed; keep the viewer usable */ }
528
+ }
529
+
530
+ resultValueAt(x, y) {
531
+ const r = this.activeResult;
532
+ if (!r?.grid) return null;
533
+ const g = r.grid;
534
+ const c = Math.round((x - g.minX) / g.cell);
535
+ const row = Math.round((g.maxY - y) / g.cell);
536
+ if (c < 0 || row < 0 || c >= g.cols || row >= g.rows) return null;
537
+ const v = r.values[row * g.cols + c];
538
+ return Number.isFinite(v) ? v : null;
539
+ }
540
+
541
+ inspect(layer, f, e, ground = null) {
542
+ const el = /** @type {HTMLElement} */ (document.getElementById('inspector'));
543
+ clear(el);
544
+ const props = layer.features[f].properties;
545
+ const rows = [];
546
+ for (const [k, v] of Object.entries(props)) {
547
+ if (k.startsWith('_') || v === null || v === '') continue;
548
+ rows.push([k, typeof v === 'number' ? fmt(v) : String(v)]);
549
+ }
550
+ const results = [];
551
+ for (const [name, arr] of layer.extraFields) if (Number.isFinite(arr[f])) results.push([name, fmt(arr[f])]);
552
+ el.appendChild(h('div', { class: 'insp-head' }, h('b', { text: layer.entry.name }), h('button', { class: 'close', text: '×', onclick: () => { el.hidden = true; } })));
553
+ const meta = [];
554
+ if (layer instanceof ExtrudedLayer) {
555
+ meta.push(['Height', `${layer.heights[f].toFixed(1)} m`], ['Ground', `${layer.baseZ[f].toFixed(1)} m`]);
556
+ if (!layer.isWall) meta.push(['Footprint', `${layer.footprint[f].toFixed(0)} m²`]);
557
+ }
558
+ if (ground) {
559
+ const v = this.resultValueAt(ground.local[0], ground.local[1]);
560
+ if (v !== null) meta.push([this.activeResult.title, fmt(v, this.activeResult.unit)]);
561
+ }
562
+ const tbl = (pairs) => h('table', { class: 'stats' }, ...pairs.map(([k, v]) => h('tr', {}, h('td', { text: k }), h('td', { text: v }))));
563
+ if (layer instanceof ExtrudedLayer && !layer.isWall) {
564
+ // Buildings get the full report: facts, ranked results, one-click analysis, PDF.
565
+ el.appendChild(reportBlock(this, layer, f));
566
+ if (ground) { const v = this.resultValueAt(ground.local[0], ground.local[1]); if (v !== null) el.appendChild(tbl([[this.activeResult.title, fmt(v, this.activeResult.unit)]])); }
567
+ if (rows.length) { el.appendChild(h('div', { class: 'insp-sub', text: 'Attributes' })); el.appendChild(tbl(rows.slice(0, 60))); }
568
+ el.hidden = false;
569
+ return;
570
+ }
571
+ if (meta.length) el.appendChild(tbl(meta));
572
+ if (results.length) { el.appendChild(h('div', { class: 'insp-sub', text: 'Analysis results' })); el.appendChild(tbl(results)); }
573
+ if (rows.length) { el.appendChild(h('div', { class: 'insp-sub', text: 'Attributes' })); el.appendChild(tbl(rows.slice(0, 60))); }
574
+ el.hidden = false;
575
+ void e;
576
+ }
577
+
578
+ inspectGround(p) {
579
+ const el = /** @type {HTMLElement} */ (document.getElementById('inspector'));
580
+ clear(el);
581
+ const [x, y] = p.local;
582
+ const z = this.terrain.heightAt(x, y);
583
+ const g = this.terrain.dataGrid;
584
+ const c = Math.round((x - g.minX) / g.cell), r = Math.round((g.maxY - y) / g.cell);
585
+ const pairs = [['Elevation', `${z.toFixed(1)} m`]];
586
+ const sa = c >= 0 && r >= 0 && c < g.cols && r < g.rows && this.terrain.max - this.terrain.min > 0.5 ? slopeAspectAt(g, c, r) : null;
587
+ if (sa && Number.isFinite(sa.slope)) {
588
+ pairs.push(['Slope', `${sa.slope.toFixed(1)}°`], ['Aspect', sa.aspect < 0 ? 'flat' : `${sa.aspect.toFixed(0)}°`]);
589
+ }
590
+ const v = this.resultValueAt(x, y);
591
+ if (v !== null) pairs.push([this.activeResult.title, fmt(v, this.activeResult.unit)]);
592
+ el.appendChild(h('div', { class: 'insp-head' }, h('b', { text: 'Ground' }), h('button', { class: 'close', text: '×', onclick: () => { el.hidden = true; } })));
593
+ el.appendChild(h('table', { class: 'stats' }, ...pairs.map(([k, val]) => h('tr', {}, h('td', { text: k }), h('td', { text: val })))));
594
+
595
+ const diurnalCanvas = h('canvas', { class: 'chart' });
596
+ const sunCanvas = h('canvas', { class: 'chart' });
597
+ el.appendChild(h('div', { class: 'insp-sub', text: '24 h microclimate at this point' }));
598
+ el.appendChild(diurnalCanvas);
599
+ el.appendChild(h('div', { class: 'insp-sub', text: 'Solar path' }));
600
+ el.appendChild(sunCanvas);
601
+
602
+ el.hidden = false;
603
+
604
+ requestAnimationFrame(() => {
605
+ const mat = this.params?.comfort?.material || 'concrete';
606
+ const lai = this.params?.comfort?.lai ?? 3.0;
607
+ const prof = diurnalPointProfile({
608
+ lat: this.site.lat, lon: this.site.lon, utcOffset: this.site.utcOffset,
609
+ month: this.date.month, day: this.date.day, weather: this.weather,
610
+ airTempMean: (this.params?.comfort?.airTemp ?? 30) - 3, airTempAmp: 5,
611
+ rhMean: this.params?.comfort?.rh ?? 50, windSpeed: this.params?.comfort?.windSpeed ?? 2,
612
+ svf: 0.75, material: mat, treeCrown: false, lai
613
+ });
614
+ drawDiurnalChart(diurnalCanvas, prof, 155);
615
+ drawSunPathDiagram(sunCanvas, {
616
+ lat: this.site.lat, lon: this.site.lon, utcOffset: this.site.utcOffset,
617
+ month: this.date.month, day: this.date.day, hour: this.time,
618
+ solarPosition, typicalDoy
619
+ }, 175);
620
+ });
621
+ }
622
+
623
+ terrainStats() {
624
+ return gridStats(this.terrain.dataGrid.values);
625
+ }
626
+
627
+ slopeGrid() {
628
+ return slopeAspect(this.terrain.dataGrid);
629
+ }
630
+ }