zero2route3d-sdk 0.2.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.
- zero2route3d/__init__.py +212 -0
- zero2route3d/accessibility_equity.py +214 -0
- zero2route3d/ahp_engine.py +117 -0
- zero2route3d/api.py +244 -0
- zero2route3d/basemap.py +44 -0
- zero2route3d/cli.py +251 -0
- zero2route3d/copernicus_dem.py +76 -0
- zero2route3d/copernicus_eo_suite.py +443 -0
- zero2route3d/dem_fetcher.py +223 -0
- zero2route3d/environmental_raster.py +411 -0
- zero2route3d/evacuation.py +121 -0
- zero2route3d/html_bundler.py +603 -0
- zero2route3d/input_validation.py +100 -0
- zero2route3d/integrations.py +260 -0
- zero2route3d/isochrone_engine.py +220 -0
- zero2route3d/kinematics.py +383 -0
- zero2route3d/local_server.py +116 -0
- zero2route3d/map_matching_3d.py +374 -0
- zero2route3d/micro_elevation.py +264 -0
- zero2route3d/mobility_profiles.py +517 -0
- zero2route3d/multimodal.py +172 -0
- zero2route3d/network_source.py +512 -0
- zero2route3d/osm_downloader.py +328 -0
- zero2route3d/osm_styling.py +351 -0
- zero2route3d/pareto_router.py +331 -0
- zero2route3d/plotting.py +292 -0
- zero2route3d/profile_dxf.py +231 -0
- zero2route3d/profile_stats.py +648 -0
- zero2route3d/py.typed +1 -0
- zero2route3d/qml_generator.py +189 -0
- zero2route3d/raster_source.py +206 -0
- zero2route3d/report_generator.py +154 -0
- zero2route3d/route_corridor_3d.py +479 -0
- zero2route3d/routing_engine.py +747 -0
- zero2route3d/solar_shadow.py +157 -0
- zero2route3d/tsp_solver.py +78 -0
- zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
- zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
- zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
- zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Road network data acquisition, vector layer ingestion, and geocoding engine.
|
|
2
|
+
|
|
3
|
+
Supports fetching OpenStreetMap networks via Overpass API with local bounding
|
|
4
|
+
box caching, reverse geocoding via Photon API, and extracting topology from
|
|
5
|
+
active QGIS vector layers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import http.client
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .input_validation import normalize_bbox
|
|
22
|
+
from .kinematics import haversine_distance_2d
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RoadSegment:
|
|
27
|
+
"""Directed edge segment representing a traversable line between two vertices."""
|
|
28
|
+
|
|
29
|
+
p1: Tuple[float, float, float]
|
|
30
|
+
p2: Tuple[float, float, float]
|
|
31
|
+
length_m: float
|
|
32
|
+
highway_type: str = "residential"
|
|
33
|
+
hierarchy_rank: int = (
|
|
34
|
+
4 # 1: Motorway, 2: Primary, 3: Secondary/Tertiary, 4: Residential, 5: Path/Service
|
|
35
|
+
)
|
|
36
|
+
lanes: int = 2
|
|
37
|
+
is_steps: bool = False
|
|
38
|
+
surface: str = "asphalt"
|
|
39
|
+
is_oneway: bool = False
|
|
40
|
+
# Real OSM "name" tag. Left empty when the way is unnamed -- an unnamed street
|
|
41
|
+
# must stay unnamed rather than be given a placeholder like "Urban Path".
|
|
42
|
+
name: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NetworkSourceError(RuntimeError):
|
|
46
|
+
"""Raised when no usable real network can be loaded for an operation."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NetworkSourceManager:
|
|
50
|
+
"""Acquires, caches, geocodes, and parses topological road networks."""
|
|
51
|
+
|
|
52
|
+
CACHE_DIR = Path(tempfile.gettempdir()) / "zero2route3d_cache"
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _highway_to_hierarchy(highway: str) -> int:
|
|
59
|
+
"""Map OSM highway tag to functional hierarchy rank (1..5)."""
|
|
60
|
+
h = str(highway).lower()
|
|
61
|
+
if h in {"motorway", "motorway_link", "trunk", "trunk_link"}:
|
|
62
|
+
return 1
|
|
63
|
+
if h in {"primary", "primary_link"}:
|
|
64
|
+
return 2
|
|
65
|
+
if h in {"secondary", "secondary_link", "tertiary", "tertiary_link"}:
|
|
66
|
+
return 3
|
|
67
|
+
if h in {"residential", "living_street", "unclassified"}:
|
|
68
|
+
return 4
|
|
69
|
+
return 5
|
|
70
|
+
|
|
71
|
+
def search_place_photon(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
72
|
+
"""Geocode place name using Photon API (HTTPS)."""
|
|
73
|
+
if not query or len(query.strip()) < 2:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
results = []
|
|
77
|
+
with contextlib.suppress(Exception):
|
|
78
|
+
encoded = urllib.parse.quote(query.strip())
|
|
79
|
+
try:
|
|
80
|
+
lim = max(1, min(50, int(limit)))
|
|
81
|
+
except (TypeError, ValueError, OverflowError):
|
|
82
|
+
lim = 5
|
|
83
|
+
conn = http.client.HTTPSConnection("photon.komoot.io", timeout=8)
|
|
84
|
+
try:
|
|
85
|
+
conn.request(
|
|
86
|
+
"GET",
|
|
87
|
+
f"/api/?q={encoded}&limit={lim}",
|
|
88
|
+
headers={"User-Agent": "zero2route3d-sdk"},
|
|
89
|
+
)
|
|
90
|
+
resp = conn.getresponse()
|
|
91
|
+
if resp.status == 200:
|
|
92
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
93
|
+
if isinstance(data, dict):
|
|
94
|
+
for feat in data.get("features", []):
|
|
95
|
+
geom = feat.get("geometry", {})
|
|
96
|
+
props = feat.get("properties", {})
|
|
97
|
+
coords = geom.get("coordinates", [])
|
|
98
|
+
if len(coords) >= 2:
|
|
99
|
+
try:
|
|
100
|
+
lon_val = float(coords[0])
|
|
101
|
+
lat_val = float(coords[1])
|
|
102
|
+
if math.isfinite(lon_val) and math.isfinite(lat_val):
|
|
103
|
+
name = props.get("name") or props.get("street") or query
|
|
104
|
+
city = (
|
|
105
|
+
props.get("city")
|
|
106
|
+
or props.get("state")
|
|
107
|
+
or props.get("country")
|
|
108
|
+
or ""
|
|
109
|
+
)
|
|
110
|
+
label = f"{name} ({city})" if city else name
|
|
111
|
+
results.append(
|
|
112
|
+
{
|
|
113
|
+
"label": str(label),
|
|
114
|
+
"lon": lon_val,
|
|
115
|
+
"lat": lat_val,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
except (ValueError, TypeError):
|
|
119
|
+
continue
|
|
120
|
+
finally:
|
|
121
|
+
conn.close()
|
|
122
|
+
return results
|
|
123
|
+
|
|
124
|
+
def fetch_osm_network_bbox(
|
|
125
|
+
self,
|
|
126
|
+
bbox: Tuple[float, float, float, float],
|
|
127
|
+
buffer_ratio: float = 0.15,
|
|
128
|
+
) -> List[RoadSegment]:
|
|
129
|
+
"""Fetch OSM highway ways within bbox using Overpass API with disk caching.
|
|
130
|
+
|
|
131
|
+
A missing/invalid response is an input error, not a reason to invent a
|
|
132
|
+
network. Callers can surface :class:`NetworkSourceError` directly to
|
|
133
|
+
QGIS users and let them choose a valid layer or retry the download.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
min_lon, min_lat, max_lon, max_lat = normalize_bbox(bbox)
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise NetworkSourceError(str(exc)) from exc
|
|
139
|
+
|
|
140
|
+
d_lon = max(0.005, max_lon - min_lon)
|
|
141
|
+
d_lat = max(0.005, max_lat - min_lat)
|
|
142
|
+
|
|
143
|
+
buf_r = float(buffer_ratio) if math.isfinite(buffer_ratio) and buffer_ratio >= 0 else 0.15
|
|
144
|
+
buf_r = min(1.0, buf_r)
|
|
145
|
+
buf_lon = d_lon * buf_r
|
|
146
|
+
buf_lat = d_lat * buf_r
|
|
147
|
+
s = max(-90.0, min_lat - buf_lat)
|
|
148
|
+
w = max(-180.0, min_lon - buf_lon)
|
|
149
|
+
n = min(90.0, max_lat + buf_lat)
|
|
150
|
+
e = min(180.0, max_lon + buf_lon)
|
|
151
|
+
|
|
152
|
+
cache_key = hashlib.sha256(f"{s:.4f}_{w:.4f}_{n:.4f}_{e:.4f}".encode("utf-8")).hexdigest()
|
|
153
|
+
cache_file = self.CACHE_DIR / f"osm_{cache_key}.json"
|
|
154
|
+
|
|
155
|
+
data = None
|
|
156
|
+
if cache_file.exists():
|
|
157
|
+
with contextlib.suppress(Exception):
|
|
158
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
159
|
+
loaded = json.load(f)
|
|
160
|
+
if isinstance(loaded, dict) and "elements" in loaded:
|
|
161
|
+
data = loaded
|
|
162
|
+
|
|
163
|
+
if not data:
|
|
164
|
+
query = f"""[out:json][timeout:25];(way["highway"]({s:.5f},{w:.5f},{n:.5f},{e:.5f}););out body;>;out skel qt;"""
|
|
165
|
+
data = self._query_overpass(query)
|
|
166
|
+
if data and isinstance(data, dict) and "elements" in data:
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
temp_cache = cache_file.with_suffix(".tmp")
|
|
169
|
+
with open(temp_cache, "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump(data, f)
|
|
171
|
+
temp_cache.replace(cache_file)
|
|
172
|
+
|
|
173
|
+
if not data or not isinstance(data, dict) or "elements" not in data:
|
|
174
|
+
raise NetworkSourceError(
|
|
175
|
+
"OpenStreetMap network download failed. Check the internet connection "
|
|
176
|
+
"or select a QGIS line network layer."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
segments = self._parse_osm_json(data)
|
|
180
|
+
if not segments:
|
|
181
|
+
raise NetworkSourceError(
|
|
182
|
+
"OpenStreetMap returned no usable road segments for this extent. "
|
|
183
|
+
"Try a larger extent or select a QGIS line network layer."
|
|
184
|
+
)
|
|
185
|
+
return segments
|
|
186
|
+
|
|
187
|
+
def require_segments(
|
|
188
|
+
self,
|
|
189
|
+
vector_layer: Any = None,
|
|
190
|
+
bbox: Optional[Tuple[float, float, float, float]] = None,
|
|
191
|
+
) -> List[RoadSegment]:
|
|
192
|
+
"""Load a real network from a QGIS line layer or OSM, never fabricated data."""
|
|
193
|
+
if vector_layer is not None:
|
|
194
|
+
segments = self.extract_from_qgis_layer(vector_layer)
|
|
195
|
+
if not segments:
|
|
196
|
+
raise NetworkSourceError(
|
|
197
|
+
"The selected network layer contains no usable line segments. "
|
|
198
|
+
"Select a non-empty line layer with a valid CRS."
|
|
199
|
+
)
|
|
200
|
+
return segments
|
|
201
|
+
if bbox is None:
|
|
202
|
+
raise NetworkSourceError(
|
|
203
|
+
"No network source was provided. Select a QGIS line layer or enable OSM download."
|
|
204
|
+
)
|
|
205
|
+
return self.fetch_osm_network_bbox(bbox)
|
|
206
|
+
|
|
207
|
+
def extract_from_geojson(self, data: Any) -> List[RoadSegment]:
|
|
208
|
+
"""Extract road segments from GeoJSON, the SDK's primary network input.
|
|
209
|
+
|
|
210
|
+
Accepts a FeatureCollection, a single Feature, a bare geometry, or a list
|
|
211
|
+
of any of those. LineString and MultiLineString geometries are split into
|
|
212
|
+
directed vertex-to-vertex segments; Z coordinates are carried through when
|
|
213
|
+
present. Non-line geometries are skipped.
|
|
214
|
+
|
|
215
|
+
OSM-style properties are honoured where available: ``highway``, ``surface``,
|
|
216
|
+
``oneway`` (including ``-1``/``reverse``), ``junction=roundabout``, ``lanes``
|
|
217
|
+
and ``name``.
|
|
218
|
+
"""
|
|
219
|
+
features = self._iter_geojson_features(data)
|
|
220
|
+
segments: List[RoadSegment] = []
|
|
221
|
+
|
|
222
|
+
for geometry, properties in features:
|
|
223
|
+
geom_type = str(geometry.get("type", ""))
|
|
224
|
+
if geom_type == "LineString":
|
|
225
|
+
lines = [geometry.get("coordinates") or []]
|
|
226
|
+
elif geom_type == "MultiLineString":
|
|
227
|
+
lines = list(geometry.get("coordinates") or [])
|
|
228
|
+
else:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
highway = str(properties.get("highway", "residential") or "residential").strip()
|
|
232
|
+
hierarchy = self._highway_to_hierarchy(highway)
|
|
233
|
+
is_steps = highway == "steps"
|
|
234
|
+
surface = str(properties.get("surface", "asphalt") or "asphalt").strip()
|
|
235
|
+
lanes = 1
|
|
236
|
+
with contextlib.suppress(Exception):
|
|
237
|
+
lanes = max(1, int(properties.get("lanes", 1)))
|
|
238
|
+
|
|
239
|
+
oneway_raw = properties.get("oneway", False)
|
|
240
|
+
oneway_tag = str(oneway_raw).strip().lower()
|
|
241
|
+
# oneway=-1 means one-way *against* the digitisation order; storing it
|
|
242
|
+
# unreversed routes traffic the wrong way down the street.
|
|
243
|
+
reversed_oneway = oneway_tag in {"-1", "reverse"}
|
|
244
|
+
oneway = (
|
|
245
|
+
oneway_raw is True
|
|
246
|
+
or oneway_tag in {"yes", "1", "true"}
|
|
247
|
+
or reversed_oneway
|
|
248
|
+
or str(properties.get("junction", "") or "").strip().lower() == "roundabout"
|
|
249
|
+
)
|
|
250
|
+
street_name = str(properties.get("name", "") or "").strip()
|
|
251
|
+
|
|
252
|
+
for line in lines:
|
|
253
|
+
vertices = self._clean_line_coordinates(line)
|
|
254
|
+
for c1, c2 in zip(vertices, vertices[1:]):
|
|
255
|
+
dist = haversine_distance_2d(c1, c2)
|
|
256
|
+
if not math.isfinite(dist) or dist < 0.1:
|
|
257
|
+
continue
|
|
258
|
+
p1, p2 = (c2, c1) if reversed_oneway else (c1, c2)
|
|
259
|
+
segments.append(
|
|
260
|
+
RoadSegment(
|
|
261
|
+
p1=p1,
|
|
262
|
+
p2=p2,
|
|
263
|
+
length_m=dist,
|
|
264
|
+
highway_type=highway,
|
|
265
|
+
hierarchy_rank=hierarchy,
|
|
266
|
+
lanes=lanes,
|
|
267
|
+
is_steps=is_steps,
|
|
268
|
+
surface=surface,
|
|
269
|
+
is_oneway=oneway,
|
|
270
|
+
name=street_name,
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return segments
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def _iter_geojson_features(cls, data: Any) -> List[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
|
278
|
+
"""Flatten any GeoJSON container into (geometry, properties) pairs."""
|
|
279
|
+
pairs: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
|
|
280
|
+
|
|
281
|
+
if isinstance(data, (list, tuple)):
|
|
282
|
+
for item in data:
|
|
283
|
+
pairs.extend(cls._iter_geojson_features(item))
|
|
284
|
+
return pairs
|
|
285
|
+
|
|
286
|
+
if not isinstance(data, dict):
|
|
287
|
+
return pairs
|
|
288
|
+
|
|
289
|
+
node_type = str(data.get("type", ""))
|
|
290
|
+
if node_type == "FeatureCollection":
|
|
291
|
+
for feature in data.get("features") or []:
|
|
292
|
+
pairs.extend(cls._iter_geojson_features(feature))
|
|
293
|
+
elif node_type == "Feature":
|
|
294
|
+
geometry = data.get("geometry")
|
|
295
|
+
properties = data.get("properties") or {}
|
|
296
|
+
if isinstance(geometry, dict):
|
|
297
|
+
if str(geometry.get("type", "")) == "GeometryCollection":
|
|
298
|
+
for sub in geometry.get("geometries") or []:
|
|
299
|
+
if isinstance(sub, dict):
|
|
300
|
+
pairs.append((sub, properties))
|
|
301
|
+
else:
|
|
302
|
+
pairs.append((geometry, properties))
|
|
303
|
+
elif node_type == "GeometryCollection":
|
|
304
|
+
for sub in data.get("geometries") or []:
|
|
305
|
+
if isinstance(sub, dict):
|
|
306
|
+
pairs.append((sub, {}))
|
|
307
|
+
elif node_type:
|
|
308
|
+
pairs.append((data, {}))
|
|
309
|
+
|
|
310
|
+
return pairs
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _clean_line_coordinates(line: Any) -> List[Tuple[float, float, float]]:
|
|
314
|
+
"""Coerce a GeoJSON coordinate array into finite (lon, lat, z) tuples."""
|
|
315
|
+
vertices: List[Tuple[float, float, float]] = []
|
|
316
|
+
if not isinstance(line, (list, tuple)):
|
|
317
|
+
return vertices
|
|
318
|
+
for pt in line:
|
|
319
|
+
if not isinstance(pt, (list, tuple)) or len(pt) < 2:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
lon = float(pt[0])
|
|
323
|
+
lat = float(pt[1])
|
|
324
|
+
z = float(pt[2]) if len(pt) > 2 else 0.0
|
|
325
|
+
except (TypeError, ValueError, OverflowError):
|
|
326
|
+
continue
|
|
327
|
+
if not (math.isfinite(lon) and math.isfinite(lat)):
|
|
328
|
+
continue
|
|
329
|
+
vertices.append((lon, lat, z if math.isfinite(z) else 0.0))
|
|
330
|
+
return vertices
|
|
331
|
+
|
|
332
|
+
def _query_overpass(self, query: str) -> Dict[str, Any] | None:
|
|
333
|
+
"""Safe HTTPS POST to Overpass API without generic urlopen."""
|
|
334
|
+
endpoints = [
|
|
335
|
+
("overpass-api.de", "/api/interpreter"),
|
|
336
|
+
("maps.mail.ru", "/osm/tools/overpass/api/interpreter"),
|
|
337
|
+
]
|
|
338
|
+
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
|
|
339
|
+
headers = {
|
|
340
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
341
|
+
"User-Agent": "zero2route3d-sdk",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for host, path in endpoints:
|
|
345
|
+
with contextlib.suppress(Exception):
|
|
346
|
+
conn = http.client.HTTPSConnection(host, timeout=12)
|
|
347
|
+
try:
|
|
348
|
+
conn.request("POST", path, body=body, headers=headers)
|
|
349
|
+
resp = conn.getresponse()
|
|
350
|
+
if resp.status == 200:
|
|
351
|
+
raw = resp.read().decode("utf-8")
|
|
352
|
+
parsed = json.loads(raw)
|
|
353
|
+
if isinstance(parsed, dict):
|
|
354
|
+
return parsed
|
|
355
|
+
finally:
|
|
356
|
+
conn.close()
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _parse_osm_json(self, data: Dict[str, Any]) -> List[RoadSegment]:
|
|
360
|
+
"""Convert OSM JSON response elements into list of RoadSegments."""
|
|
361
|
+
nodes: Dict[int, Tuple[float, float]] = {}
|
|
362
|
+
segments: List[RoadSegment] = []
|
|
363
|
+
|
|
364
|
+
for el in data.get("elements", []):
|
|
365
|
+
if el.get("type") == "node":
|
|
366
|
+
try:
|
|
367
|
+
nid = int(el["id"])
|
|
368
|
+
nlon = float(el["lon"])
|
|
369
|
+
nlat = float(el["lat"])
|
|
370
|
+
if math.isfinite(nlon) and math.isfinite(nlat):
|
|
371
|
+
nodes[nid] = (nlon, nlat)
|
|
372
|
+
except (KeyError, ValueError, TypeError):
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
for el in data.get("elements", []):
|
|
376
|
+
if el.get("type") == "way":
|
|
377
|
+
tags = el.get("tags") or {}
|
|
378
|
+
highway = str(tags.get("highway", "residential"))
|
|
379
|
+
hierarchy = self._highway_to_hierarchy(highway)
|
|
380
|
+
is_steps = highway == "steps"
|
|
381
|
+
surface = str(tags.get("surface", "asphalt"))
|
|
382
|
+
lanes = 1
|
|
383
|
+
with contextlib.suppress(Exception):
|
|
384
|
+
lanes = max(1, int(tags.get("lanes", 1)))
|
|
385
|
+
oneway_tag = str(tags.get("oneway", "")).strip().lower()
|
|
386
|
+
# oneway=-1 means the way is one-way *against* its digitisation order.
|
|
387
|
+
# Treating it as bidirectional routed vehicles the wrong way down it.
|
|
388
|
+
reversed_oneway = oneway_tag in {"-1", "reverse"}
|
|
389
|
+
oneway = (
|
|
390
|
+
oneway_tag in {"yes", "1", "true"}
|
|
391
|
+
or reversed_oneway
|
|
392
|
+
or str(tags.get("junction", "")).strip().lower() == "roundabout"
|
|
393
|
+
)
|
|
394
|
+
street_name = str(tags.get("name", "") or "").strip()
|
|
395
|
+
|
|
396
|
+
way_nodes = el.get("nodes") or []
|
|
397
|
+
for i in range(len(way_nodes) - 1):
|
|
398
|
+
n1 = way_nodes[i]
|
|
399
|
+
n2 = way_nodes[i + 1]
|
|
400
|
+
if n1 in nodes and n2 in nodes:
|
|
401
|
+
c1 = nodes[n1]
|
|
402
|
+
c2 = nodes[n2]
|
|
403
|
+
dist = haversine_distance_2d(c1, c2)
|
|
404
|
+
if dist >= 0.1 and math.isfinite(dist):
|
|
405
|
+
seg = RoadSegment(
|
|
406
|
+
p1=(c1[0], c1[1], 0.0),
|
|
407
|
+
p2=(c2[0], c2[1], 0.0),
|
|
408
|
+
length_m=dist,
|
|
409
|
+
highway_type=highway,
|
|
410
|
+
hierarchy_rank=hierarchy,
|
|
411
|
+
lanes=lanes,
|
|
412
|
+
is_steps=is_steps,
|
|
413
|
+
surface=surface,
|
|
414
|
+
is_oneway=oneway,
|
|
415
|
+
name=street_name,
|
|
416
|
+
)
|
|
417
|
+
if reversed_oneway:
|
|
418
|
+
# Store it in its true travel direction.
|
|
419
|
+
seg.p1, seg.p2 = seg.p2, seg.p1
|
|
420
|
+
segments.append(seg)
|
|
421
|
+
return segments
|
|
422
|
+
|
|
423
|
+
def extract_from_qgis_layer(self, vector_layer: Any) -> List[RoadSegment]:
|
|
424
|
+
"""Extract road network edges from an active QGIS line vector layer."""
|
|
425
|
+
if vector_layer is None:
|
|
426
|
+
return []
|
|
427
|
+
segments: List[RoadSegment] = []
|
|
428
|
+
|
|
429
|
+
def point_coordinate(point: Any, name: str, default: float = 0.0) -> float:
|
|
430
|
+
"""Read QgsPoint or QgsPointXY coordinates across QGIS versions."""
|
|
431
|
+
value = getattr(point, name, None)
|
|
432
|
+
if callable(value):
|
|
433
|
+
value = value()
|
|
434
|
+
try:
|
|
435
|
+
return float(value) if value is not None else default
|
|
436
|
+
except (TypeError, ValueError):
|
|
437
|
+
return default
|
|
438
|
+
|
|
439
|
+
with contextlib.suppress(Exception):
|
|
440
|
+
from qgis.core import (
|
|
441
|
+
QgsCoordinateReferenceSystem,
|
|
442
|
+
QgsCoordinateTransform,
|
|
443
|
+
QgsProject,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
crs_wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
|
|
447
|
+
crs_layer = vector_layer.crs()
|
|
448
|
+
needs_transform = crs_layer != crs_wgs84
|
|
449
|
+
transform = (
|
|
450
|
+
QgsCoordinateTransform(crs_layer, crs_wgs84, QgsProject.instance())
|
|
451
|
+
if needs_transform
|
|
452
|
+
else None
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
for feat in vector_layer.getFeatures():
|
|
456
|
+
geom = feat.geometry()
|
|
457
|
+
if geom.isNull() or geom.isEmpty():
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
if needs_transform and transform is not None:
|
|
461
|
+
geom.transform(transform)
|
|
462
|
+
|
|
463
|
+
lines = []
|
|
464
|
+
if geom.isMultipart():
|
|
465
|
+
lines = geom.asMultiPolyline()
|
|
466
|
+
else:
|
|
467
|
+
lines = [geom.asPolyline()]
|
|
468
|
+
|
|
469
|
+
fields = feat.fields().names()
|
|
470
|
+
highway = "residential"
|
|
471
|
+
for h_field in ["highway", "type", "road_type", "class", "KIND"]:
|
|
472
|
+
if h_field in fields and feat[h_field] is not None:
|
|
473
|
+
highway = str(feat[h_field])
|
|
474
|
+
break
|
|
475
|
+
|
|
476
|
+
hierarchy = self._highway_to_hierarchy(highway)
|
|
477
|
+
is_steps = "step" in highway.lower() or "merdiven" in highway.lower()
|
|
478
|
+
|
|
479
|
+
for line in lines:
|
|
480
|
+
for i in range(len(line) - 1):
|
|
481
|
+
p1 = line[i]
|
|
482
|
+
p2 = line[i + 1]
|
|
483
|
+
c1 = (
|
|
484
|
+
point_coordinate(p1, "x"),
|
|
485
|
+
point_coordinate(p1, "y"),
|
|
486
|
+
point_coordinate(p1, "z"),
|
|
487
|
+
)
|
|
488
|
+
c2 = (
|
|
489
|
+
point_coordinate(p2, "x"),
|
|
490
|
+
point_coordinate(p2, "y"),
|
|
491
|
+
point_coordinate(p2, "z"),
|
|
492
|
+
)
|
|
493
|
+
if not (
|
|
494
|
+
math.isfinite(c1[0])
|
|
495
|
+
and math.isfinite(c1[1])
|
|
496
|
+
and math.isfinite(c2[0])
|
|
497
|
+
and math.isfinite(c2[1])
|
|
498
|
+
):
|
|
499
|
+
continue
|
|
500
|
+
dist = haversine_distance_2d(c1, c2)
|
|
501
|
+
if dist >= 0.1 and math.isfinite(dist):
|
|
502
|
+
segments.append(
|
|
503
|
+
RoadSegment(
|
|
504
|
+
p1=c1,
|
|
505
|
+
p2=c2,
|
|
506
|
+
length_m=dist,
|
|
507
|
+
highway_type=highway,
|
|
508
|
+
hierarchy_rank=hierarchy,
|
|
509
|
+
is_steps=is_steps,
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return segments
|