Skip to main content

alice_physics/
sim_field.rs

1//! 3D Simulation Field Infrastructure
2//!
3//! Uniform grid-based scalar and vector fields for physics-driven
4//! SDF modifiers. Supports trilinear interpolation, diffusion (heat
5//! equation), decay, and point-splatting.
6//!
7//! # Design
8//!
9//! Fields store f32 values on a regular grid and convert between
10//! world-space (f32) and grid indices. All modifiers (thermal,
11//! pressure, erosion, etc.) build on these fields.
12//!
13//! Author: Moroya Sakamoto
14
15#[cfg(not(feature = "std"))]
16use alloc::vec::Vec;
17
18// ============================================================================
19// ScalarField3D
20// ============================================================================
21
22/// 3D uniform scalar field with trilinear interpolation
23#[derive(Clone, Debug)]
24pub struct ScalarField3D {
25    /// Grid data in row-major order: data[iz * ny * nx + iy * nx + ix]
26    pub data: Vec<f32>,
27    /// Grid resolution along X axis
28    pub nx: usize,
29    /// Grid resolution along Y axis
30    pub ny: usize,
31    /// Grid resolution along Z axis
32    pub nz: usize,
33    /// World-space bounds (min corner)
34    pub min: (f32, f32, f32),
35    /// World-space bounds (max corner)
36    pub max: (f32, f32, f32),
37    /// Precomputed cell size
38    cell_size: (f32, f32, f32),
39    /// Precomputed inverse cell size
40    inv_cell_size: (f32, f32, f32),
41    /// Reusable scratch buffer for `diffuse()` (avoids per-call allocation)
42    scratch: Vec<f32>,
43}
44
45impl ScalarField3D {
46    /// Create a zero-filled field with given resolution and bounds
47    #[must_use]
48    pub fn new(
49        nx: usize,
50        ny: usize,
51        nz: usize,
52        min: (f32, f32, f32),
53        max: (f32, f32, f32),
54    ) -> Self {
55        let cx = if nx > 1 {
56            (max.0 - min.0) / (nx - 1) as f32
57        } else {
58            1.0
59        };
60        let cy = if ny > 1 {
61            (max.1 - min.1) / (ny - 1) as f32
62        } else {
63            1.0
64        };
65        let cz = if nz > 1 {
66            (max.2 - min.2) / (nz - 1) as f32
67        } else {
68            1.0
69        };
70
71        let n = nx * ny * nz;
72        Self {
73            data: vec![0.0; n],
74            nx,
75            ny,
76            nz,
77            min,
78            max,
79            cell_size: (cx, cy, cz),
80            inv_cell_size: (1.0 / cx, 1.0 / cy, 1.0 / cz),
81            scratch: vec![0.0; n],
82        }
83    }
84
85    /// Create a field filled with a constant value
86    #[must_use]
87    pub fn new_filled(
88        nx: usize,
89        ny: usize,
90        nz: usize,
91        min: (f32, f32, f32),
92        max: (f32, f32, f32),
93        value: f32,
94    ) -> Self {
95        let mut field = Self::new(nx, ny, nz, min, max);
96        field.data.fill(value);
97        field
98    }
99
100    /// Total number of cells
101    #[inline]
102    #[must_use]
103    pub const fn cell_count(&self) -> usize {
104        self.nx * self.ny * self.nz
105    }
106
107    /// Linear index from grid coordinates
108    #[inline]
109    #[must_use]
110    pub const fn index(&self, ix: usize, iy: usize, iz: usize) -> usize {
111        iz * self.ny * self.nx + iy * self.nx + ix
112    }
113
114    /// Get value at grid coordinates (clamped)
115    #[inline]
116    #[must_use]
117    pub fn get(&self, ix: usize, iy: usize, iz: usize) -> f32 {
118        let ix = ix.min(self.nx - 1);
119        let iy = iy.min(self.ny - 1);
120        let iz = iz.min(self.nz - 1);
121        self.data[self.index(ix, iy, iz)]
122    }
123
124    /// Set value at grid coordinates
125    #[inline]
126    pub fn set(&mut self, ix: usize, iy: usize, iz: usize, value: f32) {
127        if ix < self.nx && iy < self.ny && iz < self.nz {
128            let idx = self.index(ix, iy, iz);
129            self.data[idx] = value;
130        }
131    }
132
133    /// Add value at grid coordinates
134    #[inline]
135    pub fn add(&mut self, ix: usize, iy: usize, iz: usize, delta: f32) {
136        if ix < self.nx && iy < self.ny && iz < self.nz {
137            let idx = self.index(ix, iy, iz);
138            self.data[idx] += delta;
139        }
140    }
141
142    /// World-space to fractional grid coordinates
143    #[inline]
144    fn world_to_grid(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
145        let gx = (x - self.min.0) * self.inv_cell_size.0;
146        let gy = (y - self.min.1) * self.inv_cell_size.1;
147        let gz = (z - self.min.2) * self.inv_cell_size.2;
148        (gx, gy, gz)
149    }
150
151    /// Trilinear interpolation at world-space position
152    #[inline]
153    #[must_use]
154    pub fn sample(&self, x: f32, y: f32, z: f32) -> f32 {
155        let (gx, gy, gz) = self.world_to_grid(x, y, z);
156
157        // Clamp to valid grid range
158        let max_x = (self.nx - 1) as f32;
159        let max_y = (self.ny - 1) as f32;
160        let max_z = (self.nz - 1) as f32;
161        let gx = crate::det_math::min(crate::det_math::max(gx, 0.0), max_x);
162        let gy = crate::det_math::min(crate::det_math::max(gy, 0.0), max_y);
163        let gz = crate::det_math::min(crate::det_math::max(gz, 0.0), max_z);
164
165        let ix0 = gx as usize;
166        let iy0 = gy as usize;
167        let iz0 = gz as usize;
168        let ix1 = (ix0 + 1).min(self.nx - 1);
169        let iy1 = (iy0 + 1).min(self.ny - 1);
170        let iz1 = (iz0 + 1).min(self.nz - 1);
171
172        let fx = gx - ix0 as f32;
173        let fy = gy - iy0 as f32;
174        let fz = gz - iz0 as f32;
175
176        // Direct data access (indices already clamped, skip redundant bounds check in get())
177        let nx = self.nx;
178        let ny_nx = self.ny * nx;
179        let d = &self.data;
180        let i000 = iz0 * ny_nx + iy0 * nx + ix0;
181        let i100 = iz0 * ny_nx + iy0 * nx + ix1;
182        let i010 = iz0 * ny_nx + iy1 * nx + ix0;
183        let i110 = iz0 * ny_nx + iy1 * nx + ix1;
184        let i001 = iz1 * ny_nx + iy0 * nx + ix0;
185        let i101 = iz1 * ny_nx + iy0 * nx + ix1;
186        let i011 = iz1 * ny_nx + iy1 * nx + ix0;
187        let i111 = iz1 * ny_nx + iy1 * nx + ix1;
188
189        let c00 = (d[i100] - d[i000]).mul_add(fx, d[i000]);
190        let c10 = (d[i110] - d[i010]).mul_add(fx, d[i010]);
191        let c01 = (d[i101] - d[i001]).mul_add(fx, d[i001]);
192        let c11 = (d[i111] - d[i011]).mul_add(fx, d[i011]);
193
194        let c0 = (c10 - c00).mul_add(fy, c00);
195        let c1 = (c11 - c01).mul_add(fy, c01);
196
197        (c1 - c0).mul_add(fz, c0)
198    }
199
200    /// Central-difference gradient at world-space position
201    #[inline]
202    #[must_use]
203    pub fn gradient(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
204        let eps_x = self.cell_size.0 * 0.5;
205        let eps_y = self.cell_size.1 * 0.5;
206        let eps_z = self.cell_size.2 * 0.5;
207
208        // 2*eps = cell_size, so 1/(2*eps) = inv_cell_size (precomputed, no division)
209        let inv_2ex = self.inv_cell_size.0;
210        let inv_2ey = self.inv_cell_size.1;
211        let inv_2ez = self.inv_cell_size.2;
212
213        let dx = (self.sample(x + eps_x, y, z) - self.sample(x - eps_x, y, z)) * inv_2ex;
214        let dy = (self.sample(x, y + eps_y, z) - self.sample(x, y - eps_y, z)) * inv_2ey;
215        let dz = (self.sample(x, y, z + eps_z) - self.sample(x, y, z - eps_z)) * inv_2ez;
216
217        (dx, dy, dz)
218    }
219
220    /// Splat value at world-space position with radius (additive)
221    pub fn splat(&mut self, x: f32, y: f32, z: f32, value: f32, radius: f32) {
222        let (gx, gy, gz) = self.world_to_grid(x, y, z);
223        // per-axis index reach: cell sizes may differ between axes
224        let gr_x = radius * self.inv_cell_size.0;
225        let gr_y = radius * self.inv_cell_size.1;
226        let gr_z = radius * self.inv_cell_size.2;
227
228        let ix_min = ((gx - gr_x).floor() as i32).max(0) as usize;
229        let ix_max = ((gx + gr_x).ceil() as i32).min(self.nx as i32 - 1) as usize;
230        let iy_min = ((gy - gr_y).floor() as i32).max(0) as usize;
231        let iy_max = ((gy + gr_y).ceil() as i32).min(self.ny as i32 - 1) as usize;
232        let iz_min = ((gz - gr_z).floor() as i32).max(0) as usize;
233        let iz_max = ((gz + gr_z).ceil() as i32).min(self.nz as i32 - 1) as usize;
234
235        let inv_r = if radius > 1e-10 { 1.0 / radius } else { 1.0 };
236        let radius_sq = radius * radius;
237
238        for iz in iz_min..=iz_max {
239            for iy in iy_min..=iy_max {
240                for ix in ix_min..=ix_max {
241                    let wx = (ix as f32).mul_add(self.cell_size.0, self.min.0);
242                    let wy = (iy as f32).mul_add(self.cell_size.1, self.min.1);
243                    let wz = (iz as f32).mul_add(self.cell_size.2, self.min.2);
244
245                    let dx = wx - x;
246                    let dy = wy - y;
247                    let dz = wz - z;
248                    let dist_sq = dz.mul_add(dz, dx.mul_add(dx, dy * dy));
249
250                    // Early reject with squared distance (no sqrt)
251                    if dist_sq < radius_sq {
252                        let dist = dist_sq.sqrt();
253                        // Smooth falloff (cubic)
254                        let t = dist.mul_add(-inv_r, 1.0);
255                        let weight = t * t * 2.0f32.mul_add(-t, 3.0); // smoothstep
256                        let idx = self.index(ix, iy, iz);
257                        self.data[idx] += value * weight;
258                    }
259                }
260            }
261        }
262    }
263
264    /// Diffuse field values (heat equation: dT/dt = k * laplacian(T))
265    ///
266    /// Uses explicit Euler with 7-point stencil. The explicit step stays
267    /// monotone (every stencil weight non-negative) only for
268    /// `n = rate * dt * (2/dx² + 2/dy² + 2/dz²) <= 1`. Within that limit it is
269    /// one step, as before; a step beyond it is split into equal sub-steps of
270    /// `n <= 1/2` each, which keeps the centre weight at least 1/2 so f32
271    /// rounding cannot turn a non-negative field negative. A non-finite or
272    /// non-positive `rate * dt` is ignored.
273    /// Scratch buffer is reused across calls (zero allocation after first call).
274    pub fn diffuse(&mut self, dt: f32, rate: f32) {
275        let (ix, iy, iz) = self.inv_cell_size;
276        let number = rate * dt * 2.0 * (ix * ix + iy * iy + iz * iz);
277        if !number.is_finite() || number <= 0.0 {
278            return;
279        }
280        // at most a million sub-steps: beyond that the field is flat anyway
281        let steps = if number <= 1.0 {
282            1
283        } else {
284            ((number * 2.0).ceil() as u32).clamp(1, 1_000_000)
285        };
286        let sub_dt = dt / steps as f32;
287        for _ in 0..steps {
288            self.diffuse_step(sub_dt, rate);
289        }
290    }
291
292    /// One explicit step of [`Self::diffuse`] (no stability check).
293    fn diffuse_step(&mut self, dt: f32, rate: f32) {
294        let inv_dx2 = self.inv_cell_size.0 * self.inv_cell_size.0;
295        let inv_dy2 = self.inv_cell_size.1 * self.inv_cell_size.1;
296        let inv_dz2 = self.inv_cell_size.2 * self.inv_cell_size.2;
297
298        let nx = self.nx;
299        let ny = self.ny;
300        let nz = self.nz;
301        let ny_nx = ny * nx;
302        let rate_dt = rate * dt;
303
304        for iz in 0..nz {
305            let iz_off = iz * ny_nx;
306            for iy in 0..ny {
307                let iy_off = iz_off + iy * nx;
308                for ix in 0..nx {
309                    let idx = iy_off + ix;
310                    let c = self.data[idx];
311
312                    let xm = if ix > 0 { self.data[idx - 1] } else { c };
313                    let xp = if ix + 1 < nx { self.data[idx + 1] } else { c };
314                    let ym = if iy > 0 { self.data[idx - nx] } else { c };
315                    let yp = if iy + 1 < ny { self.data[idx + nx] } else { c };
316                    let zm = if iz > 0 { self.data[idx - ny_nx] } else { c };
317                    let zp = if iz + 1 < nz {
318                        self.data[idx + ny_nx]
319                    } else {
320                        c
321                    };
322
323                    let laplacian = 2.0f32.mul_add(-c, zm + zp).mul_add(
324                        inv_dz2,
325                        2.0f32
326                            .mul_add(-c, xm + xp)
327                            .mul_add(inv_dx2, 2.0f32.mul_add(-c, ym + yp) * inv_dy2),
328                    );
329
330                    self.scratch[idx] = rate_dt.mul_add(laplacian, c);
331                }
332            }
333        }
334
335        std::mem::swap(&mut self.data, &mut self.scratch);
336    }
337
338    /// Exponential decay toward zero: value *= exp(-rate * dt)
339    pub fn decay(&mut self, rate: f32, dt: f32) {
340        let factor = crate::det_math::exp(-rate * dt);
341        for v in &mut self.data {
342            *v *= factor;
343        }
344    }
345
346    /// Decay toward a target value
347    pub fn decay_toward(&mut self, target: f32, rate: f32, dt: f32) {
348        let factor = crate::det_math::exp(-rate * dt);
349        for v in &mut self.data {
350            *v = (*v - target).mul_add(factor, target);
351        }
352    }
353
354    /// Clamp all values to range
355    pub fn clamp(&mut self, lo: f32, hi: f32) {
356        for v in &mut self.data {
357            if *v < lo {
358                *v = lo;
359            }
360            if *v > hi {
361                *v = hi;
362            }
363        }
364    }
365
366    /// Set all values to zero
367    pub fn clear(&mut self) {
368        self.data.fill(0.0);
369    }
370
371    /// Maximum value in the field
372    pub fn max_value(&self) -> f32 {
373        self.data
374            .iter()
375            .copied()
376            .fold(f32::NEG_INFINITY, crate::det_math::max)
377    }
378
379    /// Check if a world-space point is inside the field bounds
380    #[inline]
381    #[must_use]
382    pub fn contains(&self, x: f32, y: f32, z: f32) -> bool {
383        x >= self.min.0
384            && x <= self.max.0
385            && y >= self.min.1
386            && y <= self.max.1
387            && z >= self.min.2
388            && z <= self.max.2
389    }
390}
391
392// ============================================================================
393// VectorField3D
394// ============================================================================
395
396/// 3D uniform vector field (three scalar fields)
397#[derive(Clone, Debug)]
398pub struct VectorField3D {
399    /// X component
400    pub x: ScalarField3D,
401    /// Y component
402    pub y: ScalarField3D,
403    /// Z component
404    pub z: ScalarField3D,
405}
406
407impl VectorField3D {
408    /// Create a zero vector field
409    #[must_use]
410    pub fn new(
411        nx: usize,
412        ny: usize,
413        nz: usize,
414        min: (f32, f32, f32),
415        max: (f32, f32, f32),
416    ) -> Self {
417        Self {
418            x: ScalarField3D::new(nx, ny, nz, min, max),
419            y: ScalarField3D::new(nx, ny, nz, min, max),
420            z: ScalarField3D::new(nx, ny, nz, min, max),
421        }
422    }
423
424    /// Sample vector at world-space position
425    #[must_use]
426    pub fn sample(&self, wx: f32, wy: f32, wz: f32) -> (f32, f32, f32) {
427        (
428            self.x.sample(wx, wy, wz),
429            self.y.sample(wx, wy, wz),
430            self.z.sample(wx, wy, wz),
431        )
432    }
433
434    /// Splat a vector at world-space position
435    #[allow(clippy::too_many_arguments)]
436    pub fn splat(&mut self, wx: f32, wy: f32, wz: f32, vx: f32, vy: f32, vz: f32, radius: f32) {
437        self.x.splat(wx, wy, wz, vx, radius);
438        self.y.splat(wx, wy, wz, vy, radius);
439        self.z.splat(wx, wy, wz, vz, radius);
440    }
441
442    /// Decay all components
443    pub fn decay(&mut self, rate: f32, dt: f32) {
444        self.x.decay(rate, dt);
445        self.y.decay(rate, dt);
446        self.z.decay(rate, dt);
447    }
448
449    /// Clear all components
450    pub fn clear(&mut self) {
451        self.x.clear();
452        self.y.clear();
453        self.z.clear();
454    }
455}
456
457// ============================================================================
458// Tests
459// ============================================================================
460
461#[cfg(test)]
462#[allow(clippy::float_cmp)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn test_scalar_field_get_set() {
468        let mut field = ScalarField3D::new(4, 4, 4, (0.0, 0.0, 0.0), (3.0, 3.0, 3.0));
469        field.set(1, 2, 3, 42.0);
470        assert_eq!(field.get(1, 2, 3), 42.0);
471        assert_eq!(field.get(0, 0, 0), 0.0);
472    }
473
474    #[test]
475    fn test_trilinear_interpolation() {
476        let mut field = ScalarField3D::new(2, 2, 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
477        // Set corner values
478        field.set(0, 0, 0, 0.0);
479        field.set(1, 0, 0, 10.0);
480        field.set(0, 1, 0, 0.0);
481        field.set(1, 1, 0, 10.0);
482        field.set(0, 0, 1, 0.0);
483        field.set(1, 0, 1, 10.0);
484        field.set(0, 1, 1, 0.0);
485        field.set(1, 1, 1, 10.0);
486
487        // Midpoint along X should be 5.0
488        let mid = field.sample(0.5, 0.0, 0.0);
489        assert!((mid - 5.0).abs() < 0.01, "Expected ~5.0, got {mid}");
490
491        // Corner should be exact
492        let corner = field.sample(1.0, 0.0, 0.0);
493        assert!((corner - 10.0).abs() < 0.01, "Expected ~10.0, got {corner}");
494    }
495
496    #[test]
497    fn test_splat() {
498        let mut field = ScalarField3D::new(8, 8, 8, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0));
499        field.splat(0.0, 0.0, 0.0, 1.0, 0.5);
500
501        // Center should have high value
502        let center = field.sample(0.0, 0.0, 0.0);
503        assert!(
504            center > 0.5,
505            "Center should be high after splat, got {center}"
506        );
507
508        // Far corner should be zero
509        let far = field.sample(1.0, 1.0, 1.0);
510        assert!(far < 0.01, "Far corner should be near zero, got {far}");
511    }
512
513    #[test]
514    fn test_diffusion() {
515        let mut field = ScalarField3D::new(8, 8, 8, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0));
516        // Hot spot at center
517        field.set(4, 4, 4, 100.0);
518
519        let initial_max = field.max_value();
520
521        // Diffuse
522        for _ in 0..10 {
523            field.diffuse(0.01, 1.0);
524        }
525
526        // Max should decrease (heat spreads)
527        let after_max = field.max_value();
528        assert!(after_max < initial_max, "Diffusion should spread heat");
529
530        // Neighbors should have gained heat
531        let neighbor = field.get(3, 4, 4);
532        assert!(neighbor > 0.0, "Neighbor should have received heat");
533    }
534
535    #[test]
536    fn test_gradient() {
537        let mut field = ScalarField3D::new(8, 8, 8, (-1.0, -1.0, -1.0), (1.0, 1.0, 1.0));
538        // Linear ramp along X: value = x
539        for ix in 0..8 {
540            for iy in 0..8 {
541                for iz in 0..8 {
542                    let x = (ix as f32).mul_add(2.0 / 7.0, -1.0);
543                    field.set(ix, iy, iz, x);
544                }
545            }
546        }
547
548        let (gx, gy, gz) = field.gradient(0.0, 0.0, 0.0);
549        assert!(
550            (gx - 1.0).abs() < 0.2,
551            "X gradient should be ~1.0, got {gx}"
552        );
553        assert!(gy.abs() < 0.1, "Y gradient should be ~0, got {gy}");
554        assert!(gz.abs() < 0.1, "Z gradient should be ~0, got {gz}");
555    }
556
557    #[test]
558    fn test_decay() {
559        let mut field = ScalarField3D::new(4, 4, 4, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
560        field.set(1, 1, 1, 100.0);
561
562        field.decay(1.0, 1.0); // e^(-1) ≈ 0.368
563        let v = field.get(1, 1, 1);
564        assert!(
565            (v - 36.8).abs() < 1.0,
566            "After decay, expected ~36.8, got {v}"
567        );
568    }
569}