voxcity 1.0.2__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 (50) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator_gpu/__init__.py +115 -0
  12. voxcity/simulator_gpu/common/__init__.py +9 -0
  13. voxcity/simulator_gpu/common/geometry.py +11 -0
  14. voxcity/simulator_gpu/core.py +322 -0
  15. voxcity/simulator_gpu/domain.py +262 -0
  16. voxcity/simulator_gpu/environment.yml +11 -0
  17. voxcity/simulator_gpu/init_taichi.py +154 -0
  18. voxcity/simulator_gpu/integration.py +15 -0
  19. voxcity/simulator_gpu/kernels.py +56 -0
  20. voxcity/simulator_gpu/radiation.py +28 -0
  21. voxcity/simulator_gpu/raytracing.py +623 -0
  22. voxcity/simulator_gpu/sky.py +9 -0
  23. voxcity/simulator_gpu/solar/__init__.py +178 -0
  24. voxcity/simulator_gpu/solar/core.py +66 -0
  25. voxcity/simulator_gpu/solar/csf.py +1249 -0
  26. voxcity/simulator_gpu/solar/domain.py +561 -0
  27. voxcity/simulator_gpu/solar/epw.py +421 -0
  28. voxcity/simulator_gpu/solar/integration.py +2953 -0
  29. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  30. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  31. voxcity/simulator_gpu/solar/reflection.py +533 -0
  32. voxcity/simulator_gpu/solar/sky.py +907 -0
  33. voxcity/simulator_gpu/solar/solar.py +337 -0
  34. voxcity/simulator_gpu/solar/svf.py +446 -0
  35. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  36. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  37. voxcity/simulator_gpu/temporal.py +13 -0
  38. voxcity/simulator_gpu/utils.py +25 -0
  39. voxcity/simulator_gpu/view.py +32 -0
  40. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  41. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  42. voxcity/simulator_gpu/visibility/integration.py +808 -0
  43. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  44. voxcity/simulator_gpu/visibility/view.py +944 -0
  45. voxcity/visualizer/renderer.py +2 -1
  46. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/METADATA +16 -53
  47. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/RECORD +50 -15
  48. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  49. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  50. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,686 @@
1
+ """
2
+ Ray tracing module for palm-solar.
3
+
4
+ Implements GPU-accelerated ray tracing through a 3D voxel grid
5
+ to compute shadows, sky view factors, and canopy absorption.
6
+ Uses 3D-DDA (Digital Differential Analyzer) for voxel traversal.
7
+
8
+ PALM Alignment:
9
+ - Beer-Lambert law for canopy: trans = exp(-ext_coef * LAD * path_length)
10
+ - Solid obstacles block rays completely (trans = 0)
11
+ - Ray tracing replaces PALM's raytrace_2d subroutine with GPU-parallel version
12
+
13
+ Difference from PALM:
14
+ - PALM pre-computes dsitrans for discrete solar directions
15
+ - palm_solar traces rays dynamically for exact sun position
16
+ - This gives identical physics with slightly different numerical approach
17
+
18
+ Note: This module contains solar-specific ray tracing implementations that use
19
+ LAD (Leaf Area Density) fields. For simpler view-based ray tracing using
20
+ is_tree masks, see simulator_gpu.raytracing.
21
+ """
22
+
23
+ import taichi as ti
24
+ import math
25
+ from typing import Tuple, Optional
26
+
27
+ from .core import Vector3, Point3, EXT_COEF
28
+
29
+
30
+ @ti.func
31
+ def ray_aabb_intersect(
32
+ ray_origin: Vector3,
33
+ ray_dir: Vector3,
34
+ box_min: Vector3,
35
+ box_max: Vector3,
36
+ t_min: ti.f32,
37
+ t_max: ti.f32
38
+ ):
39
+ """
40
+ Ray-AABB intersection using slab method.
41
+
42
+ Args:
43
+ ray_origin: Ray origin point
44
+ ray_dir: Ray direction (normalized)
45
+ box_min: AABB minimum corner
46
+ box_max: AABB maximum corner
47
+ t_min: Minimum t value
48
+ t_max: Maximum t value
49
+
50
+ Returns:
51
+ Tuple of (hit, t_enter, t_exit)
52
+ """
53
+ t_enter = t_min
54
+ t_exit = t_max
55
+ hit = 1
56
+
57
+ for i in ti.static(range(3)):
58
+ if ti.abs(ray_dir[i]) < 1e-10:
59
+ # Ray parallel to slab
60
+ if ray_origin[i] < box_min[i] or ray_origin[i] > box_max[i]:
61
+ hit = 0
62
+ else:
63
+ inv_d = 1.0 / ray_dir[i]
64
+ t1 = (box_min[i] - ray_origin[i]) * inv_d
65
+ t2 = (box_max[i] - ray_origin[i]) * inv_d
66
+
67
+ if t1 > t2:
68
+ t1, t2 = t2, t1
69
+
70
+ t_enter = ti.max(t_enter, t1)
71
+ t_exit = ti.min(t_exit, t2)
72
+
73
+ if t_enter > t_exit:
74
+ hit = 0
75
+
76
+ return hit, t_enter, t_exit
77
+
78
+
79
+ @ti.func
80
+ def ray_voxel_first_hit(
81
+ ray_origin: Vector3,
82
+ ray_dir: Vector3,
83
+ is_solid: ti.template(),
84
+ nx: ti.i32,
85
+ ny: ti.i32,
86
+ nz: ti.i32,
87
+ dx: ti.f32,
88
+ dy: ti.f32,
89
+ dz: ti.f32,
90
+ max_dist: ti.f32
91
+ ):
92
+ """
93
+ 3D-DDA ray marching to find first solid voxel hit.
94
+
95
+ Args:
96
+ ray_origin: Ray origin
97
+ ray_dir: Ray direction (normalized)
98
+ is_solid: 3D field of solid cells
99
+ nx, ny, nz: Grid dimensions
100
+ dx, dy, dz: Cell sizes
101
+ max_dist: Maximum ray distance
102
+
103
+ Returns:
104
+ Tuple of (hit, t_hit, ix, iy, iz)
105
+ """
106
+ hit = 0
107
+ t_hit = max_dist
108
+ hit_ix, hit_iy, hit_iz = 0, 0, 0
109
+
110
+ # Find entry into domain
111
+ domain_min = Vector3(0.0, 0.0, 0.0)
112
+ domain_max = Vector3(nx * dx, ny * dy, nz * dz)
113
+
114
+ in_domain, t_enter, t_exit = ray_aabb_intersect(
115
+ ray_origin, ray_dir, domain_min, domain_max, 0.0, max_dist
116
+ )
117
+
118
+ if in_domain == 1:
119
+ # Start position (slightly inside domain)
120
+ t = t_enter + 1e-5
121
+ pos = ray_origin + ray_dir * t
122
+
123
+ # Current voxel indices
124
+ ix = ti.cast(ti.floor(pos[0] / dx), ti.i32)
125
+ iy = ti.cast(ti.floor(pos[1] / dy), ti.i32)
126
+ iz = ti.cast(ti.floor(pos[2] / dz), ti.i32)
127
+
128
+ # Clamp to valid range
129
+ ix = ti.max(0, ti.min(nx - 1, ix))
130
+ iy = ti.max(0, ti.min(ny - 1, iy))
131
+ iz = ti.max(0, ti.min(nz - 1, iz))
132
+
133
+ # Step directions
134
+ step_x = 1 if ray_dir[0] >= 0 else -1
135
+ step_y = 1 if ray_dir[1] >= 0 else -1
136
+ step_z = 1 if ray_dir[2] >= 0 else -1
137
+
138
+ # Initialize DDA variables
139
+ t_max_x = 1e30
140
+ t_max_y = 1e30
141
+ t_max_z = 1e30
142
+ t_delta_x = 1e30
143
+ t_delta_y = 1e30
144
+ t_delta_z = 1e30
145
+
146
+ # t values for next boundary crossing
147
+ if ti.abs(ray_dir[0]) > 1e-10:
148
+ if step_x > 0:
149
+ t_max_x = ((ix + 1) * dx - pos[0]) / ray_dir[0] + t
150
+ else:
151
+ t_max_x = (ix * dx - pos[0]) / ray_dir[0] + t
152
+ t_delta_x = ti.abs(dx / ray_dir[0])
153
+
154
+ if ti.abs(ray_dir[1]) > 1e-10:
155
+ if step_y > 0:
156
+ t_max_y = ((iy + 1) * dy - pos[1]) / ray_dir[1] + t
157
+ else:
158
+ t_max_y = (iy * dy - pos[1]) / ray_dir[1] + t
159
+ t_delta_y = ti.abs(dy / ray_dir[1])
160
+
161
+ if ti.abs(ray_dir[2]) > 1e-10:
162
+ if step_z > 0:
163
+ t_max_z = ((iz + 1) * dz - pos[2]) / ray_dir[2] + t
164
+ else:
165
+ t_max_z = (iz * dz - pos[2]) / ray_dir[2] + t
166
+ t_delta_z = ti.abs(dz / ray_dir[2])
167
+
168
+ # 3D-DDA traversal - optimized with done flag to reduce branch divergence
169
+ # Using done flag pattern is more GPU-friendly than break statements
170
+ max_steps = nx + ny + nz
171
+ done = 0
172
+
173
+ for _ in range(max_steps):
174
+ # Use done flag pattern for GPU-friendly early termination
175
+ # This reduces warp divergence compared to break statements
176
+ if done == 0:
177
+ # Bounds check - exit if outside domain
178
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny or iz < 0 or iz >= nz:
179
+ done = 1
180
+ elif t > t_exit:
181
+ done = 1
182
+ # Check current voxel for solid hit
183
+ elif is_solid[ix, iy, iz] == 1:
184
+ hit = 1
185
+ t_hit = t
186
+ hit_ix = ix
187
+ hit_iy = iy
188
+ hit_iz = iz
189
+ done = 1
190
+ else:
191
+ # Step to next voxel using branchless min selection
192
+ # This is more GPU-efficient than nested if-else
193
+ if t_max_x < t_max_y and t_max_x < t_max_z:
194
+ t = t_max_x
195
+ ix += step_x
196
+ t_max_x += t_delta_x
197
+ elif t_max_y < t_max_z:
198
+ t = t_max_y
199
+ iy += step_y
200
+ t_max_y += t_delta_y
201
+ else:
202
+ t = t_max_z
203
+ iz += step_z
204
+ t_max_z += t_delta_z
205
+
206
+ return hit, t_hit, hit_ix, hit_iy, hit_iz
207
+
208
+
209
+ @ti.func
210
+ def ray_canopy_absorption(
211
+ ray_origin: Vector3,
212
+ ray_dir: Vector3,
213
+ lad: ti.template(),
214
+ is_solid: ti.template(),
215
+ nx: ti.i32,
216
+ ny: ti.i32,
217
+ nz: ti.i32,
218
+ dx: ti.f32,
219
+ dy: ti.f32,
220
+ dz: ti.f32,
221
+ max_dist: ti.f32,
222
+ ext_coef: ti.f32
223
+ ):
224
+ """
225
+ Trace ray through canopy computing Beer-Lambert absorption.
226
+
227
+ Args:
228
+ ray_origin: Ray origin
229
+ ray_dir: Ray direction (normalized)
230
+ lad: 3D field of Leaf Area Density
231
+ is_solid: 3D field of solid cells (buildings/terrain)
232
+ nx, ny, nz: Grid dimensions
233
+ dx, dy, dz: Cell sizes
234
+ max_dist: Maximum ray distance
235
+ ext_coef: Extinction coefficient
236
+
237
+ Returns:
238
+ Tuple of (transmissivity, path_length_through_canopy)
239
+ """
240
+ transmissivity = 1.0
241
+ total_lad_path = 0.0
242
+
243
+ # Find entry into domain
244
+ domain_min = Vector3(0.0, 0.0, 0.0)
245
+ domain_max = Vector3(nx * dx, ny * dy, nz * dz)
246
+
247
+ in_domain, t_enter, t_exit = ray_aabb_intersect(
248
+ ray_origin, ray_dir, domain_min, domain_max, 0.0, max_dist
249
+ )
250
+
251
+ if in_domain == 1:
252
+ t = t_enter + 1e-5
253
+ pos = ray_origin + ray_dir * t
254
+
255
+ ix = ti.cast(ti.floor(pos[0] / dx), ti.i32)
256
+ iy = ti.cast(ti.floor(pos[1] / dy), ti.i32)
257
+ iz = ti.cast(ti.floor(pos[2] / dz), ti.i32)
258
+
259
+ ix = ti.max(0, ti.min(nx - 1, ix))
260
+ iy = ti.max(0, ti.min(ny - 1, iy))
261
+ iz = ti.max(0, ti.min(nz - 1, iz))
262
+
263
+ step_x = 1 if ray_dir[0] >= 0 else -1
264
+ step_y = 1 if ray_dir[1] >= 0 else -1
265
+ step_z = 1 if ray_dir[2] >= 0 else -1
266
+
267
+ # Initialize all DDA variables
268
+ t_max_x = 1e30
269
+ t_max_y = 1e30
270
+ t_max_z = 1e30
271
+ t_delta_x = 1e30
272
+ t_delta_y = 1e30
273
+ t_delta_z = 1e30
274
+
275
+ if ti.abs(ray_dir[0]) > 1e-10:
276
+ if step_x > 0:
277
+ t_max_x = ((ix + 1) * dx - pos[0]) / ray_dir[0] + t
278
+ else:
279
+ t_max_x = (ix * dx - pos[0]) / ray_dir[0] + t
280
+ t_delta_x = ti.abs(dx / ray_dir[0])
281
+
282
+ if ti.abs(ray_dir[1]) > 1e-10:
283
+ if step_y > 0:
284
+ t_max_y = ((iy + 1) * dy - pos[1]) / ray_dir[1] + t
285
+ else:
286
+ t_max_y = (iy * dy - pos[1]) / ray_dir[1] + t
287
+ t_delta_y = ti.abs(dy / ray_dir[1])
288
+
289
+ if ti.abs(ray_dir[2]) > 1e-10:
290
+ if step_z > 0:
291
+ t_max_z = ((iz + 1) * dz - pos[2]) / ray_dir[2] + t
292
+ else:
293
+ t_max_z = (iz * dz - pos[2]) / ray_dir[2] + t
294
+ t_delta_z = ti.abs(dz / ray_dir[2])
295
+
296
+ t_prev = t
297
+ max_steps = nx + ny + nz
298
+ done = 0
299
+
300
+ for _ in range(max_steps):
301
+ # GPU-friendly done flag pattern reduces warp divergence
302
+ if done == 0:
303
+ # Bounds and exit checks
304
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny or iz < 0 or iz >= nz:
305
+ done = 1
306
+ elif t > t_exit:
307
+ done = 1
308
+ # Hit solid -> ray blocked
309
+ elif is_solid[ix, iy, iz] == 1:
310
+ transmissivity = 0.0
311
+ done = 1
312
+ else:
313
+ # Get step distance using branchless min
314
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
315
+
316
+ # Path length through this cell
317
+ path_len = t_next - t_prev
318
+
319
+ # Accumulate absorption from LAD
320
+ # Using fused multiply-add for efficiency
321
+ cell_lad = lad[ix, iy, iz]
322
+ if cell_lad > 0.0:
323
+ lad_path = cell_lad * path_len
324
+ total_lad_path += lad_path
325
+ # Beer-Lambert: T = exp(-ext_coef * LAD * path)
326
+ transmissivity *= ti.exp(-ext_coef * lad_path)
327
+
328
+ t_prev = t_next
329
+
330
+ # Step to next voxel
331
+ if t_max_x < t_max_y and t_max_x < t_max_z:
332
+ t = t_max_x
333
+ ix += step_x
334
+ t_max_x += t_delta_x
335
+ elif t_max_y < t_max_z:
336
+ t = t_max_y
337
+ iy += step_y
338
+ t_max_y += t_delta_y
339
+ else:
340
+ t = t_max_z
341
+ iz += step_z
342
+ t_max_z += t_delta_z
343
+
344
+ return transmissivity, total_lad_path
345
+
346
+
347
+ @ti.func
348
+ def ray_point_to_point_transmissivity(
349
+ pos_from: Vector3,
350
+ pos_to: Vector3,
351
+ lad: ti.template(),
352
+ is_solid: ti.template(),
353
+ nx: ti.i32,
354
+ ny: ti.i32,
355
+ nz: ti.i32,
356
+ dx: ti.f32,
357
+ dy: ti.f32,
358
+ dz: ti.f32,
359
+ ext_coef: ti.f32
360
+ ):
361
+ """
362
+ Compute transmissivity of radiation between two points through canopy.
363
+
364
+ This is used for surface-to-surface reflections where reflected radiation
365
+ must pass through any intervening vegetation.
366
+
367
+ Args:
368
+ pos_from: Start position (emitting surface center)
369
+ pos_to: End position (receiving surface center)
370
+ lad: 3D field of Leaf Area Density
371
+ is_solid: 3D field of solid cells (buildings/terrain)
372
+ nx, ny, nz: Grid dimensions
373
+ dx, dy, dz: Cell sizes
374
+ ext_coef: Extinction coefficient
375
+
376
+ Returns:
377
+ Tuple of (transmissivity, blocked_by_solid)
378
+ - transmissivity: 0-1 fraction of radiation that gets through
379
+ - blocked_by_solid: 1 if ray hits a solid cell, 0 otherwise
380
+ """
381
+ # Compute ray direction and distance
382
+ diff = pos_to - pos_from
383
+ dist = diff.norm()
384
+
385
+ transmissivity = 1.0
386
+ blocked = 0
387
+
388
+ # Only trace if distance is significant
389
+ if dist >= 0.01:
390
+ ray_dir = diff / dist
391
+
392
+ # Starting voxel
393
+ pos = pos_from + ray_dir * 0.01 # Slight offset to avoid self-intersection
394
+
395
+ ix = ti.cast(ti.floor(pos[0] / dx), ti.i32)
396
+ iy = ti.cast(ti.floor(pos[1] / dy), ti.i32)
397
+ iz = ti.cast(ti.floor(pos[2] / dz), ti.i32)
398
+
399
+ # Clamp to valid range
400
+ ix = ti.max(0, ti.min(nx - 1, ix))
401
+ iy = ti.max(0, ti.min(ny - 1, iy))
402
+ iz = ti.max(0, ti.min(nz - 1, iz))
403
+
404
+ # Step directions
405
+ step_x = 1 if ray_dir[0] >= 0 else -1
406
+ step_y = 1 if ray_dir[1] >= 0 else -1
407
+ step_z = 1 if ray_dir[2] >= 0 else -1
408
+
409
+ # Initialize DDA variables
410
+ t_max_x = 1e30
411
+ t_max_y = 1e30
412
+ t_max_z = 1e30
413
+ t_delta_x = 1e30
414
+ t_delta_y = 1e30
415
+ t_delta_z = 1e30
416
+
417
+ t = 0.01 # Start offset
418
+
419
+ if ti.abs(ray_dir[0]) > 1e-10:
420
+ if step_x > 0:
421
+ t_max_x = ((ix + 1) * dx - pos_from[0]) / ray_dir[0]
422
+ else:
423
+ t_max_x = (ix * dx - pos_from[0]) / ray_dir[0]
424
+ t_delta_x = ti.abs(dx / ray_dir[0])
425
+
426
+ if ti.abs(ray_dir[1]) > 1e-10:
427
+ if step_y > 0:
428
+ t_max_y = ((iy + 1) * dy - pos_from[1]) / ray_dir[1]
429
+ else:
430
+ t_max_y = (iy * dy - pos_from[1]) / ray_dir[1]
431
+ t_delta_y = ti.abs(dy / ray_dir[1])
432
+
433
+ if ti.abs(ray_dir[2]) > 1e-10:
434
+ if step_z > 0:
435
+ t_max_z = ((iz + 1) * dz - pos_from[2]) / ray_dir[2]
436
+ else:
437
+ t_max_z = (iz * dz - pos_from[2]) / ray_dir[2]
438
+ t_delta_z = ti.abs(dz / ray_dir[2])
439
+
440
+ t_prev = t
441
+ max_steps = nx + ny + nz
442
+ done = 0
443
+
444
+ for _ in range(max_steps):
445
+ if done == 1:
446
+ continue # Skip remaining iterations
447
+
448
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny or iz < 0 or iz >= nz:
449
+ done = 1
450
+ continue
451
+ if t > dist: # Reached target
452
+ done = 1
453
+ continue
454
+
455
+ # Check for solid obstruction (but skip first and last cell as they're the surfaces)
456
+ if is_solid[ix, iy, iz] == 1 and t > 0.1 and t < dist - 0.1:
457
+ blocked = 1
458
+ transmissivity = 0.0
459
+ done = 1
460
+ continue
461
+
462
+ # Get step distance
463
+ t_next = t_max_x
464
+ if t_max_y < t_next:
465
+ t_next = t_max_y
466
+ if t_max_z < t_next:
467
+ t_next = t_max_z
468
+
469
+ # Limit to target distance
470
+ t_next = ti.min(t_next, dist)
471
+
472
+ # Path length through this cell
473
+ path_len = t_next - t_prev
474
+
475
+ # Accumulate absorption from LAD
476
+ cell_lad = lad[ix, iy, iz]
477
+ if cell_lad > 0.0:
478
+ # Beer-Lambert: T = exp(-ext_coef * LAD * path)
479
+ transmissivity *= ti.exp(-ext_coef * cell_lad * path_len)
480
+
481
+ t_prev = t_next
482
+
483
+ # Step to next voxel
484
+ if t_max_x < t_max_y and t_max_x < t_max_z:
485
+ t = t_max_x
486
+ ix += step_x
487
+ t_max_x += t_delta_x
488
+ elif t_max_y < t_max_z:
489
+ t = t_max_y
490
+ iy += step_y
491
+ t_max_y += t_delta_y
492
+ else:
493
+ t = t_max_z
494
+ iz += step_z
495
+ t_max_z += t_delta_z
496
+
497
+ return transmissivity, blocked
498
+
499
+
500
+ @ti.data_oriented
501
+ class RayTracer:
502
+ """
503
+ GPU-accelerated ray tracer for radiation calculations.
504
+
505
+ Traces rays through the voxel domain to compute:
506
+ - Shadow factors (direct sunlight blocking)
507
+ - Sky view factors (visible sky fraction)
508
+ - Canopy sink factors (absorption by vegetation)
509
+ """
510
+
511
+ def __init__(self, domain):
512
+ """
513
+ Initialize ray tracer with domain.
514
+
515
+ Args:
516
+ domain: Domain object with grid geometry
517
+ """
518
+ self.domain = domain
519
+ self.nx = domain.nx
520
+ self.ny = domain.ny
521
+ self.nz = domain.nz
522
+ self.dx = domain.dx
523
+ self.dy = domain.dy
524
+ self.dz = domain.dz
525
+
526
+ # Maximum ray distance (diagonal of domain)
527
+ self.max_dist = math.sqrt(
528
+ (self.nx * self.dx)**2 +
529
+ (self.ny * self.dy)**2 +
530
+ (self.nz * self.dz)**2
531
+ )
532
+
533
+ self.ext_coef = EXT_COEF
534
+
535
+ @ti.kernel
536
+ def compute_direct_shadows(
537
+ self,
538
+ surf_pos: ti.template(),
539
+ surf_dir: ti.template(),
540
+ sun_dir: ti.types.vector(3, ti.f32),
541
+ is_solid: ti.template(),
542
+ n_surf: ti.i32,
543
+ shadow_factor: ti.template()
544
+ ):
545
+ """
546
+ Compute shadow factors for all surfaces.
547
+
548
+ shadow_factor = 0 means fully sunlit
549
+ shadow_factor = 1 means fully shaded
550
+ """
551
+ for i in range(n_surf):
552
+ # Get surface position
553
+ pos = surf_pos[i]
554
+ direction = surf_dir[i]
555
+
556
+ # Check if surface faces sun
557
+ # For upward (0), downward (1), north (2), south (3), east (4), west (5)
558
+ face_sun = 1
559
+ if direction == 0: # Up
560
+ face_sun = 1 if sun_dir[2] > 0 else 0
561
+ elif direction == 1: # Down
562
+ face_sun = 1 if sun_dir[2] < 0 else 0
563
+ elif direction == 2: # North
564
+ face_sun = 1 if sun_dir[1] > 0 else 0
565
+ elif direction == 3: # South
566
+ face_sun = 1 if sun_dir[1] < 0 else 0
567
+ elif direction == 4: # East
568
+ face_sun = 1 if sun_dir[0] > 0 else 0
569
+ elif direction == 5: # West
570
+ face_sun = 1 if sun_dir[0] < 0 else 0
571
+
572
+ if face_sun == 0:
573
+ shadow_factor[i] = 1.0
574
+ else:
575
+ # Trace ray toward sun
576
+ ray_origin = Vector3(pos[0], pos[1], pos[2])
577
+
578
+ hit, _, _, _, _ = ray_voxel_first_hit(
579
+ ray_origin, sun_dir,
580
+ is_solid,
581
+ self.nx, self.ny, self.nz,
582
+ self.dx, self.dy, self.dz,
583
+ self.max_dist
584
+ )
585
+
586
+ shadow_factor[i] = ti.cast(hit, ti.f32)
587
+
588
+ @ti.kernel
589
+ def compute_direct_with_canopy(
590
+ self,
591
+ surf_pos: ti.template(),
592
+ surf_dir: ti.template(),
593
+ sun_dir: ti.types.vector(3, ti.f32),
594
+ is_solid: ti.template(),
595
+ lad: ti.template(),
596
+ n_surf: ti.i32,
597
+ shadow_factor: ti.template(),
598
+ canopy_transmissivity: ti.template()
599
+ ):
600
+ """
601
+ Compute shadow factors including canopy absorption.
602
+ """
603
+ for i in range(n_surf):
604
+ pos = surf_pos[i]
605
+ direction = surf_dir[i]
606
+
607
+ # Check if surface faces sun
608
+ face_sun = 1
609
+ if direction == 0:
610
+ face_sun = 1 if sun_dir[2] > 0 else 0
611
+ elif direction == 1:
612
+ face_sun = 1 if sun_dir[2] < 0 else 0
613
+ elif direction == 2:
614
+ face_sun = 1 if sun_dir[1] > 0 else 0
615
+ elif direction == 3:
616
+ face_sun = 1 if sun_dir[1] < 0 else 0
617
+ elif direction == 4:
618
+ face_sun = 1 if sun_dir[0] > 0 else 0
619
+ elif direction == 5:
620
+ face_sun = 1 if sun_dir[0] < 0 else 0
621
+
622
+ if face_sun == 0:
623
+ shadow_factor[i] = 1.0
624
+ canopy_transmissivity[i] = 0.0
625
+ else:
626
+ ray_origin = Vector3(pos[0], pos[1], pos[2])
627
+
628
+ trans, _ = ray_canopy_absorption(
629
+ ray_origin, sun_dir,
630
+ lad, is_solid,
631
+ self.nx, self.ny, self.nz,
632
+ self.dx, self.dy, self.dz,
633
+ self.max_dist,
634
+ self.ext_coef
635
+ )
636
+
637
+ canopy_transmissivity[i] = trans
638
+ shadow_factor[i] = 1.0 - trans
639
+
640
+
641
+ @ti.func
642
+ def sample_hemisphere_direction(i_azim: ti.i32, i_elev: ti.i32, n_azim: ti.i32, n_elev: ti.i32) -> Vector3:
643
+ """
644
+ Generate a direction on the upper hemisphere.
645
+
646
+ Args:
647
+ i_azim: Azimuthal index (0 to n_azim-1)
648
+ i_elev: Elevation index (0 to n_elev-1)
649
+ n_azim: Number of azimuthal divisions
650
+ n_elev: Number of elevation divisions
651
+
652
+ Returns:
653
+ Unit direction vector
654
+ """
655
+ PI = 3.14159265359
656
+
657
+ # Elevation angle (from zenith)
658
+ elev = (i_elev + 0.5) * (PI / 2.0) / n_elev
659
+
660
+ # Azimuth angle
661
+ azim = (i_azim + 0.5) * (2.0 * PI) / n_azim
662
+
663
+ # Convert to Cartesian (z up)
664
+ sin_elev = ti.sin(elev)
665
+ cos_elev = ti.cos(elev)
666
+
667
+ x = sin_elev * ti.sin(azim)
668
+ y = sin_elev * ti.cos(azim)
669
+ z = cos_elev
670
+
671
+ return Vector3(x, y, z)
672
+
673
+
674
+ @ti.func
675
+ def hemisphere_solid_angle(i_elev: ti.i32, n_azim: ti.i32, n_elev: ti.i32) -> ti.f32:
676
+ """
677
+ Calculate solid angle for a hemisphere segment.
678
+ """
679
+ PI = 3.14159265359
680
+
681
+ elev_low = i_elev * (PI / 2.0) / n_elev
682
+ elev_high = (i_elev + 1) * (PI / 2.0) / n_elev
683
+
684
+ d_omega = (2.0 * PI / n_azim) * (ti.cos(elev_low) - ti.cos(elev_high))
685
+
686
+ return d_omega