runmat_plot/plots/
point_cloud.rs

1//! 3D point cloud visualization
2//!
3//! High-performance GPU-accelerated 3D point cloud rendering for scatter data.
4
5use crate::core::{BoundingBox, DrawCall, Material, PipelineType, RenderData, Vertex};
6use crate::plots::surface::ColorMap;
7use glam::{Vec3, Vec4};
8
9/// Point cloud plot for 3D scatter data
10#[derive(Debug, Clone)]
11pub struct PointCloudPlot {
12    /// Point positions
13    pub positions: Vec<Vec3>,
14
15    /// Per-point data
16    pub values: Option<Vec<f64>>,
17    pub colors: Option<Vec<Vec4>>,
18    pub sizes: Option<Vec<f32>>,
19
20    /// Global styling
21    pub default_color: Vec4,
22    pub default_size: f32,
23    pub colormap: ColorMap,
24
25    /// Point rendering
26    pub point_style: PointStyle,
27    pub size_mode: SizeMode,
28
29    /// Metadata
30    pub label: Option<String>,
31    pub visible: bool,
32
33    /// Generated rendering data (cached)
34    vertices: Option<Vec<Vertex>>,
35    bounds: Option<BoundingBox>,
36    dirty: bool,
37}
38
39/// Point rendering styles
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum PointStyle {
42    /// Circular points
43    Circle,
44    /// Square points
45    Square,
46    /// 3D spheres (higher quality)
47    Sphere,
48    /// Custom mesh
49    Custom,
50}
51
52/// Point size modes
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub enum SizeMode {
55    /// Fixed size for all points
56    Fixed,
57    /// Size proportional to value
58    Proportional,
59    /// Size scaled by distance from camera
60    Perspective,
61}
62
63impl Default for PointStyle {
64    fn default() -> Self {
65        Self::Circle
66    }
67}
68
69impl Default for SizeMode {
70    fn default() -> Self {
71        Self::Fixed
72    }
73}
74
75impl PointCloudPlot {
76    /// Create a new point cloud from positions
77    pub fn new(positions: Vec<Vec3>) -> Self {
78        Self {
79            positions,
80            values: None,
81            colors: None,
82            sizes: None,
83            default_color: Vec4::new(0.0, 0.5, 1.0, 1.0), // Blue
84            default_size: 3.0,
85            colormap: ColorMap::Viridis,
86            point_style: PointStyle::default(),
87            size_mode: SizeMode::default(),
88            label: None,
89            visible: true,
90            vertices: None,
91            bounds: None,
92            dirty: true,
93        }
94    }
95
96    /// Create point cloud with values for color mapping
97    pub fn with_values(mut self, values: Vec<f64>) -> Result<Self, String> {
98        if values.len() != self.positions.len() {
99            return Err(format!(
100                "Values length ({}) must match positions length ({})",
101                values.len(),
102                self.positions.len()
103            ));
104        }
105        self.values = Some(values);
106        self.dirty = true;
107        Ok(self)
108    }
109
110    /// Create point cloud with explicit colors
111    pub fn with_colors(mut self, colors: Vec<Vec4>) -> Result<Self, String> {
112        if colors.len() != self.positions.len() {
113            return Err(format!(
114                "Colors length ({}) must match positions length ({})",
115                colors.len(),
116                self.positions.len()
117            ));
118        }
119        self.colors = Some(colors);
120        self.dirty = true;
121        Ok(self)
122    }
123
124    /// Create point cloud with variable sizes
125    pub fn with_sizes(mut self, sizes: Vec<f32>) -> Result<Self, String> {
126        if sizes.len() != self.positions.len() {
127            return Err(format!(
128                "Sizes length ({}) must match positions length ({})",
129                sizes.len(),
130                self.positions.len()
131            ));
132        }
133        self.sizes = Some(sizes);
134        self.dirty = true;
135        Ok(self)
136    }
137
138    /// Set default color for all points
139    pub fn with_default_color(mut self, color: Vec4) -> Self {
140        self.default_color = color;
141        self.dirty = true;
142        self
143    }
144
145    /// Set default size for all points
146    pub fn with_default_size(mut self, size: f32) -> Self {
147        self.default_size = size.max(0.1);
148        self.dirty = true;
149        self
150    }
151
152    /// Set colormap for value-based coloring
153    pub fn with_colormap(mut self, colormap: ColorMap) -> Self {
154        self.colormap = colormap;
155        self.dirty = true;
156        self
157    }
158
159    /// Set point rendering style
160    pub fn with_point_style(mut self, style: PointStyle) -> Self {
161        self.point_style = style;
162        self.dirty = true;
163        self
164    }
165
166    /// Set size mode
167    pub fn with_size_mode(mut self, mode: SizeMode) -> Self {
168        self.size_mode = mode;
169        self.dirty = true;
170        self
171    }
172
173    /// Set plot label for legends
174    pub fn with_label<S: Into<String>>(mut self, label: S) -> Self {
175        self.label = Some(label.into());
176        self
177    }
178
179    /// Get the number of points
180    pub fn len(&self) -> usize {
181        self.positions.len()
182    }
183
184    /// Check if the point cloud has no data
185    pub fn is_empty(&self) -> bool {
186        self.positions.is_empty()
187    }
188
189    /// Generate vertices for GPU rendering
190    pub fn generate_vertices(&mut self) -> &Vec<Vertex> {
191        if self.dirty || self.vertices.is_none() {
192            self.compute_vertices();
193            self.dirty = false;
194        }
195        self.vertices.as_ref().unwrap()
196    }
197
198    /// Get the bounding box of the point cloud
199    pub fn bounds(&mut self) -> BoundingBox {
200        if self.dirty || self.bounds.is_none() {
201            self.compute_bounds();
202        }
203        self.bounds.unwrap()
204    }
205
206    /// Compute vertices
207    fn compute_vertices(&mut self) {
208        let mut vertices = Vec::with_capacity(self.positions.len());
209
210        // Compute value range for color mapping
211        let (value_min, value_max) = if let Some(ref values) = self.values {
212            let min = values.iter().copied().fold(f64::INFINITY, f64::min);
213            let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
214            (min, max)
215        } else {
216            (0.0, 1.0)
217        };
218
219        for (i, &position) in self.positions.iter().enumerate() {
220            // Determine color
221            let color = if let Some(ref colors) = self.colors {
222                colors[i]
223            } else if let Some(ref values) = self.values {
224                let normalized = if value_max > value_min {
225                    ((values[i] - value_min) / (value_max - value_min)).clamp(0.0, 1.0)
226                } else {
227                    0.5
228                };
229                let rgb = self.colormap.map_value(normalized as f32);
230                Vec4::new(rgb.x, rgb.y, rgb.z, self.default_color.w)
231            } else {
232                self.default_color
233            };
234
235            // Determine size (encoded in normal.x for now)
236            let size = if let Some(ref sizes) = self.sizes {
237                sizes[i]
238            } else {
239                match self.size_mode {
240                    SizeMode::Fixed => self.default_size,
241                    SizeMode::Proportional => {
242                        if let Some(ref values) = self.values {
243                            let normalized = if value_max > value_min {
244                                ((values[i] - value_min) / (value_max - value_min)).clamp(0.0, 1.0)
245                            } else {
246                                0.5
247                            };
248                            self.default_size * (0.5 + normalized as f32)
249                        } else {
250                            self.default_size
251                        }
252                    }
253                    SizeMode::Perspective => self.default_size, // Camera distance handled in shader
254                }
255            };
256
257            vertices.push(Vertex {
258                position: position.to_array(),
259                color: color.to_array(),
260                normal: [size, 0.0, 0.0],    // Store size in normal.x
261                tex_coords: [i as f32, 0.0], // Point index for potential lookup
262            });
263        }
264
265        self.vertices = Some(vertices);
266    }
267
268    /// Compute bounding box
269    fn compute_bounds(&mut self) {
270        if self.positions.is_empty() {
271            self.bounds = Some(BoundingBox::new(Vec3::ZERO, Vec3::ZERO));
272            return;
273        }
274
275        let mut min = self.positions[0];
276        let mut max = self.positions[0];
277
278        for &pos in &self.positions[1..] {
279            min = min.min(pos);
280            max = max.max(pos);
281        }
282
283        // Expand bounds slightly to account for point size
284        let expansion = Vec3::splat(self.default_size * 0.01);
285        min -= expansion;
286        max += expansion;
287
288        self.bounds = Some(BoundingBox::new(min, max));
289    }
290
291    /// Generate complete render data for the graphics pipeline
292    pub fn render_data(&mut self) -> RenderData {
293        println!(
294            "DEBUG: PointCloudPlot::render_data() called with {} points",
295            self.positions.len()
296        );
297
298        let vertices = self.generate_vertices().clone();
299        let vertex_count = vertices.len();
300
301        println!("DEBUG: Generated {vertex_count} vertices for point cloud");
302
303        let material = Material {
304            albedo: self.default_color,
305            ..Default::default()
306        };
307
308        let draw_call = DrawCall {
309            vertex_offset: 0,
310            vertex_count,
311            index_offset: None,
312            index_count: None,
313            instance_count: 1,
314        };
315
316        println!("DEBUG: PointCloudPlot render_data completed successfully");
317
318        RenderData {
319            pipeline_type: PipelineType::Points,
320            vertices,
321            indices: None,
322            material,
323            draw_calls: vec![draw_call],
324        }
325    }
326
327    /// Get plot statistics for debugging
328    pub fn statistics(&self) -> PointCloudStatistics {
329        PointCloudStatistics {
330            point_count: self.positions.len(),
331            has_values: self.values.is_some(),
332            has_colors: self.colors.is_some(),
333            has_sizes: self.sizes.is_some(),
334            memory_usage: self.estimated_memory_usage(),
335        }
336    }
337
338    /// Estimate memory usage in bytes
339    pub fn estimated_memory_usage(&self) -> usize {
340        let positions_size = self.positions.len() * std::mem::size_of::<Vec3>();
341        let values_size = self
342            .values
343            .as_ref()
344            .map_or(0, |v| v.len() * std::mem::size_of::<f64>());
345        let colors_size = self
346            .colors
347            .as_ref()
348            .map_or(0, |c| c.len() * std::mem::size_of::<Vec4>());
349        let sizes_size = self
350            .sizes
351            .as_ref()
352            .map_or(0, |s| s.len() * std::mem::size_of::<f32>());
353        let vertices_size = self
354            .vertices
355            .as_ref()
356            .map_or(0, |v| v.len() * std::mem::size_of::<Vertex>());
357
358        positions_size + values_size + colors_size + sizes_size + vertices_size
359    }
360}
361
362/// Point cloud performance and data statistics
363#[derive(Debug, Clone)]
364pub struct PointCloudStatistics {
365    pub point_count: usize,
366    pub has_values: bool,
367    pub has_colors: bool,
368    pub has_sizes: bool,
369    pub memory_usage: usize,
370}
371
372/// MATLAB-compatible point cloud creation utilities
373pub mod matlab_compat {
374    use super::*;
375
376    /// Create a 3D scatter plot (equivalent to MATLAB's `scatter3(x, y, z)`)
377    pub fn scatter3(x: Vec<f64>, y: Vec<f64>, z: Vec<f64>) -> Result<PointCloudPlot, String> {
378        if x.len() != y.len() || y.len() != z.len() {
379            return Err("X, Y, and Z vectors must have the same length".to_string());
380        }
381
382        let positions: Vec<Vec3> = x
383            .into_iter()
384            .zip(y)
385            .zip(z)
386            .map(|((x, y), z)| Vec3::new(x as f32, y as f32, z as f32))
387            .collect();
388
389        Ok(PointCloudPlot::new(positions))
390    }
391
392    /// Create scatter3 with colors
393    pub fn scatter3_with_colors(
394        x: Vec<f64>,
395        y: Vec<f64>,
396        z: Vec<f64>,
397        colors: Vec<Vec4>,
398    ) -> Result<PointCloudPlot, String> {
399        scatter3(x, y, z)?.with_colors(colors)
400    }
401
402    /// Create scatter3 with values for color mapping
403    pub fn scatter3_with_values(
404        x: Vec<f64>,
405        y: Vec<f64>,
406        z: Vec<f64>,
407        values: Vec<f64>,
408        colormap: &str,
409    ) -> Result<PointCloudPlot, String> {
410        let cmap = match colormap {
411            "jet" => ColorMap::Jet,
412            "hot" => ColorMap::Hot,
413            "cool" => ColorMap::Cool,
414            "viridis" => ColorMap::Viridis,
415            "plasma" => ColorMap::Plasma,
416            "gray" | "grey" => ColorMap::Gray,
417            _ => return Err(format!("Unknown colormap: {colormap}")),
418        };
419
420        Ok(scatter3(x, y, z)?.with_values(values)?.with_colormap(cmap))
421    }
422
423    /// Create point cloud from matrix data
424    pub fn point_cloud_from_matrix(points: Vec<Vec<f64>>) -> Result<PointCloudPlot, String> {
425        if points.is_empty() {
426            return Err("Points matrix cannot be empty".to_string());
427        }
428
429        let dim = points[0].len();
430        if dim < 3 {
431            return Err("Points must have at least 3 dimensions (X, Y, Z)".to_string());
432        }
433
434        let positions: Vec<Vec3> = points
435            .into_iter()
436            .map(|point| {
437                if point.len() != dim {
438                    Vec3::ZERO // Handle inconsistent dimensions gracefully
439                } else {
440                    Vec3::new(point[0] as f32, point[1] as f32, point[2] as f32)
441                }
442            })
443            .collect();
444
445        Ok(PointCloudPlot::new(positions))
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_point_cloud_creation() {
455        let positions = vec![
456            Vec3::new(0.0, 0.0, 0.0),
457            Vec3::new(1.0, 1.0, 1.0),
458            Vec3::new(2.0, 2.0, 2.0),
459        ];
460
461        let cloud = PointCloudPlot::new(positions.clone());
462
463        assert_eq!(cloud.positions, positions);
464        assert_eq!(cloud.len(), 3);
465        assert!(!cloud.is_empty());
466        assert!(cloud.visible);
467        assert!(cloud.values.is_none());
468        assert!(cloud.colors.is_none());
469        assert!(cloud.sizes.is_none());
470    }
471
472    #[test]
473    fn test_point_cloud_with_values() {
474        let positions = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0)];
475        let values = vec![0.5, 1.5];
476
477        let cloud = PointCloudPlot::new(positions)
478            .with_values(values.clone())
479            .unwrap();
480
481        assert_eq!(cloud.values, Some(values));
482    }
483
484    #[test]
485    fn test_point_cloud_with_colors() {
486        let positions = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0)];
487        let colors = vec![Vec4::new(1.0, 0.0, 0.0, 1.0), Vec4::new(0.0, 1.0, 0.0, 1.0)];
488
489        let cloud = PointCloudPlot::new(positions)
490            .with_colors(colors.clone())
491            .unwrap();
492
493        assert_eq!(cloud.colors, Some(colors));
494    }
495
496    #[test]
497    fn test_point_cloud_validation() {
498        let positions = vec![Vec3::new(0.0, 0.0, 0.0)];
499        let wrong_values = vec![1.0, 2.0]; // Length mismatch
500
501        let result = PointCloudPlot::new(positions).with_values(wrong_values);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_point_cloud_styling() {
507        let positions = vec![Vec3::new(0.0, 0.0, 0.0)];
508
509        let cloud = PointCloudPlot::new(positions)
510            .with_default_color(Vec4::new(1.0, 0.0, 0.0, 1.0))
511            .with_default_size(5.0)
512            .with_colormap(ColorMap::Hot)
513            .with_point_style(PointStyle::Sphere)
514            .with_size_mode(SizeMode::Proportional)
515            .with_label("Test Cloud");
516
517        assert_eq!(cloud.default_color, Vec4::new(1.0, 0.0, 0.0, 1.0));
518        assert_eq!(cloud.default_size, 5.0);
519        assert_eq!(cloud.colormap, ColorMap::Hot);
520        assert_eq!(cloud.point_style, PointStyle::Sphere);
521        assert_eq!(cloud.size_mode, SizeMode::Proportional);
522        assert_eq!(cloud.label, Some("Test Cloud".to_string()));
523    }
524
525    #[test]
526    fn test_point_cloud_bounds() {
527        let positions = vec![
528            Vec3::new(-1.0, -2.0, -3.0),
529            Vec3::new(1.0, 2.0, 3.0),
530            Vec3::new(0.0, 0.0, 0.0),
531        ];
532
533        let mut cloud = PointCloudPlot::new(positions);
534        let bounds = cloud.bounds();
535
536        // Should be slightly expanded from actual bounds
537        assert!(bounds.min.x <= -1.0);
538        assert!(bounds.min.y <= -2.0);
539        assert!(bounds.min.z <= -3.0);
540        assert!(bounds.max.x >= 1.0);
541        assert!(bounds.max.y >= 2.0);
542        assert!(bounds.max.z >= 3.0);
543    }
544
545    #[test]
546    fn test_point_cloud_vertex_generation() {
547        let positions = vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0)];
548
549        let mut cloud = PointCloudPlot::new(positions);
550        let vertices = cloud.generate_vertices();
551
552        assert_eq!(vertices.len(), 2);
553        assert_eq!(vertices[0].position, [0.0, 0.0, 0.0]);
554        assert_eq!(vertices[1].position, [1.0, 1.0, 1.0]);
555    }
556
557    #[test]
558    fn test_point_cloud_statistics() {
559        let positions = vec![
560            Vec3::new(0.0, 0.0, 0.0),
561            Vec3::new(1.0, 1.0, 1.0),
562            Vec3::new(2.0, 2.0, 2.0),
563        ];
564        let values = vec![0.0, 1.0, 2.0];
565
566        let cloud = PointCloudPlot::new(positions).with_values(values).unwrap();
567
568        let stats = cloud.statistics();
569
570        assert_eq!(stats.point_count, 3);
571        assert!(stats.has_values);
572        assert!(!stats.has_colors);
573        assert!(!stats.has_sizes);
574        assert!(stats.memory_usage > 0);
575    }
576
577    #[test]
578    fn test_matlab_compat() {
579        use super::matlab_compat::*;
580
581        let x = vec![0.0, 1.0, 2.0];
582        let y = vec![0.0, 1.0, 2.0];
583        let z = vec![0.0, 1.0, 2.0];
584
585        let cloud = scatter3(x.clone(), y.clone(), z.clone()).unwrap();
586        assert_eq!(cloud.len(), 3);
587
588        let values = vec![0.0, 0.5, 1.0];
589        let cloud_with_values = scatter3_with_values(x, y, z, values, "viridis").unwrap();
590        assert!(cloud_with_values.values.is_some());
591        assert_eq!(cloud_with_values.colormap, ColorMap::Viridis);
592    }
593}