Skip to main content

runmat_plot/core/
plot_renderer.rs

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