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/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 += (1.0 - value) if value < 1.0 else 1.0
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