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