Skip to main content

runmat_plot/core/
plot_renderer.rs

1//! Unified plot rendering pipeline for both interactive GUI and static export
2//!
3//! This module provides the core rendering logic that is shared between
4//! interactive plotting windows and static file exports, ensuring consistent
5//! high-quality output across all use cases.
6
7use crate::core::renderer::Vertex;
8use crate::core::{BoundingBox, Camera, ClipPolicy, DepthMode, Scene, WgpuRenderer};
9use crate::plots::figure::{LegendEntry, TextStyle};
10use crate::plots::surface::ColorMap;
11use crate::plots::Figure;
12use glam::{Mat4, Vec3, Vec4};
13use runmat_time::Instant;
14use std::cell::RefCell;
15use std::collections::HashMap;
16use std::sync::Arc;
17
18type ViewBounds2D = (f64, f64, f64, f64);
19type PerAxesViewBounds = Vec<Option<ViewBounds2D>>;
20
21#[derive(Clone, Debug)]
22struct CachedSceneBuffers {
23    vertex_signature: (usize, usize),
24    vertex_buffer: Arc<wgpu::Buffer>,
25    index_signature: Option<(usize, usize)>,
26    index_buffer: Option<Arc<wgpu::Buffer>>,
27}
28
29#[derive(Clone, Debug, PartialEq)]
30struct AxesViewContract {
31    rows: usize,
32    cols: usize,
33    axes: Vec<AxesViewContractEntry>,
34}
35
36#[derive(Clone, Debug, PartialEq)]
37struct AxesViewContractEntry {
38    has_3d_content: bool,
39    x_limits: Option<(f64, f64)>,
40    y_limits: Option<(f64, f64)>,
41    z_limits: Option<(f64, f64)>,
42    axis_equal: bool,
43    x_log: bool,
44    y_log: bool,
45    view_azimuth_deg: Option<f32>,
46    view_elevation_deg: Option<f32>,
47    view_revision: u64,
48}
49
50const PATCH_3D_ABS_EPSILON: f32 = 1e-9;
51const PATCH_3D_REL_EPSILON: f32 = 1e-6;
52
53/// Unified plot renderer that handles both interactive and static rendering
54pub struct PlotRenderer {
55    /// WGPU renderer for GPU-accelerated rendering
56    pub wgpu_renderer: WgpuRenderer,
57
58    /// Current scene being rendered
59    pub scene: Scene,
60
61    /// Current theme configuration  
62    pub theme: crate::styling::PlotThemeConfig,
63
64    /// Cached rendering state
65    data_bounds: Option<(f64, f64, f64, f64)>,
66    needs_update: bool,
67
68    // Cached figure metadata for overlay
69    figure_title: Option<String>,
70    figure_sg_title: Option<String>,
71    figure_sg_title_style: TextStyle,
72    figure_x_label: Option<String>,
73    figure_y_label: Option<String>,
74    figure_z_label: Option<String>,
75    figure_show_grid: bool,
76    figure_show_legend: bool,
77    figure_show_box: bool,
78    figure_x_limits: Option<(f64, f64)>,
79    figure_y_limits: Option<(f64, f64)>,
80    legend_entries: Vec<LegendEntry>,
81    figure_x_log: bool,
82    figure_y_log: bool,
83    figure_axis_equal: bool,
84    figure_colormap: ColorMap,
85    figure_colorbar_enabled: bool,
86    // Categorical axis cache
87    figure_categorical_is_x: Option<bool>,
88    figure_categorical_labels: Option<Vec<String>>,
89    /// Per-axes cameras (for subplots and single-axes figures).
90    axes_cameras: Vec<Camera>,
91    /// Keep a clone of the last figure set for export/UX operations
92    pub(crate) last_figure: Option<crate::plots::Figure>,
93
94    /// Last surface extent (in pixels) that was used to build viewport-dependent geometry.
95    /// Used so we can rebuild the scene after the canvas is resized (common on wasm).
96    last_scene_viewport_px: Option<(u32, u32)>,
97    /// Last per-axes plot viewport sizes used to build viewport-dependent geometry.
98    last_axes_plot_sizes_px: Option<Vec<(u32, u32)>>,
99    /// Last per-axes orthographic view bounds used for viewport-dependent 2D stroke geometry.
100    last_axes_view_bounds: Option<PerAxesViewBounds>,
101    /// Last figure view contract used to decide whether script-owned axes state changed.
102    last_axes_view_contract: Option<AxesViewContract>,
103
104    /// If false, do not auto-fit camera when the figure updates (user has interacted).
105    camera_auto_fit: bool,
106    /// Per-axes 2D camera ownership. True means the user has interacted and automatic 2D refits
107    /// should not overwrite the camera during ordinary figure updates.
108    axes_2d_camera_user_controlled: Vec<bool>,
109    /// Last script-owned `view(...)` revision applied to each axes camera.
110    axes_applied_view_revisions: Vec<Option<u64>>,
111    /// Per-node GPU buffer cache for stable interactive redraws.
112    scene_buffer_cache: RefCell<HashMap<u64, CachedSceneBuffers>>,
113}
114
115/// Configuration for plot rendering
116#[derive(Debug, Clone)]
117pub struct PlotRenderConfig {
118    /// Output dimensions
119    pub width: u32,
120    pub height: u32,
121
122    /// Background color
123    pub background_color: Vec4,
124
125    /// Whether to draw grid
126    pub show_grid: bool,
127
128    /// Whether to draw axes
129    pub show_axes: bool,
130
131    /// Whether to draw title
132    pub show_title: bool,
133
134    /// Anti-aliasing samples
135    pub msaa_samples: u32,
136
137    /// Depth mode for 3D rendering (standard vs reversed-Z).
138    pub depth_mode: DepthMode,
139
140    /// Clip plane policy for 3D rendering.
141    pub clip_policy: ClipPolicy,
142
143    /// Theme to use
144    pub theme: crate::styling::PlotThemeConfig,
145}
146
147impl Default for PlotRenderConfig {
148    fn default() -> Self {
149        Self {
150            width: 800,
151            height: 600,
152            background_color: Vec4::new(0.08, 0.09, 0.11, 1.0), // Dark theme background
153            show_grid: true,
154            show_axes: true,
155            show_title: true,
156            msaa_samples: 4,
157            depth_mode: DepthMode::default(),
158            clip_policy: ClipPolicy::default(),
159            theme: crate::styling::PlotThemeConfig::default(),
160        }
161    }
162}
163
164/// Target surface information for rendering with optional MSAA resolve.
165pub struct RenderTarget<'a> {
166    pub view: &'a wgpu::TextureView,
167    pub resolve_target: Option<&'a wgpu::TextureView>,
168}
169
170/// Result of rendering operation
171#[derive(Debug)]
172pub struct RenderResult {
173    /// Whether rendering was successful
174    pub success: bool,
175
176    /// Rendered data bounds
177    pub data_bounds: Option<(f64, f64, f64, f64)>,
178
179    /// Performance metrics
180    pub vertex_count: usize,
181    pub triangle_count: usize,
182    pub render_time_ms: f64,
183}
184
185impl PlotRenderer {
186    /// Notify the renderer that the underlying surface configuration has changed (e.g. resize).
187    /// On wasm the canvas is often created at a tiny size and resized shortly after; some
188    /// CPU-generated geometry (like thick 2D lines) depends on viewport pixels, so we rebuild
189    /// the scene when the surface extent changes.
190    pub fn on_surface_config_updated(&mut self) {
191        let current = (
192            self.wgpu_renderer.surface_config.width.max(1),
193            self.wgpu_renderer.surface_config.height.max(1),
194        );
195        if self.last_scene_viewport_px == Some(current) {
196            return;
197        }
198        let Some(figure) = self.last_figure.clone() else {
199            self.last_scene_viewport_px = Some(current);
200            return;
201        };
202        // Rebuild scene using the updated surface extent.
203        self.set_figure(figure);
204    }
205
206    fn prepare_buffers_for_render_data(
207        &self,
208        node_id: u64,
209        render_data: &crate::core::RenderData,
210    ) -> Option<(Arc<wgpu::Buffer>, Option<Arc<wgpu::Buffer>>)> {
211        let mut cache = self.scene_buffer_cache.borrow_mut();
212        let vertex_signature = (
213            render_data.vertices.as_ptr() as usize,
214            render_data.vertices.len(),
215        );
216        let index_signature = render_data
217            .indices
218            .as_ref()
219            .map(|indices| (indices.as_ptr() as usize, indices.len()));
220
221        if let Some(cached) = cache.get(&node_id) {
222            if cached.vertex_signature == vertex_signature
223                && cached.index_signature == index_signature
224            {
225                return Some((cached.vertex_buffer.clone(), cached.index_buffer.clone()));
226            }
227        }
228
229        let vertex_buffer = self
230            .wgpu_renderer
231            .vertex_buffer_from_sources(render_data.gpu_vertices.as_ref(), &render_data.vertices)?;
232        let index_buffer = render_data
233            .indices
234            .as_ref()
235            .map(|indices| Arc::new(self.wgpu_renderer.create_index_buffer(indices)));
236
237        cache.insert(
238            node_id,
239            CachedSceneBuffers {
240                vertex_signature,
241                vertex_buffer: vertex_buffer.clone(),
242                index_signature,
243                index_buffer: index_buffer.clone(),
244            },
245        );
246
247        Some((vertex_buffer, index_buffer))
248    }
249
250    fn gpu_indirect_args(render_data: &crate::core::RenderData) -> Option<(&wgpu::Buffer, u64)> {
251        render_data
252            .gpu_vertices
253            .as_ref()
254            .and_then(|buf| buf.indirect.as_ref())
255            .map(|indirect| (indirect.args.as_ref(), indirect.offset))
256    }
257
258    /// Create a new plot renderer
259    pub async fn new(
260        device: Arc<wgpu::Device>,
261        queue: Arc<wgpu::Queue>,
262        surface_config: wgpu::SurfaceConfiguration,
263    ) -> Result<Self, Box<dyn std::error::Error>> {
264        let wgpu_renderer = WgpuRenderer::new(device, queue, surface_config).await;
265        let scene = Scene::new();
266        let theme = crate::styling::PlotThemeConfig::default();
267
268        Ok(Self {
269            wgpu_renderer,
270            scene,
271            theme,
272            data_bounds: None,
273            needs_update: true,
274            figure_title: None,
275            figure_sg_title: None,
276            figure_sg_title_style: TextStyle::default(),
277            figure_x_label: None,
278            figure_y_label: None,
279            figure_z_label: None,
280            figure_show_grid: true,
281            figure_show_legend: true,
282            figure_show_box: true,
283            figure_x_limits: None,
284            figure_y_limits: None,
285            legend_entries: Vec::new(),
286            figure_x_log: false,
287            figure_y_log: false,
288            figure_axis_equal: false,
289            figure_colormap: ColorMap::Parula,
290            figure_colorbar_enabled: false,
291            figure_categorical_is_x: None,
292            figure_categorical_labels: None,
293            axes_cameras: vec![Self::create_default_camera()],
294            last_figure: None,
295            last_scene_viewport_px: None,
296            last_axes_plot_sizes_px: None,
297            last_axes_view_bounds: None,
298            last_axes_view_contract: None,
299            camera_auto_fit: true,
300            axes_2d_camera_user_controlled: vec![false],
301            axes_applied_view_revisions: vec![None],
302            scene_buffer_cache: RefCell::new(HashMap::new()),
303        })
304    }
305
306    fn plot_element_is_3d(plot: &crate::plots::figure::PlotElement) -> bool {
307        match plot {
308            crate::plots::figure::PlotElement::Surface(surface) => !surface.image_mode,
309            crate::plots::figure::PlotElement::Patch(patch) => {
310                if patch.force_3d() {
311                    return true;
312                }
313
314                let mut max_xy = 0.0_f32;
315                let mut max_z = 0.0_f32;
316                for point in patch.vertices() {
317                    max_xy = max_xy.max(point.x.abs().max(point.y.abs()));
318                    max_z = max_z.max(point.z.abs());
319                }
320
321                max_z > PATCH_3D_ABS_EPSILON.max(max_xy * PATCH_3D_REL_EPSILON)
322            }
323            crate::plots::figure::PlotElement::Line3(_) => true,
324            crate::plots::figure::PlotElement::Scatter3(_) => true,
325            crate::plots::figure::PlotElement::Contour(contour) => contour.is_3d(),
326            _ => false,
327        }
328    }
329
330    pub fn axes_has_3d_content(&self, axes_index: usize) -> bool {
331        self.last_figure
332            .as_ref()
333            .map(|figure| {
334                figure
335                    .plots()
336                    .zip(figure.plot_axes_indices().iter().copied())
337                    .any(|(plot, plot_axes_index)| {
338                        plot_axes_index == axes_index && Self::plot_element_is_3d(plot)
339                    })
340            })
341            .unwrap_or(false)
342    }
343
344    fn axes_view_contract_for_figure(figure: &Figure) -> AxesViewContract {
345        let (rows, cols) = figure.axes_grid();
346        let axes_count = rows.max(1) * cols.max(1);
347        let mut has_3d_content = vec![false; axes_count];
348        for (plot, axes_index) in figure
349            .plots()
350            .zip(figure.plot_axes_indices().iter().copied())
351        {
352            if axes_index < axes_count && Self::plot_element_is_3d(plot) {
353                has_3d_content[axes_index] = true;
354            }
355        }
356        let axes = (0..axes_count)
357            .map(|axes_index| {
358                let meta = figure.axes_metadata(axes_index);
359                AxesViewContractEntry {
360                    has_3d_content: has_3d_content[axes_index],
361                    x_limits: meta.and_then(|m| m.x_limits),
362                    y_limits: meta.and_then(|m| m.y_limits),
363                    z_limits: meta.and_then(|m| m.z_limits),
364                    axis_equal: meta.map(|m| m.axis_equal).unwrap_or(false),
365                    x_log: meta.map(|m| m.x_log).unwrap_or(false),
366                    y_log: meta.map(|m| m.y_log).unwrap_or(false),
367                    view_azimuth_deg: meta.and_then(|m| m.view_azimuth_deg),
368                    view_elevation_deg: meta.and_then(|m| m.view_elevation_deg),
369                    view_revision: meta.map(|m| m.view_revision).unwrap_or(0),
370                }
371            })
372            .collect();
373        AxesViewContract { rows, cols, axes }
374    }
375
376    /// Mark that the user has interacted with the camera (disable auto-fit-on-update).
377    pub fn note_camera_interaction(&mut self) {
378        if self.camera_auto_fit {
379            log::debug!(target: "runmat_plot", "camera_auto_fit disabled (user interaction)");
380        }
381        self.camera_auto_fit = false;
382    }
383
384    pub fn note_axes_camera_interaction(&mut self, axes_index: usize) {
385        self.note_camera_interaction();
386        if self.axes_has_3d_content(axes_index) {
387            return;
388        }
389        if let Some(flag) = self.axes_2d_camera_user_controlled.get_mut(axes_index) {
390            *flag = true;
391        }
392    }
393
394    pub fn set_axes_camera_interaction_flags(&mut self, flags: &[bool]) {
395        self.axes_2d_camera_user_controlled
396            .resize(self.axes_cameras.len(), false);
397        let mut any_user_controlled = false;
398        for idx in 0..self.axes_cameras.len() {
399            let controlled = flags.get(idx).copied().unwrap_or(false);
400            if let Some(flag) = self.axes_2d_camera_user_controlled.get_mut(idx) {
401                *flag = controlled;
402            }
403            any_user_controlled |= controlled;
404        }
405        if any_user_controlled {
406            self.note_camera_interaction();
407        }
408    }
409
410    fn clear_axes_camera_interaction(&mut self, axes_index: usize) {
411        if let Some(flag) = self.axes_2d_camera_user_controlled.get_mut(axes_index) {
412            *flag = false;
413        }
414    }
415
416    fn clear_all_axes_camera_interaction(&mut self) {
417        for flag in &mut self.axes_2d_camera_user_controlled {
418            *flag = false;
419        }
420    }
421
422    /// Set the figure to render
423    pub fn set_figure(&mut self, figure: Figure) {
424        // Clear existing scene
425        self.scene.clear();
426        self.scene_buffer_cache.borrow_mut().clear();
427
428        // Convert figure to scene nodes
429        self.cache_figure_meta(&figure);
430        self.last_figure = Some(figure.clone());
431        self.last_axes_plot_sizes_px = None;
432        self.last_axes_view_bounds = None;
433        // Initialize axes cameras for subplot grid
434        let (rows, cols) = figure.axes_grid();
435        let num_axes = rows.max(1) * cols.max(1);
436        let axes_view_contract = Self::axes_view_contract_for_figure(&figure);
437        let axes_view_contract_changed =
438            self.last_axes_view_contract.as_ref() != Some(&axes_view_contract);
439        if axes_view_contract_changed {
440            log::debug!(
441                target: "runmat_plot.camera_refit",
442                "figure axes view contract changed; resetting script-owned camera fit rows={} cols={} axes_count={}",
443                rows,
444                cols,
445                num_axes
446            );
447            self.clear_all_axes_camera_interaction();
448            self.camera_auto_fit = true;
449        }
450        self.last_axes_view_contract = Some(axes_view_contract);
451
452        if self.axes_cameras.len() != num_axes {
453            self.axes_cameras
454                .resize_with(num_axes, Self::create_default_camera);
455            self.axes_2d_camera_user_controlled.resize(num_axes, false);
456            self.axes_applied_view_revisions.resize(num_axes, None);
457            self.camera_auto_fit = true;
458        }
459
460        for axes_index in 0..num_axes {
461            let wants_3d = self.axes_has_3d_content(axes_index);
462            let has_3d_camera = self
463                .axes_cameras
464                .get(axes_index)
465                .map(|cam| {
466                    matches!(
467                        cam.projection,
468                        crate::core::camera::ProjectionType::Perspective { .. }
469                    )
470                })
471                .unwrap_or(false);
472            if wants_3d != has_3d_camera {
473                self.axes_cameras[axes_index] = if wants_3d {
474                    Camera::new()
475                } else {
476                    Self::create_default_camera()
477                };
478                self.clear_axes_camera_interaction(axes_index);
479                if let Some(revision) = self.axes_applied_view_revisions.get_mut(axes_index) {
480                    *revision = None;
481                }
482                self.camera_auto_fit = true;
483            }
484        }
485
486        self.add_figure_to_scene(figure);
487
488        // Mark for update
489        self.needs_update = true;
490
491        // Recompute bounds and fit camera immediately (only once per initial dataset).
492        let fit_applied = if self.camera_auto_fit {
493            if num_axes > 1 {
494                self.fit_cameras_to_axes_data()
495            } else {
496                self.fit_camera_to_data()
497            }
498        } else {
499            false
500        };
501        if self.camera_auto_fit && fit_applied {
502            // Freeze the initial fit (CAD-like): don't re-fit as data updates (e.g. animations)
503            // unless the user explicitly asks (Fit Extents / Reset View) or we change plot mode.
504            self.camera_auto_fit = false;
505        }
506        self.apply_stored_axes_views();
507    }
508
509    /// Add a figure to the current scene
510    fn add_figure_to_scene(&mut self, figure: Figure) {
511        self.add_figure_to_scene_with_axes_plot_sizes(figure, None);
512    }
513
514    fn add_figure_to_scene_with_axes_plot_sizes(
515        &mut self,
516        mut figure: Figure,
517        axes_plot_sizes_px: Option<&[(u32, u32)]>,
518    ) {
519        use crate::core::SceneNode;
520
521        let (rows, cols) = figure.axes_grid();
522
523        // Convert figure to render data first, then create scene nodes.
524        // For subplot figures, avoid baking full-surface line stroke geometry before overlay
525        // layout has provided per-axes plot viewports.
526        let viewport_px = (
527            self.wgpu_renderer.surface_config.width.max(1),
528            self.wgpu_renderer.surface_config.height.max(1),
529        );
530        self.last_scene_viewport_px = Some(viewport_px);
531        let gpu = crate::core::GpuPackContext {
532            device: &self.wgpu_renderer.device,
533            queue: &self.wgpu_renderer.queue,
534        };
535        let view_bounds = self.axes_view_bounds_for_count(rows.max(1) * cols.max(1));
536        let viewport_hint = if axes_plot_sizes_px.is_some() || rows.max(1) * cols.max(1) <= 1 {
537            Some(viewport_px)
538        } else {
539            None
540        };
541        let render_data_list = figure.render_data_with_axes_with_viewport_and_gpu(
542            viewport_hint,
543            axes_plot_sizes_px,
544            Some(&view_bounds),
545            Some(&gpu),
546        );
547
548        for (node_id_counter, (axes_index, render_data)) in render_data_list.into_iter().enumerate()
549        {
550            let axes_index = axes_index.min(rows * cols - 1);
551            // Create scene node for this plot element
552            let node = SceneNode {
553                id: node_id_counter as u64,
554                name: format!("Plot {node_id_counter} @axes {axes_index}"),
555                transform: Mat4::IDENTITY,
556                visible: true,
557                cast_shadows: false,
558                receive_shadows: false,
559                axes_index,
560                parent: None,
561                children: Vec::new(),
562                render_data: Some(render_data),
563                bounds: crate::core::BoundingBox::default(),
564                lod_levels: Vec::new(),
565                current_lod: 0,
566            };
567
568            let nid = self.scene.add_node(node);
569            // Tag node with axes index via a no-op mechanism for now (could extend SceneNode in future)
570            let _ = nid;
571            let _ = axes_index;
572            let _ = rows;
573            let _ = cols;
574        }
575    }
576
577    pub fn ensure_scene_viewport_dependent_geometry_for_axes(
578        &mut self,
579        axes_plot_sizes_px: &[(u32, u32)],
580    ) {
581        let normalized: Vec<(u32, u32)> = axes_plot_sizes_px
582            .iter()
583            .map(|&(w, h)| (w.max(1), h.max(1)))
584            .collect();
585        if normalized.iter().any(|&(w, h)| w < 2 || h < 2) {
586            log::debug!(
587                target: "runmat_plot.viewport_rebuild",
588                "skipped viewport-dependent scene geometry rebuild for unstable viewport_sizes={:?}",
589                normalized
590            );
591            return;
592        }
593        let view_bounds = self.axes_view_bounds_for_count(normalized.len().max(1));
594        if self.last_axes_plot_sizes_px.as_ref() == Some(&normalized)
595            && self.last_axes_view_bounds.as_ref() == Some(&view_bounds)
596        {
597            return;
598        }
599        let Some(figure) = self.last_figure.clone() else {
600            self.last_axes_plot_sizes_px = Some(normalized);
601            self.last_axes_view_bounds = Some(view_bounds);
602            return;
603        };
604        self.scene.clear();
605        self.scene_buffer_cache.borrow_mut().clear();
606        self.add_figure_to_scene_with_axes_plot_sizes(figure, Some(&normalized));
607        log::debug!(
608            target: "runmat_plot.viewport_rebuild",
609            "rebuilt viewport-dependent scene geometry axes_count={} viewport_sizes={:?}",
610            normalized.len(),
611            normalized
612        );
613        self.refit_2d_cameras_to_scene_bounds();
614        self.last_axes_plot_sizes_px = Some(normalized);
615        self.last_axes_view_bounds = Some(view_bounds);
616        self.needs_update = true;
617    }
618
619    fn axes_view_bounds_for_count(&self, axes_count: usize) -> PerAxesViewBounds {
620        (0..axes_count)
621            .map(|idx| self.view_bounds_for_axes(idx))
622            .collect()
623    }
624
625    fn refit_2d_cameras_to_scene_bounds(&mut self) {
626        for idx in 0..self.axes_cameras.len() {
627            if self.axes_has_3d_content(idx) {
628                continue;
629            }
630            if self
631                .axes_2d_camera_user_controlled
632                .get(idx)
633                .copied()
634                .unwrap_or(false)
635            {
636                continue;
637            }
638            let Some((x_min, x_max, y_min, y_max)) = self.display_bounds_for_axes(idx) else {
639                continue;
640            };
641            let geometry_bounds = self.axes_bounds(idx);
642            let Some(cam) = self.axes_cameras.get_mut(idx) else {
643                continue;
644            };
645            if let crate::core::camera::ProjectionType::Orthographic {
646                ref mut left,
647                ref mut right,
648                ref mut bottom,
649                ref mut top,
650                ..
651            } = cam.projection
652            {
653                *left = x_min as f32;
654                *right = x_max as f32;
655                *bottom = y_min as f32;
656                *top = y_max as f32;
657                let camera_left = *left;
658                let camera_right = *right;
659                let camera_bottom = *bottom;
660                let camera_top = *top;
661                cam.position.z = 1.0;
662                cam.target.z = 0.0;
663                cam.mark_dirty();
664                if let Some(bounds) = geometry_bounds {
665                    log::debug!(
666                        target: "runmat_plot.camera_refit",
667                        "refit 2d camera to rebuilt scene bounds axes_index={} geometry=({}, {})..({}, {}) camera=({}, {})..({}, {}) margins=top:{} bottom:{} left:{} right:{}",
668                        idx,
669                        bounds.min.x,
670                        bounds.min.y,
671                        bounds.max.x,
672                        bounds.max.y,
673                        camera_left,
674                        camera_bottom,
675                        camera_right,
676                        camera_top,
677                        camera_top - bounds.max.y,
678                        bounds.min.y - camera_bottom,
679                        bounds.min.x - camera_left,
680                        camera_right - bounds.max.x
681                    );
682                } else {
683                    log::debug!(
684                        target: "runmat_plot.camera_refit",
685                        "refit 2d camera without geometry bounds axes_index={} camera=({}, {})..({}, {})",
686                        idx,
687                        camera_left,
688                        camera_bottom,
689                        camera_right,
690                        camera_top
691                    );
692                }
693                if let Some(display_bounds) = self.display_bounds_for_axes(idx) {
694                    log::debug!(
695                        target: "runmat_plot.bounds_chain",
696                        "bounds chain axes_index={} axes_bounds=({}, {})..({}, {}) display_bounds=({}, {})..({}, {}) camera_bounds=({}, {})..({}, {})",
697                        idx,
698                        geometry_bounds.map(|b| b.min.x as f64).unwrap_or(f64::NAN),
699                        geometry_bounds.map(|b| b.min.y as f64).unwrap_or(f64::NAN),
700                        geometry_bounds.map(|b| b.max.x as f64).unwrap_or(f64::NAN),
701                        geometry_bounds.map(|b| b.max.y as f64).unwrap_or(f64::NAN),
702                        display_bounds.0,
703                        display_bounds.2,
704                        display_bounds.1,
705                        display_bounds.3,
706                        camera_left,
707                        camera_bottom,
708                        camera_right,
709                        camera_top
710                    );
711                }
712            }
713        }
714    }
715
716    /// Cache figure metadata for overlay consumption
717    fn cache_figure_meta(&mut self, figure: &Figure) {
718        self.figure_title = figure.title.clone();
719        self.figure_sg_title = figure.sg_title.clone();
720        self.figure_sg_title_style = figure.sg_title_style.clone();
721        self.figure_x_label = figure.x_label.clone();
722        self.figure_y_label = figure.y_label.clone();
723        self.figure_z_label = figure.z_label.clone();
724        self.figure_show_grid = figure.grid_enabled;
725        self.figure_show_legend = figure.legend_enabled;
726        self.figure_show_box = figure.box_enabled;
727        self.figure_x_limits = figure.x_limits;
728        self.figure_y_limits = figure.y_limits;
729        self.legend_entries = figure.legend_entries();
730        self.figure_x_log = figure.x_log;
731        self.figure_y_log = figure.y_log;
732        self.figure_axis_equal = figure.axis_equal;
733        self.figure_colormap = figure.colormap;
734        self.figure_colorbar_enabled = figure.colorbar_enabled;
735        // Cache categorical labels for overlay
736        if let Some((is_x, labels)) = figure.categorical_axis_labels() {
737            self.figure_categorical_is_x = Some(is_x);
738            self.figure_categorical_labels = Some(labels);
739        } else {
740            self.figure_categorical_is_x = None;
741            self.figure_categorical_labels = None;
742        }
743    }
744
745    fn apply_stored_axes_views(&mut self) {
746        let Some(fig) = self.last_figure.as_ref() else {
747            return;
748        };
749        for (idx, cam) in self.axes_cameras.iter_mut().enumerate() {
750            if !matches!(
751                cam.projection,
752                crate::core::camera::ProjectionType::Perspective { .. }
753            ) {
754                continue;
755            }
756            if let Some(meta) = fig.axes_metadata(idx) {
757                if let (Some(az), Some(el)) = (meta.view_azimuth_deg, meta.view_elevation_deg) {
758                    if self.axes_applied_view_revisions.get(idx).copied().flatten()
759                        == Some(meta.view_revision)
760                    {
761                        continue;
762                    }
763                    cam.set_view_angles_deg(az, el);
764                    if let Some(revision) = self.axes_applied_view_revisions.get_mut(idx) {
765                        *revision = Some(meta.view_revision);
766                    }
767                } else if let Some(revision) = self.axes_applied_view_revisions.get_mut(idx) {
768                    *revision = None;
769                }
770            }
771        }
772    }
773
774    fn display_bounds_for_axes(&self, axes_index: usize) -> Option<(f64, f64, f64, f64)> {
775        let base = self.axes_bounds(axes_index)?;
776        let mut x_min = base.min.x as f64;
777        let mut x_max = base.max.x as f64;
778        let mut y_min = base.min.y as f64;
779        let mut y_max = base.max.y as f64;
780
781        if let Some(fig) = self.last_figure.as_ref() {
782            if let Some(meta) = fig.axes_metadata(axes_index) {
783                if let Some((xl, xr)) = meta.x_limits {
784                    x_min = xl;
785                    x_max = xr;
786                }
787                if let Some((yl, yr)) = meta.y_limits {
788                    y_min = yl;
789                    y_max = yr;
790                }
791                if meta.axis_equal {
792                    let cx = (x_min + x_max) * 0.5;
793                    let cy = (y_min + y_max) * 0.5;
794                    let size = (x_max - x_min).abs().max((y_max - y_min).abs()).max(0.1);
795                    x_min = cx - size * 0.5;
796                    x_max = cx + size * 0.5;
797                    y_min = cy - size * 0.5;
798                    y_max = cy + size * 0.5;
799                }
800            }
801        }
802
803        Some((x_min, x_max, y_min, y_max))
804    }
805
806    fn apply_3d_display_limits_to_bounds(
807        bounds: BoundingBox,
808        figure: Option<&Figure>,
809        axes_index: usize,
810    ) -> BoundingBox {
811        let Some(meta) = figure.and_then(|fig| fig.axes_metadata(axes_index)) else {
812            return bounds;
813        };
814        let mut min = bounds.min;
815        let mut max = bounds.max;
816        if let Some((lo, hi)) = meta.x_limits {
817            min.x = lo as f32;
818            max.x = hi as f32;
819        }
820        if let Some((lo, hi)) = meta.y_limits {
821            min.y = lo as f32;
822            max.y = hi as f32;
823        }
824        if let Some((lo, hi)) = meta.z_limits {
825            min.z = lo as f32;
826            max.z = hi as f32;
827        }
828        BoundingBox { min, max }
829    }
830
831    fn bounds_are_finite(bounds: BoundingBox) -> bool {
832        bounds.min.x.is_finite()
833            && bounds.min.y.is_finite()
834            && bounds.min.z.is_finite()
835            && bounds.max.x.is_finite()
836            && bounds.max.y.is_finite()
837            && bounds.max.z.is_finite()
838    }
839
840    fn current_3d_display_bounds_for_axes(&self, axes_index: usize) -> Option<BoundingBox> {
841        let bounds = self.axes_bounds(axes_index)?;
842        let bounds =
843            Self::apply_3d_display_limits_to_bounds(bounds, self.last_figure.as_ref(), axes_index);
844        Self::bounds_are_finite(bounds).then_some(bounds)
845    }
846
847    fn display_bounds_3d_for_axes(&self, axes_index: usize) -> Option<BoundingBox> {
848        self.current_3d_display_bounds_for_axes(axes_index)
849    }
850
851    fn axes_model_matrix(&self, _axes_index: usize) -> Mat4 {
852        Mat4::IDENTITY
853    }
854
855    fn fit_cameras_to_axes_data(&mut self) -> bool {
856        let mut applied = false;
857        for idx in 0..self.axes_cameras.len() {
858            if self.axes_has_3d_content(idx) {
859                let Some(bounds) = self.display_bounds_3d_for_axes(idx) else {
860                    continue;
861                };
862                let center = (bounds.min + bounds.max) * 0.5;
863                let mut cam = Camera::new();
864                cam.target = center;
865                cam.up = Vec3::Z;
866                cam.position = center + Vec3::new(1.0, -1.0, 1.0);
867                cam.fit_bounds(bounds.min, bounds.max);
868                self.axes_cameras[idx] = cam;
869                applied = true;
870                continue;
871            }
872
873            let Some((x_min, x_max, y_min, y_max)) = self.display_bounds_for_axes(idx) else {
874                continue;
875            };
876            let mut cam = Self::create_default_camera();
877            if let crate::core::camera::ProjectionType::Orthographic {
878                ref mut left,
879                ref mut right,
880                ref mut bottom,
881                ref mut top,
882                ..
883            } = cam.projection
884            {
885                *left = x_min as f32;
886                *right = x_max as f32;
887                *bottom = y_min as f32;
888                *top = y_max as f32;
889            }
890            cam.position.z = 1.0;
891            cam.target.z = 0.0;
892            cam.mark_dirty();
893            self.axes_cameras[idx] = cam;
894            applied = true;
895        }
896        applied
897    }
898
899    /// Calculate data bounds from scene
900    pub fn calculate_data_bounds(&mut self) -> Option<(f64, f64, f64, f64)> {
901        let mut min_x = f64::INFINITY;
902        let mut max_x = f64::NEG_INFINITY;
903        let mut min_y = f64::INFINITY;
904        let mut max_y = f64::NEG_INFINITY;
905
906        for node in self.scene.get_visible_nodes() {
907            if let Some(render_data) = &node.render_data {
908                if let Some(bounds) = render_data.bounds {
909                    min_x = min_x.min(bounds.min.x as f64);
910                    max_x = max_x.max(bounds.max.x as f64);
911                    min_y = min_y.min(bounds.min.y as f64);
912                    max_y = max_y.max(bounds.max.y as f64);
913                    continue;
914                }
915                for vertex in &render_data.vertices {
916                    let x = vertex.position[0] as f64;
917                    let y = vertex.position[1] as f64;
918                    min_x = min_x.min(x);
919                    max_x = max_x.max(x);
920                    min_y = min_y.min(y);
921                    max_y = max_y.max(y);
922                }
923            }
924        }
925
926        if min_x != f64::INFINITY && max_x != f64::NEG_INFINITY {
927            // Add a small margin around data for readability without making
928            // the plotted curve appear to exceed the labeled axis range.
929            let x_range = (max_x - min_x).max(0.1);
930            let y_range = (max_y - min_y).max(0.1);
931            let x_margin = x_range * 0.04;
932            let y_margin = y_range * 0.04;
933
934            let bounds = (
935                min_x - x_margin,
936                max_x + x_margin,
937                min_y - y_margin,
938                max_y + y_margin,
939            );
940
941            // println!("Calculated data bounds: {:?}", bounds); // Too noisy
942            self.data_bounds = Some(bounds);
943            Some(bounds)
944        } else {
945            self.data_bounds = None;
946            None
947        }
948    }
949
950    /// Fit camera to show all data.
951    ///
952    /// Returns `true` if a fit was applied (i.e. bounds existed).
953    pub fn fit_camera_to_data(&mut self) -> bool {
954        if self.axes_cameras.len() > 1 {
955            return self.fit_cameras_to_axes_data();
956        }
957
958        if self.axes_has_3d_content(0) {
959            let Some(bounds) = self.display_bounds_3d_for_axes(0) else {
960                return false;
961            };
962            let center = (bounds.min + bounds.max) * 0.5;
963            let mut cam = Camera::new();
964            cam.target = center;
965            cam.up = Vec3::Z;
966            cam.position = center + Vec3::new(1.0, -1.0, 1.0);
967            cam.fit_bounds(bounds.min, bounds.max);
968            if let Some(axis_cam) = self.axes_cameras.get_mut(0) {
969                *axis_cam = cam;
970            }
971            return true;
972        }
973
974        if let Some((x_min, x_max, y_min, y_max)) = self.display_bounds_for_axes(0) {
975            // Match the camera bounds exactly to data bounds to align with overlay grid
976            let mut cam = Self::create_default_camera();
977            let l = x_min as f32;
978            let r = x_max as f32;
979            let b = y_min as f32;
980            let t = y_max as f32;
981            if let crate::core::camera::ProjectionType::Orthographic {
982                ref mut left,
983                ref mut right,
984                ref mut bottom,
985                ref mut top,
986                ..
987            } = cam.projection
988            {
989                *left = l;
990                *right = r;
991                *bottom = b;
992                *top = t;
993            }
994            cam.position.z = 1.0;
995            cam.target.z = 0.0;
996            cam.mark_dirty();
997
998            if let Some(axis_cam) = self.axes_cameras.get_mut(0) {
999                *axis_cam = cam;
1000            }
1001            return true;
1002        }
1003        false
1004    }
1005
1006    /// Explicit "Fit Extents" action (CAD-like). Fits the camera to current data once.
1007    pub fn fit_extents(&mut self) {
1008        let _ = if self.figure_axes_grid().0 * self.figure_axes_grid().1 > 1 {
1009            self.fit_cameras_to_axes_data()
1010        } else {
1011            self.fit_camera_to_data()
1012        };
1013        self.clear_all_axes_camera_interaction();
1014        self.camera_auto_fit = false;
1015        self.needs_update = true;
1016    }
1017
1018    /// Explicit "Reset Camera" action. Restores the default orientation without re-framing.
1019    ///
1020    /// For 3D, this resets the view direction around the current data center (or current target)
1021    /// while preserving the current zoom distance.
1022    /// For 2D, this is equivalent to Fit Extents (since "home" without data bounds is rarely useful).
1023    pub fn reset_camera_position(&mut self) {
1024        let dir = Vec3::new(1.0, -1.0, 1.0).normalize_or_zero();
1025        let data_centers: Vec<Vec3> = (0..self.axes_cameras.len())
1026            .map(|idx| {
1027                if self.axes_has_3d_content(idx) {
1028                    self.display_bounds_3d_for_axes(idx)
1029                } else {
1030                    self.axes_bounds(idx)
1031                }
1032                .map(|b| (b.min + b.max) * 0.5)
1033                .unwrap_or_else(|| self.axes_cameras[idx].target)
1034            })
1035            .collect();
1036        let display_bounds: PerAxesViewBounds = (0..self.axes_cameras.len())
1037            .map(|idx| self.display_bounds_for_axes(idx))
1038            .collect();
1039        for (idx, c) in self.axes_cameras.iter_mut().enumerate() {
1040            if matches!(
1041                c.projection,
1042                crate::core::camera::ProjectionType::Perspective { .. }
1043            ) {
1044                let data_center = data_centers.get(idx).copied().unwrap_or(c.target);
1045                let dist = (c.position - c.target).length().max(0.1);
1046                c.target = data_center;
1047                c.up = Vec3::Z;
1048                c.position = data_center + dir * dist;
1049                c.mark_dirty();
1050            } else if let Some((x_min, x_max, y_min, y_max)) = display_bounds[idx] {
1051                let mut cam = Self::create_default_camera();
1052                if let crate::core::camera::ProjectionType::Orthographic {
1053                    ref mut left,
1054                    ref mut right,
1055                    ref mut bottom,
1056                    ref mut top,
1057                    ..
1058                } = cam.projection
1059                {
1060                    *left = x_min as f32;
1061                    *right = x_max as f32;
1062                    *bottom = y_min as f32;
1063                    *top = y_max as f32;
1064                }
1065                cam.position.z = 1.0;
1066                cam.target.z = 0.0;
1067                cam.mark_dirty();
1068                *c = cam;
1069            }
1070        }
1071        self.clear_all_axes_camera_interaction();
1072        self.camera_auto_fit = false;
1073        self.needs_update = true;
1074    }
1075
1076    /// Render the current scene to a specific viewport within a texture/surface
1077    pub fn render_to_viewport(
1078        &mut self,
1079        encoder: &mut wgpu::CommandEncoder,
1080        target_view: &wgpu::TextureView,
1081        _viewport: (f32, f32, f32, f32), // (x, y, width, height) in framebuffer coordinates
1082        clear_background: bool,
1083        background_color: Option<glam::Vec4>,
1084    ) -> Result<RenderResult, Box<dyn std::error::Error>> {
1085        let start_time = Instant::now();
1086
1087        // Collect render data and create buffers first
1088        let mut render_items = Vec::new();
1089        let mut total_vertices = 0;
1090        let mut total_triangles = 0;
1091
1092        for node in self.scene.get_visible_nodes() {
1093            if let Some(render_data) = &node.render_data {
1094                if let Some(vertex_buffer) = self.wgpu_renderer.vertex_buffer_from_sources(
1095                    render_data.gpu_vertices.as_ref(),
1096                    &render_data.vertices,
1097                ) {
1098                    self.wgpu_renderer
1099                        .ensure_pipeline(render_data.pipeline_type);
1100
1101                    log::trace!(
1102                        target: "runmat_plot",
1103                        "upload vertices={}, draw_calls={}",
1104                        render_data.vertex_count(),
1105                        render_data.draw_calls.len()
1106                    );
1107
1108                    render_items.push((render_data, vertex_buffer));
1109                    total_vertices += render_data.vertex_count();
1110
1111                    if render_data.pipeline_type == crate::core::PipelineType::Triangles {
1112                        total_triangles += render_data.vertex_count() / 3;
1113                    }
1114                }
1115            }
1116        }
1117
1118        // Update uniforms
1119        let mut cam = self.camera().clone();
1120        let view_proj_matrix = cam.view_proj_matrix();
1121
1122        self.wgpu_renderer
1123            .update_uniforms(view_proj_matrix, Mat4::IDENTITY);
1124
1125        // Create render pass (respect MSAA)
1126        let use_msaa = self.wgpu_renderer.msaa_sample_count > 1;
1127        let msaa_view_opt = if use_msaa {
1128            let tex = self
1129                .wgpu_renderer
1130                .device
1131                .create_texture(&wgpu::TextureDescriptor {
1132                    label: Some("runmat_msaa_color_camera"),
1133                    size: wgpu::Extent3d {
1134                        width: self.wgpu_renderer.surface_config.width,
1135                        height: self.wgpu_renderer.surface_config.height,
1136                        depth_or_array_layers: 1,
1137                    },
1138                    mip_level_count: 1,
1139                    sample_count: self.wgpu_renderer.msaa_sample_count,
1140                    dimension: wgpu::TextureDimension::D2,
1141                    format: self.wgpu_renderer.surface_config.format,
1142                    usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1143                    view_formats: &[],
1144                });
1145            Some(tex.create_view(&wgpu::TextureViewDescriptor::default()))
1146        } else {
1147            None
1148        };
1149
1150        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1151            label: Some("Viewport Plot Render Pass"),
1152            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1153                view: msaa_view_opt.as_ref().unwrap_or(target_view),
1154                resolve_target: if use_msaa { Some(target_view) } else { None },
1155                ops: wgpu::Operations {
1156                    load: if clear_background {
1157                        wgpu::LoadOp::Clear(wgpu::Color {
1158                            r: background_color.map_or(0.08, |c| c.x as f64),
1159                            g: background_color.map_or(0.09, |c| c.y as f64),
1160                            b: background_color.map_or(0.11, |c| c.z as f64),
1161                            a: background_color.map_or(1.0, |c| c.w as f64),
1162                        })
1163                    } else {
1164                        wgpu::LoadOp::Load
1165                    },
1166                    store: wgpu::StoreOp::Store,
1167                },
1168            })],
1169            depth_stencil_attachment: None,
1170            occlusion_query_set: None,
1171            timestamp_writes: None,
1172        });
1173
1174        // Apply viewport scissor to match overlay plot rect
1175        let (vx, vy, vw, vh) = _viewport;
1176        render_pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
1177
1178        // Configure direct-uniforms for precise data-to-NDC mapping within this viewport
1179        let sw = self.wgpu_renderer.surface_config.width as f32;
1180        let sh = self.wgpu_renderer.surface_config.height as f32;
1181        let ndc_left = (vx / sw) * 2.0 - 1.0;
1182        let ndc_right = ((vx + vw) / sw) * 2.0 - 1.0;
1183        let ndc_top = 1.0 - (vy / sh) * 2.0;
1184        let ndc_bottom = 1.0 - ((vy + vh) / sh) * 2.0;
1185
1186        // data_bounds passed in from caller: (x_min, y_min, x_max, y_max)
1187        let (x_min, y_min, x_max, y_max) = (0.0_f64, 0.0_f64, 1.0_f64, 1.0_f64);
1188        self.wgpu_renderer.update_direct_uniforms(
1189            [x_min as f32, y_min as f32],
1190            [x_max as f32, y_max as f32],
1191            [ndc_left, ndc_bottom],
1192            [ndc_right, ndc_top],
1193            [sw, sh],
1194        );
1195
1196        // Continue with specific pipelines below (implementation omitted here)
1197        drop(render_pass);
1198
1199        let render_time = start_time.elapsed().as_secs_f64() * 1000.0;
1200
1201        Ok(RenderResult {
1202            success: true,
1203            data_bounds: self.data_bounds,
1204            vertex_count: total_vertices,
1205            triangle_count: total_triangles,
1206            render_time_ms: render_time,
1207        })
1208    }
1209
1210    /// Render the current scene to a texture/surface
1211    pub fn render(
1212        &mut self,
1213        encoder: &mut wgpu::CommandEncoder,
1214        target: RenderTarget<'_>,
1215        config: &PlotRenderConfig,
1216    ) -> Result<RenderResult, Box<dyn std::error::Error>> {
1217        let start_time = Instant::now();
1218
1219        self.wgpu_renderer.ensure_msaa(config.msaa_samples);
1220
1221        // Update WGPU uniforms from primary axes camera
1222        let aspect_ratio = config.width as f32 / config.height as f32;
1223        let mut cam = self.camera().clone();
1224        cam.update_aspect_ratio(aspect_ratio);
1225        let view_proj_matrix = cam.view_proj_matrix();
1226        let model_matrix = self.axes_model_matrix(0);
1227        self.wgpu_renderer
1228            .update_uniforms(view_proj_matrix, model_matrix);
1229
1230        // Collect all render data and create vertex buffers first (outside render pass)
1231        let mut render_items = Vec::new();
1232        let mut total_vertices = 0;
1233        let mut total_triangles = 0;
1234
1235        for node in self.scene.get_visible_nodes() {
1236            if let Some(render_data) = &node.render_data {
1237                if let Some((vertex_buffer, index_buffer)) =
1238                    self.prepare_buffers_for_render_data(node.id, render_data)
1239                {
1240                    self.wgpu_renderer
1241                        .ensure_pipeline(render_data.pipeline_type);
1242                    render_items.push((render_data, vertex_buffer, index_buffer));
1243
1244                    total_vertices += render_data.vertex_count();
1245                    if let Some(indices) = &render_data.indices {
1246                        total_triangles += indices.len() / 3;
1247                    }
1248                }
1249            }
1250        }
1251
1252        // Pre-create image bind groups and set direct uniforms once (for textured items)
1253        let mut image_bind_groups: Vec<Option<wgpu::BindGroup>> =
1254            Vec::with_capacity(render_items.len());
1255        let has_textured_items = render_items.iter().any(|(render_data, _, _)| {
1256            render_data.pipeline_type == crate::core::PipelineType::Textured
1257        });
1258        if has_textured_items {
1259            // Ensure image pipeline once to avoid mutable borrow during pass.
1260            self.wgpu_renderer.ensure_image_pipeline();
1261            let mut inferred_bounds: Option<(f64, f64, f64, f64)> = None;
1262            for (render_data, _, _) in &render_items {
1263                let Some(bounds) = render_data.bounds.as_ref() else {
1264                    continue;
1265                };
1266                let min_x = bounds.min.x as f64;
1267                let max_x = bounds.max.x as f64;
1268                let min_y = bounds.min.y as f64;
1269                let max_y = bounds.max.y as f64;
1270                inferred_bounds = Some(match inferred_bounds {
1271                    Some((x0, x1, y0, y1)) => {
1272                        (x0.min(min_x), x1.max(max_x), y0.min(min_y), y1.max(max_y))
1273                    }
1274                    None => (min_x, max_x, min_y, max_y),
1275                });
1276            }
1277
1278            let (mut x_min, mut x_max, mut y_min, mut y_max) = self
1279                .data_bounds
1280                .or(inferred_bounds)
1281                .unwrap_or((-1.0, 1.0, -1.0, 1.0));
1282            // Avoid zero ranges in the direct image shader (division by data_range).
1283            if (x_max - x_min).abs() < f64::EPSILON {
1284                x_min -= 0.5;
1285                x_max += 0.5;
1286            }
1287            if (y_max - y_min).abs() < f64::EPSILON {
1288                y_min -= 0.5;
1289                y_max += 0.5;
1290            }
1291            log::trace!(
1292                target: "runmat_plot",
1293                "direct uniforms bounds x=({}, {}) y=({}, {}) size=({}, {})",
1294                x_min,
1295                x_max,
1296                y_min,
1297                y_max,
1298                config.width,
1299                config.height
1300            );
1301            self.wgpu_renderer.update_direct_uniforms(
1302                [x_min as f32, y_min as f32],
1303                [x_max as f32, y_max as f32],
1304                [-1.0, -1.0],
1305                [1.0, 1.0],
1306                [config.width as f32, config.height as f32],
1307            );
1308        }
1309        for (render_data, _vb, _ib) in &render_items {
1310            if render_data.pipeline_type == crate::core::PipelineType::Textured {
1311                if let Some(crate::core::scene::ImageData::Rgba8 {
1312                    width,
1313                    height,
1314                    data,
1315                }) = &render_data.image
1316                {
1317                    let (_tex, _view, img_bg) = self
1318                        .wgpu_renderer
1319                        .create_image_texture_and_bind_group(*width, *height, data);
1320                    image_bind_groups.push(Some(img_bg));
1321                } else {
1322                    image_bind_groups.push(None);
1323                }
1324            } else {
1325                image_bind_groups.push(None);
1326            }
1327        }
1328        let mut point_style_bind_groups: Vec<Option<wgpu::BindGroup>> =
1329            Vec::with_capacity(render_items.len());
1330        for (render_data, _vb, _ib) in &render_items {
1331            if matches!(
1332                render_data.pipeline_type,
1333                crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
1334            ) {
1335                let style = crate::core::renderer::PointStyleUniforms {
1336                    face_color: render_data.material.albedo.to_array(),
1337                    edge_color: render_data.material.emissive.to_array(),
1338                    edge_thickness_px: render_data.material.roughness,
1339                    marker_shape: render_data.material.metallic as u32,
1340                    _pad: [0.0, 0.0],
1341                };
1342                let (_buf, bg) = self.wgpu_renderer.create_point_style_bind_group(style);
1343                point_style_bind_groups.push(Some(bg));
1344            } else {
1345                point_style_bind_groups.push(None);
1346            }
1347        }
1348        // Expand CPU marker vertices to billboard quads.
1349        let mut point_buffers: Vec<Option<(wgpu::Buffer, usize)>> =
1350            Vec::with_capacity(render_items.len());
1351        for (render_data, _vb, _ib) in &render_items {
1352            if matches!(
1353                render_data.pipeline_type,
1354                crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
1355            ) && !render_data.vertices.is_empty()
1356            {
1357                let expanded = self
1358                    .wgpu_renderer
1359                    .create_direct_point_vertices(&render_data.vertices, 0.0);
1360                let buffer = self.wgpu_renderer.create_vertex_buffer(&expanded);
1361                point_buffers.push(Some((buffer, expanded.len())));
1362            } else {
1363                point_buffers.push(None);
1364            }
1365        }
1366        self.wgpu_renderer.update_marker_screen_uniforms([
1367            config.width.max(1) as f32,
1368            config.height.max(1) as f32,
1369        ]);
1370
1371        // Create render pass
1372        {
1373            let depth_view = self.wgpu_renderer.ensure_depth_view();
1374            let use_msaa = self.wgpu_renderer.msaa_sample_count > 1;
1375            let mut cached_msaa_view: Option<Arc<wgpu::TextureView>> = None;
1376
1377            let (color_view, resolve_target) = if use_msaa {
1378                if let Some(explicit_resolve_target) = target.resolve_target {
1379                    (target.view, Some(explicit_resolve_target))
1380                } else {
1381                    cached_msaa_view = Some(self.wgpu_renderer.ensure_msaa_color_view());
1382                    (
1383                        cached_msaa_view
1384                            .as_ref()
1385                            .expect("msaa color view should exist")
1386                            .as_ref(),
1387                        Some(target.view),
1388                    )
1389                }
1390            } else {
1391                (target.view, target.resolve_target)
1392            };
1393
1394            let depth_clear = match self.wgpu_renderer.depth_mode {
1395                crate::core::DepthMode::Standard => 1.0,
1396                crate::core::DepthMode::ReversedZ => 0.0,
1397            };
1398            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1399                label: Some("Plot Render Pass"),
1400                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1401                    view: color_view,
1402                    resolve_target,
1403                    ops: wgpu::Operations {
1404                        load: wgpu::LoadOp::Clear(wgpu::Color {
1405                            r: config.background_color.x as f64,
1406                            g: config.background_color.y as f64,
1407                            b: config.background_color.z as f64,
1408                            a: config.background_color.w as f64,
1409                        }),
1410                        store: wgpu::StoreOp::Store,
1411                    },
1412                })],
1413                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1414                    view: &depth_view,
1415                    depth_ops: Some(wgpu::Operations {
1416                        load: wgpu::LoadOp::Clear(depth_clear),
1417                        store: wgpu::StoreOp::Discard,
1418                    }),
1419                    stencil_ops: None,
1420                }),
1421                occlusion_query_set: None,
1422                timestamp_writes: None,
1423            });
1424            let _keep_msaa_view_alive = &cached_msaa_view;
1425
1426            // Now render all items with proper bind group setup
1427            for (i, (render_data, vertex_buffer, index_buffer)) in render_items.iter().enumerate() {
1428                #[cfg(target_arch = "wasm32")]
1429                {
1430                    // On wasm, "blank but drawing" is often caused by bad vertex data (NaNs/alpha=0)
1431                    // or using the wrong pipeline. Emit a single summary per item.
1432                    if log::log_enabled!(log::Level::Debug) {
1433                        if let Some(v0) = render_data.vertices.first() {
1434                            log::debug!(
1435                                target: "runmat_plot",
1436                                "wasm draw item: pipeline={:?} verts={} v0.pos=({:.3},{:.3},{:.3}) v0.color=({:.3},{:.3},{:.3},{:.3})",
1437                                render_data.pipeline_type,
1438                                render_data.vertices.len(),
1439                                v0.position[0],
1440                                v0.position[1],
1441                                v0.position[2],
1442                                v0.color[0],
1443                                v0.color[1],
1444                                v0.color[2],
1445                                v0.color[3],
1446                            );
1447                        } else if render_data.gpu_vertices.is_some() {
1448                            log::debug!(
1449                                target: "runmat_plot",
1450                                "wasm draw item: pipeline={:?} using gpu_vertices vertex_count={}",
1451                                render_data.pipeline_type,
1452                                render_data.vertex_count(),
1453                            );
1454                        } else {
1455                            log::debug!(
1456                                target: "runmat_plot",
1457                                "wasm draw item: pipeline={:?} has no vertices",
1458                                render_data.pipeline_type
1459                            );
1460                        }
1461                    }
1462                }
1463
1464                // Get the appropriate pipeline for this render data (pipeline ensured above)
1465                if render_data.pipeline_type == crate::core::PipelineType::Textured {
1466                    // Ensure image pipeline
1467                    let pipeline = self.wgpu_renderer.get_pipeline(render_data.pipeline_type);
1468                    render_pass.set_pipeline(pipeline);
1469                    // Bind direct uniforms at set(0)
1470                    // Use data bounds for image mapping
1471                    render_pass.set_bind_group(
1472                        0,
1473                        &self.wgpu_renderer.direct_uniform_bind_group,
1474                        &[],
1475                    );
1476                    if let Some(ref img_bg) = image_bind_groups[i] {
1477                        render_pass.set_bind_group(1, img_bg, &[]);
1478                    }
1479                } else {
1480                    let pipeline = self.wgpu_renderer.get_pipeline(render_data.pipeline_type);
1481                    render_pass.set_pipeline(pipeline);
1482                    // Set the uniform bind group (required by shaders)
1483                    render_pass.set_bind_group(0, self.wgpu_renderer.get_uniform_bind_group(), &[]);
1484                    if matches!(
1485                        render_data.pipeline_type,
1486                        crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
1487                    ) {
1488                        if let Some(ref bg) = point_style_bind_groups[i] {
1489                            render_pass.set_bind_group(1, bg, &[]);
1490                        }
1491                        render_pass.set_bind_group(
1492                            2,
1493                            self.wgpu_renderer.get_marker_screen_bind_group(),
1494                            &[],
1495                        );
1496                    }
1497                }
1498
1499                let is_markers = matches!(
1500                    render_data.pipeline_type,
1501                    crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
1502                );
1503                if is_markers {
1504                    if let Some((ref expanded, _len)) = point_buffers[i] {
1505                        render_pass.set_vertex_buffer(0, expanded.slice(..));
1506                    } else {
1507                        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1508                    }
1509                } else {
1510                    render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
1511                }
1512
1513                if let Some(index_buffer) = index_buffer {
1514                    render_pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
1515                    if let Some(indices) = &render_data.indices {
1516                        log::trace!(target: "runmat_plot", "draw indexed count={}", indices.len());
1517                        render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
1518                    }
1519                } else {
1520                    log::trace!(target: "runmat_plot", "draw direct vertices");
1521                    if let Some((args, offset)) = Self::gpu_indirect_args(render_data) {
1522                        render_pass.draw_indirect(args, offset);
1523                        continue;
1524                    }
1525                    if is_markers {
1526                        if let Some((_, len)) = point_buffers[i] {
1527                            render_pass.draw(0..len as u32, 0..1);
1528                            continue;
1529                        }
1530                    }
1531                    // Use draw_calls from render_data for proper vertex range handling
1532                    for draw_call in &render_data.draw_calls {
1533                        log::trace!(
1534                            target: "runmat_plot",
1535                            "draw vertices offset={} count={} instances={}",
1536                            draw_call.vertex_offset,
1537                            draw_call.vertex_count,
1538                            draw_call.instance_count
1539                        );
1540                        render_pass.draw(
1541                            draw_call.vertex_offset as u32
1542                                ..(draw_call.vertex_offset + draw_call.vertex_count) as u32,
1543                            0..draw_call.instance_count as u32,
1544                        );
1545                    }
1546                }
1547            }
1548            // drop render_pass at end of scope
1549        }
1550
1551        let render_time = start_time.elapsed().as_secs_f64() * 1000.0;
1552
1553        Ok(RenderResult {
1554            success: true,
1555            data_bounds: self.data_bounds,
1556            vertex_count: total_vertices,
1557            triangle_count: total_triangles,
1558            render_time_ms: render_time,
1559        })
1560    }
1561
1562    /// Shared scene orchestration for non-overlay render targets.
1563    ///
1564    /// For single-axes figures this follows the direct full-target render path.
1565    /// For subplot grids, it renders each axes into a deterministic tiled viewport layout.
1566    pub fn render_scene_to_target(
1567        &mut self,
1568        encoder: &mut wgpu::CommandEncoder,
1569        target_view: &wgpu::TextureView,
1570        config: &PlotRenderConfig,
1571    ) -> Result<RenderResult, Box<dyn std::error::Error>> {
1572        let start_time = Instant::now();
1573        let (rows, cols) = self.figure_axes_grid();
1574        let axes_count = rows.saturating_mul(cols);
1575        log::debug!(
1576            "runmat-plot: renderer.scene_to_target.start rows={} cols={} axes_count={} width={} height={}",
1577            rows,
1578            cols,
1579            axes_count,
1580            config.width,
1581            config.height
1582        );
1583        if axes_count <= 1 {
1584            log::debug!("runmat-plot: renderer.scene_to_target.branch_single_axes");
1585            return self.render(
1586                encoder,
1587                RenderTarget {
1588                    view: target_view,
1589                    resolve_target: None,
1590                },
1591                config,
1592            );
1593        }
1594
1595        let viewports =
1596            Self::compute_tiled_viewports(config.width.max(1), config.height.max(1), rows, cols);
1597        log::debug!(
1598            "runmat-plot: renderer.scene_to_target.branch_subplot_axes viewports={}",
1599            viewports.len()
1600        );
1601        self.render_axes_to_viewports(
1602            encoder,
1603            target_view,
1604            &viewports,
1605            config.msaa_samples.max(1),
1606            config,
1607        )?;
1608        let stats = self.scene.statistics();
1609        Ok(RenderResult {
1610            success: true,
1611            data_bounds: self.data_bounds,
1612            vertex_count: stats.total_vertices,
1613            triangle_count: stats.total_triangles,
1614            render_time_ms: start_time.elapsed().as_secs_f64() * 1000.0,
1615        })
1616    }
1617
1618    fn compute_tiled_viewports(
1619        total_width: u32,
1620        total_height: u32,
1621        rows: usize,
1622        cols: usize,
1623    ) -> Vec<(u32, u32, u32, u32)> {
1624        if rows == 0 || cols == 0 {
1625            return vec![(0, 0, total_width.max(1), total_height.max(1))];
1626        }
1627        let rows_u32 = rows as u32;
1628        let cols_u32 = cols as u32;
1629        let cell_w = (total_width / cols_u32).max(1);
1630        let cell_h = (total_height / rows_u32).max(1);
1631        let mut out = Vec::with_capacity(rows * cols);
1632        for r in 0..rows_u32 {
1633            for c in 0..cols_u32 {
1634                let x = c * cell_w;
1635                let y = r * cell_h;
1636                let mut w = cell_w;
1637                let mut h = cell_h;
1638                if c + 1 == cols_u32 {
1639                    w = total_width.saturating_sub(x).max(1);
1640                }
1641                if r + 1 == rows_u32 {
1642                    h = total_height.saturating_sub(y).max(1);
1643                }
1644                out.push((x, y, w, h));
1645            }
1646        }
1647        out
1648    }
1649
1650    /// Render using the camera-based pipeline into a viewport region with a scissor rectangle.
1651    /// This preserves existing contents (Load) and draws only inside the viewport rectangle.
1652    #[allow(clippy::too_many_arguments)]
1653    pub fn render_camera_to_viewport(
1654        &mut self,
1655        encoder: &mut wgpu::CommandEncoder,
1656        target_view: &wgpu::TextureView,
1657        viewport_scissor: (u32, u32, u32, u32),
1658        config: &PlotRenderConfig,
1659        camera: &Camera,
1660        axes_index: usize,
1661        clear_background: bool,
1662    ) -> Result<RenderResult, Box<dyn std::error::Error>> {
1663        log::debug!(
1664            "runmat-plot: renderer.camera_to_viewport.start axes_index={} viewport=({}, {}, {}, {}) clear_background={}",
1665            axes_index,
1666            viewport_scissor.0,
1667            viewport_scissor.1,
1668            viewport_scissor.2,
1669            viewport_scissor.3,
1670            clear_background
1671        );
1672        let use_msaa = config.msaa_samples.max(1) > 1;
1673        self.wgpu_renderer.ensure_msaa(config.msaa_samples);
1674        let msaa_view_keepalive = if use_msaa {
1675            Some(self.wgpu_renderer.ensure_msaa_color_view())
1676        } else {
1677            None
1678        };
1679        let render_target = if let Some(msaa_view) = msaa_view_keepalive.as_ref() {
1680            RenderTarget {
1681                view: msaa_view.as_ref(),
1682                resolve_target: Some(target_view),
1683            }
1684        } else {
1685            RenderTarget {
1686                view: target_view,
1687                resolve_target: None,
1688            }
1689        };
1690        self.render_camera_to_target_viewport(
1691            encoder,
1692            render_target,
1693            viewport_scissor,
1694            config,
1695            camera,
1696            axes_index,
1697            clear_background,
1698        )
1699    }
1700
1701    #[allow(clippy::too_many_arguments)]
1702    fn render_camera_to_target_viewport(
1703        &mut self,
1704        encoder: &mut wgpu::CommandEncoder,
1705        target: RenderTarget<'_>,
1706        viewport_scissor: (u32, u32, u32, u32),
1707        config: &PlotRenderConfig,
1708        camera: &Camera,
1709        axes_index: usize,
1710        clear_background: bool,
1711    ) -> Result<RenderResult, Box<dyn std::error::Error>> {
1712        let start_time = Instant::now();
1713
1714        // Apply MSAA preference into pipelines
1715        self.wgpu_renderer.ensure_msaa(config.msaa_samples);
1716        self.wgpu_renderer.set_depth_mode(config.depth_mode);
1717
1718        // Ensure a depth attachment exists for camera-based 3D rendering.
1719        // This is a no-op for pure 2D direct-mapped pipelines, but is required for correct
1720        // occlusion in 3D plots (surf/mesh/scatter3).
1721        let depth_view = self.wgpu_renderer.ensure_depth_view();
1722
1723        // Update standard uniforms from the provided camera
1724        let aspect_ratio = (config.width.max(1)) as f32 / (config.height.max(1)) as f32;
1725        let mut cam = camera.clone();
1726        cam.update_aspect_ratio(aspect_ratio);
1727        cam.depth_mode = config.depth_mode;
1728        log::debug!(
1729            "runmat-plot: renderer.camera_to_target_viewport.camera_ready axes_index={} aspect_ratio={} msaa_samples={}",
1730            axes_index,
1731            aspect_ratio,
1732            config.msaa_samples
1733        );
1734
1735        // Dynamic clip planes (CAD-like): keep near/far tight to visible bounds to avoid
1736        // clipping surprises and depth precision collapse on huge datasets.
1737        if config.clip_policy.dynamic {
1738            let mut bounds: Option<crate::core::scene::BoundingBox> = None;
1739            for node in self.scene.get_visible_nodes() {
1740                if let Some(rd) = &node.render_data {
1741                    if let Some(b) = rd.bounds {
1742                        bounds = Some(bounds.map_or(b, |acc| acc.union(&b)));
1743                    }
1744                }
1745            }
1746            if let Some(b) = bounds {
1747                cam.update_clip_planes_from_world_aabb(b.min, b.max, &config.clip_policy);
1748            }
1749        }
1750        let view_proj_matrix = cam.view_proj_matrix();
1751        let model_matrix = self.axes_model_matrix(axes_index);
1752        self.wgpu_renderer
1753            .update_uniforms_for_axes(axes_index, view_proj_matrix, model_matrix);
1754        log::debug!(
1755            "runmat-plot: renderer.camera_to_target_viewport.uniforms_updated axes_index={}",
1756            axes_index
1757        );
1758
1759        let (mut sx, mut sy, mut sw, mut sh) = viewport_scissor;
1760        let target_w = self.wgpu_renderer.surface_config.width.max(1);
1761        let target_h = self.wgpu_renderer.surface_config.height.max(1);
1762        if sx >= target_w || sy >= target_h {
1763            return Ok(RenderResult {
1764                success: true,
1765                data_bounds: self.data_bounds,
1766                vertex_count: 0,
1767                triangle_count: 0,
1768                render_time_ms: 0.0,
1769            });
1770        }
1771        sx = sx.min(target_w.saturating_sub(1));
1772        sy = sy.min(target_h.saturating_sub(1));
1773        sw = sw.max(1).min(target_w.saturating_sub(sx).max(1));
1774        sh = sh.max(1).min(target_h.saturating_sub(sy).max(1));
1775        let is_2d = matches!(
1776            cam.projection,
1777            crate::core::camera::ProjectionType::Orthographic { .. }
1778        );
1779        log::debug!(
1780            "runmat-plot: renderer.camera_to_target_viewport.viewport_normalized axes_index={} viewport=({}, {}, {}, {}) is_2d={}",
1781            axes_index,
1782            sx,
1783            sy,
1784            sw,
1785            sh,
1786            is_2d
1787        );
1788        match cam.projection {
1789            crate::core::camera::ProjectionType::Orthographic {
1790                left,
1791                right,
1792                bottom,
1793                top,
1794                ..
1795            } => {
1796                log::debug!(
1797                    target: "runmat_plot.draw_camera",
1798                    "draw camera axes_index={} is_2d=true viewport=({}, {}, {}, {}) bounds=({}, {})..({}, {}) cfg_wh=({}, {})",
1799                    axes_index,
1800                    sx,
1801                    sy,
1802                    sw,
1803                    sh,
1804                    left,
1805                    bottom,
1806                    right,
1807                    top,
1808                    config.width,
1809                    config.height
1810                );
1811            }
1812            crate::core::camera::ProjectionType::Perspective { .. } => {
1813                log::debug!(
1814                    target: "runmat_plot.draw_camera",
1815                    "draw camera axes_index={} is_2d=false viewport=({}, {}, {}, {}) cfg_wh=({}, {})",
1816                    axes_index,
1817                    sx,
1818                    sy,
1819                    sw,
1820                    sh,
1821                    config.width,
1822                    config.height
1823                );
1824            }
1825        }
1826
1827        // Prepare render items outside the pass
1828        let mut owned_render_data: Vec<Box<crate::core::RenderData>> = Vec::new();
1829        let mut render_items = Vec::new();
1830        let mut grid_plane_buffers: Option<(wgpu::Buffer, wgpu::Buffer)> = None;
1831        let mut total_vertices = 0usize;
1832        let mut total_triangles = 0usize;
1833        log::debug!(
1834            "runmat-plot: renderer.camera_to_target_viewport.collect_render_items.start axes_index={}",
1835            axes_index
1836        );
1837        for node in self.scene.get_visible_nodes() {
1838            if let Some(render_data) = &node.render_data {
1839                if node.axes_index == axes_index {
1840                    log::debug!(
1841                        target: "runmat_plot.draw_item",
1842                        "draw item axes_index={} node_axes_index={} pipeline={:?} vertex_count={} has_indices={} has_bounds={} gpu_vertices={}",
1843                        axes_index,
1844                        node.axes_index,
1845                        render_data.pipeline_type,
1846                        render_data.vertex_count(),
1847                        render_data.indices.is_some(),
1848                        render_data.bounds.is_some(),
1849                        render_data.gpu_vertices.is_some()
1850                    );
1851                }
1852                if let Some((vb, ib)) = self.prepare_buffers_for_render_data(node.id, render_data) {
1853                    self.wgpu_renderer
1854                        .ensure_pipeline(render_data.pipeline_type);
1855                    total_vertices += render_data.vertex_count();
1856                    if let Some(indices) = &render_data.indices {
1857                        total_triangles += indices.len() / 3;
1858                    }
1859                    render_items.push((render_data, vb, ib));
1860                }
1861            }
1862        }
1863        log::debug!(
1864            "runmat-plot: renderer.camera_to_target_viewport.collect_render_items.ok axes_index={} items={} total_vertices={} total_triangles={}",
1865            axes_index,
1866            render_items.len(),
1867            total_vertices,
1868            total_triangles
1869        );
1870
1871        // 3D helpers: CAD-style XY grid at Z=0 (grid on/off) + origin triad (always).
1872        // These are generated per-frame so they can adapt to zoom level.
1873        if !is_2d {
1874            let view_proj = view_proj_matrix;
1875            let inv_view_proj = view_proj.inverse();
1876
1877            let unproject = |ndc_x: f32, ndc_y: f32, ndc_z: f32| -> Option<Vec3> {
1878                let clip = Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
1879                let world = inv_view_proj * clip;
1880                if !world.w.is_finite() || world.w.abs() < 1e-6 {
1881                    return None;
1882                }
1883                let p = world.truncate() / world.w;
1884                if p.x.is_finite() && p.y.is_finite() && p.z.is_finite() {
1885                    Some(p)
1886                } else {
1887                    None
1888                }
1889            };
1890
1891            let ray_intersect_z0 = |ndc_x: f32, ndc_y: f32| -> Option<Vec3> {
1892                // Use a near/far pair in clip space to form a ray.
1893                let p0 = unproject(ndc_x, ndc_y, -1.0)?;
1894                let p1 = unproject(ndc_x, ndc_y, 1.0)?;
1895                let dir = p1 - p0;
1896                if !dir.z.is_finite() || dir.z.abs() < 1e-8 {
1897                    return None;
1898                }
1899                let t = (-p0.z) / dir.z;
1900                if !t.is_finite() || t <= 0.0 {
1901                    return None;
1902                }
1903                Some(p0 + dir * t)
1904            };
1905
1906            let mut plane_pts: Vec<Vec3> = Vec::new();
1907            for (nx, ny) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
1908                if let Some(p) = ray_intersect_z0(nx, ny) {
1909                    plane_pts.push(p);
1910                }
1911            }
1912
1913            // Fallback region if we couldn't intersect enough rays (camera nearly parallel to plane).
1914            let mut min_x = 0.0_f32;
1915            let mut max_x = 1.0_f32;
1916            let mut min_y = 0.0_f32;
1917            let mut max_y = 1.0_f32;
1918
1919            if plane_pts.len() >= 2 {
1920                min_x = plane_pts.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
1921                max_x = plane_pts
1922                    .iter()
1923                    .map(|p| p.x)
1924                    .fold(f32::NEG_INFINITY, f32::max);
1925                min_y = plane_pts.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
1926                max_y = plane_pts
1927                    .iter()
1928                    .map(|p| p.y)
1929                    .fold(f32::NEG_INFINITY, f32::max);
1930            } else if let crate::core::camera::ProjectionType::Perspective { fov, .. } =
1931                cam.projection
1932            {
1933                let dist = (cam.position - cam.target).length().max(1e-3);
1934                let extent = (dist * (0.5 * fov).tan() * 1.25).max(0.5);
1935                let center = Vec3::new(cam.target.x, cam.target.y, 0.0);
1936                min_x = center.x - extent;
1937                max_x = center.x + extent;
1938                min_y = center.y - extent;
1939                max_y = center.y + extent;
1940            }
1941
1942            // Expand a bit so grid lines don't pop at edges.
1943            let dx = (max_x - min_x).abs().max(1e-3);
1944            let dy = (max_y - min_y).abs().max(1e-3);
1945            let margin_x = dx * 0.04;
1946            let margin_y = dy * 0.04;
1947            min_x -= margin_x;
1948            max_x += margin_x;
1949            min_y -= margin_y;
1950            max_y += margin_y;
1951
1952            let project_to_px = |p: Vec3| -> Option<(f32, f32)> {
1953                let clip = view_proj * Vec4::new(p.x, p.y, p.z, 1.0);
1954                if !clip.w.is_finite() || clip.w.abs() < 1e-6 {
1955                    return None;
1956                }
1957                let ndc = clip.truncate() / clip.w;
1958                if !(ndc.x.is_finite() && ndc.y.is_finite()) {
1959                    return None;
1960                }
1961                let px = ((ndc.x + 1.0) * 0.5) * (sw.max(1) as f32);
1962                let py = ((1.0 - ndc.y) * 0.5) * (sh.max(1) as f32);
1963                Some((px, py))
1964            };
1965
1966            let nice_step = |raw: f64| -> f64 {
1967                if !raw.is_finite() || raw <= 0.0 {
1968                    return 1.0;
1969                }
1970                let pow10 = 10.0_f64.powf(raw.log10().floor());
1971                let norm = raw / pow10;
1972                let mult = if norm <= 1.0 {
1973                    1.0
1974                } else if norm <= 2.0 {
1975                    2.0
1976                } else if norm <= 5.0 {
1977                    5.0
1978                } else {
1979                    10.0
1980                };
1981                mult * pow10
1982            };
1983
1984            // Determine grid scale from projection at the plane center.
1985            let cx = (min_x + max_x) * 0.5;
1986            let cy = (min_y + max_y) * 0.5;
1987            let center = Vec3::new(cx, cy, 0.0);
1988            let px_per_world = {
1989                let a = project_to_px(center);
1990                let b = project_to_px(center + Vec3::new(1.0, 0.0, 0.0));
1991                match (a, b) {
1992                    (Some((ax, ay)), Some((bx, by))) => ((bx - ax).hypot(by - ay)).max(1e-3),
1993                    _ => 1.0,
1994                }
1995            };
1996            let desired_major_px = 120.0_f64;
1997            let major_step = nice_step((desired_major_px / (px_per_world as f64)).max(1e-6));
1998            let mut minor_step = major_step / 10.0;
1999            if !minor_step.is_finite() || minor_step <= 0.0 {
2000                minor_step = major_step.max(1.0);
2001            }
2002
2003            // Cap minor line density to avoid noisy/perf-heavy grids.
2004            let max_minor_lines = 180.0;
2005            let minor_count_x = (dx as f64 / minor_step).abs();
2006            let minor_count_y = (dy as f64 / minor_step).abs();
2007            if minor_count_x > max_minor_lines || minor_count_y > max_minor_lines {
2008                minor_step = (major_step / 5.0).max(major_step); // effectively disable minors
2009            }
2010
2011            let mut helper_vertices: Vec<Vertex> = Vec::new();
2012            let mut push_line = |a: Vec3, b: Vec3, color: Vec4| {
2013                helper_vertices.push(Vertex::new(a, color));
2014                helper_vertices.push(Vertex::new(b, color));
2015            };
2016
2017            // Slightly offset the grid plane to reduce z-fighting with geometry on z=0.
2018            let z_grid = -1e-4_f32;
2019
2020            // Procedural XY grid plane (depth-tested, no depth writes). This avoids far-plane
2021            // popping and keeps line density stable via shader derivatives.
2022            if self.overlay_show_grid_for_axes(axes_index) {
2023                let theme = self.theme.build_theme();
2024                let bg = theme.get_background_color();
2025                let grid = theme.get_grid_color();
2026                let bg_luma = 0.2126 * bg.x + 0.7152 * bg.y + 0.0722 * bg.z;
2027                let mut major_rgb = [grid.x, grid.y, grid.z];
2028                let mut minor_rgb = [grid.x, grid.y, grid.z];
2029                let mut major_alpha = grid.w.clamp(0.08, 0.22);
2030                let mut minor_alpha = (grid.w * 0.45).clamp(0.04, 0.14);
2031                if bg_luma <= 0.62 {
2032                    major_rgb = [grid.x * 0.80, grid.y * 0.80, grid.z * 0.80];
2033                    minor_rgb = [grid.x * 0.68, grid.y * 0.68, grid.z * 0.68];
2034                }
2035                if bg_luma > 0.62 {
2036                    major_rgb = [grid.x * 0.45, grid.y * 0.45, grid.z * 0.45];
2037                    minor_rgb = [grid.x * 0.33, grid.y * 0.33, grid.z * 0.33];
2038                    major_alpha = major_alpha.max(0.24);
2039                    minor_alpha = minor_alpha.max(0.12);
2040                }
2041                self.wgpu_renderer.ensure_grid_plane_pipeline();
2042                self.wgpu_renderer.update_grid_uniforms_for_axes(
2043                    axes_index,
2044                    crate::core::renderer::GridUniforms {
2045                        major_step: major_step as f32,
2046                        minor_step: minor_step as f32,
2047                        fade_start: (0.60 * dx.max(dy)).max(major_step as f32),
2048                        fade_end: (0.95 * dx.max(dy)).max((major_step as f32) * 2.0),
2049                        camera_pos: cam.position.to_array(),
2050                        _pad0: 0.0,
2051                        target_pos: Vec3::new(cam.target.x, cam.target.y, 0.0).to_array(),
2052                        _pad1: 0.0,
2053                        major_color: [major_rgb[0], major_rgb[1], major_rgb[2], major_alpha],
2054                        minor_color: [minor_rgb[0], minor_rgb[1], minor_rgb[2], minor_alpha],
2055                    },
2056                );
2057
2058                let quad_vertices = [
2059                    Vertex::new(Vec3::new(min_x, min_y, z_grid), Vec4::ONE),
2060                    Vertex::new(Vec3::new(max_x, min_y, z_grid), Vec4::ONE),
2061                    Vertex::new(Vec3::new(max_x, max_y, z_grid), Vec4::ONE),
2062                    Vertex::new(Vec3::new(min_x, max_y, z_grid), Vec4::ONE),
2063                ];
2064                let quad_indices: [u32; 6] = [0, 1, 2, 0, 2, 3];
2065                let vb = self.wgpu_renderer.create_vertex_buffer(&quad_vertices);
2066                let ib = self.wgpu_renderer.create_index_buffer(&quad_indices);
2067                grid_plane_buffers = Some((vb, ib));
2068            }
2069
2070            // Origin triad (always, for spatial awareness).
2071            let axis_len = (major_step as f32 * 5.0).clamp(0.5, (dx.max(dy) * 0.6).max(0.5));
2072            let origin = Vec3::new(0.0, 0.0, 0.0);
2073            let col_x = Vec4::new(0.92, 0.25, 0.25, 0.85);
2074            let col_y = Vec4::new(0.35, 0.90, 0.45, 0.85);
2075            let col_z = Vec4::new(0.35, 0.62, 0.98, 0.85);
2076            push_line(origin, origin + Vec3::new(axis_len, 0.0, 0.0), col_x);
2077            push_line(origin, origin + Vec3::new(0.0, axis_len, 0.0), col_y);
2078            push_line(origin, origin + Vec3::new(0.0, 0.0, axis_len), col_z);
2079
2080            // Dynamic tick marks on the origin triad (major step only). Labels are drawn in the
2081            // overlay so they stay crisp; these marks provide a depth-correct anchor in the scene.
2082            // NOTE: `f32::clamp` panics if min > max. When zoomed very far in, `major_step` can
2083            // be tiny, making `major_step * 0.25` smaller than a fixed minimum like 0.01.
2084            // Keep the min <= max by adapting the minimum to the current step size.
2085            let tick_max = (major_step as f32 * 0.25).max(1.0e-6);
2086            let tick_min = 0.01_f32.min(tick_max);
2087            let tick_len = (axis_len * 0.04).clamp(tick_min, tick_max);
2088            let max_ticks = 6usize;
2089            let mut add_ticks = |axis: Vec3, perp: Vec3, col: Vec4| {
2090                if major_step <= 0.0 {
2091                    return;
2092                }
2093                for i in 1..=max_ticks {
2094                    let t = (i as f32) * (major_step as f32);
2095                    if t >= axis_len * 0.999 {
2096                        break;
2097                    }
2098                    let p = origin + axis * t;
2099                    push_line(
2100                        p - perp * tick_len,
2101                        p + perp * tick_len,
2102                        Vec4::new(col.x, col.y, col.z, col.w * 0.85),
2103                    );
2104                }
2105            };
2106            add_ticks(Vec3::X, Vec3::Y, col_x);
2107            add_ticks(Vec3::Y, Vec3::X, col_y);
2108            add_ticks(Vec3::Z, Vec3::X, col_z);
2109
2110            if !helper_vertices.is_empty() {
2111                let rd = Box::new(crate::core::RenderData {
2112                    pipeline_type: crate::core::PipelineType::Lines,
2113                    vertices: helper_vertices,
2114                    indices: None,
2115                    gpu_vertices: None,
2116                    bounds: None,
2117                    material: crate::core::Material::default(),
2118                    draw_calls: vec![crate::core::DrawCall {
2119                        vertex_offset: 0,
2120                        vertex_count: 0, // filled below
2121                        index_offset: None,
2122                        index_count: None,
2123                        instance_count: 1,
2124                    }],
2125                    image: None,
2126                });
2127                owned_render_data.push(rd);
2128                let idx = owned_render_data.len() - 1;
2129                // Fill vertex_count now that vertices are owned.
2130                let vcount = owned_render_data[idx].vertices.len();
2131                if let Some(dc) = owned_render_data[idx].draw_calls.get_mut(0) {
2132                    dc.vertex_count = vcount;
2133                }
2134                let vb = Arc::new(
2135                    self.wgpu_renderer
2136                        .create_vertex_buffer(&owned_render_data[idx].vertices),
2137                );
2138                // Draw helpers first (under data, depth-tested).
2139                let rd_ref: &crate::core::RenderData = &owned_render_data[idx];
2140                render_items.insert(0, (rd_ref, vb, None));
2141                total_vertices += vcount;
2142            }
2143        }
2144
2145        // Precompute expanded point buffers to keep them alive across the render pass
2146        let mut point_buffers: Vec<Option<(wgpu::Buffer, usize)>> =
2147            Vec::with_capacity(render_items.len());
2148        for (render_data, _vb, _ib) in render_items.iter() {
2149            if matches!(
2150                render_data.pipeline_type,
2151                crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
2152            ) && !render_data.vertices.is_empty()
2153            {
2154                let expanded = self
2155                    .wgpu_renderer
2156                    // size_px=0.0 => use per-vertex normal.z sizes
2157                    .create_direct_point_vertices(&render_data.vertices, 0.0);
2158                let buf = self.wgpu_renderer.create_vertex_buffer(&expanded);
2159                point_buffers.push(Some((buf, expanded.len())));
2160            } else {
2161                point_buffers.push(None);
2162            }
2163        }
2164        // Precreate image bind groups for textured items to avoid lifetime issues
2165        let has_textured_items = render_items.iter().any(|(render_data, _vb, _ib)| {
2166            render_data.pipeline_type == crate::core::PipelineType::Textured
2167        });
2168        if has_textured_items {
2169            self.wgpu_renderer.ensure_image_pipeline();
2170        }
2171        let mut image_bind_groups: Vec<Option<wgpu::BindGroup>> =
2172            Vec::with_capacity(render_items.len());
2173
2174        for (render_data, _vb, _ib) in render_items.iter() {
2175            if render_data.pipeline_type == crate::core::PipelineType::Textured {
2176                if let Some(crate::core::scene::ImageData::Rgba8 {
2177                    width,
2178                    height,
2179                    data,
2180                }) = &render_data.image
2181                {
2182                    let (_t, _v, bg) = self
2183                        .wgpu_renderer
2184                        .create_image_texture_and_bind_group(*width, *height, data);
2185                    image_bind_groups.push(Some(bg));
2186                } else {
2187                    image_bind_groups.push(None);
2188                }
2189            } else {
2190                image_bind_groups.push(None);
2191            }
2192        }
2193        // Precreate point style bind groups for points to match pipeline layout [direct uniforms, point style]
2194        let mut point_style_bind_groups: Vec<Option<wgpu::BindGroup>> =
2195            Vec::with_capacity(render_items.len());
2196        for (render_data, _vb, _ib) in render_items.iter() {
2197            if matches!(
2198                render_data.pipeline_type,
2199                crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
2200            ) {
2201                let style = crate::core::renderer::PointStyleUniforms {
2202                    face_color: render_data.material.albedo.to_array(),
2203                    edge_color: render_data.material.emissive.to_array(),
2204                    edge_thickness_px: render_data.material.roughness,
2205                    marker_shape: render_data.material.metallic as u32,
2206                    _pad: [0.0, 0.0],
2207                };
2208                let (_buf, bg) = self.wgpu_renderer.create_point_style_bind_group(style);
2209                point_style_bind_groups.push(Some(bg));
2210            } else {
2211                point_style_bind_groups.push(None);
2212            }
2213        }
2214
2215        // Precompute optional grid geometry and uniforms so we can draw it under data
2216        // Grid is drawn only when enabled and in 2D orthographic
2217        let mut grid_vb_opt: Option<wgpu::Buffer> = None;
2218        if is_2d && self.overlay_show_grid_for_axes(axes_index) {
2219            if let Some((l, r, b, t)) = self.view_bounds_for_axes(axes_index) {
2220                // Update direct uniforms mapping for viewport
2221                self.wgpu_renderer.update_direct_uniforms_for_axes(
2222                    axes_index,
2223                    [l as f32, b as f32],
2224                    [r as f32, t as f32],
2225                    [-1.0, -1.0],
2226                    [1.0, 1.0],
2227                    [sw.max(1) as f32, sh.max(1) as f32],
2228                );
2229                self.wgpu_renderer.ensure_direct_line_pipeline();
2230
2231                let x_range = (r - l).max(1e-6);
2232                let y_range = (t - b).max(1e-6);
2233                let x_step = plot_utils::calculate_tick_interval(x_range);
2234                let y_step = plot_utils::calculate_tick_interval(y_range);
2235                let mut grid_vertices: Vec<Vertex> = Vec::new();
2236                let g = 80.0_f32 / 255.0_f32;
2237                let col = Vec4::new(g, g, g, 1.0);
2238                if x_step.is_finite() && x_step > 0.0 {
2239                    let mut x = ((l / x_step).ceil() * x_step) as f32;
2240                    let b_f = b as f32;
2241                    let t_f = t as f32;
2242                    while (x as f64) <= r {
2243                        grid_vertices.push(Vertex::new(Vec3::new(x, b_f, 0.0), col));
2244                        grid_vertices.push(Vertex::new(Vec3::new(x, t_f, 0.0), col));
2245                        x += x_step as f32;
2246                    }
2247                }
2248                if y_step.is_finite() && y_step > 0.0 {
2249                    let mut y = ((b / y_step).ceil() * y_step) as f32;
2250                    let l_f = l as f32;
2251                    let r_f = r as f32;
2252                    while (y as f64) <= t {
2253                        grid_vertices.push(Vertex::new(Vec3::new(l_f, y, 0.0), col));
2254                        grid_vertices.push(Vertex::new(Vec3::new(r_f, y, 0.0), col));
2255                        y += y_step as f32;
2256                    }
2257                }
2258                if !grid_vertices.is_empty() {
2259                    grid_vb_opt = Some(self.wgpu_renderer.create_vertex_buffer(&grid_vertices));
2260                }
2261            }
2262        }
2263
2264        // Before the pass: configure direct uniforms and ensure pipelines
2265        let bounds_opt = if is_2d {
2266            match cam.projection {
2267                crate::core::camera::ProjectionType::Orthographic {
2268                    left,
2269                    right,
2270                    bottom,
2271                    top,
2272                    ..
2273                } => Some((left as f64, right as f64, bottom as f64, top as f64)),
2274                _ => self.data_bounds,
2275            }
2276        } else {
2277            None
2278        };
2279        if is_2d {
2280            if let Some((l, r, b, t)) = bounds_opt {
2281                self.wgpu_renderer.update_direct_uniforms_for_axes(
2282                    axes_index,
2283                    [l as f32, b as f32],
2284                    [r as f32, t as f32],
2285                    [-1.0, -1.0],
2286                    [1.0, 1.0],
2287                    [sw.max(1) as f32, sh.max(1) as f32],
2288                );
2289            }
2290            self.wgpu_renderer.ensure_direct_triangle_pipeline();
2291            self.wgpu_renderer.ensure_direct_line_pipeline();
2292            self.wgpu_renderer.ensure_direct_point_pipeline();
2293        } else {
2294            // 3D: ensure camera-based pipelines exist so surfaces rotate with the camera.
2295            self.wgpu_renderer
2296                .ensure_pipeline(crate::core::PipelineType::Triangles);
2297            self.wgpu_renderer
2298                .ensure_pipeline(crate::core::PipelineType::Lines);
2299            self.wgpu_renderer
2300                .ensure_pipeline(crate::core::PipelineType::Points);
2301        }
2302        self.wgpu_renderer.update_marker_screen_uniforms_for_axes(
2303            axes_index,
2304            [sw.max(1) as f32, sh.max(1) as f32],
2305        );
2306
2307        // Begin pass with Load (preserve egui)
2308        {
2309            // Prepare MSAA render target if enabled
2310            let use_msaa = self.wgpu_renderer.msaa_sample_count > 1;
2311            log::debug!(
2312                "runmat-plot: renderer.camera_to_target_viewport.render_pass_start axes_index={} use_msaa={} clear_background={}",
2313                axes_index,
2314                use_msaa,
2315                clear_background
2316            );
2317
2318            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2319                label: Some("Plot Camera Viewport Pass"),
2320                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2321                    view: target.view,
2322                    resolve_target: if use_msaa {
2323                        target.resolve_target
2324                    } else {
2325                        None
2326                    },
2327                    ops: wgpu::Operations {
2328                        load: if clear_background {
2329                            wgpu::LoadOp::Clear(wgpu::Color {
2330                                r: config.background_color.x as f64,
2331                                g: config.background_color.y as f64,
2332                                b: config.background_color.z as f64,
2333                                a: config.background_color.w as f64,
2334                            })
2335                        } else {
2336                            wgpu::LoadOp::Load
2337                        },
2338                        store: wgpu::StoreOp::Store,
2339                    },
2340                })],
2341                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2342                    view: depth_view.as_ref(),
2343                    depth_ops: Some(wgpu::Operations {
2344                        load: wgpu::LoadOp::Clear(match config.depth_mode {
2345                            DepthMode::Standard => 1.0,
2346                            DepthMode::ReversedZ => 0.0,
2347                        }),
2348                        store: wgpu::StoreOp::Store,
2349                    }),
2350                    stencil_ops: None,
2351                }),
2352                timestamp_writes: None,
2353                occlusion_query_set: None,
2354            });
2355
2356            // Apply viewport and scissor rectangle to draw into the plot rect
2357            render_pass.set_viewport(
2358                sx as f32,
2359                sy as f32,
2360                sw.max(1) as f32,
2361                sh.max(1) as f32,
2362                0.0,
2363                1.0,
2364            );
2365            render_pass.set_scissor_rect(sx, sy, sw.max(1), sh.max(1));
2366            log::debug!(
2367                "runmat-plot: renderer.camera_to_target_viewport.render_pass_ready axes_index={} viewport=({}, {}, {}, {})",
2368                axes_index,
2369                sx,
2370                sy,
2371                sw.max(1),
2372                sh.max(1)
2373            );
2374            if let Some(ref vb_grid) = grid_vb_opt {
2375                if let Some(ref pipeline) = self.wgpu_renderer.direct_line_pipeline {
2376                    log::debug!(
2377                        "runmat-plot: renderer.camera_to_target_viewport.draw_grid_start axes_index={} vertex_buffer_size={}",
2378                        axes_index,
2379                        vb_grid.size()
2380                    );
2381                    render_pass.set_pipeline(pipeline);
2382                    render_pass.set_bind_group(
2383                        0,
2384                        self.wgpu_renderer
2385                            .get_direct_uniform_bind_group_for_axes(axes_index),
2386                        &[],
2387                    );
2388                    render_pass.set_vertex_buffer(0, vb_grid.slice(..));
2389                    // Each grid line is two vertices (LineList)
2390                    // Draw full buffer
2391                    // Note: vertex count equals number of vertices
2392                    // wgpu will interpret as lines via pipeline topology
2393                    render_pass.draw(
2394                        0..(vb_grid.size() / std::mem::size_of::<Vertex>() as u64) as u32,
2395                        0..1,
2396                    );
2397                    log::debug!(
2398                        "runmat-plot: renderer.camera_to_target_viewport.draw_grid_ok axes_index={}",
2399                        axes_index
2400                    );
2401                }
2402            }
2403
2404            // Use direct pipelines for precise 2D mapping inside the viewport
2405            let use_direct_for_triangles = is_2d;
2406            let use_direct_for_lines = is_2d;
2407            let direct_tri_pipeline = if use_direct_for_triangles && bounds_opt.is_some() {
2408                self.wgpu_renderer
2409                    .direct_triangle_pipeline
2410                    .as_ref()
2411                    .map(|p| p as *const wgpu::RenderPipeline)
2412            } else {
2413                None
2414            };
2415            let direct_line_pipeline = if use_direct_for_lines && bounds_opt.is_some() {
2416                self.wgpu_renderer
2417                    .direct_line_pipeline
2418                    .as_ref()
2419                    .map(|p| p as *const wgpu::RenderPipeline)
2420            } else {
2421                None
2422            };
2423            let direct_point_pipeline = if is_2d && bounds_opt.is_some() {
2424                self.wgpu_renderer
2425                    .direct_point_pipeline
2426                    .as_ref()
2427                    .map(|p| p as *const wgpu::RenderPipeline)
2428            } else {
2429                None
2430            };
2431
2432            for (idx, (render_data, vertex_buffer, index_buffer)) in render_items.iter().enumerate()
2433            {
2434                let is_triangles = matches!(
2435                    render_data.pipeline_type,
2436                    crate::core::PipelineType::Triangles
2437                );
2438                let is_lines =
2439                    matches!(render_data.pipeline_type, crate::core::PipelineType::Lines);
2440                let is_points = matches!(
2441                    render_data.pipeline_type,
2442                    crate::core::PipelineType::Points | crate::core::PipelineType::Scatter3
2443                );
2444                let is_textured = matches!(
2445                    render_data.pipeline_type,
2446                    crate::core::PipelineType::Textured
2447                );
2448                // Use direct mapping for lines/triangles/points for correct pixel-sized markers in GUI
2449                let use_direct = is_2d
2450                    && ((use_direct_for_triangles && is_triangles)
2451                        || (use_direct_for_lines && is_lines)
2452                        || is_points)
2453                    && bounds_opt.is_some();
2454                log::debug!(
2455                    "runmat-plot: renderer.camera_to_target_viewport.draw_item_start axes_index={} item_index={} pipeline={:?} use_direct={} textured={} indexed={} draw_calls={} point_buffer={} ",
2456                    axes_index,
2457                    idx,
2458                    render_data.pipeline_type,
2459                    use_direct,
2460                    is_textured,
2461                    index_buffer.is_some(),
2462                    render_data.draw_calls.len(),
2463                    point_buffers[idx].is_some()
2464                );
2465
2466                if use_direct {
2467                    // Safe because we only read pointers here within pass
2468                    let pipeline_ref: &wgpu::RenderPipeline = unsafe {
2469                        if is_triangles {
2470                            direct_tri_pipeline.unwrap().as_ref().unwrap()
2471                        } else if is_lines {
2472                            direct_line_pipeline.unwrap().as_ref().unwrap()
2473                        } else {
2474                            direct_point_pipeline.unwrap().as_ref().unwrap()
2475                        }
2476                    };
2477                    let uniform_bg = self
2478                        .wgpu_renderer
2479                        .get_direct_uniform_bind_group_for_axes(axes_index);
2480                    render_pass.set_pipeline(pipeline_ref);
2481                    render_pass.set_bind_group(0, uniform_bg, &[]);
2482                    if is_points {
2483                        if let Some(ref bg) = point_style_bind_groups[idx] {
2484                            render_pass.set_bind_group(1, bg, &[]);
2485                        }
2486                    }
2487                    log::debug!(
2488                        "runmat-plot: renderer.camera_to_target_viewport.draw_item_pipeline_ready axes_index={} item_index={} branch=direct",
2489                        axes_index,
2490                        idx
2491                    );
2492                } else if is_textured {
2493                    let pipeline = self
2494                        .wgpu_renderer
2495                        .get_pipeline(crate::core::PipelineType::Textured);
2496                    render_pass.set_pipeline(pipeline);
2497                    render_pass.set_bind_group(
2498                        0,
2499                        self.wgpu_renderer
2500                            .get_direct_uniform_bind_group_for_axes(axes_index),
2501                        &[],
2502                    );
2503                    if let Some(ref bg) = image_bind_groups[idx] {
2504                        render_pass.set_bind_group(1, bg, &[]);
2505                    }
2506                    log::debug!(
2507                        "runmat-plot: renderer.camera_to_target_viewport.draw_item_pipeline_ready axes_index={} item_index={} branch=textured",
2508                        axes_index,
2509                        idx
2510                    );
2511                } else {
2512                    let pipeline = self.wgpu_renderer.get_pipeline(render_data.pipeline_type);
2513                    render_pass.set_pipeline(pipeline);
2514                    render_pass.set_bind_group(
2515                        0,
2516                        self.wgpu_renderer
2517                            .get_uniform_bind_group_for_axes(axes_index),
2518                        &[],
2519                    );
2520                    if is_points {
2521                        if let Some(ref bg) = point_style_bind_groups[idx] {
2522                            render_pass.set_bind_group(1, bg, &[]);
2523                        }
2524                        render_pass.set_bind_group(
2525                            2,
2526                            self.wgpu_renderer
2527                                .get_marker_screen_bind_group_for_axes(axes_index),
2528                            &[],
2529                        );
2530                    }
2531                    log::debug!(
2532                        "runmat-plot: renderer.camera_to_target_viewport.draw_item_pipeline_ready axes_index={} item_index={} branch=standard",
2533                        axes_index,
2534                        idx
2535                    );
2536                }
2537
2538                if is_points && use_direct {
2539                    if let Some((ref buf, len)) = point_buffers[idx] {
2540                        render_pass.set_vertex_buffer(0, buf.slice(..));
2541                        render_pass.draw(0..len as u32, 0..1);
2542                        log::debug!(
2543                            "runmat-plot: renderer.camera_to_target_viewport.draw_item_ok axes_index={} item_index={} mode=direct_points vertices={}",
2544                            axes_index,
2545                            idx,
2546                            len
2547                        );
2548                        continue;
2549                    }
2550                } else if is_points {
2551                    if let Some((ref buf, _len)) = point_buffers[idx] {
2552                        render_pass.set_vertex_buffer(0, buf.slice(..));
2553                    } else {
2554                        render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
2555                    }
2556                } else {
2557                    render_pass.set_vertex_buffer(0, vertex_buffer.slice(..));
2558                }
2559                if let Some(index_buffer_ref) = index_buffer {
2560                    render_pass
2561                        .set_index_buffer(index_buffer_ref.slice(..), wgpu::IndexFormat::Uint32);
2562                    if let Some(indices) = &render_data.indices {
2563                        render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
2564                        log::debug!(
2565                            "runmat-plot: renderer.camera_to_target_viewport.draw_item_ok axes_index={} item_index={} mode=indexed indices={}",
2566                            axes_index,
2567                            idx,
2568                            indices.len()
2569                        );
2570                    }
2571                } else {
2572                    if is_points {
2573                        if let Some((_, len)) = point_buffers[idx] {
2574                            render_pass.draw(0..len as u32, 0..1);
2575                            continue;
2576                        }
2577                    }
2578                    for dc in &render_data.draw_calls {
2579                        render_pass.draw(
2580                            dc.vertex_offset as u32..(dc.vertex_offset + dc.vertex_count) as u32,
2581                            0..dc.instance_count as u32,
2582                        );
2583                        log::debug!(
2584                            "runmat-plot: renderer.camera_to_target_viewport.draw_call_ok axes_index={} item_index={} mode=draw vertex_offset={} vertex_count={} instances={}",
2585                            axes_index,
2586                            idx,
2587                            dc.vertex_offset,
2588                            dc.vertex_count,
2589                            dc.instance_count
2590                        );
2591                    }
2592                }
2593            }
2594
2595            // Draw procedural 3D grid plane after data, depth-tested (no depth writes).
2596            if let Some((ref vb, ref ib)) = grid_plane_buffers {
2597                if let Some(pipeline) = self.wgpu_renderer.grid_plane_pipeline() {
2598                    log::debug!(
2599                        "runmat-plot: renderer.camera_to_target_viewport.draw_grid_plane_start axes_index={}",
2600                        axes_index
2601                    );
2602                    render_pass.set_pipeline(pipeline);
2603                    render_pass.set_bind_group(
2604                        0,
2605                        self.wgpu_renderer
2606                            .get_uniform_bind_group_for_axes(axes_index),
2607                        &[],
2608                    );
2609                    render_pass.set_bind_group(
2610                        1,
2611                        self.wgpu_renderer
2612                            .get_grid_uniform_bind_group_for_axes(axes_index),
2613                        &[],
2614                    );
2615                    render_pass.set_vertex_buffer(0, vb.slice(..));
2616                    render_pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
2617                    render_pass.draw_indexed(0..6, 0, 0..1);
2618                    log::debug!(
2619                        "runmat-plot: renderer.camera_to_target_viewport.draw_grid_plane_ok axes_index={}",
2620                        axes_index
2621                    );
2622                }
2623            }
2624        }
2625
2626        log::debug!(
2627            "runmat-plot: renderer.camera_to_target_viewport.ok axes_index={} total_vertices={} total_triangles={}",
2628            axes_index,
2629            total_vertices,
2630            total_triangles
2631        );
2632
2633        Ok(RenderResult {
2634            success: true,
2635            data_bounds: self.data_bounds,
2636            vertex_count: total_vertices,
2637            triangle_count: total_triangles,
2638            render_time_ms: start_time.elapsed().as_secs_f64() * 1000.0,
2639        })
2640    }
2641
2642    /// Render all axes of a subplot grid into their respective viewport rectangles.
2643    /// `axes_viewports` is a vector of (x, y, w, h) in physical pixels, length equals rows*cols.
2644    pub fn render_axes_to_viewports(
2645        &mut self,
2646        encoder: &mut wgpu::CommandEncoder,
2647        target_view: &wgpu::TextureView,
2648        axes_viewports: &[(u32, u32, u32, u32)],
2649        msaa_samples: u32,
2650        base_config: &PlotRenderConfig,
2651    ) -> Result<(), Box<dyn std::error::Error>> {
2652        log::debug!(
2653            "runmat-plot: renderer.axes_to_viewports.start viewport_count={} msaa_samples={} width={} height={}",
2654            axes_viewports.len(),
2655            msaa_samples,
2656            base_config.width,
2657            base_config.height
2658        );
2659        // Build map axes_index -> node ids
2660        let mut axes_to_nodes: std::collections::HashMap<usize, Vec<crate::core::scene::NodeId>> =
2661            std::collections::HashMap::new();
2662        for node in self.scene.get_visible_nodes() {
2663            axes_to_nodes
2664                .entry(node.axes_index)
2665                .or_default()
2666                .push(node.id);
2667        }
2668
2669        if self.axes_cameras.is_empty() {
2670            self.axes_cameras.push(Self::create_default_camera());
2671        }
2672        self.wgpu_renderer
2673            .ensure_axes_uniform_capacity(axes_viewports.len().max(1));
2674
2675        // Pre-collect all node ids
2676        let all_ids: Vec<crate::core::scene::NodeId> = self
2677            .scene
2678            .get_visible_nodes()
2679            .into_iter()
2680            .map(|n| n.id)
2681            .collect();
2682        let active_axes: Vec<usize> = axes_viewports
2683            .iter()
2684            .enumerate()
2685            .filter_map(|(ax_idx, _)| {
2686                axes_to_nodes
2687                    .get(&ax_idx)
2688                    .filter(|ids| !ids.is_empty())
2689                    .map(|_| ax_idx)
2690            })
2691            .collect();
2692        if active_axes.is_empty() {
2693            log::debug!("runmat-plot: renderer.axes_to_viewports.no_active_axes");
2694            return Ok(());
2695        }
2696
2697        self.wgpu_renderer.ensure_msaa(msaa_samples.max(1));
2698        let shared_msaa_view = if self.wgpu_renderer.msaa_sample_count > 1 {
2699            Some(self.wgpu_renderer.ensure_msaa_color_view())
2700        } else {
2701            None
2702        };
2703
2704        for (ax_idx, viewport) in axes_viewports.iter().enumerate() {
2705            log::debug!(
2706                "runmat-plot: renderer.axes_to_viewports.viewport axes_index={} viewport=({}, {}, {}, {})",
2707                ax_idx,
2708                viewport.0,
2709                viewport.1,
2710                viewport.2,
2711                viewport.3
2712            );
2713            let ids_for_axes = axes_to_nodes.get(&ax_idx).cloned().unwrap_or_default();
2714            if ids_for_axes.is_empty() {
2715                log::debug!(
2716                    "runmat-plot: renderer.axes_to_viewports.skip_empty_axes axes_index={}",
2717                    ax_idx
2718                );
2719                continue;
2720            }
2721
2722            // Hide nodes not belonging to this axes
2723            let mut hidden_ids: Vec<crate::core::scene::NodeId> = Vec::new();
2724            for id in &all_ids {
2725                if !ids_for_axes.contains(id) {
2726                    if let Some(node) = self.scene.get_node_mut(*id) {
2727                        if node.visible {
2728                            node.visible = false;
2729                            hidden_ids.push(*id);
2730                        }
2731                    }
2732                }
2733            }
2734            // Update camera and bounds
2735            let cam = self
2736                .axes_cameras
2737                .get(ax_idx)
2738                .cloned()
2739                .unwrap_or_else(Self::create_default_camera);
2740            let _ = self.calculate_data_bounds();
2741
2742            // Render this axes into its viewport
2743            let mut cfg = base_config.clone();
2744            cfg.width = viewport.2;
2745            cfg.height = viewport.3;
2746            cfg.msaa_samples = msaa_samples.max(1);
2747            let is_first_axes = Some(&ax_idx) == active_axes.first();
2748            let is_last_axes = Some(&ax_idx) == active_axes.last();
2749            log::debug!(
2750                "runmat-plot: renderer.axes_to_viewports.axes_ready axes_index={} node_count={} first_axes={} last_axes={}",
2751                ax_idx,
2752                ids_for_axes.len(),
2753                is_first_axes,
2754                is_last_axes
2755            );
2756            let render_target = if let Some(ref msaa_view) = shared_msaa_view {
2757                RenderTarget {
2758                    view: msaa_view.as_ref(),
2759                    resolve_target: if is_last_axes {
2760                        Some(target_view)
2761                    } else {
2762                        None
2763                    },
2764                }
2765            } else {
2766                RenderTarget {
2767                    view: target_view,
2768                    resolve_target: None,
2769                }
2770            };
2771            let _ = self.render_camera_to_target_viewport(
2772                encoder,
2773                render_target,
2774                *viewport,
2775                &cfg,
2776                &cam,
2777                ax_idx,
2778                is_first_axes,
2779            )?;
2780            log::debug!(
2781                "runmat-plot: renderer.axes_to_viewports.axes_render_ok axes_index={}",
2782                ax_idx
2783            );
2784
2785            // Restore hidden nodes visibility
2786            for id in hidden_ids {
2787                if let Some(node) = self.scene.get_node_mut(id) {
2788                    node.visible = true;
2789                }
2790            }
2791        }
2792        log::debug!("runmat-plot: renderer.axes_to_viewports.ok");
2793        Ok(())
2794    }
2795
2796    /// Create default 2D camera for plotting
2797    fn create_default_camera() -> Camera {
2798        let mut camera = Camera::new();
2799        camera.projection = crate::core::camera::ProjectionType::Orthographic {
2800            left: -5.0,
2801            right: 5.0,
2802            bottom: -5.0,
2803            top: 5.0,
2804            // Use a deeper z range so the default camera position doesn't clip z=0 geometry.
2805            near: -10.0,
2806            far: 10.0,
2807        };
2808        camera.depth_mode = DepthMode::default();
2809        // For 2D plotting we keep the camera close to the z=0 plane.
2810        camera.position = Vec3::new(0.0, 0.0, 1.0);
2811        camera.target = Vec3::new(0.0, 0.0, 0.0);
2812        camera.up = Vec3::new(0.0, 1.0, 0.0);
2813        camera
2814    }
2815
2816    // Removed simple data_bounds getter in favor of overlay-aware bounds below
2817
2818    /// Get the primary (axes 0) camera.
2819    pub fn camera(&self) -> &Camera {
2820        self.axes_cameras
2821            .first()
2822            .expect("axes_cameras must contain at least one camera")
2823    }
2824
2825    /// Get mutable reference to the primary (axes 0) camera.
2826    pub fn camera_mut(&mut self) -> &mut Camera {
2827        self.axes_cameras
2828            .first_mut()
2829            .expect("axes_cameras must contain at least one camera")
2830    }
2831
2832    pub fn axes_camera(&self, axes_index: usize) -> Option<&Camera> {
2833        self.axes_cameras.get(axes_index)
2834    }
2835
2836    /// Get scene reference
2837    pub fn scene(&self) -> &Scene {
2838        &self.scene
2839    }
2840
2841    /// Get scene statistics
2842    pub fn scene_statistics(&self) -> crate::core::SceneStatistics {
2843        self.scene.statistics()
2844    }
2845
2846    /// Get current view bounds (camera frustum) in world/data space for 2D
2847    pub fn view_bounds(&self) -> Option<(f64, f64, f64, f64)> {
2848        match self.camera().projection {
2849            crate::core::camera::ProjectionType::Orthographic {
2850                left,
2851                right,
2852                bottom,
2853                top,
2854                ..
2855            } => Some((left as f64, right as f64, bottom as f64, top as f64)),
2856            _ => None,
2857        }
2858    }
2859
2860    /// Overlay configuration getters
2861    pub fn overlay_show_grid(&self) -> bool {
2862        self.figure_show_grid
2863    }
2864    pub fn overlay_show_grid_for_axes(&self, axes_index: usize) -> bool {
2865        self.last_figure
2866            .as_ref()
2867            .and_then(|f| f.axes_metadata(axes_index))
2868            .map(|m| m.grid_enabled)
2869            .unwrap_or(self.figure_show_grid)
2870    }
2871    pub fn overlay_show_box(&self) -> bool {
2872        self.figure_show_box
2873    }
2874    pub fn overlay_show_box_for_axes(&self, axes_index: usize) -> bool {
2875        self.last_figure
2876            .as_ref()
2877            .and_then(|f| f.axes_metadata(axes_index))
2878            .map(|m| m.box_enabled)
2879            .unwrap_or(self.figure_show_box)
2880    }
2881    pub fn overlay_title(&self) -> Option<&String> {
2882        self.figure_title.as_ref()
2883    }
2884    pub fn overlay_sg_title(&self) -> Option<&String> {
2885        self.figure_sg_title.as_ref()
2886    }
2887    pub fn overlay_sg_title_style(&self) -> &TextStyle {
2888        &self.figure_sg_title_style
2889    }
2890    pub fn overlay_title_for_axes(&self, axes_index: usize) -> Option<&String> {
2891        self.last_figure
2892            .as_ref()
2893            .and_then(|f| f.axes_metadata(axes_index))
2894            .and_then(|m| m.title.as_ref())
2895    }
2896    pub fn overlay_x_label(&self) -> Option<&String> {
2897        self.figure_x_label.as_ref()
2898    }
2899    pub fn overlay_x_label_for_axes(&self, axes_index: usize) -> Option<&String> {
2900        self.last_figure
2901            .as_ref()
2902            .and_then(|f| f.axes_metadata(axes_index))
2903            .and_then(|m| m.x_label.as_ref())
2904    }
2905    pub fn overlay_x_label_style_for_axes(&self, axes_index: usize) -> Option<&TextStyle> {
2906        self.last_figure
2907            .as_ref()
2908            .and_then(|f| f.axes_metadata(axes_index))
2909            .map(|m| &m.x_label_style)
2910    }
2911    pub fn overlay_y_label(&self) -> Option<&String> {
2912        self.figure_y_label.as_ref()
2913    }
2914    pub fn overlay_y_label_for_axes(&self, axes_index: usize) -> Option<&String> {
2915        self.last_figure
2916            .as_ref()
2917            .and_then(|f| f.axes_metadata(axes_index))
2918            .and_then(|m| m.y_label.as_ref())
2919    }
2920    pub fn overlay_y_label_style_for_axes(&self, axes_index: usize) -> Option<&TextStyle> {
2921        self.last_figure
2922            .as_ref()
2923            .and_then(|f| f.axes_metadata(axes_index))
2924            .map(|m| &m.y_label_style)
2925    }
2926    pub fn overlay_z_label(&self) -> Option<&String> {
2927        self.figure_z_label.as_ref()
2928    }
2929    pub fn overlay_z_label_for_axes(&self, axes_index: usize) -> Option<&String> {
2930        self.last_figure
2931            .as_ref()
2932            .and_then(|f| f.axes_metadata(axes_index))
2933            .and_then(|m| m.z_label.as_ref())
2934    }
2935    pub fn overlay_z_label_style_for_axes(&self, axes_index: usize) -> Option<&TextStyle> {
2936        self.last_figure
2937            .as_ref()
2938            .and_then(|f| f.axes_metadata(axes_index))
2939            .map(|m| &m.z_label_style)
2940    }
2941    pub fn active_axes_pie_labels(&self) -> Vec<(String, glam::Vec2)> {
2942        let Some(fig) = self.last_figure.as_ref() else {
2943            return Vec::new();
2944        };
2945        fig.pie_labels_for_axes(fig.active_axes_index)
2946            .into_iter()
2947            .map(|entry| (entry.label, entry.position))
2948            .collect()
2949    }
2950    pub fn pie_labels_for_axes(&self, axes_index: usize) -> Vec<(String, glam::Vec2)> {
2951        let Some(fig) = self.last_figure.as_ref() else {
2952            return Vec::new();
2953        };
2954        fig.pie_labels_for_axes(axes_index)
2955            .into_iter()
2956            .map(|entry| (entry.label, entry.position))
2957            .collect()
2958    }
2959
2960    pub fn world_text_annotations_for_axes(
2961        &self,
2962        axes_index: usize,
2963    ) -> Vec<(glam::Vec3, String, TextStyle)> {
2964        self.last_figure
2965            .as_ref()
2966            .map(|f| {
2967                f.axes_text_annotations(axes_index)
2968                    .iter()
2969                    .map(|annotation| {
2970                        (
2971                            annotation.position,
2972                            annotation.text.clone(),
2973                            annotation.style.clone(),
2974                        )
2975                    })
2976                    .collect()
2977            })
2978            .unwrap_or_default()
2979    }
2980
2981    pub fn world_axis_label_annotations_for_axes(
2982        &self,
2983        axes_index: usize,
2984    ) -> Vec<(glam::Vec3, String, TextStyle)> {
2985        let Some(fig) = self.last_figure.as_ref() else {
2986            return Vec::new();
2987        };
2988        let Some(meta) = fig.axes_metadata(axes_index) else {
2989            return Vec::new();
2990        };
2991        let Some(bounds) = self.display_bounds_3d_for_axes(axes_index) else {
2992            return Vec::new();
2993        };
2994        let dx = (bounds.max.x - bounds.min.x).abs().max(1.0e-3);
2995        let dy = (bounds.max.y - bounds.min.y).abs().max(1.0e-3);
2996        let dz = (bounds.max.z - bounds.min.z).abs().max(1.0e-3);
2997        let camera = self
2998            .axes_camera(axes_index)
2999            .or_else(|| Some(self.camera()))
3000            .expect("plot renderer must always have a camera");
3001        let center = (bounds.min + bounds.max) * 0.5;
3002        let cam_delta = camera.position - center;
3003        let sx = if cam_delta.x >= 0.0 { 1.0 } else { -1.0 };
3004        let sy = if cam_delta.y >= 0.0 { 1.0 } else { -1.0 };
3005        let sz = if cam_delta.z >= 0.0 { 1.0 } else { -1.0 };
3006        let x_anchor = glam::Vec3::new(bounds.min.x + dx * 0.82, bounds.min.y, bounds.min.z)
3007            + glam::Vec3::new(0.0, -sy * dy * 0.10, -sz * dz * 0.08);
3008        let y_anchor = glam::Vec3::new(bounds.min.x, bounds.min.y + dy * 0.82, bounds.min.z)
3009            + glam::Vec3::new(-sx * dx * 0.10, 0.0, -sz * dz * 0.08);
3010        let z_anchor = glam::Vec3::new(bounds.min.x, bounds.min.y, bounds.min.z + dz * 0.82)
3011            + glam::Vec3::new(-sx * dx * 0.08, -sy * dy * 0.08, 0.0);
3012        let mut out = Vec::new();
3013        if let Some(label) = meta.x_label.clone().filter(|s| !s.is_empty()) {
3014            out.push((x_anchor, label, meta.x_label_style.clone()));
3015        }
3016        if let Some(label) = meta.y_label.clone().filter(|s| !s.is_empty()) {
3017            out.push((y_anchor, label, meta.y_label_style.clone()));
3018        }
3019        if let Some(label) = meta.z_label.clone().filter(|s| !s.is_empty()) {
3020            out.push((z_anchor, label, meta.z_label_style.clone()));
3021        }
3022        out
3023    }
3024    pub fn overlay_show_legend(&self) -> bool {
3025        self.figure_show_legend
3026    }
3027    pub fn overlay_show_legend_for_axes(&self, axes_index: usize) -> bool {
3028        self.last_figure
3029            .as_ref()
3030            .and_then(|f| f.axes_metadata(axes_index))
3031            .map(|m| m.legend_enabled)
3032            .unwrap_or(self.figure_show_legend)
3033    }
3034    pub fn overlay_legend_entries(&self) -> &Vec<LegendEntry> {
3035        &self.legend_entries
3036    }
3037    pub fn overlay_legend_entries_for_axes(&self, axes_index: usize) -> Vec<LegendEntry> {
3038        self.last_figure
3039            .as_ref()
3040            .map(|f| f.legend_entries_for_axes(axes_index))
3041            .unwrap_or_default()
3042    }
3043    pub fn overlay_x_log(&self) -> bool {
3044        self.figure_x_log
3045    }
3046    pub fn overlay_x_log_for_axes(&self, axes_index: usize) -> bool {
3047        self.last_figure
3048            .as_ref()
3049            .and_then(|f| f.axes_metadata(axes_index))
3050            .map(|m| m.x_log)
3051            .unwrap_or(self.figure_x_log)
3052    }
3053    pub fn overlay_y_log(&self) -> bool {
3054        self.figure_y_log
3055    }
3056    pub fn overlay_y_log_for_axes(&self, axes_index: usize) -> bool {
3057        self.last_figure
3058            .as_ref()
3059            .and_then(|f| f.axes_metadata(axes_index))
3060            .map(|m| m.y_log)
3061            .unwrap_or(self.figure_y_log)
3062    }
3063    pub fn overlay_colormap(&self) -> ColorMap {
3064        self.figure_colormap
3065    }
3066    pub fn overlay_colorbar_enabled(&self) -> bool {
3067        self.figure_colorbar_enabled
3068    }
3069    /// Subplot grid
3070    pub fn figure_axes_grid(&self) -> (usize, usize) {
3071        self.last_figure
3072            .as_ref()
3073            .map(|f| f.axes_grid())
3074            .unwrap_or((1, 1))
3075    }
3076    /// Return categorical labels if any (is_x_axis, &labels)
3077    pub fn overlay_categorical_labels(&self) -> Option<(bool, &Vec<String>)> {
3078        if let (Some(is_x), Some(labels)) = (
3079            &self.figure_categorical_is_x,
3080            &self.figure_categorical_labels,
3081        ) {
3082            Some((*is_x, labels))
3083        } else {
3084            None
3085        }
3086    }
3087
3088    pub fn overlay_categorical_labels_for_axes(
3089        &self,
3090        axes_index: usize,
3091    ) -> Option<(bool, Vec<String>)> {
3092        self.last_figure
3093            .as_ref()
3094            .and_then(|f| f.categorical_axis_labels_for_axes(axes_index))
3095    }
3096
3097    pub fn overlay_x_tick_labels_for_axes(&self, axes_index: usize) -> Option<Vec<String>> {
3098        self.last_figure
3099            .as_ref()
3100            .and_then(|f| f.x_axis_tick_labels_for_axes(axes_index))
3101    }
3102
3103    pub fn overlay_y_tick_labels_for_axes(&self, axes_index: usize) -> Option<Vec<String>> {
3104        self.last_figure
3105            .as_ref()
3106            .and_then(|f| f.y_axis_tick_labels_for_axes(axes_index))
3107    }
3108
3109    pub fn overlay_histogram_edges_for_axes(&self, axes_index: usize) -> Option<(bool, Vec<f64>)> {
3110        self.last_figure
3111            .as_ref()
3112            .and_then(|f| f.histogram_axis_edges_for_axes(axes_index))
3113    }
3114
3115    /// Get stable display bounds for an axes (data/explicit limits), excluding transient pan/zoom.
3116    pub fn overlay_display_bounds_for_axes(
3117        &self,
3118        axes_index: usize,
3119    ) -> Option<(f64, f64, f64, f64)> {
3120        self.display_bounds_for_axes(axes_index)
3121    }
3122
3123    /// Get bounds used for display (manual axis limits override data bounds when provided)
3124    pub fn data_bounds(&self) -> Option<(f64, f64, f64, f64)> {
3125        let base = self.data_bounds;
3126        base.map(|(bx_min, bx_max, by_min, by_max)| {
3127            let (mut x_min, mut x_max) = (bx_min, bx_max);
3128            let (mut y_min, mut y_max) = (by_min, by_max);
3129            if let Some((xl, xr)) = self.figure_x_limits {
3130                x_min = xl;
3131                x_max = xr;
3132            }
3133            if let Some((yl, yr)) = self.figure_y_limits {
3134                y_min = yl;
3135                y_max = yr;
3136            }
3137            (x_min, x_max, y_min, y_max)
3138        })
3139    }
3140
3141    /// Get mutable reference to a specific axes camera when using subplots
3142    pub fn axes_camera_mut(&mut self, idx: usize) -> Option<&mut Camera> {
3143        self.axes_cameras.get_mut(idx)
3144    }
3145
3146    /// Get view bounds for a specific axes camera (l, r, b, t)
3147    pub fn view_bounds_for_axes(&self, idx: usize) -> Option<(f64, f64, f64, f64)> {
3148        if let Some(cam) = self.axes_cameras.get(idx) {
3149            if let crate::core::camera::ProjectionType::Orthographic {
3150                left,
3151                right,
3152                bottom,
3153                top,
3154                ..
3155            } = cam.projection
3156            {
3157                return Some((left as f64, right as f64, bottom as f64, top as f64));
3158            }
3159        }
3160        None
3161    }
3162
3163    pub fn axes_bounds(&self, axes_index: usize) -> Option<crate::core::BoundingBox> {
3164        let mut min = Vec3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY);
3165        let mut max = Vec3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
3166        let mut saw_any = false;
3167
3168        for node in self.scene.get_visible_nodes() {
3169            if node.axes_index != axes_index {
3170                continue;
3171            }
3172            let Some(render_data) = &node.render_data else {
3173                continue;
3174            };
3175            if let Some(bounds) = render_data.bounds {
3176                min = min.min(bounds.min);
3177                max = max.max(bounds.max);
3178                saw_any = true;
3179                continue;
3180            }
3181            for v in &render_data.vertices {
3182                let p = Vec3::new(v.position[0], v.position[1], v.position[2]);
3183                min = min.min(p);
3184                max = max.max(p);
3185                saw_any = true;
3186            }
3187        }
3188
3189        if !saw_any {
3190            return None;
3191        }
3192        Some(crate::core::BoundingBox { min, max })
3193    }
3194
3195    /// Prefer exporting the original figure if available
3196    pub fn export_figure_clone(&self) -> crate::plots::Figure {
3197        if let Some(f) = &self.last_figure {
3198            return f.clone();
3199        }
3200        // As a strict fallback, produce an empty figure with current metadata only
3201        let mut fig = crate::plots::Figure::new();
3202        fig.title = self.figure_title.clone();
3203        fig.sg_title = self.figure_sg_title.clone();
3204        fig.sg_title_style = self.figure_sg_title_style.clone();
3205        fig.x_label = self.figure_x_label.clone();
3206        fig.y_label = self.figure_y_label.clone();
3207        fig.legend_enabled = self.figure_show_legend;
3208        fig.grid_enabled = self.figure_show_grid;
3209        fig.box_enabled = self.figure_show_box;
3210        fig.x_limits = self.figure_x_limits;
3211        fig.y_limits = self.figure_y_limits;
3212        fig.x_log = self.figure_x_log;
3213        fig.y_log = self.figure_y_log;
3214        fig.axis_equal = self.figure_axis_equal;
3215        fig.colormap = self.figure_colormap;
3216        fig.colorbar_enabled = self.figure_colorbar_enabled;
3217        let (rows, cols) = self.figure_axes_grid();
3218        fig.set_subplot_grid(rows, cols);
3219        fig
3220    }
3221}
3222
3223#[cfg(test)]
3224mod tests {
3225    use super::*;
3226    use crate::plots::{figure::PlotElement, PatchPlot};
3227
3228    fn patch_element(vertices: Vec<Vec3>) -> PlotElement {
3229        PlotElement::Patch(PatchPlot::new(vertices, vec![vec![0, 1, 2]]).unwrap())
3230    }
3231
3232    #[test]
3233    fn patch_3d_detection_preserves_small_scene_depth() {
3234        let patch = patch_element(vec![
3235            Vec3::new(0.0, 0.0, 0.0),
3236            Vec3::new(1.0e-4, 0.0, 0.0),
3237            Vec3::new(0.0, 1.0e-4, 1.0e-8),
3238        ]);
3239
3240        assert!(PlotRenderer::plot_element_is_3d(&patch));
3241    }
3242
3243    #[test]
3244    fn patch_3d_detection_ignores_large_scene_relative_noise() {
3245        let patch = patch_element(vec![
3246            Vec3::new(0.0, 0.0, 0.0),
3247            Vec3::new(1.0e6, 0.0, 0.0),
3248            Vec3::new(0.0, 1.0e6, 0.5),
3249        ]);
3250
3251        assert!(!PlotRenderer::plot_element_is_3d(&patch));
3252    }
3253
3254    #[test]
3255    fn applies_z_limits_to_3d_display_bounds() {
3256        let mut figure = Figure::new();
3257        figure.set_axes_z_limits(0, Some((-2.0, 3.0)));
3258        let bounds = BoundingBox::new(Vec3::new(-1.0, -1.0, -10.0), Vec3::new(1.0, 1.0, 10.0));
3259
3260        let limited = PlotRenderer::apply_3d_display_limits_to_bounds(bounds, Some(&figure), 0);
3261
3262        assert_eq!(limited.min.z, -2.0);
3263        assert_eq!(limited.max.z, 3.0);
3264        assert_eq!(limited.min.x, -1.0);
3265        assert_eq!(limited.max.x, 1.0);
3266    }
3267
3268    #[test]
3269    fn degenerate_z_bounds_remain_finite_for_3d_display_bounds() {
3270        let figure = Figure::new();
3271        let bounds = BoundingBox::new(Vec3::new(-2.0, -2.0, 0.0), Vec3::new(2.0, 2.0, 0.0));
3272
3273        let display = PlotRenderer::apply_3d_display_limits_to_bounds(bounds, Some(&figure), 0);
3274
3275        assert!(PlotRenderer::bounds_are_finite(display));
3276        assert_eq!(display.min.z, 0.0);
3277        assert_eq!(display.max.z, 0.0);
3278    }
3279
3280    #[test]
3281    fn axes_view_contract_tracks_subplot_limits() {
3282        let mut base = Figure::new();
3283        base.set_subplot_grid(2, 1);
3284        let mut limited = base.clone();
3285        limited.set_axes_limits(0, Some((0.0, 30.0)), None);
3286        limited.set_axes_limits(1, Some((200.0, 450.0)), None);
3287
3288        let base_contract = PlotRenderer::axes_view_contract_for_figure(&base);
3289        let limited_contract = PlotRenderer::axes_view_contract_for_figure(&limited);
3290
3291        assert_ne!(base_contract, limited_contract);
3292        assert_eq!(limited_contract.axes[0].x_limits, Some((0.0, 30.0)));
3293        assert_eq!(limited_contract.axes[1].x_limits, Some((200.0, 450.0)));
3294    }
3295}
3296
3297/// High-level plotting utilities that use the unified renderer
3298pub mod plot_utils {
3299    pub fn generate_major_ticks(min: f64, max: f64) -> Vec<f64> {
3300        if !(min.is_finite() && max.is_finite()) || max <= min {
3301            return Vec::new();
3302        }
3303        let range = (max - min).max(1e-9);
3304        let step = calculate_tick_interval(range);
3305        if !(step.is_finite() && step > 0.0) {
3306            return Vec::new();
3307        }
3308
3309        let mut ticks = Vec::new();
3310        let mut value = (min / step).ceil() * step;
3311        let epsilon = range * 1e-6 + step * 1e-6;
3312        while value <= max + epsilon {
3313            let snapped = if value.abs() < epsilon { 0.0 } else { value };
3314            ticks.push(snapped);
3315            value += step;
3316            if ticks.len() > 64 {
3317                break;
3318            }
3319        }
3320
3321        let endpoint_tol = step * 0.18;
3322        let near_min = ticks.iter().any(|t| (*t - min).abs() <= endpoint_tol);
3323        let near_max = ticks.iter().any(|t| (*t - max).abs() <= endpoint_tol);
3324        if !near_min {
3325            ticks.insert(0, min);
3326        }
3327        if !near_max {
3328            ticks.push(max);
3329        }
3330
3331        ticks.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3332        ticks.dedup_by(|a, b| (*a - *b).abs() <= endpoint_tol * 0.5);
3333        ticks
3334    }
3335
3336    /// Calculate nice tick intervals for axis labeling
3337    pub fn calculate_tick_interval(range: f64) -> f64 {
3338        let magnitude = 10.0_f64.powf(range.log10().floor());
3339        let normalized = range / magnitude;
3340
3341        let nice_interval = if normalized <= 1.0 {
3342            0.2
3343        } else if normalized <= 2.0 {
3344            0.5
3345        } else if normalized <= 5.0 {
3346            1.0
3347        } else {
3348            2.0
3349        };
3350
3351        nice_interval * magnitude
3352    }
3353
3354    /// Format a tick label value for display
3355    pub fn format_tick_label(value: f64) -> String {
3356        fn trim_fixed(mut s: String) -> String {
3357            if s.contains('.') {
3358                while s.ends_with('0') {
3359                    s.pop();
3360                }
3361                if s.ends_with('.') {
3362                    s.pop();
3363                }
3364            }
3365            if s == "-0" {
3366                "0".to_string()
3367            } else {
3368                s
3369            }
3370        }
3371
3372        if value.abs() < 0.001 {
3373            "0".to_string()
3374        } else if value.abs() >= 1000.0 || value.fract().abs() < 0.0005 {
3375            format!("{value:.0}")
3376        } else if value.abs() < 0.1 {
3377            trim_fixed(format!("{value:.3}"))
3378        } else if value.abs() < 10.0 {
3379            trim_fixed(format!("{value:.2}"))
3380        } else {
3381            trim_fixed(format!("{value:.1}"))
3382        }
3383    }
3384
3385    /// Generate grid lines for plotting
3386    pub fn generate_grid_lines(
3387        bounds: (f64, f64, f64, f64),
3388        plot_rect: (f32, f32, f32, f32), // (left, right, bottom, top)
3389    ) -> Vec<(f32, f32, f32, f32)> {
3390        // Vector of (x1, y1, x2, y2) line segments
3391        let (x_min, x_max, y_min, y_max) = bounds;
3392        let (left, right, bottom, top) = plot_rect;
3393
3394        let mut lines = Vec::new();
3395
3396        // X-axis grid lines
3397        let x_range = x_max - x_min;
3398        let x_interval = calculate_tick_interval(x_range);
3399        let mut x_val = (x_min / x_interval).ceil() * x_interval;
3400
3401        while x_val <= x_max {
3402            let x_screen = left + ((x_val - x_min) / x_range) as f32 * (right - left);
3403            lines.push((x_screen, bottom, x_screen, top));
3404            x_val += x_interval;
3405        }
3406
3407        // Y-axis grid lines
3408        let y_range = y_max - y_min;
3409        let y_interval = calculate_tick_interval(y_range);
3410        let mut y_val = (y_min / y_interval).ceil() * y_interval;
3411
3412        while y_val <= y_max {
3413            let y_screen = bottom + ((y_val - y_min) / y_range) as f32 * (top - bottom);
3414            lines.push((left, y_screen, right, y_screen));
3415            y_val += y_interval;
3416        }
3417
3418        lines
3419    }
3420}