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,479 @@
|
|
|
1
|
+
"""3D Route Corridor extractor: buffers multi-route paths by 30m and extracts real 3D OSM buildings and 3D volumetric trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
from .kinematics import haversine_distance_2d
|
|
10
|
+
from .osm_downloader import OsmBuilding, OsmPark, OsmTree
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def point_to_segment_distance_meters(
|
|
14
|
+
px: float, py: float, ax: float, ay: float, bx: float, by: float
|
|
15
|
+
) -> float:
|
|
16
|
+
"""Distance in meters from point P(px,py) to segment AB in WGS84."""
|
|
17
|
+
if not (
|
|
18
|
+
math.isfinite(px)
|
|
19
|
+
and math.isfinite(py)
|
|
20
|
+
and math.isfinite(ax)
|
|
21
|
+
and math.isfinite(ay)
|
|
22
|
+
and math.isfinite(bx)
|
|
23
|
+
and math.isfinite(by)
|
|
24
|
+
):
|
|
25
|
+
return float("inf")
|
|
26
|
+
|
|
27
|
+
# Convert local dx, dy to approximate meters
|
|
28
|
+
cos_lat = math.cos(math.radians((ay + by) * 0.5))
|
|
29
|
+
dx = (bx - ax) * 111320.0 * cos_lat
|
|
30
|
+
dy = (by - ay) * 110540.0
|
|
31
|
+
seg_len_sq = dx * dx + dy * dy
|
|
32
|
+
if not math.isfinite(seg_len_sq) or seg_len_sq <= 1e-9:
|
|
33
|
+
return haversine_distance_2d((px, py), (ax, ay))
|
|
34
|
+
|
|
35
|
+
p_dx = (px - ax) * 111320.0 * cos_lat
|
|
36
|
+
p_dy = (py - ay) * 110540.0
|
|
37
|
+
num = p_dx * dx + p_dy * dy
|
|
38
|
+
if not math.isfinite(num):
|
|
39
|
+
return haversine_distance_2d((px, py), (ax, ay))
|
|
40
|
+
|
|
41
|
+
t = max(0.0, min(1.0, num / seg_len_sq))
|
|
42
|
+
|
|
43
|
+
proj_x = ax + t * (bx - ax)
|
|
44
|
+
proj_y = ay + t * (by - ay)
|
|
45
|
+
return haversine_distance_2d((px, py), (proj_x, proj_y))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def point_to_linestring_distance_meters(
|
|
49
|
+
px: float, py: float, linestring: Sequence[Tuple[float, float]]
|
|
50
|
+
) -> float:
|
|
51
|
+
"""Minimum distance in meters from point P to a polyline."""
|
|
52
|
+
if not linestring:
|
|
53
|
+
return float("inf")
|
|
54
|
+
if len(linestring) == 1:
|
|
55
|
+
return haversine_distance_2d((px, py), linestring[0])
|
|
56
|
+
|
|
57
|
+
min_d = float("inf")
|
|
58
|
+
for i in range(len(linestring) - 1):
|
|
59
|
+
d = point_to_segment_distance_meters(
|
|
60
|
+
px, py, linestring[i][0], linestring[i][1], linestring[i + 1][0], linestring[i + 1][1]
|
|
61
|
+
)
|
|
62
|
+
if d < min_d:
|
|
63
|
+
min_d = d
|
|
64
|
+
return min_d
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def point_to_multi_linestrings_distance_meters(
|
|
68
|
+
px: float, py: float, linestrings: Sequence[Sequence[Tuple[float, float]]]
|
|
69
|
+
) -> float:
|
|
70
|
+
"""Minimum distance in meters from point P to any polyline in the collection."""
|
|
71
|
+
if not linestrings:
|
|
72
|
+
return float("inf")
|
|
73
|
+
min_d = float("inf")
|
|
74
|
+
for line in linestrings:
|
|
75
|
+
d = point_to_linestring_distance_meters(px, py, line)
|
|
76
|
+
if d < min_d:
|
|
77
|
+
min_d = d
|
|
78
|
+
return min_d
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_closest_route_elevation(
|
|
82
|
+
px: float, py: float, route_coords: Sequence[Sequence[float]]
|
|
83
|
+
) -> float:
|
|
84
|
+
"""Find the ground elevation of the closest point along a 3D route polyline."""
|
|
85
|
+
if not route_coords:
|
|
86
|
+
return 0.0
|
|
87
|
+
if len(route_coords) == 1:
|
|
88
|
+
return (
|
|
89
|
+
float(route_coords[0][2])
|
|
90
|
+
if len(route_coords[0]) > 2 and math.isfinite(float(route_coords[0][2]))
|
|
91
|
+
else 0.0
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
min_d = float("inf")
|
|
95
|
+
closest_z = (
|
|
96
|
+
float(route_coords[0][2])
|
|
97
|
+
if len(route_coords[0]) > 2 and math.isfinite(float(route_coords[0][2]))
|
|
98
|
+
else 0.0
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for i in range(len(route_coords) - 1):
|
|
102
|
+
p1 = route_coords[i]
|
|
103
|
+
p2 = route_coords[i + 1]
|
|
104
|
+
z1 = float(p1[2]) if len(p1) > 2 and math.isfinite(float(p1[2])) else 0.0
|
|
105
|
+
z2 = float(p2[2]) if len(p2) > 2 and math.isfinite(float(p2[2])) else 0.0
|
|
106
|
+
d = point_to_segment_distance_meters(px, py, p1[0], p1[1], p2[0], p2[1])
|
|
107
|
+
if d < min_d:
|
|
108
|
+
min_d = d
|
|
109
|
+
closest_z = (z1 + z2) * 0.5
|
|
110
|
+
return closest_z
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_closest_multi_route_elevation(
|
|
114
|
+
px: float,
|
|
115
|
+
py: float,
|
|
116
|
+
all_routes: Sequence[Sequence[Sequence[float]]],
|
|
117
|
+
green_sampler: Optional[Any] = None,
|
|
118
|
+
) -> float:
|
|
119
|
+
"""Find ground elevation using the green_sampler DEM if available, or closest multi-route elevation."""
|
|
120
|
+
if green_sampler is not None and hasattr(green_sampler, "sample_elevation"):
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
elev = green_sampler.sample_elevation(px, py)
|
|
123
|
+
if elev is not None and math.isfinite(elev) and elev != 0.0:
|
|
124
|
+
return float(elev)
|
|
125
|
+
|
|
126
|
+
if not all_routes:
|
|
127
|
+
return 0.0
|
|
128
|
+
|
|
129
|
+
min_d = float("inf")
|
|
130
|
+
best_z = 0.0
|
|
131
|
+
for route in all_routes:
|
|
132
|
+
if not route:
|
|
133
|
+
continue
|
|
134
|
+
for i in range(len(route) - 1):
|
|
135
|
+
p1 = route[i]
|
|
136
|
+
p2 = route[i + 1]
|
|
137
|
+
z1 = float(p1[2]) if len(p1) > 2 and math.isfinite(float(p1[2])) else 0.0
|
|
138
|
+
z2 = float(p2[2]) if len(p2) > 2 and math.isfinite(float(p2[2])) else 0.0
|
|
139
|
+
d = point_to_segment_distance_meters(px, py, p1[0], p1[1], p2[0], p2[1])
|
|
140
|
+
if d < min_d:
|
|
141
|
+
min_d = d
|
|
142
|
+
best_z = (z1 + z2) * 0.5
|
|
143
|
+
return best_z
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def filter_corridor_assets_multi_route(
|
|
147
|
+
routes_coords: Sequence[Any],
|
|
148
|
+
buildings: Sequence[OsmBuilding],
|
|
149
|
+
buffer_meters: float = 30.0,
|
|
150
|
+
green_sampler: Optional[Any] = None,
|
|
151
|
+
osm_trees: Optional[Sequence[OsmTree]] = None,
|
|
152
|
+
osm_parks: Optional[Sequence[OsmPark]] = None,
|
|
153
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
154
|
+
"""Multi-route corridor asset extractor.
|
|
155
|
+
|
|
156
|
+
Buffers across ALL active route paths (30m buffer default) to extract:
|
|
157
|
+
1. Real 3D OSM buildings within the multi-route buffer with calculated base elevations and levels.
|
|
158
|
+
2. Real & volumetric 3D trees placed along greenery / park / corridor zones within the 30m buffer.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
(corridor_buildings, corridor_trees)
|
|
162
|
+
"""
|
|
163
|
+
if not routes_coords:
|
|
164
|
+
return [], []
|
|
165
|
+
|
|
166
|
+
# Handle input flexibility: routes_coords can be a list of routes or a single route
|
|
167
|
+
cleaned_routes: List[List[Tuple[float, float, float]]] = []
|
|
168
|
+
if len(routes_coords) > 0:
|
|
169
|
+
first_elem = routes_coords[0]
|
|
170
|
+
if (
|
|
171
|
+
isinstance(first_elem, (list, tuple))
|
|
172
|
+
and len(first_elem) > 0
|
|
173
|
+
and isinstance(first_elem[0], (int, float))
|
|
174
|
+
):
|
|
175
|
+
route_list = [routes_coords]
|
|
176
|
+
else:
|
|
177
|
+
route_list = list(routes_coords)
|
|
178
|
+
else:
|
|
179
|
+
return [], []
|
|
180
|
+
|
|
181
|
+
for r in route_list:
|
|
182
|
+
if not r or len(r) < 2:
|
|
183
|
+
continue
|
|
184
|
+
valid_pts = []
|
|
185
|
+
for c in r:
|
|
186
|
+
if isinstance(c, (list, tuple)) and len(c) >= 2:
|
|
187
|
+
lon_v = float(c[0])
|
|
188
|
+
lat_v = float(c[1])
|
|
189
|
+
ele_v = float(c[2]) if len(c) > 2 and math.isfinite(float(c[2])) else 0.0
|
|
190
|
+
if math.isfinite(lon_v) and math.isfinite(lat_v):
|
|
191
|
+
valid_pts.append((lon_v, lat_v, ele_v))
|
|
192
|
+
if len(valid_pts) >= 2:
|
|
193
|
+
cleaned_routes.append(valid_pts)
|
|
194
|
+
|
|
195
|
+
if not cleaned_routes:
|
|
196
|
+
return [], []
|
|
197
|
+
|
|
198
|
+
buf_m = float(buffer_meters) if math.isfinite(buffer_meters) and buffer_meters >= 0 else 30.0
|
|
199
|
+
all_lines_2d = [[(p[0], p[1]) for p in r] for r in cleaned_routes]
|
|
200
|
+
|
|
201
|
+
# Global bounding box across all routes
|
|
202
|
+
all_lons = [p[0] for line in all_lines_2d for p in line]
|
|
203
|
+
all_lats = [p[1] for line in all_lines_2d for p in line]
|
|
204
|
+
margin_deg = (buf_m + 35.0) / 75000.0
|
|
205
|
+
min_rx = min(all_lons) - margin_deg
|
|
206
|
+
max_rx = max(all_lons) + margin_deg
|
|
207
|
+
min_ry = min(all_lats) - margin_deg
|
|
208
|
+
max_ry = max(all_lats) + margin_deg
|
|
209
|
+
|
|
210
|
+
# -------------------------------------------------------------
|
|
211
|
+
# 1. Extract 3D Buildings within 30m multi-route buffer
|
|
212
|
+
# -------------------------------------------------------------
|
|
213
|
+
corridor_buildings: List[Dict[str, Any]] = []
|
|
214
|
+
building_centroids: List[Tuple[float, float, float]] = [] # (lon, lat, radius_m)
|
|
215
|
+
seen_building_ids = set()
|
|
216
|
+
|
|
217
|
+
for b in buildings or []:
|
|
218
|
+
if not b.polygon or len(b.polygon) < 3:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
b_id = str(b.building_id)
|
|
222
|
+
if b_id in seen_building_ids:
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
valid_poly = [
|
|
226
|
+
(float(p[0]), float(p[1]))
|
|
227
|
+
for p in b.polygon
|
|
228
|
+
if len(p) >= 2 and math.isfinite(float(p[0])) and math.isfinite(float(p[1]))
|
|
229
|
+
]
|
|
230
|
+
if len(valid_poly) < 3:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
c_lon = sum(p[0] for p in valid_poly) / len(valid_poly)
|
|
234
|
+
c_lat = sum(p[1] for p in valid_poly) / len(valid_poly)
|
|
235
|
+
|
|
236
|
+
# Fast AABB skip
|
|
237
|
+
if c_lon < min_rx or c_lon > max_rx or c_lat < min_ry or c_lat > max_ry:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
d = point_to_multi_linestrings_distance_meters(c_lon, c_lat, all_lines_2d)
|
|
241
|
+
if d <= buf_m + 15.0: # Include footprint extent
|
|
242
|
+
seen_building_ids.add(b_id)
|
|
243
|
+
base_z = get_closest_multi_route_elevation(c_lon, c_lat, cleaned_routes, green_sampler)
|
|
244
|
+
h_m = float(b.height_m) if math.isfinite(b.height_m) and b.height_m > 0 else 12.0
|
|
245
|
+
lvls = int(b.levels) if b.levels > 0 else 4
|
|
246
|
+
|
|
247
|
+
# Calculate approximate footprint radius for tree collision avoidance
|
|
248
|
+
max_poly_rad = max(haversine_distance_2d((c_lon, c_lat), p) for p in valid_poly)
|
|
249
|
+
building_centroids.append((c_lon, c_lat, max_poly_rad + 3.0))
|
|
250
|
+
|
|
251
|
+
corridor_buildings.append(
|
|
252
|
+
{
|
|
253
|
+
"id": b_id,
|
|
254
|
+
"coordinates": [[round(p[0], 6), round(p[1], 6)] for p in valid_poly],
|
|
255
|
+
"height_m": round(h_m, 1),
|
|
256
|
+
"levels": lvls,
|
|
257
|
+
"type": b.building_type,
|
|
258
|
+
"base_elevation_m": round(base_z, 2),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# -------------------------------------------------------------
|
|
263
|
+
# 2. Extract Real & Volumetric 3D Trees in 30m corridor buffer
|
|
264
|
+
# -------------------------------------------------------------
|
|
265
|
+
corridor_trees: List[Dict[str, Any]] = []
|
|
266
|
+
placed_tree_positions: List[Tuple[float, float]] = []
|
|
267
|
+
|
|
268
|
+
# 2.1 First place real OSM trees & tree rows
|
|
269
|
+
if osm_trees:
|
|
270
|
+
for t in osm_trees:
|
|
271
|
+
if not (math.isfinite(t.lon) and math.isfinite(t.lat)):
|
|
272
|
+
continue
|
|
273
|
+
d = point_to_multi_linestrings_distance_meters(t.lon, t.lat, all_lines_2d)
|
|
274
|
+
if d > buf_m + 5.0 or d < 1.0:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Check building collision
|
|
278
|
+
collides_bld = any(
|
|
279
|
+
haversine_distance_2d((t.lon, t.lat), (b_lon, b_lat)) < b_rad
|
|
280
|
+
for b_lon, b_lat, b_rad in building_centroids
|
|
281
|
+
)
|
|
282
|
+
if collides_bld:
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
# Check tree-to-tree spacing
|
|
286
|
+
if any(
|
|
287
|
+
haversine_distance_2d((t.lon, t.lat), pos) < 5.0 for pos in placed_tree_positions
|
|
288
|
+
):
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
tree_z = get_closest_multi_route_elevation(t.lon, t.lat, cleaned_routes, green_sampler)
|
|
292
|
+
corridor_trees.append(
|
|
293
|
+
{
|
|
294
|
+
"id": f"osm_tree_{t.tree_id}",
|
|
295
|
+
"coordinates": [round(t.lon, 6), round(t.lat, 6)],
|
|
296
|
+
"base_elevation_m": round(tree_z, 2),
|
|
297
|
+
"height_m": round(t.height_m, 1),
|
|
298
|
+
"canopy_radius_m": round(t.canopy_radius_m, 1),
|
|
299
|
+
"trunk_height_m": round(t.height_m * 0.3, 1),
|
|
300
|
+
"trunk_radius_m": round(max(0.18, t.canopy_radius_m * 0.08), 2),
|
|
301
|
+
"tree_type": t.tree_type,
|
|
302
|
+
"species": t.species,
|
|
303
|
+
"greenery_index": 0.85,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
placed_tree_positions.append((t.lon, t.lat))
|
|
307
|
+
|
|
308
|
+
# 2.2 Place trees inside real OSM park/greenery polygons in corridor
|
|
309
|
+
if osm_parks:
|
|
310
|
+
for p in osm_parks:
|
|
311
|
+
if not p.polygon or len(p.polygon) < 3:
|
|
312
|
+
continue
|
|
313
|
+
poly_lons = [pt[0] for pt in p.polygon]
|
|
314
|
+
poly_lats = [pt[1] for pt in p.polygon]
|
|
315
|
+
c_lon = sum(poly_lons) / len(poly_lons)
|
|
316
|
+
c_lat = sum(poly_lats) / len(poly_lats)
|
|
317
|
+
d = point_to_multi_linestrings_distance_meters(c_lon, c_lat, all_lines_2d)
|
|
318
|
+
if d <= buf_m + 10.0:
|
|
319
|
+
# Add park trees
|
|
320
|
+
for pt in p.polygon:
|
|
321
|
+
pt_lon, pt_lat = pt[0], pt[1]
|
|
322
|
+
dist_to_route = point_to_multi_linestrings_distance_meters(
|
|
323
|
+
pt_lon, pt_lat, all_lines_2d
|
|
324
|
+
)
|
|
325
|
+
if 4.0 <= dist_to_route <= buf_m:
|
|
326
|
+
if not any(
|
|
327
|
+
haversine_distance_2d((pt_lon, pt_lat), pos) < 8.0
|
|
328
|
+
for pos in placed_tree_positions
|
|
329
|
+
):
|
|
330
|
+
if not any(
|
|
331
|
+
haversine_distance_2d((pt_lon, pt_lat), (b_lon, b_lat)) < b_rad
|
|
332
|
+
for b_lon, b_lat, b_rad in building_centroids
|
|
333
|
+
):
|
|
334
|
+
tree_z = get_closest_multi_route_elevation(
|
|
335
|
+
pt_lon, pt_lat, cleaned_routes, green_sampler
|
|
336
|
+
)
|
|
337
|
+
corridor_trees.append(
|
|
338
|
+
{
|
|
339
|
+
"id": f"park_tree_{len(corridor_trees) + 1}",
|
|
340
|
+
"coordinates": [round(pt_lon, 6), round(pt_lat, 6)],
|
|
341
|
+
"base_elevation_m": round(tree_z, 2),
|
|
342
|
+
"height_m": 9.0,
|
|
343
|
+
"canopy_radius_m": 3.8,
|
|
344
|
+
"trunk_height_m": 2.2,
|
|
345
|
+
"trunk_radius_m": 0.32,
|
|
346
|
+
"tree_type": "broadleaf",
|
|
347
|
+
"species": p.name or "park",
|
|
348
|
+
"greenery_index": 0.9,
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
placed_tree_positions.append((pt_lon, pt_lat))
|
|
352
|
+
|
|
353
|
+
# 2.3 Lateral procedural corridor greenery where trees are sparse
|
|
354
|
+
lateral_offsets = [-24.0, -16.0, -9.0, 9.0, 16.0, 24.0]
|
|
355
|
+
step_interval_m = 18.0
|
|
356
|
+
|
|
357
|
+
for route in cleaned_routes:
|
|
358
|
+
for i in range(len(route) - 1):
|
|
359
|
+
p1 = route[i]
|
|
360
|
+
p2 = route[i + 1]
|
|
361
|
+
seg_dist = haversine_distance_2d((p1[0], p1[1]), (p2[0], p2[1]))
|
|
362
|
+
if seg_dist < 2.0:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
num_steps = max(1, int(round(seg_dist / step_interval_m)))
|
|
366
|
+
cos_mid_lat = math.cos(math.radians((p1[1] + p2[1]) * 0.5))
|
|
367
|
+
|
|
368
|
+
# Direction vector in approximate meters
|
|
369
|
+
dx_m = (p2[0] - p1[0]) * 111320.0 * cos_mid_lat
|
|
370
|
+
dy_m = (p2[1] - p1[1]) * 110540.0
|
|
371
|
+
seg_len = math.sqrt(dx_m * dx_m + dy_m * dy_m)
|
|
372
|
+
if seg_len < 1e-6:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
# Unit normal vector (perpendicular to road direction)
|
|
376
|
+
nx = -dy_m / seg_len
|
|
377
|
+
ny = dx_m / seg_len
|
|
378
|
+
|
|
379
|
+
for step in range(num_steps):
|
|
380
|
+
# Named `fraction`, not `t`: `t` is the OsmTree loop variable above,
|
|
381
|
+
# and rebinding it here made the two impossible to tell apart.
|
|
382
|
+
fraction = (step + 0.5) / num_steps
|
|
383
|
+
center_lon = p1[0] + fraction * (p2[0] - p1[0])
|
|
384
|
+
center_lat = p1[1] + fraction * (p2[1] - p1[1])
|
|
385
|
+
|
|
386
|
+
for offset_m in lateral_offsets:
|
|
387
|
+
if abs(offset_m) > buf_m:
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
# Offset in degrees
|
|
391
|
+
cand_lon = center_lon + (nx * offset_m) / (
|
|
392
|
+
111320.0 * math.cos(math.radians(center_lat))
|
|
393
|
+
)
|
|
394
|
+
cand_lat = center_lat + (ny * offset_m) / 110540.0
|
|
395
|
+
|
|
396
|
+
# 1. Check distance to route paths: must be >= 4.5m and <= 30m
|
|
397
|
+
dist_to_route = point_to_multi_linestrings_distance_meters(
|
|
398
|
+
cand_lon, cand_lat, all_lines_2d
|
|
399
|
+
)
|
|
400
|
+
if dist_to_route < 4.5 or dist_to_route > buf_m + 2.0:
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
# 2. Check collision with building footprints
|
|
404
|
+
collides_building = False
|
|
405
|
+
for b_lon, b_lat, b_rad in building_centroids:
|
|
406
|
+
if haversine_distance_2d((cand_lon, cand_lat), (b_lon, b_lat)) < b_rad:
|
|
407
|
+
collides_building = True
|
|
408
|
+
break
|
|
409
|
+
if collides_building:
|
|
410
|
+
continue
|
|
411
|
+
|
|
412
|
+
# 3. Check spacing against already placed trees (min 9m spacing)
|
|
413
|
+
collides_tree = False
|
|
414
|
+
for t_pos in placed_tree_positions:
|
|
415
|
+
if haversine_distance_2d((cand_lon, cand_lat), t_pos) < 9.0:
|
|
416
|
+
collides_tree = True
|
|
417
|
+
break
|
|
418
|
+
if collides_tree:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# 4. Greenery sampling (if green_sampler is available)
|
|
422
|
+
green_val = 0.55
|
|
423
|
+
if green_sampler is not None and hasattr(green_sampler, "sample_greenery"):
|
|
424
|
+
try:
|
|
425
|
+
sampled_green = green_sampler.sample_greenery(cand_lon, cand_lat)
|
|
426
|
+
except Exception:
|
|
427
|
+
sampled_green = None
|
|
428
|
+
if sampled_green is not None and math.isfinite(sampled_green):
|
|
429
|
+
green_val = float(sampled_green)
|
|
430
|
+
|
|
431
|
+
if green_val < 0.15:
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# 5. Deterministic hash variation for realistic tree morphology
|
|
435
|
+
coord_key = (round(cand_lon, 5), round(cand_lat, 5))
|
|
436
|
+
h_val = abs(hash(coord_key))
|
|
437
|
+
|
|
438
|
+
# Tree dimensions modulated by greenery and hash
|
|
439
|
+
height_m = round(5.5 + (h_val % 45) * 0.12 + green_val * 2.8, 1)
|
|
440
|
+
canopy_radius_m = round(2.0 + (h_val % 25) * 0.09 + green_val * 1.3, 1)
|
|
441
|
+
trunk_height_m = round(1.5 + (h_val % 18) * 0.08, 1)
|
|
442
|
+
trunk_radius_m = round(0.20 + (h_val % 12) * 0.015, 2)
|
|
443
|
+
tree_types = ["deciduous", "broadleaf", "conifer", "pine"]
|
|
444
|
+
tree_type = tree_types[h_val % len(tree_types)]
|
|
445
|
+
|
|
446
|
+
tree_z = get_closest_multi_route_elevation(
|
|
447
|
+
cand_lon, cand_lat, cleaned_routes, green_sampler
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
tree_id = f"tree_{len(corridor_trees) + 1}"
|
|
451
|
+
placed_tree_positions.append((cand_lon, cand_lat))
|
|
452
|
+
|
|
453
|
+
corridor_trees.append(
|
|
454
|
+
{
|
|
455
|
+
"id": tree_id,
|
|
456
|
+
"coordinates": [round(cand_lon, 6), round(cand_lat, 6)],
|
|
457
|
+
"base_elevation_m": round(tree_z, 2),
|
|
458
|
+
"height_m": height_m,
|
|
459
|
+
"canopy_radius_m": canopy_radius_m,
|
|
460
|
+
"trunk_height_m": trunk_height_m,
|
|
461
|
+
"trunk_radius_m": trunk_radius_m,
|
|
462
|
+
"tree_type": tree_type,
|
|
463
|
+
"greenery_index": round(green_val, 2),
|
|
464
|
+
}
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return corridor_buildings, corridor_trees
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def filter_buildings_in_corridor(
|
|
471
|
+
route_coords: Sequence[Tuple[float, float, float]],
|
|
472
|
+
buildings: Sequence[OsmBuilding],
|
|
473
|
+
buffer_meters: float = 30.0,
|
|
474
|
+
) -> List[Dict[str, Any]]:
|
|
475
|
+
"""Extract real OSM buildings that fall within the specified corridor buffer around the route."""
|
|
476
|
+
blds, _ = filter_corridor_assets_multi_route(
|
|
477
|
+
[route_coords], buildings, buffer_meters=buffer_meters
|
|
478
|
+
)
|
|
479
|
+
return blds
|