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
zero2geospatial3d/osm.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""OpenStreetMap buildings, trees and roads from the Overpass API.
|
|
2
|
+
|
|
3
|
+
Headless port of the plugin's OSM download: the same Overpass query, the
|
|
4
|
+
same mirror fail-over with one retry on transient errors, a one-week on-disk
|
|
5
|
+
cache, and the same Simple 3D Buildings interpretation (``building:part``
|
|
6
|
+
replaces its outline; parts inherit name and type and share ``bldg_group``).
|
|
7
|
+
Everything is returned as GeoJSON-like dicts in WGS84 longitude/latitude; no
|
|
8
|
+
QGIS, GDAL or Shapely is needed.
|
|
9
|
+
|
|
10
|
+
Every network call has a timeout.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from typing import Any, Callable, Optional
|
|
26
|
+
|
|
27
|
+
from . import osm_tags
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"OsmError",
|
|
31
|
+
"ENDPOINTS",
|
|
32
|
+
"build_query",
|
|
33
|
+
"fetch",
|
|
34
|
+
"payload_to_features",
|
|
35
|
+
"ring_centroid",
|
|
36
|
+
"point_in_ring",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
ENDPOINTS = (
|
|
40
|
+
"https://overpass-api.de/api/interpreter",
|
|
41
|
+
"https://overpass.kumi.systems/api/interpreter",
|
|
42
|
+
"https://overpass.private.coffee/api/interpreter",
|
|
43
|
+
)
|
|
44
|
+
TIMEOUT_S = 90
|
|
45
|
+
MAX_RESPONSE_BYTES = 192 * 1024 * 1024
|
|
46
|
+
CACHE_TTL_S = 7 * 24 * 3600
|
|
47
|
+
MAX_AREA_KM2 = 36.0
|
|
48
|
+
|
|
49
|
+
Ring = list # list of (lon, lat)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OsmError(RuntimeError):
|
|
53
|
+
"""Raised when every Overpass mirror failed or the area is too large."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _user_agent() -> str:
|
|
57
|
+
from . import __version__
|
|
58
|
+
|
|
59
|
+
return f"zero2geospatial3d-sdk/{__version__} (Python; +https://github.com/YusufEminoglu/zero2geospatial3d_sdk)"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_query(s: float, w: float, n: float, e: float) -> str:
|
|
63
|
+
"""Overpass QL for buildings, building parts, trees and traffic roads in a bbox."""
|
|
64
|
+
bbox = f"{s:.7f},{w:.7f},{n:.7f},{e:.7f}"
|
|
65
|
+
return (
|
|
66
|
+
f"[out:json][timeout:{TIMEOUT_S}];\n(\n"
|
|
67
|
+
f' way["building"]({bbox});\n'
|
|
68
|
+
f' relation["building"]["type"="multipolygon"]({bbox});\n'
|
|
69
|
+
f' way["building:part"]({bbox});\n'
|
|
70
|
+
f' relation["building:part"]["type"="multipolygon"]({bbox});\n'
|
|
71
|
+
f' node["natural"="tree"]({bbox});\n'
|
|
72
|
+
f' way["highway"~"{osm_tags.HIGHWAY_PATTERN}"]({bbox});\n'
|
|
73
|
+
f");\nout body geom;"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cache_path(query: str, cache_dir: Optional[str]) -> str:
|
|
78
|
+
folder = cache_dir or os.path.join(tempfile.gettempdir(), "zero2geospatial3d_osm_cache")
|
|
79
|
+
with contextlib.suppress(OSError):
|
|
80
|
+
os.makedirs(folder, exist_ok=True)
|
|
81
|
+
digest = hashlib.sha256(query.encode("utf-8")).hexdigest()[:40]
|
|
82
|
+
return os.path.join(folder, f"{digest}.json")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_cache(query: str, cache_dir: Optional[str]) -> Optional[dict[str, Any]]:
|
|
86
|
+
path = _cache_path(query, cache_dir)
|
|
87
|
+
if not os.path.isfile(path) or time.time() - os.path.getmtime(path) > CACHE_TTL_S:
|
|
88
|
+
return None
|
|
89
|
+
with contextlib.suppress(OSError, ValueError):
|
|
90
|
+
with open(path, encoding="utf-8") as handle:
|
|
91
|
+
return json.load(handle)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_cache(query: str, payload: dict[str, Any], cache_dir: Optional[str]) -> None:
|
|
96
|
+
with contextlib.suppress(OSError, TypeError, ValueError):
|
|
97
|
+
with open(_cache_path(query, cache_dir), "w", encoding="utf-8") as handle:
|
|
98
|
+
json.dump(payload, handle)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _post(endpoint: str, query: str, timeout: float) -> tuple[Optional[dict[str, Any]], str, bool]:
|
|
102
|
+
"""(payload, error_text, transient)"""
|
|
103
|
+
body = urllib.parse.urlencode({"data": query}).encode("ascii")
|
|
104
|
+
request = urllib.request.Request(
|
|
105
|
+
endpoint,
|
|
106
|
+
data=body,
|
|
107
|
+
headers={
|
|
108
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
109
|
+
"Accept": "application/json",
|
|
110
|
+
"User-Agent": _user_agent(),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(request, timeout=timeout) as reply: # noqa: S310
|
|
115
|
+
content = reply.read(MAX_RESPONSE_BYTES + 1)
|
|
116
|
+
except urllib.error.HTTPError as exc:
|
|
117
|
+
return None, f"HTTP {exc.code}", exc.code == 429 or exc.code >= 500
|
|
118
|
+
except (OSError, ValueError) as exc:
|
|
119
|
+
return None, str(exc) or "network error", True
|
|
120
|
+
if len(content) > MAX_RESPONSE_BYTES:
|
|
121
|
+
return None, "response too large; pick a smaller area", False
|
|
122
|
+
try:
|
|
123
|
+
payload = json.loads(content.decode("utf-8"))
|
|
124
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
125
|
+
return None, f"not JSON ({exc})", False
|
|
126
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("elements"), list):
|
|
127
|
+
return None, "unexpected response", False
|
|
128
|
+
if payload.get("remark"):
|
|
129
|
+
return None, f"incomplete response: {payload['remark']}", True
|
|
130
|
+
return payload, "", False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _area_km2(s: float, w: float, n: float, e: float) -> float:
|
|
134
|
+
mid = math.radians((s + n) / 2)
|
|
135
|
+
return abs(n - s) * 111.32 * abs(e - w) * 111.32 * math.cos(mid)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def fetch(
|
|
139
|
+
s: float,
|
|
140
|
+
w: float,
|
|
141
|
+
n: float,
|
|
142
|
+
e: float,
|
|
143
|
+
*,
|
|
144
|
+
progress: Optional[Callable[[str], None]] = None,
|
|
145
|
+
timeout: float = TIMEOUT_S + 15,
|
|
146
|
+
use_cache: bool = True,
|
|
147
|
+
cache_dir: Optional[str] = None,
|
|
148
|
+
endpoints: tuple[str, ...] = ENDPOINTS,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Raw Overpass JSON for the bbox (south, west, north, east) in degrees."""
|
|
151
|
+
if not (s < n and w < e):
|
|
152
|
+
raise OsmError(
|
|
153
|
+
"The bbox must be (south, west, north, east) with south < north, west < east."
|
|
154
|
+
)
|
|
155
|
+
if _area_km2(s, w, n, e) > MAX_AREA_KM2:
|
|
156
|
+
raise OsmError(f"The area is larger than {MAX_AREA_KM2:.0f} km2; pick a smaller one.")
|
|
157
|
+
report = progress or (lambda msg: None)
|
|
158
|
+
query = build_query(s, w, n, e)
|
|
159
|
+
if use_cache:
|
|
160
|
+
cached = _read_cache(query, cache_dir)
|
|
161
|
+
if cached is not None:
|
|
162
|
+
report("Using the cached OSM response.")
|
|
163
|
+
return cached
|
|
164
|
+
failures = []
|
|
165
|
+
for endpoint in endpoints:
|
|
166
|
+
host = urllib.parse.urlparse(endpoint).hostname or endpoint
|
|
167
|
+
for attempt in range(2):
|
|
168
|
+
report(f"Querying {host} ..." if not attempt else f"Retrying {host} ...")
|
|
169
|
+
if attempt:
|
|
170
|
+
time.sleep(1.5)
|
|
171
|
+
payload, error, transient = _post(endpoint, query, timeout)
|
|
172
|
+
if payload is not None:
|
|
173
|
+
if use_cache:
|
|
174
|
+
_write_cache(query, payload, cache_dir)
|
|
175
|
+
return payload
|
|
176
|
+
failures.append(f"{host}: {error}")
|
|
177
|
+
if not transient:
|
|
178
|
+
break
|
|
179
|
+
raise OsmError(
|
|
180
|
+
"Every Overpass server failed (" + "; ".join(failures[-3:]) + "). "
|
|
181
|
+
"Wait a minute and try again, or pick a smaller area."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- geometry
|
|
186
|
+
def _points(geometry: Any) -> Ring:
|
|
187
|
+
out: Ring = []
|
|
188
|
+
for item in geometry or []:
|
|
189
|
+
try:
|
|
190
|
+
lon, lat = float(item["lon"]), float(item["lat"])
|
|
191
|
+
except (KeyError, TypeError, ValueError):
|
|
192
|
+
continue
|
|
193
|
+
if math.isfinite(lon) and math.isfinite(lat) and (not out or out[-1] != (lon, lat)):
|
|
194
|
+
out.append((lon, lat))
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _closed(points: Ring) -> Optional[Ring]:
|
|
199
|
+
if len(points) < 3:
|
|
200
|
+
return None
|
|
201
|
+
if points[0] != points[-1]:
|
|
202
|
+
points = points + [points[0]]
|
|
203
|
+
return points if len(points) >= 4 else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _stitch(paths: list[Ring]) -> list[Ring]:
|
|
207
|
+
def key(p: tuple[float, float]) -> tuple[float, float]:
|
|
208
|
+
return (round(p[0], 9), round(p[1], 9))
|
|
209
|
+
|
|
210
|
+
pending = [list(p) for p in paths if len(p) >= 2]
|
|
211
|
+
rings = []
|
|
212
|
+
while pending:
|
|
213
|
+
chain = pending.pop(0)
|
|
214
|
+
progress = True
|
|
215
|
+
while key(chain[0]) != key(chain[-1]) and progress:
|
|
216
|
+
progress = False
|
|
217
|
+
for index, path in enumerate(pending):
|
|
218
|
+
if key(chain[-1]) == key(path[0]):
|
|
219
|
+
chain = chain + path[1:]
|
|
220
|
+
elif key(chain[-1]) == key(path[-1]):
|
|
221
|
+
chain = chain + list(reversed(path[:-1]))
|
|
222
|
+
elif key(chain[0]) == key(path[-1]):
|
|
223
|
+
chain = path[:-1] + chain
|
|
224
|
+
elif key(chain[0]) == key(path[0]):
|
|
225
|
+
chain = list(reversed(path[1:])) + chain
|
|
226
|
+
else:
|
|
227
|
+
continue
|
|
228
|
+
pending.pop(index)
|
|
229
|
+
progress = True
|
|
230
|
+
break
|
|
231
|
+
if key(chain[0]) == key(chain[-1]) and len(chain) >= 4:
|
|
232
|
+
rings.append(chain)
|
|
233
|
+
return rings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def point_in_ring(x: float, y: float, ring: Ring) -> bool:
|
|
237
|
+
"""Even-odd point-in-polygon test."""
|
|
238
|
+
inside = False
|
|
239
|
+
n = len(ring)
|
|
240
|
+
j = n - 1
|
|
241
|
+
for i in range(n):
|
|
242
|
+
xi, yi = ring[i][0], ring[i][1]
|
|
243
|
+
xj, yj = ring[j][0], ring[j][1]
|
|
244
|
+
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-300) + xi:
|
|
245
|
+
inside = not inside
|
|
246
|
+
j = i
|
|
247
|
+
return inside
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def ring_centroid(ring: Ring) -> tuple[float, float]:
|
|
251
|
+
"""Area centroid of a ring; a vertex mean for degenerate rings."""
|
|
252
|
+
a = cx = cy = 0.0
|
|
253
|
+
n = len(ring)
|
|
254
|
+
for i in range(n):
|
|
255
|
+
x1, y1 = ring[i][0], ring[i][1]
|
|
256
|
+
x2, y2 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
|
|
257
|
+
cross = x1 * y2 - x2 * y1
|
|
258
|
+
a += cross
|
|
259
|
+
cx += (x1 + x2) * cross
|
|
260
|
+
cy += (y1 + y2) * cross
|
|
261
|
+
if abs(a) < 1e-18:
|
|
262
|
+
return (sum(p[0] for p in ring) / n, sum(p[1] for p in ring) / n)
|
|
263
|
+
return (cx / (3 * a), cy / (3 * a))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _interior_point(polygons: list[list[Ring]]) -> tuple[float, float]:
|
|
267
|
+
outer = max((p[0] for p in polygons), key=lambda r: abs(_signed_area(r)))
|
|
268
|
+
c = ring_centroid(outer)
|
|
269
|
+
if point_in_ring(c[0], c[1], outer):
|
|
270
|
+
return c
|
|
271
|
+
# Concave outline: probe midpoints between the centroid and each vertex.
|
|
272
|
+
for v in outer:
|
|
273
|
+
m = ((c[0] + v[0]) / 2, (c[1] + v[1]) / 2)
|
|
274
|
+
if point_in_ring(m[0], m[1], outer):
|
|
275
|
+
return m
|
|
276
|
+
return outer[0][0], outer[0][1]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _signed_area(ring: Ring) -> float:
|
|
280
|
+
return (
|
|
281
|
+
sum(
|
|
282
|
+
ring[i][0] * ring[(i + 1) % len(ring)][1] - ring[(i + 1) % len(ring)][0] * ring[i][1]
|
|
283
|
+
for i in range(len(ring))
|
|
284
|
+
)
|
|
285
|
+
/ 2
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _relation_polygons(element: dict[str, Any]) -> Optional[list[list[Ring]]]:
|
|
290
|
+
outers: list[Ring] = []
|
|
291
|
+
inners: list[Ring] = []
|
|
292
|
+
for member in element.get("members") or []:
|
|
293
|
+
if member.get("type") != "way":
|
|
294
|
+
continue
|
|
295
|
+
role = (member.get("role") or "").lower()
|
|
296
|
+
(inners if role == "inner" else outers).append(_points(member.get("geometry")))
|
|
297
|
+
polys: list[list[Ring]] = [[r] for r in _stitch(outers)]
|
|
298
|
+
if not polys:
|
|
299
|
+
return None
|
|
300
|
+
for hole in _stitch(inners):
|
|
301
|
+
hx, hy = ring_centroid(hole)
|
|
302
|
+
for poly in polys:
|
|
303
|
+
if point_in_ring(hx, hy, poly[0]):
|
|
304
|
+
poly.append(hole)
|
|
305
|
+
break
|
|
306
|
+
return polys
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _geometry(polys: list[list[Ring]]) -> dict[str, Any]:
|
|
310
|
+
coords = [[[list(p) for p in ring] for ring in poly] for poly in polys]
|
|
311
|
+
if len(coords) == 1:
|
|
312
|
+
return {"type": "Polygon", "coordinates": coords[0]}
|
|
313
|
+
return {"type": "MultiPolygon", "coordinates": coords}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _in_bbox(x: float, y: float, bbox: Optional[tuple[float, float, float, float]]) -> bool:
|
|
317
|
+
if bbox is None:
|
|
318
|
+
return True
|
|
319
|
+
s, w, n, e = bbox
|
|
320
|
+
return s <= y <= n and w <= x <= e
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def payload_to_features(
|
|
324
|
+
payload: dict[str, Any], bbox: Optional[tuple[float, float, float, float]] = None
|
|
325
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
326
|
+
"""Overpass JSON -> ``{"buildings": [...], "trees": [...], "roads": [...]}``.
|
|
327
|
+
|
|
328
|
+
Each item is a GeoJSON Feature (WGS84). Building properties: ``osm_id``,
|
|
329
|
+
``building``, ``name``, ``height_m`` (top above ground), ``min_height_m``,
|
|
330
|
+
``levels``, ``height_src`` (``height``/``levels``/``default``), ``is_part``
|
|
331
|
+
and ``bldg_group``. Trees carry ``height_m``; roads carry ``highway``,
|
|
332
|
+
``name`` and ``traffic_weight``. With ``bbox`` (south, west, north, east)
|
|
333
|
+
buildings and trees are kept when their interior point lies inside it, and
|
|
334
|
+
roads when any vertex does.
|
|
335
|
+
"""
|
|
336
|
+
outlines: list[dict[str, Any]] = []
|
|
337
|
+
parts: list[dict[str, Any]] = []
|
|
338
|
+
trees: list[dict[str, Any]] = []
|
|
339
|
+
roads: list[dict[str, Any]] = []
|
|
340
|
+
for element in payload.get("elements", []):
|
|
341
|
+
tags = element.get("tags") or {}
|
|
342
|
+
etype = element.get("type")
|
|
343
|
+
if etype == "node" and tags.get("natural") == "tree":
|
|
344
|
+
try:
|
|
345
|
+
lon, lat = float(element["lon"]), float(element["lat"])
|
|
346
|
+
except (KeyError, TypeError, ValueError):
|
|
347
|
+
continue
|
|
348
|
+
if _in_bbox(lon, lat, bbox):
|
|
349
|
+
trees.append(
|
|
350
|
+
{
|
|
351
|
+
"type": "Feature",
|
|
352
|
+
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
|
353
|
+
"properties": {
|
|
354
|
+
"osm_id": f"n{element.get('id')}",
|
|
355
|
+
"height_m": osm_tags.tree_height(tags),
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
360
|
+
if etype == "way":
|
|
361
|
+
weight = osm_tags.road_traffic(tags)
|
|
362
|
+
if weight is not None:
|
|
363
|
+
pts = _points(element.get("geometry"))
|
|
364
|
+
if len(pts) >= 2 and any(_in_bbox(x, y, bbox) for x, y in pts):
|
|
365
|
+
roads.append(
|
|
366
|
+
{
|
|
367
|
+
"type": "Feature",
|
|
368
|
+
"geometry": {
|
|
369
|
+
"type": "LineString",
|
|
370
|
+
"coordinates": [list(p) for p in pts],
|
|
371
|
+
},
|
|
372
|
+
"properties": {
|
|
373
|
+
"osm_id": f"w{element.get('id')}",
|
|
374
|
+
"highway": str(tags.get("highway"))[:40],
|
|
375
|
+
"name": str(tags.get("name") or "")[:120],
|
|
376
|
+
"traffic_weight": weight,
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
continue
|
|
381
|
+
is_part = tags.get("building:part") not in (None, "", "no")
|
|
382
|
+
is_building = tags.get("building") not in (None, "", "no", "construction")
|
|
383
|
+
if not (is_part or is_building):
|
|
384
|
+
continue
|
|
385
|
+
if etype == "relation":
|
|
386
|
+
polys = _relation_polygons(element)
|
|
387
|
+
else:
|
|
388
|
+
ring = _closed(_points(element.get("geometry")))
|
|
389
|
+
polys = [[ring]] if ring else None
|
|
390
|
+
if not polys:
|
|
391
|
+
continue
|
|
392
|
+
record = {
|
|
393
|
+
"id": f"{str(etype)[0]}{element.get('id')}",
|
|
394
|
+
"polys": polys,
|
|
395
|
+
"tags": tags,
|
|
396
|
+
"centre": _interior_point(polys),
|
|
397
|
+
}
|
|
398
|
+
(parts if is_part else outlines).append(record)
|
|
399
|
+
|
|
400
|
+
replaced = set()
|
|
401
|
+
for part in parts:
|
|
402
|
+
part["outline"] = None
|
|
403
|
+
cx, cy = part["centre"]
|
|
404
|
+
for k, o in enumerate(outlines):
|
|
405
|
+
if any(point_in_ring(cx, cy, poly[0]) for poly in o["polys"]):
|
|
406
|
+
part["outline"] = o
|
|
407
|
+
replaced.add(k)
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
buildings: list[dict[str, Any]] = []
|
|
411
|
+
|
|
412
|
+
def add(record: dict[str, Any], tags: dict[str, Any], group: str, part: bool) -> None:
|
|
413
|
+
if not _in_bbox(record["centre"][0], record["centre"][1], bbox):
|
|
414
|
+
return
|
|
415
|
+
top, levels, source = osm_tags.building_height(tags)
|
|
416
|
+
bottom = osm_tags.min_height(tags)
|
|
417
|
+
if bottom >= top:
|
|
418
|
+
bottom = 0.0
|
|
419
|
+
buildings.append(
|
|
420
|
+
{
|
|
421
|
+
"type": "Feature",
|
|
422
|
+
"geometry": _geometry(record["polys"]),
|
|
423
|
+
"properties": {
|
|
424
|
+
"osm_id": record["id"],
|
|
425
|
+
"building": str(tags.get("building") or tags.get("building:part") or "yes")[
|
|
426
|
+
:40
|
|
427
|
+
],
|
|
428
|
+
"name": str(tags.get("name") or "")[:120],
|
|
429
|
+
"height_m": top,
|
|
430
|
+
"min_height_m": bottom,
|
|
431
|
+
"levels": levels,
|
|
432
|
+
"height_src": source,
|
|
433
|
+
"is_part": 1 if part else 0,
|
|
434
|
+
"bldg_group": group,
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for k, o in enumerate(outlines):
|
|
440
|
+
if k not in replaced:
|
|
441
|
+
add(o, o["tags"], o["id"], False)
|
|
442
|
+
for part in parts:
|
|
443
|
+
outline = part["outline"]
|
|
444
|
+
tags = dict(part["tags"])
|
|
445
|
+
if outline is not None:
|
|
446
|
+
for key in ("name", "building"):
|
|
447
|
+
if not tags.get(key) and outline["tags"].get(key):
|
|
448
|
+
tags[key] = outline["tags"][key]
|
|
449
|
+
add(part, tags, outline["id"] if outline is not None else part["id"], True)
|
|
450
|
+
return {"buildings": buildings, "trees": trees, "roads": roads}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""OSM tag interpretation (pure Python, no QGIS)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
FLOOR_HEIGHT_M = 3.0
|
|
8
|
+
|
|
9
|
+
# Floor counts by OSM ``building`` type, used only when OSM has neither
|
|
10
|
+
# building:levels nor height. Unknown types fall back to 3.
|
|
11
|
+
DEFAULT_FLOORS = {
|
|
12
|
+
"apartments": 4,
|
|
13
|
+
"residential": 4,
|
|
14
|
+
"dormitory": 5,
|
|
15
|
+
"house": 2,
|
|
16
|
+
"detached": 2,
|
|
17
|
+
"terrace": 2,
|
|
18
|
+
"semidetached_house": 2,
|
|
19
|
+
"bungalow": 1,
|
|
20
|
+
"commercial": 3,
|
|
21
|
+
"office": 5,
|
|
22
|
+
"retail": 2,
|
|
23
|
+
"supermarket": 1,
|
|
24
|
+
"kiosk": 1,
|
|
25
|
+
"industrial": 1,
|
|
26
|
+
"warehouse": 1,
|
|
27
|
+
"manufacture": 1,
|
|
28
|
+
"hangar": 1,
|
|
29
|
+
"school": 3,
|
|
30
|
+
"university": 4,
|
|
31
|
+
"college": 4,
|
|
32
|
+
"kindergarten": 2,
|
|
33
|
+
"hospital": 5,
|
|
34
|
+
"clinic": 3,
|
|
35
|
+
"church": 1,
|
|
36
|
+
"mosque": 1,
|
|
37
|
+
"temple": 1,
|
|
38
|
+
"synagogue": 1,
|
|
39
|
+
"cathedral": 2,
|
|
40
|
+
"chapel": 1,
|
|
41
|
+
"public": 3,
|
|
42
|
+
"civic": 3,
|
|
43
|
+
"government": 4,
|
|
44
|
+
"townhall": 3,
|
|
45
|
+
"garage": 1,
|
|
46
|
+
"garages": 1,
|
|
47
|
+
"shed": 1,
|
|
48
|
+
"hut": 1,
|
|
49
|
+
"roof": 1,
|
|
50
|
+
"carport": 1,
|
|
51
|
+
}
|
|
52
|
+
# Single-storey types read taller than 3 m per floor.
|
|
53
|
+
TALL_SINGLE_STOREY = {
|
|
54
|
+
"industrial": 7.0,
|
|
55
|
+
"warehouse": 7.0,
|
|
56
|
+
"hangar": 9.0,
|
|
57
|
+
"manufacture": 6.0,
|
|
58
|
+
"supermarket": 5.0,
|
|
59
|
+
"church": 10.0,
|
|
60
|
+
"mosque": 10.0,
|
|
61
|
+
"cathedral": 18.0,
|
|
62
|
+
"temple": 8.0,
|
|
63
|
+
"synagogue": 8.0,
|
|
64
|
+
"chapel": 6.0,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
_NUMBER = r"([-+]?(?:\d+(?:[.,]\d*)?|[.,]\d+))"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_number(value):
|
|
71
|
+
"""Parse an OSM numeric tag ('12', '12.5 m', "40'", '3;4') -> metres/float or None."""
|
|
72
|
+
if value is None:
|
|
73
|
+
return None
|
|
74
|
+
text = str(value).split(";", 1)[0].strip().lstrip("~").strip()
|
|
75
|
+
feet = re.fullmatch(
|
|
76
|
+
_NUMBER + r"\s*(?:ft|feet|foot|['′])(?:\s*" + _NUMBER + r"\s*(?:in|inches?|[\"″]))?",
|
|
77
|
+
text,
|
|
78
|
+
re.IGNORECASE,
|
|
79
|
+
)
|
|
80
|
+
if feet:
|
|
81
|
+
return (
|
|
82
|
+
float(feet.group(1).replace(",", ".")) * 0.3048
|
|
83
|
+
+ float((feet.group(2) or "0").replace(",", ".")) * 0.0254
|
|
84
|
+
)
|
|
85
|
+
match = re.fullmatch(
|
|
86
|
+
_NUMBER + r"\s*(m|met(?:er|re)s?|cm|km|ft|feet|foot|in|inches?)?", text, re.IGNORECASE
|
|
87
|
+
)
|
|
88
|
+
if not match:
|
|
89
|
+
return None
|
|
90
|
+
number = float(match.group(1).replace(",", "."))
|
|
91
|
+
unit = (match.group(2) or "").lower()
|
|
92
|
+
if unit == "cm":
|
|
93
|
+
return number / 100.0
|
|
94
|
+
if unit == "km":
|
|
95
|
+
return number * 1000.0
|
|
96
|
+
if unit in ("ft", "feet", "foot"):
|
|
97
|
+
return number * 0.3048
|
|
98
|
+
if unit.startswith("in"):
|
|
99
|
+
return number * 0.0254
|
|
100
|
+
return number
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def building_height(tags: dict) -> tuple[float, int, str]:
|
|
104
|
+
"""(top_m, levels, source) for an OSM building or building:part.
|
|
105
|
+
|
|
106
|
+
top_m is the height of the top above the ground, as the OSM ``height``
|
|
107
|
+
key defines it (Simple 3D Buildings); the bottom of a raised part is
|
|
108
|
+
min_height(). source is 'height', 'levels' or 'default' so a user can tell
|
|
109
|
+
measured heights from guesses.
|
|
110
|
+
"""
|
|
111
|
+
kind = (tags.get("building") or tags.get("building:part") or "").strip().lower()
|
|
112
|
+
levels = None
|
|
113
|
+
for key in ("building:levels", "levels"):
|
|
114
|
+
n = parse_number(tags.get(key))
|
|
115
|
+
if n is not None and n > 0:
|
|
116
|
+
levels = max(1, int(round(n)))
|
|
117
|
+
break
|
|
118
|
+
roof = parse_number(tags.get("roof:levels"))
|
|
119
|
+
roof_levels = max(0, int(round(roof))) if roof else 0
|
|
120
|
+
height = parse_number(tags.get("height"))
|
|
121
|
+
if height is not None and 0 < height < 1000:
|
|
122
|
+
total_levels = levels or max(1, int(round(height / FLOOR_HEIGHT_M)))
|
|
123
|
+
return round(max(1.0, height), 2), total_levels, "height"
|
|
124
|
+
if levels is not None:
|
|
125
|
+
total = levels + roof_levels
|
|
126
|
+
return round(total * FLOOR_HEIGHT_M, 2), total, "levels"
|
|
127
|
+
floors = DEFAULT_FLOORS.get(kind, 3)
|
|
128
|
+
if floors == 1 and kind in TALL_SINGLE_STOREY:
|
|
129
|
+
return TALL_SINGLE_STOREY[kind], 1, "default"
|
|
130
|
+
return round((floors + roof_levels) * FLOOR_HEIGHT_M, 2), floors + roof_levels, "default"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def min_height(tags: dict) -> float:
|
|
134
|
+
"""Bottom of a building part above the ground: min_height, else
|
|
135
|
+
building:min_level floors (levels the part skips, Simple 3D Buildings)."""
|
|
136
|
+
h = parse_number(tags.get("min_height"))
|
|
137
|
+
if h is not None and 0 < h < 1000:
|
|
138
|
+
return round(h, 2)
|
|
139
|
+
lv = parse_number(tags.get("building:min_level"))
|
|
140
|
+
if lv is not None and lv > 0:
|
|
141
|
+
return round(lv * FLOOR_HEIGHT_M, 2)
|
|
142
|
+
return 0.0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def tree_height(tags: dict) -> float:
|
|
146
|
+
h = parse_number(tags.get("height"))
|
|
147
|
+
if h is not None and 1 <= h <= 80:
|
|
148
|
+
return round(h, 2)
|
|
149
|
+
return 8.0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Relative traffic by OSM ``highway`` class for the noise and air-quality
|
|
153
|
+
# screening: 1 is the road the viewer's default sound power and emission stand
|
|
154
|
+
# for (a busy two-lane urban road). Screening values, not traffic counts; a
|
|
155
|
+
# field with real counts belongs in the layer's Traffic expression instead.
|
|
156
|
+
ROAD_TRAFFIC = {
|
|
157
|
+
"motorway": 8.0,
|
|
158
|
+
"trunk": 5.0,
|
|
159
|
+
"primary": 3.0,
|
|
160
|
+
"secondary": 2.0,
|
|
161
|
+
"tertiary": 1.2,
|
|
162
|
+
"unclassified": 0.6,
|
|
163
|
+
"residential": 0.4,
|
|
164
|
+
"living_street": 0.15,
|
|
165
|
+
"service": 0.15,
|
|
166
|
+
}
|
|
167
|
+
HIGHWAY_PATTERN = "^(" + "|".join(ROAD_TRAFFIC) + ")(_link)?$"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def road_traffic(tags: dict):
|
|
171
|
+
"""Relative traffic of an OSM way, or None when it is no traffic source
|
|
172
|
+
(footways, tracks, roads under construction, tunnels)."""
|
|
173
|
+
highway = str(tags.get("highway") or "")
|
|
174
|
+
link = highway.endswith("_link")
|
|
175
|
+
weight = ROAD_TRAFFIC.get(highway[:-5] if link else highway)
|
|
176
|
+
if weight is None:
|
|
177
|
+
return None
|
|
178
|
+
if str(tags.get("tunnel") or "no").lower() not in ("no", "false", "0"):
|
|
179
|
+
return None
|
|
180
|
+
return round(weight * (0.5 if link else 1.0), 3)
|
|
File without changes
|