Skip to main content

runmat_plot/plots/
errorbar.rs

1//! Error bar plot implementation.
2
3use crate::core::{
4    marker_shape_code, vertex_utils, AlphaMode, BoundingBox, DrawCall, GpuPackContext,
5    GpuVertexBuffer, Material, PipelineType, RenderData, Vertex,
6};
7use crate::gpu::errorbar::{ErrorBarGpuInputs, ErrorBarGpuParams};
8use crate::plots::line::{LineMarkerAppearance, LineStyle};
9use glam::{Vec3, Vec4};
10use log::warn;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ErrorBarOrientation {
14    Vertical,
15    Horizontal,
16    Both,
17}
18
19#[derive(Debug, Clone)]
20pub struct ErrorBar {
21    pub x: Vec<f64>,
22    pub y: Vec<f64>,
23    pub y_neg: Vec<f64>,
24    pub y_pos: Vec<f64>,
25    pub x_neg: Vec<f64>,
26    pub x_pos: Vec<f64>,
27    pub orientation: ErrorBarOrientation,
28
29    pub color: Vec4,
30    pub line_width: f32,
31    pub line_style: LineStyle,
32    pub cap_size: f32,
33    pub marker: Option<LineMarkerAppearance>,
34
35    pub label: Option<String>,
36    pub visible: bool,
37
38    vertices: Option<Vec<Vertex>>,
39    bounds: Option<BoundingBox>,
40    dirty: bool,
41    gpu_vertices: Option<GpuVertexBuffer>,
42    gpu_vertex_count: Option<usize>,
43    gpu_bounds: Option<BoundingBox>,
44    gpu_inputs: Option<ErrorBarGpuInputs>,
45    marker_vertices: Option<Vec<Vertex>>,
46    marker_gpu_vertices: Option<GpuVertexBuffer>,
47    marker_dirty: bool,
48}
49
50impl ErrorBar {
51    pub fn new_vertical(
52        x: Vec<f64>,
53        y: Vec<f64>,
54        y_neg: Vec<f64>,
55        y_pos: Vec<f64>,
56    ) -> Result<Self, String> {
57        let n = x.len();
58        if n == 0 || y.len() != n || y_neg.len() != n || y_pos.len() != n {
59            return Err("errorbar: input vectors must have equal non-zero length".to_string());
60        }
61        Ok(Self {
62            x,
63            y,
64            y_neg,
65            y_pos,
66            x_neg: vec![0.0; n],
67            x_pos: vec![0.0; n],
68            orientation: ErrorBarOrientation::Vertical,
69            color: Vec4::new(0.0, 0.0, 0.0, 1.0),
70            line_width: 1.0,
71            line_style: LineStyle::Solid,
72            cap_size: 6.0,
73            marker: Some(LineMarkerAppearance {
74                kind: crate::plots::scatter::MarkerStyle::Circle,
75                size: 6.0,
76                edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
77                face_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
78                filled: false,
79            }),
80            label: None,
81            visible: true,
82            vertices: None,
83            bounds: None,
84            dirty: true,
85            gpu_vertices: None,
86            gpu_vertex_count: None,
87            gpu_bounds: None,
88            gpu_inputs: None,
89            marker_vertices: None,
90            marker_gpu_vertices: None,
91            marker_dirty: true,
92        })
93    }
94
95    pub fn new_both(
96        x: Vec<f64>,
97        y: Vec<f64>,
98        x_neg: Vec<f64>,
99        x_pos: Vec<f64>,
100        y_neg: Vec<f64>,
101        y_pos: Vec<f64>,
102    ) -> Result<Self, String> {
103        let n = x.len();
104        if n == 0
105            || y.len() != n
106            || x_neg.len() != n
107            || x_pos.len() != n
108            || y_neg.len() != n
109            || y_pos.len() != n
110        {
111            return Err("errorbar: input vectors must have equal non-zero length".to_string());
112        }
113        let mut plot = Self::new_vertical(x, y, y_neg, y_pos)?;
114        plot.x_neg = x_neg;
115        plot.x_pos = x_pos;
116        plot.orientation = ErrorBarOrientation::Both;
117        Ok(plot)
118    }
119
120    pub fn with_style(
121        mut self,
122        color: Vec4,
123        line_width: f32,
124        line_style: LineStyle,
125        cap_size: f32,
126    ) -> Self {
127        self.color = color;
128        self.line_width = line_width.max(0.5);
129        self.line_style = line_style;
130        self.cap_size = cap_size.max(0.0);
131        self.dirty = true;
132        self.gpu_vertices = None;
133        self.gpu_vertex_count = None;
134        self.gpu_bounds = None;
135        self.gpu_inputs = None;
136        self.marker_gpu_vertices = None;
137        self.marker_dirty = true;
138        self
139    }
140
141    pub fn with_label<S: Into<String>>(mut self, label: S) -> Self {
142        self.label = Some(label.into());
143        self
144    }
145
146    pub fn set_visible(&mut self, visible: bool) {
147        self.visible = visible;
148    }
149
150    pub fn set_marker(&mut self, marker: Option<LineMarkerAppearance>) {
151        self.marker = marker;
152        self.marker_dirty = true;
153        if self.marker.is_none() {
154            self.marker_vertices = None;
155            self.marker_gpu_vertices = None;
156        }
157    }
158
159    pub fn set_marker_gpu_vertices(&mut self, buffer: Option<GpuVertexBuffer>) {
160        let has_gpu = buffer.is_some();
161        self.marker_gpu_vertices = buffer;
162        if has_gpu {
163            self.marker_vertices = None;
164        }
165    }
166
167    #[allow(clippy::too_many_arguments)]
168    pub fn from_gpu_buffer(
169        color: Vec4,
170        line_width: f32,
171        line_style: LineStyle,
172        cap_size: f32,
173        orientation: ErrorBarOrientation,
174        buffer: GpuVertexBuffer,
175        vertex_count: usize,
176        bounds: BoundingBox,
177    ) -> Self {
178        Self {
179            x: Vec::new(),
180            y: Vec::new(),
181            y_neg: Vec::new(),
182            y_pos: Vec::new(),
183            x_neg: Vec::new(),
184            x_pos: Vec::new(),
185            orientation,
186            color,
187            line_width,
188            line_style,
189            cap_size,
190            marker: None,
191            label: None,
192            visible: true,
193            vertices: None,
194            bounds: None,
195            dirty: false,
196            gpu_vertices: Some(buffer),
197            gpu_vertex_count: Some(vertex_count),
198            gpu_bounds: Some(bounds),
199            gpu_inputs: None,
200            marker_vertices: None,
201            marker_gpu_vertices: None,
202            marker_dirty: true,
203        }
204    }
205
206    #[allow(clippy::too_many_arguments)]
207    pub fn from_gpu_inputs(
208        color: Vec4,
209        line_width: f32,
210        line_style: LineStyle,
211        cap_size: f32,
212        orientation: ErrorBarOrientation,
213        inputs: ErrorBarGpuInputs,
214        bounds: BoundingBox,
215    ) -> Self {
216        Self {
217            x: Vec::new(),
218            y: Vec::new(),
219            y_neg: Vec::new(),
220            y_pos: Vec::new(),
221            x_neg: Vec::new(),
222            x_pos: Vec::new(),
223            orientation,
224            color,
225            line_width,
226            line_style,
227            cap_size,
228            marker: None,
229            label: None,
230            visible: true,
231            vertices: None,
232            bounds: None,
233            dirty: false,
234            gpu_vertices: None,
235            gpu_vertex_count: None,
236            gpu_bounds: Some(bounds),
237            gpu_inputs: Some(inputs),
238            marker_vertices: None,
239            marker_gpu_vertices: None,
240            marker_dirty: true,
241        }
242    }
243
244    fn cap_half_width_data(&self, bounds: &BoundingBox, viewport_px: Option<(u32, u32)>) -> f32 {
245        match viewport_px {
246            Some(vp) => {
247                let data_per_px = crate::core::data_units_per_px(bounds, vp);
248                self.cap_size.max(0.0) * 0.5 * data_per_px
249            }
250            None => self.cap_size.max(0.0) * 0.5,
251        }
252    }
253
254    fn generate_line_vertices_with_cap_half(&self, cap_half_data: f32) -> Vec<Vertex> {
255        let mut verts = Vec::new();
256        for i in 0..self.x.len() {
257            let xi = self.x[i] as f32;
258            let yi = self.y[i] as f32;
259            if !xi.is_finite() || !yi.is_finite() {
260                continue;
261            }
262            if matches!(
263                self.orientation,
264                ErrorBarOrientation::Vertical | ErrorBarOrientation::Both
265            ) {
266                let y0 = (self.y[i] - self.y_neg[i]) as f32;
267                let y1 = (self.y[i] + self.y_pos[i]) as f32;
268                if y0.is_finite() && y1.is_finite() && include_segment(i, self.line_style) {
269                    verts.push(Vertex::new(Vec3::new(xi, y0, 0.0), self.color));
270                    verts.push(Vertex::new(Vec3::new(xi, y1, 0.0), self.color));
271                    if cap_half_data > 0.0 {
272                        verts.push(Vertex::new(
273                            Vec3::new(xi - cap_half_data, y0, 0.0),
274                            self.color,
275                        ));
276                        verts.push(Vertex::new(
277                            Vec3::new(xi + cap_half_data, y0, 0.0),
278                            self.color,
279                        ));
280                        verts.push(Vertex::new(
281                            Vec3::new(xi - cap_half_data, y1, 0.0),
282                            self.color,
283                        ));
284                        verts.push(Vertex::new(
285                            Vec3::new(xi + cap_half_data, y1, 0.0),
286                            self.color,
287                        ));
288                    }
289                }
290            }
291            if matches!(
292                self.orientation,
293                ErrorBarOrientation::Horizontal | ErrorBarOrientation::Both
294            ) {
295                let x0 = (self.x[i] - self.x_neg[i]) as f32;
296                let x1 = (self.x[i] + self.x_pos[i]) as f32;
297                if x0.is_finite() && x1.is_finite() && include_segment(i, self.line_style) {
298                    verts.push(Vertex::new(Vec3::new(x0, yi, 0.0), self.color));
299                    verts.push(Vertex::new(Vec3::new(x1, yi, 0.0), self.color));
300                    if cap_half_data > 0.0 {
301                        verts.push(Vertex::new(
302                            Vec3::new(x0, yi - cap_half_data, 0.0),
303                            self.color,
304                        ));
305                        verts.push(Vertex::new(
306                            Vec3::new(x0, yi + cap_half_data, 0.0),
307                            self.color,
308                        ));
309                        verts.push(Vertex::new(
310                            Vec3::new(x1, yi - cap_half_data, 0.0),
311                            self.color,
312                        ));
313                        verts.push(Vertex::new(
314                            Vec3::new(x1, yi + cap_half_data, 0.0),
315                            self.color,
316                        ));
317                    }
318                }
319            }
320        }
321        verts
322    }
323
324    pub fn generate_vertices(&mut self) -> &Vec<Vertex> {
325        if self.gpu_vertices.is_some() {
326            if self.vertices.is_none() {
327                self.vertices = Some(Vec::new());
328            }
329            return self.vertices.as_ref().unwrap();
330        }
331        if self.dirty || self.vertices.is_none() {
332            let bounds = self.bounds();
333            let cap_half_data = self.cap_half_width_data(&bounds, None);
334            let verts = self.generate_line_vertices_with_cap_half(cap_half_data);
335            self.vertices = Some(verts);
336            self.dirty = false;
337        }
338        self.vertices.as_ref().unwrap()
339    }
340
341    pub fn marker_render_data(&mut self) -> Option<RenderData> {
342        let marker = self.marker.clone()?;
343        if let Some(gpu_vertices) = self.marker_gpu_vertices.clone() {
344            let vertex_count = gpu_vertices.vertex_count;
345            if vertex_count == 0 {
346                return None;
347            }
348            return Some(RenderData {
349                pipeline_type: PipelineType::Points,
350                vertices: Vec::new(),
351                indices: None,
352                gpu_vertices: Some(gpu_vertices),
353                bounds: None,
354                material: Material {
355                    albedo: marker.face_color,
356                    emissive: marker.edge_color,
357                    roughness: 1.0,
358                    metallic: marker_shape_code(marker.kind) as f32,
359                    alpha_mode: if marker.face_color.w < 0.999 {
360                        AlphaMode::Blend
361                    } else {
362                        AlphaMode::Opaque
363                    },
364                    ..Default::default()
365                },
366                draw_calls: vec![DrawCall {
367                    vertex_offset: 0,
368                    vertex_count,
369                    index_offset: None,
370                    index_count: None,
371                    instance_count: 1,
372                }],
373                image: None,
374            });
375        }
376        if self.marker_dirty || self.marker_vertices.is_none() {
377            let mut vertices = Vec::new();
378            for (&x, &y) in self.x.iter().zip(self.y.iter()) {
379                let x = x as f32;
380                let y = y as f32;
381                if !x.is_finite() || !y.is_finite() {
382                    continue;
383                }
384                vertices.push(Vertex {
385                    position: [x, y, 0.0],
386                    color: marker.face_color.to_array(),
387                    normal: [0.0, 0.0, marker.size],
388                    tex_coords: [0.0, 0.0],
389                });
390            }
391            self.marker_vertices = Some(vertices);
392            self.marker_dirty = false;
393        }
394        let vertices = self.marker_vertices.as_ref()?;
395        if vertices.is_empty() {
396            return None;
397        }
398        Some(RenderData {
399            pipeline_type: PipelineType::Points,
400            vertices: vertices.clone(),
401            indices: None,
402            gpu_vertices: None,
403            bounds: None,
404            material: Material {
405                albedo: marker.face_color,
406                emissive: marker.edge_color,
407                roughness: 1.0,
408                metallic: marker_shape_code(marker.kind) as f32,
409                alpha_mode: if marker.face_color.w < 0.999 {
410                    AlphaMode::Blend
411                } else {
412                    AlphaMode::Opaque
413                },
414                ..Default::default()
415            },
416            draw_calls: vec![DrawCall {
417                vertex_offset: 0,
418                vertex_count: vertices.len(),
419                index_offset: None,
420                index_count: None,
421                instance_count: 1,
422            }],
423            image: None,
424        })
425    }
426
427    pub fn bounds(&mut self) -> BoundingBox {
428        if let Some(bounds) = self.gpu_bounds {
429            return bounds;
430        }
431        if self.dirty || self.bounds.is_none() {
432            let mut min = Vec3::new(f32::INFINITY, f32::INFINITY, 0.0);
433            let mut max = Vec3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, 0.0);
434            for i in 0..self.x.len() {
435                let xi = self.x[i] as f32;
436                let yi = self.y[i] as f32;
437                if !xi.is_finite() || !yi.is_finite() {
438                    continue;
439                }
440                min.x = min
441                    .x
442                    .min(xi - self.x_neg.get(i).copied().unwrap_or(0.0) as f32);
443                max.x = max
444                    .x
445                    .max(xi + self.x_pos.get(i).copied().unwrap_or(0.0) as f32);
446                min.y = min
447                    .y
448                    .min(yi - self.y_neg.get(i).copied().unwrap_or(0.0) as f32);
449                max.y = max
450                    .y
451                    .max(yi + self.y_pos.get(i).copied().unwrap_or(0.0) as f32);
452            }
453            if self.cap_size > 0.0 {
454                let range_bounds = BoundingBox::new(min, max);
455                let half = self.cap_half_width_data(&range_bounds, None);
456                min.x -= half;
457                max.x += half;
458                min.y -= half;
459                max.y += half;
460            }
461            if !min.x.is_finite() {
462                min = Vec3::ZERO;
463                max = Vec3::ZERO;
464            }
465            self.bounds = Some(BoundingBox::new(min, max));
466        }
467        self.bounds.unwrap()
468    }
469
470    pub fn render_data(&mut self) -> RenderData {
471        let bounds = self.bounds();
472        let (vertices, vertex_count, gpu_vertices) = if self.gpu_vertices.is_some() {
473            (
474                Vec::new(),
475                self.gpu_vertex_count.unwrap_or(0),
476                self.gpu_vertices.clone(),
477            )
478        } else {
479            let vertices = self.generate_vertices().clone();
480            let count = vertices.len();
481            (vertices, count, None)
482        };
483        RenderData {
484            pipeline_type: PipelineType::Lines,
485            vertices,
486            indices: None,
487            gpu_vertices,
488            bounds: Some(bounds),
489            material: Material {
490                albedo: self.color,
491                roughness: self.line_width,
492                ..Default::default()
493            },
494            draw_calls: vec![DrawCall {
495                vertex_offset: 0,
496                vertex_count,
497                index_offset: None,
498                index_count: None,
499                instance_count: 1,
500            }],
501            image: None,
502        }
503    }
504
505    fn orientation_code(&self) -> u32 {
506        match self.orientation {
507            ErrorBarOrientation::Vertical => 0,
508            ErrorBarOrientation::Horizontal => 1,
509            ErrorBarOrientation::Both => 2,
510        }
511    }
512
513    fn pack_gpu_vertices_if_needed(
514        &mut self,
515        gpu: &GpuPackContext<'_>,
516        viewport_px: (u32, u32),
517    ) -> Result<(), String> {
518        if self.gpu_vertices.is_some() {
519            return Ok(());
520        }
521        let Some(inputs) = self.gpu_inputs.as_ref() else {
522            return Ok(());
523        };
524        let bounds = self
525            .gpu_bounds
526            .ok_or_else(|| "errorbar: missing GPU bounds".to_string())?;
527        let data_per_px = crate::core::data_units_per_px(&bounds, viewport_px);
528        let cap_size_data = self.cap_size.max(0.0) * data_per_px;
529        let orientation = self.orientation_code();
530        let packed = crate::gpu::errorbar::pack_vertical_vertices(
531            gpu.device,
532            gpu.queue,
533            inputs,
534            &ErrorBarGpuParams {
535                color: self.color,
536                cap_size_data,
537                line_style: self.line_style,
538                orientation,
539            },
540        )?;
541        let segments_per_point = match orientation {
542            0 | 1 => 6usize,
543            _ => 12usize,
544        };
545        self.gpu_vertex_count = Some(inputs.len as usize * segments_per_point);
546        self.gpu_vertices = Some(packed);
547        Ok(())
548    }
549
550    pub fn render_data_with_viewport_gpu(
551        &mut self,
552        viewport_px: Option<(u32, u32)>,
553        gpu: Option<&GpuPackContext<'_>>,
554    ) -> RenderData {
555        if self.gpu_inputs.is_some() && self.gpu_vertices.is_none() {
556            if let (Some(gpu), Some(vp)) = (gpu, viewport_px) {
557                if let Err(err) = self.pack_gpu_vertices_if_needed(gpu, vp) {
558                    warn!("errorbar gpu pack failed: {err}");
559                }
560            }
561        }
562        self.render_data_with_viewport(viewport_px)
563    }
564
565    pub fn render_data_with_viewport(&mut self, viewport_px: Option<(u32, u32)>) -> RenderData {
566        if self.gpu_vertices.is_some() {
567            return self.render_data();
568        }
569
570        let bounds = self.bounds();
571        let (vertices, vertex_count, pipeline_type) = if self.line_width > 1.0 {
572            let Some(viewport_px) = viewport_px else {
573                return self.render_data();
574            };
575            let data_per_px = crate::core::data_units_per_px(&bounds, viewport_px);
576            let width_data = self.line_width.max(0.1) * data_per_px;
577            let cap_half_data = self.cap_half_width_data(&bounds, Some(viewport_px));
578            let verts = self.generate_line_vertices_with_cap_half(cap_half_data);
579            let mut thick = Vec::new();
580            for segment in verts.chunks_exact(2) {
581                let x = [segment[0].position[0] as f64, segment[1].position[0] as f64];
582                let y = [segment[0].position[1] as f64, segment[1].position[1] as f64];
583                let color = Vec4::from_array(segment[0].color);
584                thick.extend(vertex_utils::create_thick_polyline(
585                    &x, &y, color, width_data,
586                ));
587            }
588            let count = thick.len();
589            (thick, count, PipelineType::Triangles)
590        } else {
591            let verts = self.generate_vertices().clone();
592            let count = verts.len();
593            (verts, count, PipelineType::Lines)
594        };
595        RenderData {
596            pipeline_type,
597            vertices,
598            indices: None,
599            gpu_vertices: None,
600            bounds: Some(bounds),
601            material: Material {
602                albedo: self.color,
603                roughness: self.line_width.max(0.0),
604                ..Default::default()
605            },
606            draw_calls: vec![DrawCall {
607                vertex_offset: 0,
608                vertex_count,
609                index_offset: None,
610                index_count: None,
611                instance_count: 1,
612            }],
613            image: None,
614        }
615    }
616
617    pub fn estimated_memory_usage(&self) -> usize {
618        self.vertices
619            .as_ref()
620            .map_or(0, |v| v.len() * std::mem::size_of::<Vertex>())
621            + self
622                .marker_vertices
623                .as_ref()
624                .map_or(0, |v| v.len() * std::mem::size_of::<Vertex>())
625    }
626}
627
628fn include_segment(index: usize, style: LineStyle) -> bool {
629    match style {
630        LineStyle::Solid => true,
631        LineStyle::Dashed => (index % 4) < 2,
632        LineStyle::Dotted => index.is_multiple_of(4),
633        LineStyle::DashDot => {
634            let m = index % 6;
635            m < 2 || m == 3
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn errorbar_bounds_include_caps() {
646        let mut plot = ErrorBar::new_vertical(
647            vec![0.0, 1.0],
648            vec![1.0, 2.0],
649            vec![0.5, 0.5],
650            vec![0.5, 0.5],
651        )
652        .unwrap()
653        .with_style(Vec4::ONE, 1.0, LineStyle::Solid, 10.0);
654        let bounds = plot.bounds();
655        assert!(bounds.max.x > 1.0);
656        assert!(bounds.min.x < 0.0);
657    }
658
659    #[test]
660    fn thick_errorbar_use_viewport_aware_triangles() {
661        let mut plot = ErrorBar::new_vertical(
662            vec![0.0, 1.0],
663            vec![1.0, 2.0],
664            vec![0.5, 0.5],
665            vec![0.5, 0.5],
666        )
667        .unwrap()
668        .with_style(Vec4::ONE, 2.0, LineStyle::Solid, 6.0);
669        let render = plot.render_data_with_viewport(Some((600, 400)));
670        assert_eq!(render.pipeline_type, PipelineType::Triangles);
671        assert!(!render.vertices.is_empty());
672    }
673}