From c637599fa7542a735542a6140a99427172330481 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Fri, 4 Sep 2026 22:13:09 +0800 Subject: [PATCH] perf(render): optimize workspace navigation rendering Bound interactive line and contour geometry by physical screen pixels, then restore full detail after 200 ms of idle time. Update plot viewports in place and add observable render statistics plus a release-only multi-page NMR benchmark. --- crates/app/src/ui/canvas/board.rs | 8 + crates/app/src/ui/canvas/board_tests.rs | 4 + crates/app/src/ui/canvas/image_painting.rs | 4 +- crates/app/src/ui/canvas/mod.rs | 4 + crates/app/src/ui/canvas/navigation.rs | 18 +- crates/app/src/ui/canvas/render_detail.rs | 60 ++++ .../src/ui/canvas/workspace_render_bench.rs | 149 +++++++++ crates/core/src/state/content.rs | 1 + crates/core/src/state/plot_object.rs | 80 +++++ crates/render/src/emf/tests.rs | 1 + crates/render/src/lib.rs | 8 + crates/render/src/screen.rs | 235 ++++++++------- crates/render/src/screen_contour_cache.rs | 130 ++++++++ crates/render/src/screen_contours.rs | 56 ++++ crates/render/src/screen_lod.rs | 285 ++++++++++++++++++ crates/render/src/screen_stats.rs | 58 ++-- crates/render/src/screen_tests.rs | 181 ++++++++--- crates/render/src/svg/document.rs | 2 + 18 files changed, 1101 insertions(+), 183 deletions(-) create mode 100644 crates/app/src/ui/canvas/render_detail.rs create mode 100644 crates/app/src/ui/canvas/workspace_render_bench.rs create mode 100644 crates/render/src/screen_contour_cache.rs create mode 100644 crates/render/src/screen_contours.rs create mode 100644 crates/render/src/screen_lod.rs diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index a53e00f..b5312a9 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -24,6 +24,7 @@ pub(crate) fn request_board_fit(app: &mut PlotxApp, ctx: &egui::Context, frame: }; app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Frame(frame)); seed_board_fit_springs(app, ctx); + mark_workspace_navigation(ctx, ctx.input(|input| input.time)); } /// Consume the transient core request once UI animation services are available. @@ -33,12 +34,14 @@ pub(crate) fn consume_board_reveal(app: &mut PlotxApp, ctx: &egui::Context) { { app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Frame(frame)); seed_board_fit_springs(app, ctx); + mark_workspace_navigation(ctx, ctx.input(|input| input.time)); } } pub(crate) fn request_board_fit_region(app: &mut PlotxApp, ctx: &egui::Context, bbox: [f32; 4]) { app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Region(bbox)); seed_board_fit_springs(app, ctx); + mark_workspace_navigation(ctx, ctx.input(|input| input.time)); } pub(crate) fn request_board_fit_viewport( @@ -49,6 +52,7 @@ pub(crate) fn request_board_fit_viewport( ) { app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Viewport { zoom, world_center }); seed_board_fit_springs(app, ctx); + mark_workspace_navigation(ctx, ctx.input(|input| input.time)); } /// Hand the board viewport to the user for the lifetime of a direct-manipulation @@ -162,6 +166,7 @@ pub(crate) fn drive_board_fit( }; let ctx = ui.ctx(); let dt = ui.input(|i| i.stable_dt); + let before = app.session.board; app.session.board.zoom = crate::ui::switcher::animate_spring(ctx, egui::Id::new(FIT_ZOOM_ID), target.zoom, dt); app.session.board.world_center[0] = crate::ui::switcher::animate_spring( @@ -176,6 +181,9 @@ pub(crate) fn drive_board_fit( target.world_center[1], dt, ); + if app.session.board != before { + mark_workspace_navigation(ctx, ui.input(|input| input.time)); + } } fn fit_bbox_around_occluders( diff --git a/crates/app/src/ui/canvas/board_tests.rs b/crates/app/src/ui/canvas/board_tests.rs index 5126163..a26076d 100644 --- a/crates/app/src/ui/canvas/board_tests.rs +++ b/crates/app/src/ui/canvas/board_tests.rs @@ -120,6 +120,10 @@ fn request_board_fit_viewport_targets_exact_camera() { let mut app = app_with_pages(&[[0.0, 0.0]]); let ctx = egui::Context::default(); request_board_fit_viewport(&mut app, &ctx, 2.5, [30.0, -40.0]); + assert_eq!( + workspace_render_detail(&ctx, 0.0), + plotx_render::screen::ScreenRenderDetail::Interactive + ); assert_eq!( app.session.viewport_mode, ViewportMode::Fit(BoardFitTarget::Viewport { diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index 284f9ff..7d39544 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -69,7 +69,8 @@ pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte }; let zoom = app.session.board.zoom; let bp = canvas.board_pos; - plotx_render::screen::paint_document_for_editor( + let detail = workspace_render_detail(painter.ctx(), painter.ctx().input(|input| input.time)); + plotx_render::screen::paint_document_for_editor_with_detail( painter, PlotRect::new(rect.left(), rect.top(), rect.width(), rect.height()), &document, @@ -80,6 +81,7 @@ pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte rect.height() * 0.5 + (bp[1] - app.session.board.world_center[1]) * zoom, ], }, + detail, ); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 2c348b3..3c0cd66 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -59,6 +59,7 @@ mod phase; mod readout; mod reference_pick; mod regions; +mod render_detail; mod slices; mod snap; mod symmetry; @@ -89,6 +90,7 @@ pub(crate) use phase::*; pub(crate) use readout::*; pub(crate) use reference_pick::*; pub(crate) use regions::*; +use render_detail::*; pub(crate) use selection_painting::*; pub(crate) use slices::*; pub(crate) use snap::*; @@ -113,6 +115,8 @@ fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { #[cfg(test)] #[path = "mod_tests.rs"] mod tests; +#[cfg(test)] +mod workspace_render_bench; #[derive(Clone, Copy)] pub(crate) enum CanvasInteractionClearScope { diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index 1cb3a08..6a0041b 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -210,6 +210,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, } Some(TrackpadNavigationTarget::Board) | None => { pan_board_view(app, delta); + mark_workspace_navigation(ui.ctx(), now); ui.ctx().request_repaint(); } } @@ -237,6 +238,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, None => { app.session.viewport_mode = ViewportMode::Manual; zoom_board_view(app, rect, p, zoom_delta); + mark_workspace_navigation(ui.ctx(), now); ui.ctx().request_repaint(); } } @@ -265,6 +267,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, let factor = (amount * WHEEL_ZOOM_SPEED).exp(); app.session.viewport_mode = ViewportMode::Manual; zoom_board_view(app, rect, p, factor); + mark_workspace_navigation(ui.ctx(), now); ui.ctx().request_repaint(); true } @@ -320,6 +323,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, } if delta != Vec2::ZERO { pan_board_view(app, delta); + mark_workspace_navigation(ui.ctx(), now); } ui.ctx().request_repaint(); return true; @@ -779,16 +783,10 @@ pub(crate) fn zoom_plot_viewport( else { return; }; - let fig = plot_object.figure().clone(); - if zoom_x { - let anchor = screen_to_x(p.x, plot, fig.x.min, fig.x.span(), fig.x.reversed); - plot_object.viewport.zoom_x(&fig, anchor, scale); - } - if zoom_y { - let anchor = screen_to_y(p.y, plot, fig.y.min, fig.y.span(), fig.y.reversed); - plot_object.viewport.zoom_y(anchor, scale); - } - plot_object.apply_viewport(); + let fig = plot_object.figure(); + let x_anchor = zoom_x.then(|| screen_to_x(p.x, plot, fig.x.min, fig.x.span(), fig.x.reversed)); + let y_anchor = zoom_y.then(|| screen_to_y(p.y, plot, fig.y.min, fig.y.span(), fig.y.reversed)); + plot_object.zoom_viewport_around(x_anchor, y_anchor, scale); app.sync_linked_x_viewports(ci, object_id); app.mark_document_dirty(); ui.ctx() diff --git a/crates/app/src/ui/canvas/render_detail.rs b/crates/app/src/ui/canvas/render_detail.rs new file mode 100644 index 0000000..2541e0a --- /dev/null +++ b/crates/app/src/ui/canvas/render_detail.rs @@ -0,0 +1,60 @@ +use plotx_render::screen::ScreenRenderDetail; +use std::time::Duration; + +const STATE_ID: &str = "plotx.workspace_interactive_render"; +const INTERACTIVE_SECONDS: f64 = 0.2; + +#[derive(Clone, Copy, Default)] +struct InteractiveRenderState { + until: f64, +} + +pub(super) fn mark_workspace_navigation(ctx: &egui::Context, now: f64) { + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new(STATE_ID), + InteractiveRenderState { + until: now + INTERACTIVE_SECONDS, + }, + ); + }); + ctx.request_repaint_after(Duration::from_millis(200)); +} + +pub(super) fn workspace_render_detail(ctx: &egui::Context, now: f64) -> ScreenRenderDetail { + let id = egui::Id::new(STATE_ID); + let state = ctx.data_mut(|data| data.get_temp::(id)); + let Some(state) = state else { + return ScreenRenderDetail::Full; + }; + if now < state.until { + ctx.request_repaint_after(Duration::from_secs_f64(state.until - now)); + ScreenRenderDetail::Interactive + } else { + ctx.data_mut(|data| data.remove_temp::(id)); + ScreenRenderDetail::Full + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_navigation_detail_expires_after_quiet_period() { + let ctx = egui::Context::default(); + assert_eq!( + workspace_render_detail(&ctx, 10.0), + ScreenRenderDetail::Full + ); + mark_workspace_navigation(&ctx, 10.0); + assert_eq!( + workspace_render_detail(&ctx, 10.199), + ScreenRenderDetail::Interactive + ); + assert_eq!( + workspace_render_detail(&ctx, 10.2), + ScreenRenderDetail::Full + ); + } +} diff --git a/crates/app/src/ui/canvas/workspace_render_bench.rs b/crates/app/src/ui/canvas/workspace_render_bench.rs new file mode 100644 index 0000000..7bf5b3f --- /dev/null +++ b/crates/app/src/ui/canvas/workspace_render_bench.rs @@ -0,0 +1,149 @@ +use plotx_core::state::{PlotxApp, build_render_document}; +use plotx_render::screen::{RenderStats, ScreenRenderDetail}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +const SCREEN_WIDTH: f32 = 1_600.0; +const SCREEN_HEIGHT: f32 = 1_000.0; +const WARMUP_FRAMES: usize = 3; +const MEASURED_FRAMES: usize = 30; + +#[test] +#[ignore = "requires PLOTX_BENCH_NMR_DIR and a release build"] +fn bench_multi_page_nmr_workspace_navigation() { + let Ok(directory) = std::env::var("PLOTX_BENCH_NMR_DIR") else { + println!("PLOTX_BENCH_NMR_DIR not set; skipping benchmark"); + return; + }; + let paths = sorted_jdf_paths(Path::new(&directory)); + assert!( + !paths.is_empty(), + "benchmark directory contains no JDF files" + ); + + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + for path in &paths { + let before = app.doc.datasets.len(); + app.load_from(path); + assert_eq!( + app.doc.datasets.len(), + before + 1, + "failed to load {}: {}", + path.display(), + app.session.status + ); + } + wait_for_compute(&mut app); + + println!( + "loaded {} JDF file(s) into {} workspace page(s)", + paths.len(), + app.doc.canvases.len() + ); + run_detail(&app, ScreenRenderDetail::Full); + run_detail(&app, ScreenRenderDetail::Interactive); +} + +fn sorted_jdf_paths(directory: &Path) -> Vec { + let mut paths = std::fs::read_dir(directory) + .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())) + .map(|entry| entry.expect("read benchmark directory entry").path()) + .filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("jdf")) + }) + .collect::>(); + paths.sort(); + paths +} + +fn wait_for_compute(app: &mut PlotxApp) { + let start = Instant::now(); + while app.compute_busy() { + assert!( + start.elapsed() < Duration::from_secs(600), + "background processing did not finish within ten minutes" + ); + app.poll_compute(); + std::thread::sleep(Duration::from_millis(5)); + } + // The completion that rebuilds a figure may enqueue its contour geometry. + while app.poll_compute() { + assert!( + start.elapsed() < Duration::from_secs(600), + "contour generation did not finish within ten minutes" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +fn run_detail(app: &PlotxApp, detail: ScreenRenderDetail) { + let ctx = egui::Context::default(); + let mut samples = Vec::with_capacity(MEASURED_FRAMES); + let mut total_stats = RenderStats::default(); + for frame in 0..(WARMUP_FRAMES + MEASURED_FRAMES) { + let pan = [frame as f32 * 3.0, frame as f32 * -2.0]; + let mut frame_stats = RenderStats::default(); + let start = Instant::now(); + let output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(SCREEN_WIDTH, SCREEN_HEIGHT), + )), + ..Default::default() + }, + |ui| { + let screen = plotx_render::Rect::new(0.0, 0.0, SCREEN_WIDTH, SCREEN_HEIGHT); + for canvas in &app.doc.canvases { + let document = build_render_document(canvas); + plotx_render::screen::paint_document_for_editor_with_detail_and_stats( + ui.painter(), + screen, + &document, + plotx_render::DocumentViewport { + zoom: 1.0, + pan: [canvas.board_pos[0] + pan[0], canvas.board_pos[1] + pan[1]], + }, + detail, + Some(&mut frame_stats), + ); + } + }, + ); + let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); + std::hint::black_box(primitives.len()); + let elapsed = start.elapsed(); + if frame >= WARMUP_FRAMES { + samples.push(elapsed); + add_stats(&mut total_stats, &frame_stats); + } + } + samples.sort_unstable(); + let median = samples[samples.len() / 2]; + let p95 = samples[(samples.len() * 95).div_ceil(100).saturating_sub(1)]; + println!( + "{detail:?}: median={median:?} p95={p95:?} documents={} line visited/submitted={}/{} contour scanned/visited/submitted={}/{}/{}", + total_stats.documents_painted, + total_stats.line_source_points_visited, + total_stats.line_points_submitted, + total_stats.contour_source_segments_scanned, + total_stats.contour_segments_visited, + total_stats.contour_segments_submitted, + ); +} + +fn add_stats(total: &mut RenderStats, frame: &RenderStats) { + total.documents_painted += frame.documents_painted; + total.full_documents_painted += frame.full_documents_painted; + total.interactive_documents_painted += frame.interactive_documents_painted; + total.line_series_visited += frame.line_series_visited; + total.line_source_points_scanned += frame.line_source_points_scanned; + total.line_points_emitted += frame.line_points_emitted; + total.line_source_points_visited += frame.line_source_points_visited; + total.line_points_submitted += frame.line_points_submitted; + total.contour_source_segments_scanned += frame.contour_source_segments_scanned; + total.contour_segments_visited += frame.contour_segments_visited; + total.contour_segments_submitted += frame.contour_segments_submitted; +} diff --git a/crates/core/src/state/content.rs b/crates/core/src/state/content.rs index 31602d5..6a476fb 100644 --- a/crates/core/src/state/content.rs +++ b/crates/core/src/state/content.rs @@ -172,6 +172,7 @@ pub fn document_item( id: format!("object_{}", object.id), frame: frame.rect(), figure: plot.figure(), + geometry_generation: Some(plot.figure_geometry_generation()), visible, title: None, }), diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index 87860e6..ab098b1 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -3,6 +3,17 @@ use super::{ SeriesId, StackSpec, }; use plotx_figure::{Figure, FigureTypography}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_FIGURE_GEOMETRY_GENERATION: AtomicU64 = AtomicU64::new(1); + +fn next_figure_geometry_generation() -> u64 { + NEXT_FIGURE_GEOMETRY_GENERATION + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("figure geometry generation overflow") +} #[derive(Clone)] pub struct PlotObject { @@ -25,6 +36,9 @@ pub struct PlotObject { /// overrides are applied. Derived property defaults read this artifact. derived_axes: DerivedAxes, figure: Figure, + /// Runtime-only identity for screen geometry caches. Clones retain it because + /// their contour buffers have identical contents; figure rebuilds replace it. + figure_geometry_generation: u64, pub viewport: CanvasViewport, } @@ -52,6 +66,7 @@ impl PlotObject { axis_overrides, derived_axes, figure, + figure_geometry_generation: next_figure_geometry_generation(), viewport, } } @@ -81,6 +96,7 @@ impl PlotObject { axis_overrides, derived_axes, figure, + figure_geometry_generation: next_figure_geometry_generation(), viewport, } } @@ -89,6 +105,10 @@ impl PlotObject { &self.figure } + pub fn figure_geometry_generation(&self) -> u64 { + self.figure_geometry_generation + } + pub fn derived_axes(&self) -> &DerivedAxes { &self.derived_axes } @@ -139,6 +159,7 @@ impl PlotObject { self.derived_axes = DerivedAxes::from_figure(&figure); prepare(self, &mut figure); self.figure = figure; + self.figure_geometry_generation = next_figure_geometry_generation(); } /// Adopt a rebuilt figure whose chart semantics may have changed. @@ -206,6 +227,23 @@ impl PlotObject { self.viewport.apply_to(&mut self.figure); } + /// Zoom the requested axes in place without cloning the materialized figure. + /// Anchors are data coordinates; `None` leaves that axis unchanged. + pub fn zoom_viewport_around( + &mut self, + x_anchor: Option, + y_anchor: Option, + scale: f64, + ) { + if let Some(anchor) = x_anchor { + self.viewport.zoom_x(&self.figure, anchor, scale); + } + if let Some(anchor) = y_anchor { + self.viewport.zoom_y(anchor, scale); + } + self.viewport.apply_to(&mut self.figure); + } + pub(crate) fn apply_axis_overrides(&mut self) { self.axis_overrides.apply_to(&mut self.figure); } @@ -243,3 +281,45 @@ impl PlotObject { self.axis_overrides.y_range.is_some() && figure.y.categories.is_none() } } + +#[cfg(test)] +mod tests { + use super::*; + use plotx_figure::{Axis, Color, Contour, Series}; + + #[test] + fn in_place_zoom_preserves_figure_buffers() { + let mut figure = Figure::new("", Axis::new("x", 0.0, 10.0), Axis::new("y", 0.0, 10.0)); + figure.series.push(Series::line( + "trace", + (0..32) + .map(|index| [index as f64 / 3.1, index as f64]) + .collect(), + )); + figure.contours.push(Contour { + segments: vec![[[0.0, 0.0], [1.0, 1.0]], [[9.0, 1.0], [10.0, 2.0]]], + color: Color::BLACK, + width: 1.0, + }); + let viewport = CanvasViewport::from_figure(&figure); + let mut plot = PlotObject::new( + None, + SeriesId::new(1), + DataBinding { series: Vec::new() }, + ChartSpec::default(), + StackSpec::default(), + AxisProjections::default(), + AxisOverrides::default(), + figure, + viewport, + ); + let line_buffer = plot.figure().series[0].points.as_ptr(); + let contour_buffer = plot.figure().contours[0].segments.as_ptr(); + + plot.zoom_viewport_around(Some(5.0), Some(5.0), 0.8); + plot.zoom_viewport_around(Some(4.0), None, 0.8); + + assert_eq!(plot.figure().series[0].points.as_ptr(), line_buffer); + assert_eq!(plot.figure().contours[0].segments.as_ptr(), contour_buffer); + } +} diff --git a/crates/render/src/emf/tests.rs b/crates/render/src/emf/tests.rs index 71cf56e..2a95221 100644 --- a/crates/render/src/emf/tests.rs +++ b/crates/render/src/emf/tests.rs @@ -12,6 +12,7 @@ fn demo_document(fig: &Figure) -> Document<'_> { id: "obj".into(), frame: Rect::new(20.0, 20.0, 360.0, 260.0), figure: fig, + geometry_generation: None, visible: true, title: None, })], diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b7534f8..1413c82 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -5,6 +5,12 @@ pub mod contour; pub mod integral; #[cfg(feature = "screen")] +mod screen_contour_cache; +#[cfg(feature = "screen")] +mod screen_contours; +#[cfg(feature = "screen")] +mod screen_lod; +#[cfg(feature = "screen")] mod screen_stats; pub mod svg; mod ticks; @@ -100,6 +106,8 @@ pub struct DocumentObject<'a> { pub id: String, pub frame: Rect, pub figure: &'a Figure, + /// Runtime identity for editor-only geometry caches. Exporters ignore it. + pub geometry_generation: Option, pub visible: bool, pub title: Option, } diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 0e54f7b..1e7194d 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -1,22 +1,27 @@ pub use crate::screen_stats::RenderStats; -use crate::screen_stats::visible_source_len; use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, DocumentViewport, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, OverlayText, Projector, Rect, TICK_LABEL_PAD, TICK_LENGTH, arrow_head, axis_layout, error_bar_segments, heatmap_cells, integral, polygon_outline, projection_points, + screen_contours::paint_contours, + screen_lod::{line_columns, screen_line_points}, }; use egui::{Align2, Color32, FontId, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2}; use plotx_figure::{AxisFrame, AxisTrace, Color, Figure, SeriesKind}; -use std::borrow::Cow; mod color_scale; mod legend; mod sticks; -/// Bounds on a pooled line's column grid. -const MIN_LINE_COLUMNS: usize = 2_048; -const MAX_LINE_COLUMNS: usize = 16_384; +/// Screen geometry fidelity. Interactive mode bounds costly geometry by pixels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ScreenRenderDetail { + #[default] + Full, + /// Temporarily omit sub-pixel detail during workspace camera movement. + Interactive, +} fn col(c: Color) -> Color32 { Color32::from_rgb(c.r, c.g, c.b) @@ -42,6 +47,30 @@ pub fn paint_with_stats( outer: Rect, fig: &Figure, scale: f32, + stats: Option<&mut RenderStats>, +) { + paint_with_detail_and_stats(painter, outer, fig, scale, ScreenRenderDetail::Full, stats); +} + +/// Paint one figure with an explicit screen detail level and optional counters. +pub fn paint_with_detail_and_stats( + painter: &egui::Painter, + outer: Rect, + fig: &Figure, + scale: f32, + detail: ScreenRenderDetail, + stats: Option<&mut RenderStats>, +) { + paint_with_detail_and_stats_impl(painter, outer, fig, scale, detail, None, stats); +} + +fn paint_with_detail_and_stats_impl( + painter: &egui::Painter, + outer: Rect, + fig: &Figure, + scale: f32, + detail: ScreenRenderDetail, + geometry_generation: Option, mut stats: Option<&mut RenderStats>, ) { let ty = fig.typography; @@ -276,14 +305,17 @@ pub fn paint_with_stats( crate::screen_annotations::paint(&clipped, fig, &proj, plot, scale); - for contour in &fig.contours { - let stroke = Stroke::new(contour.width * scale, col(contour.color)); - for seg in &contour.segments { - let (ax, ay) = proj.project(seg[0]); - let (bx, by) = proj.project(seg[1]); - clipped.line_segment([to_pos(ax, ay), to_pos(bx, by)], stroke); - } - } + paint_contours( + painter, + &clipped, + fig, + &proj, + plot, + scale, + detail, + geometry_generation, + &mut stats, + ); paint_error_bars(&clipped, &proj, fig, scale, false); @@ -296,7 +328,7 @@ pub fn paint_with_stats( if let Some(stats) = stats.as_deref_mut() { stats.line_series_visited += 1; } - let columns = line_columns(plot.width, painter.ctx().pixels_per_point()); + let columns = line_columns(detail, plot.width, painter.ctx().pixels_per_point()); let visible = screen_line_points( &series.points, fig.x.min.min(fig.x.max), @@ -304,16 +336,14 @@ pub fn paint_with_stats( columns, ); if let Some(stats) = stats.as_deref_mut() { - if matches!(visible, Cow::Owned(_)) { - stats.line_source_points_scanned += visible_source_len( - &series.points, - fig.x.min.min(fig.x.max), - fig.x.min.max(fig.x.max), - ); - } - stats.line_points_emitted += visible.len(); + stats.record_line( + visible.source_points_visited, + visible.points.len(), + visible.pooled, + ); } let pts: Vec = visible + .points .iter() .map(|p| { let (px, py) = proj.project(*p); @@ -407,89 +437,6 @@ fn paint_error_bars( } } -/// Two columns per physical pixel, so a pooled bucket stays sub-pixel. -/// `plot_width` is in egui points; without the conversion a HiDPI screen would -/// silently render at half its resolution. -fn line_columns(plot_width: f32, pixels_per_point: f32) -> usize { - let physical = (plot_width * pixels_per_point.max(1.0)).max(1.0) as usize; - physical - .saturating_mul(2) - .clamp(MIN_LINE_COLUMNS, MAX_LINE_COLUMNS) -} - -/// Clip to the viewport, then pool dense lines into min/max envelope buckets. -fn screen_line_points( - points: &[[f64; 2]], - x_min: f64, - x_max: f64, - columns: usize, -) -> Cow<'_, [[f64; 2]]> { - // Keep one neighbour on each side for continuity. Handles ascending traces - // (time) and descending ones (NMR ppm); a flat or non-monotonic series keeps - // its whole extent, which is safe but less selective. - let first_x = points.first().map(|p| p[0]); - let last_x = points.last().map(|p| p[0]); - let (start, end) = match (first_x, last_x) { - (Some(first), Some(last)) if first < last => { - let start = points - .partition_point(|point| point[0] < x_min) - .saturating_sub(1); - let end = points - .partition_point(|point| point[0] <= x_max) - .saturating_add(1) - .min(points.len()); - (start.min(end), end) - } - (Some(first), Some(last)) if first > last => { - let start = points - .partition_point(|point| point[0] > x_max) - .saturating_sub(1); - let end = points - .partition_point(|point| point[0] >= x_min) - .saturating_add(1) - .min(points.len()); - (start.min(end), end) - } - _ => (0, points.len()), - }; - let visible = &points[start..end]; - if visible.len() <= columns.saturating_mul(2) { - return Cow::Borrowed(visible); - } - - let bucket_count = columns.max(1); - let bucket_size = visible.len().div_ceil(bucket_count); - let mut pooled = Vec::with_capacity(bucket_count * 2 + 2); - pooled.push(visible[0]); - for bucket in visible.chunks(bucket_size) { - let mut min_index = 0; - let mut max_index = 0; - for index in 1..bucket.len() { - if bucket[index][1] < bucket[min_index][1] { - min_index = index; - } - if bucket[index][1] > bucket[max_index][1] { - max_index = index; - } - } - if min_index <= max_index { - pooled.push(bucket[min_index]); - if max_index != min_index { - pooled.push(bucket[max_index]); - } - } else { - pooled.push(bucket[max_index]); - pooled.push(bucket[min_index]); - } - } - if let Some(last) = visible.last() - && pooled.last() != Some(last) - { - pooled.push(*last); - } - Cow::Owned(pooled) -} - fn paint_projection( painter: &egui::Painter, fig: &Figure, @@ -542,7 +489,15 @@ pub fn paint_document( document: &Document<'_>, viewport: DocumentViewport, ) { - paint_document_impl(painter, screen, document, viewport, true, None); + paint_document_impl( + painter, + screen, + document, + viewport, + true, + ScreenRenderDetail::Full, + None, + ); } /// Paint the editable board representation. The page background stays bounded, @@ -554,7 +509,38 @@ pub fn paint_document_for_editor( document: &Document<'_>, viewport: DocumentViewport, ) { - paint_document_impl(painter, screen, document, viewport, false, None); + paint_document_for_editor_with_detail( + painter, + screen, + document, + viewport, + ScreenRenderDetail::Full, + ); +} + +/// Paint an editable document using the requested screen detail level. +pub fn paint_document_for_editor_with_detail( + painter: &egui::Painter, + screen: Rect, + document: &Document<'_>, + viewport: DocumentViewport, + detail: ScreenRenderDetail, +) { + paint_document_for_editor_with_detail_and_stats( + painter, screen, document, viewport, detail, None, + ); +} + +/// Paint an editable document with explicit detail and optional counters. +pub fn paint_document_for_editor_with_detail_and_stats( + painter: &egui::Painter, + screen: Rect, + document: &Document<'_>, + viewport: DocumentViewport, + detail: ScreenRenderDetail, + stats: Option<&mut RenderStats>, +) { + paint_document_impl(painter, screen, document, viewport, false, detail, stats); } pub fn paint_document_with_stats( @@ -564,7 +550,15 @@ pub fn paint_document_with_stats( viewport: DocumentViewport, stats: Option<&mut RenderStats>, ) { - paint_document_impl(painter, screen, document, viewport, true, stats); + paint_document_impl( + painter, + screen, + document, + viewport, + true, + ScreenRenderDetail::Full, + stats, + ); } fn paint_document_impl( @@ -573,10 +567,11 @@ fn paint_document_impl( document: &Document<'_>, viewport: DocumentViewport, clip_items_to_page: bool, + detail: ScreenRenderDetail, mut stats: Option<&mut RenderStats>, ) { if let Some(stats) = stats.as_deref_mut() { - stats.documents_painted += 1; + stats.record_document(detail); } let page = Rect::new( screen.left + viewport.pan[0], @@ -598,9 +593,14 @@ fn paint_document_impl( for item in &document.items { match item { - DocumentItem::Plot(object) => { - paint_document_object(&item_painter, page, object, viewport, stats.as_deref_mut()) - } + DocumentItem::Plot(object) => paint_document_object( + &item_painter, + page, + object, + viewport, + detail, + stats.as_deref_mut(), + ), DocumentItem::Overlay(overlay) => { paint_document_overlay(&item_painter, page, overlay, viewport) } @@ -644,6 +644,7 @@ fn paint_document_object( page: Rect, object: &DocumentObject, viewport: DocumentViewport, + detail: ScreenRenderDetail, stats: Option<&mut RenderStats>, ) { if !object.visible { @@ -655,7 +656,15 @@ fn paint_document_object( object.frame.width * viewport.zoom, object.frame.height * viewport.zoom, ); - paint_with_stats(painter, frame, object.figure, viewport.zoom, stats); + paint_with_detail_and_stats_impl( + painter, + frame, + object.figure, + viewport.zoom, + detail, + object.geometry_generation, + stats, + ); if let Some(title) = &object.title { let pos = Pos2::new( frame.left + title.position[0] * viewport.zoom, diff --git a/crates/render/src/screen_contour_cache.rs b/crates/render/src/screen_contour_cache.rs new file mode 100644 index 0000000..876a470 --- /dev/null +++ b/crates/render/src/screen_contour_cache.rs @@ -0,0 +1,130 @@ +use crate::screen::ScreenRenderDetail; +use crate::screen_lod::{ContourSegment, ScreenContourSegments, prepare_contour_lod}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard}; + +const CACHE_STATE_ID: &str = "plotx.screen_contour_lod_cache"; +const MAX_CACHE_ENTRIES: usize = 128; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) struct ContourCacheKey { + geometry_generation: u64, + contour_index: usize, + source_len: usize, + viewport: [u64; 4], +} + +impl ContourCacheKey { + pub fn new( + geometry_generation: u64, + contour_index: usize, + source_len: usize, + viewport: [f64; 4], + ) -> Self { + let [x_min, x_max, y_min, y_max] = viewport; + Self { + geometry_generation, + contour_index, + source_len, + viewport: [ + x_min.min(x_max).to_bits(), + x_min.max(x_max).to_bits(), + y_min.min(y_max).to_bits(), + y_min.max(y_max).to_bits(), + ], + } + } +} + +#[derive(Clone)] +struct CacheEntry { + lod: Arc<[usize]>, + last_used: u64, +} + +#[derive(Default)] +struct ContourCache { + entries: HashMap, + clock: u64, +} + +impl ContourCache { + fn get(&mut self, key: ContourCacheKey) -> Option> { + self.clock = self.clock.wrapping_add(1); + let entry = self.entries.get_mut(&key)?; + entry.last_used = self.clock; + Some(Arc::clone(&entry.lod)) + } + + fn insert(&mut self, key: ContourCacheKey, lod: Arc<[usize]>) { + self.clock = self.clock.wrapping_add(1); + if self.entries.len() >= MAX_CACHE_ENTRIES + && !self.entries.contains_key(&key) + && let Some(oldest) = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(key, _)| *key) + { + self.entries.remove(&oldest); + } + self.entries.insert( + key, + CacheEntry { + lod, + last_used: self.clock, + }, + ); + } +} + +type SharedContourCache = Arc>; + +pub(super) fn screen_contour_segments_cached<'a>( + ctx: &egui::Context, + cache_key: Option, + segments: &'a [ContourSegment], + detail: ScreenRenderDetail, + viewport: [f64; 4], + budget: usize, +) -> ScreenContourSegments<'a> { + let Some(key) = cache_key else { + return crate::screen_lod::screen_contour_segments(segments, detail, viewport, budget); + }; + let cache = shared_cache(ctx); + if let Some(lod) = lock_cache(&cache).get(key) { + return match detail { + ScreenRenderDetail::Full => ScreenContourSegments::full(segments, 0), + ScreenRenderDetail::Interactive => { + ScreenContourSegments::from_lod(segments, lod, budget, 0) + } + }; + } + + let lod = prepare_contour_lod(segments, viewport); + lock_cache(&cache).insert(key, Arc::clone(&lod)); + match detail { + ScreenRenderDetail::Full => ScreenContourSegments::full(segments, segments.len()), + ScreenRenderDetail::Interactive => { + ScreenContourSegments::from_lod(segments, lod, budget, segments.len()) + } + } +} + +fn shared_cache(ctx: &egui::Context) -> SharedContourCache { + ctx.data_mut(|data| { + let id = egui::Id::new(CACHE_STATE_ID); + if let Some(cache) = data.get_temp::(id) { + return cache; + } + let cache = SharedContourCache::default(); + data.insert_temp(id, Arc::clone(&cache)); + cache + }) +} + +fn lock_cache(cache: &SharedContourCache) -> MutexGuard<'_, ContourCache> { + cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/crates/render/src/screen_contours.rs b/crates/render/src/screen_contours.rs new file mode 100644 index 0000000..5a80f62 --- /dev/null +++ b/crates/render/src/screen_contours.rs @@ -0,0 +1,56 @@ +use crate::screen::{RenderStats, ScreenRenderDetail}; +use crate::screen_contour_cache::{ContourCacheKey, screen_contour_segments_cached}; +use crate::screen_lod::contour_segment_budget; +use crate::{Projector, Rect}; +use egui::{Color32, Pos2, Stroke}; +use plotx_figure::Figure; + +#[allow(clippy::too_many_arguments)] +pub(super) fn paint_contours( + painter: &egui::Painter, + clipped: &egui::Painter, + fig: &Figure, + proj: &Projector<'_>, + plot: Rect, + scale: f32, + detail: ScreenRenderDetail, + geometry_generation: Option, + stats: &mut Option<&mut RenderStats>, +) { + let viewport = [fig.x.min, fig.x.max, fig.y.min, fig.y.max]; + for (contour_index, contour) in fig.contours.iter().enumerate() { + let stroke = Stroke::new( + contour.width * scale, + Color32::from_rgb(contour.color.r, contour.color.g, contour.color.b), + ); + let budget = contour_segment_budget( + detail, + plot.width, + plot.height, + painter.ctx().pixels_per_point(), + ); + let cache_key = geometry_generation.map(|generation| { + ContourCacheKey::new(generation, contour_index, contour.segments.len(), viewport) + }); + let segments = screen_contour_segments_cached( + painter.ctx(), + cache_key, + &contour.segments, + detail, + viewport, + budget, + ); + if let Some(stats) = stats.as_deref_mut() { + stats.record_contour( + segments.source_segments_scanned(), + segments.len(), + segments.len(), + ); + } + for segment in segments.iter() { + let (ax, ay) = proj.project(segment[0]); + let (bx, by) = proj.project(segment[1]); + clipped.line_segment([Pos2::new(ax, ay), Pos2::new(bx, by)], stroke); + } + } +} diff --git a/crates/render/src/screen_lod.rs b/crates/render/src/screen_lod.rs new file mode 100644 index 0000000..d67bf08 --- /dev/null +++ b/crates/render/src/screen_lod.rs @@ -0,0 +1,285 @@ +use crate::screen::ScreenRenderDetail; +use std::borrow::Cow; +use std::sync::Arc; + +pub(super) const FULL_MIN_LINE_COLUMNS: usize = 2_048; +pub(super) const FULL_MAX_LINE_COLUMNS: usize = 16_384; +pub(super) const INTERACTIVE_MIN_LINE_COLUMNS: usize = 64; +pub(super) const INTERACTIVE_MAX_LINE_COLUMNS: usize = 2_048; +const INTERACTIVE_MIN_CONTOUR_SEGMENTS: usize = 512; +pub(super) const INTERACTIVE_MAX_CONTOUR_SEGMENTS: usize = 16_384; + +pub(super) struct ScreenLinePoints<'a> { + pub points: Cow<'a, [[f64; 2]]>, + pub source_points_visited: usize, + pub pooled: bool, +} + +pub(super) type ContourSegment = [[f64; 2]; 2]; + +pub(super) struct ScreenContourSegments<'a> { + source: &'a [ContourSegment], + selected: Option>, + budget: usize, + source_segments_scanned: usize, +} + +impl<'a> ScreenContourSegments<'a> { + pub fn full(source: &'a [ContourSegment], source_segments_scanned: usize) -> Self { + Self { + source, + selected: None, + budget: usize::MAX, + source_segments_scanned, + } + } + + pub fn from_lod( + source: &'a [ContourSegment], + selected: Arc<[usize]>, + budget: usize, + source_segments_scanned: usize, + ) -> Self { + Self { + source, + selected: Some(selected), + budget, + source_segments_scanned, + } + } + + pub fn source_segments_scanned(&self) -> usize { + self.source_segments_scanned + } + + pub fn len(&self) -> usize { + self.selected + .as_ref() + .map_or(self.source.len(), |selected| { + selected.len().min(self.budget) + }) + } + + pub fn iter(&'a self) -> impl Iterator + 'a { + (0..self.len()).map(|slot| { + let index = self.selected.as_ref().map_or(slot, |selected| { + if selected.len() <= self.budget { + selected[slot] + } else { + selected[sampled_segment_index(selected.len(), self.budget, slot)] + } + }); + &self.source[index] + }) + } +} + +pub(super) fn line_columns( + detail: ScreenRenderDetail, + plot_width: f32, + pixels_per_point: f32, +) -> usize { + match detail { + ScreenRenderDetail::Full => ((plot_width * pixels_per_point.max(1.0)).max(1.0) as usize) + .saturating_mul(2) + .clamp(FULL_MIN_LINE_COLUMNS, FULL_MAX_LINE_COLUMNS), + ScreenRenderDetail::Interactive => { + let scale = valid_pixel_scale(pixels_per_point); + ((plot_width * scale).max(1.0).ceil() as usize) + .clamp(INTERACTIVE_MIN_LINE_COLUMNS, INTERACTIVE_MAX_LINE_COLUMNS) + } + } +} + +/// Clip to the viewport, then pool dense lines into min/max envelope buckets. +pub(super) fn screen_line_points( + points: &[[f64; 2]], + x_min: f64, + x_max: f64, + columns: usize, +) -> ScreenLinePoints<'_> { + // Keep one neighbour on each side for continuity. Handles ascending traces + // (time) and descending ones (NMR ppm); a flat or non-monotonic series keeps + // its whole extent, which is safe but less selective. + let first_x = points.first().map(|p| p[0]); + let last_x = points.last().map(|p| p[0]); + let (start, end) = match (first_x, last_x) { + (Some(first), Some(last)) if first < last => { + let start = points + .partition_point(|point| point[0] < x_min) + .saturating_sub(1); + let end = points + .partition_point(|point| point[0] <= x_max) + .saturating_add(1) + .min(points.len()); + (start.min(end), end) + } + (Some(first), Some(last)) if first > last => { + let start = points + .partition_point(|point| point[0] > x_max) + .saturating_sub(1); + let end = points + .partition_point(|point| point[0] >= x_min) + .saturating_add(1) + .min(points.len()); + (start.min(end), end) + } + _ => (0, points.len()), + }; + let visible = &points[start..end]; + let source_points_visited = visible.len(); + if visible.len() <= columns.saturating_mul(2) { + return ScreenLinePoints { + points: Cow::Borrowed(visible), + source_points_visited, + pooled: false, + }; + } + + let bucket_count = columns.max(1); + let bucket_size = visible.len().div_ceil(bucket_count); + let mut pooled = Vec::with_capacity(bucket_count * 2 + 2); + pooled.push(visible[0]); + for bucket in visible.chunks(bucket_size) { + let mut min_index = 0; + let mut max_index = 0; + for index in 1..bucket.len() { + if bucket[index][1] < bucket[min_index][1] { + min_index = index; + } + if bucket[index][1] > bucket[max_index][1] { + max_index = index; + } + } + if min_index <= max_index { + pooled.push(bucket[min_index]); + if max_index != min_index { + pooled.push(bucket[max_index]); + } + } else { + pooled.push(bucket[max_index]); + pooled.push(bucket[min_index]); + } + } + if let Some(last) = visible.last() + && pooled.last() != Some(last) + { + pooled.push(*last); + } + ScreenLinePoints { + points: Cow::Owned(pooled), + source_points_visited, + pooled: true, + } +} + +pub(super) fn contour_segment_budget( + detail: ScreenRenderDetail, + plot_width: f32, + plot_height: f32, + pixels_per_point: f32, +) -> usize { + if detail == ScreenRenderDetail::Full { + return usize::MAX; + } + let scale = valid_pixel_scale(pixels_per_point); + let physical_area = (plot_width * scale).max(1.0) * (plot_height * scale).max(1.0); + ((physical_area / 8.0) as usize).clamp( + INTERACTIVE_MIN_CONTOUR_SEGMENTS, + INTERACTIVE_MAX_CONTOUR_SEGMENTS, + ) +} + +pub(super) fn screen_contour_segments( + segments: &[ContourSegment], + detail: ScreenRenderDetail, + viewport: [f64; 4], + budget: usize, +) -> ScreenContourSegments<'_> { + if detail == ScreenRenderDetail::Full { + return ScreenContourSegments::full(segments, 0); + } + let selected = prepare_contour_lod(segments, viewport); + ScreenContourSegments::from_lod(segments, selected, budget, segments.len()) +} + +pub(super) fn prepare_contour_lod(segments: &[ContourSegment], viewport: [f64; 4]) -> Arc<[usize]> { + let [x_min, x_max, y_min, y_max] = viewport; + let mut selected = segments + .iter() + .enumerate() + .filter_map(|(index, segment)| { + segment_intersects_viewport(segment, x_min, x_max, y_min, y_max).then_some(index) + }) + .collect::>(); + if selected.len() > INTERACTIVE_MAX_CONTOUR_SEGMENTS { + let source_len = selected.len(); + for slot in 0..INTERACTIVE_MAX_CONTOUR_SEGMENTS { + selected[slot] = + selected[sampled_segment_index(source_len, INTERACTIVE_MAX_CONTOUR_SEGMENTS, slot)]; + } + selected.truncate(INTERACTIVE_MAX_CONTOUR_SEGMENTS); + } + selected.into() +} + +fn segment_intersects_viewport( + segment: &ContourSegment, + x_min: f64, + x_max: f64, + y_min: f64, + y_max: f64, +) -> bool { + let [a, b] = segment; + if ![a[0], a[1], b[0], b[1], x_min, x_max, y_min, y_max] + .into_iter() + .all(f64::is_finite) + { + return false; + } + let (x_min, x_max) = (x_min.min(x_max), x_min.max(x_max)); + let (y_min, y_max) = (y_min.min(y_max), y_min.max(y_max)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let mut enter = 0.0_f64; + let mut leave = 1.0_f64; + for (direction, distance) in [ + (-dx, a[0] - x_min), + (dx, x_max - a[0]), + (-dy, a[1] - y_min), + (dy, y_max - a[1]), + ] { + if direction == 0.0 { + if distance < 0.0 { + return false; + } + continue; + } + let crossing = distance / direction; + if direction < 0.0 { + enter = enter.max(crossing); + } else { + leave = leave.min(crossing); + } + if enter > leave { + return false; + } + } + true +} + +fn valid_pixel_scale(pixels_per_point: f32) -> f32 { + if pixels_per_point.is_finite() && pixels_per_point > 0.0 { + pixels_per_point + } else { + 1.0 + } +} + +/// Select the middle segment from each equal-length source block. Integer +/// boundaries make the selection stable and spread it across the full contour. +pub(super) fn sampled_segment_index(source_len: usize, budget: usize, slot: usize) -> usize { + debug_assert!(budget > 0 && budget < source_len && slot < budget); + let start = slot * source_len / budget; + let end = (slot + 1) * source_len / budget; + start + (end - start) / 2 +} diff --git a/crates/render/src/screen_stats.rs b/crates/render/src/screen_stats.rs index 9804541..c9f14dd 100644 --- a/crates/render/src/screen_stats.rs +++ b/crates/render/src/screen_stats.rs @@ -1,32 +1,44 @@ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct RenderStats { pub documents_painted: usize, + pub full_documents_painted: usize, + pub interactive_documents_painted: usize, pub line_series_visited: usize, - /// Source points in the x-visible slice inspected by line pooling. This is - /// zero when pooling is unnecessary. + /// Source points inspected when a line is dense enough to pool. pub line_source_points_scanned: usize, pub line_points_emitted: usize, + /// Source points in x-visible slices visited by line rendering. + pub line_source_points_visited: usize, + pub line_points_submitted: usize, + /// Source segments tested against a viewport while preparing contour LOD. + pub contour_source_segments_scanned: usize, + pub contour_segments_visited: usize, + pub contour_segments_submitted: usize, } -pub(crate) fn visible_source_len(points: &[[f64; 2]], x_min: f64, x_max: f64) -> usize { - let first_x = points.first().map(|p| p[0]); - let last_x = points.last().map(|p| p[0]); - let (start, end) = match (first_x, last_x) { - (Some(first), Some(last)) if first < last => ( - points.partition_point(|p| p[0] < x_min).saturating_sub(1), - points - .partition_point(|p| p[0] <= x_max) - .saturating_add(1) - .min(points.len()), - ), - (Some(first), Some(last)) if first > last => ( - points.partition_point(|p| p[0] > x_max).saturating_sub(1), - points - .partition_point(|p| p[0] >= x_min) - .saturating_add(1) - .min(points.len()), - ), - _ => (0, points.len()), - }; - end.saturating_sub(start.min(end)) +impl RenderStats { + pub(crate) fn record_document(&mut self, detail: crate::screen::ScreenRenderDetail) { + self.documents_painted += 1; + match detail { + crate::screen::ScreenRenderDetail::Full => self.full_documents_painted += 1, + crate::screen::ScreenRenderDetail::Interactive => { + self.interactive_documents_painted += 1; + } + } + } + + pub(crate) fn record_line(&mut self, source: usize, submitted: usize, pooled: bool) { + self.line_source_points_visited += source; + self.line_points_submitted += submitted; + self.line_points_emitted += submitted; + if pooled { + self.line_source_points_scanned += source; + } + } + + pub(crate) fn record_contour(&mut self, scanned: usize, visited: usize, submitted: usize) { + self.contour_source_segments_scanned += scanned; + self.contour_segments_visited += visited; + self.contour_segments_submitted += submitted; + } } diff --git a/crates/render/src/screen_tests.rs b/crates/render/src/screen_tests.rs index a65c638..f993450 100644 --- a/crates/render/src/screen_tests.rs +++ b/crates/render/src/screen_tests.rs @@ -1,4 +1,8 @@ -use super::{MAX_LINE_COLUMNS, MIN_LINE_COLUMNS, line_columns, screen_line_points}; +use super::ScreenRenderDetail; +use crate::screen_lod::{ + FULL_MAX_LINE_COLUMNS, FULL_MIN_LINE_COLUMNS, contour_segment_budget, line_columns, + screen_contour_segments, screen_line_points, +}; #[test] fn editor_keeps_outside_page_items_visible_while_document_rendering_clips() { @@ -94,8 +98,8 @@ fn long_trace_is_bounded_and_keeps_narrow_extrema() { let mut points: Vec<_> = (0..2_000_000).map(|index| [index as f64, 0.0]).collect(); points[1_234_567][1] = -42.0; let pooled = screen_line_points(&points, 0.0, 2_000_000.0, 2_000); - assert!(pooled.len() <= 4_002); - assert!(pooled.iter().any(|point| point[1] == -42.0)); + assert!(pooled.points.len() <= 4_002); + assert!(pooled.points.iter().any(|point| point[1] == -42.0)); } #[test] @@ -103,9 +107,9 @@ fn spectrum_sized_trace_is_pooled_to_the_screen_budget() { let points: Vec<_> = (0..32_768) .map(|index| [index as f64, (index % 7) as f64]) .collect(); - let drawn = screen_line_points(&points, 0.0, 32_768.0, MIN_LINE_COLUMNS); - assert!(drawn.len() <= MIN_LINE_COLUMNS * 2 + 2); - assert!(matches!(drawn, std::borrow::Cow::Owned(_))); + let drawn = screen_line_points(&points, 0.0, 32_768.0, FULL_MIN_LINE_COLUMNS); + assert!(drawn.points.len() <= FULL_MIN_LINE_COLUMNS * 2 + 2); + assert!(matches!(drawn.points, std::borrow::Cow::Owned(_))); } #[test] @@ -113,9 +117,9 @@ fn short_trace_keeps_its_real_samples() { let points: Vec<_> = (0..2_000) .map(|index| [index as f64, (index % 7) as f64]) .collect(); - let drawn = screen_line_points(&points, 0.0, 2_000.0, MIN_LINE_COLUMNS); - assert!(drawn.as_ref() == points.as_slice()); - assert!(matches!(drawn, std::borrow::Cow::Borrowed(_))); + let drawn = screen_line_points(&points, 0.0, 2_000.0, FULL_MIN_LINE_COLUMNS); + assert!(drawn.points.as_ref() == points.as_slice()); + assert!(matches!(drawn.points, std::borrow::Cow::Borrowed(_))); } #[test] @@ -123,18 +127,19 @@ fn pooling_keeps_positive_and_negative_extrema() { let mut points: Vec<_> = (0..20_000).map(|index| [index as f64, 0.0]).collect(); points[4_321][1] = -17.0; points[12_345][1] = 23.0; - let drawn = screen_line_points(&points, 0.0, 20_000.0, MIN_LINE_COLUMNS); - assert!(drawn.iter().any(|point| point[1] == -17.0)); - assert!(drawn.iter().any(|point| point[1] == 23.0)); + let columns = line_columns(ScreenRenderDetail::Interactive, 320.0, 1.0); + let drawn = screen_line_points(&points, 0.0, 20_000.0, columns); + assert!(drawn.points.iter().any(|point| point[1] == -17.0)); + assert!(drawn.points.iter().any(|point| point[1] == 23.0)); } #[test] fn zoomed_view_keeps_only_visible_samples() { let points: Vec<_> = (0..100_000).map(|index| [index as f64, 1.0]).collect(); - let drawn = screen_line_points(&points, 40_000.0, 41_000.0, MIN_LINE_COLUMNS); - assert!(drawn.len() < 1_100); - assert!(drawn.first().unwrap()[0] < 40_000.0); - assert!(drawn.last().unwrap()[0] > 41_000.0); + let drawn = screen_line_points(&points, 40_000.0, 41_000.0, FULL_MIN_LINE_COLUMNS); + assert!(drawn.points.len() < 1_100); + assert!(drawn.points.first().unwrap()[0] < 40_000.0); + assert!(drawn.points.last().unwrap()[0] > 41_000.0); } #[test] @@ -142,10 +147,10 @@ fn descending_x_view_clips_like_nmr_ppm() { let points: Vec<_> = (0..100_000) .map(|index| [(100_000 - index) as f64, 1.0]) .collect(); - let drawn = screen_line_points(&points, 40_000.0, 41_000.0, MIN_LINE_COLUMNS); - assert!(drawn.len() < 1_100); - assert!(drawn.first().unwrap()[0] > 41_000.0); - assert!(drawn.last().unwrap()[0] < 40_000.0); + let drawn = screen_line_points(&points, 40_000.0, 41_000.0, FULL_MIN_LINE_COLUMNS); + assert!(drawn.points.len() < 1_100); + assert!(drawn.points.first().unwrap()[0] > 41_000.0); + assert!(drawn.points.last().unwrap()[0] < 40_000.0); } #[test] @@ -155,27 +160,133 @@ fn non_monotonic_x_is_pooled_without_unsafe_clipping() { .collect(); let first_x = points[0][0]; points.last_mut().unwrap()[0] = first_x; - let drawn = screen_line_points(&points, 4_000.0, 5_000.0, MIN_LINE_COLUMNS); - assert!(drawn.len() <= MIN_LINE_COLUMNS * 2 + 2); - assert_eq!(drawn.first(), points.first()); - assert_eq!(drawn.last(), points.last()); + let drawn = screen_line_points(&points, 4_000.0, 5_000.0, FULL_MIN_LINE_COLUMNS); + assert!(drawn.points.len() <= FULL_MIN_LINE_COLUMNS * 2 + 2); + assert_eq!(drawn.points.first(), points.first()); + assert_eq!(drawn.points.last(), points.last()); } #[test] fn columns_track_device_pixels_within_bounds() { - assert_eq!(line_columns(320.0, 1.0), MIN_LINE_COLUMNS); - assert_eq!(line_columns(900.0, 2.0), 3_600); - assert_eq!(line_columns(9_000.0, 2.0), MAX_LINE_COLUMNS); + assert_eq!( + line_columns(ScreenRenderDetail::Full, 320.0, 1.0), + FULL_MIN_LINE_COLUMNS + ); + assert_eq!(line_columns(ScreenRenderDetail::Full, 900.0, 2.0), 3_600); + assert_eq!( + line_columns(ScreenRenderDetail::Full, 9_000.0, 2.0), + FULL_MAX_LINE_COLUMNS + ); + assert_eq!( + line_columns(ScreenRenderDetail::Interactive, 320.1, 1.0), + 321 + ); + assert_eq!(line_columns(ScreenRenderDetail::Interactive, 20.0, 1.0), 64); } #[test] -fn render_stats_count_document_and_define_pooled_line_work() { - use plotx_figure::{Axis, Color, Figure, Series}; +fn interactive_contour_budget_is_stable_and_prioritizes_visible_segments() { + let source_len = 250_000; + let max_budget = contour_segment_budget(ScreenRenderDetail::Interactive, 512.0, 256.0, 1.0); + assert_eq!(max_budget, 16_384); + assert_eq!( + contour_segment_budget(ScreenRenderDetail::Full, 512.0, 256.0, 1.0), + usize::MAX + ); + let segments = (0..source_len) + .map(|index| { + let x = if (120_000..121_000).contains(&index) { + (index - 120_000) as f64 / 1_000.0 + } else { + 10.0 + }; + [[x, 0.2], [x, 0.8]] + }) + .collect::>(); + let selected = || { + screen_contour_segments( + &segments, + ScreenRenderDetail::Interactive, + [0.0, 1.0, 0.0, 1.0], + 512, + ) + }; + let first = selected(); + let first_x = first + .iter() + .map(|segment| segment[0][0]) + .collect::>(); + let second = selected(); + let second_x = second + .iter() + .map(|segment| segment[0][0]) + .collect::>(); + assert_eq!(first.source_segments_scanned(), source_len); + assert_eq!(first.len(), 512); + assert_eq!(first_x, second_x); + assert!(first_x.iter().all(|x| (0.0..=1.0).contains(x))); + + let full = screen_contour_segments( + &segments, + ScreenRenderDetail::Full, + [0.0, 1.0, 0.0, 1.0], + 512, + ); + assert_eq!(full.len(), source_len); +} + +#[test] +fn render_stats_separate_full_and_interactive_work() { + use plotx_figure::{Axis, Color, Contour, Figure, Series}; let mut fig = Figure::new("", Axis::new("x", 0.0, 10_000.0), Axis::new("y", -1.0, 1.0)); fig.series.push(Series::line( "trace", (0..10_000).map(|i| [i as f64, (i % 3) as f64]).collect(), )); + fig.contours.push(Contour { + segments: (0..1_000) + .map(|i| [[i as f64, 0.0], [i as f64, 1.0]]) + .collect(), + color: Color::BLACK, + width: 1.0, + }); + let ctx = egui::Context::default(); + let full_stats = render_stats(&ctx, &fig, 7, ScreenRenderDetail::Full); + assert_eq!(full_stats.full_documents_painted, 1); + assert_eq!(full_stats.interactive_documents_painted, 0); + assert_eq!(full_stats.contour_source_segments_scanned, 1_000); + assert_eq!(full_stats.contour_segments_submitted, 1_000); + + let stats = render_stats(&ctx, &fig, 7, ScreenRenderDetail::Interactive); + assert_eq!(stats.documents_painted, 1); + assert_eq!(stats.full_documents_painted, 0); + assert_eq!(stats.interactive_documents_painted, 1); + assert_eq!(stats.line_series_visited, 1); + assert_eq!(stats.line_source_points_visited, 10_000); + assert!(stats.line_points_submitted < 1_000); + assert_eq!(stats.contour_source_segments_scanned, 0); + assert_eq!(stats.contour_segments_visited, 1_000); + assert_eq!(stats.contour_segments_submitted, 1_000); + + let repeated = render_stats(&ctx, &fig, 7, ScreenRenderDetail::Interactive); + assert_eq!(repeated.contour_source_segments_scanned, 0); + + fig.x.max = 100.0; + let changed_viewport = render_stats(&ctx, &fig, 7, ScreenRenderDetail::Interactive); + assert_eq!(changed_viewport.contour_source_segments_scanned, 1_000); + assert_eq!(changed_viewport.contour_segments_submitted, 101); + + let changed_geometry = render_stats(&ctx, &fig, 8, ScreenRenderDetail::Interactive); + assert_eq!(changed_geometry.contour_source_segments_scanned, 1_000); +} + +fn render_stats( + ctx: &egui::Context, + figure: &plotx_figure::Figure, + geometry_generation: u64, + detail: ScreenRenderDetail, +) -> super::RenderStats { + use plotx_figure::Color; let document = crate::Document { width: 400.0, height: 300.0, @@ -183,15 +294,15 @@ fn render_stats_count_document_and_define_pooled_line_work() { items: vec![crate::DocumentItem::Plot(crate::DocumentObject { id: "plot".into(), frame: crate::Rect::new(0.0, 0.0, 400.0, 300.0), - figure: &fig, + figure, + geometry_generation: Some(geometry_generation), visible: true, title: None, })], }; - let ctx = egui::Context::default(); let mut stats = super::RenderStats::default(); let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - super::paint_document_with_stats( + super::paint_document_for_editor_with_detail_and_stats( ui.painter(), crate::Rect::new(0.0, 0.0, 400.0, 300.0), &document, @@ -199,11 +310,9 @@ fn render_stats_count_document_and_define_pooled_line_work() { zoom: 1.0, pan: [0.0; 2], }, + detail, Some(&mut stats), ); }); - assert_eq!(stats.documents_painted, 1); - assert_eq!(stats.line_series_visited, 1); - assert_eq!(stats.line_source_points_scanned, 10_000); - assert!(stats.line_points_emitted <= MIN_LINE_COLUMNS * 2 + 2); + stats } diff --git a/crates/render/src/svg/document.rs b/crates/render/src/svg/document.rs index feb114a..71a9a91 100644 --- a/crates/render/src/svg/document.rs +++ b/crates/render/src/svg/document.rs @@ -292,6 +292,7 @@ mod tests { id: "matching".into(), frame: Rect::new(10.0, 10.0, 50.0, 40.0), figure: &matching, + geometry_generation: None, visible: true, title: None, })], @@ -306,6 +307,7 @@ mod tests { id: "contrasting".into(), frame: Rect::new(10.0, 10.0, 50.0, 40.0), figure: &contrasting, + geometry_generation: None, visible: true, title: None, })],