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,332 @@
1
+ """Local HTTP server for the browser viewer.
2
+
3
+ Serves the viewer (``web/``) at ``/`` and the exported scene folder at
4
+ ``/scene/``. The viewer posts analysis results and feature picks back; they
5
+ are queued on a :class:`Bridge`; with ``results_dir`` set they are also
6
+ written to disk by :func:`zero2geospatial3d.results.save_result`.
7
+
8
+ Binds to 127.0.0.1 only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import functools
14
+ import http.server
15
+ import json
16
+ import mimetypes
17
+ import os
18
+ import posixpath
19
+ import socket
20
+ import socketserver
21
+ import threading
22
+ import urllib.parse
23
+ from pathlib import Path
24
+ from typing import Any, Optional
25
+
26
+ RESULT_ENDPOINT = "/api/result"
27
+ SELECT_ENDPOINT = "/api/select"
28
+ SELECTION_ENDPOINT = "/api/selection"
29
+ PING_ENDPOINT = "/api/ping"
30
+ SCENE_PREFIX = "/scene/"
31
+
32
+ MAX_RESULT_BYTES = 512 * 1024 * 1024
33
+ MAX_SELECT_BYTES = 4 * 1024 * 1024
34
+ MAX_QUEUE = 64
35
+
36
+
37
+ class Bridge:
38
+ """Thread-safe hand-over between the server thread and the caller."""
39
+
40
+ def __init__(self, results_dir: str = ""):
41
+ self.results_dir = results_dir
42
+ self.saved: list = []
43
+ self._lock = threading.Lock()
44
+ self._results: list = []
45
+ self._picks: list = []
46
+ self._selection_seq = 0
47
+ self._selection: dict = {}
48
+
49
+ # viewer -> QGIS
50
+ def push_result(self, payload: dict) -> None:
51
+ if self.results_dir:
52
+ from .results import save_result
53
+
54
+ try:
55
+ self.saved.append(save_result(payload, self.results_dir))
56
+ except (OSError, ValueError) as exc:
57
+ self.saved.append(f"error: {exc}")
58
+ with self._lock:
59
+ self._results.append(payload)
60
+ del self._results[:-MAX_QUEUE]
61
+
62
+ def pop_results(self) -> list:
63
+ with self._lock:
64
+ out, self._results = self._results, []
65
+ return out
66
+
67
+ def push_pick(self, payload: dict) -> None:
68
+ with self._lock:
69
+ self._picks.append(payload)
70
+ del self._picks[:-MAX_QUEUE]
71
+
72
+ def pop_picks(self) -> list:
73
+ with self._lock:
74
+ out, self._picks = self._picks, []
75
+ return out
76
+
77
+ # QGIS -> viewer
78
+ def set_selection(self, selection: dict) -> int:
79
+ clean = {}
80
+ for layer_id, ids in (selection or {}).items():
81
+ try:
82
+ clean[str(layer_id)] = [int(i) for i in list(ids)[:5000]]
83
+ except (TypeError, ValueError):
84
+ continue
85
+ with self._lock:
86
+ self._selection_seq += 1
87
+ self._selection = clean
88
+ return self._selection_seq
89
+
90
+ def selection(self) -> dict:
91
+ with self._lock:
92
+ return {"seq": self._selection_seq, "layers": dict(self._selection)}
93
+
94
+
95
+ class ViewerHandler(http.server.SimpleHTTPRequestHandler):
96
+ """Static files plus a small JSON API. Logging is silenced."""
97
+
98
+ def log_message(self, fmt, *args): # noqa: D401 - silence the access log
99
+ return
100
+
101
+ def end_headers(self):
102
+ # Scene files share one URL (/scene/...) across exports, so a cached
103
+ # copy of another scene's terrain must never be reused: no-store, and
104
+ # no conditional 304 answers (see send_head below).
105
+ self.send_header("Cache-Control", "no-store")
106
+ super().end_headers()
107
+
108
+ def send_head(self):
109
+ # Drop If-Modified-Since: a newly exported file can be *older* than the
110
+ # one the browser cached from a previous scene at the same path.
111
+ if "If-Modified-Since" in self.headers:
112
+ del self.headers["If-Modified-Since"]
113
+ return super().send_head()
114
+
115
+ # ------------------------------------------------------------ static
116
+ def translate_path(self, path):
117
+ scene_dir = getattr(self.server, "scene_dir", None)
118
+ clean = urllib.parse.unquote(path.split("?", 1)[0].split("#", 1)[0])
119
+ if clean.startswith(SCENE_PREFIX) and scene_dir:
120
+ rel = posixpath.normpath(clean[len(SCENE_PREFIX) :]).lstrip("/")
121
+ root = Path(scene_dir).resolve()
122
+ target = (root / rel).resolve()
123
+ if target == root or root in target.parents:
124
+ return str(target)
125
+ return str(root / "__forbidden__")
126
+ return super().translate_path(path)
127
+
128
+ # ------------------------------------------------------------ API
129
+ def _bridge(self) -> Bridge | None:
130
+ return getattr(self.server, "bridge", None)
131
+
132
+ def do_GET(self): # noqa: N802 (http.server naming)
133
+ route = self.path.split("?", 1)[0]
134
+ if route == PING_ENDPOINT:
135
+ self._send_json(200, {"ok": True, "version": getattr(self.server, "version", "")})
136
+ return
137
+ if route == SELECTION_ENDPOINT:
138
+ bridge = self._bridge()
139
+ self._send_json(200, bridge.selection() if bridge else {"seq": 0, "layers": {}})
140
+ return
141
+ super().do_GET()
142
+
143
+ def do_POST(self): # noqa: N802 (http.server naming)
144
+ route = self.path.split("?", 1)[0]
145
+ bridge = self._bridge()
146
+ if bridge is None or route not in (RESULT_ENDPOINT, SELECT_ENDPOINT):
147
+ self._send_json(404, {"ok": False, "error": "not found"})
148
+ return
149
+ limit = MAX_RESULT_BYTES if route == RESULT_ENDPOINT else MAX_SELECT_BYTES
150
+ payload, error = self._read_json(limit)
151
+ if error:
152
+ self._send_json(400, {"ok": False, "error": error})
153
+ return
154
+ if route == RESULT_ENDPOINT:
155
+ bridge.push_result(payload)
156
+ else:
157
+ bridge.push_pick(payload)
158
+ self._send_json(200, {"ok": True})
159
+
160
+ def _read_json(self, limit: int):
161
+ try:
162
+ length = int(self.headers.get("Content-Length") or 0)
163
+ except (TypeError, ValueError):
164
+ length = 0
165
+ if length <= 0 or length > limit:
166
+ return None, "missing or oversized body"
167
+ try:
168
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
169
+ except (UnicodeDecodeError, ValueError) as exc:
170
+ return None, f"bad JSON: {exc}"
171
+ if not isinstance(payload, dict):
172
+ return None, "payload must be a JSON object"
173
+ return payload, ""
174
+
175
+ def _send_json(self, status: int, body: dict) -> None:
176
+ data = json.dumps(body).encode("utf-8")
177
+ self.send_response(status)
178
+ self.send_header("Content-Type", "application/json")
179
+ self.send_header("Content-Length", str(len(data)))
180
+ self.end_headers()
181
+ self.wfile.write(data)
182
+
183
+
184
+ class _ThreadingServer(socketserver.ThreadingTCPServer):
185
+ allow_reuse_address = False
186
+ daemon_threads = True
187
+ bridge: Optional[Bridge] = None
188
+ scene_dir: str = ""
189
+ version: str = ""
190
+
191
+
192
+ class ViewerServer:
193
+ """Start/stop wrapper; finds a free port in a small range."""
194
+
195
+ def __init__(
196
+ self,
197
+ web_root: str,
198
+ host: str = "127.0.0.1",
199
+ start_port: int = 8640,
200
+ end_port: int = 8679,
201
+ version: str = "",
202
+ results_dir: str = "",
203
+ ):
204
+ self.web_root = str(web_root)
205
+ self.host = host
206
+ self.start_port = start_port
207
+ self.end_port = end_port
208
+ self.version = version
209
+ self.port: int | None = None
210
+ self.bridge = Bridge(results_dir)
211
+ self._httpd: Any = None
212
+ self._thread: Optional[threading.Thread] = None
213
+ self._scene_dir = ""
214
+ mimetypes.add_type("application/javascript", ".js")
215
+ mimetypes.add_type("application/javascript", ".mjs")
216
+ mimetypes.add_type("application/json", ".json")
217
+ mimetypes.add_type("application/json", ".geojson")
218
+ mimetypes.add_type("application/octet-stream", ".bin")
219
+ mimetypes.add_type("text/plain", ".epw")
220
+
221
+ @property
222
+ def is_running(self) -> bool:
223
+ return self._httpd is not None
224
+
225
+ @property
226
+ def base_url(self) -> str:
227
+ return f"http://{self.host}:{self.port}/" if self.port else ""
228
+
229
+ def set_scene_dir(self, path: str) -> None:
230
+ self._scene_dir = str(path or "")
231
+ if self._httpd is not None:
232
+ self._httpd.scene_dir = self._scene_dir
233
+
234
+ def viewer_url(self) -> str:
235
+ if not self.is_running:
236
+ return ""
237
+ return f"{self.base_url}index.html?scene={urllib.parse.quote(SCENE_PREFIX + 'scene.json')}"
238
+
239
+ def start(self) -> str:
240
+ if self.is_running:
241
+ return self.viewer_url()
242
+ handler = functools.partial(ViewerHandler, directory=self.web_root)
243
+ last_error: Exception | None = None
244
+ for port in range(self.start_port, self.end_port + 1):
245
+ if _port_in_use(self.host, port):
246
+ continue
247
+ try:
248
+ httpd = _ThreadingServer((self.host, port), handler)
249
+ except OSError as exc:
250
+ last_error = exc
251
+ continue
252
+ httpd.bridge = self.bridge
253
+ httpd.scene_dir = self._scene_dir
254
+ httpd.version = self.version
255
+ self._httpd = httpd
256
+ self.port = port
257
+ break
258
+ if self._httpd is None:
259
+ raise OSError(f"No free local port in {self.start_port}-{self.end_port}: {last_error}")
260
+ self._thread = threading.Thread(
261
+ target=self._httpd.serve_forever, name="Geo3DViewerServer", daemon=True
262
+ )
263
+ self._thread.start()
264
+ return self.viewer_url()
265
+
266
+ def stop(self) -> None:
267
+ if self._httpd is None:
268
+ return
269
+ self._httpd.shutdown()
270
+ self._httpd.server_close()
271
+ if self._thread is not None and self._thread.is_alive():
272
+ self._thread.join(timeout=2.0)
273
+ self._httpd = None
274
+ self._thread = None
275
+ self.port = None
276
+
277
+
278
+ def _port_in_use(host: str, port: int) -> bool:
279
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
280
+ sock.settimeout(0.2)
281
+ return sock.connect_ex((host, port)) == 0
282
+
283
+
284
+ def viewer_root() -> str:
285
+ """Folder of the bundled browser viewer (``index.html`` and ``src/``)."""
286
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
287
+
288
+
289
+ def serve_scene(
290
+ scene_dir: str,
291
+ *,
292
+ open_browser: bool = True,
293
+ block: bool = True,
294
+ results_dir: str = "",
295
+ port: int = 8640,
296
+ ) -> ViewerServer:
297
+ """Serve the bundled viewer with ``scene_dir`` on 127.0.0.1.
298
+
299
+ With ``block=True`` this runs until Ctrl+C and then stops the server;
300
+ otherwise the running :class:`ViewerServer` is returned (call ``stop()``).
301
+ Results sent from the viewer's "send to QGIS" actions are written to
302
+ ``results_dir`` when given.
303
+ """
304
+ import time
305
+ import webbrowser
306
+
307
+ from . import __version__
308
+
309
+ if not os.path.isfile(os.path.join(scene_dir, "scene.json")):
310
+ raise FileNotFoundError(f"No scene.json in {scene_dir}")
311
+ server = ViewerServer(
312
+ viewer_root(),
313
+ start_port=port,
314
+ end_port=port + 39,
315
+ version=__version__,
316
+ results_dir=results_dir,
317
+ )
318
+ server.set_scene_dir(os.path.abspath(scene_dir))
319
+ url = server.start()
320
+ print(f"Viewer running at {url}")
321
+ if open_browser:
322
+ webbrowser.open(url)
323
+ if not block:
324
+ return server
325
+ try:
326
+ while True:
327
+ time.sleep(0.5)
328
+ except KeyboardInterrupt:
329
+ pass
330
+ finally:
331
+ server.stop()
332
+ return server