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,555 @@
1
+ """Headless 3D scene: terrain + extruded buildings + trees + roads.
2
+
3
+ A :class:`Scene` is the SDK's equivalent of the plugin's exported scene. It
4
+ rasterises footprints onto the terrain grid (giving the height *surface* used
5
+ by :mod:`zero2geospatial3d.shading` and the per-cell building ids used for
6
+ roof statistics), and writes the same package the browser viewer reads
7
+ (``scene.json``, ``terrain.bin``, ``L*.geojson``), so a scene built in Python
8
+ opens in :func:`zero2geospatial3d.server.serve_scene`.
9
+
10
+ Coordinates are local metres: x east, y north, origin at the scene centre.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import json
17
+ import math
18
+ import os
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Optional, Sequence
21
+
22
+ import numpy as np
23
+
24
+ from . import geo
25
+ from .grid import Grid
26
+
27
+ __all__ = [
28
+ "SCENE_FORMAT",
29
+ "SCENE_VERSION",
30
+ "Building",
31
+ "Tree",
32
+ "Road",
33
+ "Scene",
34
+ "rasterize_polygons",
35
+ "load_scene",
36
+ ]
37
+
38
+ SCENE_FORMAT = "zero2geospatial3d-scene"
39
+ SCENE_VERSION = 1
40
+
41
+ Ring = Sequence[Sequence[float]]
42
+
43
+
44
+ @dataclass
45
+ class Building:
46
+ """An extruded footprint. ``polygons`` is a list of polygons, each a list of
47
+ rings (outer first, holes after) of local ``(x, y)``."""
48
+
49
+ polygons: list[list[Ring]]
50
+ height: float = 9.0
51
+ base: float = 0.0
52
+ id: int = 0
53
+ color: str = "#d9d4cc"
54
+ properties: dict[str, Any] = field(default_factory=dict)
55
+ group: Optional[str] = None
56
+
57
+
58
+ @dataclass
59
+ class Tree:
60
+ """A tree as a cylinder crown (``crown_radius``) from ``crown_base`` to ``height``."""
61
+
62
+ x: float
63
+ y: float
64
+ height: float = 8.0
65
+ crown_radius: float = 3.0
66
+ crown_base: float = 2.5
67
+ properties: dict[str, Any] = field(default_factory=dict)
68
+
69
+
70
+ @dataclass
71
+ class Road:
72
+ """A polyline traffic source with a relative ``traffic_weight``."""
73
+
74
+ coords: list[Sequence[float]]
75
+ traffic_weight: float = 1.0
76
+ properties: dict[str, Any] = field(default_factory=dict)
77
+
78
+
79
+ def _ring_mask(xs: np.ndarray, ys: np.ndarray, ring: Ring) -> np.ndarray:
80
+ """Even-odd point-in-ring for arrays of points."""
81
+ inside = np.zeros(xs.shape, dtype=bool)
82
+ pts = np.asarray(ring, dtype=float)[:, :2]
83
+ n = len(pts)
84
+ j = n - 1
85
+ for i in range(n):
86
+ xi, yi = pts[i]
87
+ xj, yj = pts[j]
88
+ if yi != yj:
89
+ crosses = (yi > ys) != (yj > ys)
90
+ xint = (xj - xi) * (ys - yi) / (yj - yi) + xi
91
+ inside ^= crosses & (xs < xint)
92
+ j = i
93
+ return inside
94
+
95
+
96
+ def rasterize_polygons(
97
+ grid: Grid, polygons: Sequence[list[Ring]], values: Optional[np.ndarray] = None
98
+ ) -> np.ndarray:
99
+ """Boolean mask of cells whose centre falls inside any of ``polygons``.
100
+
101
+ Polygons smaller than a cell still mark the cell under their first vertex.
102
+ """
103
+ mask = np.zeros(grid.shape, dtype=bool)
104
+ for poly in polygons:
105
+ if not poly or len(poly[0]) < 3:
106
+ continue
107
+ outer = np.asarray(poly[0], dtype=float)
108
+ c0 = max(0, int(math.floor((outer[:, 0].min() - grid.min_x) / grid.cell)))
109
+ c1 = min(grid.cols - 1, int(math.ceil((outer[:, 0].max() - grid.min_x) / grid.cell)))
110
+ r0 = max(0, int(math.floor((grid.max_y - outer[:, 1].max()) / grid.cell)))
111
+ r1 = min(grid.rows - 1, int(math.ceil((grid.max_y - outer[:, 1].min()) / grid.cell)))
112
+ if c0 > c1 or r0 > r1:
113
+ continue
114
+ xs = grid.min_x + np.arange(c0, c1 + 1) * grid.cell
115
+ ys = grid.max_y - np.arange(r0, r1 + 1) * grid.cell
116
+ xx, yy = np.meshgrid(xs, ys)
117
+ inside = _ring_mask(xx, yy, poly[0])
118
+ for hole in poly[1:]:
119
+ inside &= ~_ring_mask(xx, yy, hole)
120
+ if not inside.any():
121
+ c, r = grid.index_of(float(outer[0, 0]), float(outer[0, 1]))
122
+ if c0 <= c <= c1 and r0 <= r <= r1:
123
+ inside[r - r0, c - c0] = True
124
+ mask[r0 : r1 + 1, c0 : c1 + 1] |= inside
125
+ return mask
126
+
127
+
128
+ class Scene:
129
+ """Terrain grid plus objects, with the derived analysis rasters.
130
+
131
+ Args:
132
+ terrain: DEM on the analysis grid (local metres). Use
133
+ :meth:`Scene.flat` for a level site.
134
+ buildings, trees, roads: scene objects in local metres.
135
+ lat, lon: WGS84 position of the local origin (for the sun).
136
+ utc_offset: clock time zone; defaults to the nautical zone of ``lon``.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ terrain: Grid,
142
+ buildings: Optional[list[Building]] = None,
143
+ trees: Optional[list[Tree]] = None,
144
+ roads: Optional[list[Road]] = None,
145
+ *,
146
+ lat: float = 0.0,
147
+ lon: float = 0.0,
148
+ utc_offset: Optional[float] = None,
149
+ title: str = "Scene",
150
+ crs: Optional[dict[str, str]] = None,
151
+ origin: tuple[float, float] = (0.0, 0.0),
152
+ ) -> None:
153
+ self.terrain = terrain
154
+ self.buildings = list(buildings or [])
155
+ self.trees = list(trees or [])
156
+ self.roads = list(roads or [])
157
+ self.lat = lat
158
+ self.lon = lon
159
+ self.utc_offset = geo.utc_offset_guess(lon) if utc_offset is None else float(utc_offset)
160
+ self.title = title
161
+ self.crs = crs or {"authid": "", "wkt": ""}
162
+ self.origin = origin
163
+ self._number_buildings()
164
+
165
+ def _number_buildings(self) -> None:
166
+ """Give buildings without an id the next free positive id."""
167
+ used = {b.id for b in self.buildings if b.id}
168
+ nxt = max(used, default=0)
169
+ for b in self.buildings:
170
+ if not b.id:
171
+ nxt += 1
172
+ b.id = nxt
173
+
174
+ # ------------------------------------------------------------ builders
175
+ @classmethod
176
+ def flat(
177
+ cls, width: float, height: float, cell: float = 2.0, elevation: float = 0.0, **kwargs: Any
178
+ ) -> Scene:
179
+ """A level site of ``width`` x ``height`` metres centred on the origin."""
180
+ cols = max(2, int(math.ceil(width / cell)))
181
+ rows = max(2, int(math.ceil(height / cell)))
182
+ grid = Grid(
183
+ np.full((rows, cols), float(elevation)),
184
+ cell,
185
+ -cols * cell / 2 + cell / 2,
186
+ rows * cell / 2 - cell / 2,
187
+ )
188
+ return cls(grid, **kwargs)
189
+
190
+ @classmethod
191
+ def from_osm(
192
+ cls,
193
+ features: dict[str, list[dict[str, Any]]],
194
+ *,
195
+ lat: float,
196
+ lon: float,
197
+ half_size: float = 250.0,
198
+ cell: float = 2.0,
199
+ terrain: Optional[Grid] = None,
200
+ **kwargs: Any,
201
+ ) -> Scene:
202
+ """Scene from :func:`zero2geospatial3d.osm.payload_to_features` output.
203
+
204
+ Coordinates are projected around ``(lon, lat)`` with
205
+ :func:`zero2geospatial3d.geo.lonlat_to_local`.
206
+ """
207
+
208
+ def local(pt: Sequence[float]) -> list[float]:
209
+ x, y = geo.lonlat_to_local(pt[0], pt[1], lon, lat)
210
+ return [round(x, 2), round(y, 2)]
211
+
212
+ scene = terrain
213
+ if scene is None:
214
+ base = cls.flat(2 * half_size, 2 * half_size, cell)
215
+ scene = base.terrain
216
+ buildings = []
217
+ for k, f in enumerate(features.get("buildings", []), start=1):
218
+ g = f["geometry"]
219
+ polys = [g["coordinates"]] if g["type"] == "Polygon" else g["coordinates"]
220
+ p = f.get("properties", {})
221
+ buildings.append(
222
+ Building(
223
+ [[[local(pt) for pt in ring] for ring in poly] for poly in polys],
224
+ height=float(p.get("height_m", 9.0)),
225
+ base=float(p.get("min_height_m", 0.0)),
226
+ id=k,
227
+ properties=dict(p),
228
+ group=p.get("bldg_group"),
229
+ )
230
+ )
231
+ trees = []
232
+ for f in features.get("trees", []):
233
+ x, y = local(f["geometry"]["coordinates"])
234
+ h = float(f.get("properties", {}).get("height_m", 8.0))
235
+ trees.append(
236
+ Tree(
237
+ x,
238
+ y,
239
+ h,
240
+ crown_radius=max(1.5, 0.35 * h),
241
+ crown_base=0.3 * h,
242
+ properties=dict(f.get("properties", {})),
243
+ )
244
+ )
245
+ roads = []
246
+ for f in features.get("roads", []):
247
+ p = f.get("properties", {})
248
+ roads.append(
249
+ Road(
250
+ [local(pt) for pt in f["geometry"]["coordinates"]],
251
+ float(p.get("traffic_weight", 1.0)),
252
+ dict(p),
253
+ )
254
+ )
255
+ kwargs.setdefault("title", "OSM scene")
256
+ return cls(scene, buildings, trees, roads, lat=lat, lon=lon, **kwargs)
257
+
258
+ # ------------------------------------------------------------ rasters
259
+ def building_rasters(self) -> tuple[np.ndarray, np.ndarray]:
260
+ """``(height_above_ground, ids)`` per cell; id 0 is open ground.
261
+
262
+ Taller buildings win where footprints overlap. Raised parts
263
+ (``base > 0``) are treated as solid down to the ground (2.5-D).
264
+ """
265
+ self._number_buildings()
266
+ heights = np.zeros(self.terrain.shape)
267
+ ids = np.zeros(self.terrain.shape, dtype=np.int32)
268
+ for b in sorted(self.buildings, key=lambda b: b.height):
269
+ m = rasterize_polygons(self.terrain, b.polygons)
270
+ heights[m] = np.maximum(heights[m], b.height)
271
+ ids[m] = b.id
272
+ return heights, ids
273
+
274
+ def tree_raster(self) -> tuple[np.ndarray, np.ndarray]:
275
+ """``(crown_top_above_ground, canopy_mask)`` from the tree list."""
276
+ top = np.zeros(self.terrain.shape)
277
+ xx, yy = self.terrain.coords()
278
+ for t in self.trees:
279
+ m = (xx - t.x) ** 2 + (yy - t.y) ** 2 <= t.crown_radius**2
280
+ if not m.any():
281
+ c, r = self.terrain.index_of(t.x, t.y)
282
+ m[r, c] = True
283
+ top[m] = np.maximum(top[m], t.height)
284
+ return top, top > 0
285
+
286
+ def surface(self, include_trees: bool = False) -> Grid:
287
+ """Terrain plus building heights (and tree crowns if asked) as a :class:`Grid`."""
288
+ h, _ = self.building_rasters()
289
+ if include_trees and self.trees:
290
+ t, _ = self.tree_raster()
291
+ h = np.maximum(h, t)
292
+ return self.terrain.copy(np.nan_to_num(self.terrain.values) + h)
293
+
294
+ # ------------------------------------------------------------ export
295
+ def to_manifest(self) -> dict[str, Any]:
296
+ g = self.terrain
297
+ width = g.cols * g.cell
298
+ height = g.rows * g.cell
299
+ valid = np.isfinite(g.values)
300
+ terrain: dict[str, Any] = {
301
+ "file": "terrain.bin",
302
+ "source": "zero2geospatial3d-sdk",
303
+ "min": float(np.nanmin(g.values)) if valid.any() else 0.0,
304
+ "max": float(np.nanmax(g.values)) if valid.any() else 0.0,
305
+ }
306
+ if not valid.all():
307
+ terrain["mask"] = "terrain_mask.bin"
308
+ terrain["nodataCells"] = int((~valid).sum())
309
+ return {
310
+ "format": SCENE_FORMAT,
311
+ "version": SCENE_VERSION,
312
+ "title": self.title,
313
+ "created": _dt.datetime.now().isoformat(timespec="seconds"),
314
+ "crs": self.crs,
315
+ "origin": list(self.origin),
316
+ "extent": {
317
+ "minX": -width / 2,
318
+ "minY": -height / 2,
319
+ "maxX": width / 2,
320
+ "maxY": height / 2,
321
+ },
322
+ "geo": {"lat": self.lat, "lon": self.lon, "utcOffset": self.utc_offset},
323
+ "grid": {
324
+ "cols": g.cols,
325
+ "rows": g.rows,
326
+ "cell": g.cell,
327
+ "minX": g.min_x,
328
+ "maxY": g.max_y,
329
+ },
330
+ "terrain": terrain,
331
+ "layers": [],
332
+ "rasters": [],
333
+ "weather": None,
334
+ "warnings": [],
335
+ }
336
+
337
+ def export(self, out_dir: str, epw_path: Optional[str] = None) -> dict[str, Any]:
338
+ """Write the viewer package to ``out_dir`` and return the manifest."""
339
+ os.makedirs(out_dir, exist_ok=True)
340
+ manifest = self.to_manifest()
341
+ values = self.terrain.values
342
+ valid = np.isfinite(values)
343
+ filled = np.where(valid, values, np.nanmin(values) if valid.any() else 0.0)
344
+ filled.astype("<f4").tofile(os.path.join(out_dir, "terrain.bin"))
345
+ if not valid.all():
346
+ valid.astype(np.uint8).tofile(os.path.join(out_dir, "terrain_mask.bin"))
347
+
348
+ def write_layer(
349
+ index: int,
350
+ name: str,
351
+ kind: str,
352
+ role: str,
353
+ feats: list[dict[str, Any]],
354
+ default_h: float,
355
+ color: str,
356
+ ) -> None:
357
+ fname = f"L{index}.geojson"
358
+ with open(os.path.join(out_dir, fname), "w", encoding="utf-8") as handle:
359
+ json.dump(
360
+ {"type": "FeatureCollection", "features": feats}, handle, separators=(",", ":")
361
+ )
362
+ with open(os.path.join(out_dir, f"L{index}.ids.json"), "w", encoding="utf-8") as handle:
363
+ json.dump(list(range(1, len(feats) + 1)), handle)
364
+ field_meta = []
365
+ seen = set()
366
+ for f in feats:
367
+ for k, v in f["properties"].items():
368
+ if k.startswith("_") or k in seen:
369
+ continue
370
+ seen.add(k)
371
+ num = isinstance(v, (int, float)) and not isinstance(v, bool)
372
+ field_meta.append({"name": k, "type": "number" if num else "text"})
373
+ manifest["layers"].append(
374
+ {
375
+ "id": f"L{index}",
376
+ "name": name,
377
+ "file": fname,
378
+ "kind": kind,
379
+ "role": role,
380
+ "scenario": "both",
381
+ "count": len(feats),
382
+ "fields": field_meta,
383
+ "heightExpr": "",
384
+ "baseExpr": "",
385
+ "defaultHeight": default_h,
386
+ "colorMode": "single",
387
+ "defaultColor": color,
388
+ "sourceLayerId": f"sdk_{role}",
389
+ "sourceCrs": self.crs.get("authid", ""),
390
+ }
391
+ )
392
+
393
+ index = 0
394
+ if self.buildings:
395
+ feats = []
396
+ for fid, b in enumerate(self.buildings, start=1):
397
+ props: dict[str, Any] = {
398
+ "_id": fid,
399
+ "_h": round(max(0.0, b.height), 3),
400
+ "_c": b.color,
401
+ }
402
+ if b.base > 0:
403
+ props["_b"] = round(b.base, 3)
404
+ if b.group:
405
+ props["_grp"] = str(b.group)
406
+ props.update(_plain_props(b.properties))
407
+ coords = [
408
+ [
409
+ [[round(float(p[0]), 2), round(float(p[1]), 2)] for p in ring]
410
+ for ring in poly
411
+ ]
412
+ for poly in b.polygons
413
+ ]
414
+ feats.append(
415
+ {
416
+ "type": "Feature",
417
+ "properties": props,
418
+ "geometry": {"type": "MultiPolygon", "coordinates": coords},
419
+ }
420
+ )
421
+ index += 1
422
+ write_layer(index, "Buildings", "polygon", "buildings", feats, 9.0, "#d9d4cc")
423
+ if self.trees:
424
+ feats = []
425
+ for fid, t in enumerate(self.trees, start=1):
426
+ props = {"_id": fid, "_h": round(t.height, 3), "_c": "#4f8a3c"}
427
+ props.update(_plain_props(t.properties))
428
+ feats.append(
429
+ {
430
+ "type": "Feature",
431
+ "properties": props,
432
+ "geometry": {"type": "MultiPoint", "coordinates": [[t.x, t.y]]},
433
+ }
434
+ )
435
+ index += 1
436
+ write_layer(index, "Trees", "point", "trees", feats, 8.0, "#4f8a3c")
437
+ if self.roads:
438
+ feats = []
439
+ for fid, road in enumerate(self.roads, start=1):
440
+ props = {
441
+ "_id": fid,
442
+ "_h": 1.0,
443
+ "_c": "#ff4f2e",
444
+ "traffic_weight": road.traffic_weight,
445
+ }
446
+ props.update(_plain_props(road.properties))
447
+ line = [[[float(p[0]), float(p[1])] for p in road.coords]]
448
+ feats.append(
449
+ {
450
+ "type": "Feature",
451
+ "properties": props,
452
+ "geometry": {"type": "MultiLineString", "coordinates": line},
453
+ }
454
+ )
455
+ index += 1
456
+ write_layer(index, "Roads", "line", "roads", feats, 1.0, "#ff4f2e")
457
+ if epw_path:
458
+ import shutil
459
+
460
+ shutil.copyfile(epw_path, os.path.join(out_dir, "weather.epw"))
461
+ manifest["weather"] = {"file": "weather.epw", "source": os.path.basename(epw_path)}
462
+ with open(os.path.join(out_dir, "scene.json"), "w", encoding="utf-8") as handle:
463
+ json.dump(manifest, handle, ensure_ascii=False, indent=1)
464
+ return manifest
465
+
466
+
467
+ def _plain_props(props: dict[str, Any]) -> dict[str, Any]:
468
+ out: dict[str, Any] = {}
469
+ for k, v in props.items():
470
+ if isinstance(v, (bool, int)) or v is None:
471
+ out[k] = v
472
+ elif isinstance(v, float):
473
+ out[k] = v if math.isfinite(v) else None
474
+ else:
475
+ out[k] = str(v)[:120]
476
+ return out
477
+
478
+
479
+ def load_scene(scene_dir: str) -> Scene:
480
+ """Read a scene package written by the plugin or by :meth:`Scene.export`."""
481
+ with open(os.path.join(scene_dir, "scene.json"), encoding="utf-8") as handle:
482
+ manifest = json.load(handle)
483
+ if manifest.get("format") != SCENE_FORMAT:
484
+ raise ValueError(f"{scene_dir} is not a {SCENE_FORMAT} package")
485
+ g = manifest["grid"]
486
+ cols, rows = int(g["cols"]), int(g["rows"])
487
+ values: np.ndarray = np.zeros((rows, cols))
488
+ terrain = manifest.get("terrain")
489
+ if terrain:
490
+ values = np.fromfile(os.path.join(scene_dir, terrain["file"]), dtype="<f4").astype(float)
491
+ values = values.reshape(rows, cols)
492
+ if terrain.get("mask"):
493
+ mask = np.fromfile(os.path.join(scene_dir, terrain["mask"]), dtype=np.uint8)
494
+ values[mask.reshape(rows, cols) == 0] = np.nan
495
+ grid = Grid(values, float(g["cell"]), float(g["minX"]), float(g["maxY"]))
496
+ buildings: list[Building] = []
497
+ trees: list[Tree] = []
498
+ roads: list[Road] = []
499
+ for layer in manifest.get("layers", []):
500
+ if layer.get("scenario", "both") == "B":
501
+ continue
502
+ with open(os.path.join(scene_dir, layer["file"]), encoding="utf-8") as handle:
503
+ fc = json.load(handle)
504
+ role = layer.get("role")
505
+ default_h = float(layer.get("defaultHeight") or 0.0)
506
+ for f in fc.get("features", []):
507
+ p = f.get("properties", {})
508
+ h = p.get("_h")
509
+ h = default_h if h is None else float(h)
510
+ geom = f.get("geometry") or {}
511
+ coords = geom.get("coordinates") or []
512
+ user = {k: v for k, v in p.items() if not k.startswith("_")}
513
+ if role == "buildings" and geom.get("type") in ("MultiPolygon", "Polygon"):
514
+ polys = coords if geom["type"] == "MultiPolygon" else [coords]
515
+ buildings.append(
516
+ Building(
517
+ polys,
518
+ h,
519
+ float(p.get("_b") or 0.0),
520
+ color=p.get("_c", "#d9d4cc"),
521
+ properties=user,
522
+ group=p.get("_grp"),
523
+ )
524
+ )
525
+ elif role == "trees":
526
+ pts = coords if geom.get("type") == "MultiPoint" else [coords]
527
+ for pt in pts:
528
+ trees.append(
529
+ Tree(
530
+ float(pt[0]),
531
+ float(pt[1]),
532
+ h,
533
+ max(1.5, 0.35 * h),
534
+ 0.3 * h,
535
+ properties=user,
536
+ )
537
+ )
538
+ elif role == "roads":
539
+ lines = coords if geom.get("type") == "MultiLineString" else [coords]
540
+ w = float(user.get("traffic_weight", h or 1.0) or 1.0)
541
+ for line in lines:
542
+ roads.append(Road(line, w, user))
543
+ geo_info = manifest.get("geo") or {}
544
+ return Scene(
545
+ grid,
546
+ buildings,
547
+ trees,
548
+ roads,
549
+ lat=float(geo_info.get("lat", 0.0)),
550
+ lon=float(geo_info.get("lon", 0.0)),
551
+ utc_offset=geo_info.get("utcOffset"),
552
+ title=manifest.get("title", "Scene"),
553
+ crs=manifest.get("crs"),
554
+ origin=tuple(manifest.get("origin", (0.0, 0.0))),
555
+ )