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