voxcity 0.6.15__py3-none-any.whl → 0.7.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.
- voxcity/__init__.py +14 -8
- voxcity/downloader/__init__.py +2 -1
- voxcity/downloader/citygml.py +32 -18
- voxcity/downloader/gba.py +210 -0
- voxcity/downloader/gee.py +5 -1
- voxcity/downloader/mbfp.py +1 -1
- voxcity/downloader/oemj.py +80 -8
- voxcity/downloader/osm.py +23 -7
- voxcity/downloader/overture.py +26 -1
- voxcity/downloader/utils.py +73 -73
- voxcity/errors.py +30 -0
- voxcity/exporter/__init__.py +13 -4
- voxcity/exporter/cityles.py +633 -535
- voxcity/exporter/envimet.py +728 -708
- voxcity/exporter/magicavoxel.py +334 -297
- voxcity/exporter/netcdf.py +238 -0
- voxcity/exporter/obj.py +1481 -655
- voxcity/generator/__init__.py +44 -0
- voxcity/generator/api.py +675 -0
- voxcity/generator/grids.py +379 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/voxelizer.py +380 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +62 -12
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +806 -790
- voxcity/geoprocessor/network.py +708 -679
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +428 -0
- voxcity/geoprocessor/raster/canopy.py +258 -0
- voxcity/geoprocessor/raster/core.py +150 -0
- voxcity/geoprocessor/raster/export.py +93 -0
- voxcity/geoprocessor/raster/landcover.py +156 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +18 -14
- voxcity/models.py +113 -0
- voxcity/simulator/common/__init__.py +22 -0
- voxcity/simulator/common/geometry.py +98 -0
- voxcity/simulator/common/raytracing.py +450 -0
- voxcity/simulator/solar/__init__.py +43 -0
- voxcity/simulator/solar/integration.py +336 -0
- voxcity/simulator/solar/kernels.py +62 -0
- voxcity/simulator/solar/radiation.py +648 -0
- voxcity/simulator/solar/temporal.py +434 -0
- voxcity/simulator/view.py +36 -2286
- voxcity/simulator/visibility/__init__.py +29 -0
- voxcity/simulator/visibility/landmark.py +392 -0
- voxcity/simulator/visibility/view.py +508 -0
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/weather/__init__.py +26 -0
- voxcity/utils/weather/epw.py +146 -0
- voxcity/utils/weather/files.py +36 -0
- voxcity/utils/weather/onebuilding.py +486 -0
- voxcity/visualizer/__init__.py +24 -0
- voxcity/visualizer/builder.py +43 -0
- voxcity/visualizer/grids.py +141 -0
- voxcity/visualizer/maps.py +187 -0
- voxcity/visualizer/palette.py +228 -0
- voxcity/visualizer/renderer.py +928 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
- voxcity-0.7.0.dist-info/RECORD +77 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
- voxcity/generator.py +0 -1137
- voxcity/geoprocessor/grid.py +0 -1568
- voxcity/geoprocessor/polygon.py +0 -1344
- voxcity/simulator/solar.py +0 -2329
- voxcity/utils/visualization.py +0 -2660
- voxcity/utils/weather.py +0 -817
- voxcity-0.6.15.dist-info/RECORD +0 -37
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import List, Tuple
|
|
3
|
+
from shapely.geometry import Polygon
|
|
4
|
+
from affine import Affine
|
|
5
|
+
from pyproj import Geod, Transformer, CRS
|
|
6
|
+
import rasterio
|
|
7
|
+
from scipy.interpolate import griddata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_height_grid_from_geotiff_polygon(
|
|
11
|
+
tiff_path: str,
|
|
12
|
+
mesh_size: float,
|
|
13
|
+
polygon: List[Tuple[float, float]]
|
|
14
|
+
) -> np.ndarray:
|
|
15
|
+
"""
|
|
16
|
+
Create a height grid from a GeoTIFF file within a polygon boundary.
|
|
17
|
+
"""
|
|
18
|
+
with rasterio.open(tiff_path) as src:
|
|
19
|
+
img = src.read(1)
|
|
20
|
+
left, bottom, right, top = src.bounds
|
|
21
|
+
|
|
22
|
+
poly = Polygon(polygon)
|
|
23
|
+
left_wgs84, bottom_wgs84, right_wgs84, top_wgs84 = poly.bounds
|
|
24
|
+
|
|
25
|
+
geod = Geod(ellps="WGS84")
|
|
26
|
+
_, _, width = geod.inv(left_wgs84, bottom_wgs84, right_wgs84, bottom_wgs84)
|
|
27
|
+
_, _, height = geod.inv(left_wgs84, bottom_wgs84, left_wgs84, top_wgs84)
|
|
28
|
+
|
|
29
|
+
num_cells_x = int(width / mesh_size + 0.5)
|
|
30
|
+
num_cells_y = int(height / mesh_size + 0.5)
|
|
31
|
+
|
|
32
|
+
adjusted_mesh_size_x = (right - left) / num_cells_x
|
|
33
|
+
adjusted_mesh_size_y = (top - bottom) / num_cells_y
|
|
34
|
+
|
|
35
|
+
new_affine = Affine(adjusted_mesh_size_x, 0, left, 0, -adjusted_mesh_size_y, top)
|
|
36
|
+
|
|
37
|
+
cols, rows = np.meshgrid(np.arange(num_cells_x), np.arange(num_cells_y))
|
|
38
|
+
xs, ys = new_affine * (cols, rows)
|
|
39
|
+
xs_flat, ys_flat = xs.flatten(), ys.flatten()
|
|
40
|
+
|
|
41
|
+
row, col = src.index(xs_flat, ys_flat)
|
|
42
|
+
row, col = np.array(row), np.array(col)
|
|
43
|
+
|
|
44
|
+
valid = (row >= 0) & (row < src.height) & (col >= 0) & (col < src.width)
|
|
45
|
+
row, col = row[valid], col[valid]
|
|
46
|
+
|
|
47
|
+
grid = np.full((num_cells_y, num_cells_x), np.nan)
|
|
48
|
+
flat_indices = np.ravel_multi_index((row, col), img.shape)
|
|
49
|
+
np.put(grid, np.ravel_multi_index((rows.flatten()[valid], cols.flatten()[valid]), grid.shape), img.flat[flat_indices])
|
|
50
|
+
|
|
51
|
+
return np.flipud(grid)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_dem_grid_from_geotiff_polygon(tiff_path, mesh_size, rectangle_vertices, dem_interpolation=False):
|
|
55
|
+
"""
|
|
56
|
+
Create a Digital Elevation Model (DEM) grid from a GeoTIFF within a polygon boundary.
|
|
57
|
+
"""
|
|
58
|
+
from shapely.geometry import Polygon as ShapelyPolygon
|
|
59
|
+
from ..utils import convert_format_lat_lon
|
|
60
|
+
|
|
61
|
+
converted_coords = convert_format_lat_lon(rectangle_vertices)
|
|
62
|
+
roi_shapely = ShapelyPolygon(converted_coords)
|
|
63
|
+
|
|
64
|
+
with rasterio.open(tiff_path) as src:
|
|
65
|
+
dem = src.read(1)
|
|
66
|
+
dem = np.where(dem < -1000, 0, dem)
|
|
67
|
+
transform = src.transform
|
|
68
|
+
src_crs = src.crs
|
|
69
|
+
|
|
70
|
+
if src_crs.to_epsg() != 3857:
|
|
71
|
+
transformer_to_3857 = Transformer.from_crs(src_crs, CRS.from_epsg(3857), always_xy=True)
|
|
72
|
+
else:
|
|
73
|
+
transformer_to_3857 = lambda x, y: (x, y)
|
|
74
|
+
|
|
75
|
+
roi_bounds = roi_shapely.bounds
|
|
76
|
+
roi_left, roi_bottom = transformer_to_3857.transform(roi_bounds[0], roi_bounds[1])
|
|
77
|
+
roi_right, roi_top = transformer_to_3857.transform(roi_bounds[2], roi_bounds[3])
|
|
78
|
+
|
|
79
|
+
wgs84 = CRS.from_epsg(4326)
|
|
80
|
+
transformer_to_wgs84 = Transformer.from_crs(CRS.from_epsg(3857), wgs84, always_xy=True)
|
|
81
|
+
roi_left_wgs84, roi_bottom_wgs84 = transformer_to_wgs84.transform(roi_left, roi_bottom)
|
|
82
|
+
roi_right_wgs84, roi_top_wgs84 = transformer_to_wgs84.transform(roi_right, roi_top)
|
|
83
|
+
|
|
84
|
+
geod = Geod(ellps="WGS84")
|
|
85
|
+
_, _, roi_width_m = geod.inv(roi_left_wgs84, roi_bottom_wgs84, roi_right_wgs84, roi_bottom_wgs84)
|
|
86
|
+
_, _, roi_height_m = geod.inv(roi_left_wgs84, roi_bottom_wgs84, roi_left_wgs84, roi_top_wgs84)
|
|
87
|
+
|
|
88
|
+
num_cells_x = int(roi_width_m / mesh_size + 0.5)
|
|
89
|
+
num_cells_y = int(roi_height_m / mesh_size + 0.5)
|
|
90
|
+
|
|
91
|
+
x = np.linspace(roi_left, roi_right, num_cells_x, endpoint=False)
|
|
92
|
+
y = np.linspace(roi_top, roi_bottom, num_cells_y, endpoint=False)
|
|
93
|
+
xx, yy = np.meshgrid(x, y)
|
|
94
|
+
|
|
95
|
+
rows, cols = np.meshgrid(range(dem.shape[0]), range(dem.shape[1]), indexing='ij')
|
|
96
|
+
orig_x, orig_y = rasterio.transform.xy(transform, rows.ravel(), cols.ravel())
|
|
97
|
+
orig_x, orig_y = transformer_to_3857.transform(orig_x, orig_y)
|
|
98
|
+
|
|
99
|
+
points = np.column_stack((orig_x, orig_y))
|
|
100
|
+
values = dem.ravel()
|
|
101
|
+
if dem_interpolation:
|
|
102
|
+
grid = griddata(points, values, (xx, yy), method='cubic')
|
|
103
|
+
else:
|
|
104
|
+
grid = griddata(points, values, (xx, yy), method='nearest')
|
|
105
|
+
|
|
106
|
+
return np.flipud(grid)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Selection and filtering helpers for building footprints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict, Tuple
|
|
6
|
+
|
|
7
|
+
from shapely.geometry import Polygon, Point, shape
|
|
8
|
+
from shapely.errors import ShapelyError
|
|
9
|
+
|
|
10
|
+
from .utils import validate_polygon_coordinates
|
|
11
|
+
from ..utils.logging import get_logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def filter_buildings(geojson_data, plotting_box):
|
|
15
|
+
"""
|
|
16
|
+
Filter building features that intersect with a given bounding box.
|
|
17
|
+
"""
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
filtered_features = []
|
|
20
|
+
|
|
21
|
+
for feature in geojson_data:
|
|
22
|
+
if not validate_polygon_coordinates(feature['geometry']):
|
|
23
|
+
logger.warning("Skipping feature with invalid geometry: %s", feature.get('geometry'))
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
geom = shape(feature['geometry'])
|
|
28
|
+
if not geom.is_valid:
|
|
29
|
+
logger.warning("Skipping invalid geometry: %s", geom)
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if plotting_box.intersects(geom):
|
|
33
|
+
filtered_features.append(feature)
|
|
34
|
+
|
|
35
|
+
except ShapelyError as e:
|
|
36
|
+
logger.warning("Skipping feature due to geometry error: %s", e)
|
|
37
|
+
|
|
38
|
+
return filtered_features
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_building_containing_point(building_gdf, target_point):
|
|
42
|
+
"""
|
|
43
|
+
Find building IDs that contain a given point in their footprint.
|
|
44
|
+
"""
|
|
45
|
+
point = Point(target_point[0], target_point[1])
|
|
46
|
+
|
|
47
|
+
id_list = []
|
|
48
|
+
for _, row in building_gdf.iterrows():
|
|
49
|
+
if not isinstance(row.geometry, Polygon):
|
|
50
|
+
continue
|
|
51
|
+
if row.geometry.contains(point):
|
|
52
|
+
id_list.append(row.get('id', None))
|
|
53
|
+
|
|
54
|
+
return id_list
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_buildings_in_drawn_polygon(building_gdf, drawn_polygons, operation='within'):
|
|
58
|
+
"""
|
|
59
|
+
Find buildings that intersect with or are contained within user-drawn polygons.
|
|
60
|
+
"""
|
|
61
|
+
if not drawn_polygons:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
included_building_ids = set()
|
|
65
|
+
|
|
66
|
+
for polygon_data in drawn_polygons:
|
|
67
|
+
vertices = polygon_data['vertices']
|
|
68
|
+
drawn_polygon_shapely = Polygon(vertices)
|
|
69
|
+
|
|
70
|
+
for _, row in building_gdf.iterrows():
|
|
71
|
+
if not isinstance(row.geometry, Polygon):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if operation == 'intersect':
|
|
75
|
+
if row.geometry.intersects(drawn_polygon_shapely):
|
|
76
|
+
included_building_ids.add(row.get('id', None))
|
|
77
|
+
elif operation == 'within':
|
|
78
|
+
if row.geometry.within(drawn_polygon_shapely):
|
|
79
|
+
included_building_ids.add(row.get('id', None))
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("operation must be 'intersect' or 'within'")
|
|
82
|
+
|
|
83
|
+
return list(included_building_ids)
|
|
84
|
+
|
|
85
|
+
|
voxcity/geoprocessor/utils.py
CHANGED
|
@@ -59,6 +59,10 @@ warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarni
|
|
|
59
59
|
# Global constants
|
|
60
60
|
floor_height = 2.5 # Standard floor height in meters used for building height calculations
|
|
61
61
|
|
|
62
|
+
# Package logging
|
|
63
|
+
from ..utils.logging import get_logger
|
|
64
|
+
logger = get_logger(__name__)
|
|
65
|
+
|
|
62
66
|
# Build a compliant Nominatim user agent once and reuse it
|
|
63
67
|
try:
|
|
64
68
|
# Prefer package metadata if available
|
|
@@ -264,10 +268,10 @@ def transform_coords(transformer, lon, lat):
|
|
|
264
268
|
try:
|
|
265
269
|
x, y = transformer.transform(lon, lat)
|
|
266
270
|
if np.isinf(x) or np.isinf(y):
|
|
267
|
-
|
|
271
|
+
logger.warning("Transformation resulted in inf values for coordinates: %s, %s", lon, lat)
|
|
268
272
|
return x, y
|
|
269
273
|
except Exception as e:
|
|
270
|
-
|
|
274
|
+
logger.error("Error transforming coordinates %s, %s: %s", lon, lat, e)
|
|
271
275
|
return None, None
|
|
272
276
|
|
|
273
277
|
def create_polygon(vertices):
|
|
@@ -402,7 +406,7 @@ def save_raster(input_path, output_path):
|
|
|
402
406
|
"""
|
|
403
407
|
import shutil
|
|
404
408
|
shutil.copy(input_path, output_path)
|
|
405
|
-
|
|
409
|
+
logger.info("Copied original file to: %s", output_path)
|
|
406
410
|
|
|
407
411
|
def merge_geotiffs(geotiff_files, output_dir):
|
|
408
412
|
"""
|
|
@@ -449,11 +453,11 @@ def merge_geotiffs(geotiff_files, output_dir):
|
|
|
449
453
|
with rasterio.open(merged_path, "w", **out_meta) as dest:
|
|
450
454
|
dest.write(mosaic)
|
|
451
455
|
|
|
452
|
-
|
|
456
|
+
logger.info("Merged output saved to: %s", merged_path)
|
|
453
457
|
except Exception as e:
|
|
454
|
-
|
|
458
|
+
logger.error("Error merging files: %s", e)
|
|
455
459
|
else:
|
|
456
|
-
|
|
460
|
+
logger.info("No valid files to merge.")
|
|
457
461
|
|
|
458
462
|
# Clean up by closing all opened files
|
|
459
463
|
for src in src_files_to_mosaic:
|
|
@@ -511,10 +515,10 @@ def get_coordinates_from_cityname(place_name):
|
|
|
511
515
|
else:
|
|
512
516
|
return None
|
|
513
517
|
except GeocoderInsufficientPrivileges:
|
|
514
|
-
|
|
518
|
+
logger.warning("Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
|
|
515
519
|
return None
|
|
516
520
|
except (GeocoderTimedOut, GeocoderServiceError):
|
|
517
|
-
|
|
521
|
+
logger.error("Geocoding service timed out or encountered an error for %s", place_name)
|
|
518
522
|
return None
|
|
519
523
|
|
|
520
524
|
def get_city_country_name_from_rectangle(coordinates):
|
|
@@ -560,7 +564,7 @@ def get_city_country_name_from_rectangle(coordinates):
|
|
|
560
564
|
country = address.get('country', '')
|
|
561
565
|
return f"{city}/ {country}"
|
|
562
566
|
else:
|
|
563
|
-
|
|
567
|
+
logger.info("Reverse geocoding location not found for %s", center_coord)
|
|
564
568
|
return "Unknown Location/ Unknown Country"
|
|
565
569
|
except GeocoderInsufficientPrivileges:
|
|
566
570
|
# Fallback to offline reverse_geocoder at coarse resolution
|
|
@@ -572,10 +576,10 @@ def get_city_country_name_from_rectangle(coordinates):
|
|
|
572
576
|
return f"{name}/ {country}".strip()
|
|
573
577
|
except Exception:
|
|
574
578
|
pass
|
|
575
|
-
|
|
579
|
+
logger.warning("Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
|
|
576
580
|
return "Unknown Location/ Unknown Country"
|
|
577
581
|
except (GeocoderTimedOut, GeocoderServiceError) as e:
|
|
578
|
-
|
|
582
|
+
logger.error("Error retrieving location for %s: %s", center_coord, e)
|
|
579
583
|
return "Unknown Location/ Unknown Country"
|
|
580
584
|
|
|
581
585
|
def get_timezone_info(rectangle_coords):
|
|
@@ -626,7 +630,7 @@ def get_timezone_info(rectangle_coords):
|
|
|
626
630
|
return utc_offset, timezone_longitude_str
|
|
627
631
|
else:
|
|
628
632
|
# Return fallback values if timezone cannot be determined
|
|
629
|
-
|
|
633
|
+
logger.warning("Timezone not found for the given location, using UTC+00:00")
|
|
630
634
|
return "UTC+00:00", "0.00000"
|
|
631
635
|
|
|
632
636
|
def validate_polygon_coordinates(geometry):
|
|
@@ -743,7 +747,7 @@ def create_building_polygons(filtered_buildings):
|
|
|
743
747
|
|
|
744
748
|
# Skip invalid geometries
|
|
745
749
|
if not polygon.is_valid:
|
|
746
|
-
|
|
750
|
+
logger.warning("Skipping invalid polygon geometry")
|
|
747
751
|
continue
|
|
748
752
|
|
|
749
753
|
height = building['properties'].get('height')
|
|
@@ -786,7 +790,7 @@ def create_building_polygons(filtered_buildings):
|
|
|
786
790
|
valid_count += 1
|
|
787
791
|
|
|
788
792
|
except Exception as e:
|
|
789
|
-
|
|
793
|
+
logger.warning("Skipping invalid building geometry: %s", e)
|
|
790
794
|
continue
|
|
791
795
|
|
|
792
796
|
return building_polygons, idx
|
voxcity/models.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Tuple, Optional, Dict, Any
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class GridMetadata:
|
|
11
|
+
crs: str
|
|
12
|
+
bounds: Tuple[float, float, float, float]
|
|
13
|
+
meshsize: float
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BuildingGrid:
|
|
18
|
+
heights: np.ndarray
|
|
19
|
+
min_heights: np.ndarray # object-dtype array of lists per cell
|
|
20
|
+
ids: np.ndarray
|
|
21
|
+
meta: GridMetadata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LandCoverGrid:
|
|
26
|
+
classes: np.ndarray
|
|
27
|
+
meta: GridMetadata
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DemGrid:
|
|
32
|
+
elevation: np.ndarray
|
|
33
|
+
meta: GridMetadata
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class VoxelGrid:
|
|
38
|
+
classes: np.ndarray
|
|
39
|
+
meta: GridMetadata
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CanopyGrid:
|
|
44
|
+
top: np.ndarray
|
|
45
|
+
meta: GridMetadata
|
|
46
|
+
bottom: Optional[np.ndarray] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class VoxCity:
|
|
51
|
+
voxels: VoxelGrid
|
|
52
|
+
buildings: BuildingGrid
|
|
53
|
+
land_cover: LandCoverGrid
|
|
54
|
+
dem: DemGrid
|
|
55
|
+
tree_canopy: CanopyGrid
|
|
56
|
+
extras: Dict[str, Any] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class PipelineConfig:
|
|
61
|
+
rectangle_vertices: Any
|
|
62
|
+
meshsize: float
|
|
63
|
+
building_source: Optional[str] = None
|
|
64
|
+
land_cover_source: Optional[str] = None
|
|
65
|
+
canopy_height_source: Optional[str] = None
|
|
66
|
+
dem_source: Optional[str] = None
|
|
67
|
+
output_dir: str = "output"
|
|
68
|
+
trunk_height_ratio: Optional[float] = None
|
|
69
|
+
static_tree_height: Optional[float] = None
|
|
70
|
+
remove_perimeter_object: Optional[float] = None
|
|
71
|
+
mapvis: bool = False
|
|
72
|
+
gridvis: bool = True
|
|
73
|
+
# Structured options for strategies and I/O/visualization
|
|
74
|
+
land_cover_options: Dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
building_options: Dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
canopy_options: Dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
dem_options: Dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
io_options: Dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
visualize_options: Dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# -----------------------------
|
|
83
|
+
# Mesh data structures
|
|
84
|
+
# -----------------------------
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class MeshModel:
|
|
88
|
+
vertices: np.ndarray # (N, 3) float
|
|
89
|
+
faces: np.ndarray # (M, 3|4) int
|
|
90
|
+
colors: Optional[np.ndarray] = None # (M, 4) uint8 or None
|
|
91
|
+
name: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class MeshCollection:
|
|
96
|
+
"""Container for named meshes with simple add/access helpers."""
|
|
97
|
+
meshes: Dict[str, MeshModel] = field(default_factory=dict)
|
|
98
|
+
|
|
99
|
+
def add(self, name: str, mesh: MeshModel) -> None:
|
|
100
|
+
self.meshes[name] = mesh
|
|
101
|
+
|
|
102
|
+
def get(self, name: str) -> Optional[MeshModel]:
|
|
103
|
+
return self.meshes.get(name)
|
|
104
|
+
|
|
105
|
+
def __iter__(self):
|
|
106
|
+
return iter(self.meshes.items())
|
|
107
|
+
|
|
108
|
+
# Compatibility: some renderers expect `collection.items.items()`
|
|
109
|
+
@property
|
|
110
|
+
def items(self) -> Dict[str, MeshModel]:
|
|
111
|
+
return self.meshes
|
|
112
|
+
|
|
113
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for simulator subpackages.
|
|
3
|
+
|
|
4
|
+
Currently exposes lightweight 3D geometry helpers used by both
|
|
5
|
+
`visibility` and `solar`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .geometry import ( # noqa: F401
|
|
9
|
+
_generate_ray_directions_grid,
|
|
10
|
+
_generate_ray_directions_fibonacci,
|
|
11
|
+
rotate_vector_axis_angle,
|
|
12
|
+
_build_face_basis,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"_generate_ray_directions_grid",
|
|
17
|
+
"_generate_ray_directions_fibonacci",
|
|
18
|
+
"rotate_vector_axis_angle",
|
|
19
|
+
"_build_face_basis",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from numba import njit
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _generate_ray_directions_grid(N_azimuth: int, N_elevation: int, elevation_min_degrees: float, elevation_max_degrees: float) -> np.ndarray:
|
|
6
|
+
azimuth_angles = np.linspace(0.0, 2.0 * np.pi, int(N_azimuth), endpoint=False)
|
|
7
|
+
elevation_angles = np.deg2rad(
|
|
8
|
+
np.linspace(float(elevation_min_degrees), float(elevation_max_degrees), int(N_elevation))
|
|
9
|
+
)
|
|
10
|
+
ray_directions = np.empty((len(azimuth_angles) * len(elevation_angles), 3), dtype=np.float64)
|
|
11
|
+
out_idx = 0
|
|
12
|
+
for elevation in elevation_angles:
|
|
13
|
+
cos_elev = np.cos(elevation)
|
|
14
|
+
sin_elev = np.sin(elevation)
|
|
15
|
+
for azimuth in azimuth_angles:
|
|
16
|
+
dx = cos_elev * np.cos(azimuth)
|
|
17
|
+
dy = cos_elev * np.sin(azimuth)
|
|
18
|
+
dz = sin_elev
|
|
19
|
+
ray_directions[out_idx, 0] = dx
|
|
20
|
+
ray_directions[out_idx, 1] = dy
|
|
21
|
+
ray_directions[out_idx, 2] = dz
|
|
22
|
+
out_idx += 1
|
|
23
|
+
return ray_directions
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _generate_ray_directions_fibonacci(N_rays: int, elevation_min_degrees: float, elevation_max_degrees: float) -> np.ndarray:
|
|
27
|
+
N = int(max(1, N_rays))
|
|
28
|
+
emin = np.deg2rad(float(elevation_min_degrees))
|
|
29
|
+
emax = np.deg2rad(float(elevation_max_degrees))
|
|
30
|
+
z_min = np.sin(min(emin, emax))
|
|
31
|
+
z_max = np.sin(max(emin, emax))
|
|
32
|
+
golden_angle = np.pi * (3.0 - np.sqrt(5.0))
|
|
33
|
+
i = np.arange(N, dtype=np.float64)
|
|
34
|
+
z = z_min + (i + 0.5) * (z_max - z_min) / N
|
|
35
|
+
phi = i * golden_angle
|
|
36
|
+
r = np.sqrt(np.clip(1.0 - z * z, 0.0, 1.0))
|
|
37
|
+
x = r * np.cos(phi)
|
|
38
|
+
y = r * np.sin(phi)
|
|
39
|
+
return np.stack((x, y, z), axis=1).astype(np.float64)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@njit
|
|
43
|
+
def rotate_vector_axis_angle(vec, axis, angle):
|
|
44
|
+
axis_len = np.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2)
|
|
45
|
+
if axis_len < 1e-12:
|
|
46
|
+
return vec
|
|
47
|
+
ux, uy, uz = axis / axis_len
|
|
48
|
+
c = np.cos(angle)
|
|
49
|
+
s = np.sin(angle)
|
|
50
|
+
dot = vec[0]*ux + vec[1]*uy + vec[2]*uz
|
|
51
|
+
cross_x = uy*vec[2] - uz*vec[1]
|
|
52
|
+
cross_y = uz*vec[0] - ux*vec[2]
|
|
53
|
+
cross_z = ux*vec[1] - uy*vec[0]
|
|
54
|
+
v_rot = np.zeros(3, dtype=np.float64)
|
|
55
|
+
v_rot[0] = vec[0] * c
|
|
56
|
+
v_rot[1] = vec[1] * c
|
|
57
|
+
v_rot[2] = vec[2] * c
|
|
58
|
+
v_rot[0] += cross_x * s
|
|
59
|
+
v_rot[1] += cross_y * s
|
|
60
|
+
v_rot[2] += cross_z * s
|
|
61
|
+
tmp = dot * (1.0 - c)
|
|
62
|
+
v_rot[0] += ux * tmp
|
|
63
|
+
v_rot[1] += uy * tmp
|
|
64
|
+
v_rot[2] += uz * tmp
|
|
65
|
+
return v_rot
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@njit(cache=True, fastmath=True, nogil=True)
|
|
69
|
+
def _build_face_basis(normal):
|
|
70
|
+
nx = normal[0]; ny = normal[1]; nz = normal[2]
|
|
71
|
+
nrm = (nx*nx + ny*ny + nz*nz) ** 0.5
|
|
72
|
+
if nrm < 1e-12:
|
|
73
|
+
return (np.array((1.0, 0.0, 0.0)),
|
|
74
|
+
np.array((0.0, 1.0, 0.0)),
|
|
75
|
+
np.array((0.0, 0.0, 1.0)))
|
|
76
|
+
invn = 1.0 / nrm
|
|
77
|
+
nx *= invn; ny *= invn; nz *= invn
|
|
78
|
+
n = np.array((nx, ny, nz))
|
|
79
|
+
if abs(nz) < 0.999:
|
|
80
|
+
helper = np.array((0.0, 0.0, 1.0))
|
|
81
|
+
else:
|
|
82
|
+
helper = np.array((1.0, 0.0, 0.0))
|
|
83
|
+
ux = helper[1]*n[2] - helper[2]*n[1]
|
|
84
|
+
uy = helper[2]*n[0] - helper[0]*n[2]
|
|
85
|
+
uz = helper[0]*n[1] - helper[1]*n[0]
|
|
86
|
+
ul = (ux*ux + uy*uy + uz*uz) ** 0.5
|
|
87
|
+
if ul < 1e-12:
|
|
88
|
+
u = np.array((1.0, 0.0, 0.0))
|
|
89
|
+
else:
|
|
90
|
+
invul = 1.0 / ul
|
|
91
|
+
u = np.array((ux*invul, uy*invul, uz*invul))
|
|
92
|
+
vx = n[1]*u[2] - n[2]*u[1]
|
|
93
|
+
vy = n[2]*u[0] - n[0]*u[2]
|
|
94
|
+
vz = n[0]*u[1] - n[1]*u[0]
|
|
95
|
+
v = np.array((vx, vy, vz))
|
|
96
|
+
return u, v, n
|
|
97
|
+
|
|
98
|
+
|