voxcity 0.5.14__py3-none-any.whl → 0.5.16__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
@@ -114,7 +114,7 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
114
114
  x0, y0, z0 = origin
115
115
  dx, dy, dz = direction
116
116
 
117
- # Normalize direction vector
117
+ # Normalize direction vector to ensure consistent step sizes
118
118
  length = np.sqrt(dx*dx + dy*dy + dz*dz)
119
119
  if length == 0.0:
120
120
  return False, 1.0
@@ -122,18 +122,19 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
122
122
  dy /= length
123
123
  dz /= length
124
124
 
125
- # Initialize ray position
125
+ # Initialize ray position at center of starting voxel
126
126
  x, y, z = x0 + 0.5, y0 + 0.5, z0 + 0.5
127
127
  i, j, k = int(x0), int(y0), int(z0)
128
128
 
129
- # Calculate step directions and initial distances
129
+ # Determine step direction for each axis (-1 or +1)
130
130
  step_x = 1 if dx >= 0 else -1
131
131
  step_y = 1 if dy >= 0 else -1
132
132
  step_z = 1 if dz >= 0 else -1
133
133
 
134
- # Calculate DDA parameters with safety checks
134
+ # Calculate DDA parameters with safety checks to prevent division by zero
135
135
  EPSILON = 1e-10 # Small value to prevent division by zero
136
136
 
137
+ # Calculate distances to next voxel boundaries and step sizes for X-axis
137
138
  if abs(dx) > EPSILON:
138
139
  t_max_x = ((i + (step_x > 0)) - x) / dx
139
140
  t_delta_x = abs(1 / dx)
@@ -141,6 +142,7 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
141
142
  t_max_x = np.inf
142
143
  t_delta_x = np.inf
143
144
 
145
+ # Calculate distances to next voxel boundaries and step sizes for Y-axis
144
146
  if abs(dy) > EPSILON:
145
147
  t_max_y = ((j + (step_y > 0)) - y) / dy
146
148
  t_delta_y = abs(1 / dy)
@@ -148,6 +150,7 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
148
150
  t_max_y = np.inf
149
151
  t_delta_y = np.inf
150
152
 
153
+ # Calculate distances to next voxel boundaries and step sizes for Z-axis
151
154
  if abs(dz) > EPSILON:
152
155
  t_max_z = ((k + (step_z > 0)) - z) / dz
153
156
  t_delta_z = abs(1 / dz)
@@ -155,40 +158,39 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
155
158
  t_max_z = np.inf
156
159
  t_delta_z = np.inf
157
160
 
158
- # Track cumulative values
161
+ # Track cumulative values for tree transmittance calculation
159
162
  cumulative_transmittance = 1.0
160
163
  cumulative_hit_contribution = 0.0
161
164
  last_t = 0.0
162
165
 
163
- # Main ray traversal loop
166
+ # Main ray traversal loop using DDA algorithm
164
167
  while (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
165
168
  voxel_value = voxel_data[i, j, k]
166
169
 
167
- # Find next intersection
170
+ # Find next intersection point along the ray
168
171
  t_next = min(t_max_x, t_max_y, t_max_z)
169
172
 
170
- # Calculate segment length in current voxel
173
+ # Calculate segment length in current voxel (in real world units)
171
174
  segment_length = (t_next - last_t) * meshsize
172
175
  segment_length = max(0.0, segment_length)
173
176
 
174
- # Handle tree voxels (value -2)
177
+ # Handle tree voxels (value -2) with Beer-Lambert law transmittance
175
178
  if voxel_value == -2:
176
179
  transmittance = calculate_transmittance(segment_length, tree_k, tree_lad)
177
180
  cumulative_transmittance *= transmittance
178
181
 
179
- # if segment_length < 0:
180
- # print(f"segment_length = {segment_length}, transmittance = {transmittance}, cumulative_transmittance = {cumulative_transmittance}")
181
-
182
- # If transmittance becomes too low, consider it a hit
182
+ # If transmittance becomes too low, consider the ray blocked
183
183
  if cumulative_transmittance < 0.01:
184
184
  return True, cumulative_transmittance
185
185
 
186
- # Check for hits with other objects
186
+ # Check for hits with target objects based on inclusion/exclusion mode
187
187
  if inclusion_mode:
188
+ # Inclusion mode: hit if voxel value is in the target set
188
189
  for hv in hit_values:
189
190
  if voxel_value == hv:
190
191
  return True, cumulative_transmittance
191
192
  else:
193
+ # Exclusion mode: hit if voxel value is NOT in the allowed set
192
194
  in_set = False
193
195
  for hv in hit_values:
194
196
  if voxel_value == hv:
@@ -200,22 +202,27 @@ def trace_ray_generic(voxel_data, origin, direction, hit_values, meshsize, tree_
200
202
  # Update for next iteration
201
203
  last_t = t_next
202
204
 
203
- # Move to next voxel
205
+ # Move to next voxel using DDA step logic
204
206
  if t_max_x < t_max_y:
205
207
  if t_max_x < t_max_z:
208
+ # Step in X direction
206
209
  t_max_x += t_delta_x
207
210
  i += step_x
208
211
  else:
212
+ # Step in Z direction
209
213
  t_max_z += t_delta_z
210
214
  k += step_z
211
215
  else:
212
216
  if t_max_y < t_max_z:
217
+ # Step in Y direction
213
218
  t_max_y += t_delta_y
214
219
  j += step_y
215
220
  else:
221
+ # Step in Z direction
216
222
  t_max_z += t_delta_z
217
223
  k += step_z
218
224
 
225
+ # Ray exited the grid without hitting a target
219
226
  return False, cumulative_transmittance
220
227
 
221
228
  @njit
@@ -248,23 +255,28 @@ def compute_vi_generic(observer_location, voxel_data, ray_directions, hit_values
248
255
  total_rays = ray_directions.shape[0]
249
256
  visibility_sum = 0.0
250
257
 
258
+ # Cast rays in all specified directions
251
259
  for idx in range(total_rays):
252
260
  direction = ray_directions[idx]
253
261
  hit, value = trace_ray_generic(voxel_data, observer_location, direction,
254
262
  hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
255
263
 
264
+ # Accumulate visibility contributions based on mode
256
265
  if inclusion_mode:
257
266
  if hit:
267
+ # For trees in hit_values, use partial visibility based on transmittance
258
268
  if -2 in hit_values:
259
- # For trees in hit_values, use the hit contribution (1 - transmittance)
269
+ # Use the hit contribution (1 - transmittance) for tree visibility
260
270
  visibility_sum += value if value < 1.0 else 1.0
261
271
  else:
272
+ # Full visibility for non-tree targets
262
273
  visibility_sum += 1.0
263
274
  else:
264
275
  if not hit:
265
- # For exclusion mode, use transmittance value directly
276
+ # For exclusion mode, use transmittance value directly as visibility
266
277
  visibility_sum += value
267
278
 
279
+ # Return average visibility across all rays
268
280
  return visibility_sum / total_rays
269
281
 
270
282
  @njit(parallel=True)
@@ -298,28 +310,33 @@ def compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel, hit_va
298
310
  nx, ny, nz = voxel_data.shape
299
311
  vi_map = np.full((nx, ny), np.nan)
300
312
 
313
+ # Process each horizontal position in parallel for efficiency
301
314
  for x in prange(nx):
302
315
  for y in range(ny):
303
316
  found_observer = False
317
+ # Search from bottom to top for valid observer placement
304
318
  for z in range(1, nz):
305
- # Check for valid observer location
319
+ # Check for valid observer location: empty space above solid ground
306
320
  if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
307
- # Skip invalid ground types
321
+ # Skip invalid ground types (water or negative values)
308
322
  if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
309
323
  vi_map[x, y] = np.nan
310
324
  found_observer = True
311
325
  break
312
326
  else:
313
- # Place observer and compute view index
327
+ # Place observer at specified height above ground level
314
328
  observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
329
+ # Compute view index for this location
315
330
  vi_value = compute_vi_generic(observer_location, voxel_data, ray_directions,
316
331
  hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
317
332
  vi_map[x, y] = vi_value
318
333
  found_observer = True
319
334
  break
335
+ # Mark locations where no valid observer position was found
320
336
  if not found_observer:
321
337
  vi_map[x, y] = np.nan
322
338
 
339
+ # Flip vertically to match display orientation
323
340
  return np.flipud(vi_map)
324
341
 
325
342
  def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_mode=True, **kwargs):
@@ -365,10 +382,10 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
365
382
  Returns:
366
383
  ndarray: 2D array of computed view index values.
367
384
  """
368
- # Handle mode presets
385
+ # Handle predefined mode presets for common view indices
369
386
  if mode == 'green':
370
387
  # GVI defaults - detect vegetation and trees
371
- hit_values = (-2, 2, 5, 7)
388
+ hit_values = (-2, 2, 5, 6, 7, 8)
372
389
  inclusion_mode = True
373
390
  elif mode == 'sky':
374
391
  # SVI defaults - detect open sky
@@ -379,22 +396,25 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
379
396
  if hit_values is None:
380
397
  raise ValueError("For custom mode, you must provide hit_values.")
381
398
 
382
- # Get parameters from kwargs with defaults
399
+ # Extract parameters from kwargs with sensible defaults
383
400
  view_point_height = kwargs.get("view_point_height", 1.5)
384
401
  view_height_voxel = int(view_point_height / meshsize)
385
402
  colormap = kwargs.get("colormap", 'viridis')
386
403
  vmin = kwargs.get("vmin", 0.0)
387
404
  vmax = kwargs.get("vmax", 1.0)
405
+
406
+ # Ray casting parameters for hemisphere sampling
388
407
  N_azimuth = kwargs.get("N_azimuth", 60)
389
408
  N_elevation = kwargs.get("N_elevation", 10)
390
409
  elevation_min_degrees = kwargs.get("elevation_min_degrees", -30)
391
410
  elevation_max_degrees = kwargs.get("elevation_max_degrees", 30)
392
411
 
393
- # Tree transmittance parameters
412
+ # Tree transmittance parameters for Beer-Lambert law
394
413
  tree_k = kwargs.get("tree_k", 0.5)
395
414
  tree_lad = kwargs.get("tree_lad", 1.0)
396
415
 
397
416
  # Generate ray directions using spherical coordinates
417
+ # Create uniform sampling over specified azimuth and elevation ranges
398
418
  azimuth_angles = np.linspace(0, 2 * np.pi, N_azimuth, endpoint=False)
399
419
  elevation_angles = np.deg2rad(np.linspace(elevation_min_degrees, elevation_max_degrees, N_elevation))
400
420
 
@@ -403,6 +423,7 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
403
423
  cos_elev = np.cos(elevation)
404
424
  sin_elev = np.sin(elevation)
405
425
  for azimuth in azimuth_angles:
426
+ # Convert spherical coordinates to Cartesian
406
427
  dx = cos_elev * np.cos(azimuth)
407
428
  dy = cos_elev * np.sin(azimuth)
408
429
  dz = sin_elev
@@ -413,17 +434,17 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
413
434
  vi_map = compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel,
414
435
  hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
415
436
 
416
- # Plot results
437
+ # Create visualization with custom colormap handling
417
438
  import matplotlib.pyplot as plt
418
439
  cmap = plt.cm.get_cmap(colormap).copy()
419
- cmap.set_bad(color='lightgray')
440
+ cmap.set_bad(color='lightgray') # Color for NaN values (invalid locations)
420
441
  plt.figure(figsize=(10, 8))
421
442
  plt.imshow(vi_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
422
443
  plt.colorbar(label='View Index')
423
444
  plt.axis('off')
424
445
  plt.show()
425
446
 
426
- # Optional OBJ export
447
+ # Optional OBJ export for 3D visualization
427
448
  obj_export = kwargs.get("obj_export", False)
428
449
  if obj_export:
429
450
  dem_grid = kwargs.get("dem_grid", np.zeros_like(vi_map))
@@ -450,28 +471,33 @@ def get_view_index(voxel_data, meshsize, mode=None, hit_values=None, inclusion_m
450
471
  def mark_building_by_id(voxcity_grid_ori, building_id_grid_ori, ids, mark):
451
472
  """Mark specific buildings in the voxel grid with a given value.
452
473
 
453
- Used to identify landmark buildings for visibility analysis.
454
- Flips building ID grid vertically to match voxel grid orientation.
474
+ This function is used to identify landmark buildings for visibility analysis
475
+ by replacing their voxel values with a special marker value. It handles
476
+ coordinate system alignment between the building ID grid and voxel grid.
455
477
 
456
478
  Args:
457
- voxcity_grid (ndarray): 3D array of voxel values
458
- building_id_grid_ori (ndarray): 2D array of building IDs
459
- ids (list): List of building IDs to mark
460
- mark (int): Value to mark the buildings with
461
- """
479
+ voxcity_grid_ori (ndarray): 3D array of voxel values (original, will be copied)
480
+ building_id_grid_ori (ndarray): 2D array of building IDs (original, will be copied)
481
+ ids (list): List of building IDs to mark as landmarks
482
+ mark (int): Value to mark the landmark buildings with (typically negative)
462
483
 
484
+ Returns:
485
+ ndarray: Modified 3D voxel grid with landmark buildings marked
486
+ """
487
+ # Create working copies to avoid modifying original data
463
488
  voxcity_grid = voxcity_grid_ori.copy()
464
489
 
465
490
  # Flip building ID grid vertically to match voxel grid orientation
491
+ # This accounts for different coordinate system conventions
466
492
  building_id_grid = np.flipud(building_id_grid_ori.copy())
467
493
 
468
- # Get x,y positions from building_id_grid where landmarks are
494
+ # Find x,y positions where target building IDs are located
469
495
  positions = np.where(np.isin(building_id_grid, ids))
470
496
 
471
- # Loop through each x,y position and mark building voxels
497
+ # Process each location containing a target building
472
498
  for i in range(len(positions[0])):
473
499
  x, y = positions[0][i], positions[1][i]
474
- # Replace building voxels (-3) with mark value at this x,y position
500
+ # Find all building voxels (-3) at this x,y location and mark them
475
501
  z_mask = voxcity_grid[x, y, :] == -3
476
502
  voxcity_grid[x, y, z_mask] = mark
477
503
 
@@ -500,7 +526,7 @@ def trace_ray_to_target(voxel_data, origin, target, opaque_values):
500
526
  dy = y1 - y0
501
527
  dz = z1 - z0
502
528
 
503
- # Normalize direction vector
529
+ # Normalize direction vector for consistent traversal
504
530
  length = np.sqrt(dx*dx + dy*dy + dz*dz)
505
531
  if length == 0.0:
506
532
  return True # Origin and target are at the same location
@@ -518,7 +544,7 @@ def trace_ray_to_target(voxel_data, origin, target, opaque_values):
518
544
  step_z = 1 if dz >= 0 else -1
519
545
 
520
546
  # Calculate distances to next voxel boundaries and step sizes
521
- # Handle cases where direction components are zero
547
+ # Handle cases where direction components are zero to avoid division by zero
522
548
  if dx != 0:
523
549
  t_max_x = ((i + (step_x > 0)) - x) / dx
524
550
  t_delta_x = abs(1 / dx)
@@ -540,21 +566,22 @@ def trace_ray_to_target(voxel_data, origin, target, opaque_values):
540
566
  t_max_z = np.inf
541
567
  t_delta_z = np.inf
542
568
 
543
- # Main ray traversal loop
569
+ # Main ray traversal loop using DDA algorithm
544
570
  while True:
545
- # Check if current voxel is within bounds and opaque
571
+ # Check if current voxel is within bounds and contains opaque material
546
572
  if (0 <= i < nx) and (0 <= j < ny) and (0 <= k < nz):
547
573
  voxel_value = voxel_data[i, j, k]
548
574
  if voxel_value in opaque_values:
549
- return False # Ray is blocked
575
+ return False # Ray is blocked by opaque voxel
550
576
  else:
551
- return False # Out of bounds
577
+ return False # Ray went out of bounds before reaching target
552
578
 
553
- # Check if we've reached target voxel
579
+ # Check if we've reached the target voxel
554
580
  if i == int(x1) and j == int(y1) and k == int(z1):
555
- return True # Ray has reached the target
581
+ return True # Ray successfully reached the target
556
582
 
557
583
  # Move to next voxel using DDA algorithm
584
+ # Choose the axis with the smallest distance to next boundary
558
585
  if t_max_x < t_max_y:
559
586
  if t_max_x < t_max_z:
560
587
  t_max = t_max_x
@@ -583,20 +610,21 @@ def compute_visibility_to_all_landmarks(observer_location, landmark_positions, v
583
610
 
584
611
  Args:
585
612
  observer_location (ndarray): Observer position (x,y,z) in voxel coordinates
586
- landmark_positions (ndarray): Array of landmark positions
613
+ landmark_positions (ndarray): Array of landmark positions (n_landmarks, 3)
587
614
  voxel_data (ndarray): 3D array of voxel values
588
615
  opaque_values (ndarray): Array of voxel values that block visibility
589
616
 
590
617
  Returns:
591
618
  int: 1 if any landmark is visible, 0 if none are visible
592
619
  """
593
- # Check visibility to each landmark until one is found visible
620
+ # Check visibility to each landmark sequentially
621
+ # Early exit strategy: return 1 as soon as any landmark is visible
594
622
  for idx in range(landmark_positions.shape[0]):
595
623
  target = landmark_positions[idx].astype(np.float64)
596
624
  is_visible = trace_ray_to_target(voxel_data, observer_location, target, opaque_values)
597
625
  if is_visible:
598
- return 1 # Return as soon as one landmark is visible
599
- return 0 # No landmarks were visible
626
+ return 1 # Return immediately when first visible landmark is found
627
+ return 0 # No landmarks were visible from this location
600
628
 
601
629
  @njit(parallel=True)
602
630
  def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_height_voxel):
@@ -613,7 +641,7 @@ def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_h
613
641
 
614
642
  Args:
615
643
  voxel_data (ndarray): 3D array of voxel values
616
- landmark_positions (ndarray): Array of landmark positions
644
+ landmark_positions (ndarray): Array of landmark positions (n_landmarks, 3)
617
645
  opaque_values (ndarray): Array of voxel values that block visibility
618
646
  view_height_voxel (int): Height offset for observer in voxels
619
647
 
@@ -626,25 +654,28 @@ def compute_visibility_map(voxel_data, landmark_positions, opaque_values, view_h
626
654
  nx, ny, nz = voxel_data.shape
627
655
  visibility_map = np.full((nx, ny), np.nan)
628
656
 
629
- # Process each x,y position in parallel
657
+ # Process each x,y position in parallel for computational efficiency
630
658
  for x in prange(nx):
631
659
  for y in range(ny):
632
660
  found_observer = False
633
- # Find lowest empty voxel above ground
661
+ # Find the lowest valid observer location by searching from bottom up
634
662
  for z in range(1, nz):
663
+ # Valid observer location: empty voxel above non-empty ground
635
664
  if voxel_data[x, y, z] == 0 and voxel_data[x, y, z - 1] != 0:
636
- # Skip if standing on building or vegetation
665
+ # Skip locations above building roofs or vegetation
637
666
  if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
638
667
  visibility_map[x, y] = np.nan
639
668
  found_observer = True
640
669
  break
641
670
  else:
642
- # Place observer and check visibility
671
+ # Place observer at specified height above ground level
643
672
  observer_location = np.array([x, y, z+view_height_voxel], dtype=np.float64)
673
+ # Check visibility to any landmark from this location
644
674
  visible = compute_visibility_to_all_landmarks(observer_location, landmark_positions, voxel_data, opaque_values)
645
675
  visibility_map[x, y] = visible
646
676
  found_observer = True
647
677
  break
678
+ # Mark locations where no valid observer position exists
648
679
  if not found_observer:
649
680
  visibility_map[x, y] = np.nan
650
681
 
@@ -806,35 +837,55 @@ def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
806
837
  """
807
838
  Compute and visualize the Sky View Factor (SVF) for each valid observer cell in the voxel grid.
808
839
 
840
+ Sky View Factor measures the proportion of the sky hemisphere that is visible from a given point.
841
+ It ranges from 0 (completely obstructed) to 1 (completely open sky). This implementation:
842
+ - Uses hemisphere ray casting to sample sky visibility
843
+ - Accounts for tree transmittance using Beer-Lambert law
844
+ - Places observers at valid street-level locations
845
+ - Provides optional visualization and OBJ export
846
+
809
847
  Args:
810
848
  voxel_data (ndarray): 3D array of voxel values.
811
849
  meshsize (float): Size of each voxel in meters.
812
- show_plot (bool): Whether to display the plot.
813
- **kwargs: Additional parameters.
850
+ show_plot (bool): Whether to display the SVF visualization plot.
851
+ **kwargs: Additional parameters including:
852
+ view_point_height (float): Observer height in meters (default: 1.5)
853
+ colormap (str): Matplotlib colormap name (default: 'BuPu_r')
854
+ vmin, vmax (float): Color scale limits (default: 0.0, 1.0)
855
+ N_azimuth (int): Number of azimuth angles for ray sampling (default: 60)
856
+ N_elevation (int): Number of elevation angles for ray sampling (default: 10)
857
+ elevation_min_degrees (float): Minimum elevation angle (default: 0)
858
+ elevation_max_degrees (float): Maximum elevation angle (default: 90)
859
+ tree_k (float): Tree extinction coefficient (default: 0.6)
860
+ tree_lad (float): Leaf area density in m^-1 (default: 1.0)
861
+ obj_export (bool): Whether to export as OBJ file (default: False)
814
862
 
815
863
  Returns:
816
- ndarray: 2D array of SVF values at each cell (x, y).
864
+ ndarray: 2D array of SVF values at each valid observer location (x, y).
865
+ NaN values indicate invalid observer positions.
817
866
  """
818
- # Default parameters
867
+ # Extract default parameters with sky-specific settings
819
868
  view_point_height = kwargs.get("view_point_height", 1.5)
820
869
  view_height_voxel = int(view_point_height / meshsize)
821
- colormap = kwargs.get("colormap", 'BuPu_r')
870
+ colormap = kwargs.get("colormap", 'BuPu_r') # Blue-purple colormap suitable for sky
822
871
  vmin = kwargs.get("vmin", 0.0)
823
872
  vmax = kwargs.get("vmax", 1.0)
824
- N_azimuth = kwargs.get("N_azimuth", 60)
825
- N_elevation = kwargs.get("N_elevation", 10)
826
- elevation_min_degrees = kwargs.get("elevation_min_degrees", 0)
827
- elevation_max_degrees = kwargs.get("elevation_max_degrees", 90)
873
+
874
+ # Ray sampling parameters optimized for sky view factor
875
+ N_azimuth = kwargs.get("N_azimuth", 60) # Full 360-degree azimuth sampling
876
+ N_elevation = kwargs.get("N_elevation", 10) # Hemisphere elevation sampling
877
+ elevation_min_degrees = kwargs.get("elevation_min_degrees", 0) # Horizon
878
+ elevation_max_degrees = kwargs.get("elevation_max_degrees", 90) # Zenith
828
879
 
829
- # Get tree transmittance parameters
830
- tree_k = kwargs.get("tree_k", 0.6) # Static extinction coefficient
831
- tree_lad = kwargs.get("tree_lad", 1.0) # Leaf area density in m^-1
880
+ # Tree transmittance parameters for Beer-Lambert law
881
+ tree_k = kwargs.get("tree_k", 0.6) # Static extinction coefficient
882
+ tree_lad = kwargs.get("tree_lad", 1.0) # Leaf area density in m^-1
832
883
 
833
- # Define hit_values and inclusion_mode for sky detection
834
- hit_values = (0,)
835
- inclusion_mode = False
884
+ # Sky view factor configuration: detect open sky (value 0)
885
+ hit_values = (0,) # Sky voxels have value 0
886
+ inclusion_mode = False # Count rays that DON'T hit obstacles (exclusion mode)
836
887
 
837
- # Generate ray directions over the specified hemisphere
888
+ # Generate ray directions over the sky hemisphere (0 to 90 degrees elevation)
838
889
  azimuth_angles = np.linspace(0, 2 * np.pi, N_azimuth, endpoint=False)
839
890
  elevation_angles = np.deg2rad(np.linspace(elevation_min_degrees, elevation_max_degrees, N_elevation))
840
891
 
@@ -843,29 +894,29 @@ def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
843
894
  cos_elev = np.cos(elevation)
844
895
  sin_elev = np.sin(elevation)
845
896
  for azimuth in azimuth_angles:
897
+ # Convert spherical to Cartesian coordinates
846
898
  dx = cos_elev * np.cos(azimuth)
847
899
  dy = cos_elev * np.sin(azimuth)
848
- dz = sin_elev
900
+ dz = sin_elev # Always positive for sky hemisphere
849
901
  ray_directions.append([dx, dy, dz])
850
902
  ray_directions = np.array(ray_directions, dtype=np.float64)
851
903
 
852
- # Compute the SVF map using the compute function
904
+ # Compute the SVF map using the generic view index computation
853
905
  vi_map = compute_vi_map_generic(voxel_data, ray_directions, view_height_voxel,
854
906
  hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
855
907
 
856
- # Plot results if requested
908
+ # Display visualization if requested
857
909
  if show_plot:
858
910
  import matplotlib.pyplot as plt
859
911
  cmap = plt.cm.get_cmap(colormap).copy()
860
- cmap.set_bad(color='lightgray')
912
+ cmap.set_bad(color='lightgray') # Gray for invalid observer locations
861
913
  plt.figure(figsize=(10, 8))
862
- # plt.title("Sky View Factor Map")
863
914
  plt.imshow(vi_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
864
915
  plt.colorbar(label='Sky View Factor')
865
916
  plt.axis('off')
866
917
  plt.show()
867
918
 
868
- # Optional OBJ export
919
+ # Optional OBJ export for 3D visualization
869
920
  obj_export = kwargs.get("obj_export", False)
870
921
  if obj_export:
871
922
  dem_grid = kwargs.get("dem_grid", np.zeros_like(vi_map))
@@ -889,341 +940,55 @@ def get_sky_view_factor_map(voxel_data, meshsize, show_plot=False, **kwargs):
889
940
 
890
941
  return vi_map
891
942
 
892
- # def get_building_surface_svf(voxel_data, meshsize, **kwargs):
893
- # """
894
- # Compute and visualize the Sky View Factor (SVF) for building surface meshes.
895
-
896
- # Args:
897
- # voxel_data (ndarray): 3D array of voxel values.
898
- # meshsize (float): Size of each voxel in meters.
899
- # **kwargs: Additional parameters (colormap, ray counts, etc.)
900
-
901
- # Returns:
902
- # trimesh.Trimesh: Mesh of building surfaces with SVF values stored in metadata.
903
- # """
904
- # # Import required modules
905
- # import trimesh
906
- # import numpy as np
907
- # import time
908
-
909
- # # Default parameters
910
- # colormap = kwargs.get("colormap", 'BuPu_r')
911
- # vmin = kwargs.get("vmin", 0.0)
912
- # vmax = kwargs.get("vmax", 1.0)
913
- # N_azimuth = kwargs.get("N_azimuth", 60)
914
- # N_elevation = kwargs.get("N_elevation", 10)
915
- # debug = kwargs.get("debug", False)
916
- # progress_report = kwargs.get("progress_report", False)
917
-
918
- # # Tree transmittance parameters
919
- # tree_k = kwargs.get("tree_k", 0.6)
920
- # tree_lad = kwargs.get("tree_lad", 1.0)
921
-
922
- # # Sky detection parameters
923
- # hit_values = (0,) # Sky is typically represented by 0
924
- # inclusion_mode = False # We want rays that DON'T hit obstacles
925
-
926
- # # Extract building mesh (building voxels have value -3)
927
- # building_class_id = kwargs.get("building_class_id", -3)
928
- # start_time = time.time()
929
- # # print(f"Extracting building mesh for class ID {building_class_id}...")
930
- # try:
931
- # building_mesh = create_voxel_mesh(voxel_data, building_class_id, meshsize)
932
- # # print(f"Mesh extraction took {time.time() - start_time:.2f} seconds")
933
-
934
- # if building_mesh is None or len(building_mesh.faces) == 0:
935
- # print("No building surfaces found in voxel data.")
936
- # return None
937
-
938
- # # print(f"Successfully extracted mesh with {len(building_mesh.faces)} faces")
939
- # except Exception as e:
940
- # print(f"Error during mesh extraction: {e}")
941
- # return None
942
-
943
- # if progress_report:
944
- # print(f"Processing SVF for {len(building_mesh.faces)} building faces...")
945
-
946
- # try:
947
- # # Calculate face centers and normals
948
- # face_centers = building_mesh.triangles_center
949
- # face_normals = building_mesh.face_normals
950
-
951
- # # Initialize array to store SVF values for each face
952
- # face_svf_values = np.zeros(len(building_mesh.faces))
953
-
954
- # # Get voxel grid dimensions
955
- # grid_shape = voxel_data.shape
956
- # grid_bounds = np.array([
957
- # [0, 0, 0], # Min bounds in voxel coordinates
958
- # [grid_shape[0], grid_shape[1], grid_shape[2]] # Max bounds
959
- # ])
960
-
961
- # # Convert bounds to real-world coordinates
962
- # grid_bounds_real = grid_bounds * meshsize
963
-
964
- # # Small epsilon to detect boundary faces (within 0.5 voxel of boundary)
965
- # boundary_epsilon = meshsize * 0.05
966
-
967
- # # Create hemisphere directions for ray casting
968
- # hemisphere_dirs = []
969
- # azimuth_angles = np.linspace(0, 2 * np.pi, N_azimuth, endpoint=False)
970
- # elevation_angles = np.linspace(0, np.pi/2, N_elevation) # 0 to 90 degrees
971
-
972
- # for elevation in elevation_angles:
973
- # sin_elev = np.sin(elevation)
974
- # cos_elev = np.cos(elevation)
975
- # for azimuth in azimuth_angles:
976
- # x = cos_elev * np.cos(azimuth)
977
- # y = cos_elev * np.sin(azimuth)
978
- # z = sin_elev
979
- # hemisphere_dirs.append([x, y, z])
980
-
981
- # hemisphere_dirs = np.array(hemisphere_dirs)
982
-
983
- # # Process each face
984
- # from scipy.spatial.transform import Rotation
985
- # processed_count = 0
986
- # boundary_count = 0
987
- # nan_boundary_count = 0
988
-
989
- # start_time = time.time()
990
- # for face_idx in range(len(building_mesh.faces)):
991
- # try:
992
- # center = face_centers[face_idx]
993
- # normal = face_normals[face_idx]
994
-
995
- # # Check if this is a vertical surface (normal has no Z component)
996
- # is_vertical = abs(normal[2]) < 0.01
997
-
998
- # # Check if this face is on the boundary of the voxel grid
999
- # on_x_min = abs(center[0] - grid_bounds_real[0, 0]) < boundary_epsilon
1000
- # on_y_min = abs(center[1] - grid_bounds_real[0, 1]) < boundary_epsilon
1001
- # on_x_max = abs(center[0] - grid_bounds_real[1, 0]) < boundary_epsilon
1002
- # on_y_max = abs(center[1] - grid_bounds_real[1, 1]) < boundary_epsilon
1003
-
1004
- # # Check if this is a vertical surface on the boundary
1005
- # is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1006
-
1007
- # # Set NaN for all vertical surfaces on domain boundaries
1008
- # if is_boundary_vertical:
1009
- # face_svf_values[face_idx] = np.nan
1010
- # nan_boundary_count += 1
1011
- # processed_count += 1
1012
- # continue
1013
-
1014
- # # For non-boundary surfaces, proceed with normal SVF calculation
1015
- # # Convert center to voxel coordinates (for ray origin)
1016
- # center_voxel = center / meshsize
1017
-
1018
- # # IMPORTANT: Offset ray origin slightly to avoid self-intersection
1019
- # ray_origin = center_voxel + normal * 0.1 # Offset by 0.1 voxel units in normal direction
1020
-
1021
- # # Create rotation from z-axis to face normal
1022
- # z_axis = np.array([0, 0, 1])
1023
-
1024
- # # Handle special case where normal is parallel to z-axis
1025
- # if np.isclose(np.abs(np.dot(normal, z_axis)), 1.0, atol=1e-6):
1026
- # if np.dot(normal, z_axis) > 0: # Normal points up
1027
- # rotation_matrix = np.eye(3) # Identity matrix
1028
- # else: # Normal points down
1029
- # rotation_matrix = np.array([
1030
- # [1, 0, 0],
1031
- # [0, -1, 0],
1032
- # [0, 0, -1]
1033
- # ])
1034
- # rotation = Rotation.from_matrix(rotation_matrix)
1035
- # else:
1036
- # # For all other cases, find rotation that aligns z-axis with normal
1037
- # rotation_axis = np.cross(z_axis, normal)
1038
- # rotation_axis = rotation_axis / np.linalg.norm(rotation_axis)
1039
- # angle = np.arccos(np.clip(np.dot(z_axis, normal), -1.0, 1.0))
1040
- # rotation = Rotation.from_rotvec(rotation_axis * angle)
1041
-
1042
- # # Transform hemisphere directions to align with face normal
1043
- # local_dirs = rotation.apply(hemisphere_dirs)
1044
-
1045
- # # Filter directions - keep only those that:
1046
- # # 1. Are pointing outward from the face (dot product with normal > 0)
1047
- # # 2. Have a positive z component (upward in world space)
1048
- # valid_dirs = []
1049
- # total_dirs = 0
1050
-
1051
- # # Count total directions in the hemisphere (for normalization)
1052
- # for dir_vector in local_dirs:
1053
- # dot_product = np.dot(dir_vector, normal)
1054
- # # Count this direction if it's pointing outward from the face
1055
- # if dot_product > 0.01: # Small threshold to avoid precision issues
1056
- # total_dirs += 1
1057
- # # Only trace rays that have a positive z component (can reach sky)
1058
- # if dir_vector[2] > 0:
1059
- # valid_dirs.append(dir_vector)
1060
-
1061
- # # If no valid directions, SVF is 0
1062
- # if total_dirs == 0:
1063
- # face_svf_values[face_idx] = 0
1064
- # continue
1065
-
1066
- # # If no upward directions, SVF is 0 (all rays are blocked by ground)
1067
- # if len(valid_dirs) == 0:
1068
- # face_svf_values[face_idx] = 0
1069
- # continue
1070
-
1071
- # # Convert to numpy array for compute_vi_generic
1072
- # valid_dirs = np.array(valid_dirs, dtype=np.float64)
1073
-
1074
- # # Calculate SVF using compute_vi_generic for the upward rays
1075
- # # Then scale by the fraction of upward rays to total rays
1076
- # upward_svf = compute_vi_generic(
1077
- # ray_origin,
1078
- # voxel_data,
1079
- # valid_dirs,
1080
- # hit_values,
1081
- # meshsize,
1082
- # tree_k,
1083
- # tree_lad,
1084
- # inclusion_mode
1085
- # )
1086
-
1087
- # # Scale SVF by the fraction of rays that could potentially reach the sky
1088
- # # This accounts for downward rays that always have 0 SVF
1089
- # face_svf_values[face_idx] = upward_svf * (len(valid_dirs) / total_dirs)
1090
-
1091
- # except Exception as e:
1092
- # print(f"Error processing face {face_idx}: {e}")
1093
- # face_svf_values[face_idx] = 0
1094
-
1095
- # # Progress reporting
1096
- # processed_count += 1
1097
- # if progress_report:
1098
- # # Calculate frequency based on total number of faces, aiming for ~10 progress updates
1099
- # progress_frequency = max(1, len(building_mesh.faces) // 10)
1100
- # if processed_count % progress_frequency == 0 or processed_count == len(building_mesh.faces):
1101
- # elapsed = time.time() - start_time
1102
- # faces_per_second = processed_count / elapsed
1103
- # remaining = (len(building_mesh.faces) - processed_count) / faces_per_second if processed_count < len(building_mesh.faces) else 0
1104
- # print(f"Processed {processed_count}/{len(building_mesh.faces)} faces "
1105
- # f"({processed_count/len(building_mesh.faces)*100:.1f}%) - "
1106
- # f"{faces_per_second:.1f} faces/sec - "
1107
- # f"Est. remaining: {remaining:.1f} sec")
1108
-
1109
- # # print(f"Identified {nan_boundary_count} faces on domain vertical boundaries (set to NaN)")
1110
-
1111
- # # Store SVF values directly in mesh metadata
1112
- # if not hasattr(building_mesh, 'metadata'):
1113
- # building_mesh.metadata = {}
1114
- # building_mesh.metadata['svf_values'] = face_svf_values
1115
-
1116
- # # Apply colors to the mesh based on SVF values (only for visualization)
1117
- # if show_plot:
1118
- # import matplotlib.cm as cm
1119
- # import matplotlib.colors as mcolors
1120
- # cmap = cm.get_cmap(colormap)
1121
- # norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
1122
-
1123
- # # Get a copy of face_svf_values with NaN replaced by a specific value outside the range
1124
- # # This ensures NaN faces get a distinct color in the visualization
1125
- # vis_values = face_svf_values.copy()
1126
- # nan_mask = np.isnan(vis_values)
1127
- # if np.any(nan_mask):
1128
- # # Use a color below vmin for NaN values (they'll be clipped to vmin in the colormap)
1129
- # # But we can see them as the minimum color
1130
- # vis_values[nan_mask] = vmin - 0.1
1131
-
1132
- # # Apply colors
1133
- # face_colors = cmap(norm(vis_values))
1134
- # building_mesh.visual.face_colors = face_colors
1135
-
1136
- # # Create a scene with the colored mesh
1137
- # scene = trimesh.Scene()
1138
- # scene.add_geometry(building_mesh)
1139
- # scene.show()
1140
-
1141
- # # Also create a matplotlib figure with colorbar for reference
1142
- # import matplotlib.pyplot as plt
1143
-
1144
- # fig, ax = plt.subplots(figsize=(8, 3))
1145
- # cb = plt.colorbar(
1146
- # cm.ScalarMappable(norm=norm, cmap=cmap),
1147
- # ax=ax,
1148
- # orientation='horizontal',
1149
- # label='Sky View Factor'
1150
- # )
1151
- # ax.remove() # Remove the axes, keep only colorbar
1152
- # plt.tight_layout()
1153
- # plt.show()
1154
-
1155
- # # Plot histogram of SVF values (excluding NaN)
1156
- # valid_svf = face_svf_values[~np.isnan(face_svf_values)]
1157
- # plt.figure(figsize=(10, 6))
1158
- # plt.hist(valid_svf, bins=50, color='skyblue', alpha=0.7)
1159
- # plt.title('Distribution of Sky View Factor on Building Surfaces')
1160
- # plt.xlabel('Sky View Factor')
1161
- # plt.ylabel('Frequency')
1162
- # plt.grid(True, alpha=0.3)
1163
- # plt.tight_layout()
1164
- # plt.show()
1165
-
1166
- # # Handle optional OBJ export
1167
- # obj_export = kwargs.get("obj_export", False)
1168
- # if obj_export:
1169
- # output_dir = kwargs.get("output_directory", "output")
1170
- # output_file_name = kwargs.get("output_file_name", "building_surface_svf")
1171
-
1172
- # # Ensure output directory exists
1173
- # import os
1174
- # os.makedirs(output_dir, exist_ok=True)
1175
-
1176
- # # Export as OBJ with face colors
1177
- # try:
1178
- # building_mesh.export(f"{output_dir}/{output_file_name}.obj")
1179
- # print(f"Exported building SVF mesh to {output_dir}/{output_file_name}.obj")
1180
- # except Exception as e:
1181
- # print(f"Error exporting mesh: {e}")
1182
-
1183
- # return building_mesh
1184
-
1185
- # except Exception as e:
1186
- # print(f"Error during SVF calculation: {e}")
1187
- # import traceback
1188
- # traceback.print_exc()
1189
- # return None
1190
-
1191
- ##############################################################################
1192
- # 1) New Numba helper: Rodrigues’ rotation formula for rotating vectors
1193
- ##############################################################################
1194
943
  @njit
1195
944
  def rotate_vector_axis_angle(vec, axis, angle):
1196
945
  """
1197
- Rotate a 3D vector 'vec' around 'axis' by 'angle' (in radians),
1198
- using Rodrigues’ rotation formula.
946
+ Rotate a 3D vector around an arbitrary axis using Rodrigues' rotation formula.
947
+
948
+ This function implements the Rodrigues rotation formula:
949
+ v_rot = v*cos(θ) + (k × v)*sin(θ) + k*(k·v)*(1-cos(θ))
950
+ where k is the unit rotation axis, θ is the rotation angle, and v is the input vector.
951
+
952
+ Args:
953
+ vec (ndarray): 3D vector to rotate [x, y, z]
954
+ axis (ndarray): 3D rotation axis vector [x, y, z] (will be normalized)
955
+ angle (float): Rotation angle in radians
956
+
957
+ Returns:
958
+ ndarray: Rotated 3D vector [x, y, z]
1199
959
  """
1200
- # Normalize rotation axis
960
+ # Normalize rotation axis to unit length
1201
961
  axis_len = np.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2)
1202
962
  if axis_len < 1e-12:
1203
- # Axis is degenerate; return vec unchanged
963
+ # Degenerate axis case: return original vector unchanged
1204
964
  return vec
1205
965
 
1206
966
  ux, uy, uz = axis / axis_len
1207
967
  c = np.cos(angle)
1208
968
  s = np.sin(angle)
969
+
970
+ # Calculate dot product: k·v
1209
971
  dot = vec[0]*ux + vec[1]*uy + vec[2]*uz
1210
972
 
1211
- # cross = axis x vec
973
+ # Calculate cross product: k × v
1212
974
  cross_x = uy*vec[2] - uz*vec[1]
1213
975
  cross_y = uz*vec[0] - ux*vec[2]
1214
976
  cross_z = ux*vec[1] - uy*vec[0]
1215
977
 
1216
- # Rodrigues formula: v_rot = v*c + (k x v)*s + k*(k·v)*(1-c)
978
+ # Apply Rodrigues formula: v_rot = v*c + (k × v)*s + k*(k·v)*(1-c)
1217
979
  v_rot = np.zeros(3, dtype=np.float64)
1218
- # v*c
980
+
981
+ # First term: v*cos(θ)
1219
982
  v_rot[0] = vec[0] * c
1220
983
  v_rot[1] = vec[1] * c
1221
984
  v_rot[2] = vec[2] * c
1222
- # + (k x v)*s
985
+
986
+ # Second term: (k × v)*sin(θ)
1223
987
  v_rot[0] += cross_x * s
1224
988
  v_rot[1] += cross_y * s
1225
989
  v_rot[2] += cross_z * s
1226
- # + k*(k·v)*(1-c)
990
+
991
+ # Third term: k*(k·v)*(1-cos(θ))
1227
992
  tmp = dot * (1.0 - c)
1228
993
  v_rot[0] += ux * tmp
1229
994
  v_rot[1] += uy * tmp
@@ -1231,262 +996,6 @@ def rotate_vector_axis_angle(vec, axis, angle):
1231
996
 
1232
997
  return v_rot
1233
998
 
1234
-
1235
- # ##############################################################################
1236
- # # 2) New Numba helper: vectorized SVF computation for each face
1237
- # ##############################################################################
1238
- # @njit
1239
- # def compute_svf_for_all_faces(
1240
- # face_centers,
1241
- # face_normals,
1242
- # hemisphere_dirs,
1243
- # voxel_data,
1244
- # meshsize,
1245
- # tree_k,
1246
- # tree_lad,
1247
- # hit_values,
1248
- # inclusion_mode,
1249
- # grid_bounds_real,
1250
- # boundary_epsilon
1251
- # ):
1252
- # """
1253
- # Per-face SVF calculation in Numba:
1254
- # - Checks boundary conditions & sets NaN for boundary-vertical faces
1255
- # - Builds local hemisphere (rotates from +Z to face normal)
1256
- # - Filters directions that actually face outward (+ dot>0) and have z>0
1257
- # - Calls compute_vi_generic to get fraction that sees sky
1258
- # - Returns array of SVF values (same length as face_centers)
1259
- # """
1260
- # n_faces = face_centers.shape[0]
1261
- # face_svf_values = np.zeros(n_faces, dtype=np.float64)
1262
-
1263
- # z_axis = np.array([0.0, 0.0, 1.0])
1264
-
1265
- # for fidx in range(n_faces):
1266
- # center = face_centers[fidx]
1267
- # normal = face_normals[fidx]
1268
-
1269
- # # -- 1) Check for boundary + vertical face => NaN
1270
- # is_vertical = (abs(normal[2]) < 0.01)
1271
-
1272
- # on_x_min = (abs(center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
1273
- # on_y_min = (abs(center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
1274
- # on_x_max = (abs(center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
1275
- # on_y_max = (abs(center[1] - grid_bounds_real[1,1]) < boundary_epsilon)
1276
-
1277
- # is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1278
- # if is_boundary_vertical:
1279
- # face_svf_values[fidx] = np.nan
1280
- # continue
1281
-
1282
- # # -- 2) Compute rotation that aligns face normal -> +Z
1283
- # norm_n = np.sqrt(normal[0]**2 + normal[1]**2 + normal[2]**2)
1284
- # if norm_n < 1e-12:
1285
- # # Degenerate normal
1286
- # face_svf_values[fidx] = 0.0
1287
- # continue
1288
-
1289
- # dot_zn = z_axis[0]*normal[0] + z_axis[1]*normal[1] + z_axis[2]*normal[2]
1290
- # cos_angle = dot_zn / (norm_n)
1291
- # if cos_angle > 1.0: cos_angle = 1.0
1292
- # if cos_angle < -1.0: cos_angle = -1.0
1293
- # angle = np.arccos(cos_angle)
1294
-
1295
- # # Distinguish near +Z vs near -Z vs general case
1296
- # if abs(cos_angle - 1.0) < 1e-9:
1297
- # # normal ~ +Z => no rotation
1298
- # local_dirs = hemisphere_dirs
1299
- # elif abs(cos_angle + 1.0) < 1e-9:
1300
- # # normal ~ -Z => rotate 180 around X (or Y) axis
1301
- # axis_180 = np.array([1.0, 0.0, 0.0])
1302
- # local_dirs = np.empty_like(hemisphere_dirs)
1303
- # for i in range(hemisphere_dirs.shape[0]):
1304
- # local_dirs[i] = rotate_vector_axis_angle(hemisphere_dirs[i], axis_180, np.pi)
1305
- # else:
1306
- # # normal is neither up nor down -> do standard axis-angle
1307
- # axis_x = z_axis[1]*normal[2] - z_axis[2]*normal[1]
1308
- # axis_y = z_axis[2]*normal[0] - z_axis[0]*normal[2]
1309
- # axis_z = z_axis[0]*normal[1] - z_axis[1]*normal[0]
1310
- # rot_axis = np.array([axis_x, axis_y, axis_z], dtype=np.float64)
1311
-
1312
- # local_dirs = np.empty_like(hemisphere_dirs)
1313
- # for i in range(hemisphere_dirs.shape[0]):
1314
- # local_dirs[i] = rotate_vector_axis_angle(
1315
- # hemisphere_dirs[i],
1316
- # rot_axis,
1317
- # angle
1318
- # )
1319
-
1320
- # # -- 3) Count how many directions are outward & upward
1321
- # total_outward = 0
1322
- # num_upward = 0
1323
- # for i in range(local_dirs.shape[0]):
1324
- # dvec = local_dirs[i]
1325
- # dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
1326
- # if dp > 0.0:
1327
- # total_outward += 1
1328
- # if dvec[2] > 0.0:
1329
- # num_upward += 1
1330
-
1331
- # # If no outward directions at all => SVF=0
1332
- # if total_outward == 0:
1333
- # face_svf_values[fidx] = 0.0
1334
- # continue
1335
-
1336
- # # If no upward directions among them => SVF=0
1337
- # if num_upward == 0:
1338
- # face_svf_values[fidx] = 0.0
1339
- # continue
1340
-
1341
- # # -- 4) Create an array for only the upward directions
1342
- # valid_dirs_arr = np.empty((num_upward, 3), dtype=np.float64)
1343
- # out_idx = 0
1344
- # for i in range(local_dirs.shape[0]):
1345
- # dvec = local_dirs[i]
1346
- # dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
1347
- # if dp > 0.0 and dvec[2] > 0.0:
1348
- # valid_dirs_arr[out_idx, 0] = dvec[0]
1349
- # valid_dirs_arr[out_idx, 1] = dvec[1]
1350
- # valid_dirs_arr[out_idx, 2] = dvec[2]
1351
- # out_idx += 1
1352
-
1353
- # # -- 5) Ray origin in voxel coords, offset along face normal
1354
- # offset_vox = 0.1
1355
- # ray_origin = (center / meshsize) + (normal / norm_n) * offset_vox
1356
-
1357
- # # -- 6) Compute fraction of rays that see sky
1358
- # upward_svf = compute_vi_generic(
1359
- # ray_origin,
1360
- # voxel_data,
1361
- # valid_dirs_arr,
1362
- # hit_values,
1363
- # meshsize,
1364
- # tree_k,
1365
- # tree_lad,
1366
- # inclusion_mode
1367
- # )
1368
-
1369
- # # Scale by fraction of directions that were outward
1370
- # fraction_up = num_upward / total_outward
1371
- # face_svf_values[fidx] = upward_svf * fraction_up
1372
-
1373
- # return face_svf_values
1374
-
1375
-
1376
- # ##############################################################################
1377
- # # 3) Modified get_building_surface_svf (only numeric loop changed)
1378
- # ##############################################################################
1379
- # def get_building_surface_svf(voxel_data, meshsize, **kwargs):
1380
- # """
1381
- # Compute and visualize the Sky View Factor (SVF) for building surface meshes.
1382
-
1383
- # Args:
1384
- # voxel_data (ndarray): 3D array of voxel values.
1385
- # meshsize (float): Size of each voxel in meters.
1386
- # **kwargs: Additional parameters (colormap, ray counts, etc.)
1387
-
1388
- # Returns:
1389
- # trimesh.Trimesh: Mesh of building surfaces with SVF values stored in metadata.
1390
- # """
1391
- # import matplotlib.pyplot as plt
1392
- # import matplotlib.cm as cm
1393
- # import matplotlib.colors as mcolors
1394
- # import os
1395
-
1396
- # # Default parameters
1397
- # colormap = kwargs.get("colormap", 'BuPu_r')
1398
- # vmin = kwargs.get("vmin", 0.0)
1399
- # vmax = kwargs.get("vmax", 1.0)
1400
- # N_azimuth = kwargs.get("N_azimuth", 60)
1401
- # N_elevation = kwargs.get("N_elevation", 10)
1402
- # debug = kwargs.get("debug", False)
1403
- # progress_report = kwargs.get("progress_report", False)
1404
- # building_id_grid = kwargs.get("building_id_grid", None)
1405
-
1406
- # # Tree parameters
1407
- # tree_k = kwargs.get("tree_k", 0.6)
1408
- # tree_lad = kwargs.get("tree_lad", 1.0)
1409
-
1410
- # # Sky detection parameters
1411
- # hit_values = (0,) # '0' is sky
1412
- # inclusion_mode = False # we want rays that DON'T hit obstacles (except sky)
1413
-
1414
- # # Building ID in voxel data
1415
- # building_class_id = kwargs.get("building_class_id", -3)
1416
-
1417
- # start_time = time.time()
1418
- # # 1) Extract building mesh from voxel_data
1419
- # try:
1420
- # # This function is presumably in your codebase (not shown):
1421
- # building_mesh = create_voxel_mesh(voxel_data, building_class_id, meshsize, building_id_grid=building_id_grid)
1422
- # if building_mesh is None or len(building_mesh.faces) == 0:
1423
- # print("No building surfaces found in voxel data.")
1424
- # return None
1425
- # except Exception as e:
1426
- # print(f"Error during mesh extraction: {e}")
1427
- # return None
1428
-
1429
- # if progress_report:
1430
- # print(f"Processing SVF for {len(building_mesh.faces)} building faces...")
1431
-
1432
- # # 2) Get face centers + normals as NumPy arrays
1433
- # face_centers = building_mesh.triangles_center
1434
- # face_normals = building_mesh.face_normals
1435
-
1436
- # # 3) Precompute hemisphere directions (global, pointing up)
1437
- # azimuth_angles = np.linspace(0, 2*np.pi, N_azimuth, endpoint=False)
1438
- # elevation_angles = np.linspace(0, np.pi/2, N_elevation)
1439
- # hemisphere_list = []
1440
- # for elev in elevation_angles:
1441
- # sin_elev = np.sin(elev)
1442
- # cos_elev = np.cos(elev)
1443
- # for az in azimuth_angles:
1444
- # x = cos_elev * np.cos(az)
1445
- # y = cos_elev * np.sin(az)
1446
- # z = sin_elev
1447
- # hemisphere_list.append([x, y, z])
1448
- # hemisphere_dirs = np.array(hemisphere_list, dtype=np.float64)
1449
-
1450
- # # 4) Domain bounds in real coordinates
1451
- # grid_shape = voxel_data.shape
1452
- # grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0],grid_shape[1],grid_shape[2]]], dtype=np.float64)
1453
- # grid_bounds_real = grid_bounds_voxel * meshsize
1454
- # boundary_epsilon = meshsize * 0.05
1455
-
1456
- # # 5) Call Numba-accelerated routine
1457
- # face_svf_values = compute_svf_for_all_faces(
1458
- # face_centers,
1459
- # face_normals,
1460
- # hemisphere_dirs,
1461
- # voxel_data,
1462
- # meshsize,
1463
- # tree_k,
1464
- # tree_lad,
1465
- # hit_values,
1466
- # inclusion_mode,
1467
- # grid_bounds_real,
1468
- # boundary_epsilon
1469
- # )
1470
-
1471
- # # 6) Store SVF values in mesh metadata
1472
- # if not hasattr(building_mesh, 'metadata'):
1473
- # building_mesh.metadata = {}
1474
- # building_mesh.metadata['svf_values'] = face_svf_values
1475
-
1476
- # # OBJ export if desired
1477
- # obj_export = kwargs.get("obj_export", False)
1478
- # if obj_export:
1479
- # output_dir = kwargs.get("output_directory", "output")
1480
- # output_file_name = kwargs.get("output_file_name", "building_surface_svf")
1481
- # os.makedirs(output_dir, exist_ok=True)
1482
- # try:
1483
- # building_mesh.export(f"{output_dir}/{output_file_name}.obj")
1484
- # print(f"Exported building SVF mesh to {output_dir}/{output_file_name}.obj")
1485
- # except Exception as e:
1486
- # print(f"Error exporting mesh: {e}")
1487
-
1488
- # return building_mesh
1489
-
1490
999
  @njit
1491
1000
  def compute_view_factor_for_all_faces(
1492
1001
  face_centers,
@@ -1505,47 +1014,50 @@ def compute_view_factor_for_all_faces(
1505
1014
  """
1506
1015
  Compute a per-face "view factor" for a specified set of target voxel classes.
1507
1016
 
1508
- By default (as in the old SVF case), you would pass:
1509
- target_values = (0,) # voxel value for 'sky'
1510
- inclusion_mode = False # i.e. any *non*-sky voxel will block the ray
1017
+ This function computes view factors from building surface faces to target voxel types
1018
+ (e.g., sky, trees, other buildings). It uses hemisphere ray casting with rotation
1019
+ to align rays with each face's normal direction.
1020
+
1021
+ Typical usage examples:
1022
+ - Sky View Factor: target_values=(0,), inclusion_mode=False (sky voxels)
1023
+ - Tree View Factor: target_values=(-2,), inclusion_mode=True (tree voxels)
1024
+ - Building View Factor: target_values=(-3,), inclusion_mode=True (building voxels)
1511
1025
 
1512
- But you can pass any other combination:
1513
- - E.g. target_values = (-2,), inclusion_mode=True
1514
- to measure fraction of directions that intersect 'trees' (-2).
1515
- - E.g. target_values = (-3,), inclusion_mode=True
1516
- to measure fraction of directions that intersect 'buildings' (-3).
1517
-
1518
1026
  Args:
1519
- face_centers (np.ndarray): (n_faces, 3) face centroid positions.
1520
- face_normals (np.ndarray): (n_faces, 3) face normals.
1521
- hemisphere_dirs (np.ndarray): (N, 3) set of direction vectors in the hemisphere.
1027
+ face_centers (np.ndarray): (n_faces, 3) face centroid positions in real coordinates.
1028
+ face_normals (np.ndarray): (n_faces, 3) face normal vectors (outward pointing).
1029
+ hemisphere_dirs (np.ndarray): (N, 3) set of direction vectors in the upper hemisphere.
1522
1030
  voxel_data (np.ndarray): 3D array of voxel values.
1523
1031
  meshsize (float): Size of each voxel in meters.
1524
- tree_k (float): Tree extinction coefficient.
1525
- tree_lad (float): Leaf area density in m^-1.
1526
- target_values (tuple[int]): Voxel classes that define a 'hit'.
1527
- inclusion_mode (bool): If True, hitting any of target_values is considered "visible."
1528
- If False, hitting anything *not* in target_values (except -2 trees) blocks the ray.
1032
+ tree_k (float): Tree extinction coefficient for Beer-Lambert law.
1033
+ tree_lad (float): Leaf area density in m^-1 for tree transmittance.
1034
+ target_values (tuple[int]): Voxel classes that define a 'hit' or target.
1035
+ inclusion_mode (bool): If True, hitting target_values counts as visibility.
1036
+ If False, hitting anything NOT in target_values blocks the ray.
1529
1037
  grid_bounds_real (np.ndarray): [[x_min,y_min,z_min],[x_max,y_max,z_max]] in real coords.
1530
- boundary_epsilon (float): tolerance for marking boundary vertical faces.
1038
+ boundary_epsilon (float): Tolerance for identifying boundary vertical faces.
1531
1039
  ignore_downward (bool): If True, only consider upward rays. If False, consider all outward rays.
1532
1040
 
1533
1041
  Returns:
1534
- np.ndarray of shape (n_faces,):
1535
- The computed view factor for each face (NaN for boundary‐vertical faces).
1042
+ np.ndarray of shape (n_faces,): Computed view factor for each face.
1043
+ NaN values indicate boundary vertical faces that should be excluded.
1536
1044
  """
1537
1045
  n_faces = face_centers.shape[0]
1538
1046
  face_vf_values = np.zeros(n_faces, dtype=np.float64)
1539
1047
 
1048
+ # Reference vector pointing upward (+Z direction)
1540
1049
  z_axis = np.array([0.0, 0.0, 1.0])
1541
1050
 
1051
+ # Process each face individually
1542
1052
  for fidx in range(n_faces):
1543
1053
  center = face_centers[fidx]
1544
1054
  normal = face_normals[fidx]
1545
1055
 
1546
- # -- 1) Check for boundary + vertical face => NaN
1547
- is_vertical = (abs(normal[2]) < 0.01)
1056
+ # Check for boundary vertical faces and mark as NaN
1057
+ # This excludes faces on domain edges that may have artificial visibility
1058
+ is_vertical = (abs(normal[2]) < 0.01) # Face normal is nearly horizontal
1548
1059
 
1060
+ # Check if face is near domain boundaries
1549
1061
  on_x_min = (abs(center[0] - grid_bounds_real[0,0]) < boundary_epsilon)
1550
1062
  on_y_min = (abs(center[1] - grid_bounds_real[0,1]) < boundary_epsilon)
1551
1063
  on_x_max = (abs(center[0] - grid_bounds_real[1,0]) < boundary_epsilon)
@@ -1556,31 +1068,33 @@ def compute_view_factor_for_all_faces(
1556
1068
  face_vf_values[fidx] = np.nan
1557
1069
  continue
1558
1070
 
1559
- # -- 2) Compute rotation that aligns face normal -> +Z
1071
+ # Compute rotation to align face normal with +Z axis
1072
+ # This allows us to use the same hemisphere directions for all faces
1560
1073
  norm_n = np.sqrt(normal[0]**2 + normal[1]**2 + normal[2]**2)
1561
1074
  if norm_n < 1e-12:
1562
- # Degenerate normal
1075
+ # Degenerate normal vector
1563
1076
  face_vf_values[fidx] = 0.0
1564
1077
  continue
1565
1078
 
1079
+ # Calculate angle between face normal and +Z axis
1566
1080
  dot_zn = z_axis[0]*normal[0] + z_axis[1]*normal[1] + z_axis[2]*normal[2]
1567
1081
  cos_angle = dot_zn / (norm_n)
1568
1082
  if cos_angle > 1.0: cos_angle = 1.0
1569
1083
  if cos_angle < -1.0: cos_angle = -1.0
1570
1084
  angle = np.arccos(cos_angle)
1571
1085
 
1572
- # Distinguish near +Z vs near -Z vs general case
1086
+ # Handle special cases and general rotation
1573
1087
  if abs(cos_angle - 1.0) < 1e-9:
1574
- # normal ~ +Z => no rotation needed
1088
+ # Face normal is already aligned with +Z => no rotation needed
1575
1089
  local_dirs = hemisphere_dirs
1576
1090
  elif abs(cos_angle + 1.0) < 1e-9:
1577
- # normal ~ -Z => rotate 180 around X (or Y) axis
1091
+ # Face normal points in -Z direction => rotate 180 degrees around X axis
1578
1092
  axis_180 = np.array([1.0, 0.0, 0.0])
1579
1093
  local_dirs = np.empty_like(hemisphere_dirs)
1580
1094
  for i in range(hemisphere_dirs.shape[0]):
1581
1095
  local_dirs[i] = rotate_vector_axis_angle(hemisphere_dirs[i], axis_180, np.pi)
1582
1096
  else:
1583
- # normal is neither up nor down -> do standard axis-angle
1097
+ # General case: rotate around axis perpendicular to both +Z and face normal
1584
1098
  axis_x = z_axis[1]*normal[2] - z_axis[2]*normal[1]
1585
1099
  axis_y = z_axis[2]*normal[0] - z_axis[0]*normal[2]
1586
1100
  axis_z = z_axis[0]*normal[1] - z_axis[1]*normal[0]
@@ -1594,28 +1108,30 @@ def compute_view_factor_for_all_faces(
1594
1108
  angle
1595
1109
  )
1596
1110
 
1597
- # -- 3) Count valid directions based on ignore_downward setting
1598
- total_outward = 0
1599
- num_valid = 0
1111
+ # Count valid ray directions based on face orientation and downward filtering
1112
+ total_outward = 0 # Rays pointing away from face surface
1113
+ num_valid = 0 # Rays that meet all criteria (outward + optionally upward)
1114
+
1600
1115
  for i in range(local_dirs.shape[0]):
1601
1116
  dvec = local_dirs[i]
1117
+ # Check if ray points outward from face surface (positive dot product with normal)
1602
1118
  dp = dvec[0]*normal[0] + dvec[1]*normal[1] + dvec[2]*normal[2]
1603
1119
  if dp > 0.0:
1604
1120
  total_outward += 1
1121
+ # Apply downward filtering if requested
1605
1122
  if not ignore_downward or dvec[2] > 0.0:
1606
1123
  num_valid += 1
1607
1124
 
1608
- # If no outward directions at all => view factor = 0
1125
+ # Handle cases with no valid directions
1609
1126
  if total_outward == 0:
1610
1127
  face_vf_values[fidx] = 0.0
1611
1128
  continue
1612
1129
 
1613
- # If no valid directions => view factor = 0
1614
1130
  if num_valid == 0:
1615
1131
  face_vf_values[fidx] = 0.0
1616
1132
  continue
1617
1133
 
1618
- # -- 4) Create an array for valid directions
1134
+ # Create array containing only the valid ray directions
1619
1135
  valid_dirs_arr = np.empty((num_valid, 3), dtype=np.float64)
1620
1136
  out_idx = 0
1621
1137
  for i in range(local_dirs.shape[0]):
@@ -1627,11 +1143,11 @@ def compute_view_factor_for_all_faces(
1627
1143
  valid_dirs_arr[out_idx, 2] = dvec[2]
1628
1144
  out_idx += 1
1629
1145
 
1630
- # -- 5) Ray origin in voxel coords, offset along face normal
1631
- offset_vox = 0.1
1146
+ # Set ray origin slightly offset from face surface to avoid self-intersection
1147
+ offset_vox = 0.1 # Offset in voxel units
1632
1148
  ray_origin = (center / meshsize) + (normal / norm_n) * offset_vox
1633
1149
 
1634
- # -- 6) Compute fraction of rays that "see" the target
1150
+ # Compute fraction of valid rays that "see" the target using generic ray tracing
1635
1151
  vf = compute_vi_generic(
1636
1152
  ray_origin,
1637
1153
  voxel_data,
@@ -1643,7 +1159,8 @@ def compute_view_factor_for_all_faces(
1643
1159
  inclusion_mode
1644
1160
  )
1645
1161
 
1646
- # Scale by fraction of directions that were valid
1162
+ # Scale result by fraction of directions that were valid
1163
+ # This normalizes for the hemisphere portion that the face can actually "see"
1647
1164
  fraction_valid = num_valid / total_outward
1648
1165
  face_vf_values[fidx] = vf * fraction_valid
1649
1166
 
@@ -1651,34 +1168,73 @@ def compute_view_factor_for_all_faces(
1651
1168
 
1652
1169
  def get_surface_view_factor(voxel_data, meshsize, **kwargs):
1653
1170
  """
1654
- Compute and optionally visualize the "view factor" for surface meshes
1655
- with respect to a chosen target voxel class (or classes).
1171
+ Compute and optionally visualize view factors for surface meshes with respect to target voxel classes.
1172
+
1173
+ This function provides a flexible framework for computing various surface-based view factors:
1174
+ - Sky View Factor: Fraction of sky hemisphere visible from building surfaces
1175
+ - Tree View Factor: Fraction of directions that intersect vegetation
1176
+ - Building View Factor: Fraction of directions that intersect other buildings
1177
+ - Custom View Factors: User-defined target voxel classes
1656
1178
 
1657
- By default, it computes Sky View Factor (target_values=(0,), inclusion_mode=False).
1658
- But you can pass different arguments for other view factors:
1659
- - target_values=(-2,), inclusion_mode=True => Tree view factor
1660
- - target_values=(-3,), inclusion_mode=True => Building view factor
1661
- etc.
1179
+ The function extracts surface meshes from the voxel data, then computes view factors
1180
+ for each face using hemisphere ray casting with proper geometric transformations.
1662
1181
 
1663
1182
  Args:
1664
- voxel_data (ndarray): 3D array of voxel values
1665
- meshsize (float): Size of each voxel in meters
1666
- **kwargs: Additional parameters (colormap, ray counts, etc.)
1667
- including:
1668
- target_values (tuple[int]): voxel classes that define 'hits'
1669
- inclusion_mode (bool): interpretation of hits
1670
- building_class_id (int): which class to mesh for surface extraction
1671
- ...
1672
-
1183
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment.
1184
+ meshsize (float): Size of each voxel in meters for coordinate scaling.
1185
+ **kwargs: Extensive configuration options including:
1186
+ # Target specification:
1187
+ target_values (tuple[int]): Voxel classes to measure visibility to (default: (0,) for sky)
1188
+ inclusion_mode (bool): Interpretation of target_values (default: False for sky)
1189
+
1190
+ # Surface extraction:
1191
+ building_class_id (int): Voxel class to extract surfaces from (default: -3 for buildings)
1192
+ building_id_grid (ndarray): Optional grid mapping voxels to building IDs
1193
+
1194
+ # Ray sampling:
1195
+ N_azimuth (int): Number of azimuth angles for hemisphere sampling (default: 60)
1196
+ N_elevation (int): Number of elevation angles for hemisphere sampling (default: 10)
1197
+
1198
+ # Tree transmittance (Beer-Lambert law):
1199
+ tree_k (float): Tree extinction coefficient (default: 0.6)
1200
+ tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1201
+
1202
+ # Visualization and export:
1203
+ colormap (str): Matplotlib colormap for visualization (default: 'BuPu_r')
1204
+ vmin, vmax (float): Color scale limits (default: 0.0, 1.0)
1205
+ obj_export (bool): Whether to export mesh as OBJ file (default: False)
1206
+ output_directory (str): Directory for OBJ export (default: "output")
1207
+ output_file_name (str): Base filename for OBJ export (default: "surface_view_factor")
1208
+
1209
+ # Other options:
1210
+ progress_report (bool): Whether to print computation progress (default: False)
1211
+ debug (bool): Enable debug output (default: False)
1212
+
1673
1213
  Returns:
1674
- trimesh.Trimesh: The surface mesh with per-face view-factor values in metadata.
1214
+ trimesh.Trimesh: Surface mesh with per-face view factor values stored in metadata.
1215
+ The view factor values can be accessed via mesh.metadata[value_name].
1216
+ Returns None if no surfaces are found or extraction fails.
1217
+
1218
+ Example Usage:
1219
+ # Sky View Factor for building surfaces
1220
+ mesh = get_surface_view_factor(voxel_data, meshsize,
1221
+ target_values=(0,), inclusion_mode=False)
1222
+
1223
+ # Tree View Factor for building surfaces
1224
+ mesh = get_surface_view_factor(voxel_data, meshsize,
1225
+ target_values=(-2,), inclusion_mode=True)
1226
+
1227
+ # Custom view factor with OBJ export
1228
+ mesh = get_surface_view_factor(voxel_data, meshsize,
1229
+ target_values=(-3,), inclusion_mode=True,
1230
+ obj_export=True, output_file_name="building_view_factor")
1675
1231
  """
1676
1232
  import matplotlib.pyplot as plt
1677
1233
  import matplotlib.cm as cm
1678
1234
  import matplotlib.colors as mcolors
1679
1235
  import os
1680
1236
 
1681
- # Default parameters
1237
+ # Extract configuration parameters with appropriate defaults
1682
1238
  value_name = kwargs.get("value_name", 'view_factor_values')
1683
1239
  colormap = kwargs.get("colormap", 'BuPu_r')
1684
1240
  vmin = kwargs.get("vmin", 0.0)
@@ -1689,28 +1245,25 @@ def get_surface_view_factor(voxel_data, meshsize, **kwargs):
1689
1245
  progress_report= kwargs.get("progress_report", False)
1690
1246
  building_id_grid = kwargs.get("building_id_grid", None)
1691
1247
 
1692
- # Tree & bounding params
1248
+ # Tree transmittance parameters for Beer-Lambert law
1693
1249
  tree_k = kwargs.get("tree_k", 0.6)
1694
1250
  tree_lad = kwargs.get("tree_lad", 1.0)
1695
1251
 
1696
- # ----------------------------------------
1697
- # NEW: user can override target classes
1698
- # defaults for "sky" factor:
1699
- target_values = kwargs.get("target_values", (0,))
1700
- inclusion_mode = kwargs.get("inclusion_mode", False)
1701
- # ----------------------------------------
1252
+ # Target specification - defaults to sky view factor configuration
1253
+ target_values = kwargs.get("target_values", (0,)) # Sky voxels by default
1254
+ inclusion_mode = kwargs.get("inclusion_mode", False) # Exclusion mode for sky
1702
1255
 
1703
- # Voxel class used for building (or other) surface
1704
- building_class_id = kwargs.get("building_class_id", -3)
1256
+ # Surface extraction parameters
1257
+ building_class_id = kwargs.get("building_class_id", -3) # Building voxel class
1705
1258
 
1706
- # 1) Extract mesh from voxel_data
1259
+ # Extract surface mesh from the specified voxel class
1707
1260
  try:
1708
1261
  building_mesh = create_voxel_mesh(
1709
1262
  voxel_data,
1710
1263
  building_class_id,
1711
1264
  meshsize,
1712
1265
  building_id_grid=building_id_grid,
1713
- mesh_type='open_air'
1266
+ mesh_type='open_air' # Extract surfaces exposed to air
1714
1267
  )
1715
1268
  if building_mesh is None or len(building_mesh.faces) == 0:
1716
1269
  print("No surfaces found in voxel data for the specified class.")
@@ -1722,31 +1275,33 @@ def get_surface_view_factor(voxel_data, meshsize, **kwargs):
1722
1275
  if progress_report:
1723
1276
  print(f"Processing view factor for {len(building_mesh.faces)} faces...")
1724
1277
 
1725
- # 2) Get face centers + normals
1726
- face_centers = building_mesh.triangles_center
1727
- face_normals = building_mesh.face_normals
1278
+ # Extract geometric properties from the mesh
1279
+ face_centers = building_mesh.triangles_center # Centroid of each face
1280
+ face_normals = building_mesh.face_normals # Outward normal of each face
1728
1281
 
1729
- # 3) Precompute hemisphere directions
1282
+ # Generate hemisphere ray directions using spherical coordinates
1283
+ # These directions will be rotated to align with each face's normal
1730
1284
  azimuth_angles = np.linspace(0, 2*np.pi, N_azimuth, endpoint=False)
1731
- elevation_angles = np.linspace(0, np.pi/2, N_elevation)
1285
+ elevation_angles = np.linspace(0, np.pi/2, N_elevation) # Upper hemisphere only
1732
1286
  hemisphere_list = []
1733
1287
  for elev in elevation_angles:
1734
1288
  sin_elev = np.sin(elev)
1735
1289
  cos_elev = np.cos(elev)
1736
1290
  for az in azimuth_angles:
1291
+ # Convert spherical to Cartesian coordinates
1737
1292
  x = cos_elev * np.cos(az)
1738
1293
  y = cos_elev * np.sin(az)
1739
- z = sin_elev
1294
+ z = sin_elev # Always positive (upper hemisphere)
1740
1295
  hemisphere_list.append([x, y, z])
1741
1296
  hemisphere_dirs = np.array(hemisphere_list, dtype=np.float64)
1742
1297
 
1743
- # 4) Domain bounds in real coordinates
1298
+ # Calculate domain bounds for boundary face detection
1744
1299
  nx, ny, nz = voxel_data.shape
1745
1300
  grid_bounds_voxel = np.array([[0,0,0],[nx, ny, nz]], dtype=np.float64)
1746
1301
  grid_bounds_real = grid_bounds_voxel * meshsize
1747
- boundary_epsilon = meshsize * 0.05
1302
+ boundary_epsilon = meshsize * 0.05 # Tolerance for boundary detection
1748
1303
 
1749
- # 5) Call the new Numba routine for per-face view factor
1304
+ # Compute view factors for all faces using optimized Numba implementation
1750
1305
  face_vf_values = compute_view_factor_for_all_faces(
1751
1306
  face_centers,
1752
1307
  face_normals,
@@ -1755,18 +1310,18 @@ def get_surface_view_factor(voxel_data, meshsize, **kwargs):
1755
1310
  meshsize,
1756
1311
  tree_k,
1757
1312
  tree_lad,
1758
- target_values, # <--- new
1759
- inclusion_mode, # <--- new
1313
+ target_values, # User-specified target voxel classes
1314
+ inclusion_mode, # User-specified hit interpretation
1760
1315
  grid_bounds_real,
1761
1316
  boundary_epsilon
1762
1317
  )
1763
1318
 
1764
- # 6) Store these values in the mesh metadata
1319
+ # Store computed view factor values in mesh metadata for later access
1765
1320
  if not hasattr(building_mesh, 'metadata'):
1766
1321
  building_mesh.metadata = {}
1767
1322
  building_mesh.metadata[value_name] = face_vf_values
1768
1323
 
1769
- # Optionally export to OBJ
1324
+ # Optional OBJ file export for external visualization/analysis
1770
1325
  obj_export = kwargs.get("obj_export", False)
1771
1326
  if obj_export:
1772
1327
  output_dir = kwargs.get("output_directory", "output")