Skip to main content

viewport_lib/resources/
sparse_volume.rs

1//! Sparse voxel grid topology processing : boundary face extraction.
2//!
3//! Converts a sparse regular grid (a subset of occupied cells on an axis-aligned
4//! lattice) into a standard [`MeshData`] by extracting boundary faces (quad faces
5//! not shared between two active cells) and computing area-weighted vertex normals.
6//!
7//! Per-cell scalar and colour attributes are remapped to per-face attributes so the
8//! existing Phase 2 face-rendering path handles colouring without any new GPU
9//! infrastructure.  Per-node scalars are averaged over the four quad corner nodes
10//! to produce per-face scalars on the same path.
11//!
12//! # Node scalar indexing
13//!
14//! `node_scalars` is a dense `Vec<f32>` indexed as:
15//!
16//! ```text
17//! index = nk * (W * H) + nj * W + ni
18//! ```
19//!
20//! where `W = max_i + 2`, `H = max_j + 2`, and `max_i`/`max_j`/`max_k` are the
21//! maximum cell indices found in `active_cells`.  Node indices range from 0 to
22//! `max_cell_i + 1` on each axis, so the node grid is one cell larger than the
23//! cell grid on every axis.  If the vec is shorter than `W * H * D` (where
24//! `D = max_k + 2`), missing entries default to `0.0`.
25
26use std::collections::{HashMap, HashSet};
27
28use super::types::{AttributeData, MeshData};
29
30// ---------------------------------------------------------------------------
31// Face corner table
32// ---------------------------------------------------------------------------
33//
34// Six axis-aligned cube faces.  Each entry lists the four corner offsets
35// `[di, dj, dk]` from cell `[ci, cj, ck]` in counter-clockwise order when
36// viewed from outside the cell (outward normal convention).
37//
38// Verified normal directions:
39//   0  -X : [0,0,0],[0,0,1],[0,1,1],[0,1,0]  normal = cross([0,0,1],[0,1,1]) = [-1,0,0] ✓
40//   1  +X : [1,0,0],[1,1,0],[1,1,1],[1,0,1]  normal = cross([0,1,0],[0,1,1]) = [+1,0,0] ✓
41//   2  -Y : [0,0,0],[1,0,0],[1,0,1],[0,0,1]  normal = cross([1,0,0],[1,0,1]) = [0,-1,0] ✓
42//   3  +Y : [0,1,0],[0,1,1],[1,1,1],[1,1,0]  normal = cross([0,0,1],[1,0,1]) = [0,+1,0] ✓
43//   4  -Z : [0,0,0],[0,1,0],[1,1,0],[1,0,0]  normal = cross([0,1,0],[1,1,0]) = [0,0,-1] ✓
44//   5  +Z : [0,0,1],[1,0,1],[1,1,1],[0,1,1]  normal = cross([1,0,0],[1,1,0]) = [0,0,+1] ✓
45
46const FACE_CORNERS: [[[u32; 3]; 4]; 6] = [
47    [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]], // 0: -X
48    [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]], // 1: +X
49    [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]], // 2: -Y
50    [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]], // 3: +Y
51    [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]], // 4: -Z
52    [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]], // 5: +Z
53];
54
55// ---------------------------------------------------------------------------
56// Public data type
57// ---------------------------------------------------------------------------
58
59/// Input data for a sparse regular voxel grid.
60///
61/// Only the cells listed in `active_cells` are considered occupied.  Boundary
62/// faces (faces not shared between two active cells) are extracted and uploaded
63/// as a standard surface mesh.
64///
65/// # Attribute conventions
66///
67/// - `cell_scalars` / `cell_colours`: one entry per element of `active_cells`
68///   (parallel arrays).
69/// - `node_scalars`: dense array indexed by
70///   `nk * (W * H) + nj * W + ni` where `W = max_i + 2`, `H = max_j + 2`
71///   and `max_i`, `max_j`, `max_k` are derived from `active_cells` (see module
72///   docs).  Set via [`AttributeRef { kind: AttributeKind::Face, .. }`] after
73///   upload — node scalars are averaged to face scalars during extraction.
74///
75/// # Upload
76///
77/// ```no_run
78/// use viewport_lib::{SparseVolumeGridData, AttributeKind, AttributeRef};
79/// use std::collections::HashMap;
80///
81/// let mut data = SparseVolumeGridData::default();
82/// data.origin    = [-1.0, -1.0, -1.0];
83/// data.cell_size = 1.0;
84/// data.active_cells = vec![[0, 0, 0], [1, 0, 0]];
85/// data.cell_scalars.insert("pressure".to_string(), vec![0.3, 0.8]);
86/// // upload via resources.upload_sparse_volume_grid_data(device, &data)
87/// ```
88#[non_exhaustive]
89#[derive(Default)]
90pub struct SparseVolumeGridData {
91    /// World-space position of the `[0, 0, 0]` corner of cell `[0, 0, 0]`.
92    pub origin: [f32; 3],
93
94    /// Side length of one cubic cell in world units.  Must be positive.
95    pub cell_size: f32,
96
97    /// Grid indices `[i, j, k]` of occupied cells.
98    pub active_cells: Vec<[u32; 3]>,
99
100    /// Named per-cell scalar attributes (one `f32` per active cell, parallel to
101    /// `active_cells`).  Remapped to `AttributeKind::Face` during extraction.
102    pub cell_scalars: HashMap<String, Vec<f32>>,
103
104    /// Named per-node scalar attributes.  Dense array; see module-level
105    /// indexing convention.  Averaged over 4 quad corners to produce
106    /// `AttributeKind::Face` values.
107    pub node_scalars: HashMap<String, Vec<f32>>,
108
109    /// Named per-cell RGBA colour attributes (one `[f32; 4]` per active cell,
110    /// parallel to `active_cells`).  Remapped to `AttributeKind::FaceColour`
111    /// during extraction.
112    pub cell_colours: HashMap<String, Vec<[f32; 4]>>,
113}
114
115// ---------------------------------------------------------------------------
116// Boundary extraction
117// ---------------------------------------------------------------------------
118
119/// Convert [`SparseVolumeGridData`] into a standard [`MeshData`] by extracting
120/// the boundary surface and remapping per-cell / per-node attributes to
121/// per-face attributes.
122///
123/// Returns an empty [`MeshData`] if `active_cells` is empty or `cell_size <=
124/// 0.0`.  This causes [`upload_mesh_data`](super::ViewportGpuResources::upload_mesh_data)
125/// to return a `ViewportError::EmptyMesh` — the upload layer handles it.
126pub(crate) fn extract_sparse_boundary(data: &SparseVolumeGridData) -> MeshData {
127    if data.active_cells.is_empty() || data.cell_size <= 0.0 {
128        return MeshData::default();
129    }
130
131    let s = data.cell_size;
132    let [ox, oy, oz] = data.origin;
133
134    // Build the active-cell lookup set for O(1) neighbor queries.
135    let active_set: HashSet<[u32; 3]> = data.active_cells.iter().copied().collect();
136
137    // Compute the extent of the node grid (needed for node_scalars indexing).
138    let (max_ci, max_cj, _max_ck) = data
139        .active_cells
140        .iter()
141        .fold((0u32, 0u32, 0u32), |(mi, mj, mk), &[ci, cj, ck]| {
142            (mi.max(ci), mj.max(cj), mk.max(ck))
143        });
144    // Node grid dimensions: one more than cell grid on each axis.
145    let node_w = (max_ci + 2) as usize; // nodes along I axis
146    let node_h = (max_cj + 2) as usize; // nodes along J axis
147
148    // Deduplicate vertices: grid-node key -> vertex index in output mesh.
149    let mut node_to_vi: HashMap<[u32; 3], u32> = HashMap::new();
150    let mut positions: Vec<[f32; 3]> = Vec::new();
151
152    // Each boundary quad records: active_cell_index, 4 vertex indices, 4 node keys.
153    struct BoundaryQuad {
154        cell_idx: usize,
155        vi: [u32; 4],
156        node_keys: [[u32; 3]; 4],
157    }
158    let mut boundary_quads: Vec<BoundaryQuad> = Vec::new();
159
160    for (cell_idx, &[ci, cj, ck]) in data.active_cells.iter().enumerate() {
161        for face_dir in 0usize..6 {
162            // Check whether the neighbor in this direction is active.
163            let neighbor_active = match face_dir {
164                0 => ci > 0 && active_set.contains(&[ci - 1, cj, ck]), // -X
165                1 => active_set.contains(&[ci + 1, cj, ck]),           // +X
166                2 => cj > 0 && active_set.contains(&[ci, cj - 1, ck]), // -Y
167                3 => active_set.contains(&[ci, cj + 1, ck]),           // +Y
168                4 => ck > 0 && active_set.contains(&[ci, cj, ck - 1]), // -Z
169                5 => active_set.contains(&[ci, cj, ck + 1]),           // +Z
170                _ => unreachable!(),
171            };
172
173            if neighbor_active {
174                continue; // interior face — skip
175            }
176
177            // Boundary face: resolve the 4 node keys and deduplicate vertices.
178            let corners = &FACE_CORNERS[face_dir];
179            let mut vi = [0u32; 4];
180            let mut node_keys = [[0u32; 3]; 4];
181            for (k, &[di, dj, dk]) in corners.iter().enumerate() {
182                let nk: [u32; 3] = [ci + di, cj + dj, ck + dk];
183                node_keys[k] = nk;
184                let next_idx = positions.len() as u32;
185                let vi_k = *node_to_vi.entry(nk).or_insert_with(|| {
186                    positions.push([
187                        ox + nk[0] as f32 * s,
188                        oy + nk[1] as f32 * s,
189                        oz + nk[2] as f32 * s,
190                    ]);
191                    next_idx
192                });
193                vi[k] = vi_k;
194            }
195
196            boundary_quads.push(BoundaryQuad {
197                cell_idx,
198                vi,
199                node_keys,
200            });
201        }
202    }
203
204    let n_verts = positions.len();
205    let n_quads = boundary_quads.len();
206
207    // Build index buffer (2 triangles per quad: [A,B,C] and [A,C,D]).
208    let mut indices: Vec<u32> = Vec::with_capacity(n_quads * 6);
209    // Normal accumulation in f64 for precision.
210    let mut normal_accum: Vec<[f64; 3]> = vec![[0.0; 3]; n_verts];
211
212    for quad in &boundary_quads {
213        let [va, vb, vc, vd] = quad.vi;
214
215        // Triangle 0: A, B, C
216        indices.push(va);
217        indices.push(vb);
218        indices.push(vc);
219        // Triangle 1: A, C, D
220        indices.push(va);
221        indices.push(vc);
222        indices.push(vd);
223
224        // Accumulate area-weighted face normal to each vertex in both triangles.
225        for &[v0, v1, v2] in &[[va, vb, vc], [va, vc, vd]] {
226            let pa = positions[v0 as usize];
227            let pb = positions[v1 as usize];
228            let pc = positions[v2 as usize];
229            let ab = [
230                (pb[0] - pa[0]) as f64,
231                (pb[1] - pa[1]) as f64,
232                (pb[2] - pa[2]) as f64,
233            ];
234            let ac = [
235                (pc[0] - pa[0]) as f64,
236                (pc[1] - pa[1]) as f64,
237                (pc[2] - pa[2]) as f64,
238            ];
239            let n = [
240                ab[1] * ac[2] - ab[2] * ac[1],
241                ab[2] * ac[0] - ab[0] * ac[2],
242                ab[0] * ac[1] - ab[1] * ac[0],
243            ];
244            for &vi in &[v0, v1, v2] {
245                let acc = &mut normal_accum[vi as usize];
246                acc[0] += n[0];
247                acc[1] += n[1];
248                acc[2] += n[2];
249            }
250        }
251    }
252
253    // Normalize accumulated normals.
254    let normals: Vec<[f32; 3]> = normal_accum
255        .iter()
256        .map(|n| {
257            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
258            if len > 1e-12 {
259                [
260                    (n[0] / len) as f32,
261                    (n[1] / len) as f32,
262                    (n[2] / len) as f32,
263                ]
264            } else {
265                [0.0, 1.0, 0.0]
266            }
267        })
268        .collect();
269
270    // ---------------------------------------------------------------------------
271    // Attribute remapping
272    // ---------------------------------------------------------------------------
273
274    let mut attributes: HashMap<String, AttributeData> = HashMap::new();
275
276    // Cell scalars: one value per quad -> one per triangle (two per quad).
277    for (name, cell_vals) in &data.cell_scalars {
278        let face_scalars: Vec<f32> = boundary_quads
279            .iter()
280            .flat_map(|q| {
281                let v = cell_vals.get(q.cell_idx).copied().unwrap_or(0.0);
282                [v, v] // two triangles per quad
283            })
284            .collect();
285        attributes.insert(name.clone(), AttributeData::Face(face_scalars));
286    }
287
288    // Cell colours: one RGBA per quad -> one per triangle (two per quad).
289    for (name, cell_vals) in &data.cell_colours {
290        let face_colours: Vec<[f32; 4]> = boundary_quads
291            .iter()
292            .flat_map(|q| {
293                let c = cell_vals.get(q.cell_idx).copied().unwrap_or([1.0; 4]);
294                [c, c] // two triangles per quad
295            })
296            .collect();
297        attributes.insert(name.clone(), AttributeData::FaceColour(face_colours));
298    }
299
300    // Node scalars: average the 4 quad corner values -> one per triangle.
301    for (name, node_vals) in &data.node_scalars {
302        let face_scalars: Vec<f32> = boundary_quads
303            .iter()
304            .flat_map(|q| {
305                let avg = {
306                    let mut sum = 0.0f32;
307                    for &[ni, nj, nk] in &q.node_keys {
308                        let idx =
309                            nk as usize * node_w * node_h + nj as usize * node_w + ni as usize;
310                        sum += node_vals.get(idx).copied().unwrap_or(0.0);
311                    }
312                    sum / 4.0
313                };
314                [avg, avg] // two triangles per quad
315            })
316            .collect();
317        attributes.insert(name.clone(), AttributeData::Face(face_scalars));
318    }
319
320    MeshData {
321        positions,
322        normals,
323        indices,
324        uvs: None,
325        tangents: None,
326        attributes,
327    }
328}
329
330// ---------------------------------------------------------------------------
331// Tests
332// ---------------------------------------------------------------------------
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn single_cell() -> SparseVolumeGridData {
339        SparseVolumeGridData {
340            origin: [0.0; 3],
341            cell_size: 1.0,
342            active_cells: vec![[0, 0, 0]],
343            ..Default::default()
344        }
345    }
346
347    fn two_adjacent_cells() -> SparseVolumeGridData {
348        SparseVolumeGridData {
349            origin: [0.0; 3],
350            cell_size: 1.0,
351            active_cells: vec![[0, 0, 0], [1, 0, 0]],
352            ..Default::default()
353        }
354    }
355
356    fn three_cells_in_a_line() -> SparseVolumeGridData {
357        SparseVolumeGridData {
358            origin: [0.0; 3],
359            cell_size: 1.0,
360            active_cells: vec![[0, 0, 0], [1, 0, 0], [2, 0, 0]],
361            ..Default::default()
362        }
363    }
364
365    #[test]
366    fn single_active_cell_has_twelve_boundary_triangles() {
367        let data = single_cell();
368        let mesh = extract_sparse_boundary(&data);
369        // 6 faces × 2 triangles = 12 triangles → 36 indices
370        assert_eq!(
371            mesh.indices.len(),
372            36,
373            "single cell -> 12 boundary triangles (36 indices)"
374        );
375    }
376
377    #[test]
378    fn two_adjacent_cells_share_one_face() {
379        let data = two_adjacent_cells();
380        let mesh = extract_sparse_boundary(&data);
381        // 2 × 6 quads = 12 quads, minus 2 shared quads (one from each cell) = 10 quads
382        // 10 quads × 2 triangles = 20 triangles → 60 indices
383        assert_eq!(
384            mesh.indices.len(),
385            60,
386            "two adjacent cells -> 20 boundary triangles (60 indices)"
387        );
388    }
389
390    #[test]
391    fn three_cells_in_a_line_share_two_faces() {
392        let data = three_cells_in_a_line();
393        let mesh = extract_sparse_boundary(&data);
394        // 3 × 6 = 18 quads minus 4 shared (2 interior boundaries × 2 cells each) = 14 quads
395        // 14 × 2 = 28 triangles → 84 indices
396        assert_eq!(
397            mesh.indices.len(),
398            84,
399            "three cells in a line -> 28 boundary triangles (84 indices)"
400        );
401    }
402
403    #[test]
404    fn positions_are_correct() {
405        let data = SparseVolumeGridData {
406            origin: [1.0, 2.0, 3.0],
407            cell_size: 0.5,
408            active_cells: vec![[0, 0, 0]],
409            ..Default::default()
410        };
411        let mesh = extract_sparse_boundary(&data);
412        // Corner (0,0,0) -> [1.0, 2.0, 3.0]
413        assert!(
414            mesh.positions.contains(&[1.0, 2.0, 3.0]),
415            "corner [0,0,0] must be at origin"
416        );
417        // Corner (1,1,1) -> [1.5, 2.5, 3.5]
418        assert!(
419            mesh.positions.contains(&[1.5, 2.5, 3.5]),
420            "corner [1,1,1] must be at origin + cell_size"
421        );
422        // All 8 corners, no duplicates
423        assert_eq!(mesh.positions.len(), 8, "single cell -> 8 unique corners");
424    }
425
426    #[test]
427    fn normals_have_correct_length() {
428        let data = single_cell();
429        let mesh = extract_sparse_boundary(&data);
430        assert_eq!(mesh.normals.len(), mesh.positions.len());
431        for n in &mesh.normals {
432            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
433            assert!(
434                (len - 1.0).abs() < 1e-5,
435                "normal not unit length: {n:?} (len={len})"
436            );
437        }
438    }
439
440    #[test]
441    fn cell_scalar_remaps_to_face_attribute() {
442        let mut data = single_cell();
443        data.cell_scalars.insert("pressure".to_string(), vec![42.0]);
444        let mesh = extract_sparse_boundary(&data);
445        match mesh.attributes.get("pressure") {
446            Some(AttributeData::Face(vals)) => {
447                // 6 quads × 2 triangles = 12 entries
448                assert_eq!(vals.len(), 12, "one value per boundary triangle");
449                for &v in vals {
450                    assert_eq!(v, 42.0, "scalar must match cell value");
451                }
452            }
453            other => panic!("expected Face attribute, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn cell_colour_remaps_to_face_colour_attribute() {
459        let mut data = two_adjacent_cells();
460        data.cell_colours.insert(
461            "label".to_string(),
462            vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]],
463        );
464        let mesh = extract_sparse_boundary(&data);
465        match mesh.attributes.get("label") {
466            Some(AttributeData::FaceColour(colours)) => {
467                // 20 boundary triangles
468                assert_eq!(colours.len(), 20, "20 boundary triangles");
469            }
470            other => panic!("expected FaceColour attribute, got {other:?}"),
471        }
472    }
473
474    #[test]
475    fn node_scalar_remaps_to_face_attribute() {
476        // Single cell [0,0,0] with cell_size=1.0.
477        // Node grid: W=2, H=2. All 8 corners set to 1.0.
478        // All 4-corner averages = 1.0.
479        let mut data = single_cell();
480        let node_vals = vec![1.0f32; 8]; // 2×2×2
481        data.node_scalars.insert("dist".to_string(), node_vals);
482        let mesh = extract_sparse_boundary(&data);
483        match mesh.attributes.get("dist") {
484            Some(AttributeData::Face(vals)) => {
485                assert_eq!(vals.len(), 12, "12 boundary triangles");
486                for &v in vals {
487                    assert!(
488                        (v - 1.0).abs() < 1e-6,
489                        "averaged node scalar must equal 1.0, got {v}"
490                    );
491                }
492            }
493            other => panic!("expected Face attribute, got {other:?}"),
494        }
495    }
496
497    #[test]
498    fn empty_active_cells_returns_empty_mesh_data() {
499        let data = SparseVolumeGridData {
500            origin: [0.0; 3],
501            cell_size: 1.0,
502            ..Default::default()
503        };
504        let mesh = extract_sparse_boundary(&data);
505        assert!(mesh.positions.is_empty());
506        assert!(mesh.indices.is_empty());
507    }
508
509    #[test]
510    fn cell_size_zero_returns_empty_mesh_data() {
511        let data = SparseVolumeGridData {
512            origin: [0.0; 3],
513            cell_size: 0.0,
514            active_cells: vec![[0, 0, 0]],
515            ..Default::default()
516        };
517        let mesh = extract_sparse_boundary(&data);
518        assert!(mesh.positions.is_empty());
519        assert!(mesh.indices.is_empty());
520    }
521}