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.
Files changed (42) hide show
  1. zero2route3d/__init__.py +212 -0
  2. zero2route3d/accessibility_equity.py +214 -0
  3. zero2route3d/ahp_engine.py +117 -0
  4. zero2route3d/api.py +244 -0
  5. zero2route3d/basemap.py +44 -0
  6. zero2route3d/cli.py +251 -0
  7. zero2route3d/copernicus_dem.py +76 -0
  8. zero2route3d/copernicus_eo_suite.py +443 -0
  9. zero2route3d/dem_fetcher.py +223 -0
  10. zero2route3d/environmental_raster.py +411 -0
  11. zero2route3d/evacuation.py +121 -0
  12. zero2route3d/html_bundler.py +603 -0
  13. zero2route3d/input_validation.py +100 -0
  14. zero2route3d/integrations.py +260 -0
  15. zero2route3d/isochrone_engine.py +220 -0
  16. zero2route3d/kinematics.py +383 -0
  17. zero2route3d/local_server.py +116 -0
  18. zero2route3d/map_matching_3d.py +374 -0
  19. zero2route3d/micro_elevation.py +264 -0
  20. zero2route3d/mobility_profiles.py +517 -0
  21. zero2route3d/multimodal.py +172 -0
  22. zero2route3d/network_source.py +512 -0
  23. zero2route3d/osm_downloader.py +328 -0
  24. zero2route3d/osm_styling.py +351 -0
  25. zero2route3d/pareto_router.py +331 -0
  26. zero2route3d/plotting.py +292 -0
  27. zero2route3d/profile_dxf.py +231 -0
  28. zero2route3d/profile_stats.py +648 -0
  29. zero2route3d/py.typed +1 -0
  30. zero2route3d/qml_generator.py +189 -0
  31. zero2route3d/raster_source.py +206 -0
  32. zero2route3d/report_generator.py +154 -0
  33. zero2route3d/route_corridor_3d.py +479 -0
  34. zero2route3d/routing_engine.py +747 -0
  35. zero2route3d/solar_shadow.py +157 -0
  36. zero2route3d/tsp_solver.py +78 -0
  37. zero2route3d_sdk-0.2.0.dist-info/METADATA +251 -0
  38. zero2route3d_sdk-0.2.0.dist-info/RECORD +42 -0
  39. zero2route3d_sdk-0.2.0.dist-info/WHEEL +5 -0
  40. zero2route3d_sdk-0.2.0.dist-info/entry_points.txt +2 -0
  41. zero2route3d_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. zero2route3d_sdk-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,383 @@
1
+ """Biomechanical, vehicle, and microclimate kinematic models for 3D route analysis.
2
+
3
+ Includes:
4
+ - Geodesic 3D distance with elevation delta
5
+ - Tobler's Hiking Function for slope-aware pedestrian velocity
6
+ - Minetti (2002) metabolic energy expenditure equation
7
+ - Aerodynamic drag & wind resistance equations
8
+ - Surface rolling resistance coefficients (C_rr)
9
+ - Senior fatigue and metabolic degradation over distance
10
+ - Solar aspect and dynamic sun angle irradiance model
11
+ - UTCI-equivalent thermal comfort and heat stress formulas
12
+ - Cyclist climbing power and grade resistance curves
13
+ - E-Scooter traction and battery drain models
14
+ - Road hierarchy vehicle speed modeling
15
+ - AnimatedAvatar for multi-modal simulation interpolation
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import bisect
21
+ import math
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
24
+
25
+
26
+ @dataclass
27
+ class AnimatedAvatar:
28
+ """State and precomputed kinematics for a single moving profile on canvas."""
29
+
30
+ profile_key: str
31
+ profile_name: str
32
+ color_hex: str
33
+ coordinates_3d: List[Tuple[float, float, float]]
34
+ cumulative_distances_m: List[float] = field(default_factory=list)
35
+ timestamps_s: List[float] = field(default_factory=list)
36
+ total_duration_s: float = 0.0
37
+ total_distance_m: float = 0.0
38
+ marker: Optional[Any] = None
39
+
40
+ def interpolate_position(self, target_time_s: float) -> Tuple[float, float, float]:
41
+ """Interpolate (lon, lat, elevation) at given elapsed simulation time."""
42
+ if not self.coordinates_3d:
43
+ return 0.0, 0.0, 0.0
44
+ if (
45
+ not math.isfinite(target_time_s)
46
+ or target_time_s <= 0.0
47
+ or len(self.coordinates_3d) == 1
48
+ ):
49
+ return self.coordinates_3d[0]
50
+ if target_time_s >= self.total_duration_s:
51
+ return self.coordinates_3d[-1]
52
+
53
+ if not self.timestamps_s or len(self.timestamps_s) < len(self.coordinates_3d):
54
+ return self.coordinates_3d[0]
55
+
56
+ # Binary search for interval
57
+ idx = bisect.bisect_right(self.timestamps_s, target_time_s)
58
+ if idx <= 0:
59
+ return self.coordinates_3d[0]
60
+ if idx >= len(self.coordinates_3d):
61
+ return self.coordinates_3d[-1]
62
+
63
+ t0 = self.timestamps_s[idx - 1]
64
+ t1 = self.timestamps_s[idx]
65
+ dt = t1 - t0
66
+ alpha = (target_time_s - t0) / dt if dt > 1e-6 else 0.0
67
+ alpha = max(0.0, min(1.0, alpha))
68
+
69
+ p0 = self.coordinates_3d[idx - 1]
70
+ p1 = self.coordinates_3d[idx]
71
+
72
+ lon = p0[0] + alpha * (p1[0] - p0[0])
73
+ lat = p0[1] + alpha * (p1[1] - p0[1])
74
+ ele = p0[2] + alpha * (p1[2] - p0[2])
75
+ return lon, lat, ele
76
+
77
+
78
+ # Surface rolling resistance coefficients (C_rr)
79
+ ROLLING_RESISTANCE: Dict[str, float] = {
80
+ "asphalt": 0.004,
81
+ "concrete": 0.005,
82
+ "paved": 0.006,
83
+ "paving_stones": 0.012,
84
+ "cobblestone": 0.018,
85
+ "gravel": 0.022,
86
+ "compacted": 0.015,
87
+ "dirt": 0.030,
88
+ "ground": 0.035,
89
+ "grass": 0.045,
90
+ "sand": 0.080,
91
+ }
92
+
93
+
94
+ def haversine_distance_2d(coord1: Sequence[float], coord2: Sequence[float]) -> float:
95
+ """Calculate the geodesic 2D distance in meters between two (lon, lat) points."""
96
+ if not coord1 or not coord2 or len(coord1) < 2 or len(coord2) < 2:
97
+ return 0.0
98
+
99
+ lon1, lat1 = float(coord1[0]), float(coord1[1])
100
+ lon2, lat2 = float(coord2[0]), float(coord2[1])
101
+
102
+ if not (
103
+ math.isfinite(lon1) and math.isfinite(lat1) and math.isfinite(lon2) and math.isfinite(lat2)
104
+ ):
105
+ return 0.0
106
+
107
+ if abs(lon1 - lon2) < 1e-11 and abs(lat1 - lat2) < 1e-11:
108
+ return 0.0
109
+
110
+ radius = 6371008.8 # WGS84 mean earth radius in meters
111
+ phi1 = math.radians(lat1)
112
+ phi2 = math.radians(lat2)
113
+ delta_phi = math.radians(lat2 - lat1)
114
+ delta_lambda = math.radians(lon2 - lon1)
115
+
116
+ a = (
117
+ math.sin(delta_phi / 2.0) ** 2
118
+ + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2
119
+ )
120
+ a = max(0.0, min(1.0, a))
121
+ c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1.0 - a)))
122
+ return radius * c
123
+
124
+
125
+ def haversine_distance_3d(coord1: Sequence[float], coord2: Sequence[float]) -> float:
126
+ """Calculate the 3D distance in meters considering elevation (Z coordinate)."""
127
+ d_2d = haversine_distance_2d(coord1, coord2)
128
+ z1 = float(coord1[2]) if len(coord1) > 2 and math.isfinite(float(coord1[2])) else 0.0
129
+ z2 = float(coord2[2]) if len(coord2) > 2 and math.isfinite(float(coord2[2])) else 0.0
130
+ dz = z2 - z1
131
+ return math.hypot(d_2d, dz)
132
+
133
+
134
+ def tobler_walking_speed(slope_fraction: float, base_speed_kmh: float = 5.0) -> float:
135
+ """Compute walking speed (km/h) as a function of slope (dh / dx) using Tobler's curve.
136
+
137
+ Standard Tobler formula: W = 6.0 * exp(-3.5 * |s + 0.05|) km/h where s is slope fraction.
138
+ Scaled relative to the specific profile's base walking speed.
139
+ """
140
+ s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
141
+ s = max(-0.60, min(0.60, s_val))
142
+ b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 5.0
143
+ scale = max(0.2, b_spd / 5.0)
144
+ speed = 6.0 * math.exp(-3.5 * abs(s + 0.05)) * scale
145
+ return max(0.5, min(8.0, speed))
146
+
147
+
148
+ def minetti_energy_cost(
149
+ slope_fraction: float,
150
+ mass_kg: float = 70.0,
151
+ distance_m: float = 100.0,
152
+ ) -> Tuple[float, float]:
153
+ """Calculate metabolic energy expenditure using Minetti (2002) polynomial.
154
+
155
+ Returns:
156
+ Tuple of (energy_in_joules, energy_in_kilocalories).
157
+ """
158
+ s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
159
+ s = max(-0.50, min(0.50, s_val))
160
+ m = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
161
+ d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
162
+
163
+ cost_j_kg_m = 280.5 * (s**5) - 58.7 * (s**4) - 76.8 * (s**3) + 51.9 * (s**2) + 19.6 * s + 2.5
164
+ # Minetti's polynomial is fitted over -0.45 <= i <= +0.45; outside that range it
165
+ # turns non-physical. Clamp to the smallest cost the fit itself produces rather
166
+ # than to an invented floor.
167
+ cost_j_kg_m = max(0.6, cost_j_kg_m)
168
+ total_joules = cost_j_kg_m * m * d
169
+ total_kcal = total_joules / 4184.0
170
+ return total_joules, total_kcal
171
+
172
+
173
+ def senior_fatigue_decay(distance_m: float, accumulated_climb_m: float) -> float:
174
+ """Calculate velocity decay multiplier (1.0 down to 0.45) for elderly pedestrians due to fatigue."""
175
+ d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
176
+ climb = (
177
+ float(accumulated_climb_m)
178
+ if math.isfinite(accumulated_climb_m) and accumulated_climb_m >= 0
179
+ else 0.0
180
+ )
181
+ dist_km = d / 1000.0
182
+ fatigue_score = (dist_km * 0.12) + (climb / 100.0 * 0.25)
183
+ decay = math.exp(-0.35 * fatigue_score)
184
+ return max(0.45, min(1.0, decay))
185
+
186
+
187
+ def aerodynamic_drag_power(
188
+ velocity_kmh: float,
189
+ frontal_area_m2: float = 0.55,
190
+ drag_coeff: float = 0.88,
191
+ air_density: float = 1.225,
192
+ ) -> float:
193
+ """Calculate aerodynamic power resistance in Watts (P_aero = 0.5 * rho * C_d * A * v^3)."""
194
+ v_kmh = float(velocity_kmh) if math.isfinite(velocity_kmh) and velocity_kmh >= 0 else 0.0
195
+ v_ms = max(0.0, v_kmh / 3.6)
196
+ return 0.5 * air_density * drag_coeff * frontal_area_m2 * (v_ms**3)
197
+
198
+
199
+ def rolling_resistance_force(
200
+ mass_kg: float,
201
+ slope_fraction: float,
202
+ surface_type: str = "asphalt",
203
+ ) -> float:
204
+ """Calculate rolling friction resistance force in Newtons (F_roll = C_rr * m * g * cos(theta))."""
205
+ m = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
206
+ s = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
207
+ c_rr = ROLLING_RESISTANCE.get(str(surface_type).lower(), 0.005)
208
+ theta = math.atan(abs(s))
209
+ g = 9.80665
210
+ return c_rr * m * g * math.cos(theta)
211
+
212
+
213
+ def cycling_energy_cost(
214
+ slope_fraction: float,
215
+ mass_kg: float = 70.0,
216
+ distance_m: float = 0.0,
217
+ bike_mass_kg: float = 15.0,
218
+ surface: str = "asphalt",
219
+ gross_efficiency: float = 0.24,
220
+ ) -> Tuple[float, float]:
221
+ """Metabolic energy for cycling a segment, from a real power balance.
222
+
223
+ Mechanical work is the sum of the gravitational, rolling and aerodynamic terms
224
+ over the segment; metabolic energy is that work divided by cycling gross
225
+ efficiency (~0.20-0.25 in the literature). Descents recover no metabolic energy
226
+ but still cost basal effort, so the result is floored at zero work.
227
+
228
+ Returns (joules, kilocalories).
229
+ """
230
+ s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
231
+ s = max(-0.30, min(0.30, s_val))
232
+ rider = float(mass_kg) if math.isfinite(mass_kg) and mass_kg > 0 else 70.0
233
+ bike = float(bike_mass_kg) if math.isfinite(bike_mass_kg) and bike_mass_kg > 0 else 15.0
234
+ d = float(distance_m) if math.isfinite(distance_m) and distance_m >= 0 else 0.0
235
+ eff = (
236
+ float(gross_efficiency)
237
+ if math.isfinite(gross_efficiency) and 0.05 < gross_efficiency < 1.0
238
+ else 0.24
239
+ )
240
+ total_mass = rider + bike
241
+
242
+ theta = math.atan(s)
243
+ g = 9.80665
244
+
245
+ f_grav = total_mass * g * math.sin(theta)
246
+ f_roll = rolling_resistance_force(total_mass, s, surface)
247
+
248
+ speed_kmh = cyclist_speed(s, total_mass_kg=total_mass, surface=surface)
249
+ p_aero = aerodynamic_drag_power(speed_kmh)
250
+ v_ms = max(0.1, speed_kmh / 3.6)
251
+ f_aero = p_aero / v_ms
252
+
253
+ f_total = f_grav + f_roll + f_aero
254
+ mechanical_j = max(0.0, f_total) * d
255
+ metabolic_j = mechanical_j / eff
256
+ return metabolic_j, metabolic_j / 4184.0
257
+
258
+
259
+ def cyclist_speed(
260
+ slope_fraction: float,
261
+ base_speed_kmh: float = 18.0,
262
+ rider_power_watts: float = 160.0,
263
+ total_mass_kg: float = 85.0,
264
+ surface: str = "asphalt",
265
+ ) -> float:
266
+ """Calculate physics-based bicycle speed (km/h) accounting for gradient, rolling resistance, and drag."""
267
+ s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
268
+ s = max(-0.25, min(0.25, s_val))
269
+ b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 18.0
270
+ p_watts = (
271
+ float(rider_power_watts)
272
+ if math.isfinite(rider_power_watts) and rider_power_watts > 0
273
+ else 160.0
274
+ )
275
+ m_kg = float(total_mass_kg) if math.isfinite(total_mass_kg) and total_mass_kg > 0 else 85.0
276
+
277
+ if s < -0.02:
278
+ # Downhill with coasting limit
279
+ speed = b_spd * (1.0 + abs(s) * 2.2)
280
+ return min(45.0, speed)
281
+
282
+ # Uphill power balance: P = (F_gravity + F_roll + F_aero) * v
283
+ g = 9.80665
284
+ f_grav = m_kg * g * math.sin(math.atan(s))
285
+ f_roll = rolling_resistance_force(m_kg, s, surface)
286
+ f_resist = max(1.0, f_grav + f_roll)
287
+
288
+ # Effective speed from mechanical power: v = P / F
289
+ v_ms = p_watts / f_resist
290
+ speed_kmh = v_ms * 3.6
291
+ return max(3.5, min(b_spd * 1.5, speed_kmh))
292
+
293
+
294
+ def scooter_speed(slope_fraction: float, base_speed_kmh: float = 20.0) -> float:
295
+ """Calculate micromobility/e-scooter speed (km/h) with motor torque reduction on slopes."""
296
+ s_val = float(slope_fraction) if math.isfinite(slope_fraction) else 0.0
297
+ s = max(-0.20, min(0.20, s_val))
298
+ b_spd = float(base_speed_kmh) if math.isfinite(base_speed_kmh) and base_speed_kmh > 0 else 20.0
299
+
300
+ if s > 0.12:
301
+ return 4.0 # grade exceeds motor climbing capability
302
+ if s > 0:
303
+ speed = b_spd * max(0.25, 1.0 - (s / 0.14) ** 1.5)
304
+ return max(4.0, speed)
305
+ return min(25.0, b_spd * (1.0 + abs(s) * 0.5))
306
+
307
+
308
+ def vehicle_free_flow_speed(
309
+ hierarchy_rank: int,
310
+ lanes: int = 2,
311
+ slope_pct: float = 0.0,
312
+ base_vehicle_speed_kmh: float = 50.0,
313
+ ) -> float:
314
+ """Modelled vehicle speed in km/h based on functional road classification, vehicle profile, lanes, and grade."""
315
+ base_speeds = {
316
+ 1: 80.0, # Motorway / Primary Arterial
317
+ 2: 60.0, # Secondary Arterial
318
+ 3: 45.0, # Collector / Major Urban
319
+ 4: 30.0, # Local Residential Street
320
+ 5: 20.0, # Narrow Alley / Service Way
321
+ }
322
+ road_speed = base_speeds.get(hierarchy_rank, 30.0)
323
+ v_base = (
324
+ float(base_vehicle_speed_kmh)
325
+ if math.isfinite(base_vehicle_speed_kmh) and base_vehicle_speed_kmh > 0
326
+ else 50.0
327
+ )
328
+ speed = road_speed * (v_base / 50.0)
329
+ l_count = int(lanes) if math.isfinite(lanes) and lanes > 0 else 2
330
+ speed += min(10.0, max(0, l_count - 2) * 4.0)
331
+ s_pct = float(slope_pct) if math.isfinite(slope_pct) else 0.0
332
+ routed_slope = min(35.0, max(0.0, abs(s_pct)))
333
+ speed *= max(0.50, 1.0 - (routed_slope / 100.0) * 0.85)
334
+ return max(10.0, speed)
335
+
336
+
337
+ def solar_irradiance_aspect_factor(
338
+ aspect_deg: float,
339
+ slope_pct: float,
340
+ sun_azimuth_deg: float = 180.0, # Solar noon south
341
+ sun_elevation_deg: float = 55.0,
342
+ ) -> float:
343
+ """Calculate relative solar irradiance factor (0.0 to 1.0) based on slope angle and sun position."""
344
+ s_pct = float(slope_pct) if math.isfinite(slope_pct) else 0.0
345
+ asp_deg = float(aspect_deg) if math.isfinite(aspect_deg) else 180.0
346
+ sun_az = float(sun_azimuth_deg) if math.isfinite(sun_azimuth_deg) else 180.0
347
+ sun_el = float(sun_elevation_deg) if math.isfinite(sun_elevation_deg) else 55.0
348
+
349
+ slope_rad = math.atan(abs(s_pct) / 100.0)
350
+ aspect_rad = math.radians(asp_deg)
351
+ sun_az_rad = math.radians(sun_az)
352
+ sun_el_rad = math.radians(sun_el)
353
+
354
+ # Cosine of incidence angle
355
+ cos_inc = math.sin(sun_el_rad) * math.cos(slope_rad) + math.cos(sun_el_rad) * math.sin(
356
+ slope_rad
357
+ ) * math.cos(sun_az_rad - aspect_rad)
358
+ if not math.isfinite(cos_inc):
359
+ return 0.5
360
+ return max(0.0, min(1.0, cos_inc))
361
+
362
+
363
+ def universal_thermal_comfort_utci(
364
+ temp_c: float,
365
+ relative_humidity_pct: float = 50.0,
366
+ wind_speed_ms: float = 1.0,
367
+ mean_radiant_temp_c: float = 35.0,
368
+ ) -> float:
369
+ """Approximation of Universal Thermal Climate Index (UTCI in Celsius).
370
+
371
+ Returns normalized thermal strain score (0.0 = comfortable/cool, 1.0 = severe heat stress).
372
+ """
373
+ t_c = float(temp_c) if math.isfinite(temp_c) else 25.0
374
+ mrt = float(mean_radiant_temp_c) if math.isfinite(mean_radiant_temp_c) else 35.0
375
+ w_ms = float(wind_speed_ms) if math.isfinite(wind_speed_ms) and wind_speed_ms >= 0 else 1.0
376
+
377
+ # Simplified empirical UTCI offset
378
+ utci = t_c + 0.33 * (mrt - t_c) - 0.70 * math.sqrt(max(0.1, w_ms))
379
+ if not math.isfinite(utci):
380
+ return 0.5
381
+ # Normalize: 18C = 0.0 (no stress), 38C+ = 1.0 (extreme heat stress)
382
+ norm_stress = (utci - 18.0) / 20.0
383
+ return max(0.0, min(1.0, norm_stress))
@@ -0,0 +1,116 @@
1
+ """Localhost HTTP server for serving 02Route 3D WebGL Studio to QWebEngineView."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import functools
7
+ import http.server
8
+ import mimetypes
9
+ import socket
10
+ import socketserver
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ class QuietCorsHandler(http.server.SimpleHTTPRequestHandler):
17
+ """Simple HTTP Request Handler with CORS headers and quiet logging."""
18
+
19
+ def log_message(self, fmt: str, *args: object) -> None:
20
+ return None
21
+
22
+ def end_headers(self) -> None:
23
+ self.send_header("Access-Control-Allow-Origin", "*")
24
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
25
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
26
+ self.send_header("Cache-Control", "no-cache")
27
+ super().end_headers()
28
+
29
+ def do_OPTIONS(self) -> None: # noqa: N802
30
+ self.send_response(204)
31
+ self.end_headers()
32
+
33
+
34
+ class ReusableTcpServer(socketserver.ThreadingTCPServer):
35
+ allow_reuse_address = True
36
+ daemon_threads = True
37
+
38
+
39
+ def _port_is_open(host: str, port: int) -> bool:
40
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
41
+ sock.settimeout(0.2)
42
+ return sock.connect_ex((host, port)) == 0
43
+
44
+
45
+ class Route3DLocalServer:
46
+ """Lightweight 127.0.0.1 HTTP server for serving WebGL ES-modules without file:// CORS blocks."""
47
+
48
+ def __init__(
49
+ self, web_root: Path, host: str = "127.0.0.1", start_port: int = 8920, end_port: int = 8950
50
+ ) -> None:
51
+ self.web_root = Path(web_root).resolve()
52
+ if not self.web_root.is_dir():
53
+ raise FileNotFoundError(f"WebGL assets directory does not exist: {self.web_root}")
54
+ self.host = host
55
+ self.start_port = max(0, min(65535, int(start_port)))
56
+ self.end_port = max(self.start_port, min(65535, int(end_port)))
57
+ self.port: Optional[int] = None
58
+ self._httpd: Optional[ReusableTcpServer] = None
59
+ self._thread: Optional[threading.Thread] = None
60
+
61
+ mimetypes.add_type("application/javascript", ".js")
62
+ mimetypes.add_type("application/json", ".geojson")
63
+ mimetypes.add_type("text/css", ".css")
64
+ mimetypes.add_type("text/html", ".html")
65
+
66
+ @property
67
+ def is_running(self) -> bool:
68
+ return self._httpd is not None and self._thread is not None and self._thread.is_alive()
69
+
70
+ @property
71
+ def url(self) -> str:
72
+ if self.port is None:
73
+ return ""
74
+ return f"http://{self.host}:{self.port}/index.html"
75
+
76
+ def start(self) -> str:
77
+ if self.is_running:
78
+ return self.url
79
+
80
+ handler = functools.partial(QuietCorsHandler, directory=str(self.web_root))
81
+ for port in range(self.start_port, self.end_port + 1):
82
+ if _port_is_open(self.host, port):
83
+ continue
84
+ try:
85
+ self._httpd = ReusableTcpServer((self.host, port), handler)
86
+ self.port = port
87
+ break
88
+ except OSError:
89
+ continue
90
+
91
+ if self._httpd is None:
92
+ # Fallback to random free port assigned by OS
93
+ self._httpd = ReusableTcpServer((self.host, 0), handler)
94
+ self.port = self._httpd.server_address[1]
95
+
96
+ self._thread = threading.Thread(
97
+ target=self._httpd.serve_forever, name="Route3DServer", daemon=True
98
+ )
99
+ self._thread.start()
100
+ return self.url
101
+
102
+ def stop(self) -> None:
103
+ if self._httpd is None:
104
+ return
105
+ httpd = self._httpd
106
+ thread = self._thread
107
+ self._httpd = None
108
+ self._thread = None
109
+ self.port = None
110
+
111
+ with contextlib.suppress(Exception):
112
+ httpd.shutdown()
113
+ with contextlib.suppress(Exception):
114
+ httpd.server_close()
115
+ if thread and thread.is_alive():
116
+ thread.join(timeout=2.0)