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,753 @@
1
+ """
2
+ Landmark visibility calculation using Taichi GPU acceleration.
3
+
4
+ This module emulates the functionality of voxcity.simulator.visibility.landmark
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, List
12
+
13
+ from ..core import Vector3, Point3
14
+ from ..init_taichi import ensure_initialized
15
+ from ..raytracing import ray_trace_to_target
16
+
17
+
18
+ @ti.data_oriented
19
+ class LandmarkVisibilityCalculator:
20
+ """
21
+ GPU-accelerated Landmark Visibility calculator.
22
+
23
+ Computes visibility of landmark buildings from observation points
24
+ throughout the domain.
25
+ """
26
+
27
+ def __init__(self, domain):
28
+ """
29
+ Initialize Landmark Visibility Calculator.
30
+
31
+ Args:
32
+ domain: Domain object with grid geometry
33
+ """
34
+ # Ensure Taichi is initialized before creating any fields
35
+ ensure_initialized()
36
+
37
+ self.domain = domain
38
+ self.nx = domain.nx
39
+ self.ny = domain.ny
40
+ self.nz = domain.nz
41
+ self.dx = domain.dx
42
+ self.dy = domain.dy
43
+ self.dz = domain.dz
44
+
45
+ # Landmark positions (will be set later)
46
+ self._landmark_positions = None
47
+ self._n_landmarks = 0
48
+
49
+ def set_landmarks_from_positions(self, positions: np.ndarray):
50
+ """
51
+ Set landmark positions directly.
52
+
53
+ Args:
54
+ positions: Array of shape (n_landmarks, 3) with (x, y, z) coordinates
55
+ """
56
+ self._n_landmarks = positions.shape[0]
57
+ self._landmark_positions = ti.Vector.field(3, dtype=ti.f32, shape=(self._n_landmarks,))
58
+ self._landmark_positions.from_numpy(positions.astype(np.float32))
59
+
60
+ def set_landmarks_from_voxel_value(self, voxel_data: np.ndarray, landmark_value: int = -30):
61
+ """
62
+ Set landmark positions from voxel data based on a marker value.
63
+
64
+ Args:
65
+ voxel_data: 3D voxel class array
66
+ landmark_value: Voxel value marking landmarks
67
+ """
68
+ positions = np.argwhere(voxel_data == landmark_value).astype(np.float32)
69
+ if positions.shape[0] == 0:
70
+ raise ValueError(f"No landmark with value {landmark_value} found in voxel data.")
71
+ self.set_landmarks_from_positions(positions)
72
+
73
+ def compute_visibility_map(
74
+ self,
75
+ voxel_data: np.ndarray = None,
76
+ view_height_voxel: int = 0,
77
+ tree_k: float = 0.6,
78
+ tree_lad: float = 1.0
79
+ ) -> np.ndarray:
80
+ """
81
+ Compute landmark visibility map.
82
+
83
+ Args:
84
+ voxel_data: 3D voxel class array (optional if domain has masks)
85
+ view_height_voxel: Observer height in voxels above ground
86
+ tree_k: Tree extinction coefficient
87
+ tree_lad: Leaf area density
88
+
89
+ Returns:
90
+ 2D array with 1 where landmark is visible, 0 otherwise, nan for invalid
91
+ """
92
+ if self._landmark_positions is None or self._n_landmarks == 0:
93
+ raise ValueError("No landmarks set. Call set_landmarks_* first.")
94
+
95
+ # Prepare output
96
+ visibility_map = ti.field(dtype=ti.f32, shape=(self.nx, self.ny))
97
+
98
+ # Prepare masks
99
+ if voxel_data is not None:
100
+ is_tree = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
101
+ is_solid = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
102
+ is_walkable = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
103
+ self._setup_masks_from_voxel(voxel_data, is_tree, is_solid, is_walkable)
104
+ else:
105
+ is_tree = self.domain.is_tree
106
+ is_solid = self.domain.is_solid
107
+ # Create walkable mask - assume all non-solid, non-tree surfaces are walkable
108
+ is_walkable = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
109
+ self._init_walkable_from_domain(is_tree, is_solid, is_walkable)
110
+
111
+ # Tree attenuation
112
+ tree_att = float(math.exp(-tree_k * tree_lad * self.dz))
113
+ att_cutoff = 0.01
114
+
115
+ # Run GPU computation
116
+ self._compute_visibility_map_kernel(
117
+ visibility_map, view_height_voxel,
118
+ is_tree, is_solid, is_walkable, tree_att, att_cutoff
119
+ )
120
+
121
+ # Return flipped result
122
+ result = visibility_map.to_numpy()
123
+ return np.flipud(result)
124
+
125
+ def _setup_masks_from_voxel(
126
+ self,
127
+ voxel_data: np.ndarray,
128
+ is_tree: ti.template(),
129
+ is_solid: ti.template(),
130
+ is_walkable: ti.template()
131
+ ):
132
+ """Setup tree, solid, and walkable masks from voxel data."""
133
+ self._setup_masks_kernel(voxel_data, is_tree, is_solid, is_walkable)
134
+
135
+ @ti.kernel
136
+ def _init_walkable_from_domain(
137
+ self,
138
+ is_tree: ti.template(),
139
+ is_solid: ti.template(),
140
+ is_walkable: ti.template()
141
+ ):
142
+ """Initialize walkable mask from domain masks (assume all walkable)."""
143
+ for i, j, k in is_walkable:
144
+ # Without voxel_data, assume surfaces are walkable if not tree/solid
145
+ is_walkable[i, j, k] = 1
146
+
147
+ @ti.kernel
148
+ def _setup_masks_kernel(
149
+ self,
150
+ voxel_data: ti.types.ndarray(),
151
+ is_tree: ti.template(),
152
+ is_solid: ti.template(),
153
+ is_walkable: ti.template()
154
+ ):
155
+ for i, j, k in is_tree:
156
+ val = voxel_data[i, j, k]
157
+
158
+ tree = 0
159
+ if val == -2:
160
+ tree = 1
161
+ is_tree[i, j, k] = tree
162
+
163
+ # Solid blocks rays, but NOT landmark voxels (val == -30)
164
+ solid = 0
165
+ if val != 0 and val != -2 and val != -30:
166
+ solid = 1
167
+ is_solid[i, j, k] = solid
168
+
169
+ # Walkable: surfaces that are valid observer positions
170
+ # Exclude: water (7, 8, 9) and negative values (ground -1, tree -2, building -3, etc.)
171
+ # A surface is walkable if the voxel value is positive and not water
172
+ walkable = 1
173
+ if val == 7 or val == 8 or val == 9: # Water
174
+ walkable = 0
175
+ elif val < 0: # Ground, trees, buildings, landmarks, etc.
176
+ walkable = 0
177
+ is_walkable[i, j, k] = walkable
178
+
179
+ @ti.kernel
180
+ def _compute_visibility_map_kernel(
181
+ self,
182
+ visibility_map: ti.template(),
183
+ view_height_voxel: ti.i32,
184
+ is_tree: ti.template(),
185
+ is_solid: ti.template(),
186
+ is_walkable: ti.template(),
187
+ tree_att: ti.f32,
188
+ att_cutoff: ti.f32
189
+ ):
190
+ """Compute landmark visibility map using GPU parallel processing."""
191
+ for x, y in visibility_map:
192
+ # Find observer position (first air voxel above a solid surface)
193
+ observer_z = -1
194
+ surface_walkable = 0
195
+ for z in range(1, self.nz):
196
+ val_above = is_solid[x, y, z] + is_tree[x, y, z]
197
+ val_below = is_solid[x, y, z-1] + is_tree[x, y, z-1]
198
+
199
+ if val_above == 0 and val_below > 0:
200
+ # Found ground level - check if walkable
201
+ surface_walkable = is_walkable[x, y, z-1]
202
+ observer_z = z + view_height_voxel
203
+ break
204
+
205
+ # Mark as invalid if no observer position found or surface not walkable
206
+ # (water, building tops, etc. are not walkable)
207
+ if observer_z < 0 or observer_z >= self.nz or surface_walkable == 0:
208
+ visibility_map[x, y] = ti.cast(float('nan'), ti.f32)
209
+ continue
210
+
211
+ # Check visibility to any landmark
212
+ visible = 0
213
+ origin = Vector3(ti.cast(x, ti.f32), ti.cast(y, ti.f32), ti.cast(observer_z, ti.f32))
214
+
215
+ for lm in range(self._n_landmarks):
216
+ if visible == 0:
217
+ target = self._landmark_positions[lm]
218
+
219
+ vis = self._trace_to_landmark(
220
+ origin, target,
221
+ is_tree, is_solid,
222
+ tree_att, att_cutoff
223
+ )
224
+
225
+ if vis == 1:
226
+ visible = 1
227
+
228
+ visibility_map[x, y] = ti.cast(visible, ti.f32)
229
+
230
+ @ti.func
231
+ def _trace_to_landmark(
232
+ self,
233
+ origin: Vector3,
234
+ target: Vector3,
235
+ is_tree: ti.template(),
236
+ is_solid: ti.template(),
237
+ tree_att: ti.f32,
238
+ att_cutoff: ti.f32
239
+ ) -> ti.i32:
240
+ """Trace ray from origin to target landmark."""
241
+ diff = target - origin
242
+ dist = diff.norm()
243
+
244
+ visible = 1
245
+
246
+ if dist < 0.01:
247
+ visible = 1
248
+ else:
249
+ ray_dir = diff / dist
250
+
251
+ ox, oy, oz = origin[0], origin[1], origin[2]
252
+ x = ox + 0.5
253
+ y = oy + 0.5
254
+ z = oz + 0.5
255
+
256
+ i = ti.cast(ti.floor(ox), ti.i32)
257
+ j = ti.cast(ti.floor(oy), ti.i32)
258
+ k = ti.cast(ti.floor(oz), ti.i32)
259
+
260
+ ti_x = ti.cast(ti.floor(target[0]), ti.i32)
261
+ tj_y = ti.cast(ti.floor(target[1]), ti.i32)
262
+ tk_z = ti.cast(ti.floor(target[2]), ti.i32)
263
+
264
+ step_x = 1 if ray_dir[0] >= 0 else -1
265
+ step_y = 1 if ray_dir[1] >= 0 else -1
266
+ step_z = 1 if ray_dir[2] >= 0 else -1
267
+
268
+ BIG = 1e30
269
+ t_max_x, t_max_y, t_max_z = BIG, BIG, BIG
270
+ t_delta_x, t_delta_y, t_delta_z = BIG, BIG, BIG
271
+
272
+ if ray_dir[0] != 0.0:
273
+ t_max_x = ((i + (1 if step_x > 0 else 0)) - x) / ray_dir[0]
274
+ t_delta_x = ti.abs(1.0 / ray_dir[0])
275
+ if ray_dir[1] != 0.0:
276
+ t_max_y = ((j + (1 if step_y > 0 else 0)) - y) / ray_dir[1]
277
+ t_delta_y = ti.abs(1.0 / ray_dir[1])
278
+ if ray_dir[2] != 0.0:
279
+ t_max_z = ((k + (1 if step_z > 0 else 0)) - z) / ray_dir[2]
280
+ t_delta_z = ti.abs(1.0 / ray_dir[2])
281
+
282
+ T = 1.0
283
+ max_steps = self.nx + self.ny + self.nz
284
+ done = 0
285
+
286
+ for _ in range(max_steps):
287
+ if done == 0:
288
+ # Check bounds
289
+ if i < 0 or i >= self.nx or j < 0 or j >= self.ny or k < 0 or k >= self.nz:
290
+ visible = 0
291
+ done = 1
292
+ # Check if reached target
293
+ elif i == ti_x and j == tj_y and k == tk_z:
294
+ visible = 1
295
+ done = 1
296
+ # Check for solid blocker (not the target)
297
+ elif is_solid[i, j, k] == 1:
298
+ visible = 0
299
+ done = 1
300
+ # Check for tree attenuation
301
+ elif is_tree[i, j, k] == 1:
302
+ T *= tree_att
303
+ if T < att_cutoff:
304
+ visible = 0
305
+ done = 1
306
+
307
+ # Move to next voxel using 3D DDA
308
+ if done == 0:
309
+ if t_max_x < t_max_y:
310
+ if t_max_x < t_max_z:
311
+ t_max_x += t_delta_x
312
+ i += step_x
313
+ else:
314
+ t_max_z += t_delta_z
315
+ k += step_z
316
+ else:
317
+ if t_max_y < t_max_z:
318
+ t_max_y += t_delta_y
319
+ j += step_y
320
+ else:
321
+ t_max_z += t_delta_z
322
+ k += step_z
323
+
324
+ return visible
325
+
326
+
327
+ def mark_building_by_id(
328
+ voxcity_grid_ori: np.ndarray,
329
+ building_id_grid_ori: np.ndarray,
330
+ ids: List[int],
331
+ mark: int = -30
332
+ ) -> np.ndarray:
333
+ """
334
+ Mark specific buildings in voxel data with a marker value.
335
+
336
+ Args:
337
+ voxcity_grid_ori: 3D voxel class array
338
+ building_id_grid_ori: 2D array of building IDs (VoxCity format - needs flipud to match voxel_data)
339
+ ids: List of building IDs to mark
340
+ mark: Marker value to use
341
+
342
+ Returns:
343
+ Modified voxel_data copy
344
+ """
345
+ voxel_data = voxcity_grid_ori.copy()
346
+
347
+ # VoxCity building_id_grid is flipped relative to voxel_data coordinate system
348
+ # We need to flip it to align with voxel_data
349
+ building_id_grid_aligned = np.flipud(building_id_grid_ori)
350
+
351
+ # Find positions where building IDs match
352
+ positions = np.where(np.isin(building_id_grid_aligned, ids))
353
+ for i in range(len(positions[0])):
354
+ x, y = positions[0][i], positions[1][i]
355
+ z_mask = voxel_data[x, y, :] == -3 # Building class
356
+ voxel_data[x, y, z_mask] = mark
357
+
358
+ return voxel_data
359
+
360
+
361
+ def compute_landmark_visibility(
362
+ voxel_data: np.ndarray,
363
+ target_value: int = -30,
364
+ view_height_voxel: int = 0,
365
+ colormap: str = 'viridis'
366
+ ) -> np.ndarray:
367
+ """VoxCity-compatible landmark visibility on raw voxel data.
368
+
369
+ Matches `voxcity.simulator.visibility.landmark.compute_landmark_visibility`.
370
+
371
+ Notes:
372
+ - Uses Taichi GPU ray tracing underneath.
373
+ - Returns a 2D map flipped with `np.flipud`, consistent with VoxCity.
374
+ """
375
+ from ..domain import Domain
376
+
377
+ if voxel_data.ndim != 3:
378
+ raise ValueError("voxel_data must be a 3D array")
379
+
380
+ nx, ny, nz = voxel_data.shape
381
+ domain = Domain(nx=nx, ny=ny, nz=nz, dx=1.0, dy=1.0, dz=1.0)
382
+ calc = LandmarkVisibilityCalculator(domain)
383
+ calc.set_landmarks_from_voxel_value(voxel_data, landmark_value=int(target_value))
384
+ visibility_map = calc.compute_visibility_map(
385
+ voxel_data=voxel_data,
386
+ view_height_voxel=int(view_height_voxel),
387
+ )
388
+
389
+ # Plot (VoxCity function always plots)
390
+ try:
391
+ import matplotlib.pyplot as plt
392
+ import matplotlib.patches as mpatches
393
+
394
+ cmap = plt.cm.get_cmap(colormap, 2).copy()
395
+ cmap.set_bad(color='lightgray')
396
+ plt.figure(figsize=(10, 8))
397
+ plt.imshow(visibility_map, origin='lower', cmap=cmap, vmin=0, vmax=1)
398
+ visible_patch = mpatches.Patch(color=cmap(1.0), label='Visible (1)')
399
+ not_visible_patch = mpatches.Patch(color=cmap(0.0), label='Not Visible (0)')
400
+ plt.legend(handles=[visible_patch, not_visible_patch], loc='center left', bbox_to_anchor=(1.0, 0.5))
401
+ plt.axis('off')
402
+ plt.show()
403
+ except Exception:
404
+ pass
405
+
406
+ return visibility_map
407
+
408
+
409
+ @ti.data_oriented
410
+ class SurfaceLandmarkVisibilityCalculator:
411
+ """
412
+ GPU-accelerated Surface Landmark Visibility calculator.
413
+
414
+ Computes visibility of landmarks from building surface faces
415
+ using Taichi GPU acceleration.
416
+
417
+ This emulates voxcity.simulator.visibility.landmark.get_surface_landmark_visibility.
418
+ """
419
+
420
+ def __init__(self, domain):
421
+ """
422
+ Initialize Surface Landmark Visibility Calculator.
423
+
424
+ Args:
425
+ domain: Domain object with grid geometry
426
+ """
427
+ self.domain = domain
428
+ self.nx = domain.nx
429
+ self.ny = domain.ny
430
+ self.nz = domain.nz
431
+ self.dx = domain.dx
432
+ self.dy = domain.dy
433
+ self.dz = domain.dz
434
+ self.meshsize = domain.dx
435
+
436
+ # Landmark positions
437
+ self._landmark_positions = None
438
+ self._n_landmarks = 0
439
+
440
+ def set_landmarks_from_positions(self, positions: np.ndarray):
441
+ """
442
+ Set landmark positions directly.
443
+
444
+ Args:
445
+ positions: Array of shape (n_landmarks, 3) with (x, y, z) coordinates in voxels
446
+ """
447
+ self._n_landmarks = positions.shape[0]
448
+ self._landmark_positions = ti.Vector.field(3, dtype=ti.f32, shape=(self._n_landmarks,))
449
+ self._landmark_positions.from_numpy(positions.astype(np.float32))
450
+
451
+ def set_landmarks_from_voxel_value(self, voxel_data: np.ndarray, landmark_value: int = -30):
452
+ """
453
+ Set landmark positions from voxel data based on a marker value.
454
+
455
+ Args:
456
+ voxel_data: 3D voxel class array
457
+ landmark_value: Voxel value marking landmarks
458
+ """
459
+ positions = np.argwhere(voxel_data == landmark_value).astype(np.float32)
460
+ if positions.shape[0] == 0:
461
+ raise ValueError(f"No landmark with value {landmark_value} found in voxel data.")
462
+ self.set_landmarks_from_positions(positions)
463
+
464
+ def compute_surface_landmark_visibility(
465
+ self,
466
+ face_centers: np.ndarray,
467
+ face_normals: np.ndarray,
468
+ voxel_data: np.ndarray = None,
469
+ landmark_value: int = -30,
470
+ tree_k: float = 0.6,
471
+ tree_lad: float = 1.0,
472
+ boundary_epsilon: float = None
473
+ ) -> np.ndarray:
474
+ """
475
+ Compute landmark visibility for building surface faces.
476
+
477
+ Args:
478
+ face_centers: Array of face center positions (n_faces, 3) in world coords
479
+ face_normals: Array of face normal vectors (n_faces, 3)
480
+ voxel_data: 3D voxel class array with landmarks marked
481
+ landmark_value: Voxel value marking landmarks
482
+ tree_k: Tree extinction coefficient
483
+ tree_lad: Leaf area density
484
+ boundary_epsilon: Epsilon for boundary detection
485
+
486
+ Returns:
487
+ 1D array with 1.0 where any landmark is visible, 0.0 otherwise, nan for boundary
488
+ """
489
+ if self._landmark_positions is None or self._n_landmarks == 0:
490
+ raise ValueError("No landmarks set. Call set_landmarks_* first.")
491
+
492
+ n_faces = face_centers.shape[0]
493
+
494
+ if boundary_epsilon is None:
495
+ boundary_epsilon = self.meshsize * 0.05
496
+
497
+ # Grid bounds in world coordinates
498
+ grid_bounds_real = np.array([
499
+ [0.0, 0.0, 0.0],
500
+ [self.nx * self.meshsize, self.ny * self.meshsize, self.nz * self.meshsize]
501
+ ], dtype=np.float32)
502
+
503
+ # Prepare Taichi fields
504
+ face_centers_ti = ti.Vector.field(3, dtype=ti.f32, shape=(n_faces,))
505
+ face_normals_ti = ti.Vector.field(3, dtype=ti.f32, shape=(n_faces,))
506
+ visibility_values = ti.field(dtype=ti.f32, shape=(n_faces,))
507
+
508
+ face_centers_ti.from_numpy(face_centers.astype(np.float32))
509
+ face_normals_ti.from_numpy(face_normals.astype(np.float32))
510
+
511
+ # Prepare masks
512
+ if voxel_data is not None:
513
+ is_tree = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
514
+ is_opaque = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
515
+ self._setup_surface_masks(voxel_data, landmark_value, is_tree, is_opaque)
516
+ else:
517
+ is_tree = self.domain.is_tree
518
+ is_opaque = ti.field(dtype=ti.i32, shape=(self.nx, self.ny, self.nz))
519
+
520
+ # Tree attenuation
521
+ tree_att = float(math.exp(-tree_k * tree_lad * self.meshsize))
522
+ att_cutoff = 0.01
523
+
524
+ # Run GPU computation
525
+ self._compute_surface_landmark_kernel(
526
+ face_centers_ti, face_normals_ti, visibility_values,
527
+ is_tree, is_opaque, tree_att, att_cutoff,
528
+ grid_bounds_real, boundary_epsilon
529
+ )
530
+
531
+ return visibility_values.to_numpy()
532
+
533
+ @ti.kernel
534
+ def _setup_surface_masks(
535
+ self,
536
+ voxel_data: ti.types.ndarray(),
537
+ landmark_value: ti.i32,
538
+ is_tree: ti.template(),
539
+ is_opaque: ti.template()
540
+ ):
541
+ for i, j, k in is_tree:
542
+ val = voxel_data[i, j, k]
543
+
544
+ # Tree check
545
+ tree = 0
546
+ if val == -2:
547
+ tree = 1
548
+ is_tree[i, j, k] = tree
549
+
550
+ # Opaque: non-zero, non-tree, non-landmark
551
+ opaque = 0
552
+ if val != 0 and val != -2 and val != landmark_value:
553
+ opaque = 1
554
+ is_opaque[i, j, k] = opaque
555
+
556
+ @ti.kernel
557
+ def _compute_surface_landmark_kernel(
558
+ self,
559
+ face_centers: ti.template(),
560
+ face_normals: ti.template(),
561
+ visibility_values: ti.template(),
562
+ is_tree: ti.template(),
563
+ is_opaque: ti.template(),
564
+ tree_att: ti.f32,
565
+ att_cutoff: ti.f32,
566
+ grid_bounds: ti.types.ndarray(),
567
+ boundary_epsilon: ti.f32
568
+ ):
569
+ """Compute surface landmark visibility using GPU parallel processing."""
570
+ for f in visibility_values:
571
+ center = face_centers[f]
572
+ normal = face_normals[f]
573
+
574
+ # Check if face is on domain boundary
575
+ is_vertical = ti.abs(normal[2]) < 0.01
576
+ on_x_min = ti.abs(center[0] - grid_bounds[0, 0]) < boundary_epsilon
577
+ on_y_min = ti.abs(center[1] - grid_bounds[0, 1]) < boundary_epsilon
578
+ on_x_max = ti.abs(center[0] - grid_bounds[1, 0]) < boundary_epsilon
579
+ on_y_max = ti.abs(center[1] - grid_bounds[1, 1]) < boundary_epsilon
580
+
581
+ is_boundary = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
582
+
583
+ if is_boundary:
584
+ visibility_values[f] = ti.cast(float('nan'), ti.f32)
585
+ else:
586
+ # Normalize normal
587
+ nrm = normal.norm()
588
+ n = normal
589
+ if nrm > 1e-12:
590
+ n = normal / nrm
591
+
592
+ # Origin: face center offset by normal (in voxel coordinates)
593
+ meshsize = self.dx
594
+ ox = center[0] / meshsize + n[0] * 0.1
595
+ oy = center[1] / meshsize + n[1] * 0.1
596
+ oz = center[2] / meshsize + n[2] * 0.1
597
+
598
+ visible = 0
599
+
600
+ # Check visibility to each landmark
601
+ for lm in range(self._n_landmarks):
602
+ if visible == 0:
603
+ target = self._landmark_positions[lm]
604
+
605
+ # Direction to landmark
606
+ rx = target[0] - ox
607
+ ry = target[1] - oy
608
+ rz = target[2] - oz
609
+ rlen = ti.sqrt(rx*rx + ry*ry + rz*rz)
610
+
611
+ if rlen > 0.0:
612
+ # Check if landmark is in front of face
613
+ rdx = rx / rlen
614
+ rdy = ry / rlen
615
+ rdz = rz / rlen
616
+
617
+ dot = rdx*n[0] + rdy*n[1] + rdz*n[2]
618
+ if dot > 0.0:
619
+ # Trace ray to landmark
620
+ vis = self._trace_to_landmark(
621
+ ox, oy, oz, target,
622
+ is_tree, is_opaque,
623
+ tree_att, att_cutoff
624
+ )
625
+ if vis == 1:
626
+ visible = 1
627
+
628
+ visibility_values[f] = ti.cast(visible, ti.f32)
629
+
630
+ @ti.func
631
+ def _trace_to_landmark(
632
+ self,
633
+ ox: ti.f32,
634
+ oy: ti.f32,
635
+ oz: ti.f32,
636
+ target: ti.template(),
637
+ is_tree: ti.template(),
638
+ is_opaque: ti.template(),
639
+ tree_att: ti.f32,
640
+ att_cutoff: ti.f32
641
+ ) -> ti.i32:
642
+ """Trace ray from surface to landmark."""
643
+ diff_x = target[0] - ox
644
+ diff_y = target[1] - oy
645
+ diff_z = target[2] - oz
646
+ dist = ti.sqrt(diff_x*diff_x + diff_y*diff_y + diff_z*diff_z)
647
+
648
+ visible = 1
649
+
650
+ if dist < 0.01:
651
+ visible = 1
652
+ else:
653
+ ray_dir = ti.Vector([diff_x/dist, diff_y/dist, diff_z/dist])
654
+
655
+ x = ox + 0.5
656
+ y = oy + 0.5
657
+ z = oz + 0.5
658
+
659
+ i = ti.cast(ti.floor(ox), ti.i32)
660
+ j = ti.cast(ti.floor(oy), ti.i32)
661
+ k = ti.cast(ti.floor(oz), ti.i32)
662
+
663
+ ti_x = ti.cast(ti.floor(target[0]), ti.i32)
664
+ tj_y = ti.cast(ti.floor(target[1]), ti.i32)
665
+ tk_z = ti.cast(ti.floor(target[2]), ti.i32)
666
+
667
+ step_x = 1 if ray_dir[0] >= 0 else -1
668
+ step_y = 1 if ray_dir[1] >= 0 else -1
669
+ step_z = 1 if ray_dir[2] >= 0 else -1
670
+
671
+ BIG = 1e30
672
+ t_max_x, t_max_y, t_max_z = BIG, BIG, BIG
673
+ t_delta_x, t_delta_y, t_delta_z = BIG, BIG, BIG
674
+
675
+ if ray_dir[0] != 0.0:
676
+ t_max_x = ((i + (1 if step_x > 0 else 0)) - x) / ray_dir[0]
677
+ t_delta_x = ti.abs(1.0 / ray_dir[0])
678
+ if ray_dir[1] != 0.0:
679
+ t_max_y = ((j + (1 if step_y > 0 else 0)) - y) / ray_dir[1]
680
+ t_delta_y = ti.abs(1.0 / ray_dir[1])
681
+ if ray_dir[2] != 0.0:
682
+ t_max_z = ((k + (1 if step_z > 0 else 0)) - z) / ray_dir[2]
683
+ t_delta_z = ti.abs(1.0 / ray_dir[2])
684
+
685
+ T = 1.0
686
+ max_steps = self.nx + self.ny + self.nz
687
+ done = 0
688
+
689
+ for _ in range(max_steps):
690
+ if done == 0:
691
+ if i < 0 or i >= self.nx or j < 0 or j >= self.ny or k < 0 or k >= self.nz:
692
+ visible = 0
693
+ done = 1
694
+ elif is_opaque[i, j, k] == 1:
695
+ # Check if we're at the target
696
+ if not (i == ti_x and j == tj_y and k == tk_z):
697
+ visible = 0
698
+ done = 1
699
+ elif is_tree[i, j, k] == 1:
700
+ T *= tree_att
701
+ if T < att_cutoff:
702
+ visible = 0
703
+ done = 1
704
+
705
+ if done == 0:
706
+ if i == ti_x and j == tj_y and k == tk_z:
707
+ done = 1
708
+ else:
709
+ if t_max_x < t_max_y:
710
+ if t_max_x < t_max_z:
711
+ t_max_x += t_delta_x
712
+ i += step_x
713
+ else:
714
+ t_max_z += t_delta_z
715
+ k += step_z
716
+ else:
717
+ if t_max_y < t_max_z:
718
+ t_max_y += t_delta_y
719
+ j += step_y
720
+ else:
721
+ t_max_z += t_delta_z
722
+ k += step_z
723
+
724
+ return visible
725
+
726
+
727
+ def compute_landmark_visibility_map(
728
+ domain,
729
+ voxel_data: np.ndarray,
730
+ landmark_value: int = -30,
731
+ view_height_voxel: int = 0,
732
+ **kwargs
733
+ ) -> np.ndarray:
734
+ """
735
+ Compute landmark visibility map.
736
+
737
+ Args:
738
+ domain: Domain object
739
+ voxel_data: 3D voxel class array with landmarks marked
740
+ landmark_value: Voxel value marking landmarks
741
+ view_height_voxel: Observer height in voxels
742
+ **kwargs: Additional parameters
743
+
744
+ Returns:
745
+ 2D visibility map
746
+ """
747
+ calc = LandmarkVisibilityCalculator(domain)
748
+ calc.set_landmarks_from_voxel_value(voxel_data, landmark_value)
749
+ return calc.compute_visibility_map(
750
+ voxel_data=voxel_data,
751
+ view_height_voxel=view_height_voxel,
752
+ **kwargs
753
+ )