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, })],