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