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,645 @@
1
+ // @ts-check
2
+ // Object layers: extruded buildings and walls (merged into chunks), trees
3
+ // and poles (instanced), draped surfaces, platforms and section lines.
4
+ //
5
+ // Every extruded feature carries its id (`fkey`) and its layer index
6
+ // (`flayer`) as vertex attributes, so a single GPU pass can say which
7
+ // feature covers a pixel (key = layer index << 20 | id). Vertex heights are recomputed from per-vertex base
8
+ // elevations and per-feature heights, so a new height field or terrain
9
+ // exaggeration costs one pass over the vertices, not a rebuild.
10
+ import * as THREE from 'three';
11
+ import { LAYER_SURFACE, LAYER_OCCLUDER, LAYER_ID, KEY_SHIFT } from './viewer.js';
12
+ import { patchMaterial } from './overlay.js';
13
+ import { hexToRgb } from '../core/classify.js';
14
+
15
+ export { KEY_SHIFT };
16
+ export const SINK = 0.6; // metres a building is pushed into the ground
17
+ const CHUNK_FEATURES = 3000;
18
+
19
+ export function srgbToLinear(v) {
20
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
21
+ }
22
+
23
+ export function featureKey(layerIndex, id) {
24
+ return layerIndex * KEY_SHIFT + id;
25
+ }
26
+
27
+ export function splitKey(key) {
28
+ return { layerIndex: Math.floor(key / KEY_SHIFT), id: key % KEY_SHIFT };
29
+ }
30
+
31
+ function ringsOf(geometry) {
32
+ if (!geometry) return [];
33
+ if (geometry.type === 'Polygon') return [geometry.coordinates];
34
+ if (geometry.type === 'MultiPolygon') return geometry.coordinates;
35
+ return [];
36
+ }
37
+
38
+ function openRing(ring) {
39
+ const n = ring.length;
40
+ if (n > 1 && ring[0][0] === ring[n - 1][0] && ring[0][1] === ring[n - 1][1]) return ring.slice(0, n - 1);
41
+ return ring;
42
+ }
43
+
44
+ function signedArea(ring) {
45
+ let s = 0;
46
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) s += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
47
+ return s / 2;
48
+ }
49
+
50
+ /** Base class: a manifest layer and its features. */
51
+ export class ObjectLayer {
52
+ constructor(app, entry, index, data) {
53
+ this.app = app;
54
+ this.entry = entry;
55
+ this.index = index; // 1-based layer index used in keys
56
+ this.features = data.features;
57
+ this.count = this.features.length;
58
+ this.group = new THREE.Group();
59
+ this.group.name = entry.name;
60
+ this.visible = true;
61
+ this.colors = new Float32Array(this.count * 3);
62
+ this.sourceColors = new Float32Array(this.count * 3);
63
+ this.heights = new Float32Array(this.count);
64
+ this.sourceHeights = new Float32Array(this.count);
65
+ for (let i = 0; i < this.count; i++) {
66
+ const p = this.features[i].properties;
67
+ const rgb = hexToRgb(p._c || entry.defaultColor);
68
+ this.colors.set(rgb, i * 3);
69
+ this.sourceColors.set(rgb, i * 3);
70
+ const h = Number(p._h);
71
+ this.heights[i] = Number.isFinite(h) && p._h !== null ? h : entry.defaultHeight;
72
+ this.sourceHeights[i] = this.heights[i];
73
+ }
74
+ this.extraFields = new Map(); // analysis results: name -> Float32Array per feature
75
+ }
76
+
77
+ /** Values of a field (attribute or analysis result) per feature. */
78
+ values(field) {
79
+ if (this.extraFields.has(field)) return Array.from(/** @type {Float32Array} */ (this.extraFields.get(field)));
80
+ return this.features.map((f) => f.properties[field]);
81
+ }
82
+
83
+ fieldNames() {
84
+ const names = this.entry.fields.map((f) => ({ name: f.name, type: f.type }));
85
+ for (const name of this.extraFields.keys()) names.push({ name, type: 'number', result: true });
86
+ return names;
87
+ }
88
+
89
+ setResultField(name, values) {
90
+ this.extraFields.set(name, values);
91
+ }
92
+
93
+ setVisible(on) {
94
+ this.visible = on;
95
+ this.group.visible = on;
96
+ this.app.viewer.requestRender();
97
+ }
98
+
99
+ applyColors() { /* subclasses */ }
100
+ applyHeights() { /* subclasses */ }
101
+ updateElevation() { /* subclasses */ }
102
+ dispose() {
103
+ this.group.traverse((o) => {
104
+ const m = /** @type {any} */ (o);
105
+ if (m.geometry) m.geometry.dispose();
106
+ if (m.material) (Array.isArray(m.material) ? m.material : [m.material]).forEach((x) => x.dispose());
107
+ });
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------- extrusions
112
+ class Chunk {
113
+ constructor() {
114
+ this.pos = [];
115
+ this.nrm = [];
116
+ this.feat = []; // local feature index per vertex
117
+ this.top = []; // 1 = top vertex, 0 = bottom
118
+ this.base = []; // absolute base elevation per vertex
119
+ this.index = [];
120
+ }
121
+ }
122
+
123
+ /** Extruded polygons (buildings) or lines (walls). */
124
+ export class ExtrudedLayer extends ObjectLayer {
125
+ constructor(app, entry, index, data) {
126
+ super(app, entry, index, data);
127
+ this.isWall = entry.role === 'walls';
128
+ this.thickness = 0.35;
129
+ this.material = patchMaterial(new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.82, metalness: 0.02 }));
130
+ this.footprint = new Float32Array(this.count); // m2 (buildings)
131
+ this.centroid = new Float32Array(this.count * 2);
132
+ this.baseZ = new Float32Array(this.count); // reference ground level (depends on slopeMode)
133
+ this.zMin = new Float32Array(this.count); // lowest / highest / mean ground under the footprint
134
+ this.zMax = new Float32Array(this.count);
135
+ this.zMean = new Float32Array(this.count);
136
+ // Bottom of the extrusion above the reference level (OSM min_height): a
137
+ // part that starts in the air, like a tower over a podium or a bridge.
138
+ this.minH = new Float32Array(this.count);
139
+ for (let i = 0; i < this.count; i++) {
140
+ const b = Number(this.features[i].properties._b);
141
+ this.minH[i] = Number.isFinite(b) && b > 0 ? b : 0;
142
+ }
143
+ this.slopeMode = app.slopeMode || 'lowest';
144
+ this.chunks = [];
145
+ this._build();
146
+ this._groupGround();
147
+ this._applySlopeMode();
148
+ for (const c of this.chunks) { this._writePositions(c); this._writeColors(c); }
149
+ }
150
+
151
+ /** Parts of one building (same _grp) share one ground: the union of their footprints. */
152
+ _groupGround() {
153
+ const groups = new Map();
154
+ for (let f = 0; f < this.count; f++) {
155
+ const g = this.features[f].properties._grp;
156
+ if (g === undefined || g === null || g === '') continue;
157
+ let a = groups.get(g);
158
+ if (!a) { a = { min: Infinity, max: -Infinity, sum: 0, n: 0, members: [] }; groups.set(g, a); }
159
+ a.min = Math.min(a.min, this.zMin[f]);
160
+ a.max = Math.max(a.max, this.zMax[f]);
161
+ a.sum += this.zMean[f];
162
+ a.n++;
163
+ a.members.push(f);
164
+ }
165
+ for (const a of groups.values()) {
166
+ if (a.n < 2) continue;
167
+ for (const f of a.members) { this.zMin[f] = a.min; this.zMax[f] = a.max; this.zMean[f] = a.sum / a.n; }
168
+ }
169
+ }
170
+
171
+ _applySlopeMode() {
172
+ const src = this.slopeMode === 'highest' ? this.zMax : this.slopeMode === 'average' ? this.zMean : this.zMin;
173
+ if (!this.isWall) this.baseZ.set(src);
174
+ }
175
+
176
+ /** Where a building stands on a slope: 'lowest' (buried uphill), 'average' or 'highest' (plinth downhill). */
177
+ setSlopeMode(mode) {
178
+ this.slopeMode = mode;
179
+ this._applySlopeMode();
180
+ this.applyHeights();
181
+ }
182
+
183
+ _build() {
184
+ const terrain = this.app.terrain;
185
+ let chunk = new Chunk();
186
+ let inChunk = 0;
187
+ const flush = () => {
188
+ if (chunk.pos.length) this.chunks.push(this._makeMesh(chunk));
189
+ chunk = new Chunk();
190
+ inChunk = 0;
191
+ };
192
+ for (let f = 0; f < this.count; f++) {
193
+ const geom = this.features[f].geometry;
194
+ if (this.isWall) this._addWall(chunk, f, geom, terrain);
195
+ else this._addPolygon(chunk, f, geom, terrain);
196
+ if (++inChunk >= CHUNK_FEATURES) flush();
197
+ }
198
+ flush();
199
+ }
200
+
201
+ _addPolygon(chunk, f, geom, terrain) {
202
+ const polys = ringsOf(geom).map((poly) => poly.map(openRing).filter((r) => r.length >= 3)).filter((p) => p.length);
203
+ if (!polys.length) return;
204
+ let zmin = Infinity, zmax = -Infinity, zsum = 0, area = 0, cx = 0, cy = 0, n = 0;
205
+ for (const poly of polys) {
206
+ for (const [x, y] of poly[0]) {
207
+ const z = terrain.heightAt(x, y);
208
+ zmin = Math.min(zmin, z); zmax = Math.max(zmax, z); zsum += z;
209
+ cx += x; cy += y; n++;
210
+ }
211
+ area += Math.abs(signedArea(poly[0])) - poly.slice(1).reduce((s, h) => s + Math.abs(signedArea(h)), 0);
212
+ }
213
+ // Sample the centre too, so a footprint over a ridge or hollow has a fair mean.
214
+ const zc = terrain.heightAt(cx / n, cy / n);
215
+ this.zMin[f] = Math.min(zmin, zc);
216
+ this.zMax[f] = Math.max(zmax, zc);
217
+ this.zMean[f] = (zsum + zc) / (n + 1);
218
+ this.baseZ[f] = this.zMin[f];
219
+ this.footprint[f] = area;
220
+ this.centroid[f * 2] = cx / n;
221
+ this.centroid[f * 2 + 1] = cy / n;
222
+ for (const poly of polys) {
223
+ const outer = poly[0];
224
+ const outerCcw = signedArea(outer) > 0;
225
+ poly.forEach((ring, ri) => {
226
+ // Outward normal of edge (dx, dy) is (dy, -dx) for a CCW ring; holes face inwards.
227
+ const ccw = signedArea(ring) > 0;
228
+ const flip = ri === 0 ? !ccw : ccw;
229
+ for (let i = 0; i < ring.length; i++) {
230
+ const [x0, y0] = ring[i];
231
+ const [x1, y1] = ring[(i + 1) % ring.length];
232
+ const dx = x1 - x0, dy = y1 - y0;
233
+ const len = Math.hypot(dx, dy);
234
+ if (len < 1e-6) continue;
235
+ let nx = dy / len, ny = -dx / len;
236
+ if (flip) { nx = -nx; ny = -ny; }
237
+ this._quad(chunk, f, [x0, y0, terrain.heightAt(x0, y0)], [x1, y1, terrain.heightAt(x1, y1)], nx, ny);
238
+ }
239
+ });
240
+ // Roof.
241
+ const contour = outer.map(([x, y]) => new THREE.Vector2(x, y));
242
+ const holes = poly.slice(1).map((h) => h.map(([x, y]) => new THREE.Vector2(x, y)));
243
+ let tris;
244
+ try {
245
+ tris = THREE.ShapeUtils.triangulateShape(contour, holes);
246
+ } catch {
247
+ continue;
248
+ }
249
+ const all = contour.concat(...holes);
250
+ const start = chunk.pos.length / 3;
251
+ for (const p of all) {
252
+ chunk.pos.push(p.x, 0, -p.y);
253
+ chunk.nrm.push(0, 1, 0);
254
+ chunk.feat.push(f); chunk.top.push(1); chunk.base.push(terrain.heightAt(p.x, p.y));
255
+ }
256
+ for (const t of tris) {
257
+ // In (x, -y) the winding flips; emit so the face points up.
258
+ const [a, b, c] = t;
259
+ const ax = all[a].x, ay = all[a].y, bx = all[b].x, by = all[b].y, cx2 = all[c].x, cy2 = all[c].y;
260
+ const ccwTri = (bx - ax) * (cy2 - ay) - (by - ay) * (cx2 - ax) > 0;
261
+ if (ccwTri) chunk.index.push(start + a, start + b, start + c);
262
+ else chunk.index.push(start + a, start + c, start + b);
263
+ }
264
+ }
265
+ }
266
+
267
+ _addWall(chunk, f, geom, terrain) {
268
+ const lines = geom?.type === 'MultiLineString' ? geom.coordinates : geom?.type === 'LineString' ? [geom.coordinates] : [];
269
+ const t = this.thickness / 2;
270
+ let len = 0, cx = 0, cy = 0, n = 0;
271
+ for (const line of lines) {
272
+ for (let i = 0; i < line.length - 1; i++) {
273
+ const [x0, y0] = line[i];
274
+ const [x1, y1] = line[i + 1];
275
+ const dx = x1 - x0, dy = y1 - y0;
276
+ const l = Math.hypot(dx, dy);
277
+ if (l < 1e-6) continue;
278
+ len += l; cx += x0 + x1; cy += y0 + y1; n += 2;
279
+ const nx = dy / l, ny = -dx / l;
280
+ const z0 = terrain.heightAt(x0, y0), z1 = terrain.heightAt(x1, y1);
281
+ // Two long faces, two end caps; a flat top.
282
+ const p = [[x0 + nx * t, y0 + ny * t, z0], [x1 + nx * t, y1 + ny * t, z1], [x1 - nx * t, y1 - ny * t, z1], [x0 - nx * t, y0 - ny * t, z0]];
283
+ this._quad(chunk, f, p[0], p[1], nx, ny);
284
+ this._quad(chunk, f, p[2], p[3], -nx, -ny);
285
+ this._quad(chunk, f, p[1], p[2], dx / l, dy / l);
286
+ this._quad(chunk, f, p[3], p[0], -dx / l, -dy / l);
287
+ const s = chunk.pos.length / 3;
288
+ for (const q of p) { chunk.pos.push(q[0], 0, -q[1]); chunk.nrm.push(0, 1, 0); chunk.feat.push(f); chunk.top.push(1); chunk.base.push(q[2]); }
289
+ chunk.index.push(s, s + 1, s + 2, s, s + 2, s + 3); // top cap faces up
290
+ }
291
+ }
292
+ this.footprint[f] = len * this.thickness;
293
+ if (n) { this.centroid[f * 2] = cx / n; this.centroid[f * 2 + 1] = cy / n; }
294
+ this.baseZ[f] = n ? terrain.heightAt(cx / n, cy / n) : 0;
295
+ this.zMin[f] = this.zMax[f] = this.zMean[f] = this.baseZ[f];
296
+ }
297
+
298
+ /** A vertical quad from p0 to p1 (each [x, y, baseZ]) facing (nx, ny). */
299
+ _quad(chunk, f, p0, p1, nx, ny) {
300
+ const s = chunk.pos.length / 3;
301
+ const verts = [[p0, 0], [p1, 0], [p1, 1], [p0, 1]];
302
+ for (const [p, top] of verts) {
303
+ chunk.pos.push(p[0], 0, -p[1]);
304
+ chunk.nrm.push(nx, 0, -ny);
305
+ chunk.feat.push(f); chunk.top.push(top); chunk.base.push(p[2]);
306
+ }
307
+ // Choose the winding whose face normal matches (nx, 0, -ny).
308
+ const ax = p0[0], az = -p0[1], bx = p1[0], bz = -p1[1];
309
+ // Triangle (bottom0, bottom1, top1): normal ~ (b - a) x up.
310
+ const ex = bx - ax, ez = bz - az;
311
+ const cross = { x: -ez, z: ex }; // (ex, 0, ez) x (0, 1, 0) = (-ez, 0, ex)
312
+ const outward = cross.x * nx + cross.z * -ny > 0;
313
+ if (outward) chunk.index.push(s, s + 1, s + 2, s, s + 2, s + 3);
314
+ else chunk.index.push(s, s + 2, s + 1, s, s + 3, s + 2);
315
+ }
316
+
317
+ _makeMesh(chunk) {
318
+ const n = chunk.pos.length / 3;
319
+ if (!this.chunkOf) this.chunkOf = new Int32Array(this.count).fill(-1);
320
+ for (const f of chunk.feat) this.chunkOf[f] = this.chunks.length;
321
+ const geom = new THREE.BufferGeometry();
322
+ geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(chunk.pos), 3));
323
+ geom.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(chunk.nrm), 3));
324
+ geom.setAttribute('color', new THREE.BufferAttribute(new Float32Array(n * 3), 3));
325
+ // The ID pass reads the feature id and the layer index separately (viewer.js).
326
+ const fkey = new Float32Array(n);
327
+ const feat = Uint32Array.from(chunk.feat);
328
+ for (let i = 0; i < n; i++) fkey[i] = feat[i] + 1;
329
+ geom.setAttribute('fkey', new THREE.BufferAttribute(fkey, 1));
330
+ geom.setAttribute('flayer', new THREE.BufferAttribute(new Float32Array(n).fill(this.index), 1));
331
+ geom.setIndex(n > 65535 ? new THREE.Uint32BufferAttribute(chunk.index, 1) : new THREE.Uint16BufferAttribute(chunk.index, 1));
332
+ const mesh = new THREE.Mesh(geom, this.material);
333
+ mesh.castShadow = true;
334
+ mesh.receiveShadow = true;
335
+ mesh.layers.enable(LAYER_OCCLUDER);
336
+ mesh.layers.enable(LAYER_ID);
337
+ if (!this.isWall) mesh.layers.enable(LAYER_SURFACE);
338
+ mesh.userData = { feat, top: Uint8Array.from(chunk.top), base: Float32Array.from(chunk.base), layer: this };
339
+ this.group.add(mesh);
340
+ const c = { mesh, feat: mesh.userData.feat, top: mesh.userData.top, base: mesh.userData.base };
341
+ this._writePositions(c);
342
+ this._writeColors(c);
343
+ return c;
344
+ }
345
+
346
+ _writePositions(c) {
347
+ const terrain = this.app.terrain;
348
+ const pos = c.mesh.geometry.attributes.position;
349
+ const arr = pos.array;
350
+ for (let i = 0; i < c.feat.length; i++) {
351
+ const f = c.feat[i];
352
+ const h = this.heights[f];
353
+ if (this.isWall) {
354
+ // Walls follow the ground along their length.
355
+ arr[i * 3 + 1] = terrain.toY(c.base[i]) + (c.top[i] ? Math.max(0.05, h) : -SINK);
356
+ continue;
357
+ }
358
+ const ref = terrain.toY(this.baseZ[f]);
359
+ const top = ref + Math.max(0.05, h);
360
+ if (c.top[i]) { arr[i * 3 + 1] = top; continue; }
361
+ // Bottom: from min_height for a raised part, else down into the ground
362
+ // at this vertex, which shows a plinth on the downhill side.
363
+ const bottom = this.minH[f] > 0 ? ref + this.minH[f] : terrain.toY(c.base[i]) - SINK;
364
+ arr[i * 3 + 1] = Math.min(bottom, top - 0.05);
365
+ }
366
+ pos.needsUpdate = true;
367
+ c.mesh.geometry.computeBoundingBox();
368
+ c.mesh.geometry.computeBoundingSphere();
369
+ }
370
+
371
+ _writeColors(c) {
372
+ const col = c.mesh.geometry.attributes.color;
373
+ const arr = col.array;
374
+ // Layer colours are sRGB (as in QGIS); vertex colours are linear.
375
+ for (let i = 0; i < c.feat.length; i++) {
376
+ const f = c.feat[i];
377
+ arr[i * 3] = srgbToLinear(this.colors[f * 3]);
378
+ arr[i * 3 + 1] = srgbToLinear(this.colors[f * 3 + 1]);
379
+ arr[i * 3 + 2] = srgbToLinear(this.colors[f * 3 + 2]);
380
+ }
381
+ col.needsUpdate = true;
382
+ }
383
+
384
+ applyColors() {
385
+ for (const c of this.chunks) this._writeColors(c);
386
+ this.app.viewer.requestRender();
387
+ }
388
+
389
+ applyHeights() {
390
+ for (const c of this.chunks) this._writePositions(c);
391
+ this.app.viewer.requestRender();
392
+ }
393
+
394
+ /** Rewrite only the chunks that hold the given features. */
395
+ applyHeightsFor(features) {
396
+ const touched = new Set();
397
+ for (const f of features) if (this.chunkOf && this.chunkOf[f] >= 0) touched.add(this.chunkOf[f]);
398
+ for (const i of touched) this._writePositions(this.chunks[i]);
399
+ this.app.viewer.requestRender();
400
+ }
401
+
402
+ updateElevation() {
403
+ this.applyHeights();
404
+ }
405
+
406
+ /** Bounding box of a feature in three coordinates (for focus). */
407
+ featureCentre(f) {
408
+ const x = this.centroid[f * 2], y = this.centroid[f * 2 + 1];
409
+ return new THREE.Vector3(x, this.app.terrain.toY(this.baseZ[f]) + this.heights[f] / 2, -y);
410
+ }
411
+ }
412
+
413
+ // ---------------------------------------------------------------- instanced points
414
+ export class PointLayer extends ObjectLayer {
415
+ constructor(app, entry, index, data) {
416
+ super(app, entry, index, data);
417
+ this.isTree = entry.role === 'trees';
418
+ this.points = []; // [featureIndex, x, y]
419
+ for (let f = 0; f < this.count; f++) {
420
+ const g = this.features[f].geometry;
421
+ const pts = g?.type === 'MultiPoint' ? g.coordinates : g?.type === 'Point' ? [g.coordinates] : [];
422
+ for (const [x, y] of pts) this.points.push([f, x, y]);
423
+ }
424
+ const n = this.points.length;
425
+ if (this.isTree) {
426
+ const crownGeom = new THREE.IcosahedronGeometry(1, 1);
427
+ const trunkGeom = new THREE.CylinderGeometry(0.12, 0.16, 1, 6);
428
+ trunkGeom.translate(0, 0.5, 0);
429
+ this.crowns = new THREE.InstancedMesh(crownGeom, new THREE.MeshStandardMaterial({ roughness: 0.9, flatShading: true }), n);
430
+ this.trunks = new THREE.InstancedMesh(trunkGeom, new THREE.MeshStandardMaterial({ color: 0x6b5139, roughness: 0.9 }), n);
431
+ for (const m of [this.crowns, this.trunks]) {
432
+ m.castShadow = true;
433
+ m.receiveShadow = true;
434
+ m.layers.enable(LAYER_OCCLUDER);
435
+ this.group.add(m);
436
+ }
437
+ } else {
438
+ const geom = new THREE.CylinderGeometry(0.22, 0.26, 1, 8);
439
+ geom.translate(0, 0.5, 0);
440
+ this.poles = new THREE.InstancedMesh(geom, new THREE.MeshStandardMaterial({ roughness: 0.6, metalness: 0.3 }), n);
441
+ this.poles.castShadow = true;
442
+ this.poles.layers.enable(LAYER_OCCLUDER);
443
+ this.group.add(this.poles);
444
+ }
445
+ this.applyHeights();
446
+ this.applyColors();
447
+ }
448
+
449
+ crownRadius(h) {
450
+ return Math.max(1.2, Math.min(7, h * 0.32));
451
+ }
452
+
453
+ applyHeights() {
454
+ const m = new THREE.Matrix4();
455
+ const q = new THREE.Quaternion();
456
+ const terrain = this.app.terrain;
457
+ this.points.forEach(([f, x, y], i) => {
458
+ const h = Math.max(0.5, this.heights[f]);
459
+ const gy = terrain.groundY(x, y);
460
+ if (this.isTree) {
461
+ const r = this.crownRadius(h);
462
+ const trunkH = Math.max(0.8, h - 1.6 * r);
463
+ m.compose(new THREE.Vector3(x, gy, -y), q, new THREE.Vector3(1.4, trunkH + 0.4, 1.4));
464
+ this.trunks.setMatrixAt(i, m);
465
+ m.compose(new THREE.Vector3(x, gy + h - r, -y), q, new THREE.Vector3(r, r * 1.05, r));
466
+ this.crowns.setMatrixAt(i, m);
467
+ } else {
468
+ m.compose(new THREE.Vector3(x, gy - 0.2, -y), q, new THREE.Vector3(1, h + 0.2, 1));
469
+ this.poles.setMatrixAt(i, m);
470
+ }
471
+ });
472
+ for (const mesh of [this.crowns, this.trunks, this.poles]) {
473
+ if (!mesh) continue;
474
+ mesh.instanceMatrix.needsUpdate = true;
475
+ mesh.computeBoundingSphere();
476
+ }
477
+ this.app.viewer.requestRender();
478
+ }
479
+
480
+ applyColors() {
481
+ const target = this.isTree ? this.crowns : this.poles;
482
+ const c = new THREE.Color();
483
+ this.points.forEach(([f], i) => {
484
+ c.setRGB(this.colors[f * 3], this.colors[f * 3 + 1], this.colors[f * 3 + 2], THREE.SRGBColorSpace);
485
+ target.setColorAt(i, c);
486
+ });
487
+ if (target.instanceColor) target.instanceColor.needsUpdate = true;
488
+ this.app.viewer.requestRender();
489
+ }
490
+
491
+ updateElevation() {
492
+ this.applyHeights();
493
+ }
494
+
495
+ /** Obstacles for the wind model: [x, y, radius, height]. */
496
+ obstacles() {
497
+ return this.points.map(([f, x, y]) => [x, y, this.isTree ? this.crownRadius(this.heights[f]) : 0.3, this.heights[f]]);
498
+ }
499
+ }
500
+
501
+ // ---------------------------------------------------------------- draped surfaces
502
+ export class SurfaceLayer extends ObjectLayer {
503
+ constructor(app, entry, index, data) {
504
+ super(app, entry, index, data);
505
+ this.paintEntry = { features: this.features.map((f) => ({ geometry: polygonsOf(f.geometry) })), colors: this.colors, visible: true };
506
+ app.terrain.addSurfaceLayer(this.paintEntry);
507
+ }
508
+
509
+ setVisible(on) {
510
+ this.visible = on;
511
+ this.paintEntry.visible = on;
512
+ this.app.terrain.paint();
513
+ }
514
+
515
+ applyColors() {
516
+ this.app.terrain.paint();
517
+ }
518
+
519
+ /** Feature index containing local point (x, y), or -1. */
520
+ featureAt(x, y) {
521
+ for (let f = this.count - 1; f >= 0; f--) {
522
+ for (const poly of ringsOf(this.features[f].geometry)) {
523
+ if (inPoly(x, y, poly)) return f;
524
+ }
525
+ }
526
+ return -1;
527
+ }
528
+ }
529
+
530
+ function polygonsOf(geom) {
531
+ return { type: 'MultiPolygon', coordinates: ringsOf(geom) };
532
+ }
533
+
534
+ function inPoly(x, y, poly) {
535
+ let inside = false;
536
+ poly.forEach((ring, ri) => {
537
+ let hit = false;
538
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
539
+ const [xi, yi] = ring[i], [xj, yj] = ring[j];
540
+ if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi || 1e-12) + xi) hit = !hit;
541
+ }
542
+ if (ri === 0) inside = hit; else if (hit) inside = false;
543
+ });
544
+ return inside;
545
+ }
546
+
547
+ // ---------------------------------------------------------------- platforms
548
+ export class PlatformLayer extends ObjectLayer {
549
+ constructor(app, entry, index, data) {
550
+ super(app, entry, index, data);
551
+ this.material = new THREE.MeshStandardMaterial({ color: 0xc9a46b, transparent: true, opacity: 0.55, roughness: 0.8, side: THREE.DoubleSide, depthWrite: false });
552
+ this.rings = this.features.map((f) => ringsOf(f.geometry).map((p) => openRing(p[0])).filter((r) => r.length >= 3));
553
+ // A platform with no design level (0 or empty) gets the cut/fill-balancing level later.
554
+ this.levels = Float32Array.from(this.heights);
555
+ this._rebuild();
556
+ }
557
+
558
+ _rebuild() {
559
+ for (const child of [...this.group.children]) { this.group.remove(child); /** @type {any} */ (child).geometry.dispose(); }
560
+ const terrain = this.app.terrain;
561
+ this.rings.forEach((rings, f) => {
562
+ for (const ring of rings) {
563
+ const shape = new THREE.Shape(ring.map(([x, y]) => new THREE.Vector2(x, y)));
564
+ const geom = new THREE.ShapeGeometry(shape);
565
+ geom.rotateX(-Math.PI / 2); // (x, y) plane -> (x, -z) ground plane
566
+ const mesh = new THREE.Mesh(geom, this.material);
567
+ const level = this.levels[f] > 0 ? this.levels[f] : terrain.heightAt(ring[0][0], ring[0][1]);
568
+ mesh.position.y = terrain.toY(level) + 0.05;
569
+ mesh.renderOrder = 3;
570
+ this.group.add(mesh);
571
+ }
572
+ });
573
+ this.app.viewer.requestRender();
574
+ }
575
+
576
+ setLevel(f, level) {
577
+ this.levels[f] = level;
578
+ this._rebuild();
579
+ }
580
+
581
+ updateElevation() {
582
+ this._rebuild();
583
+ }
584
+ }
585
+
586
+ // ---------------------------------------------------------------- section lines
587
+ export class LineLayer extends ObjectLayer {
588
+ constructor(app, entry, index, data) {
589
+ super(app, entry, index, data);
590
+ this.lines = this.features.map((f) => {
591
+ const g = f.geometry;
592
+ return g?.type === 'MultiLineString' ? g.coordinates : g?.type === 'LineString' ? [g.coordinates] : [];
593
+ });
594
+ this._rebuild();
595
+ }
596
+
597
+ _rebuild() {
598
+ for (const child of [...this.group.children]) { this.group.remove(child); /** @type {any} */ (child).geometry.dispose(); }
599
+ const terrain = this.app.terrain;
600
+ const step = Math.max(1, terrain.grid.cell / 2);
601
+ const pos = [];
602
+ for (const parts of this.lines) {
603
+ for (const line of parts) {
604
+ for (let i = 0; i < line.length - 1; i++) {
605
+ const [x0, y0] = line[i], [x1, y1] = line[i + 1];
606
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step));
607
+ for (let k = 0; k < n; k++) {
608
+ const ax = x0 + ((x1 - x0) * k) / n, ay = y0 + ((y1 - y0) * k) / n;
609
+ const bx = x0 + ((x1 - x0) * (k + 1)) / n, by = y0 + ((y1 - y0) * (k + 1)) / n;
610
+ pos.push(ax, terrain.groundY(ax, ay) + 0.6, -ay, bx, terrain.groundY(bx, by) + 0.6, -by);
611
+ }
612
+ }
613
+ }
614
+ }
615
+ const geom = new THREE.BufferGeometry();
616
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
617
+ const color = new THREE.Color().setRGB(this.colors[0] ?? 0.9, this.colors[1] ?? 0.35, this.colors[2] ?? 0.2);
618
+ this.group.add(new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color, depthTest: true })));
619
+ this.app.viewer.requestRender();
620
+ }
621
+
622
+ updateElevation() {
623
+ this._rebuild();
624
+ }
625
+ }
626
+
627
+ export function createLayer(app, entry, index, data) {
628
+ switch (entry.role) {
629
+ case 'buildings':
630
+ case 'walls':
631
+ return new ExtrudedLayer(app, entry, index, data);
632
+ case 'trees':
633
+ case 'poles':
634
+ return new PointLayer(app, entry, index, data);
635
+ case 'surfaces':
636
+ return new SurfaceLayer(app, entry, index, data);
637
+ case 'platforms':
638
+ return new PlatformLayer(app, entry, index, data);
639
+ case 'sections':
640
+ case 'roads':
641
+ return new LineLayer(app, entry, index, data);
642
+ default:
643
+ return null;
644
+ }
645
+ }