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