Skip to main content

runmat_plot/gui/
plot_overlay.rs

1//! GUI overlay system for interactive plot controls and annotations
2//!
3//! This module handles the egui-based UI that sits on top of the WGPU-rendered
4//! plot content, providing controls, axis labels, grid lines, and titles.
5
6use crate::core::{plot_utils, PlotRenderer};
7use crate::plots::TextStyle;
8use crate::styling::{ModernDarkTheme, PlotThemeConfig, ThemeVariant};
9use egui::{Align2, Color32, Context, FontId, Pos2, Rect, Stroke};
10use glam::{Vec3, Vec4};
11
12/// GUI overlay manager for plot annotations and controls
13pub struct PlotOverlay {
14    /// Current theme
15    theme: PlotThemeConfig,
16
17    /// Cached plot area from last frame
18    plot_area: Option<Rect>,
19    /// Cached per-axes content rectangles from last frame (egui points, snapped)
20    axes_plot_rects: Vec<Rect>,
21    /// Cached toolbar rectangle (egui points)
22    toolbar_rect: Option<Rect>,
23    /// Cached sidebar rectangle (egui points)
24    sidebar_rect: Option<Rect>,
25
26    /// Show debug information
27    show_debug: bool,
28
29    /// Show Dystr information modal
30    show_dystr_modal: bool,
31
32    // Toolbar state
33    want_save_png: bool,
34    want_save_svg: bool,
35    want_reset_view: bool,
36    want_toggle_grid: Option<bool>,
37    want_toggle_legend: Option<bool>,
38}
39
40/// Configuration for the plot overlay
41#[derive(Debug, Clone)]
42pub struct OverlayConfig {
43    /// Whether to show the sidebar with controls
44    pub show_sidebar: bool,
45
46    /// Whether to show the top toolbar (legend/grid/reset/save).
47    pub show_toolbar: bool,
48
49    /// Scale factor applied to overlay font sizes (1.0 = default).
50    pub font_scale: f32,
51
52    /// Whether to show grid lines
53    pub show_grid: bool,
54
55    /// Whether to show axis labels
56    pub show_axes: bool,
57
58    /// Whether to show plot title
59    pub show_title: bool,
60
61    /// Custom plot title (if any)
62    pub title: Option<String>,
63
64    /// Custom axis labels
65    pub x_label: Option<String>,
66    pub y_label: Option<String>,
67
68    /// Sidebar width
69    pub sidebar_width: f32,
70
71    /// Margins around plot area
72    pub plot_margins: PlotMargins,
73}
74
75#[derive(Debug, Clone)]
76pub struct PlotMargins {
77    pub left: f32,
78    pub right: f32,
79    pub top: f32,
80    pub bottom: f32,
81}
82
83#[derive(Debug, Clone, Copy)]
84struct PanelLayout {
85    plot_rect: Rect,
86    frame_rect: Rect,
87    title_rect: Rect,
88    x_label_rect: Rect,
89    y_label_rect: Rect,
90}
91
92impl Default for OverlayConfig {
93    fn default() -> Self {
94        Self {
95            show_sidebar: true,
96            show_toolbar: true,
97            font_scale: 1.0,
98            show_grid: true,
99            show_axes: true,
100            show_title: true,
101            title: Some("Plot".to_string()),
102            x_label: Some("X".to_string()),
103            y_label: Some("Y".to_string()),
104            sidebar_width: 280.0,
105            plot_margins: PlotMargins {
106                left: 60.0,
107                right: 20.0,
108                top: 40.0,
109                bottom: 60.0,
110            },
111        }
112    }
113}
114
115/// Information about the current frame's UI state
116#[derive(Debug)]
117pub struct FrameInfo {
118    /// The plot area where WGPU should render
119    pub plot_area: Option<Rect>,
120
121    /// Whether the UI consumed any input events
122    pub consumed_input: bool,
123
124    /// Performance metrics to display
125    pub metrics: OverlayMetrics,
126}
127
128#[derive(Debug, Default)]
129pub struct OverlayMetrics {
130    pub vertex_count: usize,
131    pub triangle_count: usize,
132    pub render_time_ms: f64,
133    pub fps: f32,
134}
135
136impl Default for PlotOverlay {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl PlotOverlay {
143    const SUBPLOT_GAP_POINTS: f32 = 6.0;
144
145    fn style_color(style: &TextStyle, fallback: Color32) -> Color32 {
146        style
147            .color
148            .map(|c| {
149                Color32::from_rgb(
150                    (c.x.clamp(0.0, 1.0) * 255.0) as u8,
151                    (c.y.clamp(0.0, 1.0) * 255.0) as u8,
152                    (c.z.clamp(0.0, 1.0) * 255.0) as u8,
153                )
154            })
155            .unwrap_or(fallback)
156    }
157
158    fn style_font_size(style: &TextStyle, default_size: f32, scale: f32) -> f32 {
159        style.font_size.unwrap_or(default_size) * scale.max(0.75)
160    }
161
162    fn style_is_bold(style: &TextStyle) -> bool {
163        style
164            .font_weight
165            .as_deref()
166            .map(|weight| weight.eq_ignore_ascii_case("bold"))
167            .unwrap_or(false)
168    }
169
170    #[allow(clippy::too_many_arguments)]
171    fn paint_styled_text(
172        painter: &egui::Painter,
173        pos: Pos2,
174        align: Align2,
175        text: &str,
176        font_size: f32,
177        color: Color32,
178        bold: bool,
179        shadow_alpha: u8,
180    ) {
181        let font = FontId::proportional(font_size);
182        painter.text(
183            pos + egui::vec2(1.0, 1.0),
184            align,
185            text,
186            font.clone(),
187            Color32::from_rgba_premultiplied(0, 0, 0, shadow_alpha),
188        );
189        painter.text(pos, align, text, font.clone(), color);
190        if bold {
191            painter.text(pos + egui::vec2(0.6, 0.0), align, text, font.clone(), color);
192            painter.text(pos + egui::vec2(0.0, 0.6), align, text, font.clone(), color);
193            painter.text(pos + egui::vec2(0.6, 0.6), align, text, font, color);
194        }
195    }
196
197    fn label_stride(labels: &[String], axis_span_px: f32, font_size_px: f32) -> usize {
198        if labels.len() <= 1 || axis_span_px <= 1.0 {
199            return 1;
200        }
201        let max_chars = labels
202            .iter()
203            .map(|label| truncate_label(label, 14).chars().count())
204            .max()
205            .unwrap_or(0) as f32;
206        let estimated_label_width = (max_chars * font_size_px * 0.55).max(font_size_px * 2.0);
207        let slot_width = (axis_span_px / labels.len() as f32).max(1.0);
208        ((estimated_label_width / slot_width).ceil().max(1.0)) as usize
209    }
210
211    #[allow(clippy::too_many_arguments)]
212    fn draw_histogram_axis_ticks(
213        &self,
214        ui: &mut egui::Ui,
215        plot_rect: Rect,
216        ppp: f32,
217        axis_color: Color32,
218        label_color: Color32,
219        tick_length: f32,
220        label_offset: f32,
221        tick_font: FontId,
222        border_bottom: f32,
223        x_min: f64,
224        x_max: f64,
225        edges: &[f64],
226    ) {
227        if edges.len() < 2 || (x_max - x_min).abs() <= f64::EPSILON {
228            return;
229        }
230        let labels: Vec<String> = edges
231            .iter()
232            .map(|value| plot_utils::format_tick_label(*value))
233            .collect();
234        let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
235        let denom = (edges.len() - 1) as f64;
236        for (idx, label) in labels.iter().enumerate() {
237            if idx != 0 && idx != labels.len() - 1 && idx % stride != 0 {
238                continue;
239            }
240            let frac = idx as f64 / denom;
241            let x_val = x_min + frac * (x_max - x_min);
242            let x_screen =
243                plot_rect.min.x + ((x_val - x_min) / (x_max - x_min)) as f32 * plot_rect.width();
244            let x_screen = Self::snap_coord(x_screen, ppp);
245            ui.painter().line_segment(
246                [
247                    Pos2::new(x_screen, border_bottom),
248                    Pos2::new(x_screen, border_bottom + tick_length),
249                ],
250                Stroke::new(1.0, axis_color),
251            );
252            ui.painter().text(
253                Pos2::new(x_screen, border_bottom + label_offset),
254                Align2::CENTER_CENTER,
255                label,
256                tick_font.clone(),
257                label_color,
258            );
259        }
260    }
261
262    /// Create a new plot overlay
263    pub fn new() -> Self {
264        Self {
265            theme: PlotThemeConfig::default(),
266            plot_area: None,
267            axes_plot_rects: Vec::new(),
268            toolbar_rect: None,
269            sidebar_rect: None,
270            show_debug: false,
271            show_dystr_modal: false,
272            want_save_png: false,
273            want_save_svg: false,
274            want_reset_view: false,
275            want_toggle_grid: None,
276            want_toggle_legend: None,
277        }
278    }
279
280    pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
281        self.theme = theme;
282    }
283
284    fn has_visible_text(text: Option<&str>) -> bool {
285        text.map(|s| !s.trim().is_empty()).unwrap_or(false)
286    }
287
288    fn approx_text_width_points(text: &str, font_size: f32) -> f32 {
289        (text.chars().count() as f32) * font_size * 0.56
290    }
291
292    fn estimate_y_axis_band_width(
293        &self,
294        plot_renderer: &PlotRenderer,
295        axes_index: usize,
296        has_y_label: bool,
297        scale: f32,
298    ) -> f32 {
299        let tick_font_size = 10.0 * scale;
300        let label_offset = 15.0 * scale;
301
302        let y_log = plot_renderer.overlay_y_log_for_axes(axes_index);
303        let categorical = plot_renderer
304            .overlay_categorical_labels_for_axes(axes_index)
305            .filter(|(is_x, _)| !*is_x)
306            .map(|(_, labels)| labels)
307            .or_else(|| {
308                plot_renderer
309                    .overlay_categorical_labels()
310                    .and_then(|(is_x, labels)| if !is_x { Some(labels.clone()) } else { None })
311            });
312
313        let max_label_width = if let Some(labels) = categorical {
314            labels
315                .iter()
316                .map(|label| {
317                    Self::approx_text_width_points(&truncate_label(label, 14), tick_font_size)
318                })
319                .fold(0.0_f32, f32::max)
320        } else if let Some((_x_min, _x_max, y_min, y_max)) =
321            plot_renderer.overlay_display_bounds_for_axes(axes_index)
322        {
323            if y_log && y_min > 0.0 && y_max > 0.0 {
324                let start_decade = y_min.log10().floor() as i32;
325                let end_decade = y_max.log10().ceil() as i32;
326                (start_decade..=end_decade)
327                    .map(|d| Self::approx_text_width_points(&format!("10^{d}"), tick_font_size))
328                    .fold(0.0_f32, f32::max)
329            } else {
330                plot_utils::generate_major_ticks(y_min, y_max)
331                    .into_iter()
332                    .map(plot_utils::format_tick_label)
333                    .map(|label| Self::approx_text_width_points(&label, tick_font_size))
334                    .fold(0.0_f32, f32::max)
335            }
336        } else {
337            Self::approx_text_width_points("-1.00", tick_font_size)
338        };
339
340        let y_tick_zone = label_offset + max_label_width * 0.5 + 4.0 * scale;
341        let y_label_zone = if has_y_label {
342            11.0 * scale
343        } else {
344            4.0 * scale
345        };
346        (y_tick_zone + y_label_zone).max(24.0 * scale)
347    }
348
349    #[allow(clippy::too_many_arguments)]
350    fn layout_2d_panel(
351        &self,
352        outer: Rect,
353        plot_renderer: &PlotRenderer,
354        axes_index: usize,
355        title: Option<&str>,
356        x_label: Option<&str>,
357        y_label: Option<&str>,
358        scale: f32,
359    ) -> PanelLayout {
360        let scale = scale.max(0.75);
361        let has_title = Self::has_visible_text(title);
362        let has_x_label = Self::has_visible_text(x_label);
363        let has_y_label = Self::has_visible_text(y_label);
364        let outer_w = outer.width().max(1.0);
365        let outer_h = outer.height().max(1.0);
366        let title_gap = if has_title { 4.0 * scale } else { 1.5 * scale };
367        let x_gap = 4.0 * scale;
368        let right_pad = 3.0 * scale;
369
370        let mut title_h = if has_title {
371            (28.0 * scale).min(outer_h * 0.16)
372        } else {
373            0.0
374        };
375        let mut x_h = ((24.0 + if has_x_label { 14.0 } else { 0.0 }) * scale).min(outer_h * 0.28);
376        let y_band_estimate =
377            self.estimate_y_axis_band_width(plot_renderer, axes_index, has_y_label, scale);
378        let mut y_w = y_band_estimate.min(outer_w * 0.30);
379
380        let min_plot_w = (outer_w * 0.56).max(44.0 * scale).min(outer_w);
381        let min_plot_h = (outer_h * 0.54).max(44.0 * scale).min(outer_h);
382
383        if outer_w - y_w < min_plot_w {
384            y_w = (outer_w - min_plot_w).max(0.0);
385        }
386
387        let available_h = outer_h - title_h - title_gap - x_h - x_gap;
388        if available_h < min_plot_h {
389            let deficit = min_plot_h - available_h;
390            let reducible = title_h + x_h;
391            if reducible > 0.0 {
392                let keep = ((reducible - deficit).max(0.0)) / reducible;
393                title_h *= keep;
394                x_h *= keep;
395            }
396        }
397
398        let plot_rect = Rect::from_min_max(
399            egui::pos2(outer.min.x + y_w, outer.min.y + title_h + title_gap),
400            egui::pos2(
401                (outer.max.x - right_pad).max(outer.min.x + y_w + 1.0),
402                outer.max.y - x_h - x_gap,
403            ),
404        );
405        let frame_rect = plot_rect;
406        let title_rect = Rect::from_min_max(
407            egui::pos2(outer.min.x, outer.min.y),
408            egui::pos2(outer.max.x, plot_rect.min.y),
409        );
410        let x_label_rect = Rect::from_min_max(
411            egui::pos2(outer.min.x, plot_rect.max.y),
412            egui::pos2(outer.max.x, outer.max.y),
413        );
414        let y_label_rect = Rect::from_min_max(
415            egui::pos2(outer.min.x, plot_rect.min.y),
416            egui::pos2(plot_rect.min.x, plot_rect.max.y),
417        );
418        PanelLayout {
419            plot_rect,
420            frame_rect,
421            title_rect,
422            x_label_rect,
423            y_label_rect,
424        }
425    }
426
427    fn layout_3d_panel(&self, outer: Rect, title: Option<&str>, scale: f32) -> PanelLayout {
428        let scale = scale.max(0.75);
429        let title_h = if Self::has_visible_text(title) {
430            (28.0 * scale).min(outer.height().max(1.0) * 0.16)
431        } else {
432            0.0
433        };
434        let plot_rect =
435            Rect::from_min_max(egui::pos2(outer.min.x, outer.min.y + title_h), outer.max);
436        let title_rect = Rect::from_min_max(outer.min, egui::pos2(outer.max.x, plot_rect.min.y));
437        PanelLayout {
438            plot_rect,
439            frame_rect: plot_rect,
440            title_rect,
441            x_label_rect: plot_rect,
442            y_label_rect: plot_rect,
443        }
444    }
445
446    fn axes_is_3d(plot_renderer: &PlotRenderer, axes_index: usize) -> bool {
447        let cam = plot_renderer
448            .axes_camera(axes_index)
449            .unwrap_or_else(|| plot_renderer.camera());
450        matches!(
451            cam.projection,
452            crate::core::camera::ProjectionType::Perspective { .. }
453        )
454    }
455
456    fn panel_layout_for_axes(
457        &self,
458        outer: Rect,
459        plot_renderer: &PlotRenderer,
460        axes_index: usize,
461        scale: f32,
462    ) -> PanelLayout {
463        if Self::axes_is_3d(plot_renderer, axes_index) {
464            self.layout_3d_panel(
465                outer,
466                plot_renderer
467                    .overlay_title_for_axes(axes_index)
468                    .map(|s| s.as_str()),
469                scale,
470            )
471        } else {
472            self.layout_2d_panel(
473                outer,
474                plot_renderer,
475                axes_index,
476                plot_renderer
477                    .overlay_title_for_axes(axes_index)
478                    .map(|s| s.as_str()),
479                plot_renderer
480                    .overlay_x_label_for_axes(axes_index)
481                    .map(|s| s.as_str()),
482                plot_renderer
483                    .overlay_y_label_for_axes(axes_index)
484                    .map(|s| s.as_str()),
485                scale,
486            )
487        }
488    }
489
490    pub fn compute_subplot_plot_rects(
491        &self,
492        outer: Rect,
493        plot_renderer: &PlotRenderer,
494        font_scale: f32,
495    ) -> Vec<Rect> {
496        let plot_area = Self::outer_plot_area_for_axes(outer, plot_renderer);
497        let (rows, cols) = plot_renderer.figure_axes_grid();
498        if rows * cols <= 1 {
499            vec![
500                self.panel_layout_for_axes(plot_area, plot_renderer, 0, font_scale)
501                    .plot_rect,
502            ]
503        } else {
504            let rects = self.compute_subplot_rects(
505                plot_area,
506                rows,
507                cols,
508                Self::SUBPLOT_GAP_POINTS,
509                Self::SUBPLOT_GAP_POINTS,
510            );
511            rects
512                .into_iter()
513                .enumerate()
514                .map(|(axes_index, rect)| {
515                    self.panel_layout_for_axes(rect, plot_renderer, axes_index, font_scale)
516                        .plot_rect
517                })
518                .collect()
519        }
520    }
521
522    pub fn snap_rect_to_pixels(rect: Rect, pixels_per_point: f32) -> Rect {
523        let ppp = pixels_per_point.max(0.5);
524        let min_x = (rect.min.x * ppp).round() / ppp;
525        let min_y = (rect.min.y * ppp).round() / ppp;
526        let width = (rect.width() * ppp).round().max(1.0) / ppp;
527        let height = (rect.height() * ppp).round().max(1.0) / ppp;
528        Rect::from_min_size(egui::pos2(min_x, min_y), egui::vec2(width, height))
529    }
530
531    fn snap_coord(value: f32, pixels_per_point: f32) -> f32 {
532        let ppp = pixels_per_point.max(0.5);
533        (value * ppp).round() / ppp
534    }
535
536    fn border_centerline_edges(
537        plot_rect: Rect,
538        pixels_per_point: f32,
539        stroke_width: f32,
540    ) -> (f32, f32, f32, f32) {
541        let offset = stroke_width * 0.5;
542        let left = Self::snap_coord(plot_rect.min.x - offset, pixels_per_point);
543        let right = Self::snap_coord(plot_rect.max.x + offset, pixels_per_point);
544        let top = Self::snap_coord(plot_rect.min.y - offset, pixels_per_point);
545        let bottom = Self::snap_coord(plot_rect.max.y + offset, pixels_per_point);
546        (left, right, top, bottom)
547    }
548
549    fn draw_2d_border(&self, ui: &mut egui::Ui, plot_rect: Rect) {
550        let stroke = Stroke::new(1.5, self.theme_axis_color());
551        let ppp = ui.ctx().pixels_per_point();
552        let (left, right, top, bottom) =
553            Self::border_centerline_edges(plot_rect, ppp, stroke.width);
554        ui.painter()
555            .line_segment([Pos2::new(left, top), Pos2::new(right, top)], stroke);
556        ui.painter()
557            .line_segment([Pos2::new(left, bottom), Pos2::new(right, bottom)], stroke);
558        ui.painter()
559            .line_segment([Pos2::new(left, top), Pos2::new(left, bottom)], stroke);
560        ui.painter()
561            .line_segment([Pos2::new(right, top), Pos2::new(right, bottom)], stroke);
562    }
563
564    fn draw_plot_box_mask(&self, ui: &mut egui::Ui, plot_rect: Rect) {
565        let mask = 2.0;
566        let bg = self.theme_background_color();
567        let top = Rect::from_min_max(
568            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
569            Pos2::new(plot_rect.max.x + mask, plot_rect.min.y),
570        );
571        let bottom = Rect::from_min_max(
572            Pos2::new(plot_rect.min.x - mask, plot_rect.max.y),
573            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
574        );
575        let left = Rect::from_min_max(
576            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
577            Pos2::new(plot_rect.min.x, plot_rect.max.y + mask),
578        );
579        let right = Rect::from_min_max(
580            Pos2::new(plot_rect.max.x, plot_rect.min.y - mask),
581            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
582        );
583        ui.painter().rect_filled(top, 0.0, bg);
584        ui.painter().rect_filled(bottom, 0.0, bg);
585        ui.painter().rect_filled(left, 0.0, bg);
586        ui.painter().rect_filled(right, 0.0, bg);
587    }
588
589    pub fn compute_subplot_plot_rects_snapped(
590        &self,
591        outer: Rect,
592        plot_renderer: &PlotRenderer,
593        font_scale: f32,
594        pixels_per_point: f32,
595    ) -> Vec<Rect> {
596        self.compute_subplot_plot_rects(outer, plot_renderer, font_scale)
597            .into_iter()
598            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
599            .collect()
600    }
601
602    pub fn outer_plot_area_for_axes(available_rect: Rect, plot_renderer: &PlotRenderer) -> Rect {
603        let (rows, cols) = plot_renderer.figure_axes_grid();
604        let single_axes_is_3d = rows * cols <= 1 && Self::axes_is_3d(plot_renderer, 0);
605        if single_axes_is_3d || rows * cols > 1 {
606            available_rect
607        } else {
608            available_rect.shrink2(egui::vec2(8.0, 8.0))
609        }
610    }
611
612    fn theme_text_color(&self) -> Color32 {
613        let text = self.theme.build_theme().get_text_color();
614        Color32::from_rgba_premultiplied(
615            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
616            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
617            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
618            (text.w.clamp(0.0, 1.0) * 255.0) as u8,
619        )
620    }
621
622    fn theme_axis_color(&self) -> Color32 {
623        let axis = self.theme.build_theme().get_axis_color();
624        Color32::from_rgba_premultiplied(
625            (axis.x.clamp(0.0, 1.0) * 255.0) as u8,
626            (axis.y.clamp(0.0, 1.0) * 255.0) as u8,
627            (axis.z.clamp(0.0, 1.0) * 255.0) as u8,
628            (axis.w.clamp(0.0, 1.0) * 255.0) as u8,
629        )
630    }
631
632    fn theme_background_color(&self) -> Color32 {
633        let bg = self.theme.build_theme().get_background_color();
634        Color32::from_rgba_premultiplied(
635            (bg.x.clamp(0.0, 1.0) * 255.0) as u8,
636            (bg.y.clamp(0.0, 1.0) * 255.0) as u8,
637            (bg.z.clamp(0.0, 1.0) * 255.0) as u8,
638            (bg.w.clamp(0.0, 1.0) * 255.0) as u8,
639        )
640    }
641
642    fn themed_grid_colors(&self) -> (Color32, Color32) {
643        let grid = self.theme.build_theme().get_grid_color();
644        let major = Color32::from_rgba_premultiplied(
645            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
646            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
647            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
648            ((grid.w.clamp(0.15, 0.55)) * 255.0) as u8,
649        );
650        let minor = Color32::from_rgba_premultiplied(
651            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
652            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
653            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
654            ((grid.w * 0.6).clamp(0.10, 0.34) * 255.0) as u8,
655        );
656        (major, minor)
657    }
658
659    /// Apply theme to egui context
660    pub fn apply_theme(&self, ctx: &Context) {
661        match self.theme.variant {
662            ThemeVariant::ModernDark => {
663                ModernDarkTheme::default().apply_to_egui(ctx);
664            }
665            ThemeVariant::ClassicLight => {
666                ctx.set_visuals(egui::Visuals::light());
667            }
668            ThemeVariant::HighContrast => {
669                let mut visuals = egui::Visuals::dark();
670                visuals.extreme_bg_color = egui::Color32::BLACK;
671                visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
672                visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
673                ctx.set_visuals(visuals);
674            }
675            ThemeVariant::Custom => {
676                let mut visuals = egui::Visuals::light();
677                let bg = self.theme.build_theme().get_background_color();
678                if bg.x + bg.y + bg.z < 1.5 {
679                    visuals = egui::Visuals::dark();
680                }
681                ctx.set_visuals(visuals);
682            }
683        }
684
685        // Make context transparent so WGPU content shows through
686        let mut visuals = ctx.style().visuals.clone();
687        visuals.window_fill = Color32::TRANSPARENT;
688        visuals.panel_fill = Color32::TRANSPARENT;
689        visuals.extreme_bg_color = Color32::TRANSPARENT;
690        visuals.faint_bg_color = Color32::TRANSPARENT;
691        visuals.widgets.noninteractive.bg_fill = Color32::TRANSPARENT;
692        visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
693        visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
694        visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
695        visuals.widgets.open.bg_fill = Color32::TRANSPARENT;
696        ctx.set_visuals(visuals);
697    }
698
699    /// Render the complete overlay UI
700    pub fn render(
701        &mut self,
702        ctx: &Context,
703        plot_renderer: &PlotRenderer,
704        config: &OverlayConfig,
705        metrics: OverlayMetrics,
706    ) -> FrameInfo {
707        let mut consumed_input = false;
708        let mut plot_area = None;
709
710        // Render sidebar if enabled
711        if config.show_sidebar {
712            consumed_input |= self.render_sidebar(ctx, plot_renderer, config, &metrics);
713        }
714
715        // Render main plot area
716        let central_response = egui::CentralPanel::default()
717            .frame(egui::Frame::none()) // Transparent frame
718            .show(ctx, |ui| {
719                // Toolbar (top-right)
720                if config.show_toolbar {
721                    egui::TopBottomPanel::top("plot_toolbar")
722                        .frame(egui::Frame::none())
723                        .show_inside(ui, |ui| {
724                            let padded = ui.max_rect().shrink2(egui::vec2(12.0, 6.0));
725                            self.toolbar_rect = Some(padded);
726                            ui.allocate_ui_at_rect(padded, |ui| {
727                                ui.with_layout(
728                                    egui::Layout::right_to_left(egui::Align::Center),
729                                    |ui| {
730                                        ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
731                                        ui.spacing_mut().button_padding = egui::vec2(8.0, 6.0);
732                                        if ui.button("Save PNG").clicked() {
733                                            self.want_save_png = true;
734                                        }
735                                        if ui.button("Save SVG").clicked() {
736                                            self.want_save_svg = true;
737                                        }
738                                        if ui.button("Reset View").clicked() {
739                                            self.want_reset_view = true;
740                                        }
741                                        let mut grid = plot_renderer.overlay_show_grid();
742                                        if ui.toggle_value(&mut grid, "Grid").changed() {
743                                            self.want_toggle_grid = Some(grid);
744                                        }
745                                        let mut legend = plot_renderer.overlay_show_legend();
746                                        if ui.toggle_value(&mut legend, "Legend").changed() {
747                                            self.want_toggle_legend = Some(legend);
748                                        }
749                                    },
750                                );
751                            });
752                        });
753                } else {
754                    self.toolbar_rect = None;
755                }
756                plot_area = Some(self.render_plot_area(ui, plot_renderer, config));
757            });
758
759        consumed_input |= central_response.response.hovered();
760
761        // Render Dystr modal if needed
762        if self.show_dystr_modal {
763            consumed_input |= self.render_dystr_modal(ctx);
764        }
765
766        // Store plot area for next frame
767        self.plot_area = plot_area;
768
769        FrameInfo {
770            plot_area,
771            consumed_input,
772            metrics,
773        }
774    }
775
776    /// Render the sidebar with controls and information
777    fn render_sidebar(
778        &mut self,
779        ctx: &Context,
780        plot_renderer: &PlotRenderer,
781        config: &OverlayConfig,
782        metrics: &OverlayMetrics,
783    ) -> bool {
784        let mut consumed_input = false;
785
786        let sidebar_response = egui::SidePanel::left("plot_controls")
787            .resizable(true)
788            .default_width(config.sidebar_width)
789            .min_width(200.0)
790            .show(ctx, |ui| {
791                ui.style_mut().visuals.widgets.noninteractive.bg_fill = Color32::from_gray(25);
792                ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::from_gray(35);
793                ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::from_gray(45);
794
795                // Header with Dystr branding
796                ui.horizontal(|ui| {
797                    // Placeholder for Dystr logo (32x32 square)
798                    let logo_size = egui::Vec2::splat(32.0);
799                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::click()).0;
800
801                    // Draw placeholder logo background
802                    ui.painter().rect_filled(
803                        logo_rect,
804                        4.0, // rounded corners
805                        Color32::from_rgb(100, 100, 100),
806                    );
807
808                    // Draw "D" placeholder text in the logo area
809                    ui.painter().text(
810                        logo_rect.center(),
811                        Align2::CENTER_CENTER,
812                        "D",
813                        FontId::proportional(20.0),
814                        Color32::WHITE,
815                    );
816
817                    ui.vertical(|ui| {
818                        ui.heading("RunMat");
819                        ui.horizontal(|ui| {
820                            ui.small("a community project by ");
821                            if ui.small_button("dystr.com").clicked() {
822                                self.show_dystr_modal = true;
823                            }
824                        });
825                    });
826                });
827                ui.separator();
828                ui.label("GC Stats: [not available]");
829
830                // Camera information
831                ui.collapsing("📷 Camera", |ui| {
832                    let camera = plot_renderer.camera();
833                    ui.label(format!(
834                        "Position: {:.2}, {:.2}, {:.2}",
835                        camera.position.x, camera.position.y, camera.position.z
836                    ));
837                    ui.label(format!(
838                        "Target: {:.2}, {:.2}, {:.2}",
839                        camera.target.x, camera.target.y, camera.target.z
840                    ));
841
842                    if let Some(vb) = plot_renderer.view_bounds() {
843                        ui.label(format!("View X: {:.2} to {:.2}", vb.0, vb.1));
844                        ui.label(format!("View Y: {:.2} to {:.2}", vb.2, vb.3));
845                    }
846                    if let Some(db) = plot_renderer.data_bounds() {
847                        ui.label(format!("Data X: {:.2} to {:.2}", db.0, db.1));
848                        ui.label(format!("Data Y: {:.2} to {:.2}", db.2, db.3));
849                    }
850                });
851
852                // Scene information
853                ui.collapsing("🎬 Scene", |ui| {
854                    let stats = plot_renderer.scene_statistics();
855                    ui.label(format!("Nodes: {}", stats.total_nodes));
856                    ui.label(format!("Visible: {}", stats.visible_nodes));
857                    ui.label(format!("Vertices: {}", stats.total_vertices));
858                    ui.label(format!("Triangles: {}", stats.total_triangles));
859                });
860
861                // Performance metrics
862                ui.collapsing("⚡ Performance", |ui| {
863                    ui.label(format!("FPS: {:.1}", metrics.fps));
864                    ui.label(format!("Render: {:.2}ms", metrics.render_time_ms));
865                    ui.label(format!("Vertices: {}", metrics.vertex_count));
866                    ui.label(format!("Triangles: {}", metrics.triangle_count));
867                });
868
869                // Theme selection
870                ui.collapsing("🎨 Theme", |ui| {
871                    let label = match self.theme.variant {
872                        ThemeVariant::ModernDark => "Modern Dark",
873                        ThemeVariant::ClassicLight => "Classic Light",
874                        ThemeVariant::HighContrast => "High Contrast",
875                        ThemeVariant::Custom => "Custom",
876                    };
877                    ui.label(format!("{label} (Active)"));
878                    ui.checkbox(&mut self.show_debug, "Show Debug Info");
879                });
880
881                ui.separator();
882
883                // Plot controls
884                ui.collapsing("🔧 Controls", |ui| {
885                    ui.label("🖱️ Orbit: MMB drag (or RMB drag)");
886                    ui.label("🖱️ Pan: Shift + MMB drag (or Shift + RMB drag)");
887                    ui.label("🖱️ Zoom: Scroll wheel (zooms to cursor)");
888                    ui.label("🖱️ Alt + LMB/MMB/RMB: Orbit/Pan/Zoom");
889                    ui.label("📱 Touch: Pinch to zoom");
890                });
891            });
892
893        consumed_input |= sidebar_response.response.hovered();
894        self.sidebar_rect = Some(sidebar_response.response.rect);
895        consumed_input
896    }
897
898    /// Render the main plot area with grid, axes, and annotations
899    fn render_plot_area(
900        &mut self,
901        ui: &mut egui::Ui,
902        plot_renderer: &PlotRenderer,
903        config: &OverlayConfig,
904    ) -> Rect {
905        let available_rect = ui.available_rect_before_wrap();
906        let mut rendered_axes_rects: Vec<Rect> = Vec::new();
907
908        let (rows, cols) = plot_renderer.figure_axes_grid();
909        let plot_rect = Self::outer_plot_area_for_axes(available_rect, plot_renderer);
910
911        // Use full available rectangular plot area (do not force square);
912        // camera fitting and axis_equal settings will control aspect.
913        let plot_area_rect = plot_rect;
914
915        if rows * cols > 1 {
916            let rects = self.compute_subplot_rects(
917                plot_area_rect,
918                rows,
919                cols,
920                Self::SUBPLOT_GAP_POINTS,
921                Self::SUBPLOT_GAP_POINTS,
922            );
923            for (i, cell_rect) in rects.iter().enumerate() {
924                let cam = plot_renderer
925                    .axes_camera(i)
926                    .unwrap_or_else(|| plot_renderer.camera());
927                let panel_layout =
928                    self.panel_layout_for_axes(*cell_rect, plot_renderer, i, config.font_scale);
929                let r =
930                    Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
931                let frame_rect =
932                    Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
933                rendered_axes_rects.push(r);
934                log::debug!(
935                    target: "runmat_plot.axes_layout",
936                    "computed axes panel layout axes_index={} rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
937                    i,
938                    rows,
939                    cols,
940                    Self::axes_is_3d(plot_renderer, i),
941                    cell_rect.min.x,
942                    cell_rect.min.y,
943                    cell_rect.max.x,
944                    cell_rect.max.y,
945                    frame_rect.min.x,
946                    frame_rect.min.y,
947                    frame_rect.max.x,
948                    frame_rect.max.y,
949                    r.min.x,
950                    r.min.y,
951                    r.max.x,
952                    r.max.y
953                );
954                if matches!(
955                    cam.projection,
956                    crate::core::camera::ProjectionType::Perspective { .. }
957                ) {
958                    if config.show_title {
959                        if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
960                            self.draw_title_in_rect(
961                                ui,
962                                panel_layout.title_rect,
963                                title,
964                                config.font_scale,
965                            );
966                        }
967                    }
968                    self.draw_3d_orientation_gizmo(ui, r, plot_renderer, i, config.font_scale);
969                    self.draw_3d_origin_axis_ticks(ui, r, plot_renderer, i, config.font_scale);
970                    self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
971                    for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
972                        self.draw_pie_label(ui, r, &label, pos, config.font_scale);
973                    }
974                    if plot_renderer.overlay_show_legend_for_axes(i) {
975                        let entries = plot_renderer.overlay_legend_entries_for_axes(i);
976                        self.draw_legend(ui, r, &entries, config.font_scale);
977                    }
978                    continue;
979                }
980                // Frame (2D only; 3D uses the axes cube instead)
981                if plot_renderer.overlay_show_box_for_axes(i) {
982                    self.draw_plot_box_mask(ui, r);
983                    self.draw_2d_border(ui, frame_rect);
984                }
985
986                // Grid (2D)
987                if config.show_grid && plot_renderer.overlay_show_grid_for_axes(i) {
988                    let b = plot_renderer.view_bounds_for_axes(i);
989                    self.draw_grid(ui, r, plot_renderer, b, Some(i));
990                }
991                // Axes (2D)
992                if config.show_axes {
993                    let b = plot_renderer.view_bounds_for_axes(i);
994                    self.draw_axes(ui, r, plot_renderer, config, b, Some(i));
995                }
996
997                if config.show_title {
998                    if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
999                        self.draw_title_in_rect(
1000                            ui,
1001                            panel_layout.title_rect,
1002                            title,
1003                            config.font_scale,
1004                        );
1005                    }
1006                }
1007                if !matches!(
1008                    cam.projection,
1009                    crate::core::camera::ProjectionType::Perspective { .. }
1010                ) {
1011                    if let Some(x_label) = plot_renderer.overlay_x_label_for_axes(i) {
1012                        self.draw_x_label_in_rect(
1013                            ui,
1014                            panel_layout.x_label_rect,
1015                            x_label,
1016                            config.font_scale,
1017                        );
1018                    }
1019                }
1020                if !matches!(
1021                    cam.projection,
1022                    crate::core::camera::ProjectionType::Perspective { .. }
1023                ) {
1024                    if let Some(y_label) = plot_renderer.overlay_y_label_for_axes(i) {
1025                        self.draw_y_label_in_rect(
1026                            ui,
1027                            panel_layout.y_label_rect,
1028                            y_label,
1029                            config.font_scale,
1030                        );
1031                    }
1032                }
1033                self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1034                for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1035                    self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1036                }
1037                if plot_renderer.overlay_show_legend_for_axes(i) {
1038                    let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1039                    self.draw_legend(ui, r, &entries, config.font_scale);
1040                }
1041            }
1042        } else {
1043            let cam = plot_renderer.camera();
1044            let panel_layout =
1045                self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale);
1046            let centered_plot_rect =
1047                Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1048            let centered_frame_rect =
1049                Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1050            rendered_axes_rects.push(centered_plot_rect);
1051            log::debug!(
1052                target: "runmat_plot.axes_layout",
1053                "computed axes panel layout axes_index=0 rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1054                rows,
1055                cols,
1056                Self::axes_is_3d(plot_renderer, 0),
1057                plot_area_rect.min.x,
1058                plot_area_rect.min.y,
1059                plot_area_rect.max.x,
1060                plot_area_rect.max.y,
1061                centered_frame_rect.min.x,
1062                centered_frame_rect.min.y,
1063                centered_frame_rect.max.x,
1064                centered_frame_rect.max.y,
1065                centered_plot_rect.min.x,
1066                centered_plot_rect.min.y,
1067                centered_plot_rect.max.x,
1068                centered_plot_rect.max.y
1069            );
1070            if config.show_title {
1071                if let Some(title) = plot_renderer
1072                    .overlay_title_for_axes(0)
1073                    .or(config.title.as_ref())
1074                {
1075                    self.draw_title_in_rect(ui, panel_layout.title_rect, title, config.font_scale);
1076                }
1077            }
1078            if matches!(
1079                cam.projection,
1080                crate::core::camera::ProjectionType::Perspective { .. }
1081            ) {
1082                self.draw_3d_orientation_gizmo(
1083                    ui,
1084                    centered_plot_rect,
1085                    plot_renderer,
1086                    0,
1087                    config.font_scale,
1088                );
1089                self.draw_3d_origin_axis_ticks(
1090                    ui,
1091                    centered_plot_rect,
1092                    plot_renderer,
1093                    0,
1094                    config.font_scale,
1095                );
1096                self.draw_projected_world_texts(
1097                    ui,
1098                    centered_plot_rect,
1099                    plot_renderer,
1100                    0,
1101                    config.font_scale,
1102                );
1103            } else {
1104                // Draw plot frame (2D only; 3D uses the axes cube instead)
1105                if plot_renderer.overlay_show_box() {
1106                    self.draw_plot_box_mask(ui, centered_plot_rect);
1107                    self.draw_2d_border(ui, centered_frame_rect);
1108                }
1109                // Draw grid if enabled
1110                if config.show_grid {
1111                    self.draw_grid(ui, centered_plot_rect, plot_renderer, None, None);
1112                }
1113
1114                // Draw axes if enabled
1115                if config.show_axes {
1116                    self.draw_axes(ui, centered_plot_rect, plot_renderer, config, None, None);
1117                    // Emphasize zero baseline if within data range
1118                    if let Some((x_min, x_max, y_min, y_max)) = plot_renderer
1119                        .view_bounds()
1120                        .or_else(|| plot_renderer.data_bounds())
1121                    {
1122                        let axis_color = self.theme_axis_color();
1123                        let zero_stroke = Stroke::new(1.5, axis_color);
1124                        if y_min < 0.0 && y_max > 0.0 {
1125                            let y_screen = centered_plot_rect.max.y
1126                                - ((0.0 - y_min) / (y_max - y_min)) as f32
1127                                    * centered_plot_rect.height();
1128                            ui.painter().line_segment(
1129                                [
1130                                    Pos2::new(centered_plot_rect.min.x, y_screen),
1131                                    Pos2::new(centered_plot_rect.max.x, y_screen),
1132                                ],
1133                                zero_stroke,
1134                            );
1135                        }
1136                        if x_min < 0.0 && x_max > 0.0 {
1137                            let x_screen = centered_plot_rect.min.x
1138                                + ((0.0 - x_min) / (x_max - x_min)) as f32
1139                                    * centered_plot_rect.width();
1140                            ui.painter().line_segment(
1141                                [
1142                                    Pos2::new(x_screen, centered_plot_rect.min.y),
1143                                    Pos2::new(x_screen, centered_plot_rect.max.y),
1144                                ],
1145                                zero_stroke,
1146                            );
1147                        }
1148                    }
1149                }
1150                if let Some(x_label) = plot_renderer
1151                    .overlay_x_label_for_axes(0)
1152                    .or(config.x_label.as_ref())
1153                {
1154                    self.draw_x_label_in_rect(
1155                        ui,
1156                        panel_layout.x_label_rect,
1157                        x_label,
1158                        config.font_scale,
1159                    );
1160                }
1161                if let Some(y_label) = plot_renderer
1162                    .overlay_y_label_for_axes(0)
1163                    .or(config.y_label.as_ref())
1164                {
1165                    self.draw_y_label_in_rect(
1166                        ui,
1167                        panel_layout.y_label_rect,
1168                        y_label,
1169                        config.font_scale,
1170                    );
1171                }
1172                self.draw_projected_world_texts(
1173                    ui,
1174                    centered_plot_rect,
1175                    plot_renderer,
1176                    0,
1177                    config.font_scale,
1178                );
1179            }
1180        }
1181        let centered_plot_rect = if rows * cols <= 1 {
1182            self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale)
1183                .plot_rect
1184        } else {
1185            plot_area_rect
1186        };
1187        for (label, pos) in if rows * cols <= 1 {
1188            plot_renderer.active_axes_pie_labels()
1189        } else {
1190            Vec::new()
1191        } {
1192            self.draw_pie_label(ui, centered_plot_rect, &label, pos, config.font_scale);
1193        }
1194
1195        // Draw legend if enabled and entries available
1196        if rows * cols <= 1 && plot_renderer.overlay_show_legend() {
1197            self.draw_legend(
1198                ui,
1199                centered_plot_rect,
1200                plot_renderer.overlay_legend_entries(),
1201                config.font_scale,
1202            );
1203        }
1204
1205        // Draw colorbar if enabled
1206        if plot_renderer.overlay_colorbar_enabled() {
1207            // Simple vertical colorbar on the right side inside plot
1208            let bar_width = 12.0;
1209            let pad = 8.0;
1210            let bar_rect = Rect::from_min_max(
1211                egui::pos2(
1212                    centered_plot_rect.max.x - bar_width - pad,
1213                    centered_plot_rect.min.y + pad,
1214                ),
1215                egui::pos2(
1216                    centered_plot_rect.max.x - pad,
1217                    centered_plot_rect.max.y - pad,
1218                ),
1219            );
1220            // Fill with gradient according to colormap
1221            let steps = 64;
1222            for i in 0..steps {
1223                let t0 = i as f32 / steps as f32;
1224                let t1 = (i + 1) as f32 / steps as f32;
1225                let y0 = bar_rect.min.y + (1.0 - t0) * bar_rect.height();
1226                let y1 = bar_rect.min.y + (1.0 - t1) * bar_rect.height();
1227                let cmap = plot_renderer.overlay_colormap();
1228                let c = cmap.map_value(t0);
1229                let col = Color32::from_rgb(
1230                    (c.x * 255.0) as u8,
1231                    (c.y * 255.0) as u8,
1232                    (c.z * 255.0) as u8,
1233                );
1234                ui.painter().rect_filled(
1235                    Rect::from_min_max(
1236                        egui::pos2(bar_rect.min.x, y1),
1237                        egui::pos2(bar_rect.max.x, y0),
1238                    ),
1239                    0.0,
1240                    col,
1241                );
1242            }
1243            let bg = plot_renderer.theme.build_theme().get_background_color();
1244            let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
1245            let border = if bg_luma > 0.62 {
1246                Color32::from_gray(60)
1247            } else {
1248                Color32::WHITE
1249            };
1250            ui.painter()
1251                .rect_stroke(bar_rect, 0.0, Stroke::new(1.0, border));
1252        }
1253
1254        self.axes_plot_rects = rendered_axes_rects;
1255        centered_plot_rect
1256    }
1257
1258    /// Compute subplot rectangles within a given plot area for a rows x cols grid (row-major)
1259    pub fn compute_subplot_rects(
1260        &self,
1261        outer: Rect,
1262        rows: usize,
1263        cols: usize,
1264        hgap: f32,
1265        vgap: f32,
1266    ) -> Vec<Rect> {
1267        let rows = rows.max(1) as f32;
1268        let cols = cols.max(1) as f32;
1269        let total_hgap = hgap * (cols - 1.0);
1270        let total_vgap = vgap * (rows - 1.0);
1271        let cell_w = ((outer.width()).max(1.0) - total_hgap).max(1.0) / cols;
1272        let cell_h = ((outer.height()).max(1.0) - total_vgap).max(1.0) / rows;
1273        let mut rects = Vec::new();
1274        for r in 0..rows as i32 {
1275            for c in 0..cols as i32 {
1276                let x = outer.min.x + c as f32 * (cell_w + hgap);
1277                let y = outer.min.y + r as f32 * (cell_h + vgap);
1278                rects.push(Rect::from_min_size(
1279                    egui::pos2(x, y),
1280                    egui::vec2(cell_w, cell_h),
1281                ));
1282            }
1283        }
1284        rects
1285    }
1286
1287    /// Draw grid lines based on data bounds
1288    fn draw_grid(
1289        &self,
1290        ui: &mut egui::Ui,
1291        plot_rect: Rect,
1292        plot_renderer: &PlotRenderer,
1293        view_bounds_override: Option<(f64, f64, f64, f64)>,
1294        axes_index: Option<usize>,
1295    ) {
1296        let ppp = ui.ctx().pixels_per_point();
1297        let edge_eps = 0.51 / ppp.max(0.5);
1298        if let Some(data_bounds) = view_bounds_override
1299            .or_else(|| plot_renderer.view_bounds())
1300            .or_else(|| plot_renderer.data_bounds())
1301        {
1302            let (grid_color_major, _grid_color_minor) = self.themed_grid_colors();
1303
1304            let (x_min, x_max, y_min, y_max) = data_bounds;
1305            let x_range = x_max - x_min;
1306            let y_range = y_max - y_min;
1307
1308            // Calculate tick intervals
1309            let x_log = axes_index
1310                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1311                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1312            let y_log = axes_index
1313                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1314                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1315
1316            let x_ticks = if x_log {
1317                Vec::new()
1318            } else {
1319                plot_utils::generate_major_ticks(x_min, x_max)
1320            };
1321            let y_ticks = if y_log {
1322                Vec::new()
1323            } else {
1324                plot_utils::generate_major_ticks(y_min, y_max)
1325            };
1326
1327            // Draw vertical grid lines (linear vs log)
1328            if x_log {
1329                // Decades within [x_min, x_max]
1330                let start_decade = x_min.log10().floor() as i32;
1331                let end_decade = x_max.log10().ceil() as i32;
1332                for d in start_decade..=end_decade {
1333                    let decade = 10f64.powi(d);
1334                    for m in [1.0, 2.0, 5.0].iter() {
1335                        let x_val = decade * m;
1336                        if x_val < x_min || x_val > x_max {
1337                            continue;
1338                        }
1339                        let x_screen = plot_rect.min.x
1340                            + ((x_val.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1341                                as f32
1342                                * plot_rect.width();
1343                        let x_screen = Self::snap_coord(x_screen, ppp);
1344                        if (x_screen - plot_rect.min.x).abs() <= edge_eps
1345                            || (x_screen - plot_rect.max.x).abs() <= edge_eps
1346                        {
1347                            continue;
1348                        }
1349                        ui.painter().line_segment(
1350                            [
1351                                Pos2::new(x_screen, plot_rect.min.y),
1352                                Pos2::new(x_screen, plot_rect.max.y),
1353                            ],
1354                            Stroke::new(0.8, grid_color_major),
1355                        );
1356                    }
1357                }
1358            } else {
1359                for x_val in x_ticks {
1360                    let x_screen =
1361                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1362                    let x_screen = Self::snap_coord(x_screen, ppp);
1363                    if (x_screen - plot_rect.min.x).abs() <= edge_eps
1364                        || (x_screen - plot_rect.max.x).abs() <= edge_eps
1365                    {
1366                        continue;
1367                    }
1368                    ui.painter().line_segment(
1369                        [
1370                            Pos2::new(x_screen, plot_rect.min.y),
1371                            Pos2::new(x_screen, plot_rect.max.y),
1372                        ],
1373                        Stroke::new(0.8, grid_color_major),
1374                    );
1375                }
1376            }
1377
1378            // Draw horizontal grid lines (linear vs log)
1379            if y_log {
1380                let start_decade = y_min.log10().floor() as i32;
1381                let end_decade = y_max.log10().ceil() as i32;
1382                for d in start_decade..=end_decade {
1383                    let decade = 10f64.powi(d);
1384                    for m in [1.0, 2.0, 5.0].iter() {
1385                        let y_val = decade * m;
1386                        if y_val < y_min || y_val > y_max {
1387                            continue;
1388                        }
1389                        let y_screen = plot_rect.max.y
1390                            - ((y_val.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1391                                as f32
1392                                * plot_rect.height();
1393                        let y_screen = Self::snap_coord(y_screen, ppp);
1394                        if (y_screen - plot_rect.min.y).abs() <= edge_eps
1395                            || (y_screen - plot_rect.max.y).abs() <= edge_eps
1396                        {
1397                            continue;
1398                        }
1399                        ui.painter().line_segment(
1400                            [
1401                                Pos2::new(plot_rect.min.x, y_screen),
1402                                Pos2::new(plot_rect.max.x, y_screen),
1403                            ],
1404                            Stroke::new(0.8, grid_color_major),
1405                        );
1406                    }
1407                }
1408            } else {
1409                for y_val in y_ticks {
1410                    let y_screen =
1411                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1412                    let y_screen = Self::snap_coord(y_screen, ppp);
1413                    if (y_screen - plot_rect.min.y).abs() <= edge_eps
1414                        || (y_screen - plot_rect.max.y).abs() <= edge_eps
1415                    {
1416                        continue;
1417                    }
1418                    ui.painter().line_segment(
1419                        [
1420                            Pos2::new(plot_rect.min.x, y_screen),
1421                            Pos2::new(plot_rect.max.x, y_screen),
1422                        ],
1423                        Stroke::new(0.8, grid_color_major),
1424                    );
1425                }
1426            }
1427        }
1428    }
1429
1430    /// Draw axis ticks and numeric labels
1431    fn draw_axes(
1432        &self,
1433        ui: &mut egui::Ui,
1434        plot_rect: Rect,
1435        plot_renderer: &PlotRenderer,
1436        config: &OverlayConfig,
1437        view_bounds_override: Option<(f64, f64, f64, f64)>,
1438        axes_index: Option<usize>,
1439    ) {
1440        let ppp = ui.ctx().pixels_per_point();
1441        if let Some(data_bounds) = view_bounds_override
1442            .or_else(|| plot_renderer.view_bounds())
1443            .or_else(|| plot_renderer.data_bounds())
1444        {
1445            let (x_min, x_max, y_min, y_max) = data_bounds;
1446            let x_range = x_max - x_min;
1447            let y_range = y_max - y_min;
1448            let scale = config.font_scale.max(0.75);
1449            let tick_length = 6.0 * scale;
1450            let label_offset = 15.0 * scale;
1451            let tick_font = FontId::proportional(10.0 * scale);
1452            let axis_color = self.theme_axis_color();
1453            let label_color = self.theme_text_color();
1454            let border_left = plot_rect.min.x;
1455            let border_bottom = plot_rect.max.y;
1456
1457            let x_log = axes_index
1458                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1459                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1460            let y_log = axes_index
1461                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1462                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1463
1464            // Histogram numeric tick support and categorical axis support
1465            let (mut cat_x, mut cat_y) = (false, false);
1466            let mut custom_hist_x = false;
1467            if let Some((true, edges)) =
1468                axes_index.and_then(|idx| plot_renderer.overlay_histogram_edges_for_axes(idx))
1469            {
1470                custom_hist_x = true;
1471                self.draw_histogram_axis_ticks(
1472                    ui,
1473                    plot_rect,
1474                    ppp,
1475                    axis_color,
1476                    label_color,
1477                    tick_length,
1478                    label_offset,
1479                    tick_font.clone(),
1480                    border_bottom,
1481                    x_min,
1482                    x_max,
1483                    &edges,
1484                );
1485            }
1486            if let Some((is_x, labels)) = axes_index
1487                .and_then(|idx| plot_renderer.overlay_categorical_labels_for_axes(idx))
1488                .or_else(|| {
1489                    plot_renderer
1490                        .overlay_categorical_labels()
1491                        .map(|(is_x, labels)| (is_x, labels.clone()))
1492                })
1493            {
1494                if is_x {
1495                    cat_x = true;
1496                } else {
1497                    cat_y = true;
1498                }
1499                if is_x {
1500                    let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1501                    // Draw X categorical labels at integer positions (1..n)
1502                    for (label_idx, label) in labels.iter().enumerate() {
1503                        if label_idx != 0
1504                            && label_idx != labels.len() - 1
1505                            && label_idx % stride != 0
1506                        {
1507                            continue;
1508                        }
1509                        let x_val = (label_idx + 1) as f64;
1510                        if x_val < x_min || x_val > x_max {
1511                            continue;
1512                        }
1513                        let x_screen = plot_rect.min.x
1514                            + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1515                        let x_screen = Self::snap_coord(x_screen, ppp);
1516                        // Tick
1517                        ui.painter().line_segment(
1518                            [
1519                                Pos2::new(x_screen, border_bottom),
1520                                Pos2::new(x_screen, border_bottom + tick_length),
1521                            ],
1522                            Stroke::new(1.0, axis_color),
1523                        );
1524                        // Label
1525                        let text = truncate_label(label, 14);
1526                        ui.painter().text(
1527                            Pos2::new(x_screen, border_bottom + label_offset),
1528                            Align2::CENTER_CENTER,
1529                            text,
1530                            tick_font.clone(),
1531                            label_color,
1532                        );
1533                    }
1534                } else {
1535                    let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1536                    // Draw Y categorical labels at integer positions (1..n)
1537                    for (label_idx, label) in labels.iter().enumerate() {
1538                        if label_idx != 0
1539                            && label_idx != labels.len() - 1
1540                            && label_idx % stride != 0
1541                        {
1542                            continue;
1543                        }
1544                        let y_val = (label_idx + 1) as f64;
1545                        if y_val < y_min || y_val > y_max {
1546                            continue;
1547                        }
1548                        let y_screen = plot_rect.max.y
1549                            - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1550                        let y_screen = Self::snap_coord(y_screen, ppp);
1551                        // Tick
1552                        ui.painter().line_segment(
1553                            [
1554                                Pos2::new(border_left - tick_length, y_screen),
1555                                Pos2::new(border_left, y_screen),
1556                            ],
1557                            Stroke::new(1.0, axis_color),
1558                        );
1559                        // Label
1560                        let text = truncate_label(label, 14);
1561                        ui.painter().text(
1562                            Pos2::new(border_left - label_offset, y_screen),
1563                            Align2::CENTER_CENTER,
1564                            text,
1565                            tick_font.clone(),
1566                            label_color,
1567                        );
1568                    }
1569                }
1570            }
1571
1572            // Draw X-axis ticks and labels (categorical handled above)
1573            if x_log {
1574                let start_decade = x_min.log10().floor() as i32;
1575                let end_decade = x_max.log10().ceil() as i32;
1576                for d in start_decade..=end_decade {
1577                    let decade = 10f64.powi(d);
1578                    let x_screen = plot_rect.min.x
1579                        + ((decade.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1580                            as f32
1581                            * plot_rect.width();
1582                    let x_screen = Self::snap_coord(x_screen, ppp);
1583                    // Tick mark
1584                    ui.painter().line_segment(
1585                        [
1586                            Pos2::new(x_screen, border_bottom),
1587                            Pos2::new(x_screen, border_bottom + tick_length),
1588                        ],
1589                        Stroke::new(1.0, axis_color),
1590                    );
1591                    // Label like 10^d
1592                    ui.painter().text(
1593                        Pos2::new(x_screen, border_bottom + label_offset),
1594                        Align2::CENTER_CENTER,
1595                        format!("10^{}", d),
1596                        tick_font.clone(),
1597                        label_color,
1598                    );
1599                }
1600            } else if !cat_x && !custom_hist_x {
1601                for x_val in plot_utils::generate_major_ticks(x_min, x_max) {
1602                    let x_screen =
1603                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1604                    let x_screen = Self::snap_coord(x_screen, ppp);
1605                    ui.painter().line_segment(
1606                        [
1607                            Pos2::new(x_screen, border_bottom),
1608                            Pos2::new(x_screen, border_bottom + tick_length),
1609                        ],
1610                        Stroke::new(1.0, axis_color),
1611                    );
1612                    ui.painter().text(
1613                        Pos2::new(x_screen, border_bottom + label_offset),
1614                        Align2::CENTER_CENTER,
1615                        plot_utils::format_tick_label(x_val),
1616                        tick_font.clone(),
1617                        label_color,
1618                    );
1619                }
1620            }
1621
1622            // Draw Y-axis ticks and labels (categorical handled above)
1623            if y_log {
1624                let start_decade = y_min.log10().floor() as i32;
1625                let end_decade = y_max.log10().ceil() as i32;
1626                for d in start_decade..=end_decade {
1627                    let decade = 10f64.powi(d);
1628                    let y_screen = plot_rect.max.y
1629                        - ((decade.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1630                            as f32
1631                            * plot_rect.height();
1632                    let y_screen = Self::snap_coord(y_screen, ppp);
1633                    ui.painter().line_segment(
1634                        [
1635                            Pos2::new(border_left - tick_length, y_screen),
1636                            Pos2::new(border_left, y_screen),
1637                        ],
1638                        Stroke::new(1.0, axis_color),
1639                    );
1640                    ui.painter().text(
1641                        Pos2::new(border_left - label_offset, y_screen),
1642                        Align2::CENTER_CENTER,
1643                        format!("10^{}", d),
1644                        tick_font.clone(),
1645                        label_color,
1646                    );
1647                }
1648            } else if !cat_y {
1649                for y_val in plot_utils::generate_major_ticks(y_min, y_max) {
1650                    let y_screen =
1651                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1652                    let y_screen = Self::snap_coord(y_screen, ppp);
1653                    ui.painter().line_segment(
1654                        [
1655                            Pos2::new(border_left - tick_length, y_screen),
1656                            Pos2::new(border_left, y_screen),
1657                        ],
1658                        Stroke::new(1.0, axis_color),
1659                    );
1660                    ui.painter().text(
1661                        Pos2::new(border_left - label_offset, y_screen),
1662                        Align2::CENTER_CENTER,
1663                        plot_utils::format_tick_label(y_val),
1664                        tick_font.clone(),
1665                        label_color,
1666                    );
1667                }
1668            }
1669        }
1670    }
1671
1672    /// Draw a CAD-style XYZ orientation gizmo in the bottom-left corner of the plot rect.
1673    /// This is drawn in screen-space (overlay) but rotates with the current 3D camera.
1674    fn draw_3d_orientation_gizmo(
1675        &self,
1676        ui: &mut egui::Ui,
1677        plot_rect: Rect,
1678        plot_renderer: &PlotRenderer,
1679        axes_index: usize,
1680        font_scale: f32,
1681    ) {
1682        let cam_ref = plot_renderer
1683            .axes_camera(axes_index)
1684            .unwrap_or_else(|| plot_renderer.camera());
1685        let cam = cam_ref.clone();
1686
1687        let forward = (cam.target - cam.position).normalize_or_zero();
1688        if forward.length_squared() < 1e-9 {
1689            return;
1690        }
1691        let world_up = cam.up.normalize_or_zero();
1692        let right = forward.cross(world_up).normalize_or_zero();
1693        if right.length_squared() < 1e-9 {
1694            return;
1695        }
1696        let up = right.cross(forward).normalize_or_zero();
1697        if up.length_squared() < 1e-9 {
1698            return;
1699        }
1700
1701        let scale = font_scale.max(0.75);
1702        let base = plot_rect.width().min(plot_rect.height()).max(1.0);
1703        let gizmo_size = (base * 0.16).clamp(44.0, 110.0) * scale;
1704        let pad = (30.0 * scale).round();
1705        let origin = Pos2::new(plot_rect.min.x + pad, plot_rect.max.y - pad);
1706
1707        struct AxisItem {
1708            label: &'static str,
1709            dir_world: Vec3,
1710            color: Color32,
1711            z_sort: f32,
1712        }
1713
1714        let mut axes = [
1715            AxisItem {
1716                label: "X",
1717                dir_world: Vec3::X,
1718                color: Color32::from_rgb(235, 80, 80),
1719                z_sort: 0.0,
1720            },
1721            AxisItem {
1722                label: "Y",
1723                dir_world: Vec3::Y,
1724                color: Color32::from_rgb(90, 220, 120),
1725                z_sort: 0.0,
1726            },
1727            AxisItem {
1728                label: "Z",
1729                dir_world: Vec3::Z,
1730                color: Color32::from_rgb(90, 160, 255),
1731                z_sort: 0.0,
1732            },
1733        ];
1734
1735        // Transform world axis directions into camera space and use the camera-space z as a
1736        // painter's-order hint (draw farther axes first so nearer ones sit on top).
1737        for a in axes.iter_mut() {
1738            let x = a.dir_world.dot(right);
1739            let y = a.dir_world.dot(up);
1740            let z = a.dir_world.dot(-forward);
1741            a.z_sort = z;
1742            a.dir_world = Vec3::new(x, y, z);
1743        }
1744        axes.sort_by(|a, b| {
1745            a.z_sort
1746                .partial_cmp(&b.z_sort)
1747                .unwrap_or(std::cmp::Ordering::Equal)
1748        });
1749
1750        let painter = ui.painter();
1751
1752        painter.circle_filled(origin, 2.0 * scale, Color32::from_gray(210));
1753
1754        let axis_len = gizmo_size * 0.65;
1755        let head_len = (8.0 * scale).min(axis_len * 0.35);
1756        let head_w = 5.0 * scale;
1757        let font = FontId::proportional(11.0 * scale);
1758
1759        for a in axes.iter() {
1760            let dir2 = egui::Vec2::new(a.dir_world.x, -a.dir_world.y);
1761            let mag = dir2.length();
1762            if !mag.is_finite() || mag < 1e-4 {
1763                continue;
1764            }
1765            let d = dir2 / mag;
1766
1767            let end = origin + d * axis_len;
1768            let stroke = Stroke::new(2.0 * scale, a.color);
1769            painter.line_segment([origin, end], stroke);
1770
1771            // Arrow head
1772            let base = end - d * head_len;
1773            let perp = egui::Vec2::new(-d.y, d.x);
1774            painter.line_segment([end, base + perp * head_w], stroke);
1775            painter.line_segment([end, base - perp * head_w], stroke);
1776
1777            // Label near arrow tip
1778            let label_pos = end + d * (10.0 * scale);
1779            painter.text(
1780                label_pos,
1781                Align2::CENTER_CENTER,
1782                a.label,
1783                font.clone(),
1784                a.color,
1785            );
1786        }
1787    }
1788
1789    /// Draw dynamic tick labels for the 3D origin triad axes (X/Y/Z).
1790    /// These labels are screen-space (egui) but are positioned by projecting 3D points.
1791    fn draw_3d_origin_axis_ticks(
1792        &self,
1793        ui: &mut egui::Ui,
1794        plot_rect: Rect,
1795        plot_renderer: &PlotRenderer,
1796        axes_index: usize,
1797        font_scale: f32,
1798    ) {
1799        let cam_ref = plot_renderer
1800            .axes_camera(axes_index)
1801            .unwrap_or_else(|| plot_renderer.camera());
1802        let mut cam = cam_ref.clone();
1803        let w = plot_rect.width().max(1.0);
1804        let h = plot_rect.height().max(1.0);
1805        cam.update_aspect_ratio(w / h);
1806        let view_proj = cam.view_proj_matrix();
1807
1808        let project = |p: Vec3| -> Option<Pos2> {
1809            let clip: Vec4 = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
1810            if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
1811                return None;
1812            }
1813            let ndc = clip.truncate() / clip.w;
1814            if !(ndc.x.is_finite() && ndc.y.is_finite()) {
1815                return None;
1816            }
1817            let sx = plot_rect.min.x + ((ndc.x + 1.0) * 0.5) * plot_rect.width();
1818            let sy = plot_rect.min.y + ((1.0 - ndc.y) * 0.5) * plot_rect.height();
1819            Some(Pos2::new(sx, sy))
1820        };
1821
1822        let nice_step = |raw: f64| -> f64 {
1823            if !raw.is_finite() || raw <= 0.0 {
1824                return 1.0;
1825            }
1826            let pow10 = 10.0_f64.powf(raw.log10().floor());
1827            let norm = raw / pow10;
1828            let mult = if norm <= 1.0 {
1829                1.0
1830            } else if norm <= 2.0 {
1831                2.0
1832            } else if norm <= 5.0 {
1833                5.0
1834            } else {
1835                10.0
1836            };
1837            mult * pow10
1838        };
1839
1840        // Use the same basic heuristic as the renderer: choose a major tick spacing based on
1841        // projected pixels per world unit near the origin.
1842        let origin = Vec3::ZERO;
1843        let px_per_world = match (project(origin), project(origin + Vec3::X)) {
1844            (Some(a), Some(b)) => ((b.x - a.x).hypot(b.y - a.y) as f64).max(1e-3),
1845            _ => 1.0,
1846        };
1847        let desired_major_px = 120.0_f64;
1848        let major_step = nice_step((desired_major_px / px_per_world).max(1e-6));
1849        if !(major_step.is_finite() && major_step > 0.0) {
1850            return;
1851        }
1852        let axis_len = (major_step as f32 * 5.0).max(0.5);
1853
1854        let scale = font_scale.max(0.75);
1855        let font = FontId::proportional(10.0 * scale);
1856        let painter = ui.painter();
1857        let col_x = Color32::from_rgb(235, 80, 80);
1858        let col_y = Color32::from_rgb(90, 220, 120);
1859        let col_z = Color32::from_rgb(90, 160, 255);
1860        let panel_center = plot_rect.center();
1861
1862        let outward_offset = |pos: Pos2, base: f32| {
1863            let dir = pos - panel_center;
1864            let len = dir.length().max(1.0);
1865            (dir / len) * base
1866        };
1867
1868        if let Some(pos) = project(Vec3::X * axis_len * 1.10) {
1869            if let Some(label) = plot_renderer.overlay_x_label_for_axes(axes_index) {
1870                let style = plot_renderer
1871                    .overlay_x_label_style_for_axes(axes_index)
1872                    .cloned()
1873                    .unwrap_or_default();
1874                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(4.0 * scale, 0.0);
1875                Self::paint_styled_text(
1876                    painter,
1877                    pos + offset,
1878                    Align2::LEFT_CENTER,
1879                    label,
1880                    Self::style_font_size(&style, 12.0, scale),
1881                    Self::style_color(&style, col_x),
1882                    Self::style_is_bold(&style),
1883                    110,
1884                );
1885            }
1886        }
1887        if let Some(pos) = project(Vec3::Y * axis_len * 1.10) {
1888            if let Some(label) = plot_renderer.overlay_y_label_for_axes(axes_index) {
1889                let style = plot_renderer
1890                    .overlay_y_label_style_for_axes(axes_index)
1891                    .cloned()
1892                    .unwrap_or_default();
1893                let offset =
1894                    outward_offset(pos, 12.0 * scale) + egui::vec2(2.0 * scale, -2.0 * scale);
1895                Self::paint_styled_text(
1896                    painter,
1897                    pos + offset,
1898                    Align2::LEFT_CENTER,
1899                    label,
1900                    Self::style_font_size(&style, 12.0, scale),
1901                    Self::style_color(&style, col_y),
1902                    Self::style_is_bold(&style),
1903                    110,
1904                );
1905            }
1906        }
1907        if let Some(pos) = project(Vec3::Z * axis_len * 1.10) {
1908            if let Some(label) = plot_renderer.overlay_z_label_for_axes(axes_index) {
1909                let style = plot_renderer
1910                    .overlay_z_label_style_for_axes(axes_index)
1911                    .cloned()
1912                    .unwrap_or_default();
1913                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(0.0, -4.0 * scale);
1914                Self::paint_styled_text(
1915                    painter,
1916                    pos + offset,
1917                    Align2::LEFT_BOTTOM,
1918                    label,
1919                    Self::style_font_size(&style, 12.0, scale),
1920                    Self::style_color(&style, col_z),
1921                    Self::style_is_bold(&style),
1922                    110,
1923                );
1924            }
1925        }
1926
1927        let draw_axis = |axis: Vec3, color: Color32| {
1928            for i in 1..=6 {
1929                let t = (i as f32) * (major_step as f32);
1930                if t >= axis_len * 0.999 {
1931                    break;
1932                }
1933                let p = origin + axis * t;
1934                let Some(pos) = project(p) else { continue };
1935                // Offset labels slightly away from the axis in screen-space based on camera right/up.
1936                let offset =
1937                    outward_offset(pos, 7.0 * scale) + egui::Vec2::new(3.0 * scale, -3.0 * scale);
1938                painter.text(
1939                    pos + offset + egui::vec2(1.0, 1.0),
1940                    Align2::LEFT_CENTER,
1941                    plot_utils::format_tick_label((i as f64) * major_step),
1942                    font.clone(),
1943                    Color32::from_rgba_premultiplied(0, 0, 0, 90),
1944                );
1945                painter.text(
1946                    pos + offset,
1947                    Align2::LEFT_CENTER,
1948                    plot_utils::format_tick_label((i as f64) * major_step),
1949                    font.clone(),
1950                    color,
1951                );
1952            }
1953        };
1954        draw_axis(Vec3::X, col_x);
1955        draw_axis(Vec3::Y, col_y);
1956        draw_axis(Vec3::Z, col_z);
1957    }
1958
1959    fn draw_title_in_rect(&self, ui: &mut egui::Ui, rect: Rect, title: &str, scale: f32) {
1960        let scale = scale.max(0.75);
1961        let text_color = self.theme_text_color();
1962        ui.painter().text(
1963            rect.center(),
1964            Align2::CENTER_CENTER,
1965            title,
1966            FontId::proportional(16.0 * scale),
1967            text_color,
1968        );
1969    }
1970
1971    fn draw_legend(
1972        &self,
1973        ui: &mut egui::Ui,
1974        plot_rect: Rect,
1975        entries: &[crate::plots::figure::LegendEntry],
1976        scale: f32,
1977    ) {
1978        if entries.is_empty() {
1979            return;
1980        }
1981        let scale = scale.max(0.75);
1982        let theme = self.theme.build_theme();
1983        let bg = theme.get_background_color();
1984        let text = theme.get_text_color();
1985        let legend_text = Color32::from_rgb(
1986            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
1987            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
1988            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
1989        );
1990        let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
1991        let legend_bg = if bg_luma > 0.62 {
1992            Color32::from_rgba_premultiplied(255, 255, 255, 170)
1993        } else {
1994            Color32::from_rgba_premultiplied(0, 0, 0, 128)
1995        };
1996        let legend_stroke = if bg_luma > 0.62 {
1997            Color32::from_rgb(55, 55, 55)
1998        } else {
1999            Color32::BLACK
2000        };
2001        let pad = 8.0 * scale;
2002        let row_h = (16.0 * scale).clamp(13.0, 18.0);
2003        let swatch_w = 14.0 * scale;
2004        let text_x_gap = 18.0 * scale;
2005        let legend_w = (plot_rect.width() * 0.30).clamp(92.0, 132.0);
2006        let x = plot_rect.max.x - legend_w - pad;
2007        let mut y = plot_rect.min.y + pad + 4.0 * scale;
2008        let legend_rect = Rect::from_min_max(
2009            egui::pos2(x - pad, plot_rect.min.y + pad),
2010            egui::pos2(x + legend_w, y + entries.len() as f32 * row_h + pad),
2011        );
2012        ui.painter().rect_filled(legend_rect, 4.0, legend_bg);
2013        y += 10.0 * scale;
2014        for e in entries {
2015            let c = Color32::from_rgb(
2016                (e.color.x * 255.0) as u8,
2017                (e.color.y * 255.0) as u8,
2018                (e.color.z * 255.0) as u8,
2019            );
2020            let swatch_rect = Rect::from_min_size(
2021                egui::pos2(x, y - 5.0 * scale),
2022                egui::vec2(swatch_w, 7.0 * scale),
2023            );
2024            match e.plot_type {
2025                crate::plots::figure::PlotType::Line
2026                | crate::plots::figure::PlotType::Line3
2027                | crate::plots::figure::PlotType::Contour => {
2028                    let ymid = swatch_rect.center().y;
2029                    ui.painter().line_segment(
2030                        [
2031                            Pos2::new(swatch_rect.min.x, ymid),
2032                            Pos2::new(swatch_rect.max.x, ymid),
2033                        ],
2034                        Stroke::new(2.0, c),
2035                    );
2036                }
2037                crate::plots::figure::PlotType::Scatter
2038                | crate::plots::figure::PlotType::Scatter3 => {
2039                    let center = swatch_rect.center();
2040                    ui.painter().circle_filled(center, 3.5, c);
2041                    ui.painter()
2042                        .circle_stroke(center, 3.5, Stroke::new(1.0, legend_stroke));
2043                }
2044                crate::plots::figure::PlotType::Bar
2045                | crate::plots::figure::PlotType::Area
2046                | crate::plots::figure::PlotType::Surface
2047                | crate::plots::figure::PlotType::Pie
2048                | crate::plots::figure::PlotType::ContourFill => {
2049                    ui.painter().rect_filled(swatch_rect, 2.0, c);
2050                    ui.painter()
2051                        .rect_stroke(swatch_rect, 2.0, Stroke::new(1.0, legend_stroke));
2052                }
2053                crate::plots::figure::PlotType::ErrorBar
2054                | crate::plots::figure::PlotType::Stairs
2055                | crate::plots::figure::PlotType::Stem
2056                | crate::plots::figure::PlotType::Quiver => {
2057                    let ymid = swatch_rect.center().y;
2058                    ui.painter().line_segment(
2059                        [
2060                            Pos2::new(swatch_rect.min.x, ymid),
2061                            Pos2::new(swatch_rect.max.x - 4.0, ymid),
2062                        ],
2063                        Stroke::new(1.5, c),
2064                    );
2065                    ui.painter().line_segment(
2066                        [
2067                            Pos2::new(swatch_rect.max.x - 4.0, ymid - 3.0),
2068                            Pos2::new(swatch_rect.max.x, ymid),
2069                        ],
2070                        Stroke::new(1.0, c),
2071                    );
2072                }
2073            }
2074            ui.painter().text(
2075                egui::pos2(x + text_x_gap, y),
2076                Align2::LEFT_CENTER,
2077                &e.label,
2078                FontId::proportional(11.0 * scale),
2079                legend_text,
2080            );
2081            y += row_h;
2082        }
2083    }
2084
2085    fn draw_x_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2086        let scale = scale.max(0.75);
2087        let text_color = self.theme_text_color();
2088        ui.painter().text(
2089            Pos2::new(rect.center().x, rect.max.y - rect.height() * 0.24),
2090            Align2::CENTER_CENTER,
2091            label,
2092            FontId::proportional(14.0 * scale),
2093            text_color,
2094        );
2095    }
2096
2097    fn draw_y_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2098        let scale = scale.max(0.75);
2099        let text_color = self.theme_text_color();
2100        let galley = ui.fonts(|fonts| {
2101            fonts.layout_no_wrap(
2102                label.to_owned(),
2103                FontId::proportional(13.0 * scale),
2104                text_color,
2105            )
2106        });
2107        let size = galley.size();
2108        let center = Pos2::new(rect.min.x + rect.width() * 0.32, rect.center().y);
2109        let pos = Pos2::new(center.x - size.y * 0.5, center.y + size.x * 0.5);
2110        let mut shape = egui::epaint::TextShape::new(pos, galley, text_color);
2111        shape.angle = -std::f32::consts::FRAC_PI_2;
2112        shape.override_text_color = Some(text_color);
2113        ui.painter().add(shape);
2114    }
2115
2116    fn project_world_to_screen(
2117        &self,
2118        plot_rect: Rect,
2119        camera: &crate::core::Camera,
2120        point: glam::Vec3,
2121    ) -> Option<Pos2> {
2122        let mut cam = camera.clone();
2123        let clip = cam.view_proj_matrix() * point.extend(1.0);
2124        if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() || !clip.w.is_finite()
2125        {
2126            return None;
2127        }
2128        if clip.w.abs() < 1.0e-6 {
2129            return None;
2130        }
2131        let ndc = clip.truncate() / clip.w;
2132        if ndc.z < -1.1 || ndc.z > 1.1 {
2133            return None;
2134        }
2135        if clip.w <= 0.0
2136            && matches!(
2137                camera.projection,
2138                crate::core::camera::ProjectionType::Perspective { .. }
2139            )
2140        {
2141            return None;
2142        }
2143        let x = plot_rect.min.x + (ndc.x + 1.0) * 0.5 * plot_rect.width();
2144        let y = plot_rect.min.y + (1.0 - (ndc.y + 1.0) * 0.5) * plot_rect.height();
2145        Some(Pos2::new(x, y))
2146    }
2147
2148    fn draw_projected_world_texts(
2149        &self,
2150        ui: &mut egui::Ui,
2151        plot_rect: Rect,
2152        plot_renderer: &PlotRenderer,
2153        axes_index: usize,
2154        scale: f32,
2155    ) {
2156        let Some(camera) = plot_renderer
2157            .axes_camera(axes_index)
2158            .or_else(|| Some(plot_renderer.camera()))
2159        else {
2160            return;
2161        };
2162        let annotations = plot_renderer.world_text_annotations_for_axes(axes_index);
2163        let is_3d = Self::axes_is_3d(plot_renderer, axes_index);
2164        for (position, text, style) in annotations {
2165            if !style.visible || text.trim().is_empty() {
2166                continue;
2167            }
2168            let Some(screen) = self.project_world_to_screen(plot_rect, camera, position) else {
2169                continue;
2170            };
2171            let color = Self::style_color(&style, self.theme_text_color());
2172            let font_size = Self::style_font_size(&style, 14.0, scale);
2173            let offset = if is_3d {
2174                egui::vec2(0.0, -8.0 * scale.max(0.75))
2175            } else {
2176                egui::vec2(0.0, 6.0 * scale.max(0.75))
2177            };
2178            Self::paint_styled_text(
2179                ui.painter(),
2180                screen + offset,
2181                Align2::CENTER_CENTER,
2182                &text,
2183                font_size,
2184                color,
2185                Self::style_is_bold(&style),
2186                if is_3d { 120 } else { 90 },
2187            );
2188        }
2189    }
2190
2191    fn draw_pie_label(
2192        &self,
2193        ui: &mut egui::Ui,
2194        plot_rect: Rect,
2195        label: &str,
2196        pos: glam::Vec2,
2197        scale: f32,
2198    ) {
2199        let center = plot_rect.center();
2200        let radius = plot_rect.width().min(plot_rect.height()) * 0.4;
2201        let screen = Pos2::new(center.x + pos.x * radius, center.y - pos.y * radius);
2202        ui.painter().text(
2203            screen,
2204            Align2::CENTER_CENTER,
2205            label,
2206            FontId::proportional(12.0 * scale.max(0.75)),
2207            self.theme_text_color(),
2208        );
2209    }
2210
2211    /// Get the plot area from the last frame
2212    pub fn plot_area(&self) -> Option<Rect> {
2213        self.plot_area
2214    }
2215
2216    /// Get per-axes snapped content rectangles from the last frame.
2217    pub fn axes_plot_rects(&self) -> &[Rect] {
2218        &self.axes_plot_rects
2219    }
2220
2221    /// Get toolbar rectangle from last frame
2222    pub fn toolbar_rect(&self) -> Option<Rect> {
2223        self.toolbar_rect
2224    }
2225
2226    /// Get sidebar rectangle from last frame
2227    pub fn sidebar_rect(&self) -> Option<Rect> {
2228        self.sidebar_rect
2229    }
2230
2231    pub fn take_toolbar_actions(&mut self) -> (bool, bool, bool, Option<bool>, Option<bool>) {
2232        let out = (
2233            self.want_save_png,
2234            self.want_save_svg,
2235            self.want_reset_view,
2236            self.want_toggle_grid.take(),
2237            self.want_toggle_legend.take(),
2238        );
2239        self.want_save_png = false;
2240        self.want_save_svg = false;
2241        self.want_reset_view = false;
2242        out
2243    }
2244
2245    /// Render the Dystr information modal
2246    fn render_dystr_modal(&mut self, ctx: &Context) -> bool {
2247        let mut consumed_input = false;
2248
2249        egui::Window::new("About Dystr")
2250            .anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
2251            .collapsible(false)
2252            .resizable(false)
2253            .default_width(400.0)
2254            .show(ctx, |ui| {
2255                consumed_input = true;
2256
2257                ui.vertical_centered(|ui| {
2258                    ui.add_space(10.0);
2259
2260                    // Dystr logo placeholder (larger for modal)
2261                    let logo_size = egui::Vec2::splat(64.0);
2262                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::hover()).0;
2263
2264                    ui.painter().rect_filled(
2265                        logo_rect,
2266                        8.0,                             // rounded corners
2267                        Color32::from_rgb(60, 130, 200), // Dystr brand color placeholder
2268                    );
2269
2270                    ui.painter().text(
2271                        logo_rect.center(),
2272                        Align2::CENTER_CENTER,
2273                        "D",
2274                        FontId::proportional(40.0),
2275                        Color32::WHITE,
2276                    );
2277
2278                    ui.add_space(15.0);
2279
2280                    ui.heading("Welcome to RunMat");
2281                    ui.add_space(10.0);
2282
2283                    ui.label("RunMat is a high-performance MATLAB-compatible");
2284                    ui.label("numerical computing platform, built as part of");
2285                    ui.label("the Dystr computation ecosystem.");
2286
2287                    ui.add_space(15.0);
2288
2289                    ui.label("🚀 V8-inspired JIT compilation");
2290                    ui.label("⚡ BLAS/LAPACK acceleration");
2291                    ui.label("🎯 Full MATLAB compatibility");
2292                    ui.label("🔬 Advanced plotting & visualization");
2293
2294                    ui.add_space(20.0);
2295
2296                    ui.horizontal(|ui| {
2297                        if ui.button("Visit dystr.com").clicked() {
2298                            // Open dystr.com in browser
2299                            if let Err(e) = webbrowser::open("https://dystr.com") {
2300                                eprintln!("Failed to open browser: {e}");
2301                            }
2302                        }
2303
2304                        if ui.button("Close").clicked() {
2305                            self.show_dystr_modal = false;
2306                        }
2307                    });
2308
2309                    ui.add_space(10.0);
2310                });
2311            });
2312
2313        consumed_input
2314    }
2315}
2316
2317fn truncate_label(label: &str, max_len: usize) -> String {
2318    if label.chars().count() <= max_len {
2319        return label.to_string();
2320    }
2321    let mut out = String::new();
2322    for (i, ch) in label.chars().enumerate() {
2323        if i >= max_len - 1 {
2324            break;
2325        }
2326        out.push(ch);
2327    }
2328    out.push('…');
2329    out
2330}