voxcity 0.3.25__py3-none-any.whl → 0.4.1__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.
Potentially problematic release.
This version of voxcity might be problematic. Click here for more details.
- voxcity/exporter/obj.py +2 -2
- voxcity/generator.py +17 -4
- voxcity/geoprocessor/grid.py +5 -1
- voxcity/geoprocessor/mesh.py +21 -1
- voxcity/geoprocessor/polygon.py +95 -1
- voxcity/simulator/solar.py +656 -7
- voxcity/simulator/view.py +635 -2
- voxcity/utils/visualization.py +767 -168
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/METADATA +13 -13
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/RECORD +14 -14
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/WHEEL +1 -1
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/AUTHORS.rst +0 -0
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/LICENSE +0 -0
- {voxcity-0.3.25.dist-info → voxcity-0.4.1.dist-info}/top_level.txt +0 -0
voxcity/simulator/view.py
CHANGED
|
@@ -45,8 +45,10 @@ import numpy as np
|
|
|
45
45
|
import matplotlib.pyplot as plt
|
|
46
46
|
import matplotlib.patches as mpatches
|
|
47
47
|
from numba import njit, prange
|
|
48
|
+
import time
|
|
48
49
|
|
|
49
50
|
from ..geoprocessor.polygon import find_building_containing_point, get_buildings_in_drawn_polygon
|
|
51
|
+
from ..geoprocessor.mesh import create_voxel_mesh
|
|
50
52
|
from ..exporter.obj import grid_to_obj, export_obj
|
|
51
53
|
|
|
52
54
|
@njit
|
|
@@ -166,11 +168,15 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
|
|
|
166
168
|
|
|
167
169
|
# Calculate segment length in current voxel
|
|
168
170
|
segment_length = (t_next - last_t) * meshsize
|
|
171
|
+
segment_length = max(0.0, segment_length)
|
|
169
172
|
|
|
170
173
|
# Handle tree voxels (value -2)
|
|
171
174
|
if voxel_value == -2:
|
|
172
175
|
transmittance = calculate_transmittance(segment_length, tree_k, tree_lad)
|
|
173
176
|
cumulative_transmittance *= transmittance
|
|
177
|
+
|
|
178
|
+
# if segment_length < 0:
|
|
179
|
+
# print(f"segment_length = {segment_length}, transmittance = {transmittance}, cumulative_transmittance = {cumulative_transmittance}")
|
|
174
180
|
|
|
175
181
|
# If transmittance becomes too low, consider it a hit
|
|
176
182
|
if cumulative_transmittance < 0.01:
|
|
@@ -250,7 +256,7 @@ def compute_vi_generic(observer_location, voxel_data, ray_directions, hit_values
|
|
|
250
256
|
if hit:
|
|
251
257
|
if -2 in hit_values:
|
|
252
258
|
# For trees in hit_values, use the hit contribution (1 - transmittance)
|
|
253
|
-
visibility_sum +=
|
|
259
|
+
visibility_sum += value if value < 1.0 else 1.0
|
|
254
260
|
else:
|
|
255
261
|
visibility_sum += 1.0
|
|
256
262
|
else:
|
|
@@ -880,4 +886,631 @@ def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
|
|
|
880
886
|
vmax=vmax
|
|
881
887
|
)
|
|
882
888
|
|
|
883
|
-
return vi_map
|
|
889
|
+
return vi_map
|
|
890
|
+
|
|
891
|
+
# def get_building_surface_svf(voxel_data, meshsize, **kwargs):
|
|
892
|
+
# """
|
|
893
|
+
# Compute and visualize the Sky View Factor (SVF) for building surface meshes.
|
|
894
|
+
|
|
895
|
+
# Args:
|
|
896
|
+
# voxel_data (ndarray): 3D array of voxel values.
|
|
897
|
+
# meshsize (float): Size of each voxel in meters.
|
|
898
|
+
# **kwargs: Additional parameters (colormap, ray counts, etc.)
|
|
899
|
+
|
|
900
|
+
# Returns:
|
|
901
|
+
# trimesh.Trimesh: Mesh of building surfaces with SVF values stored in metadata.
|
|
902
|
+
# """
|
|
903
|
+
# # Import required modules
|
|
904
|
+
# import trimesh
|
|
905
|
+
# import numpy as np
|
|
906
|
+
# import time
|
|
907
|
+
|
|
908
|
+
# # Default parameters
|
|
909
|
+
# colormap = kwargs.get("colormap", 'BuPu_r')
|
|
910
|
+
# vmin = kwargs.get("vmin", 0.0)
|
|
911
|
+
# vmax = kwargs.get("vmax", 1.0)
|
|
912
|
+
# N_azimuth = kwargs.get("N_azimuth", 60)
|
|
913
|
+
# N_elevation = kwargs.get("N_elevation", 10)
|
|
914
|
+
# debug = kwargs.get("debug", False)
|
|
915
|
+
# progress_report = kwargs.get("progress_report", False)
|
|
916
|
+
|
|
917
|
+
# # Tree transmittance parameters
|
|
918
|
+
# tree_k = kwargs.get("tree_k", 0.6)
|
|
919
|
+
# tree_lad = kwargs.get("tree_lad", 1.0)
|
|
920
|
+
|
|
921
|
+
# # Sky detection parameters
|
|
922
|
+
# hit_values = (0,) # Sky is typically represented by 0
|
|
923
|
+
# inclusion_mode = False # We want rays that DON'T hit obstacles
|
|
924
|
+
|
|
925
|
+
# # Extract building mesh (building voxels have value -3)
|
|
926
|
+
# building_class_id = kwargs.get("building_class_id", -3)
|
|
927
|
+
# start_time = time.time()
|
|
928
|
+
# # print(f"Extracting building mesh for class ID {building_class_id}...")
|
|
929
|
+
# try:
|
|
930
|
+
# building_mesh = create_voxel_mesh(voxel_data, building_class_id, meshsize)
|
|
931
|
+
# # print(f"Mesh extraction took {time.time() - start_time:.2f} seconds")
|
|
932
|
+
|
|
933
|
+
# if building_mesh is None or len(building_mesh.faces) == 0:
|
|
934
|
+
# print("No building surfaces found in voxel data.")
|
|
935
|
+
# return None
|
|
936
|
+
|
|
937
|
+
# # print(f"Successfully extracted mesh with {len(building_mesh.faces)} faces")
|
|
938
|
+
# except Exception as e:
|
|
939
|
+
# print(f"Error during mesh extraction: {e}")
|
|
940
|
+
# return None
|
|
941
|
+
|
|
942
|
+
# if progress_report:
|
|
943
|
+
# print(f"Processing SVF for {len(building_mesh.faces)} building faces...")
|
|
944
|
+
|
|
945
|
+
# try:
|
|
946
|
+
# # Calculate face centers and normals
|
|
947
|
+
# face_centers = building_mesh.triangles_center
|
|
948
|
+
# face_normals = building_mesh.face_normals
|
|
949
|
+
|
|
950
|
+
# # Initialize array to store SVF values for each face
|
|
951
|
+
# face_svf_values = np.zeros(len(building_mesh.faces))
|
|
952
|
+
|
|
953
|
+
# # Get voxel grid dimensions
|
|
954
|
+
# grid_shape = voxel_data.shape
|
|
955
|
+
# grid_bounds = np.array([
|
|
956
|
+
# [0, 0, 0], # Min bounds in voxel coordinates
|
|
957
|
+
# [grid_shape[0], grid_shape[1], grid_shape[2]] # Max bounds
|
|
958
|
+
# ])
|
|
959
|
+
|
|
960
|
+
# # Convert bounds to real-world coordinates
|
|
961
|
+
# grid_bounds_real = grid_bounds * meshsize
|
|
962
|
+
|
|
963
|
+
# # Small epsilon to detect boundary faces (within 0.5 voxel of boundary)
|
|
964
|
+
# boundary_epsilon = meshsize * 0.05
|
|
965
|
+
|
|
966
|
+
# # Create hemisphere directions for ray casting
|
|
967
|
+
# hemisphere_dirs = []
|
|
968
|
+
# azimuth_angles = np.linspace(0, 2 * np.pi, N_azimuth, endpoint=False)
|
|
969
|
+
# elevation_angles = np.linspace(0, np.pi/2, N_elevation) # 0 to 90 degrees
|
|
970
|
+
|
|
971
|
+
# for elevation in elevation_angles:
|
|
972
|
+
# sin_elev = np.sin(elevation)
|
|
973
|
+
# cos_elev = np.cos(elevation)
|
|
974
|
+
# for azimuth in azimuth_angles:
|
|
975
|
+
# x = cos_elev * np.cos(azimuth)
|
|
976
|
+
# y = cos_elev * np.sin(azimuth)
|
|
977
|
+
# z = sin_elev
|
|
978
|
+
# hemisphere_dirs.append([x, y, z])
|
|
979
|
+
|
|
980
|
+
# hemisphere_dirs = np.array(hemisphere_dirs)
|
|
981
|
+
|
|
982
|
+
# # Process each face
|
|
983
|
+
# from scipy.spatial.transform import Rotation
|
|
984
|
+
# processed_count = 0
|
|
985
|
+
# boundary_count = 0
|
|
986
|
+
# nan_boundary_count = 0
|
|
987
|
+
|
|
988
|
+
# start_time = time.time()
|
|
989
|
+
# for face_idx in range(len(building_mesh.faces)):
|
|
990
|
+
# try:
|
|
991
|
+
# center = face_centers[face_idx]
|
|
992
|
+
# normal = face_normals[face_idx]
|
|
993
|
+
|
|
994
|
+
# # Check if this is a vertical surface (normal has no Z component)
|
|
995
|
+
# is_vertical = abs(normal[2]) < 0.01
|
|
996
|
+
|
|
997
|
+
# # Check if this face is on the boundary of the voxel grid
|
|
998
|
+
# on_x_min = abs(center[0] - grid_bounds_real[0, 0]) < boundary_epsilon
|
|
999
|
+
# on_y_min = abs(center[1] - grid_bounds_real[0, 1]) < boundary_epsilon
|
|
1000
|
+
# on_x_max = abs(center[0] - grid_bounds_real[1, 0]) < boundary_epsilon
|
|
1001
|
+
# on_y_max = abs(center[1] - grid_bounds_real[1, 1]) < boundary_epsilon
|
|
1002
|
+
|
|
1003
|
+
# # Check if this is a vertical surface on the boundary
|
|
1004
|
+
# is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
|
|
1005
|
+
|
|
1006
|
+
# # Set NaN for all vertical surfaces on domain boundaries
|
|
1007
|
+
# if is_boundary_vertical:
|
|
1008
|
+
# face_svf_values[face_idx] = np.nan
|
|
1009
|
+
# nan_boundary_count += 1
|
|
1010
|
+
# processed_count += 1
|
|
1011
|
+
# continue
|
|
1012
|
+
|
|
1013
|
+
# # For non-boundary surfaces, proceed with normal SVF calculation
|
|
1014
|
+
# # Convert center to voxel coordinates (for ray origin)
|
|
1015
|
+
# center_voxel = center / meshsize
|
|
1016
|
+
|
|
1017
|
+
# # IMPORTANT: Offset ray origin slightly to avoid self-intersection
|
|
1018
|
+
# ray_origin = center_voxel + normal * 0.1 # Offset by 0.1 voxel units in normal direction
|
|
1019
|
+
|
|
1020
|
+
# # Create rotation from z-axis to face normal
|
|
1021
|
+
# z_axis = np.array([0, 0, 1])
|
|
1022
|
+
|
|
1023
|
+
# # Handle special case where normal is parallel to z-axis
|
|
1024
|
+
# if np.isclose(np.abs(np.dot(normal, z_axis)), 1.0, atol=1e-6):
|
|
1025
|
+
# if np.dot(normal, z_axis) > 0: # Normal points up
|
|
1026
|
+
# rotation_matrix = np.eye(3) # Identity matrix
|
|
1027
|
+
# else: # Normal points down
|
|
1028
|
+
# rotation_matrix = np.array([
|
|
1029
|
+
# [1, 0, 0],
|
|
1030
|
+
# [0, -1, 0],
|
|
1031
|
+
# [0, 0, -1]
|
|
1032
|
+
# ])
|
|
1033
|
+
# rotation = Rotation.from_matrix(rotation_matrix)
|
|
1034
|
+
# else:
|
|
1035
|
+
# # For all other cases, find rotation that aligns z-axis with normal
|
|
1036
|
+
# rotation_axis = np.cross(z_axis, normal)
|
|
1037
|
+
# rotation_axis = rotation_axis / np.linalg.norm(rotation_axis)
|
|
1038
|
+
# angle = np.arccos(np.clip(np.dot(z_axis, normal), -1.0, 1.0))
|
|
1039
|
+
# rotation = Rotation.from_rotvec(rotation_axis * angle)
|
|
1040
|
+
|
|
1041
|
+
# # Transform hemisphere directions to align with face normal
|
|
1042
|
+
# local_dirs = rotation.apply(hemisphere_dirs)
|
|
1043
|
+
|
|
1044
|
+
# # Filter directions - keep only those that:
|
|
1045
|
+
# # 1. Are pointing outward from the face (dot product with normal > 0)
|
|
1046
|
+
# # 2. Have a positive z component (upward in world space)
|
|
1047
|
+
# valid_dirs = []
|
|
1048
|
+
# total_dirs = 0
|
|
1049
|
+
|
|
1050
|
+
# # Count total directions in the hemisphere (for normalization)
|
|
1051
|
+
# for dir_vector in local_dirs:
|
|
1052
|
+
# dot_product = np.dot(dir_vector, normal)
|
|
1053
|
+
# # Count this direction if it's pointing outward from the face
|
|
1054
|
+
# if dot_product > 0.01: # Small threshold to avoid precision issues
|
|
1055
|
+
# total_dirs += 1
|
|
1056
|
+
# # Only trace rays that have a positive z component (can reach sky)
|
|
1057
|
+
# if dir_vector[2] > 0:
|
|
1058
|
+
# valid_dirs.append(dir_vector)
|
|
1059
|
+
|
|
1060
|
+
# # If no valid directions, SVF is 0
|
|
1061
|
+
# if total_dirs == 0:
|
|
1062
|
+
# face_svf_values[face_idx] = 0
|
|
1063
|
+
# continue
|
|
1064
|
+
|
|
1065
|
+
# # If no upward directions, SVF is 0 (all rays are blocked by ground)
|
|
1066
|
+
# if len(valid_dirs) == 0:
|
|
1067
|
+
# face_svf_values[face_idx] = 0
|
|
1068
|
+
# continue
|
|
1069
|
+
|
|
1070
|
+
# # Convert to numpy array for compute_vi_generic
|
|
1071
|
+
# valid_dirs = np.array(valid_dirs, dtype=np.float64)
|
|
1072
|
+
|
|
1073
|
+
# # Calculate SVF using compute_vi_generic for the upward rays
|
|
1074
|
+
# # Then scale by the fraction of upward rays to total rays
|
|
1075
|
+
# upward_svf = compute_vi_generic(
|
|
1076
|
+
# ray_origin,
|
|
1077
|
+
# voxel_data,
|
|
1078
|
+
# valid_dirs,
|
|
1079
|
+
# hit_values,
|
|
1080
|
+
# meshsize,
|
|
1081
|
+
# tree_k,
|
|
1082
|
+
# tree_lad,
|
|
1083
|
+
# inclusion_mode
|
|
1084
|
+
# )
|
|
1085
|
+
|
|
1086
|
+
# # Scale SVF by the fraction of rays that could potentially reach the sky
|
|
1087
|
+
# # This accounts for downward rays that always have 0 SVF
|
|
1088
|
+
# face_svf_values[face_idx] = upward_svf * (len(valid_dirs) / total_dirs)
|
|
1089
|
+
|
|
1090
|
+
# except Exception as e:
|
|
1091
|
+
# print(f"Error processing face {face_idx}: {e}")
|
|
1092
|
+
# face_svf_values[face_idx] = 0
|
|
1093
|
+
|
|
1094
|
+
# # Progress reporting
|
|
1095
|
+
# processed_count += 1
|
|
1096
|
+
# if progress_report:
|
|
1097
|
+
# # Calculate frequency based on total number of faces, aiming for ~10 progress updates
|
|
1098
|
+
# progress_frequency = max(1, len(building_mesh.faces) // 10)
|
|
1099
|
+
# if processed_count % progress_frequency == 0 or processed_count == len(building_mesh.faces):
|
|
1100
|
+
# elapsed = time.time() - start_time
|
|
1101
|
+
# faces_per_second = processed_count / elapsed
|
|
1102
|
+
# remaining = (len(building_mesh.faces) - processed_count) / faces_per_second if processed_count < len(building_mesh.faces) else 0
|
|
1103
|
+
# print(f"Processed {processed_count}/{len(building_mesh.faces)} faces "
|
|
1104
|
+
# f"({processed_count/len(building_mesh.faces)*100:.1f}%) - "
|
|
1105
|
+
# f"{faces_per_second:.1f} faces/sec - "
|
|
1106
|
+
# f"Est. remaining: {remaining:.1f} sec")
|
|
1107
|
+
|
|
1108
|
+
# # print(f"Identified {nan_boundary_count} faces on domain vertical boundaries (set to NaN)")
|
|
1109
|
+
|
|
1110
|
+
# # Store SVF values directly in mesh metadata
|
|
1111
|
+
# if not hasattr(building_mesh, 'metadata'):
|
|
1112
|
+
# building_mesh.metadata = {}
|
|
1113
|
+
# building_mesh.metadata['svf_values'] = face_svf_values
|
|
1114
|
+
|
|
1115
|
+
# # Apply colors to the mesh based on SVF values (only for visualization)
|
|
1116
|
+
# if show_plot:
|
|
1117
|
+
# import matplotlib.cm as cm
|
|
1118
|
+
# import matplotlib.colors as mcolors
|
|
1119
|
+
# cmap = cm.get_cmap(colormap)
|
|
1120
|
+
# norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1121
|
+
|
|
1122
|
+
# # Get a copy of face_svf_values with NaN replaced by a specific value outside the range
|
|
1123
|
+
# # This ensures NaN faces get a distinct color in the visualization
|
|
1124
|
+
# vis_values = face_svf_values.copy()
|
|
1125
|
+
# nan_mask = np.isnan(vis_values)
|
|
1126
|
+
# if np.any(nan_mask):
|
|
1127
|
+
# # Use a color below vmin for NaN values (they'll be clipped to vmin in the colormap)
|
|
1128
|
+
# # But we can see them as the minimum color
|
|
1129
|
+
# vis_values[nan_mask] = vmin - 0.1
|
|
1130
|
+
|
|
1131
|
+
# # Apply colors
|
|
1132
|
+
# face_colors = cmap(norm(vis_values))
|
|
1133
|
+
# building_mesh.visual.face_colors = face_colors
|
|
1134
|
+
|
|
1135
|
+
# # Create a scene with the colored mesh
|
|
1136
|
+
# scene = trimesh.Scene()
|
|
1137
|
+
# scene.add_geometry(building_mesh)
|
|
1138
|
+
# scene.show()
|
|
1139
|
+
|
|
1140
|
+
# # Also create a matplotlib figure with colorbar for reference
|
|
1141
|
+
# import matplotlib.pyplot as plt
|
|
1142
|
+
|
|
1143
|
+
# fig, ax = plt.subplots(figsize=(8, 3))
|
|
1144
|
+
# cb = plt.colorbar(
|
|
1145
|
+
# cm.ScalarMappable(norm=norm, cmap=cmap),
|
|
1146
|
+
# ax=ax,
|
|
1147
|
+
# orientation='horizontal',
|
|
1148
|
+
# label='Sky View Factor'
|
|
1149
|
+
# )
|
|
1150
|
+
# ax.remove() # Remove the axes, keep only colorbar
|
|
1151
|
+
# plt.tight_layout()
|
|
1152
|
+
# plt.show()
|
|
1153
|
+
|
|
1154
|
+
# # Plot histogram of SVF values (excluding NaN)
|
|
1155
|
+
# valid_svf = face_svf_values[~np.isnan(face_svf_values)]
|
|
1156
|
+
# plt.figure(figsize=(10, 6))
|
|
1157
|
+
# plt.hist(valid_svf, bins=50, color='skyblue', alpha=0.7)
|
|
1158
|
+
# plt.title('Distribution of Sky View Factor on Building Surfaces')
|
|
1159
|
+
# plt.xlabel('Sky View Factor')
|
|
1160
|
+
# plt.ylabel('Frequency')
|
|
1161
|
+
# plt.grid(True, alpha=0.3)
|
|
1162
|
+
# plt.tight_layout()
|
|
1163
|
+
# plt.show()
|
|
1164
|
+
|
|
1165
|
+
# # Handle optional OBJ export
|
|
1166
|
+
# obj_export = kwargs.get("obj_export", False)
|
|
1167
|
+
# if obj_export:
|
|
1168
|
+
# output_dir = kwargs.get("output_directory", "output")
|
|
1169
|
+
# output_file_name = kwargs.get("output_file_name", "building_surface_svf")
|
|
1170
|
+
|
|
1171
|
+
# # Ensure output directory exists
|
|
1172
|
+
# import os
|
|
1173
|
+
# os.makedirs(output_dir, exist_ok=True)
|
|
1174
|
+
|
|
1175
|
+
# # Export as OBJ with face colors
|
|
1176
|
+
# try:
|
|
1177
|
+
# building_mesh.export(f"{output_dir}/{output_file_name}.obj")
|
|
1178
|
+
# print(f"Exported building SVF mesh to {output_dir}/{output_file_name}.obj")
|
|
1179
|
+
# except Exception as e:
|
|
1180
|
+
# print(f"Error exporting mesh: {e}")
|
|
1181
|
+
|
|
1182
|
+
# return building_mesh
|
|
1183
|
+
|
|
1184
|
+
# except Exception as e:
|
|
1185
|
+
# print(f"Error during SVF calculation: {e}")
|
|
1186
|
+
# import traceback
|
|
1187
|
+
# traceback.print_exc()
|
|
1188
|
+
# return None
|
|
1189
|
+
|
|
1190
|
+
##############################################################################
|
|
1191
|
+
# 1) New Numba helper: Rodrigues’ rotation formula for rotating vectors
|
|
1192
|
+
##############################################################################
|
|
1193
|
+
@njit
|
|
1194
|
+
def rotate_vector_axis_angle(vec, axis, angle):
|
|
1195
|
+
"""
|
|
1196
|
+
Rotate a 3D vector 'vec' around 'axis' by 'angle' (in radians),
|
|
1197
|
+
using Rodrigues’ rotation formula.
|
|
1198
|
+
"""
|
|
1199
|
+
# Normalize rotation axis
|
|
1200
|
+
axis_len = np.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2)
|
|
1201
|
+
if axis_len < 1e-12:
|
|
1202
|
+
# Axis is degenerate; return vec unchanged
|
|
1203
|
+
return vec
|
|
1204
|
+
|
|
1205
|
+
ux, uy, uz = axis / axis_len
|
|
1206
|
+
c = np.cos(angle)
|
|
1207
|
+
s = np.sin(angle)
|
|
1208
|
+
dot = vec[0]*ux + vec[1]*uy + vec[2]*uz
|
|
1209
|
+
|
|
1210
|
+
# cross = axis x vec
|
|
1211
|
+
cross_x = uy*vec[2] - uz*vec[1]
|
|
1212
|
+
cross_y = uz*vec[0] - ux*vec[2]
|
|
1213
|
+
cross_z = ux*vec[1] - uy*vec[0]
|
|
1214
|
+
|
|
1215
|
+
# Rodrigues formula: v_rot = v*c + (k x v)*s + k*(k·v)*(1-c)
|
|
1216
|
+
v_rot = np.zeros(3, dtype=np.float64)
|
|
1217
|
+
# v*c
|
|
1218
|
+
v_rot[0] = vec[0] * c
|
|
1219
|
+
v_rot[1] = vec[1] * c
|
|
1220
|
+
v_rot[2] = vec[2] * c
|
|
1221
|
+
# + (k x v)*s
|
|
1222
|
+
v_rot[0] += cross_x * s
|
|
1223
|
+
v_rot[1] += cross_y * s
|
|
1224
|
+
v_rot[2] += cross_z * s
|
|
1225
|
+
# + k*(k·v)*(1-c)
|
|
1226
|
+
tmp = dot * (1.0 - c)
|
|
1227
|
+
v_rot[0] += ux * tmp
|
|
1228
|
+
v_rot[1] += uy * tmp
|
|
1229
|
+
v_rot[2] += uz * tmp
|
|
1230
|
+
|
|
1231
|
+
return v_rot
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
##############################################################################
|
|
1235
|
+
# 2) New Numba helper: vectorized SVF computation for each face
|
|
1236
|
+
##############################################################################
|
|
1237
|
+
@njit
|
|
1238
|
+
def compute_svf_for_all_faces(
|
|
1239
|
+
face_centers,
|
|
1240
|
+
face_normals,
|
|
1241
|
+
hemisphere_dirs,
|
|
1242
|
+
voxel_data,
|
|
1243
|
+
meshsize,
|
|
1244
|
+
tree_k,
|
|
1245
|
+
tree_lad,
|
|
1246
|
+
hit_values,
|
|
1247
|
+
inclusion_mode,
|
|
1248
|
+
grid_bounds_real,
|
|
1249
|
+
boundary_epsilon
|
|
1250
|
+
):
|
|
1251
|
+
"""
|
|
1252
|
+
Per-face SVF calculation in Numba:
|
|
1253
|
+
- Checks boundary conditions & sets NaN for boundary-vertical faces
|
|
1254
|
+
- Builds local hemisphere (rotates from +Z to face normal)
|
|
1255
|
+
- Filters directions that actually face outward (+ dot>0) and have z>0
|
|
1256
|
+
- Calls compute_vi_generic to get fraction that sees sky
|
|
1257
|
+
- Returns array of SVF values (same length as face_centers)
|
|
1258
|
+
"""
|
|
1259
|
+
n_faces = face_centers.shape[0]
|
|
1260
|
+
face_svf_values = np.zeros(n_faces, dtype=np.float64)
|
|
1261
|
+
|
|
1262
|
+
z_axis = np.array([0.0, 0.0, 1.0])
|
|
1263
|
+
|
|
1264
|
+
for fidx in range(n_faces):
|
|
1265
|
+
center = face_centers[fidx]
|
|
1266
|
+
normal = face_normals[fidx]
|
|
1267
|
+
|
|
1268
|
+
# -- 1) Check for boundary + vertical face => NaN
|
|
1269
|
+
is_vertical = (abs(normal[2]) < 0.01)
|
|
1270
|
+
|
|
1271
|
+
on_x_min = (abs(center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
|
|
1272
|
+
on_y_min = (abs(center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
|
|
1273
|
+
on_x_max = (abs(center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
|
|
1274
|
+
on_y_max = (abs(center[1] - grid_bounds_real[1,1]) < boundary_epsilon)
|
|
1275
|
+
|
|
1276
|
+
is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
|
|
1277
|
+
if is_boundary_vertical:
|
|
1278
|
+
face_svf_values[fidx] = np.nan
|
|
1279
|
+
continue
|
|
1280
|
+
|
|
1281
|
+
# -- 2) Compute rotation that aligns face normal -> +Z
|
|
1282
|
+
norm_n = np.sqrt(normal[0]**2 + normal[1]**2 + normal[2]**2)
|
|
1283
|
+
if norm_n < 1e-12:
|
|
1284
|
+
# Degenerate normal
|
|
1285
|
+
face_svf_values[fidx] = 0.0
|
|
1286
|
+
continue
|
|
1287
|
+
|
|
1288
|
+
dot_zn = z_axis[0]*normal[0] + z_axis[1]*normal[1] + z_axis[2]*normal[2]
|
|
1289
|
+
cos_angle = dot_zn / (norm_n)
|
|
1290
|
+
if cos_angle > 1.0: cos_angle = 1.0
|
|
1291
|
+
if cos_angle < -1.0: cos_angle = -1.0
|
|
1292
|
+
angle = np.arccos(cos_angle)
|
|
1293
|
+
|
|
1294
|
+
# Distinguish near +Z vs near -Z vs general case
|
|
1295
|
+
if abs(cos_angle - 1.0) < 1e-9:
|
|
1296
|
+
# normal ~ +Z => no rotation
|
|
1297
|
+
local_dirs = hemisphere_dirs
|
|
1298
|
+
elif abs(cos_angle + 1.0) < 1e-9:
|
|
1299
|
+
# normal ~ -Z => rotate 180 around X (or Y) axis
|
|
1300
|
+
axis_180 = np.array([1.0, 0.0, 0.0])
|
|
1301
|
+
local_dirs = np.empty_like(hemisphere_dirs)
|
|
1302
|
+
for i in range(hemisphere_dirs.shape[0]):
|
|
1303
|
+
local_dirs[i] = rotate_vector_axis_angle(hemisphere_dirs[i], axis_180, np.pi)
|
|
1304
|
+
else:
|
|
1305
|
+
# normal is neither up nor down -> do standard axis-angle
|
|
1306
|
+
axis_x = z_axis[1]*normal[2] - z_axis[2]*normal[1]
|
|
1307
|
+
axis_y = z_axis[2]*normal[0] - z_axis[0]*normal[2]
|
|
1308
|
+
axis_z = z_axis[0]*normal[1] - z_axis[1]*normal[0]
|
|
1309
|
+
rot_axis = np.array([axis_x, axis_y, axis_z], dtype=np.float64)
|
|
1310
|
+
|
|
1311
|
+
local_dirs = np.empty_like(hemisphere_dirs)
|
|
1312
|
+
for i in range(hemisphere_dirs.shape[0]):
|
|
1313
|
+
local_dirs[i] = rotate_vector_axis_angle(
|
|
1314
|
+
hemisphere_dirs[i],
|
|
1315
|
+
rot_axis,
|
|
1316
|
+
angle
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# -- 3) Count how many directions are outward & upward
|
|
1320
|
+
total_outward = 0
|
|
1321
|
+
num_upward = 0
|
|
1322
|
+
for i in range(local_dirs.shape[0]):
|
|
1323
|
+
dvec = local_dirs[i]
|
|
1324
|
+
dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
|
|
1325
|
+
if dp > 0.0:
|
|
1326
|
+
total_outward += 1
|
|
1327
|
+
if dvec[2] > 0.0:
|
|
1328
|
+
num_upward += 1
|
|
1329
|
+
|
|
1330
|
+
# If no outward directions at all => SVF=0
|
|
1331
|
+
if total_outward == 0:
|
|
1332
|
+
face_svf_values[fidx] = 0.0
|
|
1333
|
+
continue
|
|
1334
|
+
|
|
1335
|
+
# If no upward directions among them => SVF=0
|
|
1336
|
+
if num_upward == 0:
|
|
1337
|
+
face_svf_values[fidx] = 0.0
|
|
1338
|
+
continue
|
|
1339
|
+
|
|
1340
|
+
# -- 4) Create an array for only the upward directions
|
|
1341
|
+
valid_dirs_arr = np.empty((num_upward, 3), dtype=np.float64)
|
|
1342
|
+
out_idx = 0
|
|
1343
|
+
for i in range(local_dirs.shape[0]):
|
|
1344
|
+
dvec = local_dirs[i]
|
|
1345
|
+
dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
|
|
1346
|
+
if dp > 0.0 and dvec[2] > 0.0:
|
|
1347
|
+
valid_dirs_arr[out_idx, 0] = dvec[0]
|
|
1348
|
+
valid_dirs_arr[out_idx, 1] = dvec[1]
|
|
1349
|
+
valid_dirs_arr[out_idx, 2] = dvec[2]
|
|
1350
|
+
out_idx += 1
|
|
1351
|
+
|
|
1352
|
+
# -- 5) Ray origin in voxel coords, offset along face normal
|
|
1353
|
+
offset_vox = 0.1
|
|
1354
|
+
ray_origin = (center / meshsize) + (normal / norm_n) * offset_vox
|
|
1355
|
+
|
|
1356
|
+
# -- 6) Compute fraction of rays that see sky
|
|
1357
|
+
upward_svf = compute_vi_generic(
|
|
1358
|
+
ray_origin,
|
|
1359
|
+
voxel_data,
|
|
1360
|
+
valid_dirs_arr,
|
|
1361
|
+
hit_values,
|
|
1362
|
+
meshsize,
|
|
1363
|
+
tree_k,
|
|
1364
|
+
tree_lad,
|
|
1365
|
+
inclusion_mode
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
# Scale by fraction of directions that were outward
|
|
1369
|
+
fraction_up = num_upward / total_outward
|
|
1370
|
+
face_svf_values[fidx] = upward_svf * fraction_up
|
|
1371
|
+
|
|
1372
|
+
return face_svf_values
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
##############################################################################
|
|
1376
|
+
# 3) Modified get_building_surface_svf (only numeric loop changed)
|
|
1377
|
+
##############################################################################
|
|
1378
|
+
def get_building_surface_svf(voxel_data, meshsize, **kwargs):
|
|
1379
|
+
"""
|
|
1380
|
+
Compute and visualize the Sky View Factor (SVF) for building surface meshes.
|
|
1381
|
+
|
|
1382
|
+
Args:
|
|
1383
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
1384
|
+
meshsize (float): Size of each voxel in meters.
|
|
1385
|
+
**kwargs: Additional parameters (colormap, ray counts, etc.)
|
|
1386
|
+
|
|
1387
|
+
Returns:
|
|
1388
|
+
trimesh.Trimesh: Mesh of building surfaces with SVF values stored in metadata.
|
|
1389
|
+
"""
|
|
1390
|
+
import matplotlib.pyplot as plt
|
|
1391
|
+
import matplotlib.cm as cm
|
|
1392
|
+
import matplotlib.colors as mcolors
|
|
1393
|
+
import os
|
|
1394
|
+
|
|
1395
|
+
# Default parameters
|
|
1396
|
+
colormap = kwargs.get("colormap", 'BuPu_r')
|
|
1397
|
+
vmin = kwargs.get("vmin", 0.0)
|
|
1398
|
+
vmax = kwargs.get("vmax", 1.0)
|
|
1399
|
+
N_azimuth = kwargs.get("N_azimuth", 60)
|
|
1400
|
+
N_elevation = kwargs.get("N_elevation", 10)
|
|
1401
|
+
debug = kwargs.get("debug", False)
|
|
1402
|
+
progress_report = kwargs.get("progress_report", False)
|
|
1403
|
+
building_id_grid = kwargs.get("building_id_grid", None)
|
|
1404
|
+
|
|
1405
|
+
# Tree parameters
|
|
1406
|
+
tree_k = kwargs.get("tree_k", 0.6)
|
|
1407
|
+
tree_lad = kwargs.get("tree_lad", 1.0)
|
|
1408
|
+
|
|
1409
|
+
# Sky detection parameters
|
|
1410
|
+
hit_values = (0,) # '0' is sky
|
|
1411
|
+
inclusion_mode = False # we want rays that DON'T hit obstacles (except sky)
|
|
1412
|
+
|
|
1413
|
+
# Building ID in voxel data
|
|
1414
|
+
building_class_id = kwargs.get("building_class_id", -3)
|
|
1415
|
+
|
|
1416
|
+
start_time = time.time()
|
|
1417
|
+
# 1) Extract building mesh from voxel_data
|
|
1418
|
+
try:
|
|
1419
|
+
# This function is presumably in your codebase (not shown):
|
|
1420
|
+
building_mesh = create_voxel_mesh(voxel_data, building_class_id, meshsize, building_id_grid=building_id_grid)
|
|
1421
|
+
if building_mesh is None or len(building_mesh.faces) == 0:
|
|
1422
|
+
print("No building surfaces found in voxel data.")
|
|
1423
|
+
return None
|
|
1424
|
+
except Exception as e:
|
|
1425
|
+
print(f"Error during mesh extraction: {e}")
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
if progress_report:
|
|
1429
|
+
print(f"Processing SVF for {len(building_mesh.faces)} building faces...")
|
|
1430
|
+
|
|
1431
|
+
# 2) Get face centers + normals as NumPy arrays
|
|
1432
|
+
face_centers = building_mesh.triangles_center
|
|
1433
|
+
face_normals = building_mesh.face_normals
|
|
1434
|
+
|
|
1435
|
+
# 3) Precompute hemisphere directions (global, pointing up)
|
|
1436
|
+
azimuth_angles = np.linspace(0, 2*np.pi, N_azimuth, endpoint=False)
|
|
1437
|
+
elevation_angles = np.linspace(0, np.pi/2, N_elevation)
|
|
1438
|
+
hemisphere_list = []
|
|
1439
|
+
for elev in elevation_angles:
|
|
1440
|
+
sin_elev = np.sin(elev)
|
|
1441
|
+
cos_elev = np.cos(elev)
|
|
1442
|
+
for az in azimuth_angles:
|
|
1443
|
+
x = cos_elev * np.cos(az)
|
|
1444
|
+
y = cos_elev * np.sin(az)
|
|
1445
|
+
z = sin_elev
|
|
1446
|
+
hemisphere_list.append([x, y, z])
|
|
1447
|
+
hemisphere_dirs = np.array(hemisphere_list, dtype=np.float64)
|
|
1448
|
+
|
|
1449
|
+
# 4) Domain bounds in real coordinates
|
|
1450
|
+
grid_shape = voxel_data.shape
|
|
1451
|
+
grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0],grid_shape[1],grid_shape[2]]], dtype=np.float64)
|
|
1452
|
+
grid_bounds_real = grid_bounds_voxel * meshsize
|
|
1453
|
+
boundary_epsilon = meshsize * 0.05
|
|
1454
|
+
|
|
1455
|
+
# 5) Call Numba-accelerated routine
|
|
1456
|
+
face_svf_values = compute_svf_for_all_faces(
|
|
1457
|
+
face_centers,
|
|
1458
|
+
face_normals,
|
|
1459
|
+
hemisphere_dirs,
|
|
1460
|
+
voxel_data,
|
|
1461
|
+
meshsize,
|
|
1462
|
+
tree_k,
|
|
1463
|
+
tree_lad,
|
|
1464
|
+
hit_values,
|
|
1465
|
+
inclusion_mode,
|
|
1466
|
+
grid_bounds_real,
|
|
1467
|
+
boundary_epsilon
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
# 6) Store SVF values in mesh metadata
|
|
1471
|
+
if not hasattr(building_mesh, 'metadata'):
|
|
1472
|
+
building_mesh.metadata = {}
|
|
1473
|
+
building_mesh.metadata['svf_values'] = face_svf_values
|
|
1474
|
+
|
|
1475
|
+
# 7) Optional: visualization & export
|
|
1476
|
+
show_plot = kwargs.get("show_plot", False)
|
|
1477
|
+
if show_plot:
|
|
1478
|
+
# Replace NaN with a sentinel for color mapping
|
|
1479
|
+
vis_values = face_svf_values.copy()
|
|
1480
|
+
nan_mask = np.isnan(vis_values)
|
|
1481
|
+
if np.any(nan_mask):
|
|
1482
|
+
vis_values[nan_mask] = vmin - 0.1 # force them to min color
|
|
1483
|
+
|
|
1484
|
+
cmap = cm.get_cmap(colormap)
|
|
1485
|
+
norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
1486
|
+
face_colors = cmap(norm(vis_values))
|
|
1487
|
+
building_mesh.visual.face_colors = face_colors
|
|
1488
|
+
|
|
1489
|
+
# Show in Trimesh viewer
|
|
1490
|
+
scene = trimesh.Scene()
|
|
1491
|
+
scene.add_geometry(building_mesh)
|
|
1492
|
+
scene.show()
|
|
1493
|
+
|
|
1494
|
+
# Also a histogram
|
|
1495
|
+
valid_svf = face_svf_values[~np.isnan(face_svf_values)]
|
|
1496
|
+
plt.figure(figsize=(8, 5))
|
|
1497
|
+
plt.hist(valid_svf, bins=50, alpha=0.7)
|
|
1498
|
+
plt.title("Distribution of Sky View Factor")
|
|
1499
|
+
plt.xlabel("SVF")
|
|
1500
|
+
plt.ylabel("Count")
|
|
1501
|
+
plt.grid(True, alpha=0.3)
|
|
1502
|
+
plt.show()
|
|
1503
|
+
|
|
1504
|
+
# OBJ export if desired
|
|
1505
|
+
obj_export = kwargs.get("obj_export", False)
|
|
1506
|
+
if obj_export:
|
|
1507
|
+
output_dir = kwargs.get("output_directory", "output")
|
|
1508
|
+
output_file_name = kwargs.get("output_file_name", "building_surface_svf")
|
|
1509
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1510
|
+
try:
|
|
1511
|
+
building_mesh.export(f"{output_dir}/{output_file_name}.obj")
|
|
1512
|
+
print(f"Exported building SVF mesh to {output_dir}/{output_file_name}.obj")
|
|
1513
|
+
except Exception as e:
|
|
1514
|
+
print(f"Error exporting mesh: {e}")
|
|
1515
|
+
|
|
1516
|
+
return building_mesh
|