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