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    fn reserve_sg_title_band(
491        &self,
492        outer: Rect,
493        plot_renderer: &PlotRenderer,
494        scale: f32,
495    ) -> (Rect, Option<Rect>) {
496        let Some(title) = plot_renderer.overlay_sg_title().map(String::as_str) else {
497            return (outer, None);
498        };
499        if !Self::has_visible_text(Some(title)) {
500            return (outer, None);
501        }
502
503        let scale = scale.max(0.75);
504        let band_h = (30.0 * scale).min(outer.height().max(1.0) * 0.14);
505        let gap = 4.0 * scale;
506        let title_max_y = (outer.min.y + band_h).min(outer.max.y);
507        let content_min_y = (title_max_y + gap).min(outer.max.y);
508        (
509            Rect::from_min_max(egui::pos2(outer.min.x, content_min_y), outer.max),
510            Some(Rect::from_min_max(
511                outer.min,
512                egui::pos2(outer.max.x, title_max_y),
513            )),
514        )
515    }
516
517    fn compute_subplot_plot_rects_impl(
518        &self,
519        outer: Rect,
520        plot_renderer: &PlotRenderer,
521        font_scale: f32,
522        show_title: bool,
523    ) -> Vec<Rect> {
524        let plot_area = Self::outer_plot_area_for_axes(outer, plot_renderer);
525        let (plot_area, _) = if show_title {
526            self.reserve_sg_title_band(plot_area, plot_renderer, font_scale)
527        } else {
528            (plot_area, None)
529        };
530        let (rows, cols) = plot_renderer.figure_axes_grid();
531        if rows * cols <= 1 {
532            vec![
533                self.panel_layout_for_axes(plot_area, plot_renderer, 0, font_scale)
534                    .plot_rect,
535            ]
536        } else {
537            let rects = self.compute_subplot_rects(
538                plot_area,
539                rows,
540                cols,
541                Self::SUBPLOT_GAP_POINTS,
542                Self::SUBPLOT_GAP_POINTS,
543            );
544            rects
545                .into_iter()
546                .enumerate()
547                .map(|(axes_index, rect)| {
548                    self.panel_layout_for_axes(rect, plot_renderer, axes_index, font_scale)
549                        .plot_rect
550                })
551                .collect()
552        }
553    }
554
555    pub fn compute_subplot_plot_rects(
556        &self,
557        outer: Rect,
558        plot_renderer: &PlotRenderer,
559        font_scale: f32,
560    ) -> Vec<Rect> {
561        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
562    }
563
564    /// Like [`compute_subplot_plot_rects`] but lets the caller control whether the
565    /// super-title band is reserved. Pass `show_title: false` when the overlay is
566    /// rendered without titles so that GPU viewports and hit-rects stay aligned.
567    pub fn compute_subplot_plot_rects_explicit(
568        &self,
569        outer: Rect,
570        plot_renderer: &PlotRenderer,
571        font_scale: f32,
572        show_title: bool,
573    ) -> Vec<Rect> {
574        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
575    }
576
577    pub fn snap_rect_to_pixels(rect: Rect, pixels_per_point: f32) -> Rect {
578        let ppp = pixels_per_point.max(0.5);
579        let min_x = (rect.min.x * ppp).round() / ppp;
580        let min_y = (rect.min.y * ppp).round() / ppp;
581        let width = (rect.width() * ppp).round().max(1.0) / ppp;
582        let height = (rect.height() * ppp).round().max(1.0) / ppp;
583        Rect::from_min_size(egui::pos2(min_x, min_y), egui::vec2(width, height))
584    }
585
586    fn snap_coord(value: f32, pixels_per_point: f32) -> f32 {
587        let ppp = pixels_per_point.max(0.5);
588        (value * ppp).round() / ppp
589    }
590
591    fn border_centerline_edges(
592        plot_rect: Rect,
593        pixels_per_point: f32,
594        stroke_width: f32,
595    ) -> (f32, f32, f32, f32) {
596        let offset = stroke_width * 0.5;
597        let left = Self::snap_coord(plot_rect.min.x - offset, pixels_per_point);
598        let right = Self::snap_coord(plot_rect.max.x + offset, pixels_per_point);
599        let top = Self::snap_coord(plot_rect.min.y - offset, pixels_per_point);
600        let bottom = Self::snap_coord(plot_rect.max.y + offset, pixels_per_point);
601        (left, right, top, bottom)
602    }
603
604    fn draw_2d_border(&self, ui: &mut egui::Ui, plot_rect: Rect) {
605        let stroke = Stroke::new(1.5, self.theme_axis_color());
606        let ppp = ui.ctx().pixels_per_point();
607        let (left, right, top, bottom) =
608            Self::border_centerline_edges(plot_rect, ppp, stroke.width);
609        ui.painter()
610            .line_segment([Pos2::new(left, top), Pos2::new(right, top)], stroke);
611        ui.painter()
612            .line_segment([Pos2::new(left, bottom), Pos2::new(right, bottom)], stroke);
613        ui.painter()
614            .line_segment([Pos2::new(left, top), Pos2::new(left, bottom)], stroke);
615        ui.painter()
616            .line_segment([Pos2::new(right, top), Pos2::new(right, bottom)], stroke);
617    }
618
619    fn draw_plot_box_mask(&self, ui: &mut egui::Ui, plot_rect: Rect) {
620        let mask = 2.0;
621        let bg = self.theme_background_color();
622        let top = Rect::from_min_max(
623            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
624            Pos2::new(plot_rect.max.x + mask, plot_rect.min.y),
625        );
626        let bottom = Rect::from_min_max(
627            Pos2::new(plot_rect.min.x - mask, plot_rect.max.y),
628            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
629        );
630        let left = Rect::from_min_max(
631            Pos2::new(plot_rect.min.x - mask, plot_rect.min.y - mask),
632            Pos2::new(plot_rect.min.x, plot_rect.max.y + mask),
633        );
634        let right = Rect::from_min_max(
635            Pos2::new(plot_rect.max.x, plot_rect.min.y - mask),
636            Pos2::new(plot_rect.max.x + mask, plot_rect.max.y + mask),
637        );
638        ui.painter().rect_filled(top, 0.0, bg);
639        ui.painter().rect_filled(bottom, 0.0, bg);
640        ui.painter().rect_filled(left, 0.0, bg);
641        ui.painter().rect_filled(right, 0.0, bg);
642    }
643
644    pub fn compute_subplot_plot_rects_snapped(
645        &self,
646        outer: Rect,
647        plot_renderer: &PlotRenderer,
648        font_scale: f32,
649        pixels_per_point: f32,
650    ) -> Vec<Rect> {
651        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, true)
652            .into_iter()
653            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
654            .collect()
655    }
656
657    /// Like [`compute_subplot_plot_rects_snapped`] but lets the caller control whether
658    /// the super-title band is reserved. Pass `show_title: false` when the overlay is
659    /// rendered without titles so that GPU viewports stay aligned with the drawn area.
660    pub fn compute_subplot_plot_rects_snapped_explicit(
661        &self,
662        outer: Rect,
663        plot_renderer: &PlotRenderer,
664        font_scale: f32,
665        pixels_per_point: f32,
666        show_title: bool,
667    ) -> Vec<Rect> {
668        self.compute_subplot_plot_rects_impl(outer, plot_renderer, font_scale, show_title)
669            .into_iter()
670            .map(|rect| Self::snap_rect_to_pixels(rect, pixels_per_point))
671            .collect()
672    }
673
674    pub fn outer_plot_area_for_axes(available_rect: Rect, plot_renderer: &PlotRenderer) -> Rect {
675        let (rows, cols) = plot_renderer.figure_axes_grid();
676        let single_axes_is_3d = rows * cols <= 1 && Self::axes_is_3d(plot_renderer, 0);
677        if single_axes_is_3d || rows * cols > 1 {
678            available_rect
679        } else {
680            available_rect.shrink2(egui::vec2(8.0, 8.0))
681        }
682    }
683
684    fn theme_text_color(&self) -> Color32 {
685        let text = self.theme.build_theme().get_text_color();
686        Color32::from_rgba_premultiplied(
687            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
688            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
689            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
690            (text.w.clamp(0.0, 1.0) * 255.0) as u8,
691        )
692    }
693
694    fn theme_axis_color(&self) -> Color32 {
695        let axis = self.theme.build_theme().get_axis_color();
696        Color32::from_rgba_premultiplied(
697            (axis.x.clamp(0.0, 1.0) * 255.0) as u8,
698            (axis.y.clamp(0.0, 1.0) * 255.0) as u8,
699            (axis.z.clamp(0.0, 1.0) * 255.0) as u8,
700            (axis.w.clamp(0.0, 1.0) * 255.0) as u8,
701        )
702    }
703
704    fn theme_background_color(&self) -> Color32 {
705        let bg = self.theme.build_theme().get_background_color();
706        Color32::from_rgba_premultiplied(
707            (bg.x.clamp(0.0, 1.0) * 255.0) as u8,
708            (bg.y.clamp(0.0, 1.0) * 255.0) as u8,
709            (bg.z.clamp(0.0, 1.0) * 255.0) as u8,
710            (bg.w.clamp(0.0, 1.0) * 255.0) as u8,
711        )
712    }
713
714    fn themed_grid_colors(&self) -> (Color32, Color32) {
715        let grid = self.theme.build_theme().get_grid_color();
716        let major = Color32::from_rgba_premultiplied(
717            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
718            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
719            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
720            ((grid.w.clamp(0.15, 0.55)) * 255.0) as u8,
721        );
722        let minor = Color32::from_rgba_premultiplied(
723            (grid.x.clamp(0.0, 1.0) * 255.0) as u8,
724            (grid.y.clamp(0.0, 1.0) * 255.0) as u8,
725            (grid.z.clamp(0.0, 1.0) * 255.0) as u8,
726            ((grid.w * 0.6).clamp(0.10, 0.34) * 255.0) as u8,
727        );
728        (major, minor)
729    }
730
731    /// Apply theme to egui context
732    pub fn apply_theme(&self, ctx: &Context) {
733        match self.theme.variant {
734            ThemeVariant::ModernDark => {
735                ModernDarkTheme::default().apply_to_egui(ctx);
736            }
737            ThemeVariant::ClassicLight => {
738                ctx.set_visuals(egui::Visuals::light());
739            }
740            ThemeVariant::HighContrast => {
741                let mut visuals = egui::Visuals::dark();
742                visuals.extreme_bg_color = egui::Color32::BLACK;
743                visuals.widgets.noninteractive.bg_fill = egui::Color32::BLACK;
744                visuals.widgets.noninteractive.fg_stroke.color = egui::Color32::WHITE;
745                ctx.set_visuals(visuals);
746            }
747            ThemeVariant::Custom => {
748                let mut visuals = egui::Visuals::light();
749                let bg = self.theme.build_theme().get_background_color();
750                if bg.x + bg.y + bg.z < 1.5 {
751                    visuals = egui::Visuals::dark();
752                }
753                ctx.set_visuals(visuals);
754            }
755        }
756
757        // Make context transparent so WGPU content shows through
758        let mut visuals = ctx.style().visuals.clone();
759        visuals.window_fill = Color32::TRANSPARENT;
760        visuals.panel_fill = Color32::TRANSPARENT;
761        visuals.extreme_bg_color = Color32::TRANSPARENT;
762        visuals.faint_bg_color = Color32::TRANSPARENT;
763        visuals.widgets.noninteractive.bg_fill = Color32::TRANSPARENT;
764        visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
765        visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
766        visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
767        visuals.widgets.open.bg_fill = Color32::TRANSPARENT;
768        ctx.set_visuals(visuals);
769    }
770
771    /// Render the complete overlay UI
772    pub fn render(
773        &mut self,
774        ctx: &Context,
775        plot_renderer: &PlotRenderer,
776        config: &OverlayConfig,
777        metrics: OverlayMetrics,
778    ) -> FrameInfo {
779        let mut consumed_input = false;
780        let mut plot_area = None;
781
782        // Render sidebar if enabled
783        if config.show_sidebar {
784            consumed_input |= self.render_sidebar(ctx, plot_renderer, config, &metrics);
785        }
786
787        // Render main plot area
788        let central_response = egui::CentralPanel::default()
789            .frame(egui::Frame::none()) // Transparent frame
790            .show(ctx, |ui| {
791                // Toolbar (top-right)
792                if config.show_toolbar {
793                    egui::TopBottomPanel::top("plot_toolbar")
794                        .frame(egui::Frame::none())
795                        .show_inside(ui, |ui| {
796                            let padded = ui.max_rect().shrink2(egui::vec2(12.0, 6.0));
797                            self.toolbar_rect = Some(padded);
798                            ui.allocate_ui_at_rect(padded, |ui| {
799                                ui.with_layout(
800                                    egui::Layout::right_to_left(egui::Align::Center),
801                                    |ui| {
802                                        ui.spacing_mut().item_spacing = egui::vec2(8.0, 4.0);
803                                        ui.spacing_mut().button_padding = egui::vec2(8.0, 6.0);
804                                        if ui.button("Save PNG").clicked() {
805                                            self.want_save_png = true;
806                                        }
807                                        if ui.button("Save SVG").clicked() {
808                                            self.want_save_svg = true;
809                                        }
810                                        if ui.button("Reset View").clicked() {
811                                            self.want_reset_view = true;
812                                        }
813                                        let mut grid = plot_renderer.overlay_show_grid();
814                                        if ui.toggle_value(&mut grid, "Grid").changed() {
815                                            self.want_toggle_grid = Some(grid);
816                                        }
817                                        let mut legend = plot_renderer.overlay_show_legend();
818                                        if ui.toggle_value(&mut legend, "Legend").changed() {
819                                            self.want_toggle_legend = Some(legend);
820                                        }
821                                    },
822                                );
823                            });
824                        });
825                } else {
826                    self.toolbar_rect = None;
827                }
828                plot_area = Some(self.render_plot_area(ui, plot_renderer, config));
829            });
830
831        consumed_input |= central_response.response.hovered();
832
833        // Render Dystr modal if needed
834        if self.show_dystr_modal {
835            consumed_input |= self.render_dystr_modal(ctx);
836        }
837
838        // Store plot area for next frame
839        self.plot_area = plot_area;
840
841        FrameInfo {
842            plot_area,
843            consumed_input,
844            metrics,
845        }
846    }
847
848    /// Render the sidebar with controls and information
849    fn render_sidebar(
850        &mut self,
851        ctx: &Context,
852        plot_renderer: &PlotRenderer,
853        config: &OverlayConfig,
854        metrics: &OverlayMetrics,
855    ) -> bool {
856        let mut consumed_input = false;
857
858        let sidebar_response = egui::SidePanel::left("plot_controls")
859            .resizable(true)
860            .default_width(config.sidebar_width)
861            .min_width(200.0)
862            .show(ctx, |ui| {
863                ui.style_mut().visuals.widgets.noninteractive.bg_fill = Color32::from_gray(25);
864                ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::from_gray(35);
865                ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::from_gray(45);
866
867                // Header with Dystr branding
868                ui.horizontal(|ui| {
869                    // Placeholder for Dystr logo (32x32 square)
870                    let logo_size = egui::Vec2::splat(32.0);
871                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::click()).0;
872
873                    // Draw placeholder logo background
874                    ui.painter().rect_filled(
875                        logo_rect,
876                        4.0, // rounded corners
877                        Color32::from_rgb(100, 100, 100),
878                    );
879
880                    // Draw "D" placeholder text in the logo area
881                    ui.painter().text(
882                        logo_rect.center(),
883                        Align2::CENTER_CENTER,
884                        "D",
885                        FontId::proportional(20.0),
886                        Color32::WHITE,
887                    );
888
889                    ui.vertical(|ui| {
890                        ui.heading("RunMat");
891                        ui.horizontal(|ui| {
892                            ui.small("a community project by ");
893                            if ui.small_button("dystr.com").clicked() {
894                                self.show_dystr_modal = true;
895                            }
896                        });
897                    });
898                });
899                ui.separator();
900                ui.label("GC Stats: [not available]");
901
902                // Camera information
903                ui.collapsing("📷 Camera", |ui| {
904                    let camera = plot_renderer.camera();
905                    ui.label(format!(
906                        "Position: {:.2}, {:.2}, {:.2}",
907                        camera.position.x, camera.position.y, camera.position.z
908                    ));
909                    ui.label(format!(
910                        "Target: {:.2}, {:.2}, {:.2}",
911                        camera.target.x, camera.target.y, camera.target.z
912                    ));
913
914                    if let Some(vb) = plot_renderer.view_bounds() {
915                        ui.label(format!("View X: {:.2} to {:.2}", vb.0, vb.1));
916                        ui.label(format!("View Y: {:.2} to {:.2}", vb.2, vb.3));
917                    }
918                    if let Some(db) = plot_renderer.data_bounds() {
919                        ui.label(format!("Data X: {:.2} to {:.2}", db.0, db.1));
920                        ui.label(format!("Data Y: {:.2} to {:.2}", db.2, db.3));
921                    }
922                });
923
924                // Scene information
925                ui.collapsing("🎬 Scene", |ui| {
926                    let stats = plot_renderer.scene_statistics();
927                    ui.label(format!("Nodes: {}", stats.total_nodes));
928                    ui.label(format!("Visible: {}", stats.visible_nodes));
929                    ui.label(format!("Vertices: {}", stats.total_vertices));
930                    ui.label(format!("Triangles: {}", stats.total_triangles));
931                });
932
933                // Performance metrics
934                ui.collapsing("⚡ Performance", |ui| {
935                    ui.label(format!("FPS: {:.1}", metrics.fps));
936                    ui.label(format!("Render: {:.2}ms", metrics.render_time_ms));
937                    ui.label(format!("Vertices: {}", metrics.vertex_count));
938                    ui.label(format!("Triangles: {}", metrics.triangle_count));
939                });
940
941                // Theme selection
942                ui.collapsing("🎨 Theme", |ui| {
943                    let label = match self.theme.variant {
944                        ThemeVariant::ModernDark => "Modern Dark",
945                        ThemeVariant::ClassicLight => "Classic Light",
946                        ThemeVariant::HighContrast => "High Contrast",
947                        ThemeVariant::Custom => "Custom",
948                    };
949                    ui.label(format!("{label} (Active)"));
950                    ui.checkbox(&mut self.show_debug, "Show Debug Info");
951                });
952
953                ui.separator();
954
955                // Plot controls
956                ui.collapsing("🔧 Controls", |ui| {
957                    ui.label("🖱️ Orbit: MMB drag (or RMB drag)");
958                    ui.label("🖱️ Pan: Shift + MMB drag (or Shift + RMB drag)");
959                    ui.label("🖱️ Zoom: Scroll wheel (zooms to cursor)");
960                    ui.label("🖱️ Alt + LMB/MMB/RMB: Orbit/Pan/Zoom");
961                    ui.label("📱 Touch: Pinch to zoom");
962                });
963            });
964
965        consumed_input |= sidebar_response.response.hovered();
966        self.sidebar_rect = Some(sidebar_response.response.rect);
967        consumed_input
968    }
969
970    /// Render the main plot area with grid, axes, and annotations
971    fn render_plot_area(
972        &mut self,
973        ui: &mut egui::Ui,
974        plot_renderer: &PlotRenderer,
975        config: &OverlayConfig,
976    ) -> Rect {
977        let available_rect = ui.available_rect_before_wrap();
978        let mut rendered_axes_rects: Vec<Rect> = Vec::new();
979
980        let (rows, cols) = plot_renderer.figure_axes_grid();
981        let plot_rect = Self::outer_plot_area_for_axes(available_rect, plot_renderer);
982        let (plot_rect, sg_title_rect) = if config.show_title {
983            self.reserve_sg_title_band(plot_rect, plot_renderer, config.font_scale)
984        } else {
985            (plot_rect, None)
986        };
987
988        // Use full available rectangular plot area (do not force square);
989        // camera fitting and axis_equal settings will control aspect.
990        let plot_area_rect = plot_rect;
991
992        if let Some(title_rect) = sg_title_rect {
993            if let Some(title) = plot_renderer.overlay_sg_title() {
994                self.draw_title_in_rect(
995                    ui,
996                    title_rect,
997                    title,
998                    Some(plot_renderer.overlay_sg_title_style()),
999                    config.font_scale,
1000                );
1001            }
1002        }
1003
1004        if rows * cols > 1 {
1005            let rects = self.compute_subplot_rects(
1006                plot_area_rect,
1007                rows,
1008                cols,
1009                Self::SUBPLOT_GAP_POINTS,
1010                Self::SUBPLOT_GAP_POINTS,
1011            );
1012            for (i, cell_rect) in rects.iter().enumerate() {
1013                let cam = plot_renderer
1014                    .axes_camera(i)
1015                    .unwrap_or_else(|| plot_renderer.camera());
1016                let panel_layout =
1017                    self.panel_layout_for_axes(*cell_rect, plot_renderer, i, config.font_scale);
1018                let r =
1019                    Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1020                let frame_rect =
1021                    Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1022                rendered_axes_rects.push(r);
1023                log::debug!(
1024                    target: "runmat_plot.axes_layout",
1025                    "computed axes panel layout axes_index={} rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1026                    i,
1027                    rows,
1028                    cols,
1029                    Self::axes_is_3d(plot_renderer, i),
1030                    cell_rect.min.x,
1031                    cell_rect.min.y,
1032                    cell_rect.max.x,
1033                    cell_rect.max.y,
1034                    frame_rect.min.x,
1035                    frame_rect.min.y,
1036                    frame_rect.max.x,
1037                    frame_rect.max.y,
1038                    r.min.x,
1039                    r.min.y,
1040                    r.max.x,
1041                    r.max.y
1042                );
1043                if matches!(
1044                    cam.projection,
1045                    crate::core::camera::ProjectionType::Perspective { .. }
1046                ) {
1047                    if config.show_title {
1048                        if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1049                            self.draw_title_in_rect(
1050                                ui,
1051                                panel_layout.title_rect,
1052                                title,
1053                                None,
1054                                config.font_scale,
1055                            );
1056                        }
1057                    }
1058                    self.draw_3d_orientation_gizmo(ui, r, plot_renderer, i, config.font_scale);
1059                    self.draw_3d_origin_axis_ticks(ui, r, plot_renderer, i, config.font_scale);
1060                    self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1061                    for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1062                        self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1063                    }
1064                    if plot_renderer.overlay_show_legend_for_axes(i) {
1065                        let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1066                        self.draw_legend(ui, r, &entries, config.font_scale);
1067                    }
1068                    continue;
1069                }
1070                // Frame (2D only; 3D uses the axes cube instead)
1071                if plot_renderer.overlay_show_box_for_axes(i) {
1072                    self.draw_plot_box_mask(ui, r);
1073                    self.draw_2d_border(ui, frame_rect);
1074                }
1075
1076                // Grid (2D)
1077                if config.show_grid && plot_renderer.overlay_show_grid_for_axes(i) {
1078                    let b = plot_renderer.view_bounds_for_axes(i);
1079                    self.draw_grid(ui, r, plot_renderer, b, Some(i));
1080                }
1081                // Axes (2D)
1082                if config.show_axes {
1083                    let b = plot_renderer.view_bounds_for_axes(i);
1084                    self.draw_axes(ui, r, plot_renderer, config, b, Some(i));
1085                }
1086
1087                if config.show_title {
1088                    if let Some(title) = plot_renderer.overlay_title_for_axes(i) {
1089                        self.draw_title_in_rect(
1090                            ui,
1091                            panel_layout.title_rect,
1092                            title,
1093                            None,
1094                            config.font_scale,
1095                        );
1096                    }
1097                }
1098                if !matches!(
1099                    cam.projection,
1100                    crate::core::camera::ProjectionType::Perspective { .. }
1101                ) {
1102                    if let Some(x_label) = plot_renderer.overlay_x_label_for_axes(i) {
1103                        self.draw_x_label_in_rect(
1104                            ui,
1105                            panel_layout.x_label_rect,
1106                            x_label,
1107                            config.font_scale,
1108                        );
1109                    }
1110                }
1111                if !matches!(
1112                    cam.projection,
1113                    crate::core::camera::ProjectionType::Perspective { .. }
1114                ) {
1115                    if let Some(y_label) = plot_renderer.overlay_y_label_for_axes(i) {
1116                        self.draw_y_label_in_rect(
1117                            ui,
1118                            panel_layout.y_label_rect,
1119                            y_label,
1120                            config.font_scale,
1121                        );
1122                    }
1123                }
1124                self.draw_projected_world_texts(ui, r, plot_renderer, i, config.font_scale);
1125                for (label, pos) in plot_renderer.pie_labels_for_axes(i) {
1126                    self.draw_pie_label(ui, r, &label, pos, config.font_scale);
1127                }
1128                if plot_renderer.overlay_show_legend_for_axes(i) {
1129                    let entries = plot_renderer.overlay_legend_entries_for_axes(i);
1130                    self.draw_legend(ui, r, &entries, config.font_scale);
1131                }
1132            }
1133        } else {
1134            let cam = plot_renderer.camera();
1135            let panel_layout =
1136                self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale);
1137            let centered_plot_rect =
1138                Self::snap_rect_to_pixels(panel_layout.plot_rect, ui.ctx().pixels_per_point());
1139            let centered_frame_rect =
1140                Self::snap_rect_to_pixels(panel_layout.frame_rect, ui.ctx().pixels_per_point());
1141            rendered_axes_rects.push(centered_plot_rect);
1142            log::debug!(
1143                target: "runmat_plot.axes_layout",
1144                "computed axes panel layout axes_index=0 rows={} cols={} is_3d={} cell=({}, {})..({}, {}) frame=({}, {})..({}, {}) content=({}, {})..({}, {})",
1145                rows,
1146                cols,
1147                Self::axes_is_3d(plot_renderer, 0),
1148                plot_area_rect.min.x,
1149                plot_area_rect.min.y,
1150                plot_area_rect.max.x,
1151                plot_area_rect.max.y,
1152                centered_frame_rect.min.x,
1153                centered_frame_rect.min.y,
1154                centered_frame_rect.max.x,
1155                centered_frame_rect.max.y,
1156                centered_plot_rect.min.x,
1157                centered_plot_rect.min.y,
1158                centered_plot_rect.max.x,
1159                centered_plot_rect.max.y
1160            );
1161            if config.show_title {
1162                if let Some(title) = plot_renderer
1163                    .overlay_title_for_axes(0)
1164                    .or(config.title.as_ref())
1165                {
1166                    self.draw_title_in_rect(
1167                        ui,
1168                        panel_layout.title_rect,
1169                        title,
1170                        None,
1171                        config.font_scale,
1172                    );
1173                }
1174            }
1175            if matches!(
1176                cam.projection,
1177                crate::core::camera::ProjectionType::Perspective { .. }
1178            ) {
1179                self.draw_3d_orientation_gizmo(
1180                    ui,
1181                    centered_plot_rect,
1182                    plot_renderer,
1183                    0,
1184                    config.font_scale,
1185                );
1186                self.draw_3d_origin_axis_ticks(
1187                    ui,
1188                    centered_plot_rect,
1189                    plot_renderer,
1190                    0,
1191                    config.font_scale,
1192                );
1193                self.draw_projected_world_texts(
1194                    ui,
1195                    centered_plot_rect,
1196                    plot_renderer,
1197                    0,
1198                    config.font_scale,
1199                );
1200            } else {
1201                // Draw plot frame (2D only; 3D uses the axes cube instead)
1202                if plot_renderer.overlay_show_box() {
1203                    self.draw_plot_box_mask(ui, centered_plot_rect);
1204                    self.draw_2d_border(ui, centered_frame_rect);
1205                }
1206                // Draw grid if enabled
1207                if config.show_grid {
1208                    self.draw_grid(ui, centered_plot_rect, plot_renderer, None, None);
1209                }
1210
1211                // Draw axes if enabled
1212                if config.show_axes {
1213                    self.draw_axes(ui, centered_plot_rect, plot_renderer, config, None, None);
1214                    // Emphasize zero baseline if within data range
1215                    if let Some((x_min, x_max, y_min, y_max)) = plot_renderer
1216                        .view_bounds()
1217                        .or_else(|| plot_renderer.data_bounds())
1218                    {
1219                        let axis_color = self.theme_axis_color();
1220                        let zero_stroke = Stroke::new(1.5, axis_color);
1221                        if y_min < 0.0 && y_max > 0.0 {
1222                            let y_screen = centered_plot_rect.max.y
1223                                - ((0.0 - y_min) / (y_max - y_min)) as f32
1224                                    * centered_plot_rect.height();
1225                            ui.painter().line_segment(
1226                                [
1227                                    Pos2::new(centered_plot_rect.min.x, y_screen),
1228                                    Pos2::new(centered_plot_rect.max.x, y_screen),
1229                                ],
1230                                zero_stroke,
1231                            );
1232                        }
1233                        if x_min < 0.0 && x_max > 0.0 {
1234                            let x_screen = centered_plot_rect.min.x
1235                                + ((0.0 - x_min) / (x_max - x_min)) as f32
1236                                    * centered_plot_rect.width();
1237                            ui.painter().line_segment(
1238                                [
1239                                    Pos2::new(x_screen, centered_plot_rect.min.y),
1240                                    Pos2::new(x_screen, centered_plot_rect.max.y),
1241                                ],
1242                                zero_stroke,
1243                            );
1244                        }
1245                    }
1246                }
1247                if let Some(x_label) = plot_renderer
1248                    .overlay_x_label_for_axes(0)
1249                    .or(config.x_label.as_ref())
1250                {
1251                    self.draw_x_label_in_rect(
1252                        ui,
1253                        panel_layout.x_label_rect,
1254                        x_label,
1255                        config.font_scale,
1256                    );
1257                }
1258                if let Some(y_label) = plot_renderer
1259                    .overlay_y_label_for_axes(0)
1260                    .or(config.y_label.as_ref())
1261                {
1262                    self.draw_y_label_in_rect(
1263                        ui,
1264                        panel_layout.y_label_rect,
1265                        y_label,
1266                        config.font_scale,
1267                    );
1268                }
1269                self.draw_projected_world_texts(
1270                    ui,
1271                    centered_plot_rect,
1272                    plot_renderer,
1273                    0,
1274                    config.font_scale,
1275                );
1276            }
1277        }
1278        let centered_plot_rect = if rows * cols <= 1 {
1279            self.panel_layout_for_axes(plot_area_rect, plot_renderer, 0, config.font_scale)
1280                .plot_rect
1281        } else {
1282            plot_area_rect
1283        };
1284        for (label, pos) in if rows * cols <= 1 {
1285            plot_renderer.active_axes_pie_labels()
1286        } else {
1287            Vec::new()
1288        } {
1289            self.draw_pie_label(ui, centered_plot_rect, &label, pos, config.font_scale);
1290        }
1291
1292        // Draw legend if enabled and entries available
1293        if rows * cols <= 1 && plot_renderer.overlay_show_legend() {
1294            self.draw_legend(
1295                ui,
1296                centered_plot_rect,
1297                plot_renderer.overlay_legend_entries(),
1298                config.font_scale,
1299            );
1300        }
1301
1302        // Draw colorbar if enabled
1303        if plot_renderer.overlay_colorbar_enabled() {
1304            // Simple vertical colorbar on the right side inside plot
1305            let bar_width = 12.0;
1306            let pad = 8.0;
1307            let bar_rect = Rect::from_min_max(
1308                egui::pos2(
1309                    centered_plot_rect.max.x - bar_width - pad,
1310                    centered_plot_rect.min.y + pad,
1311                ),
1312                egui::pos2(
1313                    centered_plot_rect.max.x - pad,
1314                    centered_plot_rect.max.y - pad,
1315                ),
1316            );
1317            // Fill with gradient according to colormap
1318            let steps = 64;
1319            for i in 0..steps {
1320                let t0 = i as f32 / steps as f32;
1321                let t1 = (i + 1) as f32 / steps as f32;
1322                let y0 = bar_rect.min.y + (1.0 - t0) * bar_rect.height();
1323                let y1 = bar_rect.min.y + (1.0 - t1) * bar_rect.height();
1324                let cmap = plot_renderer.overlay_colormap();
1325                let c = cmap.map_value(t0);
1326                let col = Color32::from_rgb(
1327                    (c.x * 255.0) as u8,
1328                    (c.y * 255.0) as u8,
1329                    (c.z * 255.0) as u8,
1330                );
1331                ui.painter().rect_filled(
1332                    Rect::from_min_max(
1333                        egui::pos2(bar_rect.min.x, y1),
1334                        egui::pos2(bar_rect.max.x, y0),
1335                    ),
1336                    0.0,
1337                    col,
1338                );
1339            }
1340            let bg = plot_renderer.theme.build_theme().get_background_color();
1341            let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
1342            let border = if bg_luma > 0.62 {
1343                Color32::from_gray(60)
1344            } else {
1345                Color32::WHITE
1346            };
1347            ui.painter()
1348                .rect_stroke(bar_rect, 0.0, Stroke::new(1.0, border));
1349        }
1350
1351        self.axes_plot_rects = rendered_axes_rects;
1352        centered_plot_rect
1353    }
1354
1355    /// Compute subplot rectangles within a given plot area for a rows x cols grid (row-major)
1356    pub fn compute_subplot_rects(
1357        &self,
1358        outer: Rect,
1359        rows: usize,
1360        cols: usize,
1361        hgap: f32,
1362        vgap: f32,
1363    ) -> Vec<Rect> {
1364        let rows = rows.max(1) as f32;
1365        let cols = cols.max(1) as f32;
1366        let total_hgap = hgap * (cols - 1.0);
1367        let total_vgap = vgap * (rows - 1.0);
1368        let cell_w = ((outer.width()).max(1.0) - total_hgap).max(1.0) / cols;
1369        let cell_h = ((outer.height()).max(1.0) - total_vgap).max(1.0) / rows;
1370        let mut rects = Vec::new();
1371        for r in 0..rows as i32 {
1372            for c in 0..cols as i32 {
1373                let x = outer.min.x + c as f32 * (cell_w + hgap);
1374                let y = outer.min.y + r as f32 * (cell_h + vgap);
1375                rects.push(Rect::from_min_size(
1376                    egui::pos2(x, y),
1377                    egui::vec2(cell_w, cell_h),
1378                ));
1379            }
1380        }
1381        rects
1382    }
1383
1384    /// Draw grid lines based on data bounds
1385    fn draw_grid(
1386        &self,
1387        ui: &mut egui::Ui,
1388        plot_rect: Rect,
1389        plot_renderer: &PlotRenderer,
1390        view_bounds_override: Option<(f64, f64, f64, f64)>,
1391        axes_index: Option<usize>,
1392    ) {
1393        let ppp = ui.ctx().pixels_per_point();
1394        let edge_eps = 0.51 / ppp.max(0.5);
1395        if let Some(data_bounds) = view_bounds_override
1396            .or_else(|| plot_renderer.view_bounds())
1397            .or_else(|| plot_renderer.data_bounds())
1398        {
1399            let (grid_color_major, _grid_color_minor) = self.themed_grid_colors();
1400
1401            let (x_min, x_max, y_min, y_max) = data_bounds;
1402            let x_range = x_max - x_min;
1403            let y_range = y_max - y_min;
1404
1405            // Calculate tick intervals
1406            let x_log = axes_index
1407                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1408                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1409            let y_log = axes_index
1410                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1411                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1412
1413            let x_ticks = if x_log {
1414                Vec::new()
1415            } else {
1416                plot_utils::generate_major_ticks(x_min, x_max)
1417            };
1418            let y_ticks = if y_log {
1419                Vec::new()
1420            } else {
1421                plot_utils::generate_major_ticks(y_min, y_max)
1422            };
1423
1424            // Draw vertical grid lines (linear vs log)
1425            if x_log {
1426                // Decades within [x_min, x_max]
1427                let start_decade = x_min.log10().floor() as i32;
1428                let end_decade = x_max.log10().ceil() as i32;
1429                for d in start_decade..=end_decade {
1430                    let decade = 10f64.powi(d);
1431                    for m in [1.0, 2.0, 5.0].iter() {
1432                        let x_val = decade * m;
1433                        if x_val < x_min || x_val > x_max {
1434                            continue;
1435                        }
1436                        let x_screen = plot_rect.min.x
1437                            + ((x_val.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1438                                as f32
1439                                * plot_rect.width();
1440                        let x_screen = Self::snap_coord(x_screen, ppp);
1441                        if (x_screen - plot_rect.min.x).abs() <= edge_eps
1442                            || (x_screen - plot_rect.max.x).abs() <= edge_eps
1443                        {
1444                            continue;
1445                        }
1446                        ui.painter().line_segment(
1447                            [
1448                                Pos2::new(x_screen, plot_rect.min.y),
1449                                Pos2::new(x_screen, plot_rect.max.y),
1450                            ],
1451                            Stroke::new(0.8, grid_color_major),
1452                        );
1453                    }
1454                }
1455            } else {
1456                for x_val in x_ticks {
1457                    let x_screen =
1458                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1459                    let x_screen = Self::snap_coord(x_screen, ppp);
1460                    if (x_screen - plot_rect.min.x).abs() <= edge_eps
1461                        || (x_screen - plot_rect.max.x).abs() <= edge_eps
1462                    {
1463                        continue;
1464                    }
1465                    ui.painter().line_segment(
1466                        [
1467                            Pos2::new(x_screen, plot_rect.min.y),
1468                            Pos2::new(x_screen, plot_rect.max.y),
1469                        ],
1470                        Stroke::new(0.8, grid_color_major),
1471                    );
1472                }
1473            }
1474
1475            // Draw horizontal grid lines (linear vs log)
1476            if y_log {
1477                let start_decade = y_min.log10().floor() as i32;
1478                let end_decade = y_max.log10().ceil() as i32;
1479                for d in start_decade..=end_decade {
1480                    let decade = 10f64.powi(d);
1481                    for m in [1.0, 2.0, 5.0].iter() {
1482                        let y_val = decade * m;
1483                        if y_val < y_min || y_val > y_max {
1484                            continue;
1485                        }
1486                        let y_screen = plot_rect.max.y
1487                            - ((y_val.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1488                                as f32
1489                                * plot_rect.height();
1490                        let y_screen = Self::snap_coord(y_screen, ppp);
1491                        if (y_screen - plot_rect.min.y).abs() <= edge_eps
1492                            || (y_screen - plot_rect.max.y).abs() <= edge_eps
1493                        {
1494                            continue;
1495                        }
1496                        ui.painter().line_segment(
1497                            [
1498                                Pos2::new(plot_rect.min.x, y_screen),
1499                                Pos2::new(plot_rect.max.x, y_screen),
1500                            ],
1501                            Stroke::new(0.8, grid_color_major),
1502                        );
1503                    }
1504                }
1505            } else {
1506                for y_val in y_ticks {
1507                    let y_screen =
1508                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1509                    let y_screen = Self::snap_coord(y_screen, ppp);
1510                    if (y_screen - plot_rect.min.y).abs() <= edge_eps
1511                        || (y_screen - plot_rect.max.y).abs() <= edge_eps
1512                    {
1513                        continue;
1514                    }
1515                    ui.painter().line_segment(
1516                        [
1517                            Pos2::new(plot_rect.min.x, y_screen),
1518                            Pos2::new(plot_rect.max.x, y_screen),
1519                        ],
1520                        Stroke::new(0.8, grid_color_major),
1521                    );
1522                }
1523            }
1524        }
1525    }
1526
1527    /// Draw axis ticks and numeric labels
1528    fn draw_axes(
1529        &self,
1530        ui: &mut egui::Ui,
1531        plot_rect: Rect,
1532        plot_renderer: &PlotRenderer,
1533        config: &OverlayConfig,
1534        view_bounds_override: Option<(f64, f64, f64, f64)>,
1535        axes_index: Option<usize>,
1536    ) {
1537        let ppp = ui.ctx().pixels_per_point();
1538        if let Some(data_bounds) = view_bounds_override
1539            .or_else(|| plot_renderer.view_bounds())
1540            .or_else(|| plot_renderer.data_bounds())
1541        {
1542            let (x_min, x_max, y_min, y_max) = data_bounds;
1543            let x_range = x_max - x_min;
1544            let y_range = y_max - y_min;
1545            let scale = config.font_scale.max(0.75);
1546            let tick_length = 6.0 * scale;
1547            let label_offset = 15.0 * scale;
1548            let tick_font = FontId::proportional(10.0 * scale);
1549            let axis_color = self.theme_axis_color();
1550            let label_color = self.theme_text_color();
1551            let border_left = plot_rect.min.x;
1552            let border_bottom = plot_rect.max.y;
1553
1554            let x_log = axes_index
1555                .map(|idx| plot_renderer.overlay_x_log_for_axes(idx))
1556                .unwrap_or_else(|| plot_renderer.overlay_x_log());
1557            let y_log = axes_index
1558                .map(|idx| plot_renderer.overlay_y_log_for_axes(idx))
1559                .unwrap_or_else(|| plot_renderer.overlay_y_log());
1560
1561            // Histogram numeric tick support and categorical axis support
1562            let (mut cat_x, mut cat_y) = (false, false);
1563            let mut custom_hist_x = false;
1564            if let Some((true, edges)) =
1565                axes_index.and_then(|idx| plot_renderer.overlay_histogram_edges_for_axes(idx))
1566            {
1567                custom_hist_x = true;
1568                self.draw_histogram_axis_ticks(
1569                    ui,
1570                    plot_rect,
1571                    ppp,
1572                    axis_color,
1573                    label_color,
1574                    tick_length,
1575                    label_offset,
1576                    tick_font.clone(),
1577                    border_bottom,
1578                    x_min,
1579                    x_max,
1580                    &edges,
1581                );
1582            }
1583            if let Some((is_x, labels)) = axes_index
1584                .and_then(|idx| plot_renderer.overlay_categorical_labels_for_axes(idx))
1585                .or_else(|| {
1586                    plot_renderer
1587                        .overlay_categorical_labels()
1588                        .map(|(is_x, labels)| (is_x, labels.clone()))
1589                })
1590            {
1591                if is_x {
1592                    cat_x = true;
1593                } else {
1594                    cat_y = true;
1595                }
1596                if is_x {
1597                    let stride = Self::label_stride(&labels, plot_rect.width(), tick_font.size);
1598                    // Draw X categorical labels at integer positions (1..n)
1599                    for (label_idx, label) in labels.iter().enumerate() {
1600                        if label_idx != 0
1601                            && label_idx != labels.len() - 1
1602                            && label_idx % stride != 0
1603                        {
1604                            continue;
1605                        }
1606                        let x_val = (label_idx + 1) as f64;
1607                        if x_val < x_min || x_val > x_max {
1608                            continue;
1609                        }
1610                        let x_screen = plot_rect.min.x
1611                            + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1612                        let x_screen = Self::snap_coord(x_screen, ppp);
1613                        // Tick
1614                        ui.painter().line_segment(
1615                            [
1616                                Pos2::new(x_screen, border_bottom),
1617                                Pos2::new(x_screen, border_bottom + tick_length),
1618                            ],
1619                            Stroke::new(1.0, axis_color),
1620                        );
1621                        // Label
1622                        let text = truncate_label(label, 14);
1623                        ui.painter().text(
1624                            Pos2::new(x_screen, border_bottom + label_offset),
1625                            Align2::CENTER_CENTER,
1626                            text,
1627                            tick_font.clone(),
1628                            label_color,
1629                        );
1630                    }
1631                } else {
1632                    let stride = Self::label_stride(&labels, plot_rect.height(), tick_font.size);
1633                    // Draw Y categorical labels at integer positions (1..n)
1634                    for (label_idx, label) in labels.iter().enumerate() {
1635                        if label_idx != 0
1636                            && label_idx != labels.len() - 1
1637                            && label_idx % stride != 0
1638                        {
1639                            continue;
1640                        }
1641                        let y_val = (label_idx + 1) as f64;
1642                        if y_val < y_min || y_val > y_max {
1643                            continue;
1644                        }
1645                        let y_screen = plot_rect.max.y
1646                            - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1647                        let y_screen = Self::snap_coord(y_screen, ppp);
1648                        // Tick
1649                        ui.painter().line_segment(
1650                            [
1651                                Pos2::new(border_left - tick_length, y_screen),
1652                                Pos2::new(border_left, y_screen),
1653                            ],
1654                            Stroke::new(1.0, axis_color),
1655                        );
1656                        // Label
1657                        let text = truncate_label(label, 14);
1658                        ui.painter().text(
1659                            Pos2::new(border_left - label_offset, y_screen),
1660                            Align2::CENTER_CENTER,
1661                            text,
1662                            tick_font.clone(),
1663                            label_color,
1664                        );
1665                    }
1666                }
1667            }
1668
1669            // Draw X-axis ticks and labels (categorical handled above)
1670            if x_log {
1671                let start_decade = x_min.log10().floor() as i32;
1672                let end_decade = x_max.log10().ceil() as i32;
1673                for d in start_decade..=end_decade {
1674                    let decade = 10f64.powi(d);
1675                    let x_screen = plot_rect.min.x
1676                        + ((decade.log10() - x_min.log10()) / (x_max.log10() - x_min.log10()))
1677                            as f32
1678                            * plot_rect.width();
1679                    let x_screen = Self::snap_coord(x_screen, ppp);
1680                    // Tick mark
1681                    ui.painter().line_segment(
1682                        [
1683                            Pos2::new(x_screen, border_bottom),
1684                            Pos2::new(x_screen, border_bottom + tick_length),
1685                        ],
1686                        Stroke::new(1.0, axis_color),
1687                    );
1688                    // Label like 10^d
1689                    ui.painter().text(
1690                        Pos2::new(x_screen, border_bottom + label_offset),
1691                        Align2::CENTER_CENTER,
1692                        format!("10^{}", d),
1693                        tick_font.clone(),
1694                        label_color,
1695                    );
1696                }
1697            } else if !cat_x && !custom_hist_x {
1698                for x_val in plot_utils::generate_major_ticks(x_min, x_max) {
1699                    let x_screen =
1700                        plot_rect.min.x + ((x_val - x_min) / x_range) as f32 * plot_rect.width();
1701                    let x_screen = Self::snap_coord(x_screen, ppp);
1702                    ui.painter().line_segment(
1703                        [
1704                            Pos2::new(x_screen, border_bottom),
1705                            Pos2::new(x_screen, border_bottom + tick_length),
1706                        ],
1707                        Stroke::new(1.0, axis_color),
1708                    );
1709                    ui.painter().text(
1710                        Pos2::new(x_screen, border_bottom + label_offset),
1711                        Align2::CENTER_CENTER,
1712                        plot_utils::format_tick_label(x_val),
1713                        tick_font.clone(),
1714                        label_color,
1715                    );
1716                }
1717            }
1718
1719            // Draw Y-axis ticks and labels (categorical handled above)
1720            if y_log {
1721                let start_decade = y_min.log10().floor() as i32;
1722                let end_decade = y_max.log10().ceil() as i32;
1723                for d in start_decade..=end_decade {
1724                    let decade = 10f64.powi(d);
1725                    let y_screen = plot_rect.max.y
1726                        - ((decade.log10() - y_min.log10()) / (y_max.log10() - y_min.log10()))
1727                            as f32
1728                            * plot_rect.height();
1729                    let y_screen = Self::snap_coord(y_screen, ppp);
1730                    ui.painter().line_segment(
1731                        [
1732                            Pos2::new(border_left - tick_length, y_screen),
1733                            Pos2::new(border_left, y_screen),
1734                        ],
1735                        Stroke::new(1.0, axis_color),
1736                    );
1737                    ui.painter().text(
1738                        Pos2::new(border_left - label_offset, y_screen),
1739                        Align2::CENTER_CENTER,
1740                        format!("10^{}", d),
1741                        tick_font.clone(),
1742                        label_color,
1743                    );
1744                }
1745            } else if !cat_y {
1746                for y_val in plot_utils::generate_major_ticks(y_min, y_max) {
1747                    let y_screen =
1748                        plot_rect.max.y - ((y_val - y_min) / y_range) as f32 * plot_rect.height();
1749                    let y_screen = Self::snap_coord(y_screen, ppp);
1750                    ui.painter().line_segment(
1751                        [
1752                            Pos2::new(border_left - tick_length, y_screen),
1753                            Pos2::new(border_left, y_screen),
1754                        ],
1755                        Stroke::new(1.0, axis_color),
1756                    );
1757                    ui.painter().text(
1758                        Pos2::new(border_left - label_offset, y_screen),
1759                        Align2::CENTER_CENTER,
1760                        plot_utils::format_tick_label(y_val),
1761                        tick_font.clone(),
1762                        label_color,
1763                    );
1764                }
1765            }
1766        }
1767    }
1768
1769    /// Draw a CAD-style XYZ orientation gizmo in the bottom-left corner of the plot rect.
1770    /// This is drawn in screen-space (overlay) but rotates with the current 3D camera.
1771    fn draw_3d_orientation_gizmo(
1772        &self,
1773        ui: &mut egui::Ui,
1774        plot_rect: Rect,
1775        plot_renderer: &PlotRenderer,
1776        axes_index: usize,
1777        font_scale: f32,
1778    ) {
1779        let cam_ref = plot_renderer
1780            .axes_camera(axes_index)
1781            .unwrap_or_else(|| plot_renderer.camera());
1782        let cam = cam_ref.clone();
1783
1784        let forward = (cam.target - cam.position).normalize_or_zero();
1785        if forward.length_squared() < 1e-9 {
1786            return;
1787        }
1788        let world_up = cam.up.normalize_or_zero();
1789        let right = forward.cross(world_up).normalize_or_zero();
1790        if right.length_squared() < 1e-9 {
1791            return;
1792        }
1793        let up = right.cross(forward).normalize_or_zero();
1794        if up.length_squared() < 1e-9 {
1795            return;
1796        }
1797
1798        let scale = font_scale.max(0.75);
1799        let base = plot_rect.width().min(plot_rect.height()).max(1.0);
1800        let gizmo_size = (base * 0.16).clamp(44.0, 110.0) * scale;
1801        let pad = (30.0 * scale).round();
1802        let origin = Pos2::new(plot_rect.min.x + pad, plot_rect.max.y - pad);
1803
1804        struct AxisItem {
1805            label: &'static str,
1806            dir_world: Vec3,
1807            color: Color32,
1808            z_sort: f32,
1809        }
1810
1811        let mut axes = [
1812            AxisItem {
1813                label: "X",
1814                dir_world: Vec3::X,
1815                color: Color32::from_rgb(235, 80, 80),
1816                z_sort: 0.0,
1817            },
1818            AxisItem {
1819                label: "Y",
1820                dir_world: Vec3::Y,
1821                color: Color32::from_rgb(90, 220, 120),
1822                z_sort: 0.0,
1823            },
1824            AxisItem {
1825                label: "Z",
1826                dir_world: Vec3::Z,
1827                color: Color32::from_rgb(90, 160, 255),
1828                z_sort: 0.0,
1829            },
1830        ];
1831
1832        // Transform world axis directions into camera space and use the camera-space z as a
1833        // painter's-order hint (draw farther axes first so nearer ones sit on top).
1834        for a in axes.iter_mut() {
1835            let x = a.dir_world.dot(right);
1836            let y = a.dir_world.dot(up);
1837            let z = a.dir_world.dot(-forward);
1838            a.z_sort = z;
1839            a.dir_world = Vec3::new(x, y, z);
1840        }
1841        axes.sort_by(|a, b| {
1842            a.z_sort
1843                .partial_cmp(&b.z_sort)
1844                .unwrap_or(std::cmp::Ordering::Equal)
1845        });
1846
1847        let painter = ui.painter();
1848
1849        painter.circle_filled(origin, 2.0 * scale, Color32::from_gray(210));
1850
1851        let axis_len = gizmo_size * 0.65;
1852        let head_len = (8.0 * scale).min(axis_len * 0.35);
1853        let head_w = 5.0 * scale;
1854        let font = FontId::proportional(11.0 * scale);
1855
1856        for a in axes.iter() {
1857            let dir2 = egui::Vec2::new(a.dir_world.x, -a.dir_world.y);
1858            let mag = dir2.length();
1859            if !mag.is_finite() || mag < 1e-4 {
1860                continue;
1861            }
1862            let d = dir2 / mag;
1863
1864            let end = origin + d * axis_len;
1865            let stroke = Stroke::new(2.0 * scale, a.color);
1866            painter.line_segment([origin, end], stroke);
1867
1868            // Arrow head
1869            let base = end - d * head_len;
1870            let perp = egui::Vec2::new(-d.y, d.x);
1871            painter.line_segment([end, base + perp * head_w], stroke);
1872            painter.line_segment([end, base - perp * head_w], stroke);
1873
1874            // Label near arrow tip
1875            let label_pos = end + d * (10.0 * scale);
1876            painter.text(
1877                label_pos,
1878                Align2::CENTER_CENTER,
1879                a.label,
1880                font.clone(),
1881                a.color,
1882            );
1883        }
1884    }
1885
1886    /// Draw dynamic tick labels for the 3D origin triad axes (X/Y/Z).
1887    /// These labels are screen-space (egui) but are positioned by projecting 3D points.
1888    fn draw_3d_origin_axis_ticks(
1889        &self,
1890        ui: &mut egui::Ui,
1891        plot_rect: Rect,
1892        plot_renderer: &PlotRenderer,
1893        axes_index: usize,
1894        font_scale: f32,
1895    ) {
1896        let cam_ref = plot_renderer
1897            .axes_camera(axes_index)
1898            .unwrap_or_else(|| plot_renderer.camera());
1899        let mut cam = cam_ref.clone();
1900        let w = plot_rect.width().max(1.0);
1901        let h = plot_rect.height().max(1.0);
1902        cam.update_aspect_ratio(w / h);
1903        let view_proj = cam.view_proj_matrix();
1904
1905        let project = |p: Vec3| -> Option<Pos2> {
1906            let clip: Vec4 = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
1907            if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
1908                return None;
1909            }
1910            let ndc = clip.truncate() / clip.w;
1911            if !(ndc.x.is_finite() && ndc.y.is_finite()) {
1912                return None;
1913            }
1914            let sx = plot_rect.min.x + ((ndc.x + 1.0) * 0.5) * plot_rect.width();
1915            let sy = plot_rect.min.y + ((1.0 - ndc.y) * 0.5) * plot_rect.height();
1916            Some(Pos2::new(sx, sy))
1917        };
1918
1919        let nice_step = |raw: f64| -> f64 {
1920            if !raw.is_finite() || raw <= 0.0 {
1921                return 1.0;
1922            }
1923            let pow10 = 10.0_f64.powf(raw.log10().floor());
1924            let norm = raw / pow10;
1925            let mult = if norm <= 1.0 {
1926                1.0
1927            } else if norm <= 2.0 {
1928                2.0
1929            } else if norm <= 5.0 {
1930                5.0
1931            } else {
1932                10.0
1933            };
1934            mult * pow10
1935        };
1936
1937        // Use the same basic heuristic as the renderer: choose a major tick spacing based on
1938        // projected pixels per world unit near the origin.
1939        let origin = Vec3::ZERO;
1940        let px_per_world = match (project(origin), project(origin + Vec3::X)) {
1941            (Some(a), Some(b)) => ((b.x - a.x).hypot(b.y - a.y) as f64).max(1e-3),
1942            _ => 1.0,
1943        };
1944        let desired_major_px = 120.0_f64;
1945        let major_step = nice_step((desired_major_px / px_per_world).max(1e-6));
1946        if !(major_step.is_finite() && major_step > 0.0) {
1947            return;
1948        }
1949        let axis_len = (major_step as f32 * 5.0).max(0.5);
1950
1951        let scale = font_scale.max(0.75);
1952        let font = FontId::proportional(10.0 * scale);
1953        let painter = ui.painter();
1954        let col_x = Color32::from_rgb(235, 80, 80);
1955        let col_y = Color32::from_rgb(90, 220, 120);
1956        let col_z = Color32::from_rgb(90, 160, 255);
1957        let panel_center = plot_rect.center();
1958
1959        let outward_offset = |pos: Pos2, base: f32| {
1960            let dir = pos - panel_center;
1961            let len = dir.length().max(1.0);
1962            (dir / len) * base
1963        };
1964
1965        if let Some(pos) = project(Vec3::X * axis_len * 1.10) {
1966            if let Some(label) = plot_renderer.overlay_x_label_for_axes(axes_index) {
1967                let style = plot_renderer
1968                    .overlay_x_label_style_for_axes(axes_index)
1969                    .cloned()
1970                    .unwrap_or_default();
1971                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(4.0 * scale, 0.0);
1972                Self::paint_styled_text(
1973                    painter,
1974                    pos + offset,
1975                    Align2::LEFT_CENTER,
1976                    label,
1977                    Self::style_font_size(&style, 12.0, scale),
1978                    Self::style_color(&style, col_x),
1979                    Self::style_is_bold(&style),
1980                    110,
1981                );
1982            }
1983        }
1984        if let Some(pos) = project(Vec3::Y * axis_len * 1.10) {
1985            if let Some(label) = plot_renderer.overlay_y_label_for_axes(axes_index) {
1986                let style = plot_renderer
1987                    .overlay_y_label_style_for_axes(axes_index)
1988                    .cloned()
1989                    .unwrap_or_default();
1990                let offset =
1991                    outward_offset(pos, 12.0 * scale) + egui::vec2(2.0 * scale, -2.0 * scale);
1992                Self::paint_styled_text(
1993                    painter,
1994                    pos + offset,
1995                    Align2::LEFT_CENTER,
1996                    label,
1997                    Self::style_font_size(&style, 12.0, scale),
1998                    Self::style_color(&style, col_y),
1999                    Self::style_is_bold(&style),
2000                    110,
2001                );
2002            }
2003        }
2004        if let Some(pos) = project(Vec3::Z * axis_len * 1.10) {
2005            if let Some(label) = plot_renderer.overlay_z_label_for_axes(axes_index) {
2006                let style = plot_renderer
2007                    .overlay_z_label_style_for_axes(axes_index)
2008                    .cloned()
2009                    .unwrap_or_default();
2010                let offset = outward_offset(pos, 12.0 * scale) + egui::vec2(0.0, -4.0 * scale);
2011                Self::paint_styled_text(
2012                    painter,
2013                    pos + offset,
2014                    Align2::LEFT_BOTTOM,
2015                    label,
2016                    Self::style_font_size(&style, 12.0, scale),
2017                    Self::style_color(&style, col_z),
2018                    Self::style_is_bold(&style),
2019                    110,
2020                );
2021            }
2022        }
2023
2024        let draw_axis = |axis: Vec3, color: Color32| {
2025            for i in 1..=6 {
2026                let t = (i as f32) * (major_step as f32);
2027                if t >= axis_len * 0.999 {
2028                    break;
2029                }
2030                let p = origin + axis * t;
2031                let Some(pos) = project(p) else { continue };
2032                // Offset labels slightly away from the axis in screen-space based on camera right/up.
2033                let offset =
2034                    outward_offset(pos, 7.0 * scale) + egui::Vec2::new(3.0 * scale, -3.0 * scale);
2035                painter.text(
2036                    pos + offset + egui::vec2(1.0, 1.0),
2037                    Align2::LEFT_CENTER,
2038                    plot_utils::format_tick_label((i as f64) * major_step),
2039                    font.clone(),
2040                    Color32::from_rgba_premultiplied(0, 0, 0, 90),
2041                );
2042                painter.text(
2043                    pos + offset,
2044                    Align2::LEFT_CENTER,
2045                    plot_utils::format_tick_label((i as f64) * major_step),
2046                    font.clone(),
2047                    color,
2048                );
2049            }
2050        };
2051        draw_axis(Vec3::X, col_x);
2052        draw_axis(Vec3::Y, col_y);
2053        draw_axis(Vec3::Z, col_z);
2054    }
2055
2056    fn draw_title_in_rect(
2057        &self,
2058        ui: &mut egui::Ui,
2059        rect: Rect,
2060        title: &str,
2061        style: Option<&TextStyle>,
2062        scale: f32,
2063    ) {
2064        let scale = scale.max(0.75);
2065        let style = style.cloned().unwrap_or_default();
2066        Self::paint_styled_text(
2067            ui.painter(),
2068            rect.center(),
2069            Align2::CENTER_CENTER,
2070            title,
2071            Self::style_font_size(&style, 16.0, scale),
2072            Self::style_color(&style, self.theme_text_color()),
2073            Self::style_is_bold(&style),
2074            110,
2075        );
2076    }
2077
2078    fn draw_legend(
2079        &self,
2080        ui: &mut egui::Ui,
2081        plot_rect: Rect,
2082        entries: &[crate::plots::figure::LegendEntry],
2083        scale: f32,
2084    ) {
2085        if entries.is_empty() {
2086            return;
2087        }
2088        let scale = scale.max(0.75);
2089        let theme = self.theme.build_theme();
2090        let bg = theme.get_background_color();
2091        let text = theme.get_text_color();
2092        let legend_text = Color32::from_rgb(
2093            (text.x.clamp(0.0, 1.0) * 255.0) as u8,
2094            (text.y.clamp(0.0, 1.0) * 255.0) as u8,
2095            (text.z.clamp(0.0, 1.0) * 255.0) as u8,
2096        );
2097        let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
2098        let legend_bg = if bg_luma > 0.62 {
2099            Color32::from_rgba_premultiplied(255, 255, 255, 170)
2100        } else {
2101            Color32::from_rgba_premultiplied(0, 0, 0, 128)
2102        };
2103        let legend_stroke = if bg_luma > 0.62 {
2104            Color32::from_rgb(55, 55, 55)
2105        } else {
2106            Color32::BLACK
2107        };
2108        let pad = 8.0 * scale;
2109        let row_h = (16.0 * scale).clamp(13.0, 18.0);
2110        let swatch_w = 14.0 * scale;
2111        let text_x_gap = 18.0 * scale;
2112        let legend_w = (plot_rect.width() * 0.30).clamp(92.0, 132.0);
2113        let x = plot_rect.max.x - legend_w - pad;
2114        let mut y = plot_rect.min.y + pad + 4.0 * scale;
2115        let legend_rect = Rect::from_min_max(
2116            egui::pos2(x - pad, plot_rect.min.y + pad),
2117            egui::pos2(x + legend_w, y + entries.len() as f32 * row_h + pad),
2118        );
2119        ui.painter().rect_filled(legend_rect, 4.0, legend_bg);
2120        y += 10.0 * scale;
2121        for e in entries {
2122            let c = Color32::from_rgb(
2123                (e.color.x * 255.0) as u8,
2124                (e.color.y * 255.0) as u8,
2125                (e.color.z * 255.0) as u8,
2126            );
2127            let swatch_rect = Rect::from_min_size(
2128                egui::pos2(x, y - 5.0 * scale),
2129                egui::vec2(swatch_w, 7.0 * scale),
2130            );
2131            match e.plot_type {
2132                crate::plots::figure::PlotType::Line
2133                | crate::plots::figure::PlotType::Line3
2134                | crate::plots::figure::PlotType::Contour => {
2135                    let ymid = swatch_rect.center().y;
2136                    ui.painter().line_segment(
2137                        [
2138                            Pos2::new(swatch_rect.min.x, ymid),
2139                            Pos2::new(swatch_rect.max.x, ymid),
2140                        ],
2141                        Stroke::new(2.0, c),
2142                    );
2143                }
2144                crate::plots::figure::PlotType::Scatter
2145                | crate::plots::figure::PlotType::Scatter3 => {
2146                    let center = swatch_rect.center();
2147                    ui.painter().circle_filled(center, 3.5, c);
2148                    ui.painter()
2149                        .circle_stroke(center, 3.5, Stroke::new(1.0, legend_stroke));
2150                }
2151                crate::plots::figure::PlotType::Bar
2152                | crate::plots::figure::PlotType::Area
2153                | crate::plots::figure::PlotType::Surface
2154                | crate::plots::figure::PlotType::Pie
2155                | crate::plots::figure::PlotType::ContourFill => {
2156                    ui.painter().rect_filled(swatch_rect, 2.0, c);
2157                    ui.painter()
2158                        .rect_stroke(swatch_rect, 2.0, Stroke::new(1.0, legend_stroke));
2159                }
2160                crate::plots::figure::PlotType::ErrorBar
2161                | crate::plots::figure::PlotType::Stairs
2162                | crate::plots::figure::PlotType::Stem
2163                | crate::plots::figure::PlotType::Quiver => {
2164                    let ymid = swatch_rect.center().y;
2165                    ui.painter().line_segment(
2166                        [
2167                            Pos2::new(swatch_rect.min.x, ymid),
2168                            Pos2::new(swatch_rect.max.x - 4.0, ymid),
2169                        ],
2170                        Stroke::new(1.5, c),
2171                    );
2172                    ui.painter().line_segment(
2173                        [
2174                            Pos2::new(swatch_rect.max.x - 4.0, ymid - 3.0),
2175                            Pos2::new(swatch_rect.max.x, ymid),
2176                        ],
2177                        Stroke::new(1.0, c),
2178                    );
2179                }
2180            }
2181            ui.painter().text(
2182                egui::pos2(x + text_x_gap, y),
2183                Align2::LEFT_CENTER,
2184                &e.label,
2185                FontId::proportional(11.0 * scale),
2186                legend_text,
2187            );
2188            y += row_h;
2189        }
2190    }
2191
2192    fn draw_x_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2193        let scale = scale.max(0.75);
2194        let text_color = self.theme_text_color();
2195        ui.painter().text(
2196            Pos2::new(rect.center().x, rect.max.y - rect.height() * 0.24),
2197            Align2::CENTER_CENTER,
2198            label,
2199            FontId::proportional(14.0 * scale),
2200            text_color,
2201        );
2202    }
2203
2204    fn draw_y_label_in_rect(&self, ui: &mut egui::Ui, rect: Rect, label: &str, scale: f32) {
2205        let scale = scale.max(0.75);
2206        let text_color = self.theme_text_color();
2207        let galley = ui.fonts(|fonts| {
2208            fonts.layout_no_wrap(
2209                label.to_owned(),
2210                FontId::proportional(13.0 * scale),
2211                text_color,
2212            )
2213        });
2214        let size = galley.size();
2215        let center = Pos2::new(rect.min.x + rect.width() * 0.32, rect.center().y);
2216        let pos = Pos2::new(center.x - size.y * 0.5, center.y + size.x * 0.5);
2217        let mut shape = egui::epaint::TextShape::new(pos, galley, text_color);
2218        shape.angle = -std::f32::consts::FRAC_PI_2;
2219        shape.override_text_color = Some(text_color);
2220        ui.painter().add(shape);
2221    }
2222
2223    fn project_world_to_screen(
2224        &self,
2225        plot_rect: Rect,
2226        camera: &crate::core::Camera,
2227        point: glam::Vec3,
2228    ) -> Option<Pos2> {
2229        let mut cam = camera.clone();
2230        let clip = cam.view_proj_matrix() * point.extend(1.0);
2231        if !clip.x.is_finite() || !clip.y.is_finite() || !clip.z.is_finite() || !clip.w.is_finite()
2232        {
2233            return None;
2234        }
2235        if clip.w.abs() < 1.0e-6 {
2236            return None;
2237        }
2238        let ndc = clip.truncate() / clip.w;
2239        if ndc.z < -1.1 || ndc.z > 1.1 {
2240            return None;
2241        }
2242        if clip.w <= 0.0
2243            && matches!(
2244                camera.projection,
2245                crate::core::camera::ProjectionType::Perspective { .. }
2246            )
2247        {
2248            return None;
2249        }
2250        let x = plot_rect.min.x + (ndc.x + 1.0) * 0.5 * plot_rect.width();
2251        let y = plot_rect.min.y + (1.0 - (ndc.y + 1.0) * 0.5) * plot_rect.height();
2252        Some(Pos2::new(x, y))
2253    }
2254
2255    fn draw_projected_world_texts(
2256        &self,
2257        ui: &mut egui::Ui,
2258        plot_rect: Rect,
2259        plot_renderer: &PlotRenderer,
2260        axes_index: usize,
2261        scale: f32,
2262    ) {
2263        let Some(camera) = plot_renderer
2264            .axes_camera(axes_index)
2265            .or_else(|| Some(plot_renderer.camera()))
2266        else {
2267            return;
2268        };
2269        let annotations = plot_renderer.world_text_annotations_for_axes(axes_index);
2270        let is_3d = Self::axes_is_3d(plot_renderer, axes_index);
2271        for (position, text, style) in annotations {
2272            if !style.visible || text.trim().is_empty() {
2273                continue;
2274            }
2275            let Some(screen) = self.project_world_to_screen(plot_rect, camera, position) else {
2276                continue;
2277            };
2278            let color = Self::style_color(&style, self.theme_text_color());
2279            let font_size = Self::style_font_size(&style, 14.0, scale);
2280            let offset = if is_3d {
2281                egui::vec2(0.0, -8.0 * scale.max(0.75))
2282            } else {
2283                egui::vec2(0.0, 6.0 * scale.max(0.75))
2284            };
2285            Self::paint_styled_text(
2286                ui.painter(),
2287                screen + offset,
2288                Align2::CENTER_CENTER,
2289                &text,
2290                font_size,
2291                color,
2292                Self::style_is_bold(&style),
2293                if is_3d { 120 } else { 90 },
2294            );
2295        }
2296    }
2297
2298    fn draw_pie_label(
2299        &self,
2300        ui: &mut egui::Ui,
2301        plot_rect: Rect,
2302        label: &str,
2303        pos: glam::Vec2,
2304        scale: f32,
2305    ) {
2306        let center = plot_rect.center();
2307        let radius = plot_rect.width().min(plot_rect.height()) * 0.4;
2308        let screen = Pos2::new(center.x + pos.x * radius, center.y - pos.y * radius);
2309        ui.painter().text(
2310            screen,
2311            Align2::CENTER_CENTER,
2312            label,
2313            FontId::proportional(12.0 * scale.max(0.75)),
2314            self.theme_text_color(),
2315        );
2316    }
2317
2318    /// Get the plot area from the last frame
2319    pub fn plot_area(&self) -> Option<Rect> {
2320        self.plot_area
2321    }
2322
2323    /// Get per-axes snapped content rectangles from the last frame.
2324    pub fn axes_plot_rects(&self) -> &[Rect] {
2325        &self.axes_plot_rects
2326    }
2327
2328    /// Get toolbar rectangle from last frame
2329    pub fn toolbar_rect(&self) -> Option<Rect> {
2330        self.toolbar_rect
2331    }
2332
2333    /// Get sidebar rectangle from last frame
2334    pub fn sidebar_rect(&self) -> Option<Rect> {
2335        self.sidebar_rect
2336    }
2337
2338    pub fn take_toolbar_actions(&mut self) -> (bool, bool, bool, Option<bool>, Option<bool>) {
2339        let out = (
2340            self.want_save_png,
2341            self.want_save_svg,
2342            self.want_reset_view,
2343            self.want_toggle_grid.take(),
2344            self.want_toggle_legend.take(),
2345        );
2346        self.want_save_png = false;
2347        self.want_save_svg = false;
2348        self.want_reset_view = false;
2349        out
2350    }
2351
2352    /// Render the Dystr information modal
2353    fn render_dystr_modal(&mut self, ctx: &Context) -> bool {
2354        let mut consumed_input = false;
2355
2356        egui::Window::new("About Dystr")
2357            .anchor(Align2::CENTER_CENTER, egui::Vec2::ZERO)
2358            .collapsible(false)
2359            .resizable(false)
2360            .default_width(400.0)
2361            .show(ctx, |ui| {
2362                consumed_input = true;
2363
2364                ui.vertical_centered(|ui| {
2365                    ui.add_space(10.0);
2366
2367                    // Dystr logo placeholder (larger for modal)
2368                    let logo_size = egui::Vec2::splat(64.0);
2369                    let logo_rect = ui.allocate_exact_size(logo_size, egui::Sense::hover()).0;
2370
2371                    ui.painter().rect_filled(
2372                        logo_rect,
2373                        8.0,                             // rounded corners
2374                        Color32::from_rgb(60, 130, 200), // Dystr brand color placeholder
2375                    );
2376
2377                    ui.painter().text(
2378                        logo_rect.center(),
2379                        Align2::CENTER_CENTER,
2380                        "D",
2381                        FontId::proportional(40.0),
2382                        Color32::WHITE,
2383                    );
2384
2385                    ui.add_space(15.0);
2386
2387                    ui.heading("Welcome to RunMat");
2388                    ui.add_space(10.0);
2389
2390                    ui.label("RunMat is a high-performance MATLAB-compatible");
2391                    ui.label("numerical computing platform, built as part of");
2392                    ui.label("the Dystr computation ecosystem.");
2393
2394                    ui.add_space(15.0);
2395
2396                    ui.label("🚀 V8-inspired JIT compilation");
2397                    ui.label("⚡ BLAS/LAPACK acceleration");
2398                    ui.label("🎯 Full MATLAB compatibility");
2399                    ui.label("🔬 Advanced plotting & visualization");
2400
2401                    ui.add_space(20.0);
2402
2403                    ui.horizontal(|ui| {
2404                        if ui.button("Visit dystr.com").clicked() {
2405                            // Open dystr.com in browser
2406                            if let Err(e) = webbrowser::open("https://dystr.com") {
2407                                eprintln!("Failed to open browser: {e}");
2408                            }
2409                        }
2410
2411                        if ui.button("Close").clicked() {
2412                            self.show_dystr_modal = false;
2413                        }
2414                    });
2415
2416                    ui.add_space(10.0);
2417                });
2418            });
2419
2420        consumed_input
2421    }
2422}
2423
2424fn truncate_label(label: &str, max_len: usize) -> String {
2425    if label.chars().count() <= max_len {
2426        return label.to_string();
2427    }
2428    let mut out = String::new();
2429    for (i, ch) in label.chars().enumerate() {
2430        if i >= max_len - 1 {
2431            break;
2432        }
2433        out.push(ch);
2434    }
2435    out.push('…');
2436    out
2437}