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