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,277 @@
1
+ // @ts-check
2
+ // "Terrain" tab: display, section profiles, flooding and cut/fill.
3
+ import { h, clear, row, select, number, toggle, button, card, stats, download, toast, slider } from './dom.js';
4
+ import { SHADING_MODES } from '../scene/terrain.js';
5
+ import { profile, flood, cutFill, balancedLevel, gridStats } from '../core/terrain_math.js';
6
+ import { drawProfile } from './charts.js';
7
+ import { ExtrudedLayer, LineLayer, PlatformLayer } from '../scene/objects.js';
8
+ import { sendGrid, sendFields } from './qgis_link.js';
9
+ import { TERRAIN_ANALYSES, terrainAnalyses } from '../analysis/terrain_results.js';
10
+
11
+ export function buildTerrainPanel(app, root) {
12
+ clear(root);
13
+ root.appendChild(displayCard(app));
14
+ root.appendChild(analysesCard(app));
15
+ root.appendChild(profileCard(app));
16
+ root.appendChild(floodCard(app));
17
+ root.appendChild(cutFillCard(app));
18
+ }
19
+
20
+ function displayCard(app) {
21
+ const t = app.terrain;
22
+ const exSlider = slider(t.exaggeration, { min: 1, max: 5, step: 0.25, format: (v) => `× ${v}`, onChange: (v) => app.setExaggeration(v) });
23
+ app.on((what) => { if (what === 'exaggeration') exSlider.set(app.terrain.exaggeration); });
24
+ const body = h('div', {},
25
+ row('Shading', select(Object.entries(SHADING_MODES), t.shading, (v) => t.setShading(v))),
26
+ row('Vertical exaggeration', exSlider, 'Display only: analyses reset it to 1'),
27
+ row('Contours', select([[0, 'Off'], [0.5, '0.5 m'], [1, '1 m'], [2, '2 m'], [5, '5 m'], [10, '10 m'], [25, '25 m'], [50, '50 m']], t.contourInterval,
28
+ (v) => t.setContours(Number(v))), 'Every fifth line is drawn darker'),
29
+ row('Buildings on slopes', select([['lowest', 'Lowest ground (buried uphill)'], ['average', 'Average ground (half plinth)'],
30
+ ['highest', 'Highest ground (plinth downhill)']], app.slopeMode, (v) => app.setSlopeMode(v)),
31
+ 'The level a building stands on; heights count from it. A plinth fills the gap down to the ground.'),
32
+ row('Live shadows', toggle(app.viewer.shadowsOn, (v) => app.viewer.setShadows(v))));
33
+ return card('Display', body, { open: true });
34
+ }
35
+
36
+ // ---------------------------------------------------------------- terrain analyses
37
+ function analysesCard(app) {
38
+ app.params.terrain ??= { kinds: ['elevation', 'slope', 'aspect', 'hillshade'], extent: 'scene', tpiRadius: 100 };
39
+ const p = app.params.terrain;
40
+ const list = h('div', { class: 'checks' });
41
+ for (const a of TERRAIN_ANALYSES) {
42
+ list.appendChild(h('div', { class: 'check-row terrain-row', title: a.help },
43
+ toggle(p.kinds.includes(a.id), (v) => {
44
+ p.kinds = v ? TERRAIN_ANALYSES.map((x) => x.id).filter((id) => id === a.id || p.kinds.includes(id)) : p.kinds.filter((x) => x !== a.id);
45
+ }, a.label),
46
+ a.id === 'tpi' ? h('span', { class: 'muted small' }, 'radius ', number(p.tpiRadius, { step: 10, width: '4.5em', onChange: (v) => { if (v > 0) p.tpiRadius = v; } }), ' m') : null));
47
+ }
48
+ const run = async (compare) => {
49
+ const res = await app.run('Terrain analyses', (prog) => terrainAnalyses(app, { kinds: p.kinds, extent: p.extent, tpiRadius: p.tpiRadius, onProgress: prog }));
50
+ if (res?.length && compare) app.compare?.showResults(res);
51
+ };
52
+ const body = h('div', {},
53
+ h('p', { class: 'muted small', text: 'Maps of the DEM itself. Each becomes a result: drawn on the ground, chosen in a compare panel, sent to QGIS as a GeoTIFF.' }),
54
+ list,
55
+ row('Extent', select([['scene', 'Whole DEM'], ['area', 'Analysis area']], p.extent, (v) => { p.extent = v; }),
56
+ 'Large DEMs are coarsened to at most 4 million cells for these maps'),
57
+ h('div', { class: 'btn-row' },
58
+ button('Run', () => run(false), { kind: 'primary run' }),
59
+ button('Run and compare', () => run(true), { title: 'Open the results side by side in 2, 4, 6 or 8 panels' })));
60
+ return card('Terrain analyses', body, { open: true });
61
+ }
62
+
63
+ // ---------------------------------------------------------------- profile
64
+ function profileCard(app) {
65
+ const state = { line: null, data: null, withBuildings: true };
66
+ const canvas = h('canvas', { class: 'chart' });
67
+ const summary = h('div', {});
68
+ const sections = [];
69
+ for (const layer of app.layers) {
70
+ if (!(layer instanceof LineLayer)) continue;
71
+ layer.lines.forEach((parts, f) => { if (parts[0]?.length >= 2) sections.push([`${layer.index}:${f}`, `${layer.entry.name} #${f + 1}`, parts[0]]); });
72
+ }
73
+ const compute = async () => {
74
+ if (!state.line) return;
75
+ const t = app.terrain;
76
+ let len = 0;
77
+ for (let i = 0; i < state.line.length - 1; i++) len += Math.hypot(state.line[i + 1][0] - state.line[i][0], state.line[i + 1][1] - state.line[i][1]);
78
+ const step = Math.max(0.5, Math.min(t.grid.cell / 2, len / 800));
79
+ const pts = profile(t.dataGrid, state.line, step);
80
+ if (state.withBuildings && app.layers.some((l) => l instanceof ExtrudedLayer && l.visible)) {
81
+ const xs = state.line.map((p) => p[0]), ys = state.line.map((p) => p[1]);
82
+ const pad = step * 2;
83
+ const sc = app.sceneArea();
84
+ const area = { minX: Math.max(sc.minX, Math.min(...xs) - pad), maxX: Math.min(sc.maxX, Math.max(...xs) + pad),
85
+ minY: Math.max(sc.minY, Math.min(...ys) - pad), maxY: Math.min(sc.maxY, Math.max(...ys) + pad) };
86
+ const cell = Math.max(step, Math.sqrt(((area.maxX - area.minX) * (area.maxY - area.minY)) / 4e6));
87
+ const geo = await app.analyst.gridRun({ area, cell, withIds: true });
88
+ const g = geo.grid;
89
+ for (const p of pts) {
90
+ const c = Math.round((p.x - g.minX) / g.cell), r = Math.round((g.maxY - p.y) / g.cell);
91
+ if (c < 0 || r < 0 || c >= g.cols || r >= g.rows) continue;
92
+ const i = r * g.cols + c;
93
+ // Building heights are not exaggerated: take the height above the drawn ground.
94
+ if (geo.ids[i] && Number.isFinite(geo.heights[i])) p.top = p.z + Math.max(0, geo.heights[i] - t.toY(p.z));
95
+ }
96
+ }
97
+ state.data = { points: pts, water: app.waterLevel ?? NaN };
98
+ drawProfile(canvas, state.data);
99
+ let up = 0, down = 0, maxSlope = 0;
100
+ for (let i = 1; i < pts.length; i++) {
101
+ const dz = pts[i].z - pts[i - 1].z;
102
+ if (!Number.isFinite(dz)) continue; // across a hole in the DEM
103
+ if (dz > 0) up += dz; else down -= dz;
104
+ const dd = pts[i].d - pts[i - 1].d;
105
+ if (dd > 0) maxSlope = Math.max(maxSlope, Math.abs(dz / dd));
106
+ }
107
+ const st = gridStats(pts.map((p) => p.z));
108
+ clear(summary);
109
+ summary.appendChild(stats([['Length', `${len.toFixed(0)} m`], ['Elevation', `${st.min.toFixed(1)} – ${st.max.toFixed(1)} m`],
110
+ ['Climb / descent', `${up.toFixed(0)} / ${down.toFixed(0)} m`], ['Steepest', `${(maxSlope * 100).toFixed(0)} %`]]));
111
+ app.tools.outline('profile-line', state.line, 0xff4f2e);
112
+ };
113
+ const draw = async () => {
114
+ const line = await app.tools.drawLine('Draw the section line');
115
+ if (line) { state.line = line; compute(); }
116
+ };
117
+ const body = h('div', {},
118
+ h('div', { class: 'btn-row' }, button('Draw section', draw, { kind: 'primary' }),
119
+ sections.length ? select([['', 'Section line from QGIS…'], ...sections.map(([k, l]) => [k, l])], '', (v) => {
120
+ const s = sections.find(([k]) => k === v);
121
+ if (s) { state.line = s[2]; compute(); }
122
+ }) : null),
123
+ row('Show buildings', toggle(state.withBuildings, (v) => { state.withBuildings = v; compute(); })),
124
+ canvas, summary,
125
+ h('div', { class: 'btn-row' },
126
+ button('PNG', () => state.data && download('profile.png', canvas.toDataURL('image/png'))),
127
+ button('CSV', () => state.data && download('profile.csv', profileCsv(app, state.data), 'text/csv')),
128
+ button('DXF', () => state.data && download('profile.dxf', profileDxf(state.data), 'application/dxf'))));
129
+ return card('Section & profile', body);
130
+ }
131
+
132
+ function profileCsv(app, data) {
133
+ const [ox, oy] = app.manifest.origin;
134
+ const out = ['distance_m,x,y,ground_m,building_top_m'];
135
+ for (const p of data.points) out.push(`${p.d.toFixed(2)},${(p.x + ox).toFixed(3)},${(p.y + oy).toFixed(3)},${Number.isFinite(p.z) ? p.z.toFixed(3) : ''},${Number.isFinite(p.top) ? p.top.toFixed(3) : ''}`);
136
+ return out.join('\n');
137
+ }
138
+
139
+ /** Minimal DXF R12: ground line and building outlines in (distance, elevation). */
140
+ function profileDxf(data) {
141
+ const out = ['0', 'SECTION', '2', 'ENTITIES'];
142
+ const poly = (pts, layer) => {
143
+ out.push('0', 'POLYLINE', '8', layer, '66', '1', '70', '0');
144
+ for (const [x, y] of pts) out.push('0', 'VERTEX', '8', layer, '10', x.toFixed(3), '20', y.toFixed(3), '30', '0');
145
+ out.push('0', 'SEQEND');
146
+ };
147
+ // One ground polyline per stretch with DEM data (a clipped DEM has holes).
148
+ let ground = [];
149
+ for (const p of data.points) {
150
+ if (Number.isFinite(p.z)) { ground.push([p.d, p.z]); continue; }
151
+ if (ground.length > 1) poly(ground, 'GROUND');
152
+ ground = [];
153
+ }
154
+ if (ground.length > 1) poly(ground, 'GROUND');
155
+ let run = null;
156
+ const flush = (end) => { if (run) { poly([[run.d0, run.z0], [run.d0, run.top], [end.d, run.top], [end.d, end.z]], 'BUILDINGS'); run = null; } };
157
+ for (const p of data.points) {
158
+ if (Number.isFinite(p.top)) {
159
+ if (run && Math.abs(run.top - p.top) > 0.5) flush(p);
160
+ if (!run) run = { d0: p.d, z0: p.z, top: p.top };
161
+ } else if (run) flush(p);
162
+ }
163
+ // A building still open where the section line ends.
164
+ if (data.points.length) flush(data.points[data.points.length - 1]);
165
+ out.push('0', 'ENDSEC', '0', 'EOF');
166
+ return out.join('\n');
167
+ }
168
+
169
+ // ---------------------------------------------------------------- flooding
170
+ function floodCard(app) {
171
+ const t = app.terrain;
172
+ const lo = Math.floor(t.min), hi = Math.ceil(t.max);
173
+ const state = { level: Math.round(t.min + (t.max - t.min) * 0.15), seed: null, result: null };
174
+ const info = h('div', {});
175
+ const levelSlider = slider(state.level, { min: lo, max: hi, step: Math.max(0.1, (hi - lo) / 400), format: (v) => `${v.toFixed(1)} m`,
176
+ onChange: (v) => { state.level = v; apply(); } });
177
+ const apply = () => {
178
+ const res = flood(t.dataGrid, state.level, state.seed);
179
+ state.result = res;
180
+ app.waterLevel = state.level;
181
+ t.setWater(state.level, res.floodedCells ? res.depth : null);
182
+ // Buildings standing in flooded cells.
183
+ let hit = 0;
184
+ const fields = [];
185
+ for (const layer of app.layers) {
186
+ if (!(layer instanceof ExtrudedLayer) || layer.isWall) continue;
187
+ const depth = new Float32Array(layer.count).fill(NaN);
188
+ const g = t.grid;
189
+ for (let f = 0; f < layer.count; f++) {
190
+ const c = Math.round((layer.centroid[f * 2] - g.minX) / g.cell), r = Math.round((g.maxY - layer.centroid[f * 2 + 1]) / g.cell);
191
+ if (c < 0 || r < 0 || c >= g.cols || r >= g.rows) continue;
192
+ const wet = Number.isFinite(res.depth[r * g.cols + c]) || (!state.seed && layer.zMin[f] < state.level);
193
+ // Water reaches a building at its lowest ground point, whatever the slope setting.
194
+ if (wet && layer.zMin[f] < state.level) { depth[f] = state.level - layer.zMin[f]; hit++; }
195
+ }
196
+ layer.setResultField('flood_depth_m', depth);
197
+ fields.push({ layer, name: 'flood_depth_m', values: depth });
198
+ }
199
+ state.fields = fields;
200
+ clear(info);
201
+ info.appendChild(stats([['Flooded area', `${(res.area / 10000).toFixed(2)} ha`], ['Water volume', `${Math.round(res.volume).toLocaleString('en-US')} m³`],
202
+ ['Deepest', `${res.maxDepth.toFixed(2)} m`], ['Buildings reached', String(hit)]]));
203
+ };
204
+ const body = h('div', {},
205
+ h('p', { class: 'muted small', text: 'A still water level: everything below it floods ("bathtub"), or only the ground connected to a point you pick, as water rising from a river or a low spot. Not a hydraulic model.' }),
206
+ row('Water level', levelSlider),
207
+ row('Flooding from', select([['all', 'All low ground'], ['seed', 'A picked point']], state.seed ? 'seed' : 'all', async (v) => {
208
+ if (v === 'seed') {
209
+ const p = await app.tools.pickPoint('Click where the water comes from', { roofs: false });
210
+ state.seed = p ? p.local : null;
211
+ } else state.seed = null;
212
+ apply();
213
+ })),
214
+ info,
215
+ h('div', { class: 'btn-row' }, button('Apply', apply, { kind: 'primary' }), button('Clear', () => { app.waterLevel = null; t.setWater(0, null); clear(info); }),
216
+ app.qgis ? button('To QGIS', async () => {
217
+ if (!state.result) return;
218
+ const r = { title: `Flood depth at ${state.level.toFixed(1)} m`, unit: 'm', grid: t.grid, values: state.result.depth, display: { min: 0, max: Math.max(0.1, state.result.maxDepth), ramp: 'blues' } };
219
+ await sendGrid(r, `flood_${state.level.toFixed(1).replace('.', '_')}m`);
220
+ if (state.fields?.length) await sendFields(state.fields, 'blues');
221
+ toast('Flood depth sent to QGIS.');
222
+ }) : null));
223
+ return card('Water level & flooding', body);
224
+ }
225
+
226
+ // ---------------------------------------------------------------- cut / fill
227
+ function cutFillCard(app) {
228
+ const t = app.terrain;
229
+ const state = { ring: null, level: 0, result: null };
230
+ const info = h('div', {});
231
+ const levelInput = number(0, { step: 0.1, onChange: (v) => { state.level = v; compute(); } });
232
+ const platforms = [];
233
+ for (const layer of app.layers) {
234
+ if (!(layer instanceof PlatformLayer)) continue;
235
+ layer.rings.forEach((rings, f) => { if (rings[0]) platforms.push([`${layer.index}:${f}`, `${layer.entry.name} #${f + 1}`, rings[0], layer, f]); });
236
+ }
237
+ const compute = () => {
238
+ if (!state.ring) return;
239
+ const res = cutFill(t.dataGrid, state.ring, state.level);
240
+ state.result = res;
241
+ clear(info);
242
+ info.appendChild(stats([['Platform area', `${Math.round(res.area).toLocaleString('en-US')} m²`], ['Cut', `${Math.round(res.cut).toLocaleString('en-US')} m³`],
243
+ ['Fill', `${Math.round(res.fill).toLocaleString('en-US')} m³`], ['Net (fill − cut)', `${Math.round(res.net).toLocaleString('en-US')} m³`]]));
244
+ const st = gridStats(res.delta);
245
+ const m = Math.max(Math.abs(st.min), Math.abs(st.max), 0.5);
246
+ app.results.unshift({ id: `cf${Date.now()}`, kind: 'cutfill', title: `Cut & fill to ${state.level.toFixed(2)} m`, subtitle: 'Blue: fill, red: cut',
247
+ unit: 'm', grid: t.grid, values: res.delta, display: { min: -m, max: m, ramp: 'rdbu' }, fields: [], created: new Date(),
248
+ stats: [['Cut', `${Math.round(res.cut)} m³`], ['Fill', `${Math.round(res.fill)} m³`]] });
249
+ app.showResult(app.results[0]);
250
+ app.emit('results');
251
+ const sel = state.platform;
252
+ if (sel) sel[3].setLevel(sel[4], state.level);
253
+ };
254
+ const setRing = (ring, platform = null) => {
255
+ state.ring = ring;
256
+ state.platform = platform;
257
+ const lvl = platform && platform[3].levels[platform[4]] > 0 ? platform[3].levels[platform[4]] : balancedLevel(t.grid, ring);
258
+ state.level = Math.round(lvl * 100) / 100;
259
+ levelInput.value = String(state.level);
260
+ app.tools.outline('cutfill-outline', [...ring, ring[0]], 0xc9a46b);
261
+ compute();
262
+ };
263
+ const body = h('div', {},
264
+ h('p', { class: 'muted small', text: 'Earthwork to bring an area to one flat design level. "Balance" finds the level where cut equals fill.' }),
265
+ h('div', { class: 'btn-row' }, button('Draw platform', async () => {
266
+ const ring = await app.tools.drawPolygon('Draw the platform outline');
267
+ if (ring) setRing(ring);
268
+ }, { kind: 'primary' }),
269
+ platforms.length ? select([['', 'Platform from QGIS…'], ...platforms.map(([k, l]) => [k, l])], '', (v) => {
270
+ const p = platforms.find(([k]) => k === v);
271
+ if (p) setRing(p[2], p);
272
+ }) : null),
273
+ row('Design level (m)', levelInput),
274
+ h('div', { class: 'btn-row' }, button('Balance', () => { if (state.ring) { state.level = Math.round(balancedLevel(t.grid, state.ring) * 100) / 100; levelInput.value = String(state.level); compute(); } })),
275
+ info);
276
+ return card('Cut & fill', body);
277
+ }
@@ -0,0 +1,127 @@
1
+ // @ts-check
2
+ // Viewer <-> QGIS link through the plugin's local server (core/server.py).
3
+ import { rampStops, rgbToHex } from '../core/classify.js';
4
+
5
+ let online = null;
6
+
7
+ export async function qgisOnline() {
8
+ if (online !== null) return online;
9
+ try {
10
+ const r = await fetch('/api/ping', { cache: 'no-store' });
11
+ online = r.ok;
12
+ } catch {
13
+ online = false;
14
+ }
15
+ return online;
16
+ }
17
+
18
+ export function float32ToBase64(values) {
19
+ const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
20
+ let out = '';
21
+ const chunk = 0x8000;
22
+ for (let i = 0; i < bytes.length; i += chunk) out += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
23
+ return btoa(out);
24
+ }
25
+
26
+ async function post(path, body) {
27
+ const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
28
+ if (!r.ok) throw new Error(`QGIS link: HTTP ${r.status}`);
29
+ return r.json();
30
+ }
31
+
32
+ /**
33
+ * The viewer's colours for QGIS: the ramp's stops (reversed ramps already
34
+ * reversed), or the classes of a class result with their legend labels.
35
+ */
36
+ export function qgisStyle(display = {}, legend = null) {
37
+ if (display.categorical) {
38
+ const labels = display.labels || {};
39
+ const byColor = new Map((legend || []).map((it) => [String(it.color).toLowerCase(), it.label]));
40
+ const categories = Object.entries(display.categorical).map(([value, color]) => ({
41
+ value: Number(value), color, label: labels[value] ?? byColor.get(String(color).toLowerCase()) ?? String(value)
42
+ }));
43
+ return { categories };
44
+ }
45
+ const ramp = String(display.ramp || 'viridis');
46
+ return { ramp, stops: rampStops(ramp).map(rgbToHex) };
47
+ }
48
+
49
+ /** Send a grid result as a GeoTIFF layer. */
50
+ export async function sendGrid(result, key) {
51
+ const values = result.values instanceof Float32Array ? result.values : Float32Array.from(result.values);
52
+ // Only the georeference: some grids (terrain maps, flooding) carry their own
53
+ // values array, which JSON would send a second time as a huge object.
54
+ const { cols, rows, cell, minX, maxY } = result.grid;
55
+ return post('/api/result', {
56
+ type: 'grid', name: result.title, key, unit: result.unit,
57
+ grid: { cols, rows, cell, minX, maxY }, data: float32ToBase64(values),
58
+ style: { min: result.display.min, max: result.display.max, ...qgisStyle(result.display, result.legend) }
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Send per-feature fields ([{ layer, name, values }]) grouped by layer.
64
+ * style: a ramp name, or { field, categories, other } / { ramp } for QGIS.
65
+ */
66
+ export async function sendFields(fields, style = 'viridis') {
67
+ const st = typeof style === 'string' ? qgisStyle({ ramp: style }) : (style.categories ? style : { ...qgisStyle({ ramp: style.ramp }), ...style });
68
+ const byLayer = new Map();
69
+ for (const f of fields) {
70
+ const out = byLayer.get(f.layer) || {};
71
+ const values = {};
72
+ f.values.forEach((v, i) => { if (Number.isFinite(v)) values[i + 1] = Math.round(v * 1000) / 1000; });
73
+ out[f.name] = { values };
74
+ byLayer.set(f.layer, out);
75
+ }
76
+ const sent = [];
77
+ for (const [layer, fieldsObj] of byLayer) {
78
+ await post('/api/result', { type: 'features', layer: layer.entry.id, fields: fieldsObj, style: st });
79
+ sent.push(layer.entry.name);
80
+ }
81
+ return sent;
82
+ }
83
+
84
+ /** Send pedestrian route lines (shortest vs. coolest path) as a GeoPackage line layer. */
85
+ export async function sendRoutes(result, key) {
86
+ if (!result.routes?.length) return null;
87
+ return post('/api/result', {
88
+ type: 'routes',
89
+ name: `${result.title} (routes)`,
90
+ key: `${key}_routes`,
91
+ routes: result.routes
92
+ });
93
+ }
94
+
95
+ /** Batch-send all results (grids, building fields and route layers) to QGIS. */
96
+ export async function sendAllResults(results) {
97
+ let grids = 0;
98
+ let routeSets = 0;
99
+ const layerNames = new Set();
100
+ for (const r of results || []) {
101
+ const stamp = (r.created instanceof Date ? r.created.toISOString() : new Date().toISOString())
102
+ .slice(0, 16).replace(/[-:T]/g, '');
103
+ const key = `${r.kind}_${stamp}`;
104
+ if (r.grid && r.values) {
105
+ await sendGrid(r, key);
106
+ grids++;
107
+ }
108
+ if (r.fields?.length) {
109
+ const sent = await sendFields(r.fields, r.qgisStyle || String(r.display?.ramp || 'viridis'));
110
+ for (const name of sent) layerNames.add(name);
111
+ }
112
+ if (r.routes?.length) {
113
+ await sendRoutes(r, key);
114
+ routeSets++;
115
+ }
116
+ }
117
+ return { grids, routeSets, layers: [...layerNames] };
118
+ }
119
+
120
+ export async function selectInQgis(layer, ids) {
121
+ return post('/api/select', { layer: layer.entry.id, ids });
122
+ }
123
+
124
+ export async function qgisSelection() {
125
+ const r = await fetch('/api/selection', { cache: 'no-store' });
126
+ return r.ok ? r.json() : { seq: 0, layers: {} };
127
+ }
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ // Top-bar sun clock: date, time of day, play through the day.
3
+ import { h, select, number } from './dom.js';
4
+ import { MONTH_NAMES, typicalDoy } from '../core/weather.js';
5
+ import { sunriseSunset } from '../core/solar.js';
6
+
7
+ export function buildSunClock(app, root) {
8
+ const fmt = (t) => `${String(Math.floor(t)).padStart(2, '0')}:${String(Math.round((t % 1) * 60) % 60).padStart(2, '0')}`;
9
+ const timeLabel = h('span', { class: 'clock-time', text: fmt(app.time) });
10
+ const sunLabel = h('span', { class: 'clock-sun' });
11
+ const range = h('input', { type: 'range', min: 0, max: 24, step: 0.05, value: String(app.time), class: 'clock-range',
12
+ oninput: () => { app.time = Number(range.value); app.updateSun(); } });
13
+ let playing = 0;
14
+ const play = h('button', { class: 'btn icon-btn', title: 'Play the day', text: '▶', onclick: () => {
15
+ if (playing) { cancelAnimationFrame(playing); playing = 0; play.textContent = '▶'; return; }
16
+ const { sunrise, sunset } = sunriseSunset(typicalDoy(app.date.month, app.date.day), app.site.lat, app.site.lon, app.site.utcOffset);
17
+ const start = sunrise ?? 5, end = sunset ?? 20;
18
+ if (app.time < start || app.time >= end - 0.05) app.time = start;
19
+ play.textContent = '❚❚';
20
+ let last = performance.now();
21
+ const step = (now) => {
22
+ const dt = (now - last) / 1000;
23
+ last = now;
24
+ app.time += dt * ((end - start) / 18); // the whole day in about 18 s
25
+ if (app.time >= end) { app.time = end; playing = 0; play.textContent = '▶'; app.updateSun(); return; }
26
+ app.updateSun();
27
+ playing = requestAnimationFrame(step);
28
+ };
29
+ playing = requestAnimationFrame(step);
30
+ } });
31
+ const month = select(MONTH_NAMES.map((m, i) => [i + 1, m]), app.date.month, (v) => { app.date.month = Number(v); app.updateSun(); });
32
+ const day = number(app.date.day, { min: 1, max: 31, step: 1, width: '4.6em', onChange: (v) => { app.date.day = Math.max(1, Math.min(31, v)); app.updateSun(); } });
33
+ app.on((what) => {
34
+ if (what !== 'sun') return;
35
+ range.value = String(app.time);
36
+ month.value = String(app.date.month);
37
+ day.value = String(app.date.day);
38
+ timeLabel.textContent = fmt(app.time);
39
+ const p = app.sunPosition();
40
+ const el = (p.elevation * 180) / Math.PI;
41
+ sunLabel.textContent = el > 0 ? `sun ${el.toFixed(0)}° high, ${((p.azimuth * 180) / Math.PI).toFixed(0)}°` : 'sun below the horizon';
42
+ });
43
+ root.append(h('span', { class: 'clock-icon', text: '☀' }), month, day, range, timeLabel, play, sunLabel);
44
+ app.emit('sun');
45
+ }
@@ -0,0 +1,197 @@
1
+ // @ts-check
2
+ // Pointer tools on the 3D view: pick a point, draw a line, a polygon or a
3
+ // rectangle on the ground. Each returns a promise that resolves with the
4
+ // result in local metres (x east, y north), or null when cancelled.
5
+ import * as THREE from 'three';
6
+
7
+ export class Tools {
8
+ constructor(app) {
9
+ this.app = app;
10
+ this.viewer = app.viewer;
11
+ this.active = null;
12
+ this.group = new THREE.Group();
13
+ this.group.renderOrder = 10;
14
+ this.viewer.helpers.add(this.group);
15
+ const el = this.viewer.renderer.domElement;
16
+ this._down = null;
17
+ el.addEventListener('pointerdown', (e) => { this._down = { x: e.clientX, y: e.clientY, t: performance.now() }; });
18
+ el.addEventListener('pointerup', (e) => {
19
+ if (!this._down || e.button !== 0) return;
20
+ const moved = Math.hypot(e.clientX - this._down.x, e.clientY - this._down.y);
21
+ this._down = null;
22
+ if (moved > 5) return;
23
+ if (this.active) this.active.click(e);
24
+ else app.onClick(e);
25
+ });
26
+ el.addEventListener('pointermove', (e) => this.active?.move?.(e));
27
+ el.addEventListener('dblclick', (e) => { if (this.active?.finish) { e.preventDefault(); this.active.finish(); } });
28
+ window.addEventListener('keydown', (e) => {
29
+ if (!this.active) return;
30
+ if (e.key === 'Escape') this.cancel();
31
+ else if (e.key === 'Enter') this.active.finish?.();
32
+ else if (e.key === 'Backspace') this.active.undo?.();
33
+ });
34
+ }
35
+
36
+ /** Ground point under the pointer: { world, local } or null. */
37
+ groundAt(e, { roofs = false } = {}) {
38
+ const ndc = this.viewer.ndc(e.clientX, e.clientY);
39
+ const ray = new THREE.Raycaster();
40
+ ray.setFromCamera(ndc, this.viewer.camera);
41
+ let hit = this.app.terrain.raycast(ray.ray.origin, ray.ray.direction);
42
+ if (roofs) {
43
+ ray.layers.set(0);
44
+ const meshes = [];
45
+ for (const l of this.app.layers) if (l.chunks && l.visible) for (const c of l.chunks) meshes.push(c.mesh);
46
+ const hits = ray.intersectObjects(meshes, false);
47
+ if (hits.length && (!hit || hits[0].distance < hit.distanceTo(ray.ray.origin))) hit = hits[0].point;
48
+ }
49
+ if (!hit) return null;
50
+ return { world: hit, local: [hit.x, -hit.z] };
51
+ }
52
+
53
+ _hint(text) {
54
+ const el = document.getElementById('tool-hint');
55
+ if (!el) return;
56
+ el.textContent = text || '';
57
+ el.hidden = !text;
58
+ }
59
+
60
+ _clearGraphics() {
61
+ for (const c of [...this.group.children]) {
62
+ this.group.remove(c);
63
+ /** @type {any} */ (c).geometry?.dispose();
64
+ }
65
+ this.viewer.requestRender();
66
+ }
67
+
68
+ cancel() {
69
+ const a = this.active;
70
+ this.active = null;
71
+ this._hint('');
72
+ this._clearGraphics();
73
+ this.viewer.renderer.domElement.style.cursor = '';
74
+ a?.resolve(null);
75
+ }
76
+
77
+ _start(tool) {
78
+ if (this.active) this.cancel();
79
+ this.active = tool;
80
+ this.viewer.renderer.domElement.style.cursor = 'crosshair';
81
+ }
82
+
83
+ _end(value) {
84
+ const a = this.active;
85
+ this.active = null;
86
+ this._hint('');
87
+ this.viewer.renderer.domElement.style.cursor = '';
88
+ a?.resolve(value);
89
+ }
90
+
91
+ pickPoint(hint = 'Click a point', { roofs = true } = {}) {
92
+ return new Promise((resolve) => {
93
+ this._start({ resolve, click: (e) => { const p = this.groundAt(e, { roofs }); if (p) this._end(p); } });
94
+ this._hint(`${hint} · Esc cancels`);
95
+ });
96
+ }
97
+
98
+ _drawPath(closed, hint) {
99
+ return new Promise((resolve) => {
100
+ const pts = [];
101
+ let cursor = null;
102
+ const redraw = () => {
103
+ this._clearGraphics();
104
+ const all = cursor ? [...pts, cursor] : [...pts];
105
+ if (closed && all.length > 2) all.push(all[0]);
106
+ if (all.length < 2) return;
107
+ const pos = [];
108
+ for (const [x, y] of all) pos.push(x, this.app.terrain.groundY(x, y) + 1.5, -y);
109
+ const geom = new THREE.BufferGeometry();
110
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
111
+ const line = new THREE.Line(geom, new THREE.LineBasicMaterial({ color: 0xff4f2e, depthTest: false }));
112
+ line.renderOrder = 20;
113
+ this.group.add(line);
114
+ this.viewer.requestRender();
115
+ };
116
+ const finish = () => {
117
+ if (pts.length < (closed ? 3 : 2)) return;
118
+ this._clearGraphics();
119
+ this._end(pts.slice());
120
+ };
121
+ this._start({
122
+ resolve,
123
+ click: (e) => { const p = this.groundAt(e); if (p) { pts.push(p.local); redraw(); } },
124
+ move: (e) => { if (!pts.length) return; const p = this.groundAt(e); if (p) { cursor = p.local; redraw(); } },
125
+ undo: () => { pts.pop(); redraw(); },
126
+ finish
127
+ });
128
+ this._hint(`${hint} · double-click or Enter finishes · Backspace undoes · Esc cancels`);
129
+ });
130
+ }
131
+
132
+ drawLine(hint = 'Click points along the line') {
133
+ return this._drawPath(false, hint);
134
+ }
135
+
136
+ drawPolygon(hint = 'Click the corners of the area') {
137
+ return this._drawPath(true, hint);
138
+ }
139
+
140
+ drawRect(hint = 'Click two opposite corners') {
141
+ return new Promise((resolve) => {
142
+ let a = null;
143
+ let b = null;
144
+ const redraw = () => {
145
+ this._clearGraphics();
146
+ if (!a || !b) return;
147
+ const ring = [[a[0], a[1]], [b[0], a[1]], [b[0], b[1]], [a[0], b[1]], [a[0], a[1]]];
148
+ const pos = [];
149
+ for (const [x, y] of ring) pos.push(x, this.app.terrain.groundY(x, y) + 2, -y);
150
+ const geom = new THREE.BufferGeometry();
151
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
152
+ const line = new THREE.Line(geom, new THREE.LineBasicMaterial({ color: 0x1f8fff, depthTest: false }));
153
+ line.renderOrder = 20;
154
+ this.group.add(line);
155
+ this.viewer.requestRender();
156
+ };
157
+ this._start({
158
+ resolve,
159
+ click: (e) => {
160
+ const p = this.groundAt(e);
161
+ if (!p) return;
162
+ if (!a) { a = p.local; return; }
163
+ b = p.local;
164
+ this._clearGraphics();
165
+ this._end({ minX: Math.min(a[0], b[0]), maxX: Math.max(a[0], b[0]), minY: Math.min(a[1], b[1]), maxY: Math.max(a[1], b[1]) });
166
+ },
167
+ move: (e) => { if (!a) return; const p = this.groundAt(e); if (p) { b = p.local; redraw(); } }
168
+ });
169
+ this._hint(`${hint} · Esc cancels`);
170
+ });
171
+ }
172
+
173
+ /** Persistent outline of an analysis area or drawn geometry. */
174
+ outline(name, ring, color = 0x1f8fff) {
175
+ const prev = this.viewer.helpers.getObjectByName(name);
176
+ if (prev) { this.viewer.helpers.remove(prev); /** @type {any} */ (prev).geometry?.dispose(); }
177
+ if (!ring) { this.viewer.requestRender(); return; }
178
+ const pos = [];
179
+ const step = Math.max(2, this.app.terrain.grid.cell);
180
+ for (let i = 0; i < ring.length - 1; i++) {
181
+ const [x0, y0] = ring[i], [x1, y1] = ring[i + 1];
182
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step));
183
+ for (let k = 0; k <= n; k++) {
184
+ const x = x0 + ((x1 - x0) * k) / n, y = y0 + ((y1 - y0) * k) / n;
185
+ pos.push(x, this.app.terrain.groundY(x, y) + 1.2, -y);
186
+ }
187
+ }
188
+ const geom = new THREE.BufferGeometry();
189
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
190
+ const line = new THREE.Line(geom, new THREE.LineDashedMaterial({ color, dashSize: 6, gapSize: 4, depthTest: false }));
191
+ line.computeLineDistances();
192
+ line.name = name;
193
+ line.renderOrder = 15;
194
+ this.viewer.helpers.add(line);
195
+ this.viewer.requestRender();
196
+ }
197
+ }