voxcity 0.7.0__py3-none-any.whl → 1.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,944 @@
1
+ """
2
+ View Index and Sky View Factor calculation using Taichi GPU acceleration.
3
+
4
+ This module emulates the functionality of voxcity.simulator.visibility.view
5
+ with GPU-accelerated ray tracing.
6
+ """
7
+
8
+ import taichi as ti
9
+ import numpy as np
10
+ import math
11
+ from typing import Tuple, Optional, Union, List
12
+
13
+ from ..core import Vector3, Point3, PI, TWO_PI
14
+ from ..init_taichi import ensure_initialized
15
+ from ..raytracing import (
16
+ ray_voxel_first_hit,
17
+ ray_voxel_transmissivity,
18
+ ray_aabb_intersect,
19
+ )
20
+ from .geometry import (
21
+ generate_ray_directions_grid,
22
+ generate_ray_directions_fibonacci,
23
+ generate_hemisphere_directions,
24
+ )
25
+
26
+
27
+ @ti.data_oriented
28
+ class ViewCalculator:
29
+ """
30
+ GPU-accelerated View Index calculator.
31
+
32
+ Computes view indices (green view, sky view, custom targets) by tracing rays
33
+ from observer positions through the voxel domain.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ domain,
39
+ n_azimuth: int = 120,
40
+ n_elevation: int = 20,
41
+ ray_sampling: str = "grid",
42
+ n_rays: int = None
43
+ ):
44
+ """
45
+ Initialize View Calculator.
46
+
47
+ Args:
48
+ domain: Domain object with grid geometry (simulator_gpu.domain.Domain)
49
+ n_azimuth: Number of azimuthal divisions (for grid sampling)
50
+ n_elevation: Number of elevation divisions (for grid sampling)
51
+ ray_sampling: Sampling method ('grid' or 'fibonacci')
52
+ n_rays: Total rays for fibonacci sampling (default: n_azimuth * n_elevation)
53
+ """
54
+ # Ensure Taichi is initialized before creating any fields
55
+ ensure_initialized()
56
+
57
+ self.domain = domain
58
+ self.nx = domain.nx
59
+ self.ny = domain.ny
60
+ self.nz = domain.nz
61
+ self.dx = domain.dx
62
+ self.dy = domain.dy
63
+ self.dz = domain.dz
64
+
65
+ self.n_azimuth = n_azimuth
66
+ self.n_elevation = n_elevation
67
+ self.ray_sampling = ray_sampling
68
+ self.n_rays = n_rays if n_rays else n_azimuth * n_elevation
69
+
70
+ # Maximum ray distance
71
+ self.max_dist = domain.get_max_dist()
72
+
73
+ # Pre-computed ray directions (will be set based on mode)
74
+ self._ray_dirs = None
75
+ self._n_ray_dirs = 0
76
+
77
+ def _setup_ray_directions(
78
+ self,
79
+ elevation_min: float = -30.0,
80
+ elevation_max: float = 30.0
81
+ ):
82
+ """Setup ray directions based on sampling method."""
83
+ if self.ray_sampling.lower() == "fibonacci":
84
+ dirs_np = generate_ray_directions_fibonacci(
85
+ self.n_rays, elevation_min, elevation_max
86
+ )
87
+ else:
88
+ dirs_np = generate_ray_directions_grid(
89
+ self.n_azimuth, self.n_elevation, elevation_min, elevation_max
90
+ )
91
+
92
+ self._n_ray_dirs = dirs_np.shape[0]
93
+ self._ray_dirs = ti.Vector.field(3, dtype=ti.f32, shape=(self._n_ray_dirs,))
94
+ self._ray_dirs.from_numpy(dirs_np)
95
+
96
+ def compute_view_index(
97
+ self,
98
+ voxel_data: np.ndarray = None,
99
+ mode: str = None,
100
+ hit_values: Tuple[int, ...] = None,
101
+ inclusion_mode: bool = True,
102
+ view_point_height: float = 1.5,
103
+ elevation_min_degrees: float = -30.0,
104
+ elevation_max_degrees: float = 30.0,
105
+ tree_k: float = 0.5,
106
+ tree_lad: float = 1.0
107
+ ) -> np.ndarray:
108
+ """
109
+ Compute View Index map.
110
+
111
+ Args:
112
+ voxel_data: 3D voxel class array (optional if domain has voxel data)
113
+ mode: Predefined mode ('green', 'sky', or None for custom)
114
+ hit_values: Target voxel values to count as visible
115
+ inclusion_mode: If True, count hits on targets; if False, count non-blocked rays
116
+ view_point_height: Observer height above ground (meters)
117
+ elevation_min_degrees: Minimum viewing angle
118
+ elevation_max_degrees: Maximum viewing angle
119
+ tree_k: Tree extinction coefficient
120
+ tree_lad: Leaf area density for trees
121
+
122
+ Returns:
123
+ 2D array of view index values (nx, ny)
124
+ """
125
+ # Set up mode-specific parameters
126
+ if mode == 'green':
127
+ hit_values = (-2, 2, 5, 6, 7, 8)
128
+ inclusion_mode = True
129
+ elif mode == 'sky':
130
+ hit_values = (0,)
131
+ inclusion_mode = False
132
+ elif hit_values is None:
133
+ raise ValueError("For custom mode, you must provide hit_values.")
134
+
135
+ # Setup ray directions
136
+ self._setup_ray_directions(elevation_min_degrees, elevation_max_degrees)
137
+
138
+ # Convert view height to voxels
139
+ view_height_voxel = int(view_point_height / self.dz)
140
+
141
+ # Prepare output
142
+ vi_map = ti.field(dtype=ti.f32, shape=(self.nx, self.ny))
143
+
144
+ # Prepare masks from voxel data
145
+ if voxel_data is None:
146
+ # Use domain's is_solid and is_tree
147
+ is_tree = self.domain.is_tree
148
+ is_solid = self.domain.is_solid
149
+ is_target = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
150
+ is_allowed = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
151
+ is_blocker = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
152
+ is_walkable = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
153
+
154
+ # Initialize target/allowed based on mode
155
+ self._init_target_masks_from_domain(
156
+ is_tree, is_solid, is_target, is_allowed, is_blocker, is_walkable,
157
+ inclusion_mode, mode == 'green'
158
+ )
159
+ else:
160
+ # Create masks from voxel_data
161
+ is_tree = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
162
+ is_solid = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
163
+ is_target = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
164
+ is_allowed = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
165
+ is_blocker = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
166
+ is_walkable = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
167
+
168
+ # Convert hit_values to array for kernel
169
+ hit_values_arr = np.array(hit_values, dtype=np.int32)
170
+
171
+ self._setup_masks_from_voxel_data(
172
+ voxel_data, hit_values_arr, inclusion_mode,
173
+ is_tree, is_solid, is_target, is_allowed, is_blocker, is_walkable
174
+ )
175
+
176
+ # Compute transmissivity per voxel for trees
177
+ tree_att = float(math.exp(-tree_k * tree_lad * self.dz))
178
+
179
+ # Run GPU computation
180
+ self._compute_vi_map_kernel(
181
+ vi_map, view_height_voxel,
182
+ is_tree, is_solid, is_target, is_allowed, is_blocker, is_walkable,
183
+ inclusion_mode, tree_att
184
+ )
185
+
186
+ # Flip Y-axis to match VoxCity coordinate system
187
+ result = np.flipud(vi_map.to_numpy())
188
+ return result
189
+
190
+ @ti.kernel
191
+ def _init_target_masks_from_domain(
192
+ self,
193
+ is_tree: ti.template(),
194
+ is_solid: ti.template(),
195
+ is_target: ti.template(),
196
+ is_allowed: ti.template(),
197
+ is_blocker: ti.template(),
198
+ is_walkable: ti.template(),
199
+ inclusion_mode: ti.i32,
200
+ green_mode: ti.i32
201
+ ):
202
+ """Initialize target masks from domain's is_tree and is_solid."""
203
+ for i, j, k in is_target:
204
+ # Without voxel_data, assume all surfaces are walkable
205
+ is_walkable[i, j, k] = 1
206
+
207
+ if green_mode == 1:
208
+ # Green mode: trees are targets
209
+ is_target[i, j, k] = is_tree[i, j, k]
210
+ is_allowed[i, j, k] = is_tree[i, j, k]
211
+ # Blockers are solids (non-tree)
212
+ blocker = 0
213
+ if is_solid[i, j, k] == 1 and is_tree[i, j, k] == 0:
214
+ blocker = 1
215
+ is_blocker[i, j, k] = blocker
216
+ else:
217
+ # Sky mode: no targets, just check for blockers
218
+ is_target[i, j, k] = 0
219
+ is_allowed[i, j, k] = 0
220
+ is_blocker[i, j, k] = 0
221
+
222
+ def _setup_masks_from_voxel_data(
223
+ self,
224
+ voxel_data: np.ndarray,
225
+ hit_values: np.ndarray,
226
+ inclusion_mode: bool,
227
+ is_tree: ti.template(),
228
+ is_solid: ti.template(),
229
+ is_target: ti.template(),
230
+ is_allowed: ti.template(),
231
+ is_blocker: ti.template(),
232
+ is_walkable: ti.template()
233
+ ):
234
+ """Setup masks from voxel data array."""
235
+ n_hits = len(hit_values)
236
+ self._setup_masks_kernel(
237
+ voxel_data, hit_values, n_hits, inclusion_mode,
238
+ is_tree, is_solid, is_target, is_allowed, is_blocker, is_walkable
239
+ )
240
+
241
+ @ti.kernel
242
+ def _setup_masks_kernel(
243
+ self,
244
+ voxel_data: ti.types.ndarray(),
245
+ hit_values: ti.types.ndarray(),
246
+ n_hits: ti.i32,
247
+ inclusion_mode: ti.i32,
248
+ is_tree: ti.template(),
249
+ is_solid: ti.template(),
250
+ is_target: ti.template(),
251
+ is_allowed: ti.template(),
252
+ is_blocker: ti.template(),
253
+ is_walkable: ti.template()
254
+ ):
255
+ for i, j, k in is_tree:
256
+ val = voxel_data[i, j, k]
257
+
258
+ # Tree check (code -2)
259
+ tree = 0
260
+ if val == -2:
261
+ tree = 1
262
+ is_tree[i, j, k] = tree
263
+
264
+ # Solid check (non-zero, non-tree)
265
+ solid = 0
266
+ if val != 0 and val != -2:
267
+ solid = 1
268
+ is_solid[i, j, k] = solid
269
+
270
+ # Target check
271
+ target = 0
272
+ for h in range(n_hits):
273
+ if val == hit_values[h]:
274
+ target = 1
275
+ is_target[i, j, k] = target
276
+
277
+ # Walkable: surfaces that are valid observer positions
278
+ # Exclude: water (7, 8, 9) and negative values (ground -1, tree -2, building -3, etc.)
279
+ walkable = 1
280
+ if val == 7 or val == 8 or val == 9: # Water
281
+ walkable = 0
282
+ elif val < 0: # Ground, trees, buildings, landmarks, etc.
283
+ walkable = 0
284
+ is_walkable[i, j, k] = walkable
285
+
286
+ # Set up allowed and blocker based on mode
287
+ if inclusion_mode == 1:
288
+ is_allowed[i, j, k] = target
289
+ # Blocker: non-tree, non-target, non-empty
290
+ blocker = 0
291
+ if val != 0 and tree == 0 and target == 0:
292
+ blocker = 1
293
+ is_blocker[i, j, k] = blocker
294
+ else:
295
+ is_allowed[i, j, k] = target
296
+ is_blocker[i, j, k] = 0
297
+
298
+ @ti.kernel
299
+ def _compute_vi_map_kernel(
300
+ self,
301
+ vi_map: ti.template(),
302
+ view_height_voxel: ti.i32,
303
+ is_tree: ti.template(),
304
+ is_solid: ti.template(),
305
+ is_target: ti.template(),
306
+ is_allowed: ti.template(),
307
+ is_blocker: ti.template(),
308
+ is_walkable: ti.template(),
309
+ inclusion_mode: ti.i32,
310
+ tree_att: ti.f32
311
+ ):
312
+ """Compute View Index map using GPU parallel processing."""
313
+ for x, y in vi_map:
314
+ # Find observer position (first non-solid cell above ground)
315
+ # Allow being inside tree canopy
316
+ observer_z = -1
317
+ surface_walkable = 0
318
+ for z in range(1, self.nz):
319
+ # Current cell is not solid (but can be inside tree)
320
+ current_not_solid = 1 if is_solid[x, y, z] == 0 else 0
321
+ # Below cell has something (ground, building, or tree)
322
+ below_has_something = is_solid[x, y, z-1] + is_tree[x, y, z-1]
323
+
324
+ if current_not_solid == 1 and below_has_something > 0:
325
+ # Found ground level - check if walkable
326
+ surface_walkable = is_walkable[x, y, z-1]
327
+ observer_z = z + view_height_voxel
328
+ break
329
+
330
+ # Mark as invalid if no observer position found or surface not walkable
331
+ # (water, building tops, etc. are not walkable)
332
+ if observer_z < 0 or observer_z >= self.nz or surface_walkable == 0:
333
+ vi_map[x, y] = ti.cast(float('nan'), ti.f32)
334
+ continue
335
+
336
+ # Trace rays and count visibility
337
+ visibility_sum = 0.0
338
+ valid_rays = 0
339
+
340
+ for r in range(self._n_ray_dirs):
341
+ ray_dir = self._ray_dirs[r]
342
+
343
+ # Trace ray through voxels
344
+ hit, trans = self._trace_ray_vi(
345
+ x, y, observer_z, ray_dir,
346
+ is_tree, is_solid, is_target, is_allowed, is_blocker,
347
+ inclusion_mode, tree_att
348
+ )
349
+
350
+ if inclusion_mode == 1:
351
+ if hit == 1:
352
+ visibility_sum += 1.0
353
+ else:
354
+ if hit == 0:
355
+ visibility_sum += trans
356
+
357
+ valid_rays += 1
358
+
359
+ if valid_rays > 0:
360
+ vi_map[x, y] = visibility_sum / valid_rays
361
+ else:
362
+ vi_map[x, y] = 0.0
363
+
364
+ @ti.func
365
+ def _trace_ray_vi(
366
+ self,
367
+ ox: ti.i32,
368
+ oy: ti.i32,
369
+ oz: ti.i32,
370
+ ray_dir: Vector3,
371
+ is_tree: ti.template(),
372
+ is_solid: ti.template(),
373
+ is_target: ti.template(),
374
+ is_allowed: ti.template(),
375
+ is_blocker: ti.template(),
376
+ inclusion_mode: ti.i32,
377
+ tree_att: ti.f32
378
+ ):
379
+ """
380
+ Trace a ray for view index calculation.
381
+
382
+ Returns:
383
+ (hit, transmissivity) where hit=1 means target was hit (inclusion)
384
+ or ray was blocked (exclusion)
385
+ """
386
+ hit = 0
387
+ trans = 1.0
388
+
389
+ # Start from observer position
390
+ x = ti.cast(ox, ti.f32) + 0.5
391
+ y = ti.cast(oy, ti.f32) + 0.5
392
+ z = ti.cast(oz, ti.f32) + 0.5
393
+
394
+ i = ox
395
+ j = oy
396
+ k = oz
397
+
398
+ # Track starting position to skip it
399
+ start_i = ox
400
+ start_j = oy
401
+ start_k = oz
402
+
403
+ step_x = 1 if ray_dir[0] >= 0 else -1
404
+ step_y = 1 if ray_dir[1] >= 0 else -1
405
+ step_z = 1 if ray_dir[2] >= 0 else -1
406
+
407
+ BIG = 1e30
408
+ t_max_x, t_max_y, t_max_z = BIG, BIG, BIG
409
+ t_delta_x, t_delta_y, t_delta_z = BIG, BIG, BIG
410
+
411
+ if ti.abs(ray_dir[0]) > 1e-10:
412
+ t_max_x = ((i + (1 if step_x > 0 else 0)) - x) / ray_dir[0]
413
+ t_delta_x = ti.abs(1.0 / ray_dir[0])
414
+ if ti.abs(ray_dir[1]) > 1e-10:
415
+ t_max_y = ((j + (1 if step_y > 0 else 0)) - y) / ray_dir[1]
416
+ t_delta_y = ti.abs(1.0 / ray_dir[1])
417
+ if ti.abs(ray_dir[2]) > 1e-10:
418
+ t_max_z = ((k + (1 if step_z > 0 else 0)) - z) / ray_dir[2]
419
+ t_delta_z = ti.abs(1.0 / ray_dir[2])
420
+
421
+ max_steps = self.nx + self.ny + self.nz
422
+ done = 0
423
+ first_step = 1 # Skip the starting voxel
424
+
425
+ for _ in range(max_steps):
426
+ if done == 0:
427
+ # Bounds check
428
+ if i < 0 or i >= self.nx or j < 0 or j >= self.ny or k < 0 or k >= self.nz:
429
+ done = 1
430
+ else:
431
+ # Skip the starting voxel (observer's position)
432
+ at_start = 0
433
+ if i == start_i and j == start_j and k == start_k:
434
+ at_start = 1
435
+
436
+ if at_start == 0:
437
+ # Check tree - accumulate transmissivity
438
+ if is_tree[i, j, k] == 1:
439
+ trans *= tree_att
440
+ if trans < 0.01:
441
+ if inclusion_mode == 0:
442
+ hit = 1 # Blocked in exclusion mode
443
+ done = 1
444
+
445
+ if done == 0:
446
+ if inclusion_mode == 1:
447
+ # Inclusion mode: looking for target (including trees as targets)
448
+ if is_target[i, j, k] == 1:
449
+ hit = 1
450
+ done = 1
451
+ elif is_blocker[i, j, k] == 1:
452
+ hit = 0
453
+ done = 1
454
+ else:
455
+ # Exclusion mode: looking for non-allowed
456
+ if is_tree[i, j, k] == 0 and is_allowed[i, j, k] == 0 and is_solid[i, j, k] == 1:
457
+ hit = 1 # Blocked
458
+ done = 1
459
+
460
+ if done == 0:
461
+ # Step to next voxel
462
+ if t_max_x < t_max_y:
463
+ if t_max_x < t_max_z:
464
+ t_max_x += t_delta_x
465
+ i += step_x
466
+ else:
467
+ t_max_z += t_delta_z
468
+ k += step_z
469
+ else:
470
+ if t_max_y < t_max_z:
471
+ t_max_y += t_delta_y
472
+ j += step_y
473
+ else:
474
+ t_max_z += t_delta_z
475
+ k += step_z
476
+
477
+ return hit, trans
478
+
479
+ def compute_sky_view_factor(
480
+ self,
481
+ voxel_data: np.ndarray = None,
482
+ view_point_height: float = 1.5,
483
+ n_azimuth: int = 120,
484
+ n_elevation: int = 20,
485
+ tree_k: float = 0.6,
486
+ tree_lad: float = 1.0
487
+ ) -> np.ndarray:
488
+ """
489
+ Compute Sky View Factor map.
490
+
491
+ Args:
492
+ voxel_data: 3D voxel class array (optional)
493
+ view_point_height: Observer height above ground (meters)
494
+ n_azimuth: Number of azimuthal divisions
495
+ n_elevation: Number of elevation divisions
496
+ tree_k: Tree extinction coefficient
497
+ tree_lad: Leaf area density
498
+
499
+ Returns:
500
+ 2D array of SVF values (nx, ny)
501
+ """
502
+ # SVF uses upper hemisphere (0-90 degrees elevation)
503
+ self.n_azimuth = n_azimuth
504
+ self.n_elevation = n_elevation
505
+
506
+ return self.compute_view_index(
507
+ voxel_data=voxel_data,
508
+ mode='sky',
509
+ view_point_height=view_point_height,
510
+ elevation_min_degrees=0.0,
511
+ elevation_max_degrees=90.0,
512
+ tree_k=tree_k,
513
+ tree_lad=tree_lad
514
+ )
515
+
516
+
517
+ @ti.data_oriented
518
+ class SurfaceViewFactorCalculator:
519
+ """
520
+ GPU-accelerated Surface View Factor calculator.
521
+
522
+ Computes view factors for building surface faces by tracing rays from
523
+ face centers through the voxel domain.
524
+
525
+ This emulates voxcity.simulator.visibility.view.get_surface_view_factor
526
+ using Taichi GPU acceleration.
527
+ """
528
+
529
+ def __init__(
530
+ self,
531
+ domain,
532
+ n_azimuth: int = 120,
533
+ n_elevation: int = 20,
534
+ ray_sampling: str = "grid",
535
+ n_rays: int = None
536
+ ):
537
+ """
538
+ Initialize Surface View Factor Calculator.
539
+
540
+ Args:
541
+ domain: Domain object with grid geometry
542
+ n_azimuth: Number of azimuthal divisions
543
+ n_elevation: Number of elevation divisions
544
+ ray_sampling: 'grid' or 'fibonacci'
545
+ n_rays: Total rays for fibonacci sampling
546
+ """
547
+ self.domain = domain
548
+ self.nx = domain.nx
549
+ self.ny = domain.ny
550
+ self.nz = domain.nz
551
+ self.dx = domain.dx
552
+ self.dy = domain.dy
553
+ self.dz = domain.dz
554
+ self.meshsize = domain.dx # Assuming uniform grid
555
+
556
+ self.n_azimuth = n_azimuth
557
+ self.n_elevation = n_elevation
558
+ self.ray_sampling = ray_sampling
559
+ self.n_rays = n_rays if n_rays else n_azimuth * n_elevation
560
+
561
+ # Pre-computed hemisphere directions (upper hemisphere only for surfaces)
562
+ self._hemisphere_dirs = None
563
+ self._n_hemisphere_dirs = 0
564
+ self._setup_hemisphere_directions()
565
+
566
+ def _setup_hemisphere_directions(self):
567
+ """Setup hemisphere ray directions for surface view factor."""
568
+ if self.ray_sampling.lower() == "fibonacci":
569
+ dirs_np = generate_ray_directions_fibonacci(
570
+ self.n_rays, 0.0, 90.0
571
+ )
572
+ else:
573
+ dirs_np = generate_ray_directions_grid(
574
+ self.n_azimuth, self.n_elevation, 0.0, 90.0
575
+ )
576
+
577
+ self._n_hemisphere_dirs = dirs_np.shape[0]
578
+ self._hemisphere_dirs = ti.Vector.field(3, dtype=ti.f32, shape=(self._n_hemisphere_dirs,))
579
+ self._hemisphere_dirs.from_numpy(dirs_np.astype(np.float32))
580
+
581
+ def compute_surface_view_factor(
582
+ self,
583
+ face_centers: np.ndarray,
584
+ face_normals: np.ndarray,
585
+ voxel_data: np.ndarray = None,
586
+ target_values: Tuple[int, ...] = (0,),
587
+ inclusion_mode: bool = False,
588
+ tree_k: float = 0.6,
589
+ tree_lad: float = 1.0,
590
+ boundary_epsilon: float = None
591
+ ) -> np.ndarray:
592
+ """
593
+ Compute view factors for building surface faces.
594
+
595
+ Args:
596
+ face_centers: Array of face center positions (n_faces, 3) in world coords
597
+ face_normals: Array of face normal vectors (n_faces, 3)
598
+ voxel_data: 3D voxel class array
599
+ target_values: Target voxel values for visibility
600
+ inclusion_mode: If True, count hits on targets; if False, count unblocked rays
601
+ tree_k: Tree extinction coefficient
602
+ tree_lad: Leaf area density
603
+ boundary_epsilon: Epsilon for boundary detection
604
+
605
+ Returns:
606
+ 1D array of view factor values for each face
607
+ """
608
+ n_faces = face_centers.shape[0]
609
+
610
+ if boundary_epsilon is None:
611
+ boundary_epsilon = self.meshsize * 0.05
612
+
613
+ # Grid bounds in world coordinates
614
+ grid_bounds_real = np.array([
615
+ [0.0, 0.0, 0.0],
616
+ [self.nx * self.meshsize, self.ny * self.meshsize, self.nz * self.meshsize]
617
+ ], dtype=np.float32)
618
+
619
+ # Prepare Taichi fields
620
+ face_centers_ti = ti.Vector.field(3, dtype=ti.f32, shape=(n_faces,))
621
+ face_normals_ti = ti.Vector.field(3, dtype=ti.f32, shape=(n_faces,))
622
+ face_vf_values = ti.field(dtype=ti.f32, shape=(n_faces,))
623
+
624
+ face_centers_ti.from_numpy(face_centers.astype(np.float32))
625
+ face_normals_ti.from_numpy(face_normals.astype(np.float32))
626
+
627
+ # Prepare masks
628
+ if voxel_data is not None:
629
+ is_tree = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
630
+ is_solid = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
631
+ is_target = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
632
+ is_opaque = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
633
+
634
+ target_values_arr = np.array(target_values, dtype=np.int32)
635
+ self._setup_surface_masks(
636
+ voxel_data, target_values_arr, len(target_values_arr), inclusion_mode,
637
+ is_tree, is_solid, is_target, is_opaque
638
+ )
639
+ else:
640
+ is_tree = self.domain.is_tree
641
+ is_solid = self.domain.is_solid
642
+ is_target = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
643
+ is_opaque = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
644
+
645
+ # Tree attenuation
646
+ tree_att = float(math.exp(-tree_k * tree_lad * self.meshsize))
647
+ att_cutoff = 0.01
648
+ trees_are_targets = (-2 in target_values) and inclusion_mode
649
+
650
+ # Run GPU computation
651
+ self._compute_surface_vf_kernel(
652
+ face_centers_ti, face_normals_ti, face_vf_values,
653
+ is_tree, is_solid, is_target, is_opaque,
654
+ tree_att, att_cutoff, inclusion_mode, int(trees_are_targets),
655
+ grid_bounds_real, boundary_epsilon
656
+ )
657
+
658
+ return face_vf_values.to_numpy()
659
+
660
+ @ti.kernel
661
+ def _setup_surface_masks(
662
+ self,
663
+ voxel_data: ti.types.ndarray(),
664
+ target_values: ti.types.ndarray(),
665
+ n_targets: ti.i32,
666
+ inclusion_mode: ti.i32,
667
+ is_tree: ti.template(),
668
+ is_solid: ti.template(),
669
+ is_target: ti.template(),
670
+ is_opaque: ti.template()
671
+ ):
672
+ for i, j, k in is_tree:
673
+ val = voxel_data[i, j, k]
674
+
675
+ # Tree check
676
+ tree = 0
677
+ if val == -2:
678
+ tree = 1
679
+ is_tree[i, j, k] = tree
680
+
681
+ # Solid check
682
+ solid = 0
683
+ if val != 0:
684
+ solid = 1
685
+ is_solid[i, j, k] = solid
686
+
687
+ # Target check
688
+ target = 0
689
+ for t in range(n_targets):
690
+ if val == target_values[t]:
691
+ target = 1
692
+ is_target[i, j, k] = target
693
+
694
+ # Opaque: non-zero, non-tree, non-target (for inclusion mode)
695
+ opaque = 0
696
+ if inclusion_mode == 1:
697
+ if val != 0 and tree == 0 and target == 0:
698
+ opaque = 1
699
+ else:
700
+ if val != 0 and tree == 0:
701
+ opaque = 1
702
+ is_opaque[i, j, k] = opaque
703
+
704
+ @ti.kernel
705
+ def _compute_surface_vf_kernel(
706
+ self,
707
+ face_centers: ti.template(),
708
+ face_normals: ti.template(),
709
+ face_vf_values: ti.template(),
710
+ is_tree: ti.template(),
711
+ is_solid: ti.template(),
712
+ is_target: ti.template(),
713
+ is_opaque: ti.template(),
714
+ tree_att: ti.f32,
715
+ att_cutoff: ti.f32,
716
+ inclusion_mode: ti.i32,
717
+ trees_are_targets: ti.i32,
718
+ grid_bounds: ti.types.ndarray(),
719
+ boundary_epsilon: ti.f32
720
+ ):
721
+ """Compute surface view factors using GPU parallel processing."""
722
+ for f in face_vf_values:
723
+ center = face_centers[f]
724
+ normal = face_normals[f]
725
+
726
+ # Check if face is on domain boundary (vertical and on edge)
727
+ is_vertical = ti.abs(normal[2]) < 0.01
728
+ on_x_min = ti.abs(center[0] - grid_bounds[0, 0]) < boundary_epsilon
729
+ on_y_min = ti.abs(center[1] - grid_bounds[0, 1]) < boundary_epsilon
730
+ on_x_max = ti.abs(center[0] - grid_bounds[1, 0]) < boundary_epsilon
731
+ on_y_max = ti.abs(center[1] - grid_bounds[1, 1]) < boundary_epsilon
732
+
733
+ is_boundary = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
734
+
735
+ if is_boundary:
736
+ face_vf_values[f] = ti.cast(float('nan'), ti.f32)
737
+ else:
738
+ # Build local coordinate system for face
739
+ u, v, n = self._build_face_basis(normal)
740
+
741
+ # Origin: face center offset by normal (in voxel coordinates)
742
+ meshsize = self.dx
743
+ ox = center[0] / meshsize + n[0] * 0.51
744
+ oy = center[1] / meshsize + n[1] * 0.51
745
+ oz = center[2] / meshsize + n[2] * 0.51
746
+
747
+ vis_sum = 0.0
748
+ valid_count = 0
749
+
750
+ # Trace rays in hemisphere
751
+ for r in range(self._n_hemisphere_dirs):
752
+ local_dir = self._hemisphere_dirs[r]
753
+
754
+ # Transform to world direction
755
+ dx = u[0]*local_dir[0] + v[0]*local_dir[1] + n[0]*local_dir[2]
756
+ dy = u[1]*local_dir[0] + v[1]*local_dir[1] + n[1]*local_dir[2]
757
+ dz = u[2]*local_dir[0] + v[2]*local_dir[1] + n[2]*local_dir[2]
758
+
759
+ world_dir = ti.Vector([dx, dy, dz])
760
+
761
+ # Only trace rays going outward from surface
762
+ dot = world_dir.dot(n)
763
+ if dot > 0.0:
764
+ contrib = self._trace_surface_ray(
765
+ ox, oy, oz, world_dir,
766
+ is_tree, is_solid, is_target, is_opaque,
767
+ tree_att, att_cutoff, inclusion_mode, trees_are_targets
768
+ )
769
+ vis_sum += contrib
770
+ valid_count += 1
771
+
772
+ if valid_count > 0:
773
+ face_vf_values[f] = vis_sum / valid_count
774
+ else:
775
+ face_vf_values[f] = 0.0
776
+
777
+ @ti.func
778
+ def _build_face_basis(self, normal: ti.template()) -> ti.template():
779
+ """Build orthonormal basis (u, v, n) for a surface normal."""
780
+ nrm = normal.norm()
781
+ n = normal
782
+ u = ti.Vector([1.0, 0.0, 0.0])
783
+ v = ti.Vector([0.0, 1.0, 0.0])
784
+
785
+ if nrm > 1e-12:
786
+ n = normal / nrm
787
+
788
+ # Choose helper vector
789
+ helper = ti.Vector([0.0, 0.0, 1.0])
790
+ if ti.abs(n[2]) >= 0.999:
791
+ helper = ti.Vector([1.0, 0.0, 0.0])
792
+
793
+ # u = helper x n
794
+ u = helper.cross(n)
795
+ ul = u.norm()
796
+ if ul > 1e-12:
797
+ u = u / ul
798
+ else:
799
+ u = ti.Vector([1.0, 0.0, 0.0])
800
+
801
+ # v = n x u
802
+ v = n.cross(u)
803
+
804
+ return u, v, n
805
+
806
+ @ti.func
807
+ def _trace_surface_ray(
808
+ self,
809
+ ox: ti.f32,
810
+ oy: ti.f32,
811
+ oz: ti.f32,
812
+ ray_dir: ti.template(),
813
+ is_tree: ti.template(),
814
+ is_solid: ti.template(),
815
+ is_target: ti.template(),
816
+ is_opaque: ti.template(),
817
+ tree_att: ti.f32,
818
+ att_cutoff: ti.f32,
819
+ inclusion_mode: ti.i32,
820
+ trees_are_targets: ti.i32
821
+ ) -> ti.f32:
822
+ """Trace ray from surface for view factor calculation."""
823
+ T = 1.0
824
+ result = 0.0
825
+
826
+ x = ox
827
+ y = oy
828
+ z = oz
829
+
830
+ i = ti.cast(ti.floor(ox), ti.i32)
831
+ j = ti.cast(ti.floor(oy), ti.i32)
832
+ k = ti.cast(ti.floor(oz), ti.i32)
833
+
834
+ step_x = 1 if ray_dir[0] >= 0 else -1
835
+ step_y = 1 if ray_dir[1] >= 0 else -1
836
+ step_z = 1 if ray_dir[2] >= 0 else -1
837
+
838
+ BIG = 1e30
839
+ t_max_x, t_max_y, t_max_z = BIG, BIG, BIG
840
+ t_delta_x, t_delta_y, t_delta_z = BIG, BIG, BIG
841
+
842
+ if ti.abs(ray_dir[0]) > 1e-10:
843
+ t_max_x = ((i + (1 if step_x > 0 else 0)) - x) / ray_dir[0]
844
+ t_delta_x = ti.abs(1.0 / ray_dir[0])
845
+ if ti.abs(ray_dir[1]) > 1e-10:
846
+ t_max_y = ((j + (1 if step_y > 0 else 0)) - y) / ray_dir[1]
847
+ t_delta_y = ti.abs(1.0 / ray_dir[1])
848
+ if ti.abs(ray_dir[2]) > 1e-10:
849
+ t_max_z = ((k + (1 if step_z > 0 else 0)) - z) / ray_dir[2]
850
+ t_delta_z = ti.abs(1.0 / ray_dir[2])
851
+
852
+ max_steps = self.nx + self.ny + self.nz
853
+ done = 0
854
+
855
+ for _ in range(max_steps):
856
+ if done == 0:
857
+ # Bounds check - ray escaped
858
+ if i < 0 or i >= self.nx or j < 0 or j >= self.ny or k < 0 or k >= self.nz:
859
+ if inclusion_mode == 1:
860
+ result = 0.0
861
+ else:
862
+ result = T
863
+ done = 1
864
+ else:
865
+ # Check opaque (blocker)
866
+ if is_opaque[i, j, k] == 1:
867
+ result = 0.0
868
+ done = 1
869
+ elif is_tree[i, j, k] == 1:
870
+ # Tree attenuation
871
+ T *= tree_att
872
+ if T < att_cutoff:
873
+ result = 0.0
874
+ done = 1
875
+ elif trees_are_targets == 1:
876
+ # Trees count as partial visibility
877
+ result = 1.0 - T
878
+ done = 1
879
+ elif inclusion_mode == 1 and is_target[i, j, k] == 1:
880
+ # Hit target in inclusion mode
881
+ result = 1.0
882
+ done = 1
883
+
884
+ if done == 0:
885
+ # Step to next voxel
886
+ if t_max_x < t_max_y:
887
+ if t_max_x < t_max_z:
888
+ t_max_x += t_delta_x
889
+ i += step_x
890
+ else:
891
+ t_max_z += t_delta_z
892
+ k += step_z
893
+ else:
894
+ if t_max_y < t_max_z:
895
+ t_max_y += t_delta_y
896
+ j += step_y
897
+ else:
898
+ t_max_z += t_delta_z
899
+ k += step_z
900
+
901
+ return result
902
+
903
+
904
+ # Convenience functions
905
+ def compute_view_index_map(
906
+ domain,
907
+ voxel_data: np.ndarray = None,
908
+ mode: str = 'green',
909
+ **kwargs
910
+ ) -> np.ndarray:
911
+ """
912
+ Compute View Index map.
913
+
914
+ Args:
915
+ domain: Domain object
916
+ voxel_data: 3D voxel class array
917
+ mode: 'green', 'sky', or custom
918
+ **kwargs: Additional parameters for ViewCalculator
919
+
920
+ Returns:
921
+ 2D view index map
922
+ """
923
+ calc = ViewCalculator(domain)
924
+ return calc.compute_view_index(voxel_data=voxel_data, mode=mode, **kwargs)
925
+
926
+
927
+ def compute_sky_view_factor_map(
928
+ domain,
929
+ voxel_data: np.ndarray = None,
930
+ **kwargs
931
+ ) -> np.ndarray:
932
+ """
933
+ Compute Sky View Factor map.
934
+
935
+ Args:
936
+ domain: Domain object
937
+ voxel_data: 3D voxel class array
938
+ **kwargs: Additional parameters
939
+
940
+ Returns:
941
+ 2D SVF map
942
+ """
943
+ calc = ViewCalculator(domain)
944
+ return calc.compute_sky_view_factor(voxel_data=voxel_data, **kwargs)