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,373 @@
1
+ """Weather: EnergyPlus EPW parsing, monthly summaries, wind rose, annual
2
+ sun-direction bins for radiation, and a clear-sky year when no EPW exists.
3
+
4
+ Ported from the 02Geo3D QGIS plugin (``web/src/core/weather.js``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Optional, Sequence, Union
14
+
15
+ import numpy as np
16
+
17
+ from .solar import solar_position, sun_vector
18
+
19
+ __all__ = [
20
+ "MONTH_NAMES",
21
+ "Weather",
22
+ "annual_sun_bins",
23
+ "clear_sky_year",
24
+ "conditions_at",
25
+ "hour_index",
26
+ "monthly_summary",
27
+ "parse_epw",
28
+ "read_epw",
29
+ "typical_doy",
30
+ "wind_rose",
31
+ ]
32
+
33
+ MONTH_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
34
+ MONTH_START = tuple(int(x) for x in np.concatenate(([0], np.cumsum(MONTH_DAYS)[:-1])))
35
+ MONTH_NAMES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
36
+ HOURS = 8760
37
+
38
+ _NUMERIC_ROW = re.compile(r"^\s*-?\d")
39
+
40
+
41
+ @dataclass
42
+ class Weather:
43
+ """Hourly weather for a 365-day typical year (8760 records).
44
+
45
+ Arrays are indexed by hour of the year (0 = 1 January, hour ending 01:00).
46
+ Fields that a source does not provide (e.g. air temperature for a
47
+ :func:`clear_sky_year`) are ``None``.
48
+ """
49
+
50
+ location: dict[str, Any]
51
+ month: np.ndarray
52
+ day: np.ndarray
53
+ hour: np.ndarray
54
+ ghi: np.ndarray
55
+ dni: np.ndarray
56
+ dhi: np.ndarray
57
+ dry_bulb: Optional[np.ndarray] = None
58
+ dew_point: Optional[np.ndarray] = None
59
+ rh: Optional[np.ndarray] = None
60
+ pressure: Optional[np.ndarray] = None
61
+ ir: Optional[np.ndarray] = None
62
+ wind_dir: Optional[np.ndarray] = None
63
+ wind_speed: Optional[np.ndarray] = None
64
+ sky_cover: Optional[np.ndarray] = None
65
+ extra: dict[str, Any] = field(default_factory=dict)
66
+
67
+ @property
68
+ def hours(self) -> int:
69
+ return int(self.month.shape[0])
70
+
71
+
72
+ def typical_doy(month: int, day: int) -> int:
73
+ """Day of year in a 365-day typical year."""
74
+ return MONTH_START[int(month) - 1] + int(day)
75
+
76
+
77
+ def _num(text: str, missing: float, fallback: float) -> float:
78
+ try:
79
+ v = float(text)
80
+ except (TypeError, ValueError):
81
+ return fallback
82
+ return v if math.isfinite(v) and v < missing else fallback
83
+
84
+
85
+ def parse_epw(text: str) -> Weather:
86
+ """Parse EnergyPlus EPW text into a :class:`Weather`.
87
+
88
+ Missing values use the EPW sentinels and fall back to neutral defaults.
89
+ Leap-year files carry 29 February: it is dropped so the year has 8760 hours.
90
+
91
+ Raises:
92
+ ValueError: if the text has no LOCATION line or fewer than 8760 records.
93
+ """
94
+ lines = re.split(r"\r?\n", text)
95
+ loc = [s.strip() for s in lines[0].split(",")] if lines else []
96
+ if not loc or loc[0].upper() != "LOCATION" or len(loc) < 10:
97
+ raise ValueError("Not an EPW file (no LOCATION line).")
98
+ location: dict[str, Any] = {
99
+ "city": loc[1],
100
+ "region": loc[2],
101
+ "country": loc[3],
102
+ "source": loc[4],
103
+ "wmo": loc[5],
104
+ "lat": _num(loc[6], math.inf, math.nan),
105
+ "lon": _num(loc[7], math.inf, math.nan),
106
+ "utc_offset": _num(loc[8], math.inf, math.nan),
107
+ "elevation": _num(loc[9], math.inf, math.nan),
108
+ }
109
+ rows = [ln.split(",") for ln in lines[8:] if ln and _NUMERIC_ROW.match(ln)]
110
+ if len(rows) < HOURS:
111
+ raise ValueError(f"EPW has {len(rows)} hourly records; {HOURS} are required.")
112
+
113
+ n = HOURS
114
+ month = np.zeros(n, dtype=np.uint8)
115
+ day = np.zeros(n, dtype=np.uint8)
116
+ hour = np.zeros(n, dtype=np.uint8)
117
+ cols = {
118
+ k: np.zeros(n)
119
+ for k in (
120
+ "dry_bulb",
121
+ "dew_point",
122
+ "rh",
123
+ "pressure",
124
+ "ir",
125
+ "ghi",
126
+ "dni",
127
+ "dhi",
128
+ "wind_dir",
129
+ "wind_speed",
130
+ "sky_cover",
131
+ )
132
+ }
133
+ spec = (
134
+ ("dry_bulb", 6, 99.9, 20.0, False),
135
+ ("dew_point", 7, 99.9, 10.0, False),
136
+ ("rh", 8, 999, 50.0, False),
137
+ ("pressure", 9, 999999, 101325.0, False),
138
+ ("ir", 12, 9999, math.nan, False),
139
+ ("ghi", 13, 9999, 0.0, True),
140
+ ("dni", 14, 9999, 0.0, True),
141
+ ("dhi", 15, 9999, 0.0, True),
142
+ ("wind_dir", 20, 999, 0.0, False),
143
+ ("wind_speed", 21, 999, 0.0, True),
144
+ ("sky_cover", 22, 99, 5.0, False),
145
+ )
146
+ k = 0
147
+ for r in rows:
148
+ if k >= n:
149
+ break
150
+ if len(r) < 23:
151
+ r = r + [""] * (23 - len(r))
152
+ m = int(_num(r[1], math.inf, 0))
153
+ d = int(_num(r[2], math.inf, 0))
154
+ if m == 2 and d == 29:
155
+ continue
156
+ month[k] = m
157
+ day[k] = d
158
+ hour[k] = int(_num(r[3], math.inf, 0))
159
+ for name, idx, missing, fallback, nonneg in spec:
160
+ v = _num(r[idx], missing, fallback)
161
+ cols[name][k] = max(0.0, v) if nonneg else v
162
+ k += 1
163
+ if k < n:
164
+ raise ValueError(f"EPW ended before {HOURS} hours.")
165
+ return Weather(location=location, month=month, day=day, hour=hour, **cols) # type: ignore[arg-type]
166
+
167
+
168
+ def read_epw(path: Union[str, Path]) -> Weather:
169
+ """Read and parse an EPW file from disk (UTF-8, Latin-1 fallback)."""
170
+ raw = Path(path).read_bytes()
171
+ try:
172
+ text = raw.decode("utf-8")
173
+ except UnicodeDecodeError:
174
+ text = raw.decode("latin-1")
175
+ return parse_epw(text)
176
+
177
+
178
+ def hour_index(month: int, day: int, hour_ending: int) -> int:
179
+ """Index of the EPW record for a month, day and clock hour (1..24 = hour ending)."""
180
+ return (typical_doy(month, day) - 1) * 24 + (int(hour_ending) - 1)
181
+
182
+
183
+ def monthly_summary(w: Weather) -> list[dict[str, Any]]:
184
+ """Monthly mean/min/max temperature, mean RH and wind, and GHI sum (kWh/m2)."""
185
+ out: list[dict[str, Any]] = []
186
+ for m, name in enumerate(MONTH_NAMES, start=1):
187
+ sel = w.month == m
188
+ cnt = int(sel.sum())
189
+ rec: dict[str, Any] = {
190
+ "name": name,
191
+ "t_mean": 0.0,
192
+ "t_min": math.inf,
193
+ "t_max": -math.inf,
194
+ "rh": 0.0,
195
+ "ghi_kwh": float(w.ghi[sel].sum() / 1000),
196
+ "wind": 0.0,
197
+ "n": cnt,
198
+ }
199
+ if cnt:
200
+ if w.dry_bulb is not None:
201
+ t = w.dry_bulb[sel]
202
+ rec.update(t_mean=float(t.mean()), t_min=float(t.min()), t_max=float(t.max()))
203
+ if w.rh is not None:
204
+ rec["rh"] = float(w.rh[sel].mean())
205
+ if w.wind_speed is not None:
206
+ rec["wind"] = float(w.wind_speed[sel].mean())
207
+ out.append(rec)
208
+ return out
209
+
210
+
211
+ def _filter(w: Weather, months: Optional[Sequence[int]], hours: Optional[Sequence[int]]) -> Any:
212
+ sel = np.ones(w.hours, dtype=bool)
213
+ if months:
214
+ sel &= np.isin(w.month, list(months))
215
+ if hours:
216
+ sel &= np.isin(w.hour, list(hours))
217
+ return sel
218
+
219
+
220
+ def wind_rose(
221
+ w: Weather,
222
+ sectors: int = 16,
223
+ classes: Sequence[float] = (0.5, 2, 4, 6, 8, 11),
224
+ months: Optional[Sequence[int]] = None,
225
+ hours: Optional[Sequence[int]] = None,
226
+ ) -> dict[str, Any]:
227
+ """Wind rose: frequency (fraction of hours) per direction sector and speed class.
228
+
229
+ Calm hours (speed below ``classes[0]``) are counted separately. Returns a
230
+ dict with ``freq`` (sectors x classes), ``sector_freq``, ``calm``,
231
+ ``mean_speed``, ``prevailing_direction`` (degrees) and ``prevailing_speed``.
232
+ """
233
+ if w.wind_speed is None or w.wind_dir is None:
234
+ raise ValueError("weather has no wind data")
235
+ cls = np.asarray(classes, dtype=float)
236
+ sel = _filter(w, months, hours)
237
+ v = w.wind_speed[sel].astype(float)
238
+ dirs = w.wind_dir[sel].astype(float)
239
+ total = int(v.size)
240
+ calm_mask = v < cls[0]
241
+ sector = np.floor(np.mod(dirs, 360) / (360 / sectors) + 0.5).astype(int) % sectors
242
+ # Speed class: first j>=1 with v < classes[j] gives j-1, else the last class.
243
+ c = np.searchsorted(cls, v, side="right") - 1
244
+ c = np.clip(c, 0, len(cls) - 1)
245
+ table = np.zeros((sectors, len(cls)))
246
+ ok = ~calm_mask
247
+ np.add.at(table, (sector[ok], c[ok]), 1)
248
+ freq = table / total if total else table
249
+ sector_freq = freq.sum(axis=1)
250
+ prevailing = int(np.argmax(sector_freq)) if sectors else 0
251
+ p = ok & (sector == prevailing)
252
+ return {
253
+ "sectors": sectors,
254
+ "classes": list(cls),
255
+ "freq": freq,
256
+ "sector_freq": sector_freq,
257
+ "calm": float(calm_mask.sum() / total) if total else 0.0,
258
+ "mean_speed": float(v.mean()) if total else 0.0,
259
+ "prevailing_direction": prevailing * (360 / sectors),
260
+ "prevailing_speed": float(v[p].mean()) if p.any() else 0.0,
261
+ }
262
+
263
+
264
+ # ASHRAE clear-sky model constants per month (A in W/m2, B, C; ASHRAE
265
+ # Handbook of Fundamentals, 1985): DNI = A exp(-B / sin(altitude)), DHI = C DNI.
266
+ ASHRAE_CLEAR = (
267
+ (1230, 0.142, 0.058),
268
+ (1215, 0.144, 0.060),
269
+ (1186, 0.156, 0.071),
270
+ (1136, 0.180, 0.097),
271
+ (1104, 0.196, 0.121),
272
+ (1088, 0.205, 0.134),
273
+ (1085, 0.207, 0.136),
274
+ (1107, 0.201, 0.122),
275
+ (1151, 0.177, 0.092),
276
+ (1192, 0.160, 0.073),
277
+ (1221, 0.149, 0.063),
278
+ (1233, 0.142, 0.057),
279
+ )
280
+
281
+
282
+ def clear_sky_year(lat: float, lon: float, utc_offset: float) -> Weather:
283
+ """Hourly clear-sky DNI/DHI/GHI (Wh/m2 per hour): an upper bound with no clouds."""
284
+ n = HOURS
285
+ month = np.zeros(n, dtype=np.uint8)
286
+ day = np.zeros(n, dtype=np.uint8)
287
+ hour = np.zeros(n, dtype=np.uint8)
288
+ ghi = np.zeros(n)
289
+ dni = np.zeros(n)
290
+ dhi = np.zeros(n)
291
+ k = 0
292
+ for m in range(1, 13):
293
+ a, b, c = ASHRAE_CLEAR[m - 1]
294
+ for d in range(1, MONTH_DAYS[m - 1] + 1):
295
+ doy = typical_doy(m, d)
296
+ for h in range(1, 25):
297
+ month[k], day[k], hour[k] = m, d, h
298
+ p = solar_position(doy, h - 0.5, lat, lon, utc_offset)
299
+ if p.elevation > 0.0175:
300
+ s = math.sin(p.elevation)
301
+ beam = a * math.exp(-b / s)
302
+ dni[k] = beam
303
+ dhi[k] = c * beam
304
+ ghi[k] = beam * s + c * beam
305
+ k += 1
306
+ loc = {"lat": lat, "lon": lon, "utc_offset": utc_offset, "city": "Clear sky", "clear_sky": True}
307
+ return Weather(location=loc, month=month, day=day, hour=hour, ghi=ghi, dni=dni, dhi=dhi)
308
+
309
+
310
+ def annual_sun_bins(
311
+ w: Weather,
312
+ lat: float,
313
+ lon: float,
314
+ utc_offset: float,
315
+ months: Optional[Sequence[int]] = None,
316
+ alt_step_deg: float = 5,
317
+ az_step_deg: float = 7.5,
318
+ ) -> dict[str, Any]:
319
+ """Bin the year's direct-beam hours into sky patches.
320
+
321
+ Each patch becomes one direction (the DNI-weighted mean sun vector of its
322
+ hours, scene axes +x east, +y up, -z north) with the patch's DNI sum in
323
+ Wh/m2 as weight. Also returns DHI, GHI and DNI sums.
324
+
325
+ Returns:
326
+ ``{"directions": [{"dir": (x, y, z), "weight": wh}], "dhi_sum", "ghi_sum", "dni_sum"}``
327
+ """
328
+ sel = _filter(w, months, None)
329
+ bins: dict[int, list[float]] = {}
330
+ dhi_sum = float(w.dhi[sel].sum())
331
+ ghi_sum = float(w.ghi[sel].sum())
332
+ dni_sum = 0.0
333
+ alt_step = math.radians(alt_step_deg)
334
+ az_step = math.radians(az_step_deg)
335
+ for i in np.nonzero(sel & (w.dni > 0))[0]:
336
+ dni = float(w.dni[i])
337
+ p = solar_position(
338
+ typical_doy(int(w.month[i]), int(w.day[i])), int(w.hour[i]) - 0.5, lat, lon, utc_offset
339
+ )
340
+ if p.elevation <= 0.0087: # below 0.5 degrees
341
+ continue
342
+ dni_sum += dni
343
+ key = int(math.floor(p.elevation / alt_step)) * 1000 + int(math.floor(p.azimuth / az_step))
344
+ v = sun_vector(p.elevation, p.azimuth)
345
+ b = bins.setdefault(key, [0.0, 0.0, 0.0, 0.0])
346
+ b[0] += v[0] * dni
347
+ b[1] += v[1] * dni
348
+ b[2] += v[2] * dni
349
+ b[3] += dni
350
+ directions = []
351
+ for x, y, z, wt in bins.values():
352
+ length = math.sqrt(x * x + y * y + z * z) or 1.0
353
+ directions.append({"dir": (x / length, y / length, z / length), "weight": wt})
354
+ return {"directions": directions, "dhi_sum": dhi_sum, "ghi_sum": ghi_sum, "dni_sum": dni_sum}
355
+
356
+
357
+ def _at(arr: Optional[np.ndarray], i: int) -> Optional[float]:
358
+ return None if arr is None else float(arr[i])
359
+
360
+
361
+ def conditions_at(w: Weather, month: int, day: int, hour_ending: int) -> dict[str, Optional[float]]:
362
+ """Hourly conditions for comfort analysis (``None`` for fields the source lacks)."""
363
+ i = hour_index(month, day, hour_ending)
364
+ return {
365
+ "air_temp": _at(w.dry_bulb, i),
366
+ "rh": _at(w.rh, i),
367
+ "wind_speed": _at(w.wind_speed, i),
368
+ "wind_dir": _at(w.wind_dir, i),
369
+ "ghi": float(w.ghi[i]),
370
+ "dni": float(w.dni[i]),
371
+ "dhi": float(w.dhi[i]),
372
+ "ir": _at(w.ir, i),
373
+ }
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>02 Geo3D Analyzer</title>
7
+ <link rel="icon" href="data:,">
8
+ <link rel="stylesheet" href="./src/style.css">
9
+ <script type="importmap">
10
+ {
11
+ "imports": {
12
+ "three": "./vendor/three/three.module.js",
13
+ "three/addons/": "./vendor/three/addons/"
14
+ }
15
+ }
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div id="app">
20
+ <header id="topbar">
21
+ <div class="brand"><span class="logo">02</span><span>Geo3D Analyzer</span></div>
22
+ <div id="scene-title" class="scene-title"></div>
23
+ <div id="sun-clock" class="sun-clock"></div>
24
+ <div id="fps" class="fps"></div>
25
+ <a href="https://geophilo.com/zero2geospatial3d/" target="_blank" rel="noopener" class="topbar-manual" title="Open the 02 Geo3D Analyzer Reference Manual">Manual ↗</a>
26
+ </header>
27
+ <aside id="sidebar">
28
+ <nav id="tabs"></nav>
29
+ <div id="panels"></div>
30
+ </aside>
31
+ <main id="view">
32
+ <div id="tool-hint" class="tool-hint" hidden></div>
33
+ <div id="legend" class="legend" hidden></div>
34
+ <div id="inspector" class="inspector" hidden></div>
35
+ <div id="empty" class="empty" hidden>
36
+ <h2>No scene to show</h2>
37
+ <p>Open the viewer from QGIS: <b>02 Geo3D Analyzer</b> panel → <b>Open in 3D</b>.</p>
38
+ <p class="detail"></p>
39
+ </div>
40
+ <div id="toast" class="toast"></div>
41
+ </main>
42
+ <footer id="statusbar">
43
+ <span id="status-text">Starting …</span>
44
+ <span id="status-progress" class="progress" hidden><span></span></span>
45
+ <span class="hint">Drag to orbit · right-drag to pan · wheel to zoom · click a building to inspect</span>
46
+ </footer>
47
+ </div>
48
+ <script type="module" src="./src/main.js"></script>
49
+ </body>
50
+ </html>