diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 0bd4bbc..2c348b3 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -57,6 +57,7 @@ mod panel_selection; mod peaks; mod phase; mod readout; +mod reference_pick; mod regions; mod slices; mod snap; @@ -86,6 +87,7 @@ pub(crate) use panel_notes::*; pub(crate) use peaks::*; pub(crate) use phase::*; pub(crate) use readout::*; +pub(crate) use reference_pick::*; pub(crate) use regions::*; pub(crate) use selection_painting::*; pub(crate) use slices::*; @@ -109,26 +111,8 @@ fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { } #[cfg(test)] -mod culling_tests { - use super::finite_rect_intersects; - - #[test] - fn edge_contact_is_visible_and_non_finite_is_not() { - let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0)); - assert!(finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)), - clip, - )); - assert!(!finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)), - clip, - )); - assert!(!finite_rect_intersects( - egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)), - clip, - )); - } -} +#[path = "mod_tests.rs"] +mod tests; #[derive(Clone, Copy)] pub(crate) enum CanvasInteractionClearScope { @@ -163,6 +147,13 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { sync_macos_trackpad_gesture(ui.ctx(), &ui.input(|i| i.events.clone()), pointer_owned); let view_consumed = pointer_owned && handle_navigation(app, ci, rect, ui); + // An armed Reference pick owns the pointer over its plot: a click there + // sets the step's source position instead of starting any other gesture. + // Navigation stays ahead of it so wheel and trackpad zoom keep working + // while aiming at a peak. + let view_consumed = + view_consumed || handle_reference_pick(app, ci, rect, ui, pointer_owned && !view_consumed); + let frame_consumed = if pointer_owned && !view_consumed && app.session.ui.interaction.allows_frame_dispatch() { dispatch_frame_gesture(app, rect, ui) @@ -388,6 +379,9 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_integrals(app, ci, object_id, di, plot, &painter, chrome); paint_integrals_2d(app, ci, object_id, di, plot, &painter, chrome); paint_peaks(app, ci, object_id, di, plot, &painter, chrome); + if pointer_owned { + paint_reference_pick(app, ci, object_id, di, plot, ui, &painter, chrome); + } handle_and_paint_craft_result(app, ci, object_id, plot, &painter, ui); paint_cursor_tool(app, ci, object_id, di, plot, ui, &painter, chrome); paint_symmetry( @@ -599,198 +593,3 @@ fn resize_cursor(handle: ResizeHandle) -> egui::CursorIcon { ResizeHandle::TopRight | ResizeHandle::BottomLeft => egui::CursorIcon::ResizeNeSw, } } - -#[cfg(test)] -mod tests { - use super::*; - - use plotx_core::state::{CanvasObject, CanvasObjectKind, CanvasViewport, PlotObject, TextBox}; - use plotx_figure::{Axis, Figure}; - - #[test] - fn hit_object_selects_text_box() { - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(7), - name: "Text".to_owned(), - frame: ObjectFrame::new(20.0, 20.0, 100.0, 30.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Text(TextBox::label("hi".to_owned())), - }); - - let hit = hit_object(&canvas, Pos2::new(50.0, 30.0), 1.0); - - assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(7))); - } - - #[test] - fn hit_object_finds_object_outside_page_bounds() { - let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 100.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(1), - name: "plot".to_owned(), - frame: ObjectFrame::new(-30.0, 20.0, 50.0, 40.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Plot(Box::new({ - let figure = - Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); - let viewport = CanvasViewport::from_figure(&figure); - PlotObject::new( - None, - plotx_core::state::SeriesId::new(1), - plotx_core::state::DataBinding { series: Vec::new() }, - plotx_core::state::ChartSpec::default(), - plotx_core::state::StackSpec::default(), - plotx_core::state::AxisProjections::default(), - plotx_core::state::AxisOverrides::default(), - figure, - viewport, - ) - })), - }); - - let hit = hit_object(&canvas, Pos2::new(-10.0, 30.0), 1.0); - - assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(1))); - } - - #[test] - fn data_edit_target_requires_data_tool_and_selected_plot() { - let mut app = PlotxApp::new(); - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - canvas.objects.push(CanvasObject { - id: ObjectId::new(3), - name: "plot".to_owned(), - frame: ObjectFrame::new(10.0, 10.0, 80.0, 60.0), - locked: false, - visible: true, - kind: CanvasObjectKind::Plot(Box::new({ - let figure = - Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); - let viewport = CanvasViewport::from_figure(&figure); - PlotObject::new( - None, - plotx_core::state::SeriesId::new(1), - plotx_core::state::DataBinding { series: Vec::new() }, - plotx_core::state::ChartSpec::default(), - plotx_core::state::StackSpec::default(), - plotx_core::state::AxisProjections::default(), - plotx_core::state::AxisOverrides::default(), - figure, - viewport, - ) - })), - }); - app.doc.canvases.push(canvas); - app.session.active_canvas = Some(0); - app.doc.canvases[0].selected_object = Some(ObjectId::new(3)); - - app.session.tool = Tool::Select; - assert_eq!(data_edit_target(&app, 0), None); - - app.session.tool = Tool::BrowseZoom; - assert_eq!(data_edit_target(&app, 0), Some(ObjectId::new(3))); - } - - #[test] - fn phase_editor_open_drives_on_plot_pivot() { - use num_complex::Complex64; - use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; - use plotx_io::{Domain, NmrData}; - use std::f64::consts::TAU; - - let npoints = 256; - let (sw, obs, carrier) = (4000.0, 400.0, 5.0); - let dt = 1.0 / sw; - let points = (0..npoints) - .map(|k| { - let t = k as f64 * dt; - let decay = (-t / 0.25f64).exp(); - let freq_hz = (2.0 - carrier) * obs; - Complex64::from_polar(decay, TAU * freq_hz * t) - }) - .collect(); - let data = NmrData { - points, - domain: Domain::Time, - spectral_width_hz: sw, - observe_freq_mhz: obs, - carrier_ppm: carrier, - nucleus: "1H".to_owned(), - source: "synthetic".to_owned(), - group_delay: 0.0, - }; - - let mut app = PlotxApp::new(); - app.doc - .datasets - .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); - let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); - let id = canvas.allocate_object_id(); - let obj = app.build_plot_object( - 0, - ObjectFrame::new(10.0, 10.0, 80.0, 60.0), - id, - "plot".into(), - ); - canvas.objects.push(obj); - app.doc.canvases.push(canvas); - app.session.active_canvas = Some(0); - app.focus_single(0); - - let pivot = Color32::from_rgb(0xE0, 0x6C, 0x22); - let count = |app: &mut PlotxApp| { - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size( - egui::Pos2::ZERO, - egui::vec2(1000.0, 800.0), - )), - ..Default::default() - }; - let ctx = egui::Context::default(); - // Two passes: the first lays out the board, the second paints with a - // stable geometry. - let _ = ctx.run_ui(input.clone(), |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); - }); - let out = ctx.run_ui(input, |ui| { - egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); - }); - out.shapes - .iter() - .filter(|cs| match &cs.shape { - egui::epaint::Shape::LineSegment { stroke, .. } => stroke.color == pivot, - egui::epaint::Shape::Circle(c) => c.fill == pivot, - _ => false, - }) - .count() - }; - - let phase_id = app.doc.datasets[0] - .axis_pipeline(PhaseAxis::Direct) - .unwrap() - .steps - .iter() - .find(|s| matches!(s.kind, plotx_processing::StepKind::Phase(_))) - .unwrap() - .id; - - app.sync_phase_interaction(); - assert_eq!(count(&mut app), 0, "no pivot before the Phase editor opens"); - - app.session.ui.proc_expanded_step = Some((app.doc.datasets[0].resource_id(), phase_id)); - app.sync_phase_interaction(); - assert_eq!(app.session.tool, Tool::ManualPhase); - assert!( - count(&mut app) > 0, - "pivot appears while the Phase editor is open" - ); - - app.session.ui.proc_expanded_step = None; - app.sync_phase_interaction(); - assert_ne!(app.session.tool, Tool::ManualPhase); - assert_eq!(count(&mut app), 0, "pivot gone after the editor collapses"); - } -} diff --git a/crates/app/src/ui/canvas/mod_tests.rs b/crates/app/src/ui/canvas/mod_tests.rs new file mode 100644 index 0000000..282a048 --- /dev/null +++ b/crates/app/src/ui/canvas/mod_tests.rs @@ -0,0 +1,206 @@ +use super::*; + +use plotx_core::state::{CanvasObject, CanvasObjectKind, CanvasViewport, PlotObject, TextBox}; +use plotx_figure::{Axis, Figure}; + +#[test] +fn edge_contact_is_visible_and_non_finite_is_not() { + let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0)); + assert!(finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)), + clip, + )); +} + +#[test] +fn hit_object_selects_text_box() { + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(7), + name: "Text".to_owned(), + frame: ObjectFrame::new(20.0, 20.0, 100.0, 30.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("hi".to_owned())), + }); + + let hit = hit_object(&canvas, Pos2::new(50.0, 30.0), 1.0); + + assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(7))); +} + +#[test] +fn hit_object_finds_object_outside_page_bounds() { + let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 100.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(1), + name: "plot".to_owned(), + frame: ObjectFrame::new(-30.0, 20.0, 50.0, 40.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new({ + let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ) + })), + }); + + let hit = hit_object(&canvas, Pos2::new(-10.0, 30.0), 1.0); + + assert_eq!(hit.map(|hit| hit.object), Some(ObjectId::new(1))); +} + +#[test] +fn data_edit_target_requires_data_tool_and_selected_plot() { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + canvas.objects.push(CanvasObject { + id: ObjectId::new(3), + name: "plot".to_owned(), + frame: ObjectFrame::new(10.0, 10.0, 80.0, 60.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new({ + let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); + let viewport = CanvasViewport::from_figure(&figure); + PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ) + })), + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.doc.canvases[0].selected_object = Some(ObjectId::new(3)); + + app.session.tool = Tool::Select; + assert_eq!(data_edit_target(&app, 0), None); + + app.session.tool = Tool::BrowseZoom; + assert_eq!(data_edit_target(&app, 0), Some(ObjectId::new(3))); +} + +#[test] +fn phase_editor_open_drives_on_plot_pivot() { + use num_complex::Complex64; + use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; + use plotx_io::{Domain, NmrData}; + use std::f64::consts::TAU; + + let npoints = 256; + let (sw, obs, carrier) = (4000.0, 400.0, 5.0); + let dt = 1.0 / sw; + let points = (0..npoints) + .map(|k| { + let t = k as f64 * dt; + let decay = (-t / 0.25f64).exp(); + let freq_hz = (2.0 - carrier) * obs; + Complex64::from_polar(decay, TAU * freq_hz * t) + }) + .collect(); + let data = NmrData { + points, + domain: Domain::Time, + spectral_width_hz: sw, + observe_freq_mhz: obs, + carrier_ppm: carrier, + nucleus: "1H".to_owned(), + source: "synthetic".to_owned(), + group_delay: 0.0, + }; + + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 200.0]); + let id = canvas.allocate_object_id(); + let obj = app.build_plot_object( + 0, + ObjectFrame::new(10.0, 10.0, 80.0, 60.0), + id, + "plot".into(), + ); + canvas.objects.push(obj); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.focus_single(0); + + let pivot = Color32::from_rgb(0xE0, 0x6C, 0x22); + let count = |app: &mut PlotxApp| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1000.0, 800.0), + )), + ..Default::default() + }; + let ctx = egui::Context::default(); + // Two passes: the first lays out the board, the second paints with a + // stable geometry. + let _ = ctx.run_ui(input.clone(), |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); + }); + let out = ctx.run_ui(input, |ui| { + egui::CentralPanel::default().show_inside(ui, |ui| render_central(app, ui)); + }); + out.shapes + .iter() + .filter(|cs| match &cs.shape { + egui::epaint::Shape::LineSegment { stroke, .. } => stroke.color == pivot, + egui::epaint::Shape::Circle(c) => c.fill == pivot, + _ => false, + }) + .count() + }; + + let phase_id = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| matches!(s.kind, plotx_processing::StepKind::Phase(_))) + .unwrap() + .id; + + app.sync_phase_interaction(); + assert_eq!(count(&mut app), 0, "no pivot before the Phase editor opens"); + + app.session.ui.proc_expanded_step = Some((app.doc.datasets[0].resource_id(), phase_id)); + app.sync_phase_interaction(); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert!( + count(&mut app) > 0, + "pivot appears while the Phase editor is open" + ); + + app.session.ui.proc_expanded_step = None; + app.sync_phase_interaction(); + assert_ne!(app.session.tool, Tool::ManualPhase); + assert_eq!(count(&mut app), 0, "pivot gone after the editor collapses"); +} diff --git a/crates/app/src/ui/canvas/reference_pick.rs b/crates/app/src/ui/canvas/reference_pick.rs new file mode 100644 index 0000000..51e8d6a --- /dev/null +++ b/crates/app/src/ui/canvas/reference_pick.rs @@ -0,0 +1,265 @@ +//! The one-shot on-plot pick for a Reference step's source position. +//! +//! While the step editor arms a pick, hovering the target plot previews the +//! snapped position and a click writes the step's `at_ppm` through the +//! property catalog (one undo step, same recompute as typing the number). +//! The mode's lifetime is owned by `PlotxApp::resolve_reference_pick`. + +use super::*; +use plotx_core::automation::{ComponentRef, ResourceRef, TargetRef}; +use plotx_core::properties::PropertyValue; +use plotx_core::state::{PhaseOrient, ResolvedReferencePick}; + +#[cfg(test)] +#[path = "reference_pick_tests.rs"] +mod tests; + +/// Screen-space geometry of the pick target plot: the axis mappings copied out +/// of the figure so previews and commits share one conversion. +#[derive(Clone, Copy)] +struct PickGeometry { + xmin: f64, + xspan: f64, + xrev: bool, + ymin: f64, + yspan: f64, + yrev: bool, +} + +impl PickGeometry { + fn of(figure: &plotx_figure::Figure) -> Self { + Self { + xmin: figure.x.min, + xspan: figure.x.span(), + xrev: figure.x.reversed, + ymin: figure.y.min, + yspan: figure.y.span(), + yrev: figure.y.reversed, + } + } +} + +/// The previewed pick under the pointer: the position in finished-axis ppm and +/// where its guide line and optional apex dot sit on screen. +struct PickedPosition { + ppm: f64, + line_px: f32, + apex: Option, +} + +/// Input half of the pick. Returns `true` while the pointer hovers the armed +/// plot, so the caller keeps layout and data gestures from starting under a +/// click that is meant for the pick. `pointer_allowed` carries the caller's +/// pointer-ownership verdict; Escape disarms regardless of it. +pub(crate) fn handle_reference_pick( + app: &mut PlotxApp, + ci: usize, + board_rect: egui::Rect, + ui: &Ui, + pointer_allowed: bool, +) -> bool { + let Some(resolved) = app.resolve_reference_pick() else { + return false; + }; + let (esc, hover, pressed, shift) = ui.input(|input| { + ( + input.key_pressed(egui::Key::Escape), + input.pointer.hover_pos(), + input.pointer.primary_pressed(), + input.modifiers.shift, + ) + }); + if esc { + app.session.ui.reference_pick = None; + app.session.status = "Reference pick cancelled.".to_owned(); + return false; + } + if !pointer_allowed || app.interaction().is_active() { + return false; + } + let Some((plot, geometry)) = pick_plot(app, ci, board_rect, &resolved) else { + return false; + }; + let Some(p) = hover.filter(|p| plot_contains(plot, *p)) else { + return false; + }; + if pressed && let Some(picked) = picked_position(app, &resolved, geometry, plot, p, shift) { + commit_reference_pick(app, &resolved, picked.ppm); + } + true +} + +/// Preview half of the pick, painted with the other plot chrome: a guide line +/// at the snapped position, its apex, and a ppm readout by the cursor. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_reference_pick( + app: &PlotxApp, + ci: usize, + object_id: ObjectId, + di: usize, + plot: PlotRect, + ui: &Ui, + painter: &egui::Painter, + chrome: ChromeStyle, +) { + let Some(resolved) = app.resolve_reference_pick() else { + return; + }; + if resolved.dataset_index != di || app.interaction().is_active() { + return; + } + let Some(figure) = app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.plot()) + .map(|plot| plot.figure()) + else { + return; + }; + let geometry = PickGeometry::of(figure); + let (hover, shift) = ui.input(|input| (input.pointer.hover_pos(), input.modifiers.shift)); + let Some(p) = hover.filter(|p| plot_contains(plot, *p)) else { + return; + }; + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + let Some(picked) = picked_position(app, &resolved, geometry, plot, p, shift) else { + return; + }; + let stroke = Stroke::new(1.5_f32, chrome.pivot); + let (a, b) = match resolved.axis.orient() { + PhaseOrient::Vertical => ( + Pos2::new(picked.line_px, plot.top), + Pos2::new(picked.line_px, plot.bottom()), + ), + PhaseOrient::Horizontal => ( + Pos2::new(plot.left, picked.line_px), + Pos2::new(plot.right(), picked.line_px), + ), + }; + // Dashed, so the transient pick guide never reads as the phase pivot line. + painter.add(egui::Shape::dashed_line(&[a, b], stroke, 5.0, 4.0)); + if let Some(apex) = picked.apex { + painter.circle_filled(apex, 3.5, chrome.pivot); + } + let galley = painter.layout_no_wrap( + format!("{:.3} ppm", picked.ppm), + egui::FontId::proportional(11.0), + chrome.pivot, + ); + let anchor = p + egui::vec2(12.0, -18.0); + painter.rect_filled( + egui::Rect::from_min_size(anchor, galley.size()).expand(3.0), + 3.0, + Color32::from_black_alpha(if ui.visuals().dark_mode { 150 } else { 20 }), + ); + painter.galley(anchor, galley, chrome.pivot); +} + +/// The plot the armed pick may act on: the canvas's data-edit or active plot, +/// but only while it shows the picked dataset. +fn pick_plot( + app: &PlotxApp, + ci: usize, + board_rect: egui::Rect, + resolved: &ResolvedReferencePick, +) -> Option<(PlotRect, PickGeometry)> { + let object_id = + data_edit_target(app, ci).or_else(|| app.doc.canvases[ci].active_plot_object_id())?; + let object = app.doc.canvases[ci].object(object_id)?; + let di = object.dataset().and_then(|id| app.doc.dataset_index(id))?; + if di != resolved.dataset_index { + return None; + } + let outer = object_screen_rect( + app.session.board, + &app.doc.canvases[ci], + object_id, + board_rect, + )?; + let figure = object.plot()?.figure(); + let zoom = app.session.board.zoom; + let layout = plotx_render::axis_layout(figure, outer.width / zoom, outer.height / zoom); + let plot = plotx_render::Projector::new(figure, outer, &layout.margins.scaled(zoom)).plot; + Some((plot, PickGeometry::of(figure))) +} + +/// Resolve the pointer into a picked position. On a 1D trace the pick snaps to +/// the same zoom-scaled apex search manual peak picking uses (`Shift` = nearest +/// sample); an axis without a 1D trace (a 2D dimension) picks the raw +/// coordinate. +fn picked_position( + app: &PlotxApp, + resolved: &ResolvedReferencePick, + geometry: PickGeometry, + plot: PlotRect, + p: Pos2, + shift: bool, +) -> Option { + match resolved.axis.orient() { + PhaseOrient::Vertical => { + let x = screen_to_x(p.x, plot, geometry.xmin, geometry.xspan, geometry.xrev); + let trace = app + .doc + .datasets + .get(resolved.dataset_index) + .and_then(|dataset| dataset.displayed_trace(None)); + match trace { + Some(trace) => { + let snap = super::peaks::manual_peak_snap(shift, geometry.xspan, plot.width); + let (px, py) = trace.pick(x, snap); + let sx = x_to_screen(px, plot, geometry.xmin, geometry.xspan, geometry.xrev); + let sy = y_to_screen(py, plot, geometry.ymin, geometry.yspan, geometry.yrev); + Some(PickedPosition { + ppm: px, + line_px: sx, + apex: plot_contains(plot, Pos2::new(sx, sy)).then_some(Pos2::new(sx, sy)), + }) + } + None => Some(PickedPosition { + ppm: x, + line_px: p.x, + apex: None, + }), + } + } + PhaseOrient::Horizontal => Some(PickedPosition { + ppm: screen_to_y(p.y, plot, geometry.ymin, geometry.yspan, geometry.yrev), + line_px: p.y, + apex: None, + }), + } +} + +/// Write the picked position into the step's `at_ppm` through the property +/// catalog. The displayed ppm converts into the step's own axis coordinates by +/// removing the calibration that reference steps from this one onward apply +/// (`AxisPipeline::chemical_shift_offset_from_step_ppm`), so after the edit the +/// picked feature reads exactly `target_ppm`. +fn commit_reference_pick(app: &mut PlotxApp, resolved: &ResolvedReferencePick, picked_ppm: f64) { + let offset = app + .doc + .datasets + .get(resolved.dataset_index) + .and_then(|dataset| dataset.axis_pipeline(resolved.axis)) + .map(|pipeline| pipeline.chemical_shift_offset_from_step_ppm(resolved.pick.step)) + .unwrap_or(0.0); + let at_ppm = picked_ppm - offset; + app.session.ui.reference_pick = None; + let target = TargetRef { + resource: ResourceRef::from(resolved.pick.dataset), + component: Some(ComponentRef::ProcessingStep(resolved.pick.step)), + }; + match app.plan_property_write( + plotx_core::properties::reference::AT_PPM, + std::slice::from_ref(&target), + &PropertyValue::Float(at_ppm), + ) { + Ok(commit) => { + app.commit_property(commit); + app.session.status = + format!("Reference source set to {at_ppm:.3} ppm — now set the target position."); + } + Err(error) => { + app.session.status = format!("Could not set the reference source: {error}"); + } + } +} diff --git a/crates/app/src/ui/canvas/reference_pick_tests.rs b/crates/app/src/ui/canvas/reference_pick_tests.rs new file mode 100644 index 0000000..b199883 --- /dev/null +++ b/crates/app/src/ui/canvas/reference_pick_tests.rs @@ -0,0 +1,116 @@ +use super::*; +use plotx_core::state::{Dataset, NmrDataset, PhaseAxis}; +use plotx_io::{Domain, NmrData}; +use plotx_processing::{ProcessingStep, ReferenceParams, StepId, StepKind, StepSource}; + +fn synthetic_app() -> PlotxApp { + use num_complex::Complex64; + use std::f64::consts::TAU; + let npoints = 256; + let (sw, obs, carrier) = (4000.0, 400.0, 5.0); + let dt = 1.0 / sw; + let points = (0..npoints) + .map(|k| { + let t = k as f64 * dt; + let decay = (-t / 0.25f64).exp(); + let freq_hz = (2.0 - carrier) * obs; + Complex64::from_polar(decay, TAU * freq_hz * t) + }) + .collect(); + let data = NmrData { + points, + domain: Domain::Time, + spectral_width_hz: sw, + observe_freq_mhz: obs, + carrier_ppm: carrier, + nucleus: "1H".to_owned(), + source: "synthetic".to_owned(), + group_delay: 0.0, + }; + let mut app = PlotxApp::new(); + app.doc + .datasets + .push(Dataset::Nmr(Box::new(NmrDataset::load(data)))); + app.focus_single(0); + app +} + +fn add_reference_step(app: &mut PlotxApp, params: ReferenceParams) -> StepId { + let pipe = app.doc.datasets[0] + .axis_pipeline_mut(PhaseAxis::Direct) + .unwrap(); + let id = StepId::new(pipe.steps.iter().map(|s| s.id.get()).max().unwrap_or(0) + 1); + pipe.steps.push(ProcessingStep::new( + id, + StepKind::Reference(params), + StepSource::User, + )); + id +} + +fn reference_params(app: &PlotxApp, step: StepId) -> ReferenceParams { + let StepKind::Reference(params) = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| s.id == step) + .unwrap() + .kind + else { + panic!("the step must stay a Reference step"); + }; + params +} + +/// A pick on the displayed axis must land the picked feature exactly on +/// `target_ppm` after the recompute: the step's own current offset is removed +/// from the picked coordinate before it becomes the new `at_ppm`. +#[test] +fn commit_converts_the_displayed_pick_into_step_coordinates() { + let mut app = synthetic_app(); + let step = add_reference_step( + &mut app, + ReferenceParams { + at_ppm: 1.0, + target_ppm: 2.0, + }, + ); + let dataset = app.doc.datasets[0].resource_id(); + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app.resolve_reference_pick().expect("armed and expanded"); + + // The step currently applies +1.0 ppm, so a feature displayed at 5.0 sits + // at 4.0 on the axis entering the step. + commit_reference_pick(&mut app, &resolved, 5.0); + + let params = reference_params(&app, step); + assert!((params.at_ppm - 4.0).abs() < 1e-12); + assert_eq!(params.target_ppm, 2.0); + // One-shot: the pick disarms on commit, and the edit is one undo step. + assert!(app.session.ui.reference_pick.is_none()); + app.undo(); + assert_eq!(reference_params(&app, step).at_ppm, 1.0); +} + +/// A fresh step (zero offset) stores the picked coordinate verbatim. +#[test] +fn commit_on_a_fresh_step_stores_the_picked_position() { + let mut app = synthetic_app(); + let step = add_reference_step( + &mut app, + ReferenceParams { + at_ppm: 0.0, + target_ppm: 0.0, + }, + ); + let dataset = app.doc.datasets[0].resource_id(); + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app.resolve_reference_pick().expect("armed and expanded"); + + commit_reference_pick(&mut app, &resolved, 3.25); + + assert!((reference_params(&app, step).at_ppm - 3.25).abs() < 1e-12); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index cf61020..dceac92 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -154,6 +154,9 @@ pub fn render( // A sidebar may have changed an expanded Phase step before the canvas paints. app.sync_phase_interaction(); + // Same for an armed Reference pick: collapsing its editor or switching + // datasets must not leave a live click trap on the plot. + app.sync_reference_pick(); egui::CentralPanel::default() .frame(egui::Frame::new().fill(workspace_fill(dark)).inner_margin( central_workspace_margin( diff --git a/crates/app/src/ui/properties/sections.rs b/crates/app/src/ui/properties/sections.rs index 8d9a32a..267fb09 100644 --- a/crates/app/src/ui/properties/sections.rs +++ b/crates/app/src/ui/properties/sections.rs @@ -352,7 +352,35 @@ pub(crate) fn baseline_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut } pub(crate) fn reference_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { - processing_parameter_section(app, REFERENCE_SECTION, "Reference", target, ui) + let rendered = processing_parameter_section(app, REFERENCE_SECTION, "Reference", target, ui); + if rendered { + reference_pick_row(app, target, ui); + } + rendered +} + +/// The click-to-enter row that arms the one-shot on-plot pick of the source +/// position: click the peak on the spectrum instead of typing its ppm. +fn reference_pick_row(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) { + use plotx_core::automation::ComponentRef; + let Some(ComponentRef::ProcessingStep(step)) = target.component else { + return; + }; + let Ok(dataset) = plotx_core::state::DatasetId::try_from(&target.resource) else { + return; + }; + let armed = + app.session.ui.reference_pick == Some(plotx_core::state::ReferencePick { dataset, step }); + let label = if armed { + "Picking… click the spectrum (Esc cancels)" + } else { + "Pick position on spectrum" + }; + if crate::ui::affordance::selectable_row(ui, armed, egui_phosphor::regular::CROSSHAIR, label) + .clicked() + { + app.toggle_reference_pick(dataset, step); + } } pub(crate) fn smooth_section(app: &mut PlotxApp, target: &TargetRef, ui: &mut Ui) -> bool { diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 5ee8c6a..753ba5a 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -301,6 +301,58 @@ impl PlotxApp { } } + /// Arm or disarm the one-shot pick of a Reference step's source position + /// on the plot. Arming replaces any previously armed pick. + pub fn toggle_reference_pick(&mut self, dataset: DatasetId, step: plotx_processing::StepId) { + let pick = crate::state::ReferencePick { dataset, step }; + if self.session.ui.reference_pick.take() == Some(pick) { + self.session.status = "Reference pick cancelled.".to_owned(); + return; + } + self.session.ui.reference_pick = Some(pick); + self.session.status = + "Click the spectrum to pick the reference source position — Shift skips the peak snap, Esc cancels." + .to_owned(); + } + + /// The armed Reference pick resolved against the document, or `None` when + /// nothing valid is armed. A pick is valid only while the step editor that + /// armed it is still expanded on the active dataset and the step is still + /// a Reference step — the same lifetime the on-plot phase mode uses — so a + /// collapsed card cannot leave a live click trap on the plot. + pub fn resolve_reference_pick(&self) -> Option { + let pick = self.session.ui.reference_pick?; + if self.session.ui.proc_expanded_step != Some((pick.dataset, pick.step)) { + return None; + } + let dataset_index = self.active_dataset()?; + let dataset = self.doc.datasets.get(dataset_index)?; + if dataset.resource_id() != pick.dataset { + return None; + } + let axis = dataset.phase_axes().iter().copied().find(|&axis| { + dataset.axis_pipeline(axis).is_some_and(|pipe| { + pipe.steps.iter().any(|step| { + step.id == pick.step + && matches!(step.kind, plotx_processing::StepKind::Reference(_)) + }) + }) + })?; + Some(crate::state::ResolvedReferencePick { + pick, + dataset_index, + axis, + }) + } + + /// Drop an armed Reference pick that went stale — its editor collapsed, + /// the active dataset changed, or the step is gone. + pub fn sync_reference_pick(&mut self) { + if self.session.ui.reference_pick.is_some() && self.resolve_reference_pick().is_none() { + self.session.ui.reference_pick = None; + } + } + /// Switch the first enabled Phase step on `axis` to manual, seeding it from the /// phase the auto method currently yields so the display does not jump. Used by /// the on-plot phase grab and the panel's Manual/Auto switch. diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index fb3f3e8..f534d66 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -14,6 +14,7 @@ mod linefit; mod more; mod multiplet; mod pr2; +mod reference_pick; mod scheme_apply; mod stable_identity; mod stack; diff --git a/crates/core/src/actions/tests/reference_pick.rs b/crates/core/src/actions/tests/reference_pick.rs new file mode 100644 index 0000000..dabf737 --- /dev/null +++ b/crates/core/src/actions/tests/reference_pick.rs @@ -0,0 +1,83 @@ +//! Lifetime of the one-shot Reference on-plot pick: it is valid exactly while +//! the step editor that armed it stays expanded on the active dataset. + +use super::*; +use crate::state::{PhaseAxis, ReferencePick}; +use plotx_processing::{ProcessingStep, ReferenceParams, StepId, StepKind, StepSource}; + +fn add_reference_step(app: &mut PlotxApp) -> StepId { + let pipe = app.doc.datasets[0] + .axis_pipeline_mut(PhaseAxis::Direct) + .unwrap(); + let id = StepId::new(pipe.steps.iter().map(|s| s.id.get()).max().unwrap_or(0) + 1); + pipe.steps.push(ProcessingStep::new( + id, + StepKind::Reference(ReferenceParams { + at_ppm: 0.0, + target_ppm: 0.0, + }), + StepSource::User, + )); + id +} + +#[test] +fn reference_pick_resolves_only_while_its_editor_is_expanded() { + let mut app = sample_app(); + let step = add_reference_step(&mut app); + let dataset = dataset_id(&app, 0); + + app.toggle_reference_pick(dataset, step); + assert_eq!( + app.session.ui.reference_pick, + Some(ReferencePick { dataset, step }) + ); + // Armed but the editor is not expanded: not resolvable, and sync drops it. + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); + + app.session.ui.proc_expanded_step = Some((dataset, step)); + app.toggle_reference_pick(dataset, step); + let resolved = app + .resolve_reference_pick() + .expect("expanded editor arms a valid pick"); + assert_eq!(resolved.dataset_index, 0); + assert_eq!(resolved.axis, PhaseAxis::Direct); + assert_eq!(resolved.pick, ReferencePick { dataset, step }); + + // Collapsing the editor invalidates the pick; sync clears the arm state. + app.session.ui.proc_expanded_step = None; + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); +} + +#[test] +fn reference_pick_toggle_disarms_and_a_non_reference_step_never_resolves() { + let mut app = sample_app(); + let step = add_reference_step(&mut app); + let dataset = dataset_id(&app, 0); + app.session.ui.proc_expanded_step = Some((dataset, step)); + + app.toggle_reference_pick(dataset, step); + assert!(app.resolve_reference_pick().is_some()); + app.toggle_reference_pick(dataset, step); + assert!(app.session.ui.reference_pick.is_none()); + + // A pick armed for a step that is not a Reference step must not resolve — + // the pick would otherwise write at_ppm into a foreign step. + let phase_step = app.doc.datasets[0] + .axis_pipeline(PhaseAxis::Direct) + .unwrap() + .steps + .iter() + .find(|s| matches!(s.kind, StepKind::Phase(_))) + .unwrap() + .id; + app.session.ui.proc_expanded_step = Some((dataset, phase_step)); + app.toggle_reference_pick(dataset, phase_step); + assert!(app.resolve_reference_pick().is_none()); + app.sync_reference_pick(); + assert!(app.session.ui.reference_pick.is_none()); +} diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 8b611a4..ef1570f 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -96,6 +96,7 @@ mod peaks2d; mod plot_interaction; mod plot_object; mod pseudo_map_field; +mod reference_pick; mod region; mod reports; mod scientific_summary; @@ -189,6 +190,7 @@ pub use peaks2d::*; pub use plot_interaction::*; pub use plot_object::*; pub(crate) use pseudo_map_field::{DOSY_GRID_COLS, DOSY_GRID_ROWS, dosy_scalar_grid}; +pub use reference_pick::*; pub use region::*; pub use reports::*; pub use scientific_summary::*; diff --git a/crates/core/src/state/reference_pick.rs b/crates/core/src/state/reference_pick.rs new file mode 100644 index 0000000..cbcbefb --- /dev/null +++ b/crates/core/src/state/reference_pick.rs @@ -0,0 +1,20 @@ +//! The one-shot "pick a Reference position on the plot" arm state. + +use super::{DatasetId, PhaseAxis, StepId}; + +/// A one-shot request to pick a Reference step's source position (`at_ppm`) on +/// the plot. Typed IDs, because the arm state outlives the frame that set it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReferencePick { + pub dataset: DatasetId, + pub step: StepId, +} + +/// An armed [`ReferencePick`] resolved against the current document: the pick +/// plus the one-shot dataset index and the axis whose pipeline owns the step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ResolvedReferencePick { + pub pick: ReferencePick, + pub dataset_index: usize, + pub axis: PhaseAxis, +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 52b8481..8dc0973 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -22,6 +22,13 @@ mod trace_composer; pub use trace_composer::{TraceComposerItem, TraceComposerState}; mod trace_alignment; pub use trace_alignment::TraceAlignmentDialogState; +#[path = "ui_state_dialogs.rs"] +mod dialogs; +pub use dialogs::{ + AlignSpectraDialogState, CommandPaletteState, ProcessingSchemeDialogState, + ProcessingTemplateDialogState, SpectrumArithmeticDialogState, SpectrumArithmeticOp, + TemplateBrowserEntry, +}; #[path = "ui_state_rename.rs"] mod rename; pub use rename::{RenameState, RenameTarget}; @@ -101,92 +108,6 @@ impl SettingsDialog { } } -#[derive(Default)] -pub struct CommandPaletteState { - pub query: String, - pub selected: usize, -} - -pub enum ProcessingSchemeDialogState { - ResolvePending { - fallback_dataset: usize, - }, - Review { - path: std::path::PathBuf, - plan: crate::project::SchemeApplicationPlan, - policy: crate::project::SchemeApplicationPolicy, - }, -} - -pub struct TemplateBrowserEntry { - pub name: String, - pub path: std::path::PathBuf, - pub scheme: Result, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum SpectrumArithmeticOp { - AddDataset, - SubtractDataset, - MultiplyConstant, - AddConstant, -} - -impl SpectrumArithmeticOp { - pub const ALL: [Self; 4] = [ - Self::AddDataset, - Self::SubtractDataset, - Self::MultiplyConstant, - Self::AddConstant, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::AddDataset => "A + k·B", - Self::SubtractDataset => "A − k·B", - Self::MultiplyConstant => "A × k", - Self::AddConstant => "A + c", - } - } - - pub fn is_binary(self) -> bool { - matches!(self, Self::AddDataset | Self::SubtractDataset) - } -} - -#[derive(Clone, Copy)] -pub struct SpectrumArithmeticDialogState { - pub a: usize, - pub b: usize, - pub op: SpectrumArithmeticOp, - pub k: f64, - pub constant: f64, -} - -#[derive(Clone)] -pub struct AlignSpectraDialogState { - pub lo: f64, - pub hi: f64, - pub custom_target: bool, - pub target_ppm: f64, - /// Preview cache: peak detection over every candidate is too heavy to rerun - /// on each repaint, so the plan persists until inputs or the doc change. - pub plan: Option, - pub history_mark: (usize, usize), -} - -pub enum ProcessingTemplateDialogState { - SaveAs { - dataset: usize, - name: String, - }, - Browse { - dataset: usize, - entries: Vec, - confirm_delete: Option, - }, -} - /// Cached model-editor validation: the parsed definition plus its /// unclassified symbols on success, or the parse/compile error text. #[derive(Clone)] @@ -438,6 +359,10 @@ pub struct UiState { /// dataset is stored alongside it; without it, expanding a row on one /// dataset would light up the same-numbered row on every other one. pub proc_expanded_step: Option<(DatasetId, StepId)>, + /// The armed one-shot request to pick a Reference step's source position on + /// the plot. Armed by the step editor's pick button; disarmed by the pick + /// click, Escape, or going stale (see `PlotxApp::sync_reference_pick`). + pub reference_pick: Option, /// Latched result of the last phase-editing sync: `true` while the canvas is /// held in on-plot phase mode because a Phase step's editor is open. Edge- /// detected so a manual tool switch mid-phasing isn't fought each frame. @@ -600,6 +525,7 @@ impl Default for UiState { slice: None, slice_kind: plotx_processing::SliceKind::Row, proc_expanded_step: None, + reference_pick: None, phase_edit_active: false, proc_paused: false, proc_pending: None, diff --git a/crates/core/src/state/ui_state_dialogs.rs b/crates/core/src/state/ui_state_dialogs.rs new file mode 100644 index 0000000..3833f87 --- /dev/null +++ b/crates/core/src/state/ui_state_dialogs.rs @@ -0,0 +1,89 @@ +//! Working state for the modal dialogs the UI can open: command palette, +//! processing schemes and templates, spectrum arithmetic, and alignment. +//! Split from `ui_state.rs` to keep that file under the source-size limit. + +#[derive(Default)] +pub struct CommandPaletteState { + pub query: String, + pub selected: usize, +} + +pub enum ProcessingSchemeDialogState { + ResolvePending { + fallback_dataset: usize, + }, + Review { + path: std::path::PathBuf, + plan: crate::project::SchemeApplicationPlan, + policy: crate::project::SchemeApplicationPolicy, + }, +} + +pub struct TemplateBrowserEntry { + pub name: String, + pub path: std::path::PathBuf, + pub scheme: Result, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SpectrumArithmeticOp { + AddDataset, + SubtractDataset, + MultiplyConstant, + AddConstant, +} + +impl SpectrumArithmeticOp { + pub const ALL: [Self; 4] = [ + Self::AddDataset, + Self::SubtractDataset, + Self::MultiplyConstant, + Self::AddConstant, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::AddDataset => "A + k·B", + Self::SubtractDataset => "A − k·B", + Self::MultiplyConstant => "A × k", + Self::AddConstant => "A + c", + } + } + + pub fn is_binary(self) -> bool { + matches!(self, Self::AddDataset | Self::SubtractDataset) + } +} + +#[derive(Clone, Copy)] +pub struct SpectrumArithmeticDialogState { + pub a: usize, + pub b: usize, + pub op: SpectrumArithmeticOp, + pub k: f64, + pub constant: f64, +} + +#[derive(Clone)] +pub struct AlignSpectraDialogState { + pub lo: f64, + pub hi: f64, + pub custom_target: bool, + pub target_ppm: f64, + /// Preview cache: peak detection over every candidate is too heavy to rerun + /// on each repaint, so the plan persists until inputs or the doc change. + pub plan: Option, + pub history_mark: (usize, usize), +} + +pub enum ProcessingTemplateDialogState { + SaveAs { + dataset: usize, + name: String, + }, + Browse { + dataset: usize, + entries: Vec, + confirm_delete: Option, + }, +} diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index a17d280..2f6f656 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -629,6 +629,26 @@ impl AxisPipeline { .sum() } + /// Net chemical-shift translation applied at or after `step` by enabled + /// reference steps — the calibration separating the coordinates entering + /// `step` from the finished axis. A position picked on the displayed + /// spectrum converts into `step`'s own `at_ppm` by subtracting this value. + /// `step` counts only while enabled, matching how its current offset does + /// or does not shape the display. Kept beside + /// [`Self::chemical_shift_reference_offset_ppm`] so reference-step + /// semantics stay in one place. + pub fn chemical_shift_offset_from_step_ppm(&self, step: StepId) -> f64 { + self.steps + .iter() + .skip_while(|s| s.id != step) + .filter(|s| s.enabled) + .filter_map(|s| match s.kind { + StepKind::Reference(reference) => Some(reference.target_ppm - reference.at_ppm), + _ => None, + }) + .sum() + } + /// The zero-fill target for this axis: the last enabled `ZeroFill` step, or /// `None` when the axis carries none. pub fn zero_fill(&self) -> ZeroFill { diff --git a/crates/processing/src/tests.rs b/crates/processing/src/tests.rs index 59dc6ee..80c959f 100644 --- a/crates/processing/src/tests.rs +++ b/crates/processing/src/tests.rs @@ -490,6 +490,40 @@ fn pipeline_reference_offset_matches_all_enabled_reference_steps() { assert!((pipeline.chemical_shift_reference_offset_ppm() - 0.15).abs() < 1e-12); } +/// The from-step reduction is the calibration between a step's input axis and +/// the finished axis: it must count the step itself only while enabled, count +/// later enabled reference steps, and ignore earlier ones. +#[test] +fn pipeline_offset_from_step_counts_that_step_and_later_enabled_ones() { + let first = step(StepKind::Reference(ReferenceParams { + at_ppm: 1.0, + target_ppm: 1.2, + })); + let mut disabled = step(StepKind::Reference(ReferenceParams { + at_ppm: 4.0, + target_ppm: 20.0, + })); + disabled.enabled = false; + let last = step(StepKind::Reference(ReferenceParams { + at_ppm: 3.0, + target_ppm: 2.95, + })); + let (first_id, disabled_id, last_id) = (first.id, disabled.id, last.id); + let pipeline = AxisPipeline { + steps: vec![step(StepKind::Fft), first, disabled, last], + }; + + assert!((pipeline.chemical_shift_offset_from_step_ppm(first_id) - 0.15).abs() < 1e-12); + // A disabled step contributes nothing of its own, only what follows it. + assert!((pipeline.chemical_shift_offset_from_step_ppm(disabled_id) - -0.05).abs() < 1e-12); + assert!((pipeline.chemical_shift_offset_from_step_ppm(last_id) - -0.05).abs() < 1e-12); + // An id the pipeline does not hold calibrates by zero. + assert_eq!( + pipeline.chemical_shift_offset_from_step_ppm(StepId::new(u64::MAX)), + 0.0 + ); +} + #[test] fn public_process_returns_a_time_output_instead_of_panicking() { let data = fid(2.0, 0.0); diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 569a390..a6a8c19 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -29,8 +29,10 @@ screen is immediately usable, and a session touches at most three things: 2. **Baseline** — baseline correction is off by default; enable the step when the baseline rolls or offsets. 3. **Reference** — add a Reference step to pin a known peak to its - chemical-shift position. Peak marks are calibrated with the spectrum: - editing the Reference step moves existing marks along with the axis. + chemical-shift position, typing the source position or picking it directly + on the spectrum. Peak marks are calibrated with the spectrum: editing the + Reference step moves existing marks along with the axis. See + [Reference](#reference). 2D datasets get a cosine-bell apodization enabled by default. A true 2D acquisition shows two pipelines, **F2 (direct)** then **F1 (indirect)**, in the @@ -193,6 +195,24 @@ phase angles in radians, the pivot as a fraction. Baseline correction is off by default. Enable the step when your spectrum needs it. +## Reference + +A Reference step shifts the whole chemical-shift axis so the point currently at +**Source** reads **Target**: pin your standard (TMS, DSS, a solvent line) to +its known position. Add it from **Add step → Frequency domain → Reference**. + +You can type the source position, or pick it on the spectrum: open the step and +click **Pick position on spectrum**. Hovering the plot previews the position — +the pick snaps to the nearest peak apex within a small window around the +cursor, so zooming in first lets you land on a weak line precisely. Hold +Shift to skip the snap and read the exact cursor position instead. +Click to fill **Source**, then type the shift that peak should have into +**Target**. Esc, collapsing the step, or switching datasets cancels +an unused pick. + +Peak marks are calibrated with the spectrum, so editing a Reference step moves +existing marks along with the axis. + ## Reusing a pipeline A pipeline (including the group-delay setting) can be saved as a portable diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index 0726f19..f8c7921 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -21,8 +21,9 @@ XPS 为每个谱区使用独立的有序 recipe,而不是 NMR 管线。recipe 1. **相位**——自动结果不理想时,打开相位校正步骤手动调节 φ0 / φ1 并 实时预览,或切换自动算法。 2. **基线**——基线校正默认关闭;基线起伏或偏移时启用该步骤。 -3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置。峰标记与谱图共用 - 同一定标:修改参考步骤时,已有的峰标记会随坐标轴一起平移。 +3. **参考**——添加参考步骤,把已知峰定标到其化学位移位置;源位置可以直接 + 输入,也可以在谱图上拾取。峰标记与谱图共用同一定标:修改参考步骤时, + 已有的峰标记会随坐标轴一起平移。参见[参考(定标)](#参考定标)。 2D 数据集默认启用余弦钟形切趾。真 2D 谱会按处理顺序显示两条管线:先 **F2 (direct)**,后 **F1 (indirect)**。已经变换过的数据标记为 @@ -164,6 +165,21 @@ FFT 是一个普通的 *Time to Frequency* 类型步骤,而不是列表中固 基线校正默认关闭。谱图需要时启用该步骤即可。 +## 参考(定标) + +参考步骤把整条化学位移轴平移,使当前位于 **Source** 的点读作 **Target**: +用它把标准物(TMS、DSS 或溶剂峰)定标到已知位置。从 **Add step → +Frequency domain → Reference** 添加。 + +Source 既可以直接输入,也可以在谱图上拾取:展开该步骤并点击 +**Pick position on spectrum**。悬停谱图即可预览位置——拾取会吸附到光标附近 +小窗口内最近的峰顶,因此先放大再点击就能精确落在弱峰上。按住 +Shift 可跳过吸附,直接读取光标位置。单击填入 **Source**,再在 +**Target** 中输入该峰应有的化学位移。按 Esc、折叠该步骤或切换 +数据集都会取消未使用的拾取。 + +峰标记与谱图共用同一定标,因此编辑参考步骤时已有标记会随坐标轴一起平移。 + ## 复用管线 管线(含群延迟设置)可保存为可移植的 `.plotxproc` 配方文件或命名模板,