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 color attributes are remapped to per-face attributes so the
8//! existing Phase 2 face-rendering path handles coloring 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_colors`: 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 color attributes (one `[f32; 4]` per active cell,
110 /// parallel to `active_cells`). Remapped to `AttributeKind::FaceColor`
111 /// during extraction.
112 pub cell_colors: 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 colors: one RGBA per quad -> one per triangle (two per quad).
289 for (name, cell_vals) in &data.cell_colors {
290 let face_colors: 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::FaceColor(face_colors));
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_color_remaps_to_face_color_attribute() {
459 let mut data = two_adjacent_cells();
460 data.cell_colors.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::FaceColor(colors)) => {
467 // 20 boundary triangles
468 assert_eq!(colors.len(), 20, "20 boundary triangles");
469 }
470 other => panic!("expected FaceColor 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}