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