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