Skip to main content

runmat_plot/plots/
errorbar.rs

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