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