Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/app/src/ui/canvas/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions crates/app/src/ui/canvas/board_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion crates/app/src/ui/canvas/image_painting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
);
}

Expand Down
4 changes: 4 additions & 0 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ mod phase;
mod readout;
mod reference_pick;
mod regions;
mod render_detail;
mod slices;
mod snap;
mod symmetry;
Expand Down Expand Up @@ -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::*;
Expand All @@ -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 {
Expand Down
18 changes: 8 additions & 10 deletions crates/app/src/ui/canvas/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
60 changes: 60 additions & 0 deletions crates/app/src/ui/canvas/render_detail.rs
Original file line number Diff line number Diff line change
@@ -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::<InteractiveRenderState>(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::<InteractiveRenderState>(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
);
}
}
149 changes: 149 additions & 0 deletions crates/app/src/ui/canvas/workspace_render_bench.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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::<Vec<_>>();
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;
}
1 change: 1 addition & 0 deletions crates/core/src/state/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
Loading
Loading