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.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- 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
|
+
}
|