Skip to main content

alice_physics/
heatmap.rs

1//! Stress and Temperature Heatmap Visualization
2//!
3//! Generates 2D heatmap data from 3D physics state by sampling along axis-aligned
4//! slices. Supports stress visualization from contact forces and temperature fields.
5//!
6//! The output `Heatmap` contains raw `Fix128` values plus min/max range, and can
7//! be converted to RGBA pixels using a viridis-like colormap.
8//!
9//! All sampling uses deterministic 128-bit fixed-point arithmetic.
10//! RGBA conversion uses `f64` intermediates for color interpolation only.
11
12use crate::math::{Fix128, Vec3Fix};
13
14#[cfg(not(feature = "std"))]
15use alloc::vec;
16#[cfg(not(feature = "std"))]
17use alloc::vec::Vec;
18
19// ============================================================================
20// Heatmap Types
21// ============================================================================
22
23/// Axis along which to take a 2D slice of the 3D domain.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum SliceAxis {
26    /// Slice perpendicular to the X axis (YZ plane)
27    X,
28    /// Slice perpendicular to the Y axis (XZ plane)
29    Y,
30    /// Slice perpendicular to the Z axis (XY plane)
31    Z,
32}
33
34/// Configuration for heatmap generation.
35#[derive(Clone, Debug)]
36pub struct HeatmapConfig {
37    /// Horizontal resolution (pixels/cells)
38    pub resolution_x: usize,
39    /// Vertical resolution (pixels/cells)
40    pub resolution_y: usize,
41    /// Axis perpendicular to the slice plane
42    pub slice_axis: SliceAxis,
43    /// Position along the slice axis
44    pub slice_offset: Fix128,
45    /// Minimum corner of the sampling region
46    pub bounds_min: Vec3Fix,
47    /// Maximum corner of the sampling region
48    pub bounds_max: Vec3Fix,
49}
50
51/// A 2D heatmap of scalar values.
52#[derive(Clone, Debug)]
53pub struct Heatmap {
54    /// Row-major scalar data (width * height elements)
55    pub data: Vec<Fix128>,
56    /// Width (horizontal resolution)
57    pub width: usize,
58    /// Height (vertical resolution)
59    pub height: usize,
60    /// Minimum value in the data
61    pub min_value: Fix128,
62    /// Maximum value in the data
63    pub max_value: Fix128,
64}
65
66// ============================================================================
67// Heatmap Generation
68// ============================================================================
69
70/// Build a 3D sample point from 2D grid coordinates and the slice configuration.
71fn sample_point(config: &HeatmapConfig, u_frac: Fix128, v_frac: Fix128) -> Vec3Fix {
72    let bmin = config.bounds_min;
73    let bmax = config.bounds_max;
74
75    match config.slice_axis {
76        SliceAxis::X => Vec3Fix::new(
77            config.slice_offset,
78            bmin.y + (bmax.y - bmin.y) * u_frac,
79            bmin.z + (bmax.z - bmin.z) * v_frac,
80        ),
81        SliceAxis::Y => Vec3Fix::new(
82            bmin.x + (bmax.x - bmin.x) * u_frac,
83            config.slice_offset,
84            bmin.z + (bmax.z - bmin.z) * v_frac,
85        ),
86        SliceAxis::Z => Vec3Fix::new(
87            bmin.x + (bmax.x - bmin.x) * u_frac,
88            bmin.y + (bmax.y - bmin.y) * v_frac,
89            config.slice_offset,
90        ),
91    }
92}
93
94/// Generate a stress heatmap from rigid body contacts.
95///
96/// Samples a 2D slice and accumulates contact force density at each pixel
97/// using a radial falloff kernel from nearby contact points.
98///
99/// # Arguments
100///
101/// - `body_positions`: Positions of all rigid bodies
102/// - `contacts`: Contact data as `(point, normal, force_magnitude)` triples
103/// - `config`: Heatmap sampling configuration
104#[must_use]
105pub fn generate_stress_heatmap(
106    body_positions: &[Vec3Fix],
107    contacts: &[(Vec3Fix, Vec3Fix, Fix128)],
108    config: &HeatmapConfig,
109) -> Heatmap {
110    let w = config.resolution_x.max(1);
111    let h = config.resolution_y.max(1);
112    let mut data = vec![Fix128::ZERO; w * h];
113
114    let _ = body_positions; // Used for spatial reference; contacts carry positions
115
116    let influence_radius = Fix128::from_int(2);
117    let radius_sq = influence_radius * influence_radius;
118
119    for iy in 0..h {
120        for ix in 0..w {
121            let u = Fix128::from_ratio(ix as i64, w as i64);
122            let v = Fix128::from_ratio(iy as i64, h as i64);
123            let sample = sample_point(config, u, v);
124
125            let mut stress = Fix128::ZERO;
126
127            for &(ref contact_point, ref _normal, force) in contacts {
128                let diff = sample - *contact_point;
129                let dist_sq = diff.dot(diff);
130
131                if dist_sq < radius_sq {
132                    // Linear falloff
133                    let dist = dist_sq.sqrt();
134                    let falloff = Fix128::ONE - dist / influence_radius;
135                    stress = stress + force * falloff;
136                }
137            }
138
139            data[iy * w + ix] = stress;
140        }
141    }
142
143    compute_heatmap_minmax(data, w, h)
144}
145
146/// Generate a temperature heatmap by sampling a temperature function.
147///
148/// Evaluates the provided temperature function at each sample point on
149/// the 2D slice.
150///
151/// # Arguments
152///
153/// - `temperature_fn`: Function mapping a 3D point to a temperature value
154/// - `config`: Heatmap sampling configuration
155#[must_use]
156pub fn generate_temperature_heatmap<F>(temperature_fn: F, config: &HeatmapConfig) -> Heatmap
157where
158    F: Fn(Vec3Fix) -> Fix128,
159{
160    let w = config.resolution_x.max(1);
161    let h = config.resolution_y.max(1);
162    let mut data = vec![Fix128::ZERO; w * h];
163
164    for iy in 0..h {
165        for ix in 0..w {
166            let u = Fix128::from_ratio(ix as i64, w as i64);
167            let v = Fix128::from_ratio(iy as i64, h as i64);
168            let sample = sample_point(config, u, v);
169            data[iy * w + ix] = temperature_fn(sample);
170        }
171    }
172
173    compute_heatmap_minmax(data, w, h)
174}
175
176/// Compute min/max and build the `Heatmap` struct.
177fn compute_heatmap_minmax(data: Vec<Fix128>, width: usize, height: usize) -> Heatmap {
178    let mut min_val = data.first().copied().unwrap_or(Fix128::ZERO);
179    let mut max_val = min_val;
180
181    for &v in &data {
182        if v < min_val {
183            min_val = v;
184        }
185        if v > max_val {
186            max_val = v;
187        }
188    }
189
190    Heatmap {
191        data,
192        width,
193        height,
194        min_value: min_val,
195        max_value: max_val,
196    }
197}
198
199// ============================================================================
200// RGBA Conversion (viridis-like colormap)
201// ============================================================================
202
203/// Convert a heatmap to RGBA pixels using a viridis-like colormap.
204///
205/// Maps `min_value` to dark purple and `max_value` to bright yellow.
206/// Returns one `[R, G, B, A]` per pixel in row-major order.
207///
208/// This function uses `f64` intermediates for color interpolation.
209/// The heatmap values themselves remain in `Fix128`.
210#[must_use]
211pub fn heatmap_to_rgba(heatmap: &Heatmap) -> Vec<[u8; 4]> {
212    let range = heatmap.max_value - heatmap.min_value;
213    let has_range = !range.is_zero();
214
215    heatmap
216        .data
217        .iter()
218        .map(|&val| {
219            let t = if has_range {
220                ((val - heatmap.min_value) / range).to_f64().clamp(0.0, 1.0)
221            } else {
222                0.5
223            };
224            viridis_color(t)
225        })
226        .collect()
227}
228
229/// Viridis-like colormap: 5-stop linear interpolation.
230///
231/// Stops: 0.0 -> dark purple, 0.25 -> blue, 0.5 -> teal,
232///        0.75 -> green-yellow, 1.0 -> bright yellow.
233#[allow(clippy::suboptimal_flops)]
234fn viridis_color(t: f64) -> [u8; 4] {
235    // Viridis key colors (simplified 5-stop gradient)
236    const STOPS: [(f64, f64, f64); 5] = [
237        (0.267, 0.004, 0.329), // dark purple
238        (0.282, 0.140, 0.458), // blue-purple
239        (0.127, 0.566, 0.551), // teal
240        (0.544, 0.774, 0.247), // green-yellow
241        (0.993, 0.906, 0.144), // bright yellow
242    ];
243
244    let t = t.clamp(0.0, 1.0);
245    let segment = crate::det_math::min64(t * 4.0, 3.999);
246    let idx = segment as usize;
247    let frac = segment - idx as f64;
248
249    let (r0, g0, b0) = STOPS[idx];
250    let (r1, g1, b1) = STOPS[idx + 1];
251
252    let r = (r1 - r0) * frac + r0;
253    let g = (g1 - g0) * frac + g0;
254    let b = (b1 - b0) * frac + b0;
255
256    [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255]
257}
258
259// ============================================================================
260// World view
261// ============================================================================
262
263impl crate::solver::PhysicsWorld {
264    /// The stress heatmap of the last step's contacts: [`generate_stress_heatmap`]
265    /// over the body positions and [`contact_forces`](Self::contact_forces)`(dt)`,
266    /// where `dt` is the frame step passed to [`step`](Self::step) (a `dt` of zero
267    /// carries no force, so the map is all zero).
268    #[must_use]
269    pub fn stress_heatmap(&self, dt: Fix128, config: &HeatmapConfig) -> Heatmap {
270        let positions: Vec<Vec3Fix> = self.bodies.iter().map(|b| b.position).collect();
271        generate_stress_heatmap(&positions, &self.contact_forces(dt), config)
272    }
273}
274
275// ============================================================================
276// Tests
277// ============================================================================
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn default_config() -> HeatmapConfig {
284        HeatmapConfig {
285            resolution_x: 4,
286            resolution_y: 4,
287            slice_axis: SliceAxis::Z,
288            slice_offset: Fix128::ZERO,
289            bounds_min: Vec3Fix::from_int(-5, -5, -5),
290            bounds_max: Vec3Fix::from_int(5, 5, 5),
291        }
292    }
293
294    #[test]
295    fn test_stress_heatmap_no_contacts() {
296        let config = default_config();
297        let heatmap = generate_stress_heatmap(&[], &[], &config);
298        assert_eq!(heatmap.width, 4);
299        assert_eq!(heatmap.height, 4);
300        assert_eq!(heatmap.data.len(), 16);
301        // All values should be zero
302        for &v in &heatmap.data {
303            assert!(v.is_zero());
304        }
305    }
306
307    #[test]
308    fn test_stress_heatmap_with_contact() {
309        let config = default_config();
310        let contacts = vec![(Vec3Fix::ZERO, Vec3Fix::UNIT_Y, Fix128::from_int(100))];
311        let heatmap = generate_stress_heatmap(&[], &contacts, &config);
312        assert!(heatmap.max_value > Fix128::ZERO);
313    }
314
315    #[test]
316    fn test_temperature_heatmap_uniform() {
317        let config = default_config();
318        let temp = Fix128::from_int(300); // 300K
319        let heatmap = generate_temperature_heatmap(|_| temp, &config);
320        assert_eq!(heatmap.min_value, temp);
321        assert_eq!(heatmap.max_value, temp);
322    }
323
324    #[test]
325    fn test_temperature_heatmap_gradient() {
326        let config = HeatmapConfig {
327            resolution_x: 10,
328            resolution_y: 1,
329            slice_axis: SliceAxis::Z,
330            slice_offset: Fix128::ZERO,
331            bounds_min: Vec3Fix::from_int(0, 0, 0),
332            bounds_max: Vec3Fix::from_int(10, 10, 10),
333        };
334        // Temperature increases with X
335        let heatmap = generate_temperature_heatmap(|p| p.x, &config);
336        assert!(heatmap.max_value > heatmap.min_value);
337    }
338
339    #[test]
340    fn test_heatmap_to_rgba_length() {
341        let config = default_config();
342        let heatmap = generate_temperature_heatmap(|_| Fix128::ONE, &config);
343        let rgba = heatmap_to_rgba(&heatmap);
344        assert_eq!(rgba.len(), 16); // 4x4
345    }
346
347    #[test]
348    fn test_heatmap_to_rgba_alpha_255() {
349        let config = default_config();
350        let heatmap = generate_temperature_heatmap(|p| p.x, &config);
351        let rgba = heatmap_to_rgba(&heatmap);
352        for pixel in &rgba {
353            assert_eq!(pixel[3], 255);
354        }
355    }
356
357    #[test]
358    fn test_viridis_endpoints() {
359        let dark = viridis_color(0.0);
360        let bright = viridis_color(1.0);
361        // Dark purple should have low R, G, moderate B
362        assert!(dark[0] < 100);
363        // Bright yellow should have high R, G
364        assert!(bright[0] > 200);
365        assert!(bright[1] > 200);
366    }
367
368    #[test]
369    fn test_slice_axis_x() {
370        let config = HeatmapConfig {
371            resolution_x: 2,
372            resolution_y: 2,
373            slice_axis: SliceAxis::X,
374            slice_offset: Fix128::from_int(5),
375            bounds_min: Vec3Fix::from_int(0, 0, 0),
376            bounds_max: Vec3Fix::from_int(10, 10, 10),
377        };
378        // Temperature = X coordinate
379        let heatmap = generate_temperature_heatmap(|p| p.x, &config);
380        // All samples should have X=5 (the slice offset)
381        for &v in &heatmap.data {
382            assert_eq!(v.hi, 5);
383        }
384    }
385
386    #[test]
387    fn test_slice_axis_y() {
388        let config = HeatmapConfig {
389            resolution_x: 2,
390            resolution_y: 2,
391            slice_axis: SliceAxis::Y,
392            slice_offset: Fix128::from_int(3),
393            bounds_min: Vec3Fix::from_int(0, 0, 0),
394            bounds_max: Vec3Fix::from_int(10, 10, 10),
395        };
396        let heatmap = generate_temperature_heatmap(|p| p.y, &config);
397        for &v in &heatmap.data {
398            assert_eq!(v.hi, 3);
399        }
400    }
401
402    #[test]
403    fn test_heatmap_dimensions() {
404        let config = HeatmapConfig {
405            resolution_x: 7,
406            resolution_y: 3,
407            slice_axis: SliceAxis::Z,
408            slice_offset: Fix128::ZERO,
409            bounds_min: Vec3Fix::from_int(-1, -1, -1),
410            bounds_max: Vec3Fix::from_int(1, 1, 1),
411        };
412        let heatmap = generate_temperature_heatmap(|_| Fix128::ZERO, &config);
413        assert_eq!(heatmap.width, 7);
414        assert_eq!(heatmap.height, 3);
415        assert_eq!(heatmap.data.len(), 21);
416    }
417}