Skip to main content

runmat_plot/plots/
line.rs

1//! Line plot implementation
2//!
3//! High-performance line plotting with GPU acceleration.
4
5use crate::core::{
6    vertex_utils, AlphaMode, BoundingBox, DrawCall, GpuPackContext, GpuVertexBuffer, Material,
7    PipelineType, RenderData, Vertex,
8};
9use crate::gpu::line::LineGpuInputs;
10use crate::plots::scatter::MarkerStyle as ScatterMarkerStyle;
11use glam::{Vec3, Vec4};
12use log::{trace, warn};
13
14/// High-performance GPU-accelerated line plot
15#[derive(Debug, Clone)]
16pub struct LinePlot {
17    /// Raw data points (x, y coordinates)
18    pub x_data: Vec<f64>,
19    pub y_data: Vec<f64>,
20
21    /// Visual styling
22    pub color: Vec4,
23    pub line_width: f32,
24    pub line_style: LineStyle,
25    pub line_join: LineJoin,
26    pub line_cap: LineCap,
27    pub marker: Option<LineMarkerAppearance>,
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    gpu_vertices: Option<GpuVertexBuffer>,
38    gpu_vertex_count: Option<usize>,
39    gpu_line_inputs: Option<LineGpuInputs>,
40    marker_vertices: Option<Vec<Vertex>>,
41    marker_gpu_vertices: Option<GpuVertexBuffer>,
42    marker_dirty: bool,
43    gpu_topology: Option<PipelineType>,
44    gpu_pack_viewport_px: Option<(u32, u32)>,
45    gpu_pack_view_bounds: Option<(f32, f32, f32, f32)>,
46}
47
48#[derive(Debug, Clone)]
49pub struct LineMarkerAppearance {
50    pub kind: ScatterMarkerStyle,
51    pub size: f32,
52    pub edge_color: Vec4,
53    pub face_color: Vec4,
54    pub filled: bool,
55}
56
57#[derive(Debug, Clone)]
58pub struct LineGpuStyle {
59    pub color: Vec4,
60    pub line_width: f32,
61    pub line_style: LineStyle,
62    pub marker: Option<LineMarkerAppearance>,
63}
64
65/// Line rendering styles
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum LineStyle {
68    Solid,
69    Dashed,
70    Dotted,
71    DashDot,
72}
73
74/// Line join style for thick polylines
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum LineJoin {
77    Miter,
78    Bevel,
79    Round,
80}
81
82impl Default for LineJoin {
83    fn default() -> Self {
84        Self::Miter
85    }
86}
87
88/// Line cap style for thick polylines
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum LineCap {
91    Butt,
92    Square,
93    Round,
94}
95
96impl Default for LineCap {
97    fn default() -> Self {
98        Self::Butt
99    }
100}
101
102impl Default for LineStyle {
103    fn default() -> Self {
104        Self::Solid
105    }
106}
107
108impl LinePlot {
109    pub(crate) fn has_gpu_line_inputs(&self) -> bool {
110        self.gpu_line_inputs.is_some()
111    }
112
113    pub(crate) fn has_gpu_vertices(&self) -> bool {
114        self.gpu_vertices.is_some()
115    }
116
117    /// Create a new line plot with data
118    pub fn new(x_data: Vec<f64>, y_data: Vec<f64>) -> Result<Self, String> {
119        if x_data.len() != y_data.len() {
120            return Err(format!(
121                "Data length mismatch: x_data has {} points, y_data has {} points",
122                x_data.len(),
123                y_data.len()
124            ));
125        }
126
127        if x_data.is_empty() {
128            return Err("Cannot create line plot with empty data".to_string());
129        }
130
131        Ok(Self {
132            x_data,
133            y_data,
134            color: Vec4::new(0.0, 0.5, 1.0, 1.0), // Default blue
135            line_width: 1.0,
136            line_style: LineStyle::default(),
137            line_join: LineJoin::default(),
138            line_cap: LineCap::default(),
139            marker: None,
140            label: None,
141            visible: true,
142            vertices: None,
143            bounds: None,
144            dirty: true,
145            gpu_vertices: None,
146            gpu_vertex_count: None,
147            gpu_line_inputs: None,
148            marker_vertices: None,
149            marker_gpu_vertices: None,
150            marker_dirty: true,
151            gpu_topology: None,
152            gpu_pack_viewport_px: None,
153            gpu_pack_view_bounds: None,
154        })
155    }
156
157    /// Build a line plot directly from a GPU vertex buffer.
158    pub fn from_gpu_buffer(
159        buffer: GpuVertexBuffer,
160        vertex_count: usize,
161        style: LineGpuStyle,
162        bounds: BoundingBox,
163        pipeline: PipelineType,
164        marker_buffer: Option<GpuVertexBuffer>,
165    ) -> Self {
166        Self {
167            x_data: Vec::new(),
168            y_data: Vec::new(),
169            color: style.color,
170            line_width: style.line_width,
171            line_style: style.line_style,
172            line_join: LineJoin::Miter,
173            line_cap: LineCap::Butt,
174            marker: style.marker,
175            label: None,
176            visible: true,
177            vertices: None,
178            bounds: Some(bounds),
179            dirty: false,
180            gpu_vertices: Some(buffer),
181            gpu_vertex_count: Some(vertex_count),
182            gpu_line_inputs: None,
183            marker_vertices: None,
184            marker_gpu_vertices: marker_buffer,
185            marker_dirty: true,
186            gpu_topology: Some(pipeline),
187            gpu_pack_viewport_px: None,
188            gpu_pack_view_bounds: None,
189        }
190    }
191
192    /// Create a GPU-backed line plot from X/Y device buffers.
193    ///
194    /// Geometry is packed at render-time when a viewport size is available so that pixel-based
195    /// widths can be converted into data units.
196    pub fn from_gpu_xy(
197        inputs: LineGpuInputs,
198        style: LineGpuStyle,
199        bounds: BoundingBox,
200        marker_buffer: Option<GpuVertexBuffer>,
201    ) -> Self {
202        Self {
203            x_data: Vec::new(),
204            y_data: Vec::new(),
205            color: style.color,
206            line_width: style.line_width,
207            line_style: style.line_style,
208            line_join: LineJoin::Miter,
209            line_cap: LineCap::Butt,
210            marker: style.marker,
211            label: None,
212            visible: true,
213            vertices: None,
214            bounds: Some(bounds),
215            dirty: false,
216            gpu_vertices: None,
217            gpu_vertex_count: None,
218            gpu_line_inputs: Some(inputs),
219            marker_vertices: None,
220            marker_gpu_vertices: marker_buffer,
221            marker_dirty: true,
222            gpu_topology: None,
223            gpu_pack_viewport_px: None,
224            gpu_pack_view_bounds: None,
225        }
226    }
227
228    fn invalidate_gpu_data(&mut self) {
229        self.gpu_vertices = None;
230        self.gpu_vertex_count = None;
231        self.bounds = None;
232        self.gpu_line_inputs = None;
233        self.marker_gpu_vertices = None;
234        self.marker_dirty = true;
235        self.gpu_topology = None;
236        self.gpu_pack_viewport_px = None;
237        self.gpu_pack_view_bounds = None;
238    }
239
240    fn invalidate_marker_data(&mut self) {
241        self.marker_vertices = None;
242        self.marker_dirty = true;
243        if self.gpu_vertices.is_none() {
244            self.marker_gpu_vertices = None;
245        }
246    }
247
248    /// Create a line plot with custom styling
249    pub fn with_style(mut self, color: Vec4, line_width: f32, line_style: LineStyle) -> Self {
250        self.color = color;
251        self.line_width = line_width;
252        self.line_style = line_style;
253        self.dirty = true;
254        self.invalidate_gpu_data();
255        self
256    }
257
258    /// Set the plot label for legends
259    pub fn with_label<S: Into<String>>(mut self, label: S) -> Self {
260        self.label = Some(label.into());
261        self
262    }
263
264    /// Update the data points
265    pub fn update_data(&mut self, x_data: Vec<f64>, y_data: Vec<f64>) -> Result<(), String> {
266        if x_data.len() != y_data.len() {
267            return Err(format!(
268                "Data length mismatch: x_data has {} points, y_data has {} points",
269                x_data.len(),
270                y_data.len()
271            ));
272        }
273
274        if x_data.is_empty() {
275            return Err("Cannot update with empty data".to_string());
276        }
277
278        self.x_data = x_data;
279        self.y_data = y_data;
280        self.dirty = true;
281        self.invalidate_marker_data();
282        Ok(())
283    }
284
285    /// Set the color of the line
286    pub fn set_color(&mut self, color: Vec4) {
287        self.color = color;
288        self.dirty = true;
289        self.invalidate_gpu_data();
290        self.invalidate_marker_data();
291    }
292
293    /// Set the line width
294    pub fn set_line_width(&mut self, width: f32) {
295        self.line_width = width.max(0.1); // Minimum line width
296        self.dirty = true;
297        self.invalidate_gpu_data();
298    }
299
300    /// Set the line style
301    pub fn set_line_style(&mut self, style: LineStyle) {
302        self.line_style = style;
303        self.dirty = true;
304        self.invalidate_gpu_data();
305    }
306
307    /// Attach marker metadata so renderers can emit hybrid line+marker plots.
308    pub fn set_marker(&mut self, marker: Option<LineMarkerAppearance>) {
309        self.marker = marker;
310        self.invalidate_marker_data();
311    }
312
313    /// Set the line join style for thick lines
314    pub fn set_line_join(&mut self, join: LineJoin) {
315        self.line_join = join;
316        self.dirty = true;
317        self.invalidate_gpu_data();
318    }
319
320    /// Set the line cap style for thick lines
321    pub fn set_line_cap(&mut self, cap: LineCap) {
322        self.line_cap = cap;
323        self.dirty = true;
324        self.invalidate_gpu_data();
325    }
326
327    /// Show or hide the plot
328    pub fn set_visible(&mut self, visible: bool) {
329        self.visible = visible;
330    }
331
332    /// Get the number of data points
333    pub fn len(&self) -> usize {
334        if !self.x_data.is_empty() {
335            self.x_data.len()
336        } else {
337            self.gpu_vertex_count.unwrap_or(0)
338        }
339    }
340
341    /// Check if the plot has no data
342    pub fn is_empty(&self) -> bool {
343        self.len() == 0
344    }
345
346    /// Generate vertices for GPU rendering
347    pub fn generate_vertices(&mut self) -> &Vec<Vertex> {
348        if self.gpu_vertices.is_some() {
349            if self.vertices.is_none() {
350                self.vertices = Some(Vec::new());
351            }
352            return self.vertices.as_ref().unwrap();
353        }
354        if self.dirty || self.vertices.is_none() {
355            if self.line_width > 1.0 {
356                // Use triangle extrusion for thicker lines; switch pipeline in render_data
357                let base_tris = match self.line_cap {
358                    LineCap::Butt => vertex_utils::create_thick_polyline_with_join(
359                        &self.x_data,
360                        &self.y_data,
361                        self.color,
362                        self.line_width,
363                        self.line_join,
364                    ),
365                    LineCap::Square => vertex_utils::create_thick_polyline_square_caps(
366                        &self.x_data,
367                        &self.y_data,
368                        self.color,
369                        self.line_width,
370                    ),
371                    LineCap::Round => vertex_utils::create_thick_polyline_round_caps(
372                        &self.x_data,
373                        &self.y_data,
374                        self.color,
375                        self.line_width,
376                        12,
377                    ),
378                };
379                let tris = match self.line_style {
380                    LineStyle::Solid => base_tris,
381                    LineStyle::Dashed | LineStyle::DashDot | LineStyle::Dotted => {
382                        vertex_utils::create_thick_polyline_dashed(
383                            &self.x_data,
384                            &self.y_data,
385                            self.color,
386                            self.line_width,
387                            self.line_style,
388                        )
389                    }
390                };
391                self.vertices = Some(tris);
392            } else {
393                let verts = match self.line_style {
394                    LineStyle::Solid => {
395                        vertex_utils::create_line_plot(&self.x_data, &self.y_data, self.color)
396                    }
397                    LineStyle::Dashed | LineStyle::DashDot => {
398                        vertex_utils::create_line_plot_dashed(
399                            &self.x_data,
400                            &self.y_data,
401                            self.color,
402                            self.line_style,
403                        )
404                    }
405                    LineStyle::Dotted => {
406                        // Render as a sequence of tiny dashes to approximate dots
407                        vertex_utils::create_line_plot_dashed(
408                            &self.x_data,
409                            &self.y_data,
410                            self.color,
411                            LineStyle::Dashed,
412                        )
413                    }
414                };
415                self.vertices = Some(verts);
416            }
417            self.dirty = false;
418        }
419        self.vertices.as_ref().unwrap()
420    }
421
422    fn generate_thin_line_vertices(&self) -> Vec<Vertex> {
423        match self.line_style {
424            LineStyle::Solid => {
425                vertex_utils::create_line_plot(&self.x_data, &self.y_data, self.color)
426            }
427            LineStyle::Dashed | LineStyle::DashDot => vertex_utils::create_line_plot_dashed(
428                &self.x_data,
429                &self.y_data,
430                self.color,
431                self.line_style,
432            ),
433            LineStyle::Dotted => vertex_utils::create_line_plot_dashed(
434                &self.x_data,
435                &self.y_data,
436                self.color,
437                LineStyle::Dashed,
438            ),
439        }
440    }
441
442    /// Get the bounding box of the data
443    pub fn bounds(&mut self) -> BoundingBox {
444        if self.bounds.is_some() && self.x_data.is_empty() && self.y_data.is_empty() {
445            return self.bounds.unwrap_or_default();
446        }
447        if self.dirty || self.bounds.is_none() {
448            let points: Vec<Vec3> = self
449                .x_data
450                .iter()
451                .zip(self.y_data.iter())
452                .map(|(&x, &y)| Vec3::new(x as f32, y as f32, 0.0))
453                .collect();
454            self.bounds = Some(BoundingBox::from_points(&points));
455        }
456        self.bounds.unwrap()
457    }
458
459    fn pack_gpu_vertices_if_needed(
460        &mut self,
461        gpu: &GpuPackContext<'_>,
462        viewport_px: (u32, u32),
463        view_bounds: Option<(f64, f64, f64, f64)>,
464    ) -> Result<(), String> {
465        let bounds = self
466            .bounds
467            .as_ref()
468            .ok_or_else(|| "missing line bounds".to_string())?;
469        let stroke_bounds = Self::stroke_bounds_from_view_bounds(*bounds, view_bounds);
470        let pack_bounds_key = (
471            stroke_bounds.min.x,
472            stroke_bounds.max.x,
473            stroke_bounds.min.y,
474            stroke_bounds.max.y,
475        );
476        if self.gpu_vertices.is_some() {
477            if self.gpu_pack_viewport_px == Some(viewport_px)
478                && self.gpu_pack_view_bounds == Some(pack_bounds_key)
479            {
480                return Ok(());
481            }
482            self.gpu_vertices = None;
483            self.gpu_vertex_count = None;
484            self.gpu_topology = None;
485        }
486        let Some(inputs) = self.gpu_line_inputs.as_ref() else {
487            return Ok(());
488        };
489
490        let stroke_width_px = self.line_width.max(1.0);
491        let x_span = (stroke_bounds.max.x - stroke_bounds.min.x).abs().max(1e-12);
492        let y_span = (stroke_bounds.max.y - stroke_bounds.min.y).abs().max(1e-12);
493        trace!(
494            target: "runmat_plot",
495            "line-pack: begin len={} line_width_px={} stroke_width_px={} viewport_px={:?} bounds=({:?}..{:?}) stroke_bounds=({:?}..{:?})",
496            inputs.len,
497            self.line_width,
498            stroke_width_px,
499            viewport_px,
500            bounds.min,
501            bounds.max,
502            stroke_bounds.min,
503            stroke_bounds.max
504        );
505
506        let params = crate::gpu::line::LineGpuParams {
507            color: self.color,
508            half_width_px: stroke_width_px * 0.5,
509            viewport_width_px: viewport_px.0 as f32,
510            viewport_height_px: viewport_px.1 as f32,
511            x_min: stroke_bounds.min.x,
512            x_span,
513            y_min: stroke_bounds.min.y,
514            y_span,
515            line_style: self.line_style,
516            marker_size: 1.0,
517        };
518        let packed =
519            crate::gpu::line::pack_vertices_from_xy(gpu.device, gpu.queue, inputs, &params)
520                .map_err(|e| format!("gpu line packing failed: {e}"))?;
521        trace!(
522            target: "runmat_plot",
523            "line-pack: complete max_vertices={} indirect_present={}",
524            packed.vertex_count,
525            packed.indirect.is_some()
526        );
527
528        self.gpu_vertices = Some(packed);
529        self.gpu_vertex_count = Some(self.gpu_vertices.as_ref().unwrap().vertex_count);
530        self.gpu_topology = Some(PipelineType::Triangles);
531        self.gpu_pack_viewport_px = Some(viewport_px);
532        self.gpu_pack_view_bounds = Some(pack_bounds_key);
533        Ok(())
534    }
535
536    pub fn render_data_with_viewport_gpu(
537        &mut self,
538        viewport_px: Option<(u32, u32)>,
539        view_bounds: Option<(f64, f64, f64, f64)>,
540        gpu: Option<&GpuPackContext<'_>>,
541    ) -> RenderData {
542        trace!(
543            target: "runmat_plot",
544            "line: render_data_with_viewport_gpu viewport_px={:?} view_bounds={:?} gpu_ctx_present={} gpu_line_inputs_present={} gpu_vertices_present={}",
545            viewport_px,
546            view_bounds,
547            gpu.is_some(),
548            self.gpu_line_inputs.is_some(),
549            self.gpu_vertices.is_some()
550        );
551        if self.gpu_line_inputs.is_some() {
552            if let (Some(gpu), Some(vp)) = (gpu, viewport_px) {
553                if let Err(err) = self.pack_gpu_vertices_if_needed(gpu, vp, view_bounds) {
554                    warn!("line gpu pack failed: {err}");
555                }
556            }
557        }
558        self.render_data_with_viewport_and_view_bounds(viewport_px, view_bounds)
559    }
560
561    /// Generate complete render data for the graphics pipeline
562    pub fn render_data(&mut self) -> RenderData {
563        let using_gpu = self.gpu_vertices.is_some();
564        let gpu_vertices = self.gpu_vertices.clone();
565        let (vertices, vertex_count) = if using_gpu {
566            (Vec::new(), self.gpu_vertex_count.unwrap_or(0))
567        } else if self.line_width > 1.0 {
568            // Without a viewport there is no meaningful conversion from pixel width to data
569            // units. Keep fallback geometry thin; viewport-aware paths rebuild pixel-stable
570            // triangle strokes once an actual plot rect is known.
571            let verts = self.generate_thin_line_vertices();
572            let count = verts.len();
573            (verts, count)
574        } else {
575            let verts = self.generate_vertices().clone();
576            let count = verts.len();
577            (verts, count)
578        };
579
580        // Encode width/style/cap/join into material for exporters:
581        // - roughness: line width
582        // - metallic: line style code (0 solid,1 dashed,2 dotted,3 dashdot)
583        // - emissive.x: cap (0 butt,1 square,2 round)
584        // - emissive.y: join (0 miter,1 bevel,2 round)
585        let style_code = match self.line_style {
586            LineStyle::Solid => 0.0,
587            LineStyle::Dashed => 1.0,
588            LineStyle::Dotted => 2.0,
589            LineStyle::DashDot => 3.0,
590        };
591        let cap_code = match self.line_cap {
592            LineCap::Butt => 0.0,
593            LineCap::Square => 1.0,
594            LineCap::Round => 2.0,
595        };
596        let join_code = match self.line_join {
597            LineJoin::Miter => 0.0,
598            LineJoin::Bevel => 1.0,
599            LineJoin::Round => 2.0,
600        };
601        let mut material = Material {
602            albedo: self.color,
603            ..Default::default()
604        };
605        material.roughness = self.line_width.max(0.0);
606        material.metallic = style_code;
607        material.emissive = Vec4::new(cap_code, join_code, -1.0, 0.0);
608
609        let draw_call = DrawCall {
610            vertex_offset: 0,
611            vertex_count,
612            index_offset: None,
613            index_count: None,
614            instance_count: 1,
615        };
616
617        // If thick polyline was generated, we must render as triangles
618        let pipeline = if using_gpu {
619            self.gpu_topology.unwrap_or(if self.line_width > 1.0 {
620                PipelineType::Triangles
621            } else {
622                PipelineType::Lines
623            })
624        } else {
625            PipelineType::Lines
626        };
627        RenderData {
628            pipeline_type: pipeline,
629            vertices,
630            indices: None,
631            gpu_vertices,
632            bounds: Some(self.bounds()),
633            material,
634            draw_calls: vec![draw_call],
635            image: None,
636        }
637    }
638
639    /// Generate render data, using an optional viewport size hint (width, height in pixels).
640    ///
641    /// With a viewport available, 2D lines are always rendered as triangle strokes so dense
642    /// polylines remain visually continuous at all zoom levels and lengths. The user-facing
643    /// `line_width` is expressed in *pixels* and extrusion is performed in viewport space,
644    /// then mapped back to data coordinates for the rest of the render pipeline.
645    pub fn render_data_with_viewport(&mut self, viewport_px: Option<(u32, u32)>) -> RenderData {
646        self.render_data_with_viewport_and_view_bounds(viewport_px, None)
647    }
648
649    pub fn render_data_with_viewport_and_view_bounds(
650        &mut self,
651        viewport_px: Option<(u32, u32)>,
652        view_bounds: Option<(f64, f64, f64, f64)>,
653    ) -> RenderData {
654        if self.gpu_vertices.is_some() {
655            // GPU paths already handle sizing via pipeline/state; keep existing behavior.
656            return self.render_data();
657        }
658
659        let Some(viewport_px) = viewport_px else {
660            return self.render_data();
661        };
662        let bounds = self.bounds();
663        let stroke_bounds = Self::stroke_bounds_from_view_bounds(bounds, view_bounds);
664        let stroke_width_px = self.line_width.max(1.0);
665        let tris = self.build_viewport_stroke_vertices(stroke_bounds, viewport_px, stroke_width_px);
666        let vertex_count = tris.len();
667
668        let style_code = match self.line_style {
669            LineStyle::Solid => 0.0,
670            LineStyle::Dashed => 1.0,
671            LineStyle::Dotted => 2.0,
672            LineStyle::DashDot => 3.0,
673        };
674        let cap_code = match self.line_cap {
675            LineCap::Butt => 0.0,
676            LineCap::Square => 1.0,
677            LineCap::Round => 2.0,
678        };
679        let join_code = match self.line_join {
680            LineJoin::Miter => 0.0,
681            LineJoin::Bevel => 1.0,
682            LineJoin::Round => 2.0,
683        };
684        let mut material = Material {
685            albedo: self.color,
686            ..Default::default()
687        };
688        // Keep the user-facing width in pixels for exporters/metadata.
689        material.roughness = self.line_width.max(0.0);
690        material.metallic = style_code;
691        material.emissive = Vec4::new(cap_code, join_code, -1.0, 0.0);
692
693        let draw_call = DrawCall {
694            vertex_offset: 0,
695            vertex_count,
696            index_offset: None,
697            index_count: None,
698            instance_count: 1,
699        };
700
701        RenderData {
702            pipeline_type: PipelineType::Triangles,
703            vertices: tris,
704            indices: None,
705            gpu_vertices: None,
706            bounds: Some(bounds),
707            material,
708            draw_calls: vec![draw_call],
709            image: None,
710        }
711    }
712
713    fn stroke_bounds_from_view_bounds(
714        data_bounds: BoundingBox,
715        view_bounds: Option<(f64, f64, f64, f64)>,
716    ) -> BoundingBox {
717        let Some((left, right, bottom, top)) = view_bounds else {
718            return data_bounds;
719        };
720        if !(left.is_finite() && right.is_finite() && bottom.is_finite() && top.is_finite()) {
721            return data_bounds;
722        }
723        let (min_x, max_x) = if left <= right {
724            (left as f32, right as f32)
725        } else {
726            (right as f32, left as f32)
727        };
728        let (min_y, max_y) = if bottom <= top {
729            (bottom as f32, top as f32)
730        } else {
731            (top as f32, bottom as f32)
732        };
733        if !(min_x.is_finite() && max_x.is_finite() && min_y.is_finite() && max_y.is_finite())
734            || (max_x - min_x).abs() < 1e-12
735            || (max_y - min_y).abs() < 1e-12
736        {
737            return data_bounds;
738        }
739        BoundingBox {
740            min: Vec3::new(min_x, min_y, data_bounds.min.z),
741            max: Vec3::new(max_x, max_y, data_bounds.max.z),
742        }
743    }
744
745    fn build_viewport_stroke_vertices(
746        &self,
747        bounds: BoundingBox,
748        viewport_px: (u32, u32),
749        stroke_width_px: f32,
750    ) -> Vec<Vertex> {
751        let x_span = (bounds.max.x - bounds.min.x).abs().max(1e-12);
752        let y_span = (bounds.max.y - bounds.min.y).abs().max(1e-12);
753        let vw = (viewport_px.0 as f32).max(1.0);
754        let vh = (viewport_px.1 as f32).max(1.0);
755        let sx = vw / x_span;
756        let sy = vh / y_span;
757
758        let x_px: Vec<f64> = self
759            .x_data
760            .iter()
761            .map(|&x| ((x as f32 - bounds.min.x) * sx) as f64)
762            .collect();
763        let y_px: Vec<f64> = self
764            .y_data
765            .iter()
766            .map(|&y| ((y as f32 - bounds.min.y) * sy) as f64)
767            .collect();
768
769        let base_tris = match self.line_cap {
770            LineCap::Butt => vertex_utils::create_thick_polyline_with_join(
771                &x_px,
772                &y_px,
773                self.color,
774                stroke_width_px,
775                self.line_join,
776            ),
777            LineCap::Square => vertex_utils::create_thick_polyline_square_caps(
778                &x_px,
779                &y_px,
780                self.color,
781                stroke_width_px,
782            ),
783            LineCap::Round => vertex_utils::create_thick_polyline_round_caps(
784                &x_px,
785                &y_px,
786                self.color,
787                stroke_width_px,
788                12,
789            ),
790        };
791        let mut tris = match self.line_style {
792            LineStyle::Solid => base_tris,
793            LineStyle::Dashed | LineStyle::DashDot | LineStyle::Dotted => {
794                vertex_utils::create_thick_polyline_dashed(
795                    &x_px,
796                    &y_px,
797                    self.color,
798                    stroke_width_px,
799                    self.line_style,
800                )
801            }
802        };
803
804        let inv_sx = x_span / vw;
805        let inv_sy = y_span / vh;
806        for v in &mut tris {
807            let px = v.position[0];
808            let py = v.position[1];
809            v.position[0] = bounds.min.x + px * inv_sx;
810            v.position[1] = bounds.min.y + py * inv_sy;
811        }
812        tris
813    }
814
815    /// Generate render data representing the markers for this line plot.
816    pub fn marker_render_data(&mut self) -> Option<RenderData> {
817        let marker = self.marker.clone()?;
818        let material = Self::build_marker_material(&marker);
819
820        if let Some(gpu_vertices) = self.marker_gpu_vertices.clone() {
821            let vertex_count = gpu_vertices.vertex_count;
822            if vertex_count == 0 {
823                return None;
824            }
825            let draw_call = DrawCall {
826                vertex_offset: 0,
827                vertex_count,
828                index_offset: None,
829                index_count: None,
830                instance_count: 1,
831            };
832            return Some(RenderData {
833                pipeline_type: PipelineType::Points,
834                vertices: Vec::new(),
835                indices: None,
836                gpu_vertices: Some(gpu_vertices),
837                bounds: Some(self.bounds()),
838                material,
839                draw_calls: vec![draw_call],
840                image: None,
841            });
842        }
843
844        let vertices = self.marker_vertices_slice(&marker)?;
845        if vertices.is_empty() {
846            return None;
847        }
848        let draw_call = DrawCall {
849            vertex_offset: 0,
850            vertex_count: vertices.len(),
851            index_offset: None,
852            index_count: None,
853            instance_count: 1,
854        };
855
856        Some(RenderData {
857            pipeline_type: PipelineType::Points,
858            vertices: vertices.to_vec(),
859            indices: None,
860            gpu_vertices: None,
861            bounds: Some(self.bounds()),
862            material,
863            draw_calls: vec![draw_call],
864            image: None,
865        })
866    }
867
868    fn build_marker_material(marker: &LineMarkerAppearance) -> Material {
869        let mut material = Material {
870            albedo: marker.face_color,
871            ..Default::default()
872        };
873        if !marker.filled {
874            material.albedo.w = 0.0;
875        }
876        material.emissive = marker.edge_color;
877        material.roughness = 1.0;
878        material.metallic = marker_style_code(marker.kind);
879        material.alpha_mode = AlphaMode::Blend;
880        material
881    }
882
883    fn marker_vertices_slice(&mut self, marker: &LineMarkerAppearance) -> Option<&[Vertex]> {
884        if self.x_data.len() != self.y_data.len() || self.x_data.is_empty() {
885            return None;
886        }
887
888        if self.marker_vertices.is_none() || self.marker_dirty {
889            let mut verts = Vec::with_capacity(self.x_data.len());
890            for (&x, &y) in self.x_data.iter().zip(self.y_data.iter()) {
891                let mut vertex = Vertex::new(Vec3::new(x as f32, y as f32, 0.0), marker.face_color);
892                vertex.normal[2] = marker.size.max(1.0);
893                verts.push(vertex);
894            }
895            self.marker_vertices = Some(verts);
896            self.marker_dirty = false;
897        }
898        self.marker_vertices.as_deref()
899    }
900
901    /// Get plot statistics for debugging
902    pub fn statistics(&self) -> PlotStatistics {
903        let (min_x, max_x) = self
904            .x_data
905            .iter()
906            .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &x| {
907                (min.min(x), max.max(x))
908            });
909        let (min_y, max_y) = self
910            .y_data
911            .iter()
912            .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), &y| {
913                (min.min(y), max.max(y))
914            });
915
916        PlotStatistics {
917            point_count: self.x_data.len(),
918            x_range: (min_x, max_x),
919            y_range: (min_y, max_y),
920            memory_usage: self.estimated_memory_usage(),
921        }
922    }
923
924    /// Estimate memory usage in bytes
925    pub fn estimated_memory_usage(&self) -> usize {
926        std::mem::size_of::<f64>() * (self.x_data.len() + self.y_data.len())
927            + self
928                .vertices
929                .as_ref()
930                .map_or(0, |v| v.len() * std::mem::size_of::<Vertex>())
931            + self.gpu_vertex_count.unwrap_or(0) * std::mem::size_of::<Vertex>()
932    }
933}
934
935fn marker_style_code(kind: ScatterMarkerStyle) -> f32 {
936    match kind {
937        ScatterMarkerStyle::Circle => 0.0,
938        ScatterMarkerStyle::Square => 1.0,
939        ScatterMarkerStyle::Triangle => 2.0,
940        ScatterMarkerStyle::Diamond => 3.0,
941        ScatterMarkerStyle::Plus => 4.0,
942        ScatterMarkerStyle::Cross => 5.0,
943        ScatterMarkerStyle::Star => 6.0,
944        ScatterMarkerStyle::Hexagon => 7.0,
945    }
946}
947
948/// Plot performance and data statistics
949#[derive(Debug, Clone)]
950pub struct PlotStatistics {
951    pub point_count: usize,
952    pub x_range: (f64, f64),
953    pub y_range: (f64, f64),
954    pub memory_usage: usize,
955}
956
957/// MATLAB-compatible line plot creation utilities
958pub mod matlab_compat {
959    use super::*;
960
961    /// Create a simple line plot (equivalent to MATLAB's `plot(x, y)`)
962    pub fn plot(x: Vec<f64>, y: Vec<f64>) -> Result<LinePlot, String> {
963        LinePlot::new(x, y)
964    }
965
966    /// Create a line plot with specified color (`plot(x, y, 'r')`)
967    pub fn plot_with_color(x: Vec<f64>, y: Vec<f64>, color: &str) -> Result<LinePlot, String> {
968        let color_vec = parse_matlab_color(color)?;
969        Ok(LinePlot::new(x, y)?.with_style(color_vec, 1.0, LineStyle::Solid))
970    }
971
972    /// Parse MATLAB color specifications
973    fn parse_matlab_color(color: &str) -> Result<Vec4, String> {
974        match color {
975            "r" | "red" => Ok(Vec4::new(1.0, 0.0, 0.0, 1.0)),
976            "g" | "green" => Ok(Vec4::new(0.0, 1.0, 0.0, 1.0)),
977            "b" | "blue" => Ok(Vec4::new(0.0, 0.0, 1.0, 1.0)),
978            "c" | "cyan" => Ok(Vec4::new(0.0, 1.0, 1.0, 1.0)),
979            "m" | "magenta" => Ok(Vec4::new(1.0, 0.0, 1.0, 1.0)),
980            "y" | "yellow" => Ok(Vec4::new(1.0, 1.0, 0.0, 1.0)),
981            "k" | "black" => Ok(Vec4::new(0.0, 0.0, 0.0, 1.0)),
982            "w" | "white" => Ok(Vec4::new(1.0, 1.0, 1.0, 1.0)),
983            _ => Err(format!("Unknown color: {color}")),
984        }
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn test_line_plot_creation() {
994        let x = vec![0.0, 1.0, 2.0, 3.0];
995        let y = vec![0.0, 1.0, 0.0, 1.0];
996
997        let plot = LinePlot::new(x.clone(), y.clone()).unwrap();
998
999        assert_eq!(plot.x_data, x);
1000        assert_eq!(plot.y_data, y);
1001        assert_eq!(plot.len(), 4);
1002        assert!(!plot.is_empty());
1003        assert!(plot.visible);
1004    }
1005
1006    #[test]
1007    fn test_line_plot_data_validation() {
1008        // Mismatched lengths should fail
1009        let x = vec![0.0, 1.0, 2.0];
1010        let y = vec![0.0, 1.0];
1011        assert!(LinePlot::new(x, y).is_err());
1012
1013        // Empty data should fail
1014        let empty_x: Vec<f64> = vec![];
1015        let empty_y: Vec<f64> = vec![];
1016        assert!(LinePlot::new(empty_x, empty_y).is_err());
1017    }
1018
1019    #[test]
1020    fn test_line_plot_styling() {
1021        let x = vec![0.0, 1.0, 2.0];
1022        let y = vec![1.0, 2.0, 1.5];
1023        let color = Vec4::new(1.0, 0.0, 0.0, 1.0);
1024
1025        let plot = LinePlot::new(x, y)
1026            .unwrap()
1027            .with_style(color, 2.0, LineStyle::Dashed)
1028            .with_label("Test Line");
1029
1030        assert_eq!(plot.color, color);
1031        assert_eq!(plot.line_width, 2.0);
1032        assert_eq!(plot.line_style, LineStyle::Dashed);
1033        assert_eq!(plot.label, Some("Test Line".to_string()));
1034    }
1035
1036    #[test]
1037    fn test_line_plot_data_update() {
1038        let mut plot = LinePlot::new(vec![0.0, 1.0], vec![0.0, 1.0]).unwrap();
1039
1040        let new_x = vec![0.0, 0.5, 1.0, 1.5];
1041        let new_y = vec![0.0, 0.25, 1.0, 2.25];
1042
1043        plot.update_data(new_x.clone(), new_y.clone()).unwrap();
1044
1045        assert_eq!(plot.x_data, new_x);
1046        assert_eq!(plot.y_data, new_y);
1047        assert_eq!(plot.len(), 4);
1048    }
1049
1050    #[test]
1051    fn test_line_plot_bounds() {
1052        let x = vec![-1.0, 0.0, 1.0, 2.0];
1053        let y = vec![-2.0, 0.0, 1.0, 3.0];
1054
1055        let mut plot = LinePlot::new(x, y).unwrap();
1056        let bounds = plot.bounds();
1057
1058        assert_eq!(bounds.min.x, -1.0);
1059        assert_eq!(bounds.max.x, 2.0);
1060        assert_eq!(bounds.min.y, -2.0);
1061        assert_eq!(bounds.max.y, 3.0);
1062    }
1063
1064    #[test]
1065    fn test_line_plot_vertex_generation() {
1066        let x = vec![0.0, 1.0, 2.0];
1067        let y = vec![0.0, 1.0, 0.0];
1068
1069        let mut plot = LinePlot::new(x, y).unwrap();
1070        let vertices = plot.generate_vertices();
1071
1072        // Should have 2 line segments (4 vertices total)
1073        assert_eq!(vertices.len(), 4);
1074
1075        // Check first line segment
1076        assert_eq!(vertices[0].position, [0.0, 0.0, 0.0]);
1077        assert_eq!(vertices[1].position, [1.0, 1.0, 0.0]);
1078    }
1079
1080    #[test]
1081    fn test_line_plot_render_data() {
1082        let x = vec![0.0, 1.0, 2.0];
1083        let y = vec![1.0, 2.0, 1.0];
1084
1085        let mut plot = LinePlot::new(x, y).unwrap();
1086        let render_data = plot.render_data();
1087
1088        assert_eq!(render_data.pipeline_type, PipelineType::Lines);
1089        assert_eq!(render_data.vertices.len(), 4); // 2 line segments
1090        assert!(render_data.indices.is_none());
1091        assert_eq!(render_data.draw_calls.len(), 1);
1092    }
1093
1094    #[test]
1095    fn test_line_plot_statistics() {
1096        let x = vec![0.0, 1.0, 2.0, 3.0];
1097        let y = vec![-1.0, 0.0, 1.0, 2.0];
1098
1099        let plot = LinePlot::new(x, y).unwrap();
1100        let stats = plot.statistics();
1101
1102        assert_eq!(stats.point_count, 4);
1103        assert_eq!(stats.x_range, (0.0, 3.0));
1104        assert_eq!(stats.y_range, (-1.0, 2.0));
1105        assert!(stats.memory_usage > 0);
1106    }
1107
1108    #[test]
1109    fn test_matlab_compat_colors() {
1110        use super::matlab_compat::*;
1111
1112        let x = vec![0.0, 1.0];
1113        let y = vec![0.0, 1.0];
1114
1115        let red_plot = plot_with_color(x.clone(), y.clone(), "r").unwrap();
1116        assert_eq!(red_plot.color, Vec4::new(1.0, 0.0, 0.0, 1.0));
1117
1118        let blue_plot = plot_with_color(x.clone(), y.clone(), "blue").unwrap();
1119        assert_eq!(blue_plot.color, Vec4::new(0.0, 0.0, 1.0, 1.0));
1120
1121        // Invalid color should fail
1122        assert!(plot_with_color(x, y, "invalid").is_err());
1123    }
1124
1125    #[test]
1126    fn marker_render_data_produces_point_draw_call() {
1127        let mut plot = LinePlot::new(vec![0.0, 1.0], vec![0.0, 1.0]).unwrap();
1128        plot.set_marker(Some(LineMarkerAppearance {
1129            kind: ScatterMarkerStyle::Circle,
1130            size: 8.0,
1131            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
1132            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
1133            filled: true,
1134        }));
1135        let marker_data = plot.marker_render_data().expect("marker render data");
1136        assert_eq!(marker_data.pipeline_type, PipelineType::Points);
1137        assert_eq!(marker_data.draw_calls[0].vertex_count, 2);
1138    }
1139
1140    #[test]
1141    fn line_plot_handles_large_trace() {
1142        let n = 50_000;
1143        let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
1144        let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.001).sin()).collect();
1145        let mut plot = LinePlot::new(x, y).unwrap();
1146        let render_data = plot.render_data();
1147        assert_eq!(render_data.vertices.len(), (n - 1) * 2);
1148    }
1149
1150    #[test]
1151    fn thin_line_with_viewport_uses_triangle_stroke_geometry() {
1152        let x = vec![0.0, 1.0, 2.0];
1153        let y = vec![0.0, 1.0, 0.0];
1154        let mut plot = LinePlot::new(x, y).unwrap();
1155        plot.set_line_width(1.0);
1156        let render_data = plot.render_data_with_viewport(Some((800, 600)));
1157        assert_eq!(render_data.pipeline_type, PipelineType::Triangles);
1158        assert!(render_data.vertices.len() >= 12); // at least 2 segments worth of stroke triangles
1159        assert_eq!(render_data.vertices.len() % 3, 0);
1160    }
1161
1162    #[test]
1163    fn thin_line_without_viewport_keeps_legacy_line_path() {
1164        let x = vec![0.0, 1.0, 2.0];
1165        let y = vec![0.0, 1.0, 0.0];
1166        let mut plot = LinePlot::new(x, y).unwrap();
1167        plot.set_line_width(1.0);
1168        let render_data = plot.render_data_with_viewport(None);
1169        assert_eq!(render_data.pipeline_type, PipelineType::Lines);
1170        assert_eq!(render_data.vertices.len(), 4); // 2 segments * 2 vertices
1171    }
1172
1173    #[test]
1174    fn thick_line_without_viewport_keeps_legacy_line_path() {
1175        let x = vec![0.0, 1.0, 2.0];
1176        let y = vec![0.0, 1.0, 0.0];
1177        let mut plot = LinePlot::new(x, y).unwrap();
1178        plot.set_line_width(2.0);
1179        let render_data = plot.render_data_with_viewport(None);
1180        assert_eq!(render_data.pipeline_type, PipelineType::Lines);
1181        assert_eq!(render_data.vertices.len(), 4); // 2 segments * 2 vertices
1182    }
1183
1184    #[test]
1185    fn viewport_stroke_width_is_pixel_stable_across_anisotropic_axes() {
1186        let x = vec![-100.0, 0.0];
1187        let y = vec![10000.0, 0.0];
1188        let mut plot = LinePlot::new(x, y).unwrap();
1189        plot.set_line_width(1.0);
1190        let viewport = (1400, 1000);
1191        let render_data = plot.render_data_with_viewport(Some(viewport));
1192        assert_eq!(render_data.pipeline_type, PipelineType::Triangles);
1193        assert!(render_data.vertices.len() >= 6);
1194
1195        let bounds = render_data.bounds.expect("bounds");
1196        let v0 = render_data.vertices[0].position;
1197        let v1 = render_data.vertices[1].position;
1198        let px_per_x = viewport.0 as f32 / (bounds.max.x - bounds.min.x).abs().max(1e-12);
1199        let px_per_y = viewport.1 as f32 / (bounds.max.y - bounds.min.y).abs().max(1e-12);
1200        let dx_px = (v0[0] - v1[0]) * px_per_x;
1201        let dy_px = (v0[1] - v1[1]) * px_per_y;
1202        let width_px = (dx_px * dx_px + dy_px * dy_px).sqrt();
1203        assert!(
1204            (width_px - 1.0).abs() < 0.05,
1205            "expected ~1px stroke, got {width_px}"
1206        );
1207    }
1208
1209    #[test]
1210    fn viewport_stroke_width_uses_visible_view_bounds_when_zoomed() {
1211        let x = vec![0.0, 500.0];
1212        let y = vec![0.0, 0.0];
1213        let mut plot = LinePlot::new(x, y).unwrap();
1214        plot.set_line_width(2.0);
1215        let viewport = (1000, 500);
1216        let view_bounds = (0.0, 30.0, -1.0, 1.0);
1217
1218        let render_data =
1219            plot.render_data_with_viewport_and_view_bounds(Some(viewport), Some(view_bounds));
1220
1221        assert_eq!(render_data.pipeline_type, PipelineType::Triangles);
1222        let v0 = render_data.vertices[0].position;
1223        let v1 = render_data.vertices[1].position;
1224        let px_per_y = viewport.1 as f32 / (view_bounds.3 - view_bounds.2) as f32;
1225        let width_px = (v0[1] - v1[1]).abs() * px_per_y;
1226        assert!(
1227            (width_px - 2.0).abs() < 0.05,
1228            "expected zoomed stroke to remain ~2px, got {width_px}"
1229        );
1230    }
1231}