Skip to main content

polyscope_structures/volume_grid/
scalar_quantity.rs

1//! Scalar quantities for volume grids.
2
3use glam::{UVec3, Vec3};
4use polyscope_core::quantity::{Quantity, QuantityKind};
5use polyscope_core::{McmMesh, marching_cubes};
6use polyscope_render::{GridcubePickUniforms, GridcubeRenderData, IsosurfaceRenderData};
7use wgpu::util::DeviceExt;
8
9/// Visualization mode for volume grid scalar quantities.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum VolumeGridVizMode {
12    /// Colored cubes at each grid node/cell.
13    Gridcube,
14    /// Isosurface extracted via marching cubes (node scalars only).
15    Isosurface,
16}
17
18/// A scalar quantity defined at grid nodes.
19pub struct VolumeGridNodeScalarQuantity {
20    name: String,
21    structure_name: String,
22    values: Vec<f32>,
23    node_dim: UVec3,
24    enabled: bool,
25
26    // Visualization parameters
27    color_map: String,
28    data_min: f32,
29    data_max: f32,
30
31    // Visualization mode
32    viz_mode: VolumeGridVizMode,
33
34    // Gridcube state
35    gridcube_render_data: Option<GridcubeRenderData>,
36    gridcube_dirty: bool,
37
38    // Isosurface state
39    isosurface_level: f32,
40    isosurface_color: Vec3,
41    isosurface_render_data: Option<IsosurfaceRenderData>,
42    isosurface_mesh_cache: Option<McmMesh>,
43    isosurface_dirty: bool,
44
45    // Grid geometry (needed for MC coordinate transform)
46    bound_min: Vec3,
47    bound_max: Vec3,
48
49    // Flag: user clicked "Register as Surface Mesh"
50    register_as_mesh_requested: bool,
51
52    // Pick state
53    pick_uniform_buffer: Option<wgpu::Buffer>,
54    pick_bind_group: Option<wgpu::BindGroup>,
55    global_start: u32,
56}
57
58impl VolumeGridNodeScalarQuantity {
59    /// Creates a new node scalar quantity.
60    pub fn new(
61        name: impl Into<String>,
62        structure_name: impl Into<String>,
63        values: Vec<f32>,
64        node_dim: UVec3,
65        bound_min: Vec3,
66        bound_max: Vec3,
67    ) -> Self {
68        let (data_min, data_max) = Self::compute_range(&values);
69        let isosurface_level = (data_min + data_max) * 0.5;
70        Self {
71            name: name.into(),
72            structure_name: structure_name.into(),
73            values,
74            node_dim,
75            enabled: false,
76            color_map: "viridis".to_string(),
77            data_min,
78            data_max,
79            viz_mode: VolumeGridVizMode::Gridcube,
80            gridcube_render_data: None,
81            gridcube_dirty: true,
82            isosurface_level,
83            isosurface_color: Vec3::new(0.047, 0.451, 0.690), // default blue
84            isosurface_render_data: None,
85            isosurface_mesh_cache: None,
86            isosurface_dirty: true,
87            bound_min,
88            bound_max,
89            register_as_mesh_requested: false,
90            pick_uniform_buffer: None,
91            pick_bind_group: None,
92            global_start: 0,
93        }
94    }
95
96    fn compute_range(values: &[f32]) -> (f32, f32) {
97        let mut min = f32::MAX;
98        let mut max = f32::MIN;
99        for &v in values {
100            if v.is_finite() {
101                min = min.min(v);
102                max = max.max(v);
103            }
104        }
105        if min > max { (0.0, 1.0) } else { (min, max) }
106    }
107
108    /// Returns the values.
109    #[must_use]
110    pub fn values(&self) -> &[f32] {
111        &self.values
112    }
113
114    /// Returns the grid node dimensions.
115    #[must_use]
116    pub fn node_dim(&self) -> UVec3 {
117        self.node_dim
118    }
119
120    /// Gets the value at a 3D index.
121    #[must_use]
122    pub fn get(&self, i: u32, j: u32, k: u32) -> f32 {
123        let idx = i as usize
124            + j as usize * self.node_dim.x as usize
125            + k as usize * self.node_dim.x as usize * self.node_dim.y as usize;
126        self.values.get(idx).copied().unwrap_or(0.0)
127    }
128
129    /// Gets the color map name.
130    #[must_use]
131    pub fn color_map(&self) -> &str {
132        &self.color_map
133    }
134
135    /// Sets the color map name.
136    pub fn set_color_map(&mut self, name: impl Into<String>) -> &mut Self {
137        self.color_map = name.into();
138        self.gridcube_dirty = true;
139        self
140    }
141
142    /// Gets the data range.
143    #[must_use]
144    pub fn data_range(&self) -> (f32, f32) {
145        (self.data_min, self.data_max)
146    }
147
148    /// Sets the data range.
149    pub fn set_data_range(&mut self, min: f32, max: f32) -> &mut Self {
150        self.data_min = min;
151        self.data_max = max;
152        self
153    }
154
155    // --- Visualization mode ---
156
157    /// Gets the current visualization mode.
158    #[must_use]
159    pub fn viz_mode(&self) -> VolumeGridVizMode {
160        self.viz_mode
161    }
162
163    /// Sets the visualization mode.
164    pub fn set_viz_mode(&mut self, mode: VolumeGridVizMode) -> &mut Self {
165        self.viz_mode = mode;
166        self
167    }
168
169    // --- Isosurface ---
170
171    /// Gets the isosurface level.
172    #[must_use]
173    pub fn isosurface_level(&self) -> f32 {
174        self.isosurface_level
175    }
176
177    /// Sets the isosurface level (invalidates cache).
178    pub fn set_isosurface_level(&mut self, level: f32) -> &mut Self {
179        self.isosurface_level = level;
180        self.isosurface_dirty = true;
181        // Keep old mesh cache and render data — replaced atomically in init phase
182        self
183    }
184
185    /// Gets the isosurface color.
186    #[must_use]
187    pub fn isosurface_color(&self) -> Vec3 {
188        self.isosurface_color
189    }
190
191    /// Sets the isosurface color.
192    pub fn set_isosurface_color(&mut self, color: Vec3) -> &mut Self {
193        self.isosurface_color = color;
194        self
195    }
196
197    /// Returns whether the isosurface needs re-extraction.
198    #[must_use]
199    pub fn isosurface_dirty(&self) -> bool {
200        self.isosurface_dirty
201    }
202
203    /// Returns whether the gridcube needs GPU re-init.
204    #[must_use]
205    pub fn gridcube_dirty(&self) -> bool {
206        self.gridcube_dirty
207    }
208
209    /// Extracts the isosurface mesh using marching cubes.
210    ///
211    /// `VolumeGrid` stores values as `idx = i + j*nx + k*nx*ny` (z-slowest, x-fastest),
212    /// but `marching_cubes` indexes as `(i_mc*ny + j_mc)*nz + k_mc` (x-slowest, z-fastest).
213    /// We pass dims in `(nz, ny, nx)` order so MC's inner loop steps over our X axis,
214    /// then map output vertices/normals back to world space:
215    ///   `world_x = k_mc, world_y = j_mc, world_z = i_mc`
216    /// (i.e. swap the X and Z components of every position and normal).
217    ///
218    /// That swap has determinant -1, which flips triangle handedness. We restore
219    /// CCW winding by swapping two indices in every triangle — required for the
220    /// `front_facing` test in `simple_mesh.wgsl` and for `register_isosurface_as_mesh`
221    /// which recomputes per-face normals from winding.
222    pub fn extract_isosurface(&mut self) -> &McmMesh {
223        if self.isosurface_mesh_cache.is_none() || self.isosurface_dirty {
224            let nx = self.node_dim.x;
225            let ny = self.node_dim.y;
226            let nz = self.node_dim.z;
227
228            let mut mesh = marching_cubes(&self.values, self.isosurface_level, nz, ny, nx);
229
230            let cell_dim = Vec3::new(
231                (nx - 1).max(1) as f32,
232                (ny - 1).max(1) as f32,
233                (nz - 1).max(1) as f32,
234            );
235            let spacing = (self.bound_max - self.bound_min) / cell_dim;
236
237            for v in &mut mesh.vertices {
238                *v = Vec3::new(
239                    v.z * spacing.x + self.bound_min.x,
240                    v.y * spacing.y + self.bound_min.y,
241                    v.x * spacing.z + self.bound_min.z,
242                );
243            }
244
245            for n in &mut mesh.normals {
246                *n = Vec3::new(n.z / spacing.x, n.y / spacing.y, n.x / spacing.z);
247                let len = n.length();
248                if len > 0.0 {
249                    *n /= len;
250                }
251            }
252
253            for tri in mesh.indices.chunks_exact_mut(3) {
254                tri.swap(1, 2);
255            }
256
257            self.isosurface_mesh_cache = Some(mesh);
258            self.isosurface_dirty = false;
259        }
260        self.isosurface_mesh_cache.as_ref().unwrap()
261    }
262
263    /// Returns the cached isosurface mesh, if available.
264    #[must_use]
265    pub fn isosurface_mesh(&self) -> Option<&McmMesh> {
266        self.isosurface_mesh_cache.as_ref()
267    }
268
269    // --- GPU resources ---
270
271    /// Returns the gridcube render data.
272    #[must_use]
273    pub fn gridcube_render_data(&self) -> Option<&GridcubeRenderData> {
274        self.gridcube_render_data.as_ref()
275    }
276
277    /// Returns a mutable reference to the gridcube render data.
278    pub fn gridcube_render_data_mut(&mut self) -> Option<&mut GridcubeRenderData> {
279        self.gridcube_render_data.as_mut()
280    }
281
282    /// Sets the gridcube render data.
283    pub fn set_gridcube_render_data(&mut self, data: GridcubeRenderData) {
284        self.gridcube_render_data = Some(data);
285        self.gridcube_dirty = false;
286    }
287
288    /// Returns the isosurface render data.
289    #[must_use]
290    pub fn isosurface_render_data(&self) -> Option<&IsosurfaceRenderData> {
291        self.isosurface_render_data.as_ref()
292    }
293
294    /// Returns a mutable reference to the isosurface render data.
295    pub fn isosurface_render_data_mut(&mut self) -> Option<&mut IsosurfaceRenderData> {
296        self.isosurface_render_data.as_mut()
297    }
298
299    /// Sets the isosurface render data.
300    pub fn set_isosurface_render_data(&mut self, data: IsosurfaceRenderData) {
301        self.isosurface_render_data = Some(data);
302        self.isosurface_dirty = false;
303    }
304
305    /// Clears the isosurface render data (e.g. when isovalue yields empty mesh).
306    pub fn clear_isosurface_render_data(&mut self) {
307        self.isosurface_render_data = None;
308        self.isosurface_dirty = false;
309    }
310
311    // --- Pick resources ---
312
313    /// Initializes pick resources for this quantity.
314    ///
315    /// Requires that `gridcube_render_data` is already initialized (needs the position buffer).
316    pub fn init_pick_resources(
317        &mut self,
318        device: &wgpu::Device,
319        pick_bind_group_layout: &wgpu::BindGroupLayout,
320        camera_buffer: &wgpu::Buffer,
321        global_start: u32,
322    ) {
323        self.global_start = global_start;
324
325        let Some(gridcube_rd) = &self.gridcube_render_data else {
326            return;
327        };
328
329        let uniforms = GridcubePickUniforms {
330            global_start,
331            cube_size_factor: 1.0,
332            ..Default::default()
333        };
334
335        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
336            label: Some("gridcube node pick uniforms"),
337            contents: bytemuck::cast_slice(&[uniforms]),
338            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
339        });
340
341        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
342            label: Some("gridcube node pick bind group"),
343            layout: pick_bind_group_layout,
344            entries: &[
345                wgpu::BindGroupEntry {
346                    binding: 0,
347                    resource: camera_buffer.as_entire_binding(),
348                },
349                wgpu::BindGroupEntry {
350                    binding: 1,
351                    resource: uniform_buffer.as_entire_binding(),
352                },
353                wgpu::BindGroupEntry {
354                    binding: 2,
355                    resource: gridcube_rd.position_buffer.as_entire_binding(),
356                },
357            ],
358        });
359
360        self.pick_uniform_buffer = Some(uniform_buffer);
361        self.pick_bind_group = Some(bind_group);
362    }
363
364    /// Returns the pick bind group, if initialized.
365    #[must_use]
366    pub fn pick_bind_group(&self) -> Option<&wgpu::BindGroup> {
367        self.pick_bind_group.as_ref()
368    }
369
370    /// Updates the pick uniform buffer with current model transform and cube size factor.
371    pub fn update_pick_uniforms(
372        &self,
373        queue: &wgpu::Queue,
374        model: [[f32; 4]; 4],
375        cube_size_factor: f32,
376    ) {
377        if let Some(buffer) = &self.pick_uniform_buffer {
378            let uniforms = GridcubePickUniforms {
379                model,
380                global_start: self.global_start,
381                cube_size_factor,
382                ..Default::default()
383            };
384            queue.write_buffer(buffer, 0, bytemuck::cast_slice(&[uniforms]));
385        }
386    }
387
388    /// Returns the number of pick elements (= number of gridcube instances).
389    #[must_use]
390    pub fn num_pick_elements(&self) -> u32 {
391        self.gridcube_render_data
392            .as_ref()
393            .map_or(0, |rd| rd.num_instances)
394    }
395
396    /// Returns the total vertices for the pick draw call.
397    #[must_use]
398    pub fn pick_total_vertices(&self) -> u32 {
399        self.gridcube_render_data
400            .as_ref()
401            .map_or(0, GridcubeRenderData::total_vertices)
402    }
403
404    /// Returns whether the user has requested registering the isosurface as a mesh.
405    #[must_use]
406    pub fn register_as_mesh_requested(&self) -> bool {
407        self.register_as_mesh_requested
408    }
409
410    /// Clears the register-as-mesh request flag.
411    pub fn clear_register_as_mesh_request(&mut self) {
412        self.register_as_mesh_requested = false;
413    }
414
415    /// Builds egui UI for this quantity.
416    pub fn build_egui_ui(&mut self, ui: &mut egui::Ui, colormap_names: &[&str]) {
417        ui.horizontal(|ui| {
418            let mut enabled = self.enabled;
419            if ui.checkbox(&mut enabled, "").changed() {
420                self.enabled = enabled;
421            }
422            ui.label(&self.name);
423            ui.label(format!("[{:.3}, {:.3}]", self.data_min, self.data_max));
424        });
425
426        if self.enabled {
427            let indent_id = egui::Id::new(&self.name).with("node_scalar_indent");
428            ui.indent(indent_id, |ui| {
429                // Viz mode toggle
430                ui.horizontal(|ui| {
431                    ui.label("Mode:");
432                    if ui
433                        .selectable_label(self.viz_mode == VolumeGridVizMode::Gridcube, "Gridcube")
434                        .clicked()
435                    {
436                        self.viz_mode = VolumeGridVizMode::Gridcube;
437                    }
438                    if ui
439                        .selectable_label(
440                            self.viz_mode == VolumeGridVizMode::Isosurface,
441                            "Isosurface",
442                        )
443                        .clicked()
444                    {
445                        self.viz_mode = VolumeGridVizMode::Isosurface;
446                    }
447                });
448
449                match self.viz_mode {
450                    VolumeGridVizMode::Gridcube => {
451                        self.build_gridcube_ui(ui, colormap_names);
452                    }
453                    VolumeGridVizMode::Isosurface => {
454                        self.build_isosurface_ui(ui);
455                    }
456                }
457            });
458        }
459    }
460
461    fn build_gridcube_ui(&mut self, ui: &mut egui::Ui, colormap_names: &[&str]) {
462        // Colormap selector
463        if !colormap_names.is_empty() {
464            ui.horizontal(|ui| {
465                ui.label("Colormap:");
466                egui::ComboBox::from_id_salt(format!("{}_colormap", self.name))
467                    .selected_text(&self.color_map)
468                    .show_ui(ui, |ui| {
469                        for &name in colormap_names {
470                            if ui.selectable_label(self.color_map == name, name).clicked() {
471                                self.color_map = name.to_string();
472                                self.gridcube_dirty = true;
473                            }
474                        }
475                    });
476            });
477        }
478
479        // Data range
480        ui.horizontal(|ui| {
481            ui.label("Range:");
482            let mut min = self.data_min;
483            let mut max = self.data_max;
484            let speed = (max - min).abs() * 0.01;
485            let speed = if speed > 0.0 { speed } else { 0.01 };
486            ui.add(egui::DragValue::new(&mut min).speed(speed));
487            ui.label("–");
488            ui.add(egui::DragValue::new(&mut max).speed(speed));
489            if (min - self.data_min).abs() > f32::EPSILON
490                || (max - self.data_max).abs() > f32::EPSILON
491            {
492                self.data_min = min;
493                self.data_max = max;
494            }
495        });
496    }
497
498    fn build_isosurface_ui(&mut self, ui: &mut egui::Ui) {
499        egui::Grid::new(format!("{}_iso_grid", self.name))
500            .num_columns(2)
501            .show(ui, |ui| {
502                ui.label("Color:");
503                let mut color = [
504                    self.isosurface_color.x,
505                    self.isosurface_color.y,
506                    self.isosurface_color.z,
507                ];
508                if ui.color_edit_button_rgb(&mut color).changed() {
509                    self.isosurface_color = Vec3::new(color[0], color[1], color[2]);
510                }
511                ui.end_row();
512
513                ui.label("Level:");
514                let mut level = self.isosurface_level;
515                let (range_min, range_max) = (self.data_min, self.data_max);
516                if ui
517                    .add(egui::Slider::new(&mut level, range_min..=range_max))
518                    .changed()
519                {
520                    self.isosurface_level = level;
521                    self.isosurface_dirty = true;
522                }
523                ui.end_row();
524            });
525
526        // Triangle count
527        if let Some(mesh) = &self.isosurface_mesh_cache {
528            ui.label(format!("{} tris", mesh.indices.len() / 3));
529        }
530
531        // Buttons: equal-width columns, same row
532        let has_cache = self.isosurface_mesh_cache.is_some();
533        if has_cache {
534            ui.columns(2, |cols| {
535                let w = cols[0].available_width();
536                let h = cols[0].spacing().interact_size.y;
537                if cols[0]
538                    .add_sized([w, h], egui::Button::new("Refresh"))
539                    .clicked()
540                {
541                    self.isosurface_dirty = true;
542                    self.isosurface_mesh_cache = None;
543                    self.isosurface_render_data = None;
544                }
545                if cols[1]
546                    .add_sized([w, h], egui::Button::new("Register Mesh"))
547                    .clicked()
548                {
549                    self.register_as_mesh_requested = true;
550                }
551            });
552        } else if ui.button("Refresh").clicked() {
553            self.isosurface_dirty = true;
554            self.isosurface_mesh_cache = None;
555            self.isosurface_render_data = None;
556        }
557    }
558}
559
560impl Quantity for VolumeGridNodeScalarQuantity {
561    fn name(&self) -> &str {
562        &self.name
563    }
564
565    fn structure_name(&self) -> &str {
566        &self.structure_name
567    }
568
569    fn kind(&self) -> QuantityKind {
570        QuantityKind::Scalar
571    }
572
573    fn is_enabled(&self) -> bool {
574        self.enabled
575    }
576
577    fn set_enabled(&mut self, enabled: bool) {
578        self.enabled = enabled;
579    }
580
581    fn data_size(&self) -> usize {
582        self.values.len()
583    }
584
585    fn build_ui(&mut self, _ui: &dyn std::any::Any) {
586        // UI is built via build_egui_ui
587    }
588
589    fn refresh(&mut self) {
590        self.gridcube_render_data = None;
591        self.gridcube_dirty = true;
592        self.isosurface_render_data = None;
593        self.isosurface_mesh_cache = None;
594        self.isosurface_dirty = true;
595        self.pick_uniform_buffer = None;
596        self.pick_bind_group = None;
597    }
598
599    fn clear_gpu_resources(&mut self) {
600        self.gridcube_render_data = None;
601        self.isosurface_render_data = None;
602    }
603
604    fn as_any(&self) -> &dyn std::any::Any {
605        self
606    }
607
608    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
609        self
610    }
611}
612
613/// A scalar quantity defined at grid cells.
614pub struct VolumeGridCellScalarQuantity {
615    name: String,
616    structure_name: String,
617    values: Vec<f32>,
618    cell_dim: UVec3,
619    enabled: bool,
620
621    // Visualization parameters
622    color_map: String,
623    data_min: f32,
624    data_max: f32,
625
626    // Gridcube state (cell scalars only support gridcube, not isosurface)
627    gridcube_render_data: Option<GridcubeRenderData>,
628    gridcube_dirty: bool,
629
630    // Grid geometry (reserved for future gridcube world-space mapping)
631    #[allow(dead_code)]
632    bound_min: Vec3,
633    #[allow(dead_code)]
634    bound_max: Vec3,
635
636    // Pick state
637    pick_uniform_buffer: Option<wgpu::Buffer>,
638    pick_bind_group: Option<wgpu::BindGroup>,
639    global_start: u32,
640}
641
642impl VolumeGridCellScalarQuantity {
643    /// Creates a new cell scalar quantity.
644    pub fn new(
645        name: impl Into<String>,
646        structure_name: impl Into<String>,
647        values: Vec<f32>,
648        cell_dim: UVec3,
649        bound_min: Vec3,
650        bound_max: Vec3,
651    ) -> Self {
652        let (data_min, data_max) = Self::compute_range(&values);
653        Self {
654            name: name.into(),
655            structure_name: structure_name.into(),
656            values,
657            cell_dim,
658            enabled: false,
659            color_map: "viridis".to_string(),
660            data_min,
661            data_max,
662            gridcube_render_data: None,
663            gridcube_dirty: true,
664            bound_min,
665            bound_max,
666            pick_uniform_buffer: None,
667            pick_bind_group: None,
668            global_start: 0,
669        }
670    }
671
672    fn compute_range(values: &[f32]) -> (f32, f32) {
673        let mut min = f32::MAX;
674        let mut max = f32::MIN;
675        for &v in values {
676            if v.is_finite() {
677                min = min.min(v);
678                max = max.max(v);
679            }
680        }
681        if min > max { (0.0, 1.0) } else { (min, max) }
682    }
683
684    /// Returns the values.
685    #[must_use]
686    pub fn values(&self) -> &[f32] {
687        &self.values
688    }
689
690    /// Returns the grid cell dimensions.
691    #[must_use]
692    pub fn cell_dim(&self) -> UVec3 {
693        self.cell_dim
694    }
695
696    /// Gets the value at a 3D index.
697    #[must_use]
698    pub fn get(&self, i: u32, j: u32, k: u32) -> f32 {
699        let idx = i as usize
700            + j as usize * self.cell_dim.x as usize
701            + k as usize * self.cell_dim.x as usize * self.cell_dim.y as usize;
702        self.values.get(idx).copied().unwrap_or(0.0)
703    }
704
705    /// Gets the color map name.
706    #[must_use]
707    pub fn color_map(&self) -> &str {
708        &self.color_map
709    }
710
711    /// Sets the color map name.
712    pub fn set_color_map(&mut self, name: impl Into<String>) -> &mut Self {
713        self.color_map = name.into();
714        self.gridcube_dirty = true;
715        self
716    }
717
718    /// Gets the data range.
719    #[must_use]
720    pub fn data_range(&self) -> (f32, f32) {
721        (self.data_min, self.data_max)
722    }
723
724    /// Sets the data range.
725    pub fn set_data_range(&mut self, min: f32, max: f32) -> &mut Self {
726        self.data_min = min;
727        self.data_max = max;
728        self
729    }
730
731    /// Returns whether the gridcube needs GPU re-init.
732    #[must_use]
733    pub fn gridcube_dirty(&self) -> bool {
734        self.gridcube_dirty
735    }
736
737    /// Returns the gridcube render data.
738    #[must_use]
739    pub fn gridcube_render_data(&self) -> Option<&GridcubeRenderData> {
740        self.gridcube_render_data.as_ref()
741    }
742
743    /// Returns a mutable reference to the gridcube render data.
744    pub fn gridcube_render_data_mut(&mut self) -> Option<&mut GridcubeRenderData> {
745        self.gridcube_render_data.as_mut()
746    }
747
748    /// Sets the gridcube render data.
749    pub fn set_gridcube_render_data(&mut self, data: GridcubeRenderData) {
750        self.gridcube_render_data = Some(data);
751        self.gridcube_dirty = false;
752    }
753
754    // --- Pick resources ---
755
756    /// Initializes pick resources for this cell scalar quantity.
757    pub fn init_pick_resources(
758        &mut self,
759        device: &wgpu::Device,
760        pick_bind_group_layout: &wgpu::BindGroupLayout,
761        camera_buffer: &wgpu::Buffer,
762        global_start: u32,
763    ) {
764        self.global_start = global_start;
765
766        let Some(gridcube_rd) = &self.gridcube_render_data else {
767            return;
768        };
769
770        let uniforms = GridcubePickUniforms {
771            global_start,
772            cube_size_factor: 1.0,
773            ..Default::default()
774        };
775
776        let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
777            label: Some("gridcube cell pick uniforms"),
778            contents: bytemuck::cast_slice(&[uniforms]),
779            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
780        });
781
782        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
783            label: Some("gridcube cell pick bind group"),
784            layout: pick_bind_group_layout,
785            entries: &[
786                wgpu::BindGroupEntry {
787                    binding: 0,
788                    resource: camera_buffer.as_entire_binding(),
789                },
790                wgpu::BindGroupEntry {
791                    binding: 1,
792                    resource: uniform_buffer.as_entire_binding(),
793                },
794                wgpu::BindGroupEntry {
795                    binding: 2,
796                    resource: gridcube_rd.position_buffer.as_entire_binding(),
797                },
798            ],
799        });
800
801        self.pick_uniform_buffer = Some(uniform_buffer);
802        self.pick_bind_group = Some(bind_group);
803    }
804
805    /// Returns the pick bind group, if initialized.
806    #[must_use]
807    pub fn pick_bind_group(&self) -> Option<&wgpu::BindGroup> {
808        self.pick_bind_group.as_ref()
809    }
810
811    /// Updates the pick uniform buffer with current model transform and cube size factor.
812    pub fn update_pick_uniforms(
813        &self,
814        queue: &wgpu::Queue,
815        model: [[f32; 4]; 4],
816        cube_size_factor: f32,
817    ) {
818        if let Some(buffer) = &self.pick_uniform_buffer {
819            let uniforms = GridcubePickUniforms {
820                model,
821                global_start: self.global_start,
822                cube_size_factor,
823                ..Default::default()
824            };
825            queue.write_buffer(buffer, 0, bytemuck::cast_slice(&[uniforms]));
826        }
827    }
828
829    /// Returns the number of pick elements (= number of gridcube instances).
830    #[must_use]
831    pub fn num_pick_elements(&self) -> u32 {
832        self.gridcube_render_data
833            .as_ref()
834            .map_or(0, |rd| rd.num_instances)
835    }
836
837    /// Returns the total vertices for the pick draw call.
838    #[must_use]
839    pub fn pick_total_vertices(&self) -> u32 {
840        self.gridcube_render_data
841            .as_ref()
842            .map_or(0, GridcubeRenderData::total_vertices)
843    }
844
845    /// Builds egui UI for this quantity.
846    pub fn build_egui_ui(&mut self, ui: &mut egui::Ui, colormap_names: &[&str]) {
847        ui.horizontal(|ui| {
848            let mut enabled = self.enabled;
849            if ui.checkbox(&mut enabled, "").changed() {
850                self.enabled = enabled;
851            }
852            ui.label(&self.name);
853            ui.label(format!("[{:.3}, {:.3}]", self.data_min, self.data_max));
854        });
855
856        if self.enabled {
857            let indent_id = egui::Id::new(&self.name).with("cell_scalar_indent");
858            ui.indent(indent_id, |ui| {
859                // Colormap selector
860                if !colormap_names.is_empty() {
861                    ui.horizontal(|ui| {
862                        ui.label("Colormap:");
863                        egui::ComboBox::from_id_salt(format!("{}_colormap", self.name))
864                            .selected_text(&self.color_map)
865                            .show_ui(ui, |ui| {
866                                for &name in colormap_names {
867                                    if ui.selectable_label(self.color_map == name, name).clicked() {
868                                        self.color_map = name.to_string();
869                                        self.gridcube_dirty = true;
870                                    }
871                                }
872                            });
873                    });
874                }
875
876                // Data range
877                ui.horizontal(|ui| {
878                    ui.label("Range:");
879                    let mut min = self.data_min;
880                    let mut max = self.data_max;
881                    let speed = (max - min).abs() * 0.01;
882                    let speed = if speed > 0.0 { speed } else { 0.01 };
883                    ui.add(egui::DragValue::new(&mut min).speed(speed));
884                    ui.label("–");
885                    ui.add(egui::DragValue::new(&mut max).speed(speed));
886                    if (min - self.data_min).abs() > f32::EPSILON
887                        || (max - self.data_max).abs() > f32::EPSILON
888                    {
889                        self.data_min = min;
890                        self.data_max = max;
891                    }
892                });
893            });
894        }
895    }
896}
897
898impl Quantity for VolumeGridCellScalarQuantity {
899    fn name(&self) -> &str {
900        &self.name
901    }
902
903    fn structure_name(&self) -> &str {
904        &self.structure_name
905    }
906
907    fn kind(&self) -> QuantityKind {
908        QuantityKind::Scalar
909    }
910
911    fn is_enabled(&self) -> bool {
912        self.enabled
913    }
914
915    fn set_enabled(&mut self, enabled: bool) {
916        self.enabled = enabled;
917    }
918
919    fn data_size(&self) -> usize {
920        self.values.len()
921    }
922
923    fn build_ui(&mut self, _ui: &dyn std::any::Any) {
924        // UI is built via build_egui_ui
925    }
926
927    fn refresh(&mut self) {
928        self.gridcube_render_data = None;
929        self.gridcube_dirty = true;
930        self.pick_uniform_buffer = None;
931        self.pick_bind_group = None;
932    }
933
934    fn clear_gpu_resources(&mut self) {
935        self.gridcube_render_data = None;
936    }
937
938    fn as_any(&self) -> &dyn std::any::Any {
939        self
940    }
941
942    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
943        self
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    /// Build a non-uniform 3x4x5 grid with field value = `world_x` and verify the
952    /// isosurface lies on the plane `world_x` = level. Regression test for an
953    /// indexing-order mismatch between `VolumeGrid` storage (z-slowest) and
954    /// `marching_cubes` (x-slowest) — equivalent to upstream C++ commit e91a709.
955    /// Also verifies that triangle winding stays consistent after the
956    /// handedness-flipping swizzle.
957    #[test]
958    fn isosurface_x_aligned_non_uniform_grid() {
959        let nx: u32 = 3;
960        let ny: u32 = 4;
961        let nz: u32 = 5;
962        let bound_min = Vec3::new(0.0, 0.0, 0.0);
963        let bound_max = Vec3::new(2.0, 3.0, 4.0); // spacing = (1, 1, 1)
964
965        let mut values = Vec::with_capacity((nx * ny * nz) as usize);
966        for _k in 0..nz {
967            for _j in 0..ny {
968                for i in 0..nx {
969                    values.push(i as f32);
970                }
971            }
972        }
973
974        let mut q = VolumeGridNodeScalarQuantity::new(
975            "test",
976            "grid",
977            values,
978            UVec3::new(nx, ny, nz),
979            bound_min,
980            bound_max,
981        );
982        q.set_isosurface_level(1.5);
983
984        let mesh = q.extract_isosurface();
985        assert!(!mesh.vertices.is_empty(), "isosurface should not be empty");
986        for v in &mesh.vertices {
987            assert!(
988                (v.x - 1.5).abs() < 1e-4,
989                "vertex {v:?} should lie on plane world_x = 1.5"
990            );
991            assert!(
992                v.y >= bound_min.y - 1e-4 && v.y <= bound_max.y + 1e-4,
993                "vertex {v:?} world_y outside [0, 3]"
994            );
995            assert!(
996                v.z >= bound_min.z - 1e-4 && v.z <= bound_max.z + 1e-4,
997                "vertex {v:?} world_z outside [0, 4]"
998            );
999        }
1000
1001        // Field gradient is +X everywhere, so outward normals point in +X.
1002        // Stored normals must agree, AND face normals computed from triangle
1003        // winding must agree — otherwise `front_facing` and registered-mesh
1004        // normals will be inverted.
1005        for n in &mesh.normals {
1006            assert!(
1007                n.x > 0.5,
1008                "stored normal {n:?} should point in +X direction"
1009            );
1010        }
1011        for tri in mesh.indices.chunks_exact(3) {
1012            let v0 = mesh.vertices[tri[0] as usize];
1013            let v1 = mesh.vertices[tri[1] as usize];
1014            let v2 = mesh.vertices[tri[2] as usize];
1015            let geom_normal = (v1 - v0).cross(v2 - v0);
1016            assert!(
1017                geom_normal.x > 0.0,
1018                "triangle winding gives normal {geom_normal:?}, expected +X"
1019            );
1020        }
1021    }
1022
1023    /// Anisotropic-spacing variant: 3x4x5 grid with `bound_max = (20, 3, 4)` so
1024    /// `spacing = (10, 1, 1)`. Field is `world_x / 10` (i.e. integer i), level
1025    /// is 1.5 — vertices should land on plane `world_x = 15`. Catches a bug
1026    /// where someone swizzles indices but uses the wrong `spacing` axis.
1027    #[test]
1028    fn isosurface_anisotropic_spacing_x_axis() {
1029        let nx: u32 = 3;
1030        let ny: u32 = 4;
1031        let nz: u32 = 5;
1032        let bound_min = Vec3::new(0.0, 0.0, 0.0);
1033        let bound_max = Vec3::new(20.0, 3.0, 4.0);
1034
1035        let mut values = Vec::with_capacity((nx * ny * nz) as usize);
1036        for _k in 0..nz {
1037            for _j in 0..ny {
1038                for i in 0..nx {
1039                    values.push(i as f32);
1040                }
1041            }
1042        }
1043
1044        let mut q = VolumeGridNodeScalarQuantity::new(
1045            "test",
1046            "grid",
1047            values,
1048            UVec3::new(nx, ny, nz),
1049            bound_min,
1050            bound_max,
1051        );
1052        q.set_isosurface_level(1.5);
1053
1054        let mesh = q.extract_isosurface();
1055        assert!(!mesh.vertices.is_empty());
1056        for v in &mesh.vertices {
1057            assert!(
1058                (v.x - 15.0).abs() < 1e-3,
1059                "vertex {v:?} should lie on plane world_x = 15.0"
1060            );
1061        }
1062    }
1063
1064    /// Same setup but field value = `world_z`, so the isosurface should lie on a
1065    /// plane `world_z` = level. Catches axis-confusion regressions on Z specifically.
1066    #[test]
1067    fn isosurface_z_aligned_non_uniform_grid() {
1068        let nx: u32 = 3;
1069        let ny: u32 = 4;
1070        let nz: u32 = 5;
1071        let bound_min = Vec3::new(0.0, 0.0, 0.0);
1072        let bound_max = Vec3::new(2.0, 3.0, 4.0);
1073
1074        let mut values = Vec::with_capacity((nx * ny * nz) as usize);
1075        for k in 0..nz {
1076            for _j in 0..ny {
1077                for _i in 0..nx {
1078                    values.push(k as f32);
1079                }
1080            }
1081        }
1082
1083        let mut q = VolumeGridNodeScalarQuantity::new(
1084            "test",
1085            "grid",
1086            values,
1087            UVec3::new(nx, ny, nz),
1088            bound_min,
1089            bound_max,
1090        );
1091        q.set_isosurface_level(2.5);
1092
1093        let mesh = q.extract_isosurface();
1094        assert!(!mesh.vertices.is_empty(), "isosurface should not be empty");
1095        for v in &mesh.vertices {
1096            assert!(
1097                (v.z - 2.5).abs() < 1e-4,
1098                "vertex {v:?} should lie on plane world_z = 2.5"
1099            );
1100        }
1101    }
1102}