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