voxcity 1.0.13__py3-none-any.whl → 1.0.15__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.
@@ -64,3 +64,16 @@ from .integration import ( # noqa: F401
64
64
  save_irradiance_mesh,
65
65
  load_irradiance_mesh,
66
66
  )
67
+
68
+ # Computation mask utilities (re-export from simulator_gpu for convenience)
69
+ try:
70
+ from voxcity.simulator_gpu.solar.mask import ( # noqa: F401
71
+ create_computation_mask,
72
+ draw_computation_mask,
73
+ get_mask_from_drawing,
74
+ visualize_computation_mask,
75
+ get_mask_info,
76
+ )
77
+ except ImportError:
78
+ # simulator_gpu may not be installed
79
+ pass
@@ -1,115 +1,90 @@
1
- """simulator_gpu: GPU-accelerated simulation modules using Taichi.
1
+ """simulator_gpu: GPU-accelerated urban simulation using Taichi.
2
2
 
3
- Compatibility goal:
4
- Allow the common VoxCity pattern to work without code changes beyond the
5
- import alias:
3
+ This package provides GPU-accelerated implementations for:
4
+ - Solar radiation simulation (direct, diffuse, cumulative)
5
+ - View analysis (green view index, sky view factor)
6
+ - Landmark visibility analysis
6
7
 
7
- import simulator_gpu as simulator
8
+ Submodules:
9
+ solar: Solar radiation calculations
10
+ visibility: View and visibility analysis
8
11
 
9
- by flattening a VoxCity-like public namespace (view/visibility/solar/utils).
12
+ Example:
13
+ from voxcity.simulator_gpu import solar, visibility
14
+
15
+ # Solar radiation
16
+ irradiance = solar.get_global_solar_irradiance_using_epw(voxcity, ...)
17
+
18
+ # View analysis
19
+ gvi = visibility.get_view_index(voxcity, mode='green')
10
20
  """
11
21
 
12
22
  import os
13
23
 
14
- # Disable Numba caching to prevent stale cache issues when module paths change.
15
- # This avoids "ModuleNotFoundError: No module named 'simulator_gpu'" errors
16
- # that can occur when Numba tries to load cached functions with old module paths.
17
- os.environ.setdefault("NUMBA_CACHE_DIR", "") # Disable disk caching
18
- os.environ.setdefault("NUMBA_DISABLE_JIT", "0") # Keep JIT enabled for performance
19
-
20
- # Import Taichi initialization utilities first
21
- from .init_taichi import ( # noqa: F401
22
- init_taichi,
23
- ensure_initialized,
24
- is_initialized,
25
- )
26
-
27
- # Check if Taichi is available
28
- try:
29
- import taichi as ti
30
- _TAICHI_AVAILABLE = True
31
- except ImportError:
32
- _TAICHI_AVAILABLE = False
24
+ # Disable Numba caching to prevent stale cache issues
25
+ os.environ.setdefault("NUMBA_CACHE_DIR", "")
26
+ os.environ.setdefault("NUMBA_DISABLE_JIT", "0")
33
27
 
34
- # VoxCity-style flattening
35
- from .view import * # noqa: F401,F403
36
- from .solar import * # noqa: F401,F403
37
- from .utils import * # noqa: F401,F403
28
+ # Taichi initialization
29
+ from .init_taichi import init_taichi, ensure_initialized, is_initialized
38
30
 
39
- # Export submodules for explicit access
40
- from . import solar # noqa: F401
41
- from . import visibility # noqa: F401
42
- from . import view # noqa: F401
43
- from . import utils # noqa: F401
44
- from . import common # noqa: F401
45
-
46
- # VoxCity-flattened module names that some code expects to exist on the toplevel
47
- from . import sky # noqa: F401
48
- from . import kernels # noqa: F401
49
- from . import radiation # noqa: F401
50
- from . import temporal # noqa: F401
51
- from . import integration # noqa: F401
52
-
53
- # Commonly re-exported VoxCity solar helpers
54
- from .kernels import compute_direct_solar_irradiance_map_binary # noqa: F401
55
- from .radiation import compute_solar_irradiance_for_all_faces # noqa: F401
56
-
57
- # Backward compatibility: some code treats `simulator.view` as `simulator.visibility`
58
- # (VoxCity provides `view.py` wrapper; we also provide that module).
59
-
60
- # Export shared modules (kept; extra symbols are fine)
61
- from .core import ( # noqa: F401
31
+ # Core utilities
32
+ from .core import (
62
33
  Vector3, Point3,
63
34
  PI, TWO_PI, DEG_TO_RAD, RAD_TO_DEG,
64
35
  SOLAR_CONSTANT, EXT_COEF,
65
36
  )
66
- from .domain import Domain, IUP, IDOWN, INORTH, ISOUTH, IEAST, IWEST # noqa: F401
67
-
68
37
 
69
- def clear_numba_cache():
70
- """Clear Numba's compiled function cache to resolve stale cache issues.
71
-
72
- Call this function if you encounter errors like:
73
- ModuleNotFoundError: No module named 'simulator_gpu'
74
-
75
- After calling this function, restart your Python kernel/interpreter.
76
- """
77
- import shutil
78
- import glob
79
- from pathlib import Path
80
-
81
- cleared = []
82
-
83
- # Clear .nbc and .nbi files in the package directory
84
- package_dir = Path(__file__).parent
85
- for pattern in ["**/*.nbc", "**/*.nbi"]:
86
- for cache_file in package_dir.glob(pattern):
87
- try:
88
- cache_file.unlink()
89
- cleared.append(str(cache_file))
90
- except Exception:
91
- pass
92
-
93
- # Clear __pycache__ directories
94
- for pycache in package_dir.glob("**/__pycache__"):
95
- try:
96
- shutil.rmtree(pycache)
97
- cleared.append(str(pycache))
98
- except Exception:
99
- pass
100
-
101
- # Try to clear user's .numba_cache if it exists
102
- home = Path.home()
103
- numba_cache = home / ".numba_cache"
104
- if numba_cache.exists():
105
- try:
106
- shutil.rmtree(numba_cache)
107
- cleared.append(str(numba_cache))
108
- except Exception:
109
- pass
110
-
111
- print(f"Cleared {len(cleared)} cache items. Please restart your Python kernel.")
112
- return cleared
38
+ # Domain (shared between solar and visibility)
39
+ from .domain import Domain, Surfaces, extract_surfaces_from_domain
40
+ from .domain import IUP, IDOWN, INORTH, ISOUTH, IEAST, IWEST
41
+
42
+ # Submodules
43
+ from . import solar
44
+ from . import visibility
45
+
46
+ # Convenience imports from solar
47
+ from .solar import (
48
+ get_global_solar_irradiance_using_epw,
49
+ get_building_global_solar_irradiance_using_epw,
50
+ get_direct_solar_irradiance_map,
51
+ get_diffuse_solar_irradiance_map,
52
+ get_global_solar_irradiance_map,
53
+ )
113
54
 
55
+ # Convenience imports from visibility
56
+ from .visibility import (
57
+ get_view_index,
58
+ get_sky_view_factor_map,
59
+ get_surface_view_factor,
60
+ get_landmark_visibility_map,
61
+ get_surface_landmark_visibility,
62
+ )
114
63
 
115
64
  __version__ = "0.1.0"
65
+
66
+ __all__ = [
67
+ # Initialization
68
+ 'init_taichi', 'ensure_initialized', 'is_initialized',
69
+ # Core
70
+ 'Vector3', 'Point3',
71
+ 'PI', 'TWO_PI', 'DEG_TO_RAD', 'RAD_TO_DEG',
72
+ 'SOLAR_CONSTANT', 'EXT_COEF',
73
+ # Domain
74
+ 'Domain', 'Surfaces', 'extract_surfaces_from_domain',
75
+ 'IUP', 'IDOWN', 'INORTH', 'ISOUTH', 'IEAST', 'IWEST',
76
+ # Submodules
77
+ 'solar', 'visibility',
78
+ # Solar (convenience)
79
+ 'get_global_solar_irradiance_using_epw',
80
+ 'get_building_global_solar_irradiance_using_epw',
81
+ 'get_direct_solar_irradiance_map',
82
+ 'get_diffuse_solar_irradiance_map',
83
+ 'get_global_solar_irradiance_map',
84
+ # Visibility (convenience)
85
+ 'get_view_index',
86
+ 'get_sky_view_factor_map',
87
+ 'get_surface_view_factor',
88
+ 'get_landmark_visibility_map',
89
+ 'get_surface_landmark_visibility',
90
+ ]
@@ -1,262 +1,36 @@
1
1
  """
2
2
  Shared domain definition for simulator_gpu.
3
3
 
4
- Represents the 3D computational domain with:
5
- - Grid cells (dx, dy, dz spacing)
6
- - Topography (terrain height)
7
- - Building geometry (3D obstacles)
8
- - Plant canopy (Leaf Area Density - LAD)
9
- - Tree mask for view analysis
4
+ This module re-exports the Domain class from solar.domain for backward compatibility.
5
+ The main implementation is in simulator_gpu.solar.domain which includes:
6
+ - Domain class with full grid, terrain, building, and vegetation support
7
+ - Surfaces class for radiation calculations
8
+ - Surface extraction utilities
10
9
  """
11
10
 
12
- import taichi as ti
13
- import numpy as np
14
- from typing import Tuple, Optional, Union
15
- from .core import Vector3, Point3, EXT_COEF
16
- from .init_taichi import ensure_initialized
11
+ # Re-export from solar.domain (the main implementation)
12
+ from .solar.domain import (
13
+ Domain,
14
+ Surfaces,
15
+ extract_surfaces_from_domain,
16
+ IUP,
17
+ IDOWN,
18
+ INORTH,
19
+ ISOUTH,
20
+ IEAST,
21
+ IWEST,
22
+ DIR_NORMALS,
23
+ )
17
24
 
18
-
19
- # Surface direction indices (matching PALM convention)
20
- IUP = 0 # Upward facing (horizontal roof/ground)
21
- IDOWN = 1 # Downward facing
22
- INORTH = 2 # North facing (positive y)
23
- ISOUTH = 3 # South facing (negative y)
24
- IEAST = 4 # East facing (positive x)
25
- IWEST = 5 # West facing (negative x)
26
-
27
- # Direction normal vectors (x, y, z)
28
- DIR_NORMALS = {
29
- IUP: (0.0, 0.0, 1.0),
30
- IDOWN: (0.0, 0.0, -1.0),
31
- INORTH: (0.0, 1.0, 0.0),
32
- ISOUTH: (0.0, -1.0, 0.0),
33
- IEAST: (1.0, 0.0, 0.0),
34
- IWEST: (-1.0, 0.0, 0.0),
35
- }
36
-
37
-
38
- @ti.data_oriented
39
- class Domain:
40
- """
41
- 3D computational domain for simulation.
42
-
43
- The domain uses a regular grid with:
44
- - x: West to East
45
- - y: South to North
46
- - z: Ground to Sky
47
-
48
- Attributes:
49
- nx, ny, nz: Number of grid cells in each direction
50
- dx, dy, dz: Grid spacing in meters
51
- origin: (x, y, z) coordinates of domain origin
52
- """
53
-
54
- def __init__(
55
- self,
56
- nx: int,
57
- ny: int,
58
- nz: int,
59
- dx: float = 1.0,
60
- dy: float = 1.0,
61
- dz: float = 1.0,
62
- origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
63
- origin_lat: Optional[float] = None,
64
- origin_lon: Optional[float] = None
65
- ):
66
- """
67
- Initialize the domain.
68
-
69
- Args:
70
- nx, ny, nz: Grid dimensions
71
- dx, dy, dz: Grid spacing (m)
72
- origin: Domain origin coordinates
73
- origin_lat: Latitude for solar calculations (degrees)
74
- origin_lon: Longitude for solar calculations (degrees)
75
- """
76
- # Ensure Taichi is initialized before creating any fields
77
- ensure_initialized()
78
-
79
- self.nx = nx
80
- self.ny = ny
81
- self.nz = nz
82
- self.dx = dx
83
- self.dy = dy
84
- self.dz = dz
85
- self.origin = origin
86
- self.origin_lat = origin_lat if origin_lat is not None else 0.0
87
- self.origin_lon = origin_lon if origin_lon is not None else 0.0
88
-
89
- # Domain bounds
90
- self.x_min = origin[0]
91
- self.x_max = origin[0] + nx * dx
92
- self.y_min = origin[1]
93
- self.y_max = origin[1] + ny * dy
94
- self.z_min = origin[2]
95
- self.z_max = origin[2] + nz * dz
96
-
97
- # Grid cell volume
98
- self.cell_volume = dx * dy * dz
99
-
100
- # Topography: terrain height at each (i, j) column
101
- self.topo_top = ti.field(dtype=ti.i32, shape=(nx, ny))
102
-
103
- # Building mask: 1 if cell is solid (building), 0 if air
104
- self.is_solid = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
105
-
106
- # Tree mask: 1 if cell is tree canopy, 0 otherwise
107
- self.is_tree = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
108
-
109
- # Leaf Area Density (m^2/m^3) for plant canopy
110
- self.lad = ti.field(dtype=ti.f32, shape=(nx, ny, nz))
111
-
112
- # Plant canopy top index for each column
113
- self.plant_top = ti.field(dtype=ti.i32, shape=(nx, ny))
114
-
115
- # Surface count
116
- self.n_surfaces = ti.field(dtype=ti.i32, shape=())
117
-
118
- # Initialize arrays
119
- self._init_arrays()
120
-
121
- @ti.kernel
122
- def _init_arrays(self):
123
- """Initialize all arrays to default values."""
124
- for i, j in self.topo_top:
125
- self.topo_top[i, j] = 0
126
- self.plant_top[i, j] = 0
127
-
128
- for i, j, k in self.is_solid:
129
- self.is_solid[i, j, k] = 0
130
- self.is_tree[i, j, k] = 0
131
- self.lad[i, j, k] = 0.0
132
-
133
- def set_flat_terrain(self, height: float = 0.0):
134
- """Set flat terrain at given height."""
135
- k_top = int(height / self.dz)
136
- self._set_flat_terrain_kernel(k_top)
137
-
138
- def initialize_terrain(self, height: float = 0.0):
139
- """Alias for set_flat_terrain."""
140
- self.set_flat_terrain(height)
141
-
142
- @ti.kernel
143
- def _set_flat_terrain_kernel(self, k_top: ti.i32):
144
- for i, j in self.topo_top:
145
- self.topo_top[i, j] = k_top
146
- for k in range(k_top + 1):
147
- self.is_solid[i, j, k] = 1
148
-
149
- def set_terrain_from_array(self, terrain_height: np.ndarray):
150
- """
151
- Set terrain from 2D numpy array of heights.
152
-
153
- Args:
154
- terrain_height: 2D array (nx, ny) of terrain heights in meters
155
- """
156
- terrain_k = (terrain_height / self.dz).astype(np.int32)
157
- self._set_terrain_kernel(terrain_k)
158
-
159
- @ti.kernel
160
- def _set_terrain_kernel(self, terrain_k: ti.types.ndarray()):
161
- for i, j in self.topo_top:
162
- k_top = terrain_k[i, j]
163
- self.topo_top[i, j] = k_top
164
- for k in range(self.nz):
165
- if k <= k_top:
166
- self.is_solid[i, j, k] = 1
167
- else:
168
- self.is_solid[i, j, k] = 0
169
-
170
- def add_building(
171
- self,
172
- x_range: Optional[Tuple[int, int]] = None,
173
- y_range: Optional[Tuple[int, int]] = None,
174
- z_range: Optional[Tuple[int, int]] = None,
175
- *,
176
- x_start: Optional[int] = None,
177
- x_end: Optional[int] = None,
178
- y_start: Optional[int] = None,
179
- y_end: Optional[int] = None,
180
- height: Optional[float] = None
181
- ):
182
- """
183
- Add a rectangular building to the domain.
184
- """
185
- # Handle convenience parameters
186
- if x_start is not None and x_end is not None:
187
- x_range = (x_start, x_end)
188
- if y_start is not None and y_end is not None:
189
- y_range = (y_start, y_end)
190
- if height is not None and z_range is None:
191
- k_top = int(height / self.dz) + 1
192
- z_range = (0, k_top)
193
-
194
- if x_range is None or y_range is None or z_range is None:
195
- raise ValueError("Must provide either range tuples or individual parameters")
196
-
197
- self._add_building_kernel(x_range[0], x_range[1], y_range[0], y_range[1], z_range[0], z_range[1])
198
-
199
- @ti.kernel
200
- def _add_building_kernel(self, i_min: ti.i32, i_max: ti.i32, j_min: ti.i32, j_max: ti.i32, k_min: ti.i32, k_max: ti.i32):
201
- for i, j, k in ti.ndrange((i_min, i_max), (j_min, j_max), (k_min, k_max)):
202
- self.is_solid[i, j, k] = 1
203
-
204
- def add_tree(
205
- self,
206
- x_range: Tuple[int, int],
207
- y_range: Tuple[int, int],
208
- z_range: Tuple[int, int],
209
- lad_value: float = 1.0
210
- ):
211
- """
212
- Add a tree canopy region to the domain.
213
-
214
- Args:
215
- x_range, y_range, z_range: Grid index ranges
216
- lad_value: Leaf Area Density value
217
- """
218
- self._add_tree_kernel(x_range[0], x_range[1], y_range[0], y_range[1], z_range[0], z_range[1], lad_value)
219
-
220
- @ti.kernel
221
- def _add_tree_kernel(self, i_min: ti.i32, i_max: ti.i32, j_min: ti.i32, j_max: ti.i32, k_min: ti.i32, k_max: ti.i32, lad: ti.f32):
222
- for i, j, k in ti.ndrange((i_min, i_max), (j_min, j_max), (k_min, k_max)):
223
- self.is_tree[i, j, k] = 1
224
- self.lad[i, j, k] = lad
225
-
226
- def set_from_voxel_data(self, voxel_data: np.ndarray, tree_code: int = -2, solid_codes: Optional[list] = None):
227
- """
228
- Set domain from a 3D voxel data array.
229
-
230
- Args:
231
- voxel_data: 3D numpy array with voxel class codes
232
- tree_code: Class code for trees (default -2)
233
- solid_codes: List of codes that are solid (default: all non-zero except tree_code)
234
- """
235
- if solid_codes is None:
236
- # All non-zero codes except tree are solid
237
- solid_codes = []
238
-
239
- self._set_from_voxel_data_kernel(voxel_data, tree_code)
240
-
241
- @ti.kernel
242
- def _set_from_voxel_data_kernel(self, voxel_data: ti.types.ndarray(), tree_code: ti.i32):
243
- for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
244
- val = voxel_data[i, j, k]
245
- if val == tree_code:
246
- self.is_tree[i, j, k] = 1
247
- self.is_solid[i, j, k] = 0
248
- elif val != 0:
249
- self.is_solid[i, j, k] = 1
250
- self.is_tree[i, j, k] = 0
251
- else:
252
- self.is_solid[i, j, k] = 0
253
- self.is_tree[i, j, k] = 0
254
-
255
- def get_max_dist(self) -> float:
256
- """Get maximum ray distance (domain diagonal)."""
257
- import math
258
- return math.sqrt(
259
- (self.nx * self.dx)**2 +
260
- (self.ny * self.dy)**2 +
261
- (self.nz * self.dz)**2
262
- )
25
+ __all__ = [
26
+ 'Domain',
27
+ 'Surfaces',
28
+ 'extract_surfaces_from_domain',
29
+ 'IUP',
30
+ 'IDOWN',
31
+ 'INORTH',
32
+ 'ISOUTH',
33
+ 'IEAST',
34
+ 'IWEST',
35
+ 'DIR_NORMALS',
36
+ ]
@@ -466,6 +466,159 @@ def ray_canopy_absorption(
466
466
  return transmissivity, total_lad_path
467
467
 
468
468
 
469
+ @ti.func
470
+ def ray_point_to_point_transmissivity(
471
+ pos_from: Vector3,
472
+ pos_to: Vector3,
473
+ lad: ti.template(),
474
+ is_solid: ti.template(),
475
+ nx: ti.i32,
476
+ ny: ti.i32,
477
+ nz: ti.i32,
478
+ dx: ti.f32,
479
+ dy: ti.f32,
480
+ dz: ti.f32,
481
+ ext_coef: ti.f32
482
+ ):
483
+ """
484
+ Compute transmissivity of radiation between two points through canopy.
485
+
486
+ This is used for surface-to-surface reflections where reflected radiation
487
+ must pass through any intervening vegetation.
488
+
489
+ Args:
490
+ pos_from: Start position (emitting surface center)
491
+ pos_to: End position (receiving surface center)
492
+ lad: 3D field of Leaf Area Density
493
+ is_solid: 3D field of solid cells (buildings/terrain)
494
+ nx, ny, nz: Grid dimensions
495
+ dx, dy, dz: Cell sizes
496
+ ext_coef: Extinction coefficient
497
+
498
+ Returns:
499
+ Tuple of (transmissivity, blocked_by_solid)
500
+ - transmissivity: 0-1 fraction of radiation that gets through
501
+ - blocked_by_solid: 1 if ray hits a solid cell, 0 otherwise
502
+ """
503
+ # Compute ray direction and distance
504
+ diff = pos_to - pos_from
505
+ dist = diff.norm()
506
+
507
+ transmissivity = 1.0
508
+ blocked = 0
509
+
510
+ # Only trace if distance is significant
511
+ if dist >= 0.01:
512
+ ray_dir = diff / dist
513
+
514
+ # Starting voxel
515
+ pos = pos_from + ray_dir * 0.01 # Slight offset to avoid self-intersection
516
+
517
+ ix = ti.cast(ti.floor(pos[0] / dx), ti.i32)
518
+ iy = ti.cast(ti.floor(pos[1] / dy), ti.i32)
519
+ iz = ti.cast(ti.floor(pos[2] / dz), ti.i32)
520
+
521
+ # Clamp to valid range
522
+ ix = ti.max(0, ti.min(nx - 1, ix))
523
+ iy = ti.max(0, ti.min(ny - 1, iy))
524
+ iz = ti.max(0, ti.min(nz - 1, iz))
525
+
526
+ # Step directions
527
+ step_x = 1 if ray_dir[0] >= 0 else -1
528
+ step_y = 1 if ray_dir[1] >= 0 else -1
529
+ step_z = 1 if ray_dir[2] >= 0 else -1
530
+
531
+ # Initialize DDA variables
532
+ t_max_x = 1e30
533
+ t_max_y = 1e30
534
+ t_max_z = 1e30
535
+ t_delta_x = 1e30
536
+ t_delta_y = 1e30
537
+ t_delta_z = 1e30
538
+
539
+ t = 0.01 # Start offset
540
+
541
+ if ti.abs(ray_dir[0]) > 1e-10:
542
+ if step_x > 0:
543
+ t_max_x = ((ix + 1) * dx - pos_from[0]) / ray_dir[0]
544
+ else:
545
+ t_max_x = (ix * dx - pos_from[0]) / ray_dir[0]
546
+ t_delta_x = ti.abs(dx / ray_dir[0])
547
+
548
+ if ti.abs(ray_dir[1]) > 1e-10:
549
+ if step_y > 0:
550
+ t_max_y = ((iy + 1) * dy - pos_from[1]) / ray_dir[1]
551
+ else:
552
+ t_max_y = (iy * dy - pos_from[1]) / ray_dir[1]
553
+ t_delta_y = ti.abs(dy / ray_dir[1])
554
+
555
+ if ti.abs(ray_dir[2]) > 1e-10:
556
+ if step_z > 0:
557
+ t_max_z = ((iz + 1) * dz - pos_from[2]) / ray_dir[2]
558
+ else:
559
+ t_max_z = (iz * dz - pos_from[2]) / ray_dir[2]
560
+ t_delta_z = ti.abs(dz / ray_dir[2])
561
+
562
+ t_prev = t
563
+ max_steps = nx + ny + nz
564
+ done = 0
565
+
566
+ for _ in range(max_steps):
567
+ if done == 1:
568
+ continue # Skip remaining iterations
569
+
570
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny or iz < 0 or iz >= nz:
571
+ done = 1
572
+ continue
573
+ if t > dist: # Reached target
574
+ done = 1
575
+ continue
576
+
577
+ # Check for solid obstruction (but skip first and last cell as they're the surfaces)
578
+ if is_solid[ix, iy, iz] == 1 and t > 0.1 and t < dist - 0.1:
579
+ blocked = 1
580
+ transmissivity = 0.0
581
+ done = 1
582
+ continue
583
+
584
+ # Get step distance
585
+ t_next = t_max_x
586
+ if t_max_y < t_next:
587
+ t_next = t_max_y
588
+ if t_max_z < t_next:
589
+ t_next = t_max_z
590
+
591
+ # Limit to target distance
592
+ t_next = ti.min(t_next, dist)
593
+
594
+ # Path length through this cell
595
+ path_len = t_next - t_prev
596
+
597
+ # Accumulate absorption from LAD
598
+ cell_lad = lad[ix, iy, iz]
599
+ if cell_lad > 0.0:
600
+ # Beer-Lambert: T = exp(-ext_coef * LAD * path)
601
+ transmissivity *= ti.exp(-ext_coef * cell_lad * path_len)
602
+
603
+ t_prev = t_next
604
+
605
+ # Step to next voxel
606
+ if t_max_x < t_max_y and t_max_x < t_max_z:
607
+ t = t_max_x
608
+ ix += step_x
609
+ t_max_x += t_delta_x
610
+ elif t_max_y < t_max_z:
611
+ t = t_max_y
612
+ iy += step_y
613
+ t_max_y += t_delta_y
614
+ else:
615
+ t = t_max_z
616
+ iz += step_z
617
+ t_max_z += t_delta_z
618
+
619
+ return transmissivity, blocked
620
+
621
+
469
622
  @ti.func
470
623
  def ray_trace_to_target(
471
624
  origin: Vector3,