diff --git a/crates/app/src/ui/canvas/workspace_render_bench.rs b/crates/app/src/ui/canvas/workspace_render_bench.rs index 8efb7696..e09187d7 100644 --- a/crates/app/src/ui/canvas/workspace_render_bench.rs +++ b/crates/app/src/ui/canvas/workspace_render_bench.rs @@ -84,10 +84,20 @@ fn desktop_frames(app: &mut PlotiumApp, label: &str) { let mut title = None; let mut samples = Vec::new(); let mut tessellation = Vec::new(); - for frame in 0..13 { + let mut totals = Vec::new(); + let mut vertices = 0; + const DESKTOP_WARMUP_FRAMES: usize = 90; + for frame in 0..DESKTOP_WARMUP_FRAMES + MEASURED_FRAMES { + if frame == 60 { + // Freeze the settled fit before timing Full detail; spring movement + // otherwise keeps refreshing the interactive-detail grace period. + app.session.viewport_mode = plotium_core::state::ViewportMode::Manual; + } let start = Instant::now(); let output = ctx.run_ui( egui::RawInput { + // Advance beyond the navigation detail grace period before sampling. + time: Some(frame as f64 / 60.0), screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(SCREEN_WIDTH, SCREEN_HEIGHT), @@ -113,17 +123,39 @@ fn desktop_frames(app: &mut PlotiumApp, label: &str) { ); let ui_time = start.elapsed(); let start = Instant::now(); - std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point)); - if frame >= 3 { + let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); + let tessellation_time = start.elapsed(); + if frame >= DESKTOP_WARMUP_FRAMES { + assert_eq!( + super::render_detail::workspace_render_detail(&ctx, frame as f64 / 60.0), + ScreenRenderDetail::Full + ); + vertices = primitives + .iter() + .map(|primitive| match &primitive.primitive { + egui::epaint::Primitive::Mesh(mesh) => mesh.vertices.len(), + egui::epaint::Primitive::Callback(_) => 0, + }) + .sum::(); samples.push(ui_time); - tessellation.push(start.elapsed()); + totals.push(ui_time + tessellation_time); + tessellation.push(tessellation_time); } } samples.sort_unstable(); tessellation.sort_unstable(); + totals.sort_unstable(); + println!( + "{label} total CPU: median={:?} p95={:?}; vertices={vertices}", + totals[totals.len() / 2], + totals[(totals.len() * 95).div_ceil(100) - 1] + ); println!( "{label} desktop UI: median={:?} p95={:?}; tessellation median={:?} p95={:?}", - samples[5], samples[9], tessellation[5], tessellation[9] + samples[samples.len() / 2], + samples[(samples.len() * 95).div_ceil(100) - 1], + tessellation[tessellation.len() / 2], + tessellation[(tessellation.len() * 95).div_ceil(100) - 1] ); } @@ -266,3 +298,65 @@ fn add_stats(total: &mut RenderStats, frame: &RenderStats) { total.contour_segments_visited += frame.contour_segments_visited; total.contour_segments_submitted += frame.contour_segments_submitted; } + +/// CPU UI construction and tessellation only, not end-to-end GPU frame time. +/// Run with cargo test --release -p plotium bench_stress_workspace -- --ignored --nocapture. +#[test] +#[ignore = "synthetic CPU benchmark; use a release build"] +fn bench_stress_workspace() { + use plotium_core::actions::Action; + use plotium_core::state::{BoardFitTarget, Dataset, NmrDataset, ViewportMode}; + for count in [12, 60, 120, 500] { + let mut app = PlotiumApp::new_with_settings(plotium_core::settings::Settings::default()); + for index in 0..count { + let data = plotium_io::NmrData { + points: (0..32768) + .map(|i| { + let x = i as f64 / 32768.0; + num_complex::Complex64::new( + (-((x - 0.35) / 0.01).powi(2)).exp() + + 0.4 * (-((x - 0.7) / 0.02).powi(2)).exp(), + 0.0, + ) + }) + .collect(), + domain: plotium_io::Domain::Frequency, + spectral_width_hz: 4000.0, + observe_freq_mhz: 400.0, + carrier_ppm: 5.0, + nucleus: "1H".to_owned(), + source: format!("synthetic-{index}"), + group_delay: 0.0, + }; + let dataset = Dataset::Nmr(Box::new(NmrDataset::load(data).unwrap())); + // Prepare each independent page without measuring repeated import/history + // bookkeeping; this benchmark measures only the finished workspace. + let mut page_app = + PlotiumApp::new_with_settings(plotium_core::settings::Settings::default()); + page_app + .try_execute_action(Action::insert_dataset_with_default_canvas( + &page_app, + dataset, + format!("Spectrum {index}"), + [160.0, 100.0], + )) + .unwrap(); + wait_for_compute(&mut page_app); + let mut canvas = page_app.doc.canvases.remove(0); + let size = canvas.size_pt(); + canvas.board_pos = [ + (index % 3) as f32 * (size[0] + plotium_core::state::BOARD_GUTTER_PT), + (index / 3) as f32 * (size[1] + plotium_core::state::BOARD_GUTTER_PT), + ]; + app.doc.datasets.append(&mut page_app.doc.datasets); + app.doc.canvases.push(canvas); + } + app.activate_canvas(0); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::AllFrames); + desktop_frames(&mut app, &format!("{count} overview")); + app.session.viewport_mode = ViewportMode::Fit(BoardFitTarget::Frame( + plotium_core::state::BoardFrameId::Page(app.doc.canvases[count / 2].resource_id), + )); + desktop_frames(&mut app, &format!("{count} single-canvas")); + } +} diff --git a/crates/render/src/screen_contour_cache.rs b/crates/render/src/screen_contour_cache.rs index d7826367..4c09c10f 100644 --- a/crates/render/src/screen_contour_cache.rs +++ b/crates/render/src/screen_contour_cache.rs @@ -94,20 +94,20 @@ pub(super) fn screen_contour_segments_cached<'a>( 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) + ScreenRenderDetail::Full if budget == usize::MAX => { + ScreenContourSegments::full(segments, 0) } + _ => 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()) + ScreenRenderDetail::Full if budget == usize::MAX => { + ScreenContourSegments::full(segments, segments.len()) } + _ => ScreenContourSegments::from_lod(segments, lod, budget, segments.len()), } } diff --git a/crates/render/src/screen_lod.rs b/crates/render/src/screen_lod.rs index 5d43a1b4..3a0454b0 100644 --- a/crates/render/src/screen_lod.rs +++ b/crates/render/src/screen_lod.rs @@ -1,7 +1,7 @@ use crate::screen::ScreenRenderDetail; use std::sync::Arc; -pub(super) const FULL_MIN_LINE_COLUMNS: usize = 2_048; +pub(super) const FULL_MIN_LINE_COLUMNS: usize = 64; 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; @@ -90,10 +90,12 @@ pub(super) fn contour_segment_budget( plot_height: f32, pixels_per_point: f32, ) -> usize { - if detail == ScreenRenderDetail::Full { + let scale = valid_pixel_scale(pixels_per_point); + // Keep inspection-sized plots exact; only small overview plots use a + // segment budget. Export renderers do not use this screen-only policy. + if detail == ScreenRenderDetail::Full && plot_width.max(plot_height) * scale >= 256.0 { 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, @@ -107,7 +109,7 @@ pub(super) fn screen_contour_segments( viewport: [f64; 4], budget: usize, ) -> ScreenContourSegments<'_> { - if detail == ScreenRenderDetail::Full { + if detail == ScreenRenderDetail::Full && budget == usize::MAX { return ScreenContourSegments::full(segments, 0); } let selected = prepare_contour_lod(segments, viewport); diff --git a/crates/render/src/screen_tests.rs b/crates/render/src/screen_tests.rs index e188a287..280f85b4 100644 --- a/crates/render/src/screen_tests.rs +++ b/crates/render/src/screen_tests.rs @@ -118,7 +118,7 @@ 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 = line_points(&points, 0.0, 2_000.0, FULL_MIN_LINE_COLUMNS); + let drawn = line_points(&points, 0.0, 2_000.0, 2_048); assert!(drawn.points.as_ref() == points.as_slice()); assert!(matches!(drawn.points, std::borrow::Cow::Borrowed(_))); } @@ -169,10 +169,7 @@ fn non_monotonic_x_is_pooled_without_unsafe_clipping() { #[test] fn columns_track_device_pixels_within_bounds() { - assert_eq!( - line_columns(ScreenRenderDetail::Full, 320.0, 1.0), - FULL_MIN_LINE_COLUMNS - ); + assert_eq!(line_columns(ScreenRenderDetail::Full, 320.0, 1.0), 640); assert_eq!(line_columns(ScreenRenderDetail::Full, 900.0, 2.0), 3_600); assert_eq!( line_columns(ScreenRenderDetail::Full, 9_000.0, 2.0), @@ -231,7 +228,7 @@ fn interactive_contour_budget_is_stable_and_prioritizes_visible_segments() { &segments, ScreenRenderDetail::Full, [0.0, 1.0, 0.0, 1.0], - 512, + usize::MAX, ); assert_eq!(full.len(), source_len); } @@ -356,3 +353,45 @@ fn log_y_pooling_ignores_samples_without_a_position() { let interior = &drawn.points[1..drawn.points.len() - 1]; assert!(interior.iter().all(|p| p[1] > 0.0)); } + +#[test] +fn overview_full_detail_contours_are_bounded_but_inspection_is_exact() { + use crate::screen_contour_cache::{ContourCacheKey, screen_contour_segments_cached}; + + let small = contour_segment_budget(ScreenRenderDetail::Full, 40.0, 30.0, 1.0); + let larger = contour_segment_budget(ScreenRenderDetail::Full, 200.0, 150.0, 1.0); + assert!(small < larger); + let segments = vec![[[0.0, 0.0], [1.0, 1.0]]; 20_000]; + let ctx = egui::Context::default(); + let viewport = [0.0, 1.0, 0.0, 1.0]; + let key = ContourCacheKey::new(7, 0, segments.len(), viewport); + let draw = |budget| { + screen_contour_segments_cached( + &ctx, + Some(key), + &segments, + ScreenRenderDetail::Full, + viewport, + budget, + ) + }; + + let overview = draw(small); + assert_eq!(overview.len(), small); + assert_eq!(overview.source_segments_scanned(), segments.len()); + + let inspection = draw(contour_segment_budget( + ScreenRenderDetail::Full, + 800.0, + 600.0, + 1.0, + )); + assert_eq!(inspection.len(), segments.len()); + assert_eq!(inspection.source_segments_scanned(), 0); + + let overview_again = draw(small); + assert_eq!(overview_again.len(), small); + assert_eq!(overview_again.source_segments_scanned(), 0); + assert_eq!(line_columns(ScreenRenderDetail::Full, 4.0, 1.0), 64); + assert_eq!(line_columns(ScreenRenderDetail::Full, 1200.0, 1.0), 2400); +} diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index 70a4d362..33847c14 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -38,6 +38,8 @@ introduces the same regions in walkthrough form. near another frame, it snaps to that frame's edges and the standard gap. Hold Alt while dragging to bypass snapping. See [Navigation](/reference/shortcuts/#navigation) for fitting and moving the view. + Screen plots adapt line and contour detail to their displayed size. Zoom in + to inspect fine detail; figure exports are unaffected by this screen simplification. - **Data sheet** — the panel docked below the board where a table is edited. The board shrinks to make room. Drag its top edge to resize it; its header collapses, maximizes, or closes it. See [Tables](/guides/tables/#editing). diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index d957c35a..dc5d650d 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -31,6 +31,8 @@ Plotium 的界面为英文;手册中加粗的英文词即界面上的原文标 数据表标题可调整位置;靠近其他框架时,会吸附到其边缘和框架间的标准间距。 拖动时按住 Alt 可暂时绕过吸附。视野适配与移动操作见 [导航](/zh-cn/reference/shortcuts/#导航)。 + 屏幕上的曲线和等高线细节会随显示尺寸调整。检查精细结构时请放大视图; + 图形导出不受这种屏幕简化影响。 - **数据工作表(Data sheet)**——停靠在画板下方、用于编辑表格的面板,画板会相应 变矮。拖动其上边缘可调整高度;标题栏可折叠、最大化或关闭它。参见 [表格](/zh-cn/guides/tables/#编辑)。