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,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