zero2geospatial3d-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|