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,275 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Renderer, camera, controls, lights and the render-on-demand loop.
|
|
3
|
+
//
|
|
4
|
+
// Axes: +x east, +y up, -z north (local scene metres). Three.js object
|
|
5
|
+
// layers decide what each analysis pass sees:
|
|
6
|
+
// 0 visible drawn on screen
|
|
7
|
+
// 1 SURFACE surfaces analysed from above (terrain, roofs)
|
|
8
|
+
// 2 OCCLUDER everything that blocks sun, sky or view (adds trees, walls)
|
|
9
|
+
// 3 ID features that carry a feature key (for per-feature results)
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
12
|
+
|
|
13
|
+
export const LAYER_SURFACE = 1;
|
|
14
|
+
export const LAYER_OCCLUDER = 2;
|
|
15
|
+
export const LAYER_ID = 3;
|
|
16
|
+
/** Feature keys are layerIndex * KEY_SHIFT + id (objects.js featureKey). */
|
|
17
|
+
export const KEY_SHIFT = 1 << 20;
|
|
18
|
+
|
|
19
|
+
// Feature keys in the ID pass: the layer index (1..255) in alpha, the
|
|
20
|
+
// feature id (< 2^20) in RGB. Two attributes, because one float32 holds a
|
|
21
|
+
// whole key (layer * 2^20 + id) exactly only up to layer 15.
|
|
22
|
+
const ID_VERTEX = `
|
|
23
|
+
attribute float fkey;
|
|
24
|
+
attribute float flayer;
|
|
25
|
+
varying float vKey;
|
|
26
|
+
varying float vLayer;
|
|
27
|
+
#include <common>
|
|
28
|
+
void main() {
|
|
29
|
+
vKey = fkey;
|
|
30
|
+
vLayer = flayer;
|
|
31
|
+
#include <begin_vertex>
|
|
32
|
+
#include <project_vertex>
|
|
33
|
+
}`;
|
|
34
|
+
const ID_FRAGMENT = `
|
|
35
|
+
precision highp float;
|
|
36
|
+
varying float vKey;
|
|
37
|
+
varying float vLayer;
|
|
38
|
+
void main() {
|
|
39
|
+
float k = floor(vKey + 0.5);
|
|
40
|
+
float r = floor(k / 65536.0);
|
|
41
|
+
float g = floor((k - r * 65536.0) / 256.0);
|
|
42
|
+
float b = k - r * 65536.0 - g * 256.0;
|
|
43
|
+
gl_FragColor = vec4(r / 255.0, g / 255.0, b / 255.0, floor(vLayer + 0.5) / 255.0);
|
|
44
|
+
}`;
|
|
45
|
+
|
|
46
|
+
export function makeIdMaterial() {
|
|
47
|
+
return new THREE.ShaderMaterial({ vertexShader: ID_VERTEX, fragmentShader: ID_FRAGMENT, side: THREE.DoubleSide });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Feature key from an ID-pass pixel (a = layer index, rgb = feature id). */
|
|
51
|
+
export function decodeKey(r, g, b, a) {
|
|
52
|
+
return a * KEY_SHIFT + r * 65536 + g * 256 + b;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class Viewer {
|
|
56
|
+
/** @param {HTMLElement} container */
|
|
57
|
+
constructor(container) {
|
|
58
|
+
this.container = container;
|
|
59
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, logarithmicDepthBuffer: false });
|
|
60
|
+
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
|
61
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
62
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
63
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
64
|
+
renderer.toneMappingExposure = 1.05;
|
|
65
|
+
renderer.shadowMap.enabled = true;
|
|
66
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
67
|
+
container.appendChild(renderer.domElement);
|
|
68
|
+
this.renderer = renderer;
|
|
69
|
+
|
|
70
|
+
this.scene = new THREE.Scene();
|
|
71
|
+
this.scene.background = new THREE.Color(0xdfe8f0);
|
|
72
|
+
this.camera = new THREE.PerspectiveCamera(45, container.clientWidth / Math.max(1, container.clientHeight), 1, 60000);
|
|
73
|
+
this.camera.position.set(400, 500, 600);
|
|
74
|
+
this.controls = new OrbitControls(this.camera, renderer.domElement);
|
|
75
|
+
this.controls.enableDamping = true;
|
|
76
|
+
this.controls.dampingFactor = 0.12;
|
|
77
|
+
this.controls.maxPolarAngle = Math.PI * 0.495;
|
|
78
|
+
this.controls.screenSpacePanning = true;
|
|
79
|
+
this.controls.addEventListener('change', () => this.requestRender());
|
|
80
|
+
|
|
81
|
+
this.hemi = new THREE.HemisphereLight(0xeaf2ff, 0x8a8174, 1.2);
|
|
82
|
+
this.scene.add(this.hemi);
|
|
83
|
+
this.sun = new THREE.DirectionalLight(0xfff4e0, 2.4);
|
|
84
|
+
this.sun.castShadow = true;
|
|
85
|
+
this.sun.shadow.mapSize.set(4096, 4096);
|
|
86
|
+
this.sun.shadow.bias = -0.0004;
|
|
87
|
+
this.sun.shadow.normalBias = 0.6;
|
|
88
|
+
this.scene.add(this.sun);
|
|
89
|
+
this.scene.add(this.sun.target);
|
|
90
|
+
this.sunDir = new THREE.Vector3(0.3, 0.8, -0.4).normalize();
|
|
91
|
+
this.shadowsOn = true;
|
|
92
|
+
|
|
93
|
+
this.world = new THREE.Group();
|
|
94
|
+
this.scene.add(this.world);
|
|
95
|
+
this.helpers = new THREE.Group();
|
|
96
|
+
this.scene.add(this.helpers);
|
|
97
|
+
|
|
98
|
+
this.idMaterial = makeIdMaterial();
|
|
99
|
+
this._pickTarget = new THREE.WebGLRenderTarget(1, 1, { type: THREE.UnsignedByteType });
|
|
100
|
+
this._dirty = true;
|
|
101
|
+
this._frameHooks = [];
|
|
102
|
+
this.extentRadius = 1000;
|
|
103
|
+
this.stats = { fps: 0, frames: 0, last: performance.now(), drawCalls: 0, triangles: 0 };
|
|
104
|
+
this.renderHook = null; // compare views draw several panels instead of one view
|
|
105
|
+
this._postRender = []; // run right after a frame is drawn (video compositing)
|
|
106
|
+
|
|
107
|
+
window.addEventListener('resize', () => this.resize());
|
|
108
|
+
if ('ResizeObserver' in window) new ResizeObserver(() => this.resize()).observe(container);
|
|
109
|
+
renderer.setAnimationLoop(() => this._loop());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
resize() {
|
|
113
|
+
const w = this.container.clientWidth;
|
|
114
|
+
const h = this.container.clientHeight;
|
|
115
|
+
if (!w || !h) return;
|
|
116
|
+
this.renderer.setSize(w, h);
|
|
117
|
+
this.camera.aspect = w / h;
|
|
118
|
+
this.camera.updateProjectionMatrix();
|
|
119
|
+
this.requestRender();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
requestRender() {
|
|
123
|
+
this._dirty = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Draw the view once: one camera, or the compare panels. */
|
|
127
|
+
draw() {
|
|
128
|
+
if (this.renderHook) this.renderHook();
|
|
129
|
+
else this.renderer.render(this.scene, this.camera);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onPostRender(fn) {
|
|
133
|
+
this._postRender.push(fn);
|
|
134
|
+
return () => { this._postRender = this._postRender.filter((x) => x !== fn); };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
onFrame(fn) {
|
|
138
|
+
this._frameHooks.push(fn);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_loop() {
|
|
142
|
+
const moved = this.controls.update();
|
|
143
|
+
for (const fn of this._frameHooks) if (fn()) this._dirty = true;
|
|
144
|
+
if (!this._dirty && !moved) return;
|
|
145
|
+
this._dirty = false;
|
|
146
|
+
this._fitClipping();
|
|
147
|
+
this._fitShadow();
|
|
148
|
+
this.draw();
|
|
149
|
+
for (const fn of this._postRender) fn();
|
|
150
|
+
const s = this.stats;
|
|
151
|
+
s.frames++;
|
|
152
|
+
s.drawCalls = this.renderer.info.render.calls;
|
|
153
|
+
s.triangles = this.renderer.info.render.triangles;
|
|
154
|
+
const now = performance.now();
|
|
155
|
+
if (now - s.last > 1000) { s.fps = (s.frames * 1000) / (now - s.last); s.frames = 0; s.last = now; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Sun direction (unit vector towards the sun) and whether it is up. */
|
|
159
|
+
setSun(dir) {
|
|
160
|
+
this.sunDir.set(dir[0], dir[1], dir[2]).normalize();
|
|
161
|
+
const up = this.sunDir.y > 0.005;
|
|
162
|
+
this.sun.visible = up;
|
|
163
|
+
// Dimmer, warmer light near the horizon; sky ambience follows.
|
|
164
|
+
const elev = Math.asin(Math.max(-1, Math.min(1, this.sunDir.y)));
|
|
165
|
+
const k = Math.max(0, Math.min(1, elev / 0.5));
|
|
166
|
+
this.sun.intensity = up ? 0.6 + 2.0 * k : 0;
|
|
167
|
+
this.sun.color.setRGB(1, 0.82 + 0.16 * k, 0.62 + 0.3 * k);
|
|
168
|
+
this.hemi.intensity = 0.35 + 0.9 * Math.max(0, Math.min(1, (elev + 0.1) / 0.6));
|
|
169
|
+
const sky = new THREE.Color().setRGB(0.55 + 0.33 * k, 0.62 + 0.29 * k, 0.72 + 0.22 * k);
|
|
170
|
+
if (!up) sky.setRGB(0.16, 0.19, 0.26);
|
|
171
|
+
this.scene.background = sky;
|
|
172
|
+
this.requestRender();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setShadows(on) {
|
|
176
|
+
this.shadowsOn = on;
|
|
177
|
+
this.sun.castShadow = on;
|
|
178
|
+
this.requestRender();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Near/far planes follow the camera distance, so a 50 km scene keeps depth precision up close. */
|
|
182
|
+
_fitClipping() {
|
|
183
|
+
const cam = this.camera;
|
|
184
|
+
const dist = cam.position.distanceTo(this.controls.target);
|
|
185
|
+
const near = Math.max(0.2, Math.min(50, dist / 1500));
|
|
186
|
+
const far = Math.max(dist * 40, this.extentRadius * 6, 5000);
|
|
187
|
+
if (Math.abs(cam.near - near) / near > 0.1 || Math.abs(cam.far - far) / far > 0.1) {
|
|
188
|
+
cam.near = near;
|
|
189
|
+
cam.far = far;
|
|
190
|
+
cam.updateProjectionMatrix();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Fit the sun's shadow camera around the area the camera looks at. */
|
|
195
|
+
_fitShadow() {
|
|
196
|
+
if (!this.sun.visible || !this.shadowsOn) return;
|
|
197
|
+
const target = this.controls.target;
|
|
198
|
+
const dist = this.camera.position.distanceTo(target);
|
|
199
|
+
const r = Math.max(120, Math.min(this.extentRadius, dist * 1.2));
|
|
200
|
+
const cam = this.sun.shadow.camera;
|
|
201
|
+
cam.left = -r; cam.right = r; cam.top = r; cam.bottom = -r;
|
|
202
|
+
cam.near = 1; cam.far = 4 * r + 2000;
|
|
203
|
+
cam.updateProjectionMatrix();
|
|
204
|
+
this.sun.position.copy(target).addScaledVector(this.sunDir, 2 * r + 800);
|
|
205
|
+
this.sun.target.position.copy(target);
|
|
206
|
+
this.sun.target.updateMatrixWorld();
|
|
207
|
+
// Bias in proportion to the shadow-map texel: enough to avoid acne, small
|
|
208
|
+
// enough that shadows still touch the walls that cast them up close.
|
|
209
|
+
this.sun.shadow.normalBias = Math.min(1.5, Math.max(0.03, ((2 * r) / 4096) * 0.8));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Frame a box (three coordinates). */
|
|
213
|
+
frame(box, { pitch = 0.9, yaw = -0.6 } = {}) {
|
|
214
|
+
const centre = box.getCenter(new THREE.Vector3());
|
|
215
|
+
const size = box.getSize(new THREE.Vector3());
|
|
216
|
+
const radius = Math.max(50, Math.hypot(size.x, size.z) / 2);
|
|
217
|
+
this.extentRadius = Math.max(300, radius * 1.4);
|
|
218
|
+
const dist = radius / Math.tan((this.camera.fov * Math.PI) / 360) * 0.9;
|
|
219
|
+
this.controls.target.copy(centre);
|
|
220
|
+
this.camera.position.set(centre.x + Math.sin(yaw) * Math.cos(pitch) * dist,
|
|
221
|
+
centre.y + Math.sin(pitch) * dist, centre.z + Math.cos(yaw) * Math.cos(pitch) * dist);
|
|
222
|
+
this.camera.near = Math.max(0.5, dist / 2000);
|
|
223
|
+
this.camera.far = Math.max(20000, dist * 20);
|
|
224
|
+
this.camera.updateProjectionMatrix();
|
|
225
|
+
this.controls.maxDistance = dist * 6;
|
|
226
|
+
this.controls.update();
|
|
227
|
+
this.requestRender();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Feature key under a screen position (client pixels), 0 for none. */
|
|
231
|
+
pickKey(clientX, clientY) {
|
|
232
|
+
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
233
|
+
const x = clientX - rect.left;
|
|
234
|
+
const y = clientY - rect.top;
|
|
235
|
+
const w = rect.width;
|
|
236
|
+
const h = rect.height;
|
|
237
|
+
const cam = this.camera;
|
|
238
|
+
cam.setViewOffset(w, h, Math.floor(x), Math.floor(y), 1, 1);
|
|
239
|
+
const prevBg = this.scene.background;
|
|
240
|
+
const prevOverride = this.scene.overrideMaterial;
|
|
241
|
+
const prevMask = cam.layers.mask;
|
|
242
|
+
this.scene.background = null;
|
|
243
|
+
this.scene.overrideMaterial = this.idMaterial;
|
|
244
|
+
cam.layers.set(LAYER_ID);
|
|
245
|
+
const r = this.renderer;
|
|
246
|
+
const prevTarget = r.getRenderTarget();
|
|
247
|
+
const clear = r.getClearColor(new THREE.Color());
|
|
248
|
+
const alpha = r.getClearAlpha();
|
|
249
|
+
r.setRenderTarget(this._pickTarget);
|
|
250
|
+
r.setClearColor(0x000000, 0);
|
|
251
|
+
r.clear();
|
|
252
|
+
r.render(this.scene, cam);
|
|
253
|
+
const px = new Uint8Array(4);
|
|
254
|
+
r.readRenderTargetPixels(this._pickTarget, 0, 0, 1, 1, px);
|
|
255
|
+
r.setRenderTarget(prevTarget);
|
|
256
|
+
r.setClearColor(clear, alpha);
|
|
257
|
+
cam.clearViewOffset();
|
|
258
|
+
cam.layers.mask = prevMask;
|
|
259
|
+
this.scene.background = prevBg;
|
|
260
|
+
this.scene.overrideMaterial = prevOverride;
|
|
261
|
+
this.requestRender();
|
|
262
|
+
return px[3] > 0 ? decodeKey(px[0], px[1], px[2], px[3]) : 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Normalised device coordinates of a client position. */
|
|
266
|
+
ndc(clientX, clientY) {
|
|
267
|
+
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
268
|
+
return new THREE.Vector2(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
screenshot() {
|
|
272
|
+
this.renderer.render(this.scene, this.camera);
|
|
273
|
+
return this.renderer.domElement.toDataURL('image/png');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 3D animated wind streamline and particle flow visualization for the Three.js viewer.
|
|
3
|
+
// Deflects wind smoothly around solid obstacles and colors lines by local pedestrian speed.
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { rampStops, rampColor } from '../core/classify.js';
|
|
7
|
+
|
|
8
|
+
const getRaf = () => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 16));
|
|
9
|
+
const getCaf = () => (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id) => clearTimeout(id));
|
|
10
|
+
|
|
11
|
+
export class WindStreamlines {
|
|
12
|
+
/**
|
|
13
|
+
* @param {any} app Reference to the main 3D application instance
|
|
14
|
+
*/
|
|
15
|
+
constructor(app) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
this.group = new THREE.Group();
|
|
18
|
+
this.group.name = 'wind_streamlines';
|
|
19
|
+
this.active = false;
|
|
20
|
+
this._raf = 0;
|
|
21
|
+
this._lastTime = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generates and starts animating wind streamlines.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} params
|
|
28
|
+
* @param {{ cols: number, rows: number, cell: number, minX: number, maxY: number }} params.grid Grid dimension metadata
|
|
29
|
+
* @param {Float32Array} params.values Local wind speed ratios or values per cell (NaN inside buildings)
|
|
30
|
+
* @param {Int32Array|Float32Array|Array<number>|null} [params.ids] Optional building ID mask
|
|
31
|
+
* @param {number} [params.windFromDeg] Meteorological wind direction in degrees (default 270)
|
|
32
|
+
* @param {number} [params.speed10] Reference wind speed at 10 m height in m/s (default 4)
|
|
33
|
+
* @param {number} [params.count] Total number of animated streamlines (default 450)
|
|
34
|
+
* @param {number} [params.trailSteps] Number of simulation vertices per streamline (default 14)
|
|
35
|
+
*/
|
|
36
|
+
show({
|
|
37
|
+
grid,
|
|
38
|
+
values,
|
|
39
|
+
ids = null,
|
|
40
|
+
windFromDeg = 270,
|
|
41
|
+
speed10 = 4,
|
|
42
|
+
count = 450,
|
|
43
|
+
trailSteps = 14
|
|
44
|
+
}) {
|
|
45
|
+
this.hide();
|
|
46
|
+
this.active = true;
|
|
47
|
+
|
|
48
|
+
const { cols, rows, cell, minX = 0, maxY = 0 } = grid;
|
|
49
|
+
if (cols <= 0 || rows <= 0 || cell <= 0) return;
|
|
50
|
+
|
|
51
|
+
// 1. Two-pass chamfer distance field to nearest building obstacle in metres
|
|
52
|
+
const INF = 1e6;
|
|
53
|
+
const distField = new Float32Array(cols * rows);
|
|
54
|
+
for (let i = 0; i < cols * rows; i++) {
|
|
55
|
+
distField[i] = ((ids && ids[i] > 0) || !Number.isFinite(values[i])) ? 0 : INF;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const d1 = cell;
|
|
59
|
+
const d2 = cell * Math.SQRT2;
|
|
60
|
+
for (let r = 0; r < rows; r++) {
|
|
61
|
+
for (let c = 0; c < cols; c++) {
|
|
62
|
+
const i = r * cols + c;
|
|
63
|
+
let v = distField[i];
|
|
64
|
+
if (c > 0) v = Math.min(v, distField[i - 1] + d1);
|
|
65
|
+
if (r > 0) {
|
|
66
|
+
v = Math.min(v, distField[i - cols] + d1);
|
|
67
|
+
if (c > 0) v = Math.min(v, distField[i - cols - 1] + d2);
|
|
68
|
+
if (c < cols - 1) v = Math.min(v, distField[i - cols + 1] + d2);
|
|
69
|
+
}
|
|
70
|
+
distField[i] = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (let r = rows - 1; r >= 0; r--) {
|
|
74
|
+
for (let c = cols - 1; c >= 0; c--) {
|
|
75
|
+
const i = r * cols + c;
|
|
76
|
+
let v = distField[i];
|
|
77
|
+
if (c < cols - 1) v = Math.min(v, distField[i + 1] + d1);
|
|
78
|
+
if (r < rows - 1) {
|
|
79
|
+
v = Math.min(v, distField[i + cols] + d1);
|
|
80
|
+
if (c < cols - 1) v = Math.min(v, distField[i + cols + 1] + d2);
|
|
81
|
+
if (c > 0) v = Math.min(v, distField[i + cols - 1] + d2);
|
|
82
|
+
}
|
|
83
|
+
distField[i] = v;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Base wind vector (blows towards toRad)
|
|
88
|
+
const toRad = (((windFromDeg + 180) % 360) * Math.PI) / 180;
|
|
89
|
+
const wx = Math.sin(toRad); // +x = east
|
|
90
|
+
const wy = Math.cos(toRad); // +y = north
|
|
91
|
+
|
|
92
|
+
// Helper to evaluate local deflected velocity and speed at world coordinate (x, y)
|
|
93
|
+
const sampleFlow = (x, y) => {
|
|
94
|
+
const col = (x - minX) / cell - 0.5;
|
|
95
|
+
const row = (maxY - y) / cell - 0.5;
|
|
96
|
+
const ic = Math.max(0, Math.min(cols - 1, Math.round(col)));
|
|
97
|
+
const ir = Math.max(0, Math.min(rows - 1, Math.round(row)));
|
|
98
|
+
const idx = ir * cols + ic;
|
|
99
|
+
|
|
100
|
+
const dVal = distField[idx];
|
|
101
|
+
const cl = Math.max(0, ic - 1);
|
|
102
|
+
const cr = Math.min(cols - 1, ic + 1);
|
|
103
|
+
const ru = Math.max(0, ir - 1);
|
|
104
|
+
const rd = Math.min(rows - 1, ir + 1);
|
|
105
|
+
|
|
106
|
+
// Distance gradient pointing away from obstacle
|
|
107
|
+
const gradX = (distField[ir * cols + cr] - distField[ir * cols + cl]) / (cell * Math.max(1, cr - cl));
|
|
108
|
+
const gradY = -(distField[rd * cols + ic] - distField[ru * cols + ic]) / (cell * Math.max(1, rd - ru));
|
|
109
|
+
const gLen = Math.hypot(gradX, gradY);
|
|
110
|
+
|
|
111
|
+
let vx = wx;
|
|
112
|
+
let vy = wy;
|
|
113
|
+
|
|
114
|
+
if (dVal < 4 * cell && gLen > 1e-4) {
|
|
115
|
+
const nx = gradX / gLen;
|
|
116
|
+
const ny = gradY / gLen;
|
|
117
|
+
const dot = wx * nx + wy * ny;
|
|
118
|
+
if (dot < 0) {
|
|
119
|
+
// Deflect along the obstacle tangent
|
|
120
|
+
const tx = -ny;
|
|
121
|
+
const ty = nx;
|
|
122
|
+
const sign = (wx * tx + wy * ty >= 0) ? 1 : -1;
|
|
123
|
+
const tanX = sign * tx;
|
|
124
|
+
const tanY = sign * ty;
|
|
125
|
+
const blend = Math.exp(-dVal / (1.5 * cell));
|
|
126
|
+
vx = (1 - blend) * wx + blend * tanX;
|
|
127
|
+
vy = (1 - blend) * wy + blend * tanY;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const vLen = Math.hypot(vx, vy);
|
|
132
|
+
if (vLen > 1e-5) {
|
|
133
|
+
vx /= vLen;
|
|
134
|
+
vy /= vLen;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rawRatio = Number.isFinite(values[idx]) ? values[idx] : 0.8;
|
|
138
|
+
const spd = speed10 * Math.max(0.1, Math.min(2.0, rawRatio));
|
|
139
|
+
return { vx, vy, speed: spd };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// 3. Find open seed locations
|
|
143
|
+
const openCells = [];
|
|
144
|
+
for (let r = 0; r < rows; r++) {
|
|
145
|
+
for (let c = 0; c < cols; c++) {
|
|
146
|
+
if (distField[r * cols + c] > cell * 0.4) {
|
|
147
|
+
openCells.push({ c, r });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (openCells.length === 0) return;
|
|
152
|
+
|
|
153
|
+
// 4. Trace count streamlines
|
|
154
|
+
const stops = rampStops('spectral-r');
|
|
155
|
+
const stepDist = cell * 1.0;
|
|
156
|
+
const streamlines = [];
|
|
157
|
+
|
|
158
|
+
for (let s = 0; s < count; s++) {
|
|
159
|
+
const seedCell = openCells[Math.floor(Math.random() * openCells.length)];
|
|
160
|
+
let curX = minX + (seedCell.c + 0.5 + (Math.random() - 0.5) * 0.6) * cell;
|
|
161
|
+
let curY = maxY - (seedCell.r + 0.5 + (Math.random() - 0.5) * 0.6) * cell;
|
|
162
|
+
|
|
163
|
+
const pts = [];
|
|
164
|
+
let speedSum = 0;
|
|
165
|
+
|
|
166
|
+
for (let step = 0; step < trailSteps; step++) {
|
|
167
|
+
const flow = sampleFlow(curX, curY);
|
|
168
|
+
speedSum += flow.speed;
|
|
169
|
+
|
|
170
|
+
const ground = (this.app?.terrain?.groundY) ? this.app.terrain.groundY(curX, curY) : 0;
|
|
171
|
+
// Three.js scene axes: +x east, +y up, -z north
|
|
172
|
+
const threeX = curX;
|
|
173
|
+
const threeY = ground + 1.8;
|
|
174
|
+
const threeZ = -curY;
|
|
175
|
+
|
|
176
|
+
const normSpeed = Math.max(0, Math.min(1, flow.speed / (speed10 * 1.6)));
|
|
177
|
+
const col = rampColor(stops, normSpeed);
|
|
178
|
+
|
|
179
|
+
pts.push({
|
|
180
|
+
pos: [threeX, threeY, threeZ],
|
|
181
|
+
col: [col[0], col[1], col[2]],
|
|
182
|
+
speed: flow.speed
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
curX += flow.vx * stepDist;
|
|
186
|
+
curY += flow.vy * stepDist;
|
|
187
|
+
|
|
188
|
+
// A streamline that leaves the grid ends there; respawning it inside
|
|
189
|
+
// would draw a stray segment from the edge to the new point.
|
|
190
|
+
if (curX < minX || curX > minX + cols * cell || curY < maxY - rows * cell || curY > maxY) break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
streamlines.push({
|
|
194
|
+
pts,
|
|
195
|
+
meanSpeed: speedSum / pts.length,
|
|
196
|
+
phase: Math.random()
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 5. Construct THREE.LineSegments geometry for animated streaks
|
|
201
|
+
// Each streamline renders an animated head-tail streak of sub-segments
|
|
202
|
+
const segmentsPerStreak = 3;
|
|
203
|
+
const vertexCount = count * segmentsPerStreak * 2;
|
|
204
|
+
const positions = new Float32Array(vertexCount * 3);
|
|
205
|
+
const colors = new Float32Array(vertexCount * 3);
|
|
206
|
+
|
|
207
|
+
const geometry = new THREE.BufferGeometry();
|
|
208
|
+
const posAttr = new THREE.BufferAttribute(positions, 3);
|
|
209
|
+
const colAttr = new THREE.BufferAttribute(colors, 3);
|
|
210
|
+
geometry.setAttribute('position', posAttr);
|
|
211
|
+
geometry.setAttribute('color', colAttr);
|
|
212
|
+
|
|
213
|
+
const material = new THREE.LineBasicMaterial({
|
|
214
|
+
vertexColors: true,
|
|
215
|
+
transparent: true,
|
|
216
|
+
opacity: 0.88,
|
|
217
|
+
depthWrite: false
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const lines = new THREE.LineSegments(geometry, material);
|
|
221
|
+
lines.name = 'streamlines_mesh';
|
|
222
|
+
this.group.add(lines);
|
|
223
|
+
|
|
224
|
+
if (this.app?.viewer?.helpers && this.group.parent !== this.app.viewer.helpers) {
|
|
225
|
+
this.app.viewer.helpers.add(this.group);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 6. Animation loop advancing phase along each streamline
|
|
229
|
+
this._lastTime = performance.now();
|
|
230
|
+
|
|
231
|
+
const animateLoop = (time) => {
|
|
232
|
+
if (!this.active) return;
|
|
233
|
+
// Paused while an analysis runs: every frame redraws the whole scene,
|
|
234
|
+
// which competes with the analysis for the GPU and the main thread
|
|
235
|
+
// (six times slower in a software-rendered test).
|
|
236
|
+
if (this.app?.busy) {
|
|
237
|
+
this._lastTime = time;
|
|
238
|
+
this._raf = getRaf()(animateLoop);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const dt = Math.min(0.05, (time - this._lastTime) * 0.001);
|
|
243
|
+
this._lastTime = time;
|
|
244
|
+
|
|
245
|
+
let vOffset = 0;
|
|
246
|
+
|
|
247
|
+
for (let s = 0; s < count; s++) {
|
|
248
|
+
const sl = streamlines[s];
|
|
249
|
+
sl.phase = (sl.phase + dt * (sl.meanSpeed / Math.max(1, speed10)) * 0.35) % 1.0;
|
|
250
|
+
|
|
251
|
+
const numPts = sl.pts.length;
|
|
252
|
+
const headIdxFloat = sl.phase * (numPts - 1);
|
|
253
|
+
|
|
254
|
+
for (let seg = 0; seg < segmentsPerStreak; seg++) {
|
|
255
|
+
const tailFrac = seg / segmentsPerStreak;
|
|
256
|
+
const nextFrac = (seg + 1) / segmentsPerStreak;
|
|
257
|
+
|
|
258
|
+
const p0Index = Math.max(0, Math.min(numPts - 1, Math.floor(headIdxFloat - (1 - tailFrac) * 3)));
|
|
259
|
+
const p1Index = Math.max(0, Math.min(numPts - 1, Math.floor(headIdxFloat - (1 - nextFrac) * 3)));
|
|
260
|
+
|
|
261
|
+
const ptA = sl.pts[p0Index];
|
|
262
|
+
const ptB = sl.pts[p1Index];
|
|
263
|
+
|
|
264
|
+
const alphaA = 0.2 + 0.8 * tailFrac;
|
|
265
|
+
const alphaB = 0.2 + 0.8 * nextFrac;
|
|
266
|
+
|
|
267
|
+
// Vertex 1
|
|
268
|
+
positions[vOffset] = ptA.pos[0];
|
|
269
|
+
positions[vOffset + 1] = ptA.pos[1];
|
|
270
|
+
positions[vOffset + 2] = ptA.pos[2];
|
|
271
|
+
colors[vOffset] = ptA.col[0] * alphaA;
|
|
272
|
+
colors[vOffset + 1] = ptA.col[1] * alphaA;
|
|
273
|
+
colors[vOffset + 2] = ptA.col[2] * alphaA;
|
|
274
|
+
|
|
275
|
+
// Vertex 2
|
|
276
|
+
positions[vOffset + 3] = ptB.pos[0];
|
|
277
|
+
positions[vOffset + 4] = ptB.pos[1];
|
|
278
|
+
positions[vOffset + 5] = ptB.pos[2];
|
|
279
|
+
colors[vOffset + 3] = ptB.col[0] * alphaB;
|
|
280
|
+
colors[vOffset + 4] = ptB.col[1] * alphaB;
|
|
281
|
+
colors[vOffset + 5] = ptB.col[2] * alphaB;
|
|
282
|
+
|
|
283
|
+
vOffset += 6;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
posAttr.needsUpdate = true;
|
|
288
|
+
colAttr.needsUpdate = true;
|
|
289
|
+
|
|
290
|
+
if (this.app?.viewer?.requestRender) {
|
|
291
|
+
this.app.viewer.requestRender();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this._raf = getRaf()(animateLoop);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
this._raf = getRaf()(animateLoop);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Stops animation, clears geometry and removes streamlines from the viewer.
|
|
302
|
+
*/
|
|
303
|
+
hide() {
|
|
304
|
+
this.active = false;
|
|
305
|
+
if (this._raf) {
|
|
306
|
+
getCaf()(this._raf);
|
|
307
|
+
this._raf = 0;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (this.group.parent) {
|
|
311
|
+
this.group.parent.remove(this.group);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this._dispose();
|
|
315
|
+
|
|
316
|
+
if (this.app?.viewer?.requestRender) {
|
|
317
|
+
this.app.viewer.requestRender();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Internal cleanup of Three.js geometries and materials.
|
|
323
|
+
* @private
|
|
324
|
+
*/
|
|
325
|
+
_dispose() {
|
|
326
|
+
while (this.group.children.length > 0) {
|
|
327
|
+
const child = /** @type {any} */ (this.group.children[0]);
|
|
328
|
+
this.group.remove(child);
|
|
329
|
+
if (child.geometry) child.geometry.dispose();
|
|
330
|
+
if (child.material) {
|
|
331
|
+
if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose());
|
|
332
|
+
else child.material.dispose();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|