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.
Files changed (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {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
+
@@ -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
- print(f"Transformation resulted in inf values for coordinates: {lon}, {lat}")
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
- print(f"Error transforming coordinates {lon}, {lat}: {e}")
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
- print(f"Copied original file to: {output_path}")
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
- print(f"Merged output saved to: {merged_path}")
456
+ logger.info("Merged output saved to: %s", merged_path)
453
457
  except Exception as e:
454
- print(f"Error merging files: {e}")
458
+ logger.error("Error merging files: %s", e)
455
459
  else:
456
- print("No valid files to merge.")
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
- print("Warning: Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
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
- print(f"Error: Geocoding service timed out or encountered an error for {place_name}")
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
- print("Location not found")
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
- print("Warning: Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
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
- print(f"Error retrieving location for {center_coord}: {e}")
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
- print("Warning: Timezone not found for the given location, using UTC+00:00")
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
- print(f"Warning: Skipping invalid polygon geometry")
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
- print(f"Warning: Skipping invalid building geometry: {e}")
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
+