From e423555b3d7bc41f2e6bf3561cc596d2f1f3feb1 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 4 Nov 2026 18:37:17 +0800 Subject: [PATCH] feat(nmr): unify phase entry and axis semantics --- crates/app/src/ui/canvas/breadcrumb.rs | 54 +- crates/app/src/ui/canvas/mod.rs | 7 +- crates/app/src/ui/canvas/phase.rs | 26 + crates/app/src/ui/command_exec.rs | 4 +- crates/app/src/ui/commands.rs | 19 +- crates/app/src/ui/commands/identity.rs | 11 +- crates/app/src/ui/commands/ribbon.rs | 6 +- crates/app/src/ui/commands/roster.rs | 2 +- crates/app/src/ui/commands_identity_tests.rs | 4 +- crates/app/src/ui/commands_ribbon_tests.rs | 89 +++- crates/app/src/ui/mod.rs | 6 + crates/app/src/ui/nmr_volume.rs | 492 ++++++++++-------- crates/app/src/ui/nmr_volume_tests.rs | 419 +++++++++++++++ crates/app/src/ui/properties/discovery.rs | 27 +- .../app/src/ui/properties/discovery_tests.rs | 5 +- crates/app/src/ui/ribbon/layout.rs | 9 +- .../actions/app_impl/processing_snapshot.rs | 17 + crates/core/src/state/dataset_delete.rs | 1 + crates/core/src/state/ui_state.rs | 1 + crates/processing/src/nmr_volume.rs | 101 +++- .../processing/src/nmr_volume_phase_tests.rs | 49 ++ docs/src/content/docs/guides/nmr-3d.md | 41 +- docs/src/content/docs/guides/processing.md | 10 + docs/src/content/docs/reference/ribbon.md | 3 +- docs/src/content/docs/zh-cn/guides/nmr-3d.md | 34 +- .../content/docs/zh-cn/guides/processing.md | 8 + .../content/docs/zh-cn/reference/ribbon.md | 3 +- 27 files changed, 1162 insertions(+), 286 deletions(-) create mode 100644 crates/app/src/ui/nmr_volume_tests.rs create mode 100644 crates/processing/src/nmr_volume_phase_tests.rs diff --git a/crates/app/src/ui/canvas/breadcrumb.rs b/crates/app/src/ui/canvas/breadcrumb.rs index 9b8c7b09..2b5c9540 100644 --- a/crates/app/src/ui/canvas/breadcrumb.rs +++ b/crates/app/src/ui/canvas/breadcrumb.rs @@ -13,6 +13,7 @@ pub(crate) fn canvas_breadcrumb(app: &mut PlotiumApp, ci: usize, ui: &mut Ui) { .object(id) .map(|object| (id, object.name.clone())) }); + let phase = (app.session.tool == Tool::ManualPhase).then(|| phase_target(app, ci)); let mut clicked = None; ui.horizontal(|ui| { ui.add_space(2.0); @@ -30,10 +31,14 @@ pub(crate) fn canvas_breadcrumb(app: &mut PlotiumApp, ci: usize, ui: &mut Ui) { if ui.small_button(name).clicked() { clicked = Some(BreadcrumbTarget::Content(id)); } - } else if lead.is_none() { + } else if lead.is_none() && phase.is_none() { ui.weak("›"); ui.small(app.session.tool.label()); } + if let Some(phase) = phase { + ui.weak("›"); + ui.small(phase); + } }); ui.add_space(2.0); match clicked { @@ -44,6 +49,53 @@ pub(crate) fn canvas_breadcrumb(app: &mut PlotiumApp, ci: usize, ui: &mut Ui) { } } +fn phase_target(app: &PlotiumApp, ci: usize) -> String { + use plotium_core::state::PhaseAxis; + let target = data_edit_target(app, ci) + .and_then(|id| app.doc.canvases[ci].object(id)) + .and_then(|object| object.dataset()) + .and_then(|id| app.doc.dataset_index(id)) + .and_then(|di| app.doc.datasets.get(di)); + let Some(dataset) = target else { + return "Phase · Select a spectrum plot".into(); + }; + if crate::ui::nmr_volume::phase_target(app) != Some(dataset.resource_id()) { + return "Phase · Select a plot of the chosen spectrum".into(); + } + let axis = dataset.active_phase_axis(app.session.ui.phase_axis); + let (nucleus, scope) = match dataset { + Dataset::Nmr(nmr) => (nmr.data.axes().first(), "whole spectrum"), + Dataset::Nmr2D(nmr) if nmr.volume.is_none() => ( + match axis { + PhaseAxis::F1 => nmr.native_base.axes().first(), + _ => nmr.native_base.axes().last(), + }, + "all traces along this axis", + ), + _ => return "Phase · Select a phaseable spectrum plot".into(), + }; + if !crate::ui::nmr_volume::canvas_axis_phaseable(dataset, axis) { + return format!( + "Phase · {} · No enabled frequency-domain phase step on {}", + dataset.display_name(), + axis.label() + ); + } + let nucleus = nucleus + .and_then(|axis| axis.nucleus.as_deref()) + .unwrap_or("unknown"); + let direction = if axis == PhaseAxis::F1 { + "vertical axis" + } else { + "horizontal axis" + }; + format!( + "Phase · {} · {} · {nucleus} · {direction} · {scope}", + dataset.display_name(), + axis.label() + ) +} + enum BreadcrumbTarget { Canvas, Panel(PanelId), diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index fcc59257..30eed835 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -136,7 +136,6 @@ pub(crate) enum CanvasInteractionClearScope { } pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { - super::nmr_volume::poll_phase(app, ui.ctx()); let Some(ci) = app.session.active_canvas else { if app.doc.canvases.is_empty() && !board_frames(app).is_empty() { board::render_sheets_only(app, ui); @@ -339,7 +338,10 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { match app.session.tool { // Ahead of the `pointer_owned` guard: the pivot line is painted here, // and it stays on screen whatever the pointer is over. - Tool::ManualPhase => { + Tool::ManualPhase + if super::nmr_volume::phase_target(app) + == Some(app.doc.datasets[di].resource_id()) => + { let axis = app.doc.datasets[di].active_phase_axis(app.session.ui.phase_axis); if let Some(pivot_ppm) = displayed_phase_pivot_ppm(app, di, axis) { let fig = &app.doc.canvases[ci] @@ -411,6 +413,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { | Tool::Rect | Tool::Ellipse | Tool::Line + | Tool::ManualPhase | Tool::Arrow => {} } } diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index 0db8b61a..9ecbd750 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -12,6 +12,28 @@ pub(crate) struct PhaseDragCtx { pub map_rev: bool, } +#[test] +fn pivot_is_hidden_when_the_axis_phase_step_is_disabled_or_removed() { + let mut app = PlotiumApp::new(); + app.doc + .datasets + .push(crate::ui::properties::fixture::homonuclear_frequency_2d()); + let axis = PhaseAxis::F2; + assert!(displayed_phase_pivot_ppm(&app, 0, axis).is_some()); + for step in &mut app.doc.datasets[0].axis_pipeline_mut(axis).unwrap().steps { + if matches!(step.kind, plotium_processing::StepKind::Phase(_)) { + step.enabled = false; + } + } + assert!(displayed_phase_pivot_ppm(&app, 0, axis).is_none()); + app.doc.datasets[0] + .axis_pipeline_mut(axis) + .unwrap() + .steps + .retain(|step| !matches!(step.kind, plotium_processing::StepKind::Phase(_))); + assert!(displayed_phase_pivot_ppm(&app, 0, axis).is_none()); +} + /// The committed pivot, overridden by the canvas-only position while its handle /// is being dragged. pub(crate) fn displayed_phase_pivot_ppm( @@ -22,6 +44,7 @@ pub(crate) fn displayed_phase_pivot_ppm( if app.doc.datasets[dataset] .as_nmr2d() .is_some_and(|n| n.volume.is_some()) + || !crate::ui::nmr_volume::canvas_axis_phaseable(&app.doc.datasets[dataset], axis) { return None; } @@ -61,6 +84,9 @@ pub(crate) fn handle_phase_before_paint( else { return; }; + if crate::ui::nmr_volume::phase_target(app) != Some(app.doc.datasets[di].resource_id()) { + return; + } let Some(outer) = content_screen_rect( app.session.board, &app.doc.canvases[ci], diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 0894a561..17db90d0 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -60,7 +60,7 @@ fn execute_inner( } CommandId::OpenFile => super::file_dialogs::open_file(app), CommandId::OpenFolder => super::file_dialogs::open_folder(app), - CommandId::CancelNmrVolumePhase => super::nmr_volume::cancel_phase(ctx), + CommandId::CancelNmrVolumePhase => super::nmr_volume::cancel_phase(app, ctx), CommandId::Peak3DExport | CommandId::Peak3DDelete | CommandId::Peak3DLocate @@ -356,7 +356,7 @@ fn execute_inner( CommandId::Tool(Tool::Symmetry) => { reveal_tool_group(app, Tool::Symmetry, ToolGroup::Nmr2dExperiment); } - CommandId::OpenNmrVolumePhase => { + CommandId::Tool(Tool::ManualPhase) if super::nmr_volume::active_volume(app) => { super::nmr_volume::open_phase(app); } CommandId::Tool(tool) => app.toggle_tool(tool), diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index dda331bc..7fa2453d 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -33,7 +33,6 @@ pub enum CommandId { OpenFolder, ImportNmrSampling, ApplyNmrVolumePhase, - OpenNmrVolumePhase, CancelNmrVolumePhase, PreviousNmrPlane, NextNmrPlane, @@ -300,7 +299,6 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { app.session.ui.table_import_preview.is_none(), "Finish or cancel the current table import preview before importing another table.", ), - CommandId::OpenNmrVolumePhase => requires(super::nmr_volume::active_volume(app), "Select a three-dimensional NMR spectrum before opening phase settings."), CommandId::CancelNmrVolumePhase => requires(app.session.ui.nmr_volume_phase_busy, "Start phase processing before cancelling it."), CommandId::Peak3DExport | CommandId::Peak3DDelete | CommandId::Peak3DLocate | CommandId::Peak3DPrevious | CommandId::Peak3DNext => super::peaks3d::gate(app, id), CommandId::ApplyNmrVolumePhase => super::nmr_volume::phase_gate(app), @@ -721,14 +719,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { .is_some_and(|dataset| dataset.supports_symmetry_review()), "Select a homonuclear COSY, TOCSY, or NOESY / ROESY contour spectrum.", ), - // Share the Phase settings gate so its tool stays consistent. - CommandId::Tool(Tool::ManualPhase) => requires( - super::properties::discovery::group_applies( - app, - super::properties::panel::PHASE_SECTION, - ), - "Select an NMR spectrum with a phase processing step before phasing it by hand.", - ), + CommandId::Tool(Tool::ManualPhase) => super::nmr_volume::open_phase_gate(app), CommandId::Tool(tool) if tool.is_data_tool() => requires( dataset().is_some(), "Select a dataset before using this data tool.", @@ -743,9 +734,10 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { let palette_available = helpers::palette_available(app, id); // Activation requirements must not trap an already-active tool after the // dataset context changes: its command remains available for deactivation. - let active_tool = id - .tool_target() - .is_some_and(|tool| app.session.tool == tool); + let active_tool = id.tool_target().is_some_and(|tool| { + app.session.tool == tool + && !(tool == Tool::ManualPhase && super::nmr_volume::active_volume(app)) + }); let enabled = (gate.is_ok() || active_tool) && palette_available; let disabled_reason = if enabled { None } else { gate.err() }; // A contextual group is dropped from the Ribbon entirely rather than shown @@ -753,7 +745,6 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { // `groups_for_tab`, pushing usable groups into the overflow menu. let ribbon = ribbon_placement(id).filter(|placement| match placement.applicability { Applicability::Always => true, - Applicability::NmrVolumeOnly => super::nmr_volume::active_volume(app), Applicability::LineAlignmentOnly => app.trace_alignment_target().is_some(), Applicability::TableOnly => is_table(), Applicability::XasOnly => dataset().and_then(Dataset::as_xas).is_some(), diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 92f48dd2..1b071ebe 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -57,9 +57,8 @@ pub(super) fn command_identity( CommandId::CloseWindow => plain("Close Window", Some(icon::X)), CommandId::OpenFile => plain("Open File…", Some(icon::FILE)), CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)), - CommandId::OpenNmrVolumePhase => plain("3D Phase settings", Some(icon::ANGLE)), - CommandId::CancelNmrVolumePhase => plain("Cancel 3D NMR Phase", Some(icon::X)), - CommandId::ApplyNmrVolumePhase => plain("Apply 3D NMR Phase", Some(icon::PLAY)), + CommandId::CancelNmrVolumePhase => plain("Stop phase calculation", Some(icon::X)), + CommandId::ApplyNmrVolumePhase => plain("Apply volume phase", Some(icon::PLAY)), CommandId::PreviousNmrPlane => plain("Previous NMR Plane", Some(icon::CARET_LEFT)), CommandId::ImportProtein => plain("Import protein sequence…", None), CommandId::ProteinAssignments => plain("Protein assignments", None), @@ -385,9 +384,10 @@ pub(super) fn command_identity( } CommandId::CycleCursor => plain("Next Cursor", Some(icon::CROSSHAIR)), CommandId::Tool(Tool::ManualPhase) => ( - "Interactive phase".into(), + "Phase".into(), tool_icon(Tool::ManualPhase), - Some(app.session.tool == Tool::ManualPhase), + (!super::super::nmr_volume::active_volume(app)) + .then_some(app.session.tool == Tool::ManualPhase), ), CommandId::Tool(tool) => ( format!("Tool: {}", tool.label()), @@ -475,7 +475,6 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::OpenFile => "file.open_file", CommandId::OpenFolder => "file.open_folder", CommandId::ImportNmrSampling => "file.import_nmr_sampling", - CommandId::OpenNmrVolumePhase => "nmr.phase.open", CommandId::CancelNmrVolumePhase => "nmr.phase.cancel", CommandId::ApplyNmrVolumePhase => "nmr.phase.apply", CommandId::PreviousNmrPlane => "nmr.plane.previous", diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs index 9a05644f..e06eb16d 100644 --- a/crates/app/src/ui/commands/ribbon.rs +++ b/crates/app/src/ui/commands/ribbon.rs @@ -21,7 +21,6 @@ pub enum Applicability { XasOnly, SeriesOnly, Homonuclear2dOnly, - NmrVolumeOnly, ToolGroup(ToolGroup), } @@ -68,7 +67,6 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly), CommandId::AlignTraces => (Analyze, "Overlay", 1, LineAlignmentOnly), CommandId::XpsWorkbench(_) => (Analyze, "XPS", 0, Applicability::ToolGroup(ToolGroup::Xps)), - CommandId::OpenNmrVolumePhase => (Process, "3D Phase", 0, Applicability::NmrVolumeOnly), CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always), CommandId::SpectrumArithmetic | CommandId::AlignSpectra | CommandId::Craft => { (Process, "Transform", 1, Always) @@ -139,7 +137,9 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { ) => (Arrange, "Annotate", 3, Always), // The group's own declaration decides where it lands, so a new group // needs no arm here. - CommandId::PropertyGroup(section) => { + CommandId::PropertyGroup(section) + if section != crate::ui::properties::panel::PHASE_SECTION => + { let spot = crate::ui::properties::discovery::group(section)?.ribbon; (spot.tab, spot.group, spot.priority, Always) } diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index 3173adbd..4b3235fb 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -21,7 +21,6 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::OpenFolder, CommandId::ImportNmrSampling, CommandId::ApplyNmrVolumePhase, - CommandId::OpenNmrVolumePhase, CommandId::CancelNmrVolumePhase, CommandId::PreviousNmrPlane, CommandId::NextNmrPlane, @@ -190,6 +189,7 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { ids.extend( crate::ui::properties::GROUPS .iter() + .filter(|group| group.section != crate::ui::properties::panel::PHASE_SECTION) .map(|group| CommandId::PropertyGroup(group.section)), ); ids.extend([PropertyStep::Lower, PropertyStep::Raise].map(CommandId::StepProperty)); diff --git a/crates/app/src/ui/commands_identity_tests.rs b/crates/app/src/ui/commands_identity_tests.rs index bc2cef83..49752621 100644 --- a/crates/app/src/ui/commands_identity_tests.rs +++ b/crates/app/src/ui/commands_identity_tests.rs @@ -51,10 +51,10 @@ fn phase_label_retains_interactive_tool_toggle_identity() { let mut app = PlotiumApp::new(); let id = CommandId::Tool(Tool::ManualPhase); let description = describe(&app, id); - assert_eq!(description.label, "Interactive phase"); + assert_eq!(description.label, "Phase"); assert_eq!(description.checked, Some(false)); app.session.tool = Tool::ManualPhase; let description = describe(&app, id); - assert_eq!(description.label, "Interactive phase"); + assert_eq!(description.label, "Phase"); assert_eq!(description.checked, Some(true)); } diff --git a/crates/app/src/ui/commands_ribbon_tests.rs b/crates/app/src/ui/commands_ribbon_tests.rs index 07c4d14e..517e256d 100644 --- a/crates/app/src/ui/commands_ribbon_tests.rs +++ b/crates/app/src/ui/commands_ribbon_tests.rs @@ -38,34 +38,52 @@ fn ribbon_offers_the_grid_popover_instead_of_preset_tiles() { assert!(describe(&app_with_nmr(), CommandId::ArrangeGridCustom).enabled); } -/// The on-plot phase tool is gated like the Phase settings tile: a table or -/// an empty document cannot be phased, and the tooltip says what can. #[test] -fn manual_phase_needs_a_spectrum_with_a_phase_step() { - let reason = "Select an NMR spectrum with a phase processing step before phasing it by hand."; +fn manual_phase_explains_missing_targets_and_disabled_steps() { + let id = CommandId::Tool(Tool::ManualPhase); for app in [app(), app_with_table(1)] { - let command = describe(&app, CommandId::Tool(Tool::ManualPhase)); + let command = describe(&app, id); assert!(!command.enabled); - assert_eq!(command.disabled_reason, Some(reason)); + assert_eq!( + command.disabled_reason, + Some("Select an NMR spectrum before opening Phase.") + ); + } + let mut app = app_with_nmr(); + assert!(describe(&app, id).enabled); + let di = app.active_dataset().unwrap(); + let pipeline = app.doc.datasets[di] + .axis_pipeline_mut(plotium_core::state::PhaseAxis::Direct) + .unwrap(); + for step in &mut pipeline.steps { + if matches!(step.kind, plotium_processing::StepKind::Phase(_)) { + step.enabled = false; + } } - assert!(describe(&app_with_nmr(), CommandId::Tool(Tool::ManualPhase)).enabled); + let command = describe(&app, id); + assert!(!command.enabled); + assert!(command.disabled_reason.unwrap().contains("Phase step")); } #[test] -fn phase_settings_and_interactive_tool_keep_distinct_ribbon_entries() { +fn phase_has_one_task_entry() { let app = app_with_nmr(); let interactive = describe(&app, CommandId::Tool(Tool::ManualPhase)); let settings = describe( &app, CommandId::PropertyGroup(super::super::properties::panel::PHASE_SECTION), ); - assert_eq!(interactive.label, "Interactive phase"); + assert_eq!(interactive.label, "Phase"); assert_eq!(settings.label, "Phase settings"); assert!(interactive.enabled && settings.enabled); - assert!(interactive.ribbon.is_some() && settings.ribbon.is_some()); + assert!(interactive.ribbon.is_some() && settings.ribbon.is_none()); assert_eq!(interactive.checked, Some(false)); assert_eq!(settings.checked, None); - assert!(!describe(&app, CommandId::OpenNmrVolumePhase).enabled); + assert!( + !catalog(&app) + .iter() + .any(|command| command.id == settings.id) + ); } #[test] @@ -80,3 +98,52 @@ fn interactive_phase_remains_a_toggle_for_standalone_spectra() { assert_ne!(app.session.tool, Tool::ManualPhase); assert_eq!(describe(&app, tool).checked, Some(false)); } + +#[test] +fn phase_dispatches_true_2d_to_the_canvas() { + let mut app = app(); + let action = plotium_core::actions::Action::insert_dataset_with_default_canvas( + &app, + super::super::properties::fixture::homonuclear_frequency_2d(), + "Contour".to_owned(), + plotium_core::state::DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + let id = CommandId::Tool(Tool::ManualPhase); + let description = describe(&app, id); + assert!(description.enabled, "{:?}", description.disabled_reason); + execute_without_clipboard(id, &mut app, &egui::Context::default()); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert_eq!(describe(&app, id).checked, Some(true)); +} + +#[test] +fn phase_does_not_route_a_data_list_target_to_another_plot() { + use plotium_core::state::SelectionScope; + + let mut app = app_with_nmr(); + let plotted = app.active_dataset().unwrap(); + let selected = app.doc.datasets.len(); + app.doc + .datasets + .push(super::super::properties::fixture::homonuclear_frequency_2d()); + app.focus_single(selected); + app.session.ui.selection_scope = SelectionScope::DataList; + let command = CommandId::Tool(Tool::ManualPhase); + let description = describe(&app, command); + assert!(!description.enabled); + assert!( + description + .disabled_reason + .unwrap() + .contains("chosen spectrum") + ); + execute_without_clipboard(command, &mut app, &egui::Context::default()); + assert_ne!(app.session.tool, Tool::ManualPhase); + app.session.ui.selection_scope = SelectionScope::CanvasObjects; + assert_eq!( + super::super::nmr_volume::phase_target(&app), + Some(app.doc.datasets[plotted].resource_id()) + ); + assert!(describe(&app, command).enabled); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 7fe19578..0a3120ad 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -92,6 +92,12 @@ pub fn render( ribbon_chrome: RibbonChrome, ) { let ctx = ui.ctx().clone(); + nmr_volume::poll_phase(app, &ctx); + if let plotium_core::state::Interaction::Phase(drag) = app.interaction() + && nmr_volume::phase_target(app) != Some(drag.dataset) + { + app.cancel_interaction(); + } result_follow::begin_frame(&ctx); let title = project_window_title(app); if last_window_title.as_deref() != Some(title.as_str()) { diff --git a/crates/app/src/ui/nmr_volume.rs b/crates/app/src/ui/nmr_volume.rs index 65d6e251..1a50863a 100644 --- a/crates/app/src/ui/nmr_volume.rs +++ b/crates/app/src/ui/nmr_volume.rs @@ -4,14 +4,38 @@ use plotium_core::state::{Dataset, PlotiumApp}; use super::commands::{self, CommandId}; +fn canvas_phase_target(app: &PlotiumApp) -> Option { + let canvas = app.doc.canvases.get(app.session.active_canvas?)?; + let plot = canvas + .selected_plot_object_id() + .or_else(|| canvas.active_plot_object_id())?; + canvas.object(plot)?.dataset() +} + +pub(super) fn phase_target(app: &PlotiumApp) -> Option { + use plotium_core::state::SelectionScope; + let listed = app + .active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .map(Dataset::resource_id); + match app.session.ui.selection_scope { + SelectionScope::DataList => listed, + _ => canvas_phase_target(app).or(listed), + } +} + pub(super) fn active_volume(app: &PlotiumApp) -> bool { - app.active_dataset() + phase_target(app) + .and_then(|id| app.doc.dataset_index(id)) .and_then(|di| app.doc.datasets.get(di)) .and_then(Dataset::as_nmr2d) .is_some_and(|n| n.volume.is_some()) } pub(super) fn open_phase(app: &mut PlotiumApp) { + if let Some(di) = phase_target(app).and_then(|id| app.doc.dataset_index(id)) { + app.focus_single(di); + } app.cancel_interaction(); app.set_tool(plotium_core::state::Tool::BrowseZoom); super::tools::expand_processing_surface(app); @@ -144,14 +168,13 @@ pub(super) fn navigation(app: &mut PlotiumApp, ui: &mut Ui) { } pub(super) fn apply_phase(app: &mut PlotiumApp, ctx: &egui::Context) { - let Some(di) = app.active_dataset() else { - return; - }; - let id = app.doc.datasets[di].resource_id(); + poll_phase(app, ctx); if let Err(reason) = phase_gate(app) { - app.session.status = reason.into(); + app.session.status = reason.to_owned(); return; } + let id = phase_target(app).expect("validated phase target"); + let di = app.doc.dataset_index(id).expect("validated phase dataset"); let draft = app.session.ui.nmr_volume_phase_drafts[&id].clone(); let Some(mut dataset) = app.doc.datasets[di].as_nmr2d().cloned() else { return; @@ -161,10 +184,9 @@ pub(super) fn apply_phase(app: &mut PlotiumApp, ctx: &egui::Context) { .as_ref() .expect("local volume invariant") .phases; - if phase_busy(ctx) { - return; - } app.session.ui.nmr_volume_phase_busy = true; + let name = app.doc.datasets[di].display_name(); + app.session.status = format!("Calculating phase for {name}…"); let cancellation = nmr::CancellationToken::new(); let worker_cancellation = cancellation.clone(); let output = std::sync::Arc::new(std::sync::Mutex::new(None)); @@ -172,7 +194,9 @@ pub(super) fn apply_phase(app: &mut PlotiumApp, ctx: &egui::Context) { data.insert_temp( egui::Id::new("volume_phase_job"), PhaseJob { + project: app.session.project_context, id, + name, expected, cancellation, output: output.clone(), @@ -206,17 +230,29 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { return; }; let committed = volume.phases; - let mut draft = app - .session - .ui - .nmr_volume_phase_drafts - .get(&id) - .cloned() - .filter(|draft| draft.baseline == committed) - .unwrap_or(plotium_core::state::NmrVolumePhaseDraft { - baseline: committed, - phases: committed, - }); + let (mut draft, stale) = phase_draft(app, id, volume); + let name = app.doc.datasets[di].display_name(); + let fixed = volume.fixed_axis; + let axes = std::array::from_fn::<_, 3, _>(|axis| { + let nucleus = volume.source.axes()[axis] + .nucleus + .as_deref() + .unwrap_or("unknown"); + ( + format!("F{} · {nucleus} · {}", axis + 1, axis_role(axis, fixed)), + volume.processed.supports_phase(axis), + volume.processed.phase_pivot_scale(axis), + ) + }); + // Once reset to the current baseline, hidden edits must not return on Undo. + if stale { + discard_phase_draft(app, id); + } + let job = host + .ctx() + .data(|data| data.get_temp::(egui::Id::new("volume_phase_job"))); + let busy = app.session.ui.nmr_volume_phase_busy; + let owns_target = phase_target(app) == Some(id); let geometry = task_card::geometry( app, host, @@ -230,39 +266,80 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { task_card::area(host, area_id, geometry.pos).show(host.ctx(), |ui| { ui.set_width(geometry.width); crate::ui::card_frame(ui.visuals().dark_mode, egui::Margin::ZERO).show(ui, |ui| { - task_card::header(ui, area_id, "3D Processing", Some("Phase adjustment"), |ui| { - if crate::ui::panel_chrome::close(ui, "Close Processing").clicked() { close = true; } + task_card::header(ui, area_id, "Phase", Some(name.as_str()), |ui| { + let response = crate::ui::panel_chrome::close(ui, "Close Phase") + .on_hover_text("Hide Phase. Keeps the draft and lets any calculation continue."); + if response.clicked() { close = true; } }); if collapsed { return; } egui::ScrollArea::vertical().max_height(geometry.body_height).show(ui, |ui| { - ui.small("Basic transform completed. Phase is user-adjustable; an absorptive spectrum has not been independently confirmed."); - for (axis, phase) in draft.phases.iter_mut().enumerate() { - ui.separator(); - ui.strong(format!("Acquisition F{}", axis + 1)); - ui.horizontal(|ui| { - ui.label("Zero order"); - ui.add(DragValue::new(&mut phase.p0_degrees).speed(0.1).suffix("°")); - }); - ui.horizontal(|ui| { - ui.label("First order"); - ui.add(DragValue::new(&mut phase.p1_degrees).speed(0.1).suffix("°")); - }); - ui.horizontal(|ui| { - ui.label("Pivot fraction"); - ui.add(DragValue::new(&mut phase.pivot_fraction).range(0.0..=1.0).speed(0.001)); - }); - } - app.session.ui.nmr_volume_phase_drafts.insert(id, draft.clone()); - let cancel = commands::describe(app, CommandId::CancelNmrVolumePhase); - if ui.add_enabled(cancel.enabled, egui::Button::new("Cancel processing")).on_disabled_hover_text(cancel.disabled_reason.unwrap_or_default()).clicked() { - commands::execute_without_clipboard(CommandId::CancelNmrVolumePhase, app, ui.ctx()); - } - if app.session.ui.nmr_volume_phase_busy { ui.spinner(); ui.label("Processing phase…"); } - let desc = commands::describe(app, CommandId::ApplyNmrVolumePhase); - if ui.add_enabled(desc.enabled, egui::Button::new("Apply phase")) - .on_disabled_hover_text(desc.disabled_reason.unwrap_or_default()).clicked() { - commands::execute_without_clipboard(CommandId::ApplyNmrVolumePhase, app, ui.ctx()); - } + ui.label("Applies to the entire 3D spectrum. The current plane is for observation."); + ui.small("PH0 and PH1 set the correction on the source data. Apply replaces the previous settings. An absorptive spectrum has not been independently confirmed."); + if stale { + ui.label("The source or committed phase changed. Editing starts from the current settings."); + } + if draft.phases != committed { + ui.strong("Changes have not been applied. No live preview."); + } + if let Some(job) = &job { + ui.spinner(); + let action = if job.cancellation.is_cancelled() { "Stopping" } else { "Calculating phase for" }; + ui.label(format!("{action} {}{}", job.name, if job.id == id { " (this spectrum)" } else { " (another spectrum)" })); + ui.small("Closing Phase leaves the calculation running."); + let stop = commands::describe(app, CommandId::CancelNmrVolumePhase); + if ui.add_enabled(stop.enabled, egui::Button::new("Stop calculation")).clicked() { + commands::execute_without_clipboard(CommandId::CancelNmrVolumePhase, app, ui.ctx()); + } + } + if !owns_target { + ui.small("Select this spectrum to edit its phase, or open Phase for the current spectrum."); + } + ui.push_id(id, |ui| ui.add_enabled_ui(!busy && owns_target, |ui| { + for (axis, (label, supported, scale)) in axes.iter().enumerate() { + ui.separator(); + ui.strong(label); + if !supported { + ui.small("Phase unavailable: this axis needs a complex frequency-domain signal."); + continue; + } + let phase = &mut draft.phases[axis]; + for (label, value) in [("PH0", &mut phase.p0_degrees), ("PH1", &mut phase.p1_degrees)] { + ui.horizontal(|ui| { + ui.label(label); + let mut next = *value; + if ui.add(DragValue::new(&mut next).speed(0.1).suffix("°")).changed() && next.is_finite() { + *value = next; + } + }); + } + match scale.as_ref().map_err(Clone::clone).and_then(|scale| scale.ppm(phase.pivot_fraction).map(|ppm| (scale, ppm))) { + Ok((scale, mut ppm)) => { + ui.horizontal(|ui| { + ui.label("Pivot"); + if ui.add(DragValue::new(&mut ppm).speed(0.001).max_decimals(8).suffix(" ppm")).changed() + && let Err(error) = scale.fraction(ppm).and_then(|q| phase.repivot(q)) + { + app.session.status = format!("Cannot move pivot for {name}: {error}"); + } + }); + } + Err(error) => { ui.small(format!("Pivot unavailable: {error}")); } + } + } + })); + let dirty = draft.phases != committed; + if ui.add_enabled(!busy && dirty, egui::Button::new("Discard changes")).clicked() + || (!busy && !dirty && !stale) + { + discard_phase_draft(app, id); + } else if dirty { + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + } + let desc = commands::describe(app, CommandId::ApplyNmrVolumePhase); + if ui.add_enabled(desc.enabled && owns_target, egui::Button::new("Apply")) + .on_disabled_hover_text(desc.disabled_reason.unwrap_or_default()).clicked() { + commands::execute_without_clipboard(CommandId::ApplyNmrVolumePhase, app, ui.ctx()); + } }); }); }); @@ -273,23 +350,25 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { #[derive(Clone)] struct PhaseJob { + project: plotium_core::state::ProjectContextId, id: plotium_core::state::DatasetId, + name: String, expected: [plotium_processing::nmr_volume::VolumePhase; 3], cancellation: nmr::CancellationToken, output: std::sync::Arc>>>, } -fn phase_busy(ctx: &egui::Context) -> bool { - ctx.data(|data| { - data.get_temp::(egui::Id::new("volume_phase_job")) - .is_some() - }) -} pub(super) fn poll_phase(app: &mut PlotiumApp, ctx: &egui::Context) { let key = egui::Id::new("volume_phase_job"); let Some(job) = ctx.data(|data| data.get_temp::(key)) else { return; }; + if job.project != app.session.project_context { + job.cancellation.cancel(); + ctx.data_mut(|data| data.remove::(key)); + app.session.ui.nmr_volume_phase_busy = false; + return; + } let result = match job.output.lock() { Ok(mut output) => output.take(), Err(error) => Some(Err(format!("NMR phase worker failed: {error}"))), @@ -298,20 +377,33 @@ pub(super) fn poll_phase(app: &mut PlotiumApp, ctx: &egui::Context) { ctx.data_mut(|data| data.remove::(key)); app.session.ui.nmr_volume_phase_busy = false; if job.cancellation.is_cancelled() { - app.session.status = "NMR phase processing cancelled".into(); + app.session.status = format!("Stopped phase calculation for {}. Draft kept.", job.name); return; } let result = result.and_then(|dataset| app.install_nmr_volume_result(job.id, job.expected, dataset)); - if let Err(error) = result { - app.session.status = format!("Cannot apply NMR phase: {error}"); - } + app.session.status = match result { + Ok(()) => { + app.session.ui.nmr_volume_phase_drafts.remove(&job.id); + format!("Applied phase to {}", job.name) + } + Err(error) => format!("Cannot apply phase to {}: {error}", job.name), + }; } -pub(super) fn cancel_phase(ctx: &egui::Context) { +pub(super) fn cancel_phase(app: &mut PlotiumApp, ctx: &egui::Context) { + let key = egui::Id::new("volume_phase_job"); + if ctx + .data(|data| data.get_temp::(key)) + .is_some_and(|job| job.project != app.session.project_context) + { + poll_phase(app, ctx); + return; + } if let Some(job) = ctx.data(|data| data.get_temp::(egui::Id::new("volume_phase_job"))) { job.cancellation.cancel(); + app.session.status = format!("Stopping phase calculation for {}…", job.name); } } @@ -321,32 +413,147 @@ pub(super) fn phase_gate(app: &PlotiumApp) -> Result<(), &'static str> { "Wait for phase processing to finish or cancel it before applying new settings.", ); } - if !app - .active_dataset() - .and_then(|di| app.doc.datasets.get(di)) - .and_then(Dataset::as_nmr2d) - .is_some_and(|n| n.volume.is_some()) - { - return Err("Select a three-dimensional NMR spectrum before adjusting its phase."); - } - let data = &app.doc.datasets[app.active_dataset().expect("validated active dataset")]; - let phases = data + volume_phase_gate(app)?; + let id = phase_target(app).expect("validated phase target"); + let data = &app.doc.datasets[app.doc.dataset_index(id).expect("validated phase dataset")]; + let volume = data .as_nmr2d() .and_then(|n| n.volume.as_ref()) - .expect("validated volume") - .phases; - if !app + .expect("validated volume"); + let draft = app .session .ui .nmr_volume_phase_drafts .get(&data.resource_id()) - .is_some_and(|draft| draft.baseline == phases && draft.phases != phases) + .ok_or("Open Phase and edit a phase setting before applying.")?; + if !draft_matches( + draft, + &std::sync::Arc::downgrade(volume.source.dataset()), + volume.phases, + ) { + return Err( + "The source or committed phase changed. Edit the current settings in Phase before applying.", + ); + } + if draft.phases == volume.phases { + return Err("Edit a phase setting before applying."); + } + if draft.phases.iter().any(|phase| phase.validate().is_err()) { + return Err("Enter finite phase angles and a valid pivot before applying."); + } + if draft.phases.iter().enumerate().any(|(axis, phase)| { + !volume.processed.supports_phase(axis) + && (phase.p0_degrees != 0.0 || phase.p1_degrees != 0.0) + }) { + return Err("Apply nonzero phase only to complex frequency-domain signal axes."); + } + Ok(()) +} + +fn phase_draft( + app: &PlotiumApp, + id: plotium_core::state::DatasetId, + volume: &plotium_core::state::NmrVolumeState, +) -> (plotium_core::state::NmrVolumePhaseDraft, bool) { + let source = std::sync::Arc::downgrade(volume.source.dataset()); + let saved = app.session.ui.nmr_volume_phase_drafts.get(&id); + if let Some(draft) = saved + && draft_matches(draft, &source, volume.phases) { - return Err("Open 3D Processing and edit a phase setting before applying phase."); + return (draft.clone(), false); + } + ( + plotium_core::state::NmrVolumePhaseDraft { + source, + baseline: volume.phases, + phases: volume.phases, + }, + saved.is_some(), + ) +} + +fn discard_phase_draft(app: &mut PlotiumApp, id: plotium_core::state::DatasetId) { + app.session.ui.nmr_volume_phase_drafts.remove(&id); +} + +fn draft_matches( + draft: &plotium_core::state::NmrVolumePhaseDraft, + source: &std::sync::Weak, + phases: [plotium_processing::nmr_volume::VolumePhase; 3], +) -> bool { + draft.source.ptr_eq(source) && draft.baseline == phases +} + +pub(super) fn open_phase_gate(app: &PlotiumApp) -> Result<(), &'static str> { + let id = phase_target(app).ok_or("Select an NMR spectrum before opening Phase.")?; + let dataset = app + .doc + .dataset_index(id) + .and_then(|di| app.doc.datasets.get(di)) + .ok_or("Select an NMR spectrum before opening Phase.")?; + if !matches!(dataset, Dataset::Nmr(_) | Dataset::Nmr2D(_)) { + return Err("Select an NMR spectrum before opening Phase."); + } + if let Some(volume) = dataset.as_nmr2d().and_then(|n| n.volume.as_ref()) { + return require_volume_phase(volume); + } + let axis = dataset.active_phase_axis(app.session.ui.phase_axis); + if !canvas_axis_phaseable(dataset, axis) { + return Err( + "Enable a Phase step on the current frequency-domain axis, or choose another axis in Processing.", + ); + } + if canvas_phase_target(app) != Some(id) { + return Err( + "Select a plot of the chosen spectrum on the active canvas before opening Phase.", + ); } Ok(()) } +pub(super) fn canvas_axis_phaseable( + dataset: &Dataset, + axis: plotium_core::state::PhaseAxis, +) -> bool { + dataset.pivot_ppm(axis).is_some() + && dataset.axis_pipeline(axis).is_some_and(|pipeline| { + pipeline.steps.iter().any(|step| { + step.enabled && matches!(step.kind, plotium_processing::StepKind::Phase(_)) + }) + }) +} + +fn volume_phase_gate(app: &PlotiumApp) -> Result<(), &'static str> { + let volume = phase_target(app) + .and_then(|id| app.doc.dataset_index(id)) + .and_then(|di| app.doc.datasets.get(di)) + .and_then(Dataset::as_nmr2d) + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a three-dimensional NMR spectrum before applying phase.")?; + require_volume_phase(volume) +} + +fn require_volume_phase(volume: &plotium_core::state::NmrVolumeState) -> Result<(), &'static str> { + if !(0..3).any(|axis| volume.processed.supports_phase(axis)) { + return Err( + "Select a spectrum with real and imaginary components; these axes cannot be phased.", + ); + } + Ok(()) +} + +fn axis_role(axis: usize, fixed: usize) -> &'static str { + // The plane descriptor keeps the surviving acquisition axes in order: + // rows are vertical and the faster-varying columns are horizontal. + if axis == fixed { + "fixed axis" + } else if axis == usize::from(fixed == 0) { + "vertical axis" + } else { + "horizontal axis" + } +} + pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'static str> { use plotium_core::state::Tool; let volume = app @@ -354,9 +561,6 @@ pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'stat .and_then(|di| app.doc.datasets.get(di)) .and_then(Dataset::as_nmr2d) .is_some_and(|n| n.volume.is_some()); - if volume && id == CommandId::Tool(Tool::ManualPhase) { - return Err("Open 3D Phase settings to adjust the full volume."); - } if volume && matches!( id, @@ -366,136 +570,12 @@ pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'stat ) { return Err( - "Open a standalone spectrum for peak or integral editing; use 3D Processing to adjust volume phase.", + "Open a standalone spectrum for peak or integral editing; use Phase to adjust volume phase.", ); } Ok(()) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn volume_phase_settings_end_peak_editing_and_open_processing() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../io/tests/fixtures/nmr/bruker-3d-qf"); - let loaded = plotium_io::load_path(path).unwrap(); - let (dataset, _) = - plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); - let mut app = PlotiumApp::new(); - let owner = dataset.resource_id(); - app.doc.datasets.push(dataset); - app.focus_single(0); - app.session.ui.processing_task_collapsed = true; - let id = CommandId::OpenNmrVolumePhase; - let description = commands::describe(&app, id); - assert!(description.enabled, "{:?}", description.disabled_reason); - assert!(description.ribbon.is_some()); - assert_eq!(description.checked, None); - assert_eq!(description.label, "3D Phase settings"); - app.set_tool(plotium_core::state::Tool::Peaks); - commands::execute_without_clipboard(id, &mut app, &egui::Context::default()); - assert_eq!(app.session.tool, plotium_core::state::Tool::BrowseZoom); - assert!( - !commands::describe( - &app, - CommandId::Tool(plotium_core::state::Tool::ManualPhase) - ) - .enabled - ); - assert_eq!(app.session.ui.processing_task_dataset, Some(owner)); - assert!(!app.session.ui.processing_task_collapsed); - } - - #[test] - fn volume_navigation_renders_and_disables_endpoint_steps() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../io/tests/fixtures/nmr/bruker-3d-qf"); - let loaded = plotium_io::load_path(&path).unwrap(); - let (dataset, _) = - plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); - let mut app = PlotiumApp::new(); - app.doc.datasets.push(dataset); - app.focus_single(0); - let id = app.doc.datasets[0].resource_id(); - app.select_nmr_volume_plane(id, 0, 0).unwrap(); - assert!(!commands::describe(&app, CommandId::PreviousNmrPlane).enabled); - assert!(commands::describe(&app, CommandId::NextNmrPlane).enabled); - let description = commands::describe(&app, CommandId::ApplyNmrVolumePhase); - assert!(!description.enabled); - assert!( - description - .disabled_reason - .unwrap() - .contains("Open 3D Processing") - ); - let committed = app.doc.datasets[0] - .as_nmr2d() - .unwrap() - .volume - .as_ref() - .unwrap() - .phases; - let mut draft = plotium_core::state::NmrVolumePhaseDraft { - baseline: committed, - phases: committed, - }; - app.session - .ui - .nmr_volume_phase_drafts - .insert(id, draft.clone()); - assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); - draft.phases[0].p0_degrees = 15.0; - app.session.ui.nmr_volume_phase_drafts.insert(id, draft); - assert!(commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); - // Readiness belongs to the selected dataset, not the last opened card. - app.doc.datasets.push({ - let loaded = plotium_io::load_path(&path).unwrap(); - plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) - .unwrap() - .0 - }); - app.focus_single(1); - assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); - app.focus_single(0); - app.session - .ui - .nmr_volume_phase_drafts - .get_mut(&id) - .unwrap() - .baseline[0] - .p0_degrees = 90.0; - assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); - let ctx = egui::Context::default(); - let _ = ctx.run_ui(Default::default(), |ui| navigation(&mut app, ui)); - step(&mut app, true); - assert_eq!( - app.doc.datasets[0] - .as_nmr2d() - .unwrap() - .volume - .as_ref() - .unwrap() - .index, - 1 - ); - } - - #[test] - fn plane_commands_explain_missing_volume_and_phase_cancel_tracks_worker() { - let mut app = PlotiumApp::new(); - for id in [ - CommandId::PreviousNmrPlane, - CommandId::NextNmrPlane, - CommandId::ApplyNmrVolumePhase, - ] { - let description = commands::describe(&app, id); - assert!(!description.enabled); - assert!(description.disabled_reason.unwrap().contains("Select")); - } - assert!(!commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); - app.session.ui.nmr_volume_phase_busy = true; - assert!(commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); - } -} +#[path = "nmr_volume_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/nmr_volume_tests.rs b/crates/app/src/ui/nmr_volume_tests.rs new file mode 100644 index 00000000..b4fef118 --- /dev/null +++ b/crates/app/src/ui/nmr_volume_tests.rs @@ -0,0 +1,419 @@ +use super::*; + +#[test] +fn volume_phase_settings_end_peak_editing_and_open_processing() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(path).unwrap(); + let (dataset, _) = + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let mut app = PlotiumApp::new(); + let owner = dataset.resource_id(); + app.doc.datasets.push(dataset); + app.focus_single(0); + app.session.ui.processing_task_collapsed = true; + let id = CommandId::Tool(plotium_core::state::Tool::ManualPhase); + let description = commands::describe(&app, id); + assert!(description.enabled, "{:?}", description.disabled_reason); + assert!(description.ribbon.is_some()); + assert_eq!(description.checked, None); + assert_eq!(description.label, "Phase"); + app.set_tool(plotium_core::state::Tool::Peaks); + commands::execute_without_clipboard(id, &mut app, &egui::Context::default()); + assert_eq!(app.session.tool, plotium_core::state::Tool::BrowseZoom); + assert_eq!(app.session.ui.processing_task_dataset, Some(owner)); + assert!(!app.session.ui.processing_task_collapsed); +} + +#[test] +fn volume_navigation_renders_and_disables_endpoint_steps() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(&path).unwrap(); + let (dataset, _) = + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.focus_single(0); + let id = app.doc.datasets[0].resource_id(); + app.select_nmr_volume_plane(id, 0, 0).unwrap(); + assert!(!commands::describe(&app, CommandId::PreviousNmrPlane).enabled); + assert!(commands::describe(&app, CommandId::NextNmrPlane).enabled); + let description = commands::describe(&app, CommandId::ApplyNmrVolumePhase); + assert!(!description.enabled); + assert!(description.disabled_reason.unwrap().contains("Open Phase")); + let committed = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases; + let mut draft = plotium_core::state::NmrVolumePhaseDraft { + source: std::sync::Arc::downgrade( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .source + .dataset(), + ), + baseline: committed, + phases: committed, + }; + app.session + .ui + .nmr_volume_phase_drafts + .insert(id, draft.clone()); + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + draft.phases[2].p0_degrees = 15.0; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + assert!(commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + // Readiness belongs to the selected dataset, not the last opened card. + app.doc.datasets.push({ + let loaded = plotium_io::load_path(&path).unwrap(); + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) + .unwrap() + .0 + }); + app.focus_single(1); + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + app.focus_single(0); + app.session + .ui + .nmr_volume_phase_drafts + .get_mut(&id) + .unwrap() + .baseline[0] + .p0_degrees = 90.0; + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + let ctx = egui::Context::default(); + let _ = ctx.run_ui(Default::default(), |ui| navigation(&mut app, ui)); + step(&mut app, true); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .index, + 1 + ); +} + +#[test] +fn plane_commands_explain_missing_volume_and_phase_cancel_tracks_worker() { + let mut app = PlotiumApp::new(); + for id in [ + CommandId::PreviousNmrPlane, + CommandId::NextNmrPlane, + CommandId::ApplyNmrVolumePhase, + ] { + let description = commands::describe(&app, id); + assert!(!description.enabled); + assert!(description.disabled_reason.unwrap().contains("Select")); + } + assert!(!commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); + app.session.ui.nmr_volume_phase_busy = true; + assert!(commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); +} + +fn volume_fixture() -> Dataset { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(path).unwrap(); + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) + .unwrap() + .0 +} + +#[test] +fn stale_draft_does_not_return_after_undo_when_card_is_closed() { + let mut app = PlotiumApp::new(); + app.doc.datasets.push(volume_fixture()); + app.focus_single(0); + let id = app.doc.datasets[0].resource_id(); + let (mut draft, _) = phase_draft(&app, id, volume(&app)); + let baseline = draft.baseline; + draft.phases[2].p0_degrees = 15.0; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + let mut changed = baseline; + changed[2].p0_degrees = 30.0; + app.set_nmr_volume_phases(id, changed).unwrap(); + assert!(!app.session.ui.nmr_volume_phase_drafts.contains_key(&id)); + app.undo(); + assert_eq!(volume(&app).phases, baseline); + assert!(phase_gate(&app).is_err()); + assert_eq!(phase_draft(&app, id, volume(&app)).0.phases, baseline); +} + +fn volume(app: &PlotiumApp) -> &plotium_core::state::NmrVolumeState { + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() +} + +#[test] +fn draft_keeps_acquisition_axes_across_navigation_and_reopening() { + let mut app = PlotiumApp::new(); + app.doc.datasets.push(volume_fixture()); + app.focus_single(0); + let id = app.doc.datasets[0].resource_id(); + let mut committed = volume(&app).phases; + committed[2].p0_degrees = 10.0; + app.set_nmr_volume_phases(id, committed).unwrap(); + let (mut draft, stale) = phase_draft(&app, id, volume(&app)); + assert!(!stale); + draft.phases[2].p1_degrees = 22.0; + let edited = draft.phases; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + for (fixed, roles) in [ + (0, ["fixed axis", "vertical axis", "horizontal axis"]), + (1, ["vertical axis", "fixed axis", "horizontal axis"]), + (2, ["vertical axis", "horizontal axis", "fixed axis"]), + ] { + app.select_nmr_volume_plane(id, fixed, 0).unwrap(); + assert_eq!( + std::array::from_fn(|axis| axis_role(axis, volume(&app).fixed_axis)), + roles + ); + assert_eq!(phase_draft(&app, id, volume(&app)).0.phases, edited); + } + let state = volume(&app); + let ppm = std::array::from_fn(|axis| { + state + .processed + .axis_coordinate(axis, state.anchor[axis]) + .unwrap() + }); + app.add_peak_3d(id, ppm).unwrap(); + assert_eq!(phase_draft(&app, id, volume(&app)).0.phases, edited); + open_phase(&mut app); + app.session + .ui + .close_task_tab(plotium_core::state::TaskDockTab::Processing); + open_phase(&mut app); + assert_eq!(phase_draft(&app, id, volume(&app)).0.phases, edited); + let mark = (app.session.undo_stack.len(), app.doc.edit_generation); + discard_phase_draft(&mut app, id); + assert_eq!(phase_draft(&app, id, volume(&app)).0.phases, committed); + assert_eq!(volume(&app).phases, committed); + assert_eq!( + mark, + (app.session.undo_stack.len(), app.doc.edit_generation) + ); + assert!(phase_gate(&app).is_err()); +} + +#[test] +fn phase_jobs_report_their_original_target_and_cancel_before_install() { + let mut app = PlotiumApp::new(); + app.doc + .datasets + .extend([volume_fixture(), volume_fixture()]); + app.doc.datasets[0].set_name(Some("First volume".into())); + app.doc.datasets[1].set_name(Some("Second volume".into())); + let id = app.doc.datasets[0].resource_id(); + let (mut draft, _) = phase_draft(&app, id, volume(&app)); + let baseline = draft.baseline; + draft.phases[2].p0_degrees = 10.0; + let mut worker = app.doc.datasets[0].as_nmr2d().unwrap().clone(); + worker.set_volume_phases(draft.phases).unwrap(); + app.session + .ui + .nmr_volume_phase_drafts + .insert(id, draft.clone()); + app.focus_single(1); + let ctx = egui::Context::default(); + let mut job = PhaseJob { + project: app.session.project_context, + id, + name: "First volume".into(), + expected: baseline, + cancellation: nmr::CancellationToken::new(), + output: std::sync::Arc::new(std::sync::Mutex::new(Some(Ok(worker.clone())))), + }; + let key = egui::Id::new("volume_phase_job"); + ctx.data_mut(|data| data.insert_temp(key, job.clone())); + app.session.ui.nmr_volume_phase_busy = true; + cancel_phase(&mut app, &ctx); + poll_phase(&mut app, &ctx); + assert_eq!(volume(&app).phases, baseline); + assert_eq!( + app.session.ui.nmr_volume_phase_drafts[&id].phases, + draft.phases + ); + assert!( + app.session + .status + .contains("Stopped phase calculation for First volume") + ); + + job.cancellation = nmr::CancellationToken::new(); + *job.output.lock().unwrap() = Some(Err("test worker error".into())); + ctx.data_mut(|data| data.insert_temp(key, job.clone())); + poll_phase(&mut app, &ctx); + assert!( + app.session + .status + .contains("First volume: test worker error") + ); + + let mut changed = baseline; + changed[2].p0_degrees = 20.0; + app.set_nmr_volume_phases(id, changed).unwrap(); + *job.output.lock().unwrap() = Some(Ok(worker.clone())); + ctx.data_mut(|data| data.insert_temp(key, job.clone())); + poll_phase(&mut app, &ctx); + assert!( + app.session + .status + .contains("First volume: Discarded an outdated phase result") + ); + assert_eq!(volume(&app).phases, changed); + assert!(!app.session.ui.nmr_volume_phase_drafts.contains_key(&id)); + app.select_nmr_volume_plane(id, 2, 1).unwrap(); + let anchor = volume(&app).anchor; + let other = app.doc.datasets[1] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases; + job.expected = changed; + *job.output.lock().unwrap() = Some(Ok(worker)); + ctx.data_mut(|data| data.insert_temp(key, job)); + poll_phase(&mut app, &ctx); + assert!(app.session.status.contains("Applied phase to First volume")); + assert_eq!(volume(&app).phases, draft.phases); + assert_eq!(volume(&app).fixed_axis, 2); + assert_eq!(volume(&app).anchor, anchor); + assert_eq!( + app.doc.datasets[1] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases, + other + ); + assert_eq!(app.active_dataset(), Some(1)); +} + +#[test] +fn changing_project_detaches_a_running_phase_job_without_replacing_status() { + let mut app = PlotiumApp::new(); + let ctx = egui::Context::default(); + let job = PhaseJob { + project: app.session.project_context, + id: volume_fixture().resource_id(), + name: "Previous project".into(), + expected: Default::default(), + cancellation: nmr::CancellationToken::new(), + output: std::sync::Arc::new(std::sync::Mutex::new(None)), + }; + let key = egui::Id::new("volume_phase_job"); + ctx.data_mut(|data| data.insert_temp(key, job.clone())); + + let mut loaded = PlotiumApp::new(); + loaded.doc.datasets.push(volume_fixture()); + loaded.focus_single(0); + app.install_loaded_project(loaded); + app.session.status = "Project opened".into(); + poll_phase(&mut app, &ctx); + assert!(job.cancellation.is_cancelled()); + assert!(ctx.data(|data| data.get_temp::(key)).is_none()); + assert!(!app.session.ui.nmr_volume_phase_busy); + *job.output.lock().unwrap() = Some(Err("Old worker finished".into())); + poll_phase(&mut app, &ctx); + assert_eq!(app.session.status, "Project opened"); + + let id = app.doc.datasets[0].resource_id(); + let (mut draft, _) = phase_draft(&app, id, volume(&app)); + draft.phases[2].p0_degrees = 15.0; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + apply_phase(&mut app, &ctx); + let next = ctx.data(|data| data.get_temp::(key)).unwrap(); + assert_eq!(next.project, app.session.project_context); + assert_eq!(next.id, id); + cancel_phase(&mut app, &ctx); + assert!(next.cancellation.is_cancelled()); +} + +#[test] +fn repivot_preserves_real_and_imaginary_volume_samples() { + let dataset = volume_fixture(); + let mut before = dataset.as_nmr2d().unwrap().clone(); + let mut after = before.clone(); + let mut phases = before.volume.as_ref().unwrap().phases; + phases[2].p0_degrees = 23.0; + phases[2].p1_degrees = -137.0; + phases[2].pivot_fraction = 0.31; + before.set_volume_phases(phases).unwrap(); + phases[2].repivot(0.79).unwrap(); + after.set_volume_phases(phases).unwrap(); + let before = &before.volume.as_ref().unwrap().processed; + let after = &after.volume.as_ref().unwrap().processed; + let shape = before.shape(); + for x in [0, shape[0] / 2, shape[0] - 1] { + for y in [0, shape[1] / 2, shape[1] - 1] { + for z in 0..shape[2] { + for component in [0, 1] { + let sample = |volume: &plotium_processing::nmr_volume::NmrVolume| { + volume + .processed() + .data() + .get(&[x, y, z], &[0, 0, component]) + .unwrap() + }; + let expected = sample(before); + assert!((sample(after) - expected).abs() <= 1e-10 * (1.0 + expected.abs())); + } + } + } + } +} + +#[test] +fn phase_routes_volume_selection_by_scope() { + use plotium_core::state::{SelectionScope, Tool}; + + let mut app = PlotiumApp::new(); + let action = plotium_core::actions::Action::insert_dataset_with_default_canvas( + &app, + crate::ui::properties::fixture::homonuclear_frequency_2d(), + "Contour".to_owned(), + plotium_core::state::DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + let plotted = app.active_dataset().unwrap(); + let di = app.doc.datasets.len(); + app.doc.datasets.push(volume_fixture()); + app.focus_single(di); + let command = CommandId::Tool(Tool::ManualPhase); + let ctx = egui::Context::default(); + app.session.ui.selection_scope = SelectionScope::CanvasObjects; + commands::execute_without_clipboard(command, &mut app, &ctx); + assert_eq!( + phase_target(&app), + Some(app.doc.datasets[plotted].resource_id()) + ); + assert_eq!(app.session.tool, Tool::ManualPhase); + + app.focus_single(di); + app.session.ui.selection_scope = SelectionScope::DataList; + commands::execute_without_clipboard(command, &mut app, &ctx); + assert_eq!(app.session.tool, Tool::BrowseZoom); + assert_eq!( + app.session.ui.processing_task_dataset, + Some(app.doc.datasets[di].resource_id()) + ); +} diff --git a/crates/app/src/ui/properties/discovery.rs b/crates/app/src/ui/properties/discovery.rs index c841c736..e68e88d1 100644 --- a/crates/app/src/ui/properties/discovery.rs +++ b/crates/app/src/ui/properties/discovery.rs @@ -349,21 +349,42 @@ pub(crate) fn step_selection(app: &mut PlotiumApp, step: PropertyStep) { /// Channel 4: the context-menu entries for whatever the selection draws. /// -/// Navigation only — every entry reveals a group at its canonical home. +/// Parameter entries reveal their groups; Phase starts the shared task. pub(crate) fn context_menu(app: &mut PlotiumApp, ui: &mut Ui) { let now = ui.input(|input| input.time); + use crate::ui::commands::{self, CommandId}; + use plotium_core::state::{Dataset, Tool}; + let phase = app + .active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .is_some_and(|dataset| matches!(dataset, Dataset::Nmr(_) | Dataset::Nmr2D(_))) + .then(|| commands::describe(app, CommandId::Tool(Tool::ManualPhase))); let reachable: Vec<(&'static str, PropertyId)> = groups() .iter() - .filter(|group| group_applies(app, group.section)) + .filter(|group| { + group.section != super::panel::PHASE_SECTION && group_applies(app, group.section) + }) .filter_map(|group| { entry_property(group.section, PRESENTATIONS) .map(|property| (group.label.get(), property)) }) .collect(); - if reachable.is_empty() { + if reachable.is_empty() && phase.is_none() { return; } ui.separator(); + if let Some(phase) = phase + && ui + .add_enabled( + phase.enabled, + egui::Button::new(phase.label.as_str()).selected(phase.checked.unwrap_or(false)), + ) + .on_disabled_hover_text(phase.disabled_reason.unwrap_or_default()) + .clicked() + { + commands::execute_without_clipboard(phase.id, app, ui.ctx()); + ui.close(); + } for (label, property) in reachable { if ui.button(format!("{label} settings…")).clicked() { super::super::command_palette::reveal_property(app, property, now); diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs index 1c2dca2d..bd323432 100644 --- a/crates/app/src/ui/properties/discovery_tests.rs +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -134,7 +134,10 @@ fn overlaid_display_encodings_are_reported_as_ambiguous() { fn every_declared_group_has_a_ribbon_home() { let app = PlotiumApp::new_with_settings(plotium_core::settings::Settings::default()); let catalog = commands::catalog(&app); - for group in GROUPS { + for group in GROUPS + .iter() + .filter(|group| group.section != super::super::panel::PHASE_SECTION) + { let descriptor = catalog .iter() .find(|command| command.id == CommandId::PropertyGroup(group.section)) diff --git a/crates/app/src/ui/ribbon/layout.rs b/crates/app/src/ui/ribbon/layout.rs index e55b9ebf..40f282be 100644 --- a/crates/app/src/ui/ribbon/layout.rs +++ b/crates/app/src/ui/ribbon/layout.rs @@ -385,14 +385,7 @@ pub(super) fn groups_for_tab( pub(super) fn group_order(tab: WorkflowTab, group: &str) -> u8 { let order: &[&str] = match tab { WorkflowTab::Data => &["Import", "Build", "Export"], - WorkflowTab::Process => &[ - "Processing", - "Correct", - "3D Phase", - "Transform", - "XAS", - "Recipes", - ], + WorkflowTab::Process => &["Processing", "Correct", "Transform", "XAS", "Recipes"], WorkflowTab::Analyze => &[ "Protein", "Range", diff --git a/crates/core/src/actions/app_impl/processing_snapshot.rs b/crates/core/src/actions/app_impl/processing_snapshot.rs index 7f35f51d..89c64d28 100644 --- a/crates/core/src/actions/app_impl/processing_snapshot.rs +++ b/crates/core/src/actions/app_impl/processing_snapshot.rs @@ -27,6 +27,23 @@ impl PlotiumApp { .doc .dataset_index(snapshot.resource_id()) .ok_or_else(|| ActionApplyError::StaleTarget(snapshot.resource_id().to_string()))?; + let phase_basis = |dataset: &Dataset| { + dataset + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .map(|volume| { + ( + std::sync::Arc::as_ptr(volume.source.dataset()), + volume.phases, + ) + }) + }; + if phase_basis(&self.doc.datasets[index]) != phase_basis(snapshot) { + self.session + .ui + .nmr_volume_phase_drafts + .remove(&snapshot.resource_id()); + } let mut next = snapshot.clone(); match (&self.doc.datasets[index], &mut next) { (Dataset::Nmr(current), Dataset::Nmr(next)) => { diff --git a/crates/core/src/state/dataset_delete.rs b/crates/core/src/state/dataset_delete.rs index 4c677657..987618fa 100644 --- a/crates/core/src/state/dataset_delete.rs +++ b/crates/core/src/state/dataset_delete.rs @@ -102,6 +102,7 @@ impl PlotiumApp { /// Repair index-addressed session state after `datasets.remove(index)`. pub(crate) fn forget_removed_dataset(&mut self, index: usize, id: DatasetId) { + self.session.ui.nmr_volume_phase_drafts.remove(&id); self.session.compute.invalidate_dataset(id); if matches!( self.session.ui.craft_feedback.get(&id), diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index ff92e24c..97dc6798 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -44,6 +44,7 @@ pub use text_edit::{PanelNoteEditState, PlotMenuPoint, PlotTextEditState, TextEd /// A desktop phase draft belongs to one dataset and one committed phase baseline. #[derive(Clone)] pub struct NmrVolumePhaseDraft { + pub source: std::sync::Weak, pub baseline: [plotium_processing::nmr_volume::VolumePhase; 3], pub phases: [plotium_processing::nmr_volume::VolumePhase; 3], } diff --git a/crates/processing/src/nmr_volume.rs b/crates/processing/src/nmr_volume.rs index d13dbfa9..b344b5aa 100644 --- a/crates/processing/src/nmr_volume.rs +++ b/crates/processing/src/nmr_volume.rs @@ -3,7 +3,7 @@ use plotium_io::nmr_view::NmrSource; use std::sync::Arc; use nmr::ExecutionContext; -use nmr::axis::{AxisDomain, AxisUnit}; +use nmr::axis::{AxisDomain, AxisRole, AxisUnit}; use nmr::processed::{ProcessedAxis, ProcessedDataset}; use nmr::processing::{ DelaySource, DigitalFilterCorrection, FourierExponentSign, FourierTransform, FrequencyFrame, @@ -63,8 +63,7 @@ impl NmrVolume { Vec::new() }; for (axis, phase) in phases.iter().enumerate() { - PhaseCorrection::new(phase.p0_degrees, phase.p1_degrees, phase.pivot_fraction) - .map_err(|e| e.to_string())?; + phase.validate()?; if phase.p0_degrees != 0.0 || phase.p1_degrees != 0.0 { operations.push(ProcessingOperation::PhaseCorrection { axis, @@ -187,6 +186,34 @@ impl NmrVolume { .map_err(|e| e.to_string()) } + pub fn supports_phase(&self, axis: usize) -> bool { + use nmr::processed::ComponentBasis; + // Match nmr's PhaseCorrection contract for an already processed axis. + self.axes().get(axis).is_some_and(|axis| { + axis.domain() == AxisDomain::Frequency + && matches!( + axis.role(), + AxisRole::DirectAcquisition | AxisRole::IndirectAcquisition | AxisRole::Signal + ) + && matches!( + axis.component_basis(), + ComponentBasis::Cartesian | ComponentBasis::SharedComplex { .. } + ) + }) + } + + pub fn phase_pivot_scale(&self, axis: usize) -> Result { + let points = *self.shape.get(axis).ok_or("Invalid acquisition axis")?; + if points < 2 { + return Err("Pivot needs at least two ppm coordinates".into()); + } + let nmr::axis::AxisCoordinates::Uniform { start, step } = self.axes()[axis].coordinates() + else { + return Err("Pivot editing requires a uniform ppm grid".into()); + }; + PhasePivotScale::new(points, *start, *step) + } + pub fn nearest(&self, axis: usize, ppm: f64) -> Result { if axis >= 3 || !ppm.is_finite() { return Err("invalid plane coordinate".into()); @@ -313,3 +340,71 @@ impl Default for VolumePhase { } } } + +// Frequency frames in a volume have a regular ppm grid. The phase endpoint +// q=1 lies one sample beyond the last coordinate (whose position is (N-1)/N). +#[derive(Clone, Copy)] +pub struct PhasePivotScale { + first: f64, + end: f64, +} + +impl PhasePivotScale { + fn new(points: usize, first: f64, step: f64) -> Result { + if points < 2 { + return Err("Pivot needs at least two ppm coordinates".into()); + } + let end = step.mul_add(points as f64, first); + let last = step.mul_add((points - 1) as f64, first); + if !first.is_finite() || !(end - first).is_finite() || end == first || end == last { + return Err("This axis has no usable ppm mapping for the pivot".into()); + } + Ok(Self { first, end }) + } + + pub fn ppm(self, fraction: f64) -> Result { + if !(0.0..=1.0).contains(&fraction) { + return Err("Pivot fraction must be between 0 and 1".into()); + } + Ok(if fraction == 1.0 { + self.end + } else { + self.first + fraction * (self.end - self.first) + }) + } + + pub fn fraction(self, ppm: f64) -> Result { + if !(self.first.min(self.end)..=self.first.max(self.end)).contains(&ppm) { + return Err(format!( + "Enter a pivot between {} and {} ppm", + self.first.min(self.end), + self.first.max(self.end) + )); + } + Ok((ppm - self.first) / (self.end - self.first)) + } +} + +impl VolumePhase { + pub fn validate(self) -> Result<(), String> { + PhaseCorrection::new(self.p0_degrees, self.p1_degrees, self.pivot_fraction) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + pub fn repivot(&mut self, fraction: f64) -> Result<(), String> { + self.validate()?; + let next = Self { + p0_degrees: self.p0_degrees + self.p1_degrees * (fraction - self.pivot_fraction), + pivot_fraction: fraction, + ..*self + }; + next.validate()?; + *self = next; + Ok(()) + } +} + +#[cfg(test)] +#[path = "nmr_volume_phase_tests.rs"] +mod phase_tests; diff --git a/crates/processing/src/nmr_volume_phase_tests.rs b/crates/processing/src/nmr_volume_phase_tests.rs new file mode 100644 index 00000000..2f8f8c49 --- /dev/null +++ b/crates/processing/src/nmr_volume_phase_tests.rs @@ -0,0 +1,49 @@ +use super::*; + +#[test] +fn phase_pivot_uses_sample_positions_and_preserves_the_complex_rotation() { + for step in [0.125, -0.125] { + let scale = PhasePivotScale::new(8, 4.0, step).unwrap(); + assert_eq!(scale.ppm(7.0 / 8.0).unwrap(), 4.0 + 7.0 * step); + assert_eq!(scale.ppm(1.0).unwrap(), 4.0 + 8.0 * step); + let before = VolumePhase { + p0_degrees: 23.0, + p1_degrees: -137.0, + pivot_fraction: 0.31, + }; + for q in [0.0, 0.17, 7.0 / 8.0, 1.0] { + let mut after = before; + let recovered = scale.fraction(scale.ppm(q).unwrap()).unwrap(); + assert!((recovered - q).abs() < 1e-14); + after.repivot(recovered).unwrap(); + for i in 0..8 { + let angle = |p: VolumePhase| { + p.p0_degrees + p.p1_degrees * (i as f64 / 8.0 - p.pivot_fraction) + }; + let rotate = |p| { + nmr::Complex64::new(0.37, -1.4) + * nmr::Complex64::from_polar(1.0, angle(p).to_radians()) + }; + assert!((angle(before) - angle(after)).abs() < 1e-12); + assert!((rotate(before) - rotate(after)).norm() < 1e-12); + } + } + } +} + +#[test] +fn phase_pivot_rejects_unusable_grids_and_out_of_range_values() { + assert!(PhasePivotScale::new(1, 4.0, 0.1).is_err()); + assert!(PhasePivotScale::new(8, 4.0, 0.0).is_err()); + assert!(PhasePivotScale::new(8, f64::INFINITY, 0.1).is_err()); + let scale = PhasePivotScale::new(8, 4.0, -0.125).unwrap(); + for ppm in [f64::NAN, f64::INFINITY, 2.99, 4.01] { + assert!(scale.fraction(ppm).is_err()); + } + let mut phase = VolumePhase::default(); + for fraction in [f64::NAN, -0.1, 1.1] { + assert!(scale.ppm(fraction).is_err()); + assert!(phase.repivot(fraction).is_err()); + assert_eq!(phase, VolumePhase::default()); + } +} diff --git a/docs/src/content/docs/guides/nmr-3d.md b/docs/src/content/docs/guides/nmr-3d.md index 83032cf9..47f6d346 100644 --- a/docs/src/content/docs/guides/nmr-3d.md +++ b/docs/src/content/docs/guides/nmr-3d.md @@ -18,15 +18,41 @@ remain stable while browsing. Changing orientation retains the position on each acquisition axis. Positive and negative contours retain their sign. Adjust contour sensitivity with -the existing controls. Basic processing does not establish that the spectrum has -an experimentally verified absorptive phase. Use the per-axis phase controls to -adjust zero-order phase, first-order phase and pivot; apply explicitly and cancel -unwanted processing. Complex components remain available for phase adjustment. +the existing controls. Complex components remain available for +[phase correction](#phase-correction). Save a normal `.plotium` project to retain the acquisition snapshot, processing parameters and current plane. The project can reopen without the original experiment directory. The first implementation targets dense 3D acquisitions; -it does not add 3D NUS reconstruction, 4D browsing or protein assignments. +it does not add 3D NUS reconstruction or 4D browsing. + +## Phase correction + +Select the 3D spectrum and choose **Process → Correct → Phase** to open its +phase panel. Check the target spectrum name before editing. Each acquisition +axis (F1/F2/F3) shows its nucleus (or `unknown`) and its current display role: +horizontal, vertical, or fixed. Changing planes updates these roles; phase +settings remain attached to their acquisition axes. + +Adjust **PH0** and **PH1** for the required axis, then click **Apply** and inspect +the result on the spectrum. Apply recalculates the whole volume from the source, +replacing the previous phase settings. Browse other planes to check the result. +The displayed 2D slice does not support dragging to adjust phase, and parameter +changes have no live preview. + +**Pivot** sets the reference position for first-order phase in ppm. Moving it +automatically compensates PH0 so the phase correction remains unchanged. +The allowed range extends one sample interval beyond the last point; the control +is unavailable when the axis has no usable uniform ppm grid. + +- **Discard changes** restores the applied settings without recalculating. +- **Stop calculation** cancels the named calculation and retains your edits. +- Closing the panel retains your edits and lets any calculation continue. + +You can browse planes or switch spectra during a calculation; the result applies +to the spectrum named when it started. If the source data or applied phase +settings change, unapplied edits are discarded; reopen the panel to edit the +current settings. ## Automation @@ -70,10 +96,5 @@ Automation uses `nmr.peaks3d.list` for read-only inspection and JSON export, and `nmr.peaks3d` for add/edit/delete/locate operations. Inspect the tool schemas for parameters, stable peak IDs and acquisition-axis ordering. -For a 3D spectrum, **Process → 3D Phase → 3D Phase settings** opens the per-axis -phase controls and ends peak picking. Apply phase explicitly after editing. -**Interactive phase** is the on-plot tool for standalone 1D/2D spectra; -**Phase settings** remains their separate parameter-panel entry. - MCP exposes the read-only table through `plotium_peaks3d_inspect`. Use `plotium_plan` / `plotium_execute` with `nmr.peaks3d` for edits. diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 4dba1666..86b94e99 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -249,6 +249,16 @@ until you press it. ## Phase correction +For a 1D or 2D spectrum, select its plot on the active canvas and choose +**Process → Correct → Phase** to adjust phase on the plot. The label above the +plot identifies the target spectrum, acquisition axis, nucleus and display +direction. To change the axis on a 2D spectrum, open the other axis’s **Phase** +step in **Processing**. + +3D spectra use a parameter panel with explicit Apply and no live preview; see +[3D phase correction](/guides/nmr-3d/#phase-correction). +The controls and live preview described below apply to standalone 1D/2D spectra. + Automatic phase correction is enabled by default; you can switch methods or adjust φ0 / φ1 manually with live preview. diff --git a/docs/src/content/docs/reference/ribbon.md b/docs/src/content/docs/reference/ribbon.md index d0aa40b9..fd2e66fa 100644 --- a/docs/src/content/docs/reference/ribbon.md +++ b/docs/src/content/docs/reference/ribbon.md @@ -18,8 +18,7 @@ available. - **Data** — **Import** (open files, folders, tables, and images), **Build** (new tables, stacking), **Export** (data export). - **Process** — **Processing** (the pipeline steps: apodization, zero fill, - baseline, reference, and friends), **Correct** (**Interactive phase**, the on-plot 1D/2D tool), - **3D Phase** (**3D Phase settings**, for the full volume), **Transform** (arithmetic, spectrum alignment, CRAFT), + baseline, reference, and friends), **Correct** (phase correction), **Transform** (arithmetic, spectrum alignment, CRAFT), **Recipes** (processing templates). - **Analyze** — **Range** (the analysis range every fit reads), then the groups your active dataset supports: **XPS** (the XPS workbench pages), diff --git a/docs/src/content/docs/zh-cn/guides/nmr-3d.md b/docs/src/content/docs/zh-cn/guides/nmr-3d.md index 74610a4c..0caae37d 100644 --- a/docs/src/content/docs/zh-cn/guides/nmr-3d.md +++ b/docs/src/content/docs/zh-cn/guides/nmr-3d.md @@ -14,13 +14,34 @@ Plotium 在后台处理原始数据,并在普通工作区显示二维频率平 切层复用处理后的体数据,保留缩放和等高线设置;切换方向时保留各采集轴的位置。 正负等高线保留信号符号,灵敏度通过已有控件调整。 -基础处理不代表已获得经过实验确认的吸收相位。 -可逐轴调整零阶相位、一阶相位和枢轴,再明确应用;不需要的处理可以取消。 -复数分量保留用于相位调整。 +复数分量保留用于[相位校正](#相位校正)。 保存普通 `.plotium` 项目会保留采集快照、处理参数和当前平面。 重新打开项目不要求原始实验目录仍可访问。 -首版面向常规采样三维数据,不扩展三维 NUS 重建、四维浏览或蛋白指认。 +首版面向常规采样三维数据,不扩展三维 NUS 重建或四维浏览。 + +## 相位校正 + +选中三维谱,选择 **Process → Correct → Phase** 打开相位面板。 +编辑前先确认面板中的目标谱名。各采集轴(F1/F2/F3)显示核种 +(缺失时为 `unknown`)和当前显示方向:横轴、纵轴或固定轴。 +切换平面只更新显示方向,相位参数始终对应原来的采集轴。 + +调整所需轴的 **PH0** 和 **PH1**,点击 **Apply** 后在谱图上检查结果。 +Apply 从源数据重新计算整卷,替换上次的相位设置;可浏览其他切片检查校正效果。 +当前显示的二维切片不支持拖动调相,修改参数也没有实时预览。 + +**Pivot** 以 ppm 设置一阶相位的参考位置。移动枢轴时会自动补偿 PH0, +保持相位校正结果不变。允许范围延伸至最后一个采样点之外一个采样间隔; +轴上没有可用的均匀 ppm 网格时,该控件不可用。 + +- **Discard changes** 恢复已应用的参数,不触发重算。 +- **Stop calculation** 取消指明的计算,保留所做的修改。 +- 关闭面板保留修改,正在运行的计算继续。 + +计算期间可以浏览平面或切换谱,结果仍应用于启动时指定的谱。 +如果源数据或已应用的相位设置发生变化,未应用的修改会被丢弃; +重新打开面板即可从当前设置开始编辑。 ## 自动化 @@ -57,10 +78,5 @@ Previous 和 Next 用于逐峰浏览。列标签包含采集维度、核种和 p 自动化通过 `nmr.peaks3d.list` 只读检查和导出 JSON,通过 `nmr.peaks3d` 增删改峰或定位。参数、稳定峰 ID 与采集轴顺序请参阅工具 schema。 -选中三维谱时,**Process → 3D Phase → 3D Phase settings** 打开三轴相位控件, -并结束标峰模式。编辑参数后明确应用相位。 -**Interactive phase** 是独立 1D/2D 谱的谱图交互调相工具; -**Phase settings** 保留为独立的参数面板入口。 - MCP 通过 `plotium_peaks3d_inspect` 提供只读峰表;编辑操作使用 `plotium_plan` / `plotium_execute`,指定 `nmr.peaks3d`。 diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index bff0f2be..e7ca1e45 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -201,6 +201,14 @@ Plotium 根据受支持的 `DSPFVS`/`DECIM` 设置确定延迟;否则延迟保 ## 相位校正 +对于一维或二维谱,先在当前画布选中谱图,再选择 **Process → Correct → Phase** +进行谱图交互调相。谱图上方显示目标谱、采集轴、核种和显示方向。 +要在二维谱中切换轴,请在 **Processing** 中打开另一条轴的 **Phase** 步骤。 + +三维谱使用参数面板,修改后需点击 Apply,没有实时预览,详见 +[三维相位校正](/zh-cn/guides/nmr-3d/#相位校正)。 +下述控件与实时预览适用于独立的一维、二维谱。 + 自动相位校正默认启用;也可以切换算法,或手动调节 φ0 / φ1 并实时预览。 拖动二维相位时,Plotium 暂时固定已解析的等高线阈值,并随实部强度变化更新 diff --git a/docs/src/content/docs/zh-cn/reference/ribbon.md b/docs/src/content/docs/zh-cn/reference/ribbon.md index 648dc629..2bedb9d6 100644 --- a/docs/src/content/docs/zh-cn/reference/ribbon.md +++ b/docs/src/content/docs/zh-cn/reference/ribbon.md @@ -16,8 +16,7 @@ Analyze → Figure → Arrange**,**View** 排在最后。它是快捷入口— - **Data**——**Import**(打开文件、文件夹、表格与图片)、**Build**(新建 表格、堆叠)、**Export**(数据导出)。 - **Process**——**Processing**(处理管线各步骤:切趾、补零、基线、 - 参考等)、**Correct**(**Interactive phase**:1D/2D 谱图交互调相)、 - **3D Phase**(**3D Phase settings**:完整体数据的相位参数面板)、**Transform**(谱运算、谱 + 参考等)、**Correct**(相位校正)、**Transform**(谱运算、谱 对齐、CRAFT)、**Recipes**(处理模板)。 - **Analyze**——**Range**(所有拟合读取的分析范围),然后是当前数据集支持 的分组:**XPS**(XPS 工作台各页)、**Extract**(质谱提取)、**Regions**、