From 125b4995ad1ed7ec680625775099b8afdb135780 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Thu, 5 Nov 2026 13:47:24 +0800 Subject: [PATCH] feat(nmr): add interactive 3D phase previews --- crates/app/src/shot.rs | 43 +++ crates/app/src/shot/scenes.rs | 2 + crates/app/src/ui/canvas/breadcrumb.rs | 31 ++ crates/app/src/ui/canvas/image_painting.rs | 24 +- crates/app/src/ui/canvas/mod.rs | 10 +- crates/app/src/ui/canvas/phase.rs | 280 +++++++++++++++- crates/app/src/ui/canvas/phase_tests.rs | 141 ++++++++ crates/app/src/ui/command_exec.rs | 2 + crates/app/src/ui/commands.rs | 7 +- crates/app/src/ui/commands/identity.rs | 3 +- crates/app/src/ui/mod.rs | 4 + crates/app/src/ui/nmr_phase_preview.rs | 300 +++++++++++++++++ crates/app/src/ui/nmr_phase_preview_tests.rs | 204 ++++++++++++ crates/app/src/ui/nmr_volume.rs | 61 +++- crates/app/src/ui/nmr_volume_tests.rs | 33 +- crates/app/src/ui/phase_context.rs | 112 +++++++ crates/app/src/ui/shortcuts.rs | 94 +----- crates/app/src/ui/shortcuts_escape.rs | 90 +++++ crates/app/src/ui/shortcuts_tests.rs | 27 ++ crates/app/src/ui/tools/task_card.rs | 2 +- crates/core/src/state/app_impl.rs | 8 + crates/core/src/state/app_impl_nmr_volume.rs | 82 +++++ crates/core/src/state/compute.rs | 2 +- crates/core/src/state/compute_field.rs | 4 +- crates/core/src/state/nmr_volume.rs | 20 ++ crates/core/src/state/stack.rs | 9 + crates/core/src/state/ui_state.rs | 1 + crates/processing/src/nmr_volume.rs | 5 + crates/processing/src/nmr_volume_preview.rs | 126 +++++++ crates/processing/tests/nmr_volume.rs | 307 ++++++++++++++++++ crates/processing/tests/phase_modes_qa.rs | 81 +++++ docs/src/content/docs/guides/nmr-3d.md | 41 ++- docs/src/content/docs/guides/processing.md | 14 +- docs/src/content/docs/zh-cn/guides/nmr-3d.md | 29 +- .../content/docs/zh-cn/guides/processing.md | 10 +- 35 files changed, 2077 insertions(+), 132 deletions(-) create mode 100644 crates/app/src/ui/canvas/phase_tests.rs create mode 100644 crates/app/src/ui/nmr_phase_preview.rs create mode 100644 crates/app/src/ui/nmr_phase_preview_tests.rs create mode 100644 crates/app/src/ui/phase_context.rs create mode 100644 crates/app/src/ui/shortcuts_escape.rs create mode 100644 crates/processing/src/nmr_volume_preview.rs create mode 100644 crates/processing/tests/phase_modes_qa.rs diff --git a/crates/app/src/shot.rs b/crates/app/src/shot.rs index 91d641cf..97655e1f 100644 --- a/crates/app/src/shot.rs +++ b/crates/app/src/shot.rs @@ -79,6 +79,7 @@ impl Theme { /// live in the variant so the scene list stays a plain data table. #[derive(Clone, Copy)] enum Op { + VolumePhase, Workspace(bool), ImportResults, TableDraftConflict, @@ -306,6 +307,7 @@ fn themes_from_env() -> Vec { fn run_op(op: Op, app: &mut PlotiumApp, ctx: &egui::Context) -> Result<(), String> { match op { + Op::VolumePhase => volume_phase_shot(app, ctx)?, Op::Workspace(content) => workspace_shot::setup(app, content), Op::ImportResults => workspace_shot::imports(app), Op::TableDraftConflict => table_shot::conflict(app)?, @@ -343,6 +345,47 @@ fn run_op(op: Op, app: &mut PlotiumApp, ctx: &egui::Context) -> Result<(), Strin Ok(()) } +fn volume_phase_shot(app: &mut PlotiumApp, ctx: &egui::Context) -> Result<(), String> { + use plotium_core::state::{NmrVolumePhaseDraft, TaskDockTab}; + *app = PlotiumApp::new_with_settings(Settings::default()); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(fixture).map_err(|e| e.to_string())?; + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) + .map_err(|e| e.to_string())?; + let id = dataset.resource_id(); + let action = Action::insert_dataset_with_default_canvas( + app, + dataset, + "Phase fixture".into(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app.focus_single(0); + let volume = app.doc.datasets[0] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .ok_or("Expected a volume")?; + let mut phases = volume.phases; + phases[2].p0_degrees = 31.0; + phases[2].p1_degrees = -47.0; + app.session.ui.nmr_volume_phase_drafts.insert( + id, + NmrVolumePhaseDraft { + source: Arc::downgrade(volume.source.dataset()), + baseline: volume.phases, + phases, + canvas_axis: 2, + }, + ); + app.session.ui.processing_task_dataset = Some(id); + app.session.ui.open_task_tab(TaskDockTab::Processing); + app.set_tool(Tool::ManualPhase); + app.session.ui.ribbon_tab = WorkflowTab::Process; + crate::ui::canvas::request_board_fit(app, ctx, FrameRef::Page(0)); + Ok(()) +} + fn xps_setup(app: &mut PlotiumApp, ctx: &egui::Context) -> Result<(), String> { *app = PlotiumApp::new_with_settings(Settings::default()); let energy = (0..241) diff --git a/crates/app/src/shot/scenes.rs b/crates/app/src/shot/scenes.rs index 511dad78..b8940ffd 100644 --- a/crates/app/src/shot/scenes.rs +++ b/crates/app/src/shot/scenes.rs @@ -100,6 +100,8 @@ pub(super) const SCENES: &[Scene] = &[ act(2, Op::Resize(1440.0, 900.0)), act(2, Op::PanelControls(true)), shot(8, "panel_controls_expanded"), + act(2, Op::VolumePhase), + shot(40, "volume_phase_preview"), Scene { settle: 4, op: None, diff --git a/crates/app/src/ui/canvas/breadcrumb.rs b/crates/app/src/ui/canvas/breadcrumb.rs index 2b5c9540..e0ca8476 100644 --- a/crates/app/src/ui/canvas/breadcrumb.rs +++ b/crates/app/src/ui/canvas/breadcrumb.rs @@ -40,6 +40,11 @@ pub(crate) fn canvas_breadcrumb(app: &mut PlotiumApp, ci: usize, ui: &mut Ui) { ui.small(phase); } }); + if app.session.active_canvas == Some(ci) + && let Some(feedback) = crate::ui::nmr_phase_preview::canvas_feedback(app, ui.ctx()) + { + ui.small(feedback); + } ui.add_space(2.0); match clicked { Some(BreadcrumbTarget::Canvas) => app.exit_panel_scope(), @@ -63,6 +68,32 @@ fn phase_target(app: &PlotiumApp, ci: usize) -> String { return "Phase · Select a plot of the chosen spectrum".into(); } let axis = dataset.active_phase_axis(app.session.ui.phase_axis); + if let Some(volume) = dataset.as_nmr2d().and_then(|n| n.volume.as_ref()) { + let id = dataset.resource_id(); + if let Some(draft) = app.session.ui.nmr_volume_phase_drafts.get(&id) { + let a = draft.canvas_axis; + let nucleus = volume.source.axes()[a] + .nucleus + .as_deref() + .unwrap_or("unknown"); + return format!( + "Phase · {} · F{} · {nucleus} · {} · Apply: entire volume", + dataset.display_name(), + a + 1, + if a == volume.fixed_axis { + "fixed (numeric controls)" + } else if axis == PhaseAxis::F1 { + "vertical" + } else { + "horizontal" + } + ); + } + return format!( + "Phase · {} · choose an acquisition axis", + dataset.display_name() + ); + } let (nucleus, scope) = match dataset { Dataset::Nmr(nmr) => (nmr.data.axes().first(), "whole spectrum"), Dataset::Nmr2D(nmr) if nmr.volume.is_none() => ( diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs index 469c5add..73d60f22 100644 --- a/crates/app/src/ui/canvas/image_painting.rs +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -78,7 +78,29 @@ pub(super) fn paint_document( painter: &egui::Painter, ) { let canvas = &app.doc.canvases[ci]; - let document = canvas_document(app, ci); + let mut document = canvas_document(app, ci); + let previews: Vec<_> = canvas + .objects + .iter() + .map(|object| { + let plot = object.plot()?; + let preview = super::super::nmr_phase_preview::contours( + app, + painter.ctx(), + object.dataset()?, + object.id, + )?; + let mut figure = plot.figure().clone(); + figure.contours = preview.contours.clone(); + Some(figure) + }) + .collect(); + for (item, preview) in document.items.iter_mut().zip(&previews) { + if let (plotium_render::DocumentItem::Plot(plot), Some(figure)) = (item, preview) { + plot.figure = figure; + plot.geometry_generation = None; + } + } let zoom = app.session.board.zoom; let bp = canvas.board_pos; let detail = workspace_render_detail(painter.ctx(), painter.ctx().input(|input| input.time)); diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 30eed835..f54115c8 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -58,7 +58,8 @@ mod panel_notes; mod panel_selection; mod peaks; mod peaks3d; -mod phase; +pub(super) mod phase; +pub(super) use phase::cancel_volume_drag; mod plot_annotation_menu; mod plot_labels; mod plot_marks; @@ -145,7 +146,10 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { return; }; canvas_breadcrumb(app, ci, ui); + phase::phase_axis_picker(app, ui); super::nmr_volume::navigation(app, ui); + super::nmr_phase_preview::update(app, ui.ctx()); + phase::validate_volume_drag(app, ui.ctx()); let avail = ui.available_rect_before_wrap(); let (resp, painter) = ui.allocate_painter(avail.size(), Sense::click_and_drag()); let geometry = super::workspace_geometry(app, resp.rect, ui.ctx()); @@ -182,6 +186,8 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { // `dispatch_frame_gesture` may have switched the active frame. let ci = app.session.active_canvas.unwrap_or(ci); + super::phase_context::reconcile(app, ui.ctx()); + // Processing direct manipulation must update the document before its plots // are painted, so the changed spectrum is visible in this same frame. if pointer_owned && app.session.tool == Tool::ManualPhase { @@ -342,7 +348,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { 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); + let axis = phase::selected_phase_axis(app, di); if let Some(pivot_ppm) = displayed_phase_pivot_ppm(app, di, axis) { let fig = &app.doc.canvases[ci] .object(object_id) diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index 9ecbd750..9a5c0052 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -2,6 +2,10 @@ use super::*; use plotium_core::actions::DatasetProcessingState; use plotium_core::state::{PhaseAxis, PhaseOrient}; +#[cfg(test)] +#[path = "phase_tests.rs"] +mod gesture_tests; + pub(crate) struct PhaseDragCtx { pub axis: PhaseAxis, pub orient: PhaseOrient, @@ -12,6 +16,75 @@ pub(crate) struct PhaseDragCtx { pub map_rev: bool, } +pub(crate) fn selected_phase_axis(app: &PlotiumApp, di: usize) -> PhaseAxis { + let dataset = &app.doc.datasets[di]; + if let Some(volume) = dataset.as_nmr2d().and_then(|n| n.volume.as_ref()) { + let selected = app + .session + .ui + .nmr_volume_phase_drafts + .get(&dataset.resource_id()) + .map(|d| d.canvas_axis) + .unwrap_or(2); + let remaining: Vec<_> = (0..3).filter(|a| *a != volume.fixed_axis).collect(); + return if selected == remaining[0] { + PhaseAxis::F1 + } else { + PhaseAxis::F2 + }; + } + dataset.active_phase_axis(app.session.ui.phase_axis) +} + +pub(crate) fn phase_axis_picker(app: &mut PlotiumApp, ui: &mut Ui) { + if app.session.tool != Tool::ManualPhase { + return; + } + let Some(di) = + crate::ui::nmr_volume::phase_target(app).and_then(|id| app.doc.dataset_index(id)) + else { + return; + }; + let dataset = &app.doc.datasets[di]; + let Some(nmr) = dataset + .as_nmr2d() + .filter(|n| n.volume.is_none() && n.is_true_2d()) + else { + return; + }; + let labels: Vec<_> = [ + (PhaseAxis::F2, "Horizontal", 1), + (PhaseAxis::F1, "Vertical", 0), + ] + .into_iter() + .map(|(axis, direction, index)| { + let nucleus = nmr + .native_base + .axes() + .get(index) + .and_then(|a| a.nucleus.as_deref()) + .unwrap_or("unknown"); + ( + axis, + format!("{direction} · F{} · {nucleus}", index + 1), + crate::ui::nmr_volume::canvas_axis_phaseable(dataset, axis), + ) + }) + .collect(); + ui.horizontal_wrapped(|ui| { + for (axis, label, enabled) in labels { + if ui.add_enabled(enabled, egui::Button::new(label).selected(app.session.ui.phase_axis == axis)) + .on_disabled_hover_text("Enable a Phase step on this complex frequency-domain axis.").clicked() { + app.cancel_interaction(); app.session.ui.phase_axis = axis; + if let Some(step) = app.doc.datasets[di].axis_pipeline(axis).and_then(|p| p.steps.iter().find(|s| s.enabled && matches!(s.kind, plotium_processing::StepKind::Phase(_)))) { + app.session.ui.proc_expanded_step = Some((app.doc.datasets[di].resource_id(), step.id)); + } + } + } + ui.small("Drag vertically: left PH0 · right PH1 · pivot handle moves in ppm. Manual editing takes over automatic phase."); + }); +} + #[test] fn pivot_is_hidden_when_the_axis_phase_step_is_disabled_or_removed() { let mut app = PlotiumApp::new(); @@ -41,6 +114,27 @@ pub(crate) fn displayed_phase_pivot_ppm( dataset: usize, axis: PhaseAxis, ) -> Option { + let data = app.doc.datasets.get(dataset)?; + if let Some(volume) = data.as_nmr2d().and_then(|n| n.volume.as_ref()) { + let draft = app + .session + .ui + .nmr_volume_phase_drafts + .get(&data.resource_id())?; + if !volume.processed.supports_local_phase_preview() + || draft.canvas_axis == volume.fixed_axis + || !volume.processed.supports_phase(draft.canvas_axis) + || app.session.ui.nmr_volume_phase_busy + { + return None; + } + return volume + .processed + .phase_pivot_scale(draft.canvas_axis) + .ok()? + .ppm(draft.phases[draft.canvas_axis].pivot_fraction) + .ok(); + } if app.doc.datasets[dataset] .as_nmr2d() .is_some_and(|n| n.volume.is_some()) @@ -105,7 +199,7 @@ pub(crate) fn handle_phase_before_paint( }; let zoom = app.session.board.zoom; let plot = plot_screen_box(&app.doc.canvases[ci], object_id, figure, outer, zoom); - let axis = app.doc.datasets[di].active_phase_axis(app.session.ui.phase_axis); + let axis = selected_phase_axis(app, di); let Some(pivot_ppm) = displayed_phase_pivot_ppm(app, di, axis) else { return; }; @@ -150,6 +244,13 @@ pub(crate) fn handle_phase_drag( ctx: PhaseDragCtx, ui: &Ui, ) -> bool { + if app.doc.datasets[di] + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + { + volume_phase_drag(app, di, rect, plot, &ctx, ui); + return false; + } let (hover, p_down, s_down, p_pressed, s_pressed, delta) = ui.input(|i| { ( i.pointer.hover_pos(), @@ -272,3 +373,180 @@ pub(crate) fn finish_phase_drag(app: &mut PlotiumApp) { return; }; } + +#[derive(Clone)] +struct VolumeDrag { + id: plotium_core::state::DatasetId, + project: plotium_core::state::ProjectContextId, + axis: usize, + fixed: usize, + index: usize, + kind: PhaseDragKind, + before: plotium_core::state::NmrVolumePhaseDraft, +} +pub(crate) fn cancel_volume_drag(app: &mut PlotiumApp, ctx: &egui::Context) -> bool { + let key = egui::Id::new("volume_phase_drag"); + let Some(drag) = ctx.data(|data| data.get_temp::(key)) else { + return false; + }; + ctx.data_mut(|data| data.remove::(key)); + let valid = drag.project == app.session.project_context + && app + .doc + .dataset_by_id(drag.id) + .and_then(Dataset::as_nmr2d) + .and_then(|n| n.volume.as_ref()) + .is_some_and(|volume| { + volume.phases == drag.before.baseline + && drag + .before + .source + .ptr_eq(&std::sync::Arc::downgrade(volume.source.dataset())) + }); + if valid { + app.session + .ui + .nmr_volume_phase_drafts + .insert(drag.id, drag.before); + } + app.session.status = "Cancelled phase drag.".to_owned(); + true +} +pub(crate) fn validate_volume_drag(app: &PlotiumApp, ctx: &egui::Context) { + let key = egui::Id::new("volume_phase_drag"); + let Some(drag) = ctx.data(|data| data.get_temp::(key)) else { + return; + }; + let valid = app.session.tool == Tool::ManualPhase + && ctx.input(|i| { + i.pointer.primary_down() || i.pointer.secondary_down() || i.pointer.any_released() + }) + && crate::ui::nmr_volume::phase_target(app) == Some(drag.id) + && app.session.project_context == drag.project + && !app.session.ui.nmr_volume_phase_busy + && app + .doc + .dataset_by_id(drag.id) + .and_then(Dataset::as_nmr2d) + .and_then(|n| n.volume.as_ref()) + .is_some_and(|v| { + v.fixed_axis == drag.fixed + && v.index == drag.index + && v.phases == drag.before.baseline + && drag + .before + .source + .ptr_eq(&std::sync::Arc::downgrade(v.source.dataset())) + }) + && app + .session + .ui + .nmr_volume_phase_drafts + .get(&drag.id) + .is_some_and(|d| d.canvas_axis == drag.axis); + if !valid { + ctx.data_mut(|data| data.remove::(key)); + } +} +fn volume_phase_drag( + app: &mut PlotiumApp, + di: usize, + rect: egui::Rect, + plot: PlotRect, + ctx: &PhaseDragCtx, + ui: &Ui, +) { + let id = app.doc.datasets[di].resource_id(); + let Some(draft) = app.session.ui.nmr_volume_phase_drafts.get(&id).cloned() else { + return; + }; + let volume = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .expect("volume gesture target"); + let key = egui::Id::new("volume_phase_drag"); + let (hover, primary, secondary, pressed, right_pressed, delta, escape) = ui.input(|i| { + ( + i.pointer.hover_pos(), + i.pointer.primary_down(), + i.pointer.secondary_down(), + i.pointer.primary_pressed(), + i.pointer.secondary_pressed(), + i.pointer.delta(), + i.key_pressed(egui::Key::Escape), + ) + }); + let mut drag = ui.ctx().data(|data| data.get_temp::(key)); + if drag.is_none() && (pressed || right_pressed) && hover.is_some_and(|p| rect.contains(p)) { + let p = hover.expect("checked pointer"); + let position = if ctx.orient == PhaseOrient::Vertical { + p.x + } else { + p.y + }; + let kind = if (position - ctx.piv_px).abs() <= PIVOT_GRAB_PX { + PhaseDragKind::Pivot + } else if right_pressed { + PhaseDragKind::Ph1 + } else { + PhaseDragKind::Ph0 + }; + drag = Some(VolumeDrag { + id, + project: app.session.project_context, + axis: draft.canvas_axis, + fixed: volume.fixed_axis, + index: volume.index, + kind, + before: draft.clone(), + }); + } + let Some(drag) = drag else { + return; + }; + if drag.id != id + || drag.project != app.session.project_context + || drag.axis != draft.canvas_axis + || drag.fixed != volume.fixed_axis + || drag.index != volume.index + || !drag.before.source.ptr_eq(&draft.source) + || drag.before.baseline != draft.baseline + || app.session.ui.nmr_volume_phase_busy + { + ui.ctx().data_mut(|data| data.remove::(key)); + return; + } + let scale = volume.processed.phase_pivot_scale(drag.axis); + let mut next = draft; + if escape { + next = drag.before.clone(); + } else { + let phase = &mut next.phases[drag.axis]; + match drag.kind { + PhaseDragKind::Ph0 => phase.p0_degrees -= delta.y as f64 * PH0_PER_PX.to_degrees(), + PhaseDragKind::Ph1 => phase.p1_degrees -= delta.y as f64 * PH1_PER_PX.to_degrees(), + PhaseDragKind::Pivot => { + if let Some(p) = hover { + let ppm = if ctx.orient == PhaseOrient::Vertical { + screen_to_x(p.x, plot, ctx.map_min, ctx.map_span, ctx.map_rev) + } else { + screen_to_y(p.y, plot, ctx.map_min, ctx.map_span, ctx.map_rev) + }; + if let Err(error) = scale + .and_then(|scale| scale.fraction(ppm)) + .and_then(|q| phase.repivot(q)) + { + app.session.status = format!("Cannot move phase pivot: {error}"); + } + } + } + } + } + app.session.ui.nmr_volume_phase_drafts.insert(id, next); + if escape || (!primary && !secondary) { + ui.ctx().data_mut(|data| data.remove::(key)); + } else { + ui.ctx().data_mut(|data| data.insert_temp(key, drag)); + } + ui.ctx().request_repaint(); +} diff --git a/crates/app/src/ui/canvas/phase_tests.rs b/crates/app/src/ui/canvas/phase_tests.rs new file mode 100644 index 00000000..244a27e5 --- /dev/null +++ b/crates/app/src/ui/canvas/phase_tests.rs @@ -0,0 +1,141 @@ +use super::*; + +#[test] +fn volume_canvas_drag_edits_the_numeric_draft_without_committing_or_undo() { + use plotium_core::state::NmrVolumePhaseDraft; + 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 id = dataset.resource_id(); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.focus_single(0); + app.set_tool(Tool::ManualPhase); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let baseline = volume.phases; + app.session.ui.nmr_volume_phase_drafts.insert( + id, + NmrVolumePhaseDraft { + canvas_axis: 2, + source: std::sync::Arc::downgrade(volume.source.dataset()), + baseline, + phases: baseline, + }, + ); + let mark = (app.doc.edit_generation, app.session.undo_stack.len()); + let egui_ctx = egui::Context::default(); + let context = PhaseDragCtx { + axis: PhaseAxis::F2, + orient: PhaseOrient::Vertical, + piv_px: 20.0, + pivot_ppm: 5.0, + map_min: 0.0, + map_span: 10.0, + map_rev: false, + }; + let mut frame = |events| { + let input = egui::RawInput { + events, + ..Default::default() + }; + let _ = egui_ctx.run_ui(input, |ui| { + volume_phase_drag( + &mut app, + 0, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(100.0, 100.0)), + PlotRect::new(0.0, 0.0, 100.0, 100.0), + &context, + ui, + ) + }); + }; + frame(vec![ + egui::Event::PointerMoved(egui::pos2(60.0, 60.0)), + egui::Event::PointerButton { + pos: egui::pos2(60.0, 60.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ]); + frame(vec![egui::Event::PointerMoved(egui::pos2(60.0, 40.0))]); + frame(vec![egui::Event::PointerButton { + pos: egui::pos2(60.0, 40.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + assert!(app.session.ui.nmr_volume_phase_drafts[&id].phases[2].p0_degrees > 0.0); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases, + baseline + ); + assert_eq!( + (app.doc.edit_generation, app.session.undo_stack.len()), + mark + ); + assert!( + egui_ctx + .data(|data| data.get_temp::(egui::Id::new("volume_phase_drag"))) + .is_none() + ); +} + +#[test] +fn cancel_volume_drag_restores_only_the_drag_and_keeps_mode() { + 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 id = dataset.resource_id(); + let volume = dataset.as_nmr2d().unwrap().volume.as_ref().unwrap(); + let mut before = plotium_core::state::NmrVolumePhaseDraft { + canvas_axis: 2, + source: std::sync::Arc::downgrade(volume.source.dataset()), + baseline: volume.phases, + phases: volume.phases, + }; + before.phases[2].p0_degrees = 13.0; + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.set_tool(Tool::ManualPhase); + let ctx = egui::Context::default(); + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new("volume_phase_drag"), + VolumeDrag { + id, + project: app.session.project_context, + axis: 2, + fixed: 0, + index: 0, + kind: PhaseDragKind::Ph0, + before: before.clone(), + }, + ) + }); + let mut changed = before.clone(); + changed.phases[2].p0_degrees = 42.0; + app.session.ui.nmr_volume_phase_drafts.insert(id, changed); + assert!(cancel_volume_drag(&mut app, &ctx)); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert_eq!( + app.session.ui.nmr_volume_phase_drafts[&id].phases, + before.phases + ); + assert!(!cancel_volume_drag(&mut app, &ctx)); +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 17db90d0..3bd4035b 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -29,6 +29,7 @@ fn execute_inner( clipboard: Option<&mut ClipboardTablePaste>, ctx: &egui::Context, ) { + super::phase_context::reconcile(app, ctx); if matches!(id, CommandId::Undo | CommandId::Redo) { // Commit any debounced wheel zoom before the enabled gate, so the // pending zoom becomes the next undoable step and history is ordered @@ -361,6 +362,7 @@ fn execute_inner( } CommandId::Tool(tool) => app.toggle_tool(tool), } + super::phase_context::reconcile(app, ctx); } fn execute_panel_command(id: CommandId, app: &mut PlotiumApp) { diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 7fa2453d..cc407cb2 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -734,10 +734,9 @@ 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 - && !(tool == Tool::ManualPhase && super::nmr_volume::active_volume(app)) - }); + let active_tool = id + .tool_target() + .is_some_and(|tool| app.session.tool == tool); 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 diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 1b071ebe..2968a951 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -386,8 +386,7 @@ pub(super) fn command_identity( CommandId::Tool(Tool::ManualPhase) => ( "Phase".into(), tool_icon(Tool::ManualPhase), - (!super::super::nmr_volume::active_volume(app)) - .then_some(app.session.tool == Tool::ManualPhase), + Some(app.session.tool == Tool::ManualPhase), ), CommandId::Tool(tool) => ( format!("Tool: {}", tool.label()), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 0a3120ad..aea7ce29 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -22,6 +22,7 @@ mod export_dialog; mod feedback_banner; mod figure_typography; mod paused_results_notice; +mod phase_context; pub(crate) mod plot_annotations; use feedback_banner::feedback_banner; pub(crate) mod file_dialogs; @@ -92,6 +93,7 @@ pub fn render( ribbon_chrome: RibbonChrome, ) { let ctx = ui.ctx().clone(); + phase_context::reconcile(app, &ctx); nmr_volume::poll_phase(app, &ctx); if let plotium_core::state::Interaction::Phase(drag) = app.interaction() && nmr_volume::phase_target(app) != Some(drag.dataset) @@ -193,6 +195,7 @@ pub fn render( let workspace_width = ui.available_width(); sidebars::render(app, ui, dark, workspace_width); + phase_context::reconcile(app, &ctx); // A sidebar may have changed an expanded Phase step before the canvas paints. app.sync_phase_interaction(); @@ -692,5 +695,6 @@ mod feedback_tests { } } +mod nmr_phase_preview; mod nmr_volume; mod peaks3d; diff --git a/crates/app/src/ui/nmr_phase_preview.rs b/crates/app/src/ui/nmr_phase_preview.rs new file mode 100644 index 00000000..e840b625 --- /dev/null +++ b/crates/app/src/ui/nmr_phase_preview.rs @@ -0,0 +1,300 @@ +//! One coalesced, disposable preview. The document and field caches stay committed. +use plotium_core::state::*; +use plotium_processing::nmr_volume::VolumePhase; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +struct Request { + project: ProjectContextId, + id: DatasetId, + plot: ObjectId, + generation: u64, + processed: std::sync::Weak, + phases: [VolumePhase; 3], + fixed: usize, + index: usize, +} +impl Request { + fn same_plane(&self, other: &Self) -> bool { + self.plot == other.plot + && self.generation == other.generation + && self.project == other.project + && self.id == other.id + && self.processed.ptr_eq(&other.processed) + && self.fixed == other.fixed + && self.index == other.index + } + fn matches(&self, other: &Self) -> bool { + self.same_plane(other) && self.phases == other.phases + } +} +type PreviewOutput = Arc>>>; +#[derive(Clone, Default)] +struct Preview { + running: Option<(Request, PreviewOutput)>, + shown: Option<(Request, Arc)>, + levels: Option<(Request, ContourGeometryCacheKey)>, + failed: Option, + companions: Option<(Vec, Vec)>, +} +fn key() -> egui::Id { + egui::Id::new("volume_local_phase_preview") +} +pub(super) fn canvas_feedback(app: &PlotiumApp, ctx: &egui::Context) -> Option<&'static str> { + request(app)?; + Some(feedback(app, ctx)) +} +pub(super) fn feedback(app: &PlotiumApp, ctx: &egui::Context) -> &'static str { + let Some(current) = request(app) else { + return "Committed display · no active preview"; + }; + let cache = ctx + .data(|data| data.get_temp::(key())) + .unwrap_or_default(); + if cache + .failed + .as_ref() + .is_some_and(|failed| failed.matches(¤t)) + { + "Preview failed · see status" + } else if cache + .shown + .as_ref() + .is_some_and(|(shown, _)| shown.matches(¤t)) + { + "Unapplied · current plane preview" + } else { + "Unapplied · updating plane preview…" + } +} +fn request(app: &PlotiumApp) -> Option { + if !super::tools::task_card::is_active(app, TaskDockTab::Processing) { + return None; + } + let id = super::nmr_volume::phase_target(app)?; + let canvas = app.doc.canvases.get(app.session.active_canvas?)?; + let plot_id = canvas + .selected_plot_object_id() + .or_else(|| canvas.active_plot_object_id())?; + let plot = canvas.object(plot_id)?.plot()?; + if canvas.object(plot_id)?.dataset() != Some(id) { + return None; + } + if app.session.ui.processing_task_dataset != Some(id) { + return None; + } + let volume = app.doc.dataset_by_id(id)?.as_nmr2d()?.volume.as_ref()?; + let draft = app.session.ui.nmr_volume_phase_drafts.get(&id)?; + if draft.baseline != volume.phases + || !draft + .source + .ptr_eq(&Arc::downgrade(volume.source.dataset())) + || draft.phases == volume.phases + { + return None; + } + Some(Request { + plot: plot_id, + generation: plot.figure_geometry_generation(), + project: app.session.project_context, + id, + processed: Arc::downgrade(volume.processed.source().dataset()), + phases: draft.phases, + fixed: volume.fixed_axis, + index: volume.index, + }) +} +pub(super) fn contours( + app: &PlotiumApp, + ctx: &egui::Context, + id: DatasetId, + object: ObjectId, +) -> Option> { + let current = request(app)?; + if current.id != id || current.plot != object { + return None; + } + let cache = ctx.data(|data| data.get_temp::(key()))?; + let (shown, figure) = cache.shown?; + if !shown.same_plane(¤t) { + return None; + } + let mut composed = (*figure).clone(); + let (mut before, after) = cache.companions?; + before.append(&mut composed.contours); + before.extend(after); + composed.contours = before; + Some(Arc::new(composed)) +} +fn target_series(app: &PlotiumApp, current: &Request) -> Option { + app.doc + .canvases + .iter() + .flat_map(|c| &c.objects) + .find(|object| object.id == current.plot) + .and_then(|o| o.plot()) + .into_iter() + .flat_map(|plot| &plot.binding.series) + .find(|series| { + series.source.resource == current.id + && series.visible + && app + .doc + .dataset_by_id(current.id) + .and_then(|d| d.field_descriptor(series.source.field)) + .is_some_and(|d| d.local_id == "nmr.real") + && matches!(series.encoding, plotium_figure::SeriesEncoding::Contour(_)) + }) + .cloned() +} +fn companion_series( + series: Vec, + target: Option, +) -> (Vec, Vec) { + let mut before = Vec::new(); + let mut after = Vec::new(); + let mut passed_target = false; + for series in series { + if Some(series.id) == target { + passed_target = true; + } else if passed_target { + after.push(series); + } else { + before.push(series); + } + } + (before, after) +} +pub(super) fn update(app: &mut PlotiumApp, ctx: &egui::Context) { + let current = request(app); + let mut cache = ctx + .data(|data| data.get_temp::(key())) + .unwrap_or_default(); + if let Some((running, output)) = &cache.running { + let result = match output.lock() { + Ok(mut output) => output.take(), + Err(error) => Some(Err(format!("Phase preview worker failed: {error}"))), + }; + if let Some(result) = result { + if current + .as_ref() + .is_some_and(|current| running.matches(current)) + { + match result { + Ok(figure) => cache.shown = Some((running.clone(), Arc::new(figure))), + Err(error) => { + app.session.status = format!("Cannot preview phase: {error}"); + cache.failed = Some(running.clone()); + } + } + } + cache.running = None; + } + } + if let Some(current) = current { + if cache.running.is_none() + && !cache + .shown + .as_ref() + .is_some_and(|(shown, _)| shown.matches(¤t)) + && !cache + .failed + .as_ref() + .is_some_and(|failed| failed.matches(¤t)) + { + let series = target_series(app, ¤t); + if let Some(series) = series { + let levels = cache + .levels + .as_ref() + .filter(|(previous, _)| previous.same_plane(¤t)) + .map(|(_, levels)| Ok(levels.clone())) + .unwrap_or_else(|| app.volume_preview_contour_key(&series)); + match levels { + Ok(levels) => { + cache.levels = Some((current.clone(), levels.clone())); + let dataset = app + .doc + .dataset_by_id(current.id) + .and_then(Dataset::as_nmr2d) + .expect("validated preview target") + .clone(); + let output = Arc::new(Mutex::new(None)); + cache.running = Some((current.clone(), output.clone())); + let ctx = ctx.clone(); + let plotium_figure::SeriesEncoding::Contour(spec) = series.encoding else { + unreachable!() + }; + std::thread::spawn(move || { + let volume = dataset.volume.as_ref().expect("validated preview volume"); + let started = std::time::Instant::now(); + let result = volume + .processed + .phase_preview_plane( + current.fixed, + current.index, + volume.phases, + current.phases, + &mut nmr::ExecutionContext::default(), + ) + .and_then(|plane| { + dataset.phase_preview_contours(plane, levels, spec.style) + }); + log::debug!( + "Volume phase plane {:?}: {:?}", + volume.processed.shape(), + started.elapsed() + ); + match output.lock() { + Ok(mut output) => *output = Some(result), + Err(error) => log::error!("Cannot deliver phase preview: {error}"), + } + ctx.request_repaint(); + }); + } + Err(error) => { + app.session.status = format!("Cannot preview phase: {error}"); + cache.failed = Some(current); + } + } + } else { + app.session.status = + "Show the target spectrum's real contour field to preview phase.".into(); + cache.failed = Some(current); + } + } + } else { + cache.shown = None; + cache.levels = None; + cache.failed = None; + } + cache.companions = if let Some(current) = request(app) { + let target = target_series(app, ¤t).map(|series| series.id); + let (before, after) = app + .doc + .canvases + .iter() + .flat_map(|c| &c.objects) + .find(|object| object.id == current.plot) + .and_then(|o| o.plot()) + .map(|p| { + companion_series( + app.display_binding(p.display_owner, &p.binding).series, + target, + ) + }) + .unwrap_or_default(); + app.volume_preview_companion_contours(&before) + .zip(app.volume_preview_companion_contours(&after)) + } else { + None + }; + if cache.running.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + ctx.data_mut(|data| data.insert_temp(key(), cache)); +} + +#[cfg(test)] +#[path = "nmr_phase_preview_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/nmr_phase_preview_tests.rs b/crates/app/src/ui/nmr_phase_preview_tests.rs new file mode 100644 index 00000000..902c7cf3 --- /dev/null +++ b/crates/app/src/ui/nmr_phase_preview_tests.rs @@ -0,0 +1,204 @@ +use super::*; + +fn fixture() -> (PlotiumApp, DatasetId) { + 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 id = dataset.resource_id(); + let mut app = PlotiumApp::new(); + let action = plotium_core::actions::Action::insert_dataset_with_default_canvas( + &app, + dataset, + "Preview fixture".into(), + DEFAULT_CANVAS_SIZE_MM, + ); + app.execute_action(action); + app.focus_single(0); + app.session.ui.processing_task_dataset = Some(id); + app.session.ui.open_task_tab(TaskDockTab::Processing); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let mut phases = volume.phases; + phases[2].p0_degrees = 41.0; + app.session.ui.nmr_volume_phase_drafts.insert( + id, + NmrVolumePhaseDraft { + canvas_axis: 2, + source: Arc::downgrade(volume.source.dataset()), + baseline: volume.phases, + phases, + }, + ); + (app, id) +} + +#[test] +fn numeric_preview_remains_visible_after_exiting_canvas_phasing() { + let (mut app, id) = fixture(); + let ctx = egui::Context::default(); + app.set_tool(Tool::ManualPhase); + let before = request(&app).unwrap(); + app.toggle_tool(Tool::ManualPhase); + assert!(request(&app).unwrap().matches(&before)); + assert_eq!( + canvas_feedback(&app, &ctx), + Some("Unapplied · updating plane preview…") + ); + app.session.ui.close_task_tab(TaskDockTab::Processing); + assert!(canvas_feedback(&app, &ctx).is_none()); + assert!(app.session.ui.nmr_volume_phase_drafts.contains_key(&id)); +} + +#[test] +fn requests_bind_target_project_baseline_draft_and_slice() { + let (mut app, id) = fixture(); + let original = request(&app).unwrap(); + app.session + .ui + .nmr_volume_phase_drafts + .get_mut(&id) + .unwrap() + .phases[2] + .p1_degrees = 27.0; + let edited = request(&app).unwrap(); + assert!(original.same_plane(&edited)); + assert!(!original.matches(&edited)); + let volume = app.doc.datasets[0] + .as_nmr2d_mut() + .unwrap() + .volume + .as_mut() + .unwrap(); + volume.index += 1; + assert!(!original.same_plane(&request(&app).unwrap())); + app.session + .ui + .nmr_volume_phase_drafts + .get_mut(&id) + .unwrap() + .baseline[2] + .p0_degrees = 9.0; + assert!(request(&app).is_none()); + let (mut app, _) = fixture(); + let first = request(&app).unwrap(); + app.session.project_context = PlotiumApp::new().session.project_context; + assert!(!first.matches(&request(&app).unwrap())); +} + +#[test] +fn closed_discarded_or_retargeted_previews_are_invisible() { + let (mut app, id) = fixture(); + let current = request(&app).unwrap(); + let object = current.plot; + let ctx = egui::Context::default(); + let figure = plotium_figure::Figure::new( + "test", + plotium_figure::Axis::new("x", 0.0, 1.0), + plotium_figure::Axis::new("y", 0.0, 1.0), + ); + ctx.data_mut(|data| { + data.insert_temp( + key(), + Preview { + shown: Some((current, Arc::new(figure))), + companions: Some((Vec::new(), Vec::new())), + ..Default::default() + }, + ) + }); + assert!(contours(&app, &ctx, id, object).is_some()); + let before = (app.doc.edit_generation, app.session.undo_stack.len()); + app.session.ui.close_task_tab(TaskDockTab::Processing); + assert!(contours(&app, &ctx, id, object).is_none()); + app.session.ui.processing_task_dataset = Some(id); + app.session.ui.open_task_tab(TaskDockTab::Processing); + assert!(contours(&app, &ctx, id, object).is_some()); + app.session.ui.nmr_volume_phase_drafts.remove(&id); + assert!(contours(&app, &ctx, id, object).is_none()); + assert_eq!( + (app.doc.edit_generation, app.session.undo_stack.len()), + before + ); +} + +#[test] +fn late_result_cannot_install_after_draft_changes() { + let (mut app, id) = fixture(); + let current = request(&app).unwrap(); + let output = Arc::new(Mutex::new(Some(Err("obsolete worker failure".into())))); + let ctx = egui::Context::default(); + ctx.data_mut(|data| { + data.insert_temp( + key(), + Preview { + running: Some((current, output)), + ..Default::default() + }, + ) + }); + app.session + .ui + .nmr_volume_phase_drafts + .get_mut(&id) + .unwrap() + .phases[2] + .p0_degrees += 1.0; + update(&mut app, &ctx); + let cache = ctx.data(|data| data.get_temp::(key())).unwrap(); + assert!( + cache + .running + .as_ref() + .is_none_or(|(r, _)| r.matches(&request(&app).unwrap())) + ); + assert!(cache.shown.is_none()); + assert!(cache.failed.is_none()); + assert!(!app.session.status.contains("obsolete")); +} + +#[test] +fn preview_target_is_one_series_even_with_same_dataset_layers() { + let (mut app, _) = fixture(); + let current = request(&app).unwrap(); + let target = target_series(&app, ¤t).unwrap(); + let plot = app + .doc + .canvases + .iter_mut() + .flat_map(|c| &mut c.objects) + .find(|o| o.id == current.plot) + .unwrap() + .plot_mut() + .unwrap(); + let mut second = target.clone(); + second.id = SeriesId::new(999); + plot.binding.series.push(second.clone()); + assert_eq!(target_series(&app, ¤t).unwrap().id, target.id); + let mut preceding = target.clone(); + preceding.id = SeriesId::new(998); + let mut following = second.clone(); + following.id = SeriesId::new(1000); + let (before, after) = companion_series( + vec![ + preceding.clone(), + target.clone(), + second.clone(), + following.clone(), + ], + Some(target.id), + ); + assert_eq!( + before.iter().map(|s| s.id).collect::>(), + vec![preceding.id] + ); + assert_eq!( + after.iter().map(|s| s.id).collect::>(), + vec![second.id, following.id] + ); +} diff --git a/crates/app/src/ui/nmr_volume.rs b/crates/app/src/ui/nmr_volume.rs index 1a50863a..d4cacb81 100644 --- a/crates/app/src/ui/nmr_volume.rs +++ b/crates/app/src/ui/nmr_volume.rs @@ -33,11 +33,15 @@ pub(super) fn active_volume(app: &PlotiumApp) -> bool { } pub(super) fn open_phase(app: &mut PlotiumApp) { + if app.session.tool == plotium_core::state::Tool::ManualPhase { + app.toggle_tool(plotium_core::state::Tool::ManualPhase); + return; + } 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); + app.toggle_tool(plotium_core::state::Tool::ManualPhase); super::tools::expand_processing_surface(app); app.session.ui.processing_task_collapsed = false; } @@ -240,7 +244,8 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { .unwrap_or("unknown"); ( format!("F{} · {nucleus} · {}", axis + 1, axis_role(axis, fixed)), - volume.processed.supports_phase(axis), + volume.processed.supports_phase(axis) + && volume.processed.supports_local_phase_preview(), volume.processed.phase_pivot_scale(axis), ) }); @@ -266,20 +271,40 @@ 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, "Phase", Some(name.as_str()), |ui| { + task_card::header(ui, area_id, "Phase correction", 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."); + .on_hover_text("Exit canvas phasing and hide Phase and its preview. Keep the draft and let any calculation continue."); if response.clicked() { close = true; } }); if collapsed { return; } egui::ScrollArea::vertical().max_height(geometry.body_height).show(ui, |ui| { - ui.label("Applies to the entire 3D spectrum. The current plane is for observation."); + ui.label("Apply changes the entire 3D spectrum. Preview changes only this displayed plane."); + let command = CommandId::Tool(plotium_core::state::Tool::ManualPhase); + let desc = commands::describe(app, command); + let active = app.session.tool == plotium_core::state::Tool::ManualPhase; + if ui.add_enabled(desc.enabled && owns_target, egui::Button::new("Canvas phasing").selected(active)) + .on_disabled_hover_text(if owns_target { desc.disabled_reason.unwrap_or_default() } else { "Select this spectrum before changing its canvas phasing mode." }).clicked() { + commands::execute_without_clipboard(command, app, ui.ctx()); + } + if active && busy { + ui.small("Canvas phasing paused while the phase calculation runs."); + } else if active && !axes[draft.canvas_axis].1 { + ui.small("Canvas phasing paused: this axis needs independent Cartesian frequency-domain components."); + } else if active && canvas_phase_target(app) != Some(id) { + ui.small("Canvas phasing paused: select a plot of this spectrum on the active canvas. Numeric controls remain available."); + } else if active && draft.canvas_axis == fixed { + ui.small("Canvas phasing paused: choose a plane containing the selected axis. Numeric controls remain available."); + } else if active { + ui.small("Drag vertically: left PH0, right PH1; move the pivot handle in ppm. Esc exits canvas phasing and keeps the draft."); + } else { + ui.small("Canvas phasing off. Numeric edits automatically preview the current plane."); + } 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."); + ui.strong(super::nmr_phase_preview::feedback(app, ui.ctx())); } if let Some(job) = &job { ui.spinner(); @@ -295,11 +320,16 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { 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, _)) in axes.iter().enumerate() { + if ui.add_enabled(*supported, egui::Button::new(label).selected(draft.canvas_axis == axis)) + .on_disabled_hover_text("Choose an axis with independent Cartesian frequency-domain components.").clicked() { draft.canvas_axis = axis; } + } for (axis, (label, supported, scale)) in axes.iter().enumerate() { + if draft.canvas_axis != axis { continue; } ui.separator(); ui.strong(label); if !supported { - ui.small("Phase unavailable: this axis needs a complex frequency-domain signal."); + ui.small("Phase unavailable: use independent Cartesian frequency-domain components."); continue; } let phase = &mut draft.phases[axis]; @@ -329,14 +359,14 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { })); 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 { + } else { 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")) + if ui.add_enabled(desc.enabled && owns_target, egui::Button::new("Apply to 3D spectrum")) .on_disabled_hover_text(desc.disabled_reason.unwrap_or_default()).clicked() { commands::execute_without_clipboard(CommandId::ApplyNmrVolumePhase, app, ui.ctx()); } @@ -344,6 +374,7 @@ pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { }); }); if close { + app.set_tool(plotium_core::state::Tool::BrowseZoom); app.session.ui.close_task_tab(TaskDockTab::Processing); } } @@ -464,6 +495,9 @@ fn phase_draft( } ( plotium_core::state::NmrVolumePhaseDraft { + canvas_axis: (0..3) + .find(|axis| *axis != volume.fixed_axis && volume.processed.supports_phase(*axis)) + .unwrap_or(2), source, baseline: volume.phases, phases: volume.phases, @@ -498,7 +532,12 @@ pub(super) fn open_phase_gate(app: &PlotiumApp) -> Result<(), &'static str> { return require_volume_phase(volume); } let axis = dataset.active_phase_axis(app.session.ui.phase_axis); - if !canvas_axis_phaseable(dataset, axis) { + if !canvas_axis_phaseable(dataset, axis) + && !dataset + .phase_axes() + .iter() + .any(|axis| canvas_axis_phaseable(dataset, *axis)) + { return Err( "Enable a Phase step on the current frequency-domain axis, or choose another axis in Processing.", ); diff --git a/crates/app/src/ui/nmr_volume_tests.rs b/crates/app/src/ui/nmr_volume_tests.rs index b4fef118..18eefa48 100644 --- a/crates/app/src/ui/nmr_volume_tests.rs +++ b/crates/app/src/ui/nmr_volume_tests.rs @@ -16,11 +16,11 @@ fn volume_phase_settings_end_peak_editing_and_open_processing() { 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.checked, Some(false)); 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.tool, plotium_core::state::Tool::ManualPhase); assert_eq!(app.session.ui.processing_task_dataset, Some(owner)); assert!(!app.session.ui.processing_task_collapsed); } @@ -50,6 +50,7 @@ fn volume_navigation_renders_and_disables_endpoint_steps() { .unwrap() .phases; let mut draft = plotium_core::state::NmrVolumePhaseDraft { + canvas_axis: 2, source: std::sync::Arc::downgrade( app.doc.datasets[0] .as_nmr2d() @@ -410,10 +411,36 @@ fn phase_routes_volume_selection_by_scope() { app.focus_single(di); app.session.ui.selection_scope = SelectionScope::DataList; + // The previously selected data-list dataset is already focused; leave the + // canvas tool explicitly before activating the new target. + app.set_tool(Tool::BrowseZoom); commands::execute_without_clipboard(command, &mut app, &ctx); - assert_eq!(app.session.tool, Tool::BrowseZoom); + assert_eq!(app.session.tool, Tool::ManualPhase); assert_eq!( app.session.ui.processing_task_dataset, Some(app.doc.datasets[di].resource_id()) ); } + +#[test] +fn volume_phase_toggle_retains_card_and_draft() { + use plotium_core::state::Tool; + let mut app = PlotiumApp::new(); + app.doc.datasets.push(volume_fixture()); + app.focus_single(0); + let ctx = egui::Context::default(); + let command = CommandId::Tool(Tool::ManualPhase); + commands::execute_without_clipboard(command, &mut app, &ctx); + let owner = app.session.ui.processing_task_dataset; + let id = owner.unwrap(); + let mut draft = phase_draft(&app, id, volume(&app)).0; + draft.phases[2].p0_degrees = 17.0; + let phases = draft.phases; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + assert_eq!(commands::describe(&app, command).checked, Some(true)); + commands::execute_without_clipboard(command, &mut app, &ctx); + assert_ne!(app.session.tool, Tool::ManualPhase); + assert_eq!(app.session.ui.processing_task_dataset, owner); + assert_eq!(commands::describe(&app, command).checked, Some(false)); + assert_eq!(app.session.ui.nmr_volume_phase_drafts[&id].phases, phases); +} diff --git a/crates/app/src/ui/phase_context.rs b/crates/app/src/ui/phase_context.rs new file mode 100644 index 00000000..e5350304 --- /dev/null +++ b/crates/app/src/ui/phase_context.rs @@ -0,0 +1,112 @@ +use plotium_core::state::{CanvasId, DatasetId, PlotiumApp, ProjectContextId, Tool}; + +type Context = (ProjectContextId, Option, Option); + +pub(super) fn reconcile(app: &mut PlotiumApp, ctx: &egui::Context) { + let key = egui::Id::new("manual_phase_context"); + if app.session.tool != Tool::ManualPhase { + super::canvas::cancel_volume_drag(app, ctx); + ctx.data_mut(|data| data.remove::(key)); + return; + } + let current = ( + app.session.project_context, + app.session + .active_canvas + .and_then(|ci| app.doc.canvases.get(ci)) + .map(|c| c.resource_id), + super::nmr_volume::phase_target(app), + ); + let previous = ctx.data(|data| data.get_temp::(key)); + if previous.is_some_and(|previous| previous != current) { + super::canvas::cancel_volume_drag(app, ctx); + app.cancel_interaction(); + app.set_tool(Tool::BrowseZoom); + ctx.data_mut(|data| data.remove::(key)); + } else { + ctx.data_mut(|data| data.insert_temp(key, current)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn changed_canvas_identity_exits_phase() { + let mut app = PlotiumApp::new_with_settings(Default::default()); + let ctx = egui::Context::default(); + app.set_tool(Tool::ManualPhase); + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new("manual_phase_context"), + ( + app.session.project_context, + Some(CanvasId::new()), + None::, + ), + ); + }); + reconcile(&mut app, &ctx); + assert_eq!(app.session.tool, Tool::BrowseZoom); + } + + #[test] + fn unchanged_context_retains_phase() { + let mut app = PlotiumApp::new_with_settings(Default::default()); + let ctx = egui::Context::default(); + app.set_tool(Tool::ManualPhase); + reconcile(&mut app, &ctx); + reconcile(&mut app, &ctx); + assert_eq!(app.session.tool, Tool::ManualPhase); + } + + #[test] + fn plane_navigation_keeps_dirty_draft_but_direct_dataset_switch_exits() { + use plotium_core::state::{NmrVolumePhaseDraft, SelectionScope}; + 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 id = dataset.resource_id(); + let volume = dataset.as_nmr2d().unwrap().volume.as_ref().unwrap(); + let mut draft = NmrVolumePhaseDraft { + canvas_axis: 2, + source: std::sync::Arc::downgrade(volume.source.dataset()), + baseline: volume.phases, + phases: volume.phases, + }; + draft.phases[2].p0_degrees = 19.0; + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.doc + .datasets + .push(crate::ui::properties::fixture::homonuclear_frequency_2d()); + app.focus_single(0); + app.session.ui.selection_scope = SelectionScope::DataList; + app.session + .ui + .nmr_volume_phase_drafts + .insert(id, draft.clone()); + let ctx = egui::Context::default(); + app.set_tool(Tool::ManualPhase); + reconcile(&mut app, &ctx); + app.select_nmr_volume_plane(id, 1, 0).unwrap(); + reconcile(&mut app, &ctx); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert_eq!(app.session.ui.nmr_volume_phase_drafts[&id].canvas_axis, 2); + assert_eq!( + app.session.ui.nmr_volume_phase_drafts[&id].phases, + draft.phases + ); + // Also cover selection paths that mutate state without focus_single. + app.session.ui.data_selection = vec![1]; + reconcile(&mut app, &ctx); + assert_eq!(app.session.tool, Tool::BrowseZoom); + assert_eq!( + app.session.ui.nmr_volume_phase_drafts[&id].phases, + draft.phases + ); + } +} diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index 6d93263e..88feb2cd 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -1,3 +1,7 @@ +#[path = "shortcuts_escape.rs"] +mod escape; +use escape::handle_escape; + use super::*; use plotium_core::properties::PropertyStep; use plotium_core::state::Tool; @@ -364,98 +368,20 @@ fn bound_command(i: &egui::InputState) -> Option { pub(super) fn handle_escape_shortcut(app: &mut PlotiumApp, ctx: &egui::Context) { // egui has already dropped a cell editor's focus for this Escape. - if ctx.egui_wants_keyboard_input() || data_sheet::sheet_held_keyboard(ctx) { + if ctx.egui_wants_keyboard_input() + || data_sheet::sheet_held_keyboard(ctx) + || egui::Popup::is_any_open(ctx) + { return; } let (escape, now) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.time)); if !escape { return; } - handle_escape(app, now); -} - -fn handle_escape(app: &mut PlotiumApp, now: f64) { - if app.interaction().is_active() { - let phase = matches!(app.interaction(), Interaction::Phase(_)); - app.cancel_interaction(); - app.session.status = if phase { - "Cancelled phase drag.".to_owned() - } else { - "Cancelled interaction.".to_owned() - }; - return; - } - - if app.session.ui.wheel_zoom.is_some() || app.session.ui.wheel_property.is_some() { - app.finish_pending_wheel_zoom(now, true); - app.finish_pending_wheel_property(now, true); - app.session.status = "Cancelled interaction.".to_owned(); + if canvas::cancel_volume_drag(app, ctx) { return; } - // Esc steps back one level: analysis region -> plot part -> panel-letter - // sub-selection -> selection -> active tool. - if app.session.ui.analysis_selection.is_some() { - if let Some(ci) = app.session.active_canvas { - canvas::clear_canvas_interaction_state( - app, - ci, - canvas::CanvasInteractionClearScope::All, - ); - } else { - app.session.ui.analysis_selection = None; - } - app.session.status = "Cleared analysis selection.".to_owned(); - return; - } - - if app.selected_plot_part().is_some() { - app.session.ui.plot_part_selection = None; - app.session.status = "Selected the whole plot.".to_owned(); - return; - } - - if app.session.ui.panel_label_selection.is_some() { - app.session.ui.panel_label_selection = None; - app.session.status = "Selection cleared.".to_owned(); - return; - } - - if app - .session - .ui - .hierarchical_selection - .lead() - .is_some_and(|path| path.panel.is_some() && path.content.is_some()) - { - app.exit_panel_scope(); - app.session.status = "Selected parent panel.".to_owned(); - return; - } - - if !app.session.ui.frame_selection.is_empty() { - app.session.ui.frame_selection.clear(); - app.session.status = "Frame selection cleared.".to_owned(); - return; - } - - if !app.session.ui.data_selection.is_empty() { - app.focus_datasets(&[], None); - app.session.status = "Dataset selection cleared.".to_owned(); - return; - } - - if !matches!(app.session.ui.selection, Selection::None) - || !app.session.ui.hierarchical_selection.paths().is_empty() - { - exit_to_page(app, "Selection cleared."); - return; - } - - let rest = app.session.tool.rest(); - if app.session.tool != rest { - app.set_tool(rest); - app.session.status = "Exited tool mode.".to_owned(); - } + handle_escape(app, now); } fn exit_to_page(app: &mut PlotiumApp, status: &str) { diff --git a/crates/app/src/ui/shortcuts_escape.rs b/crates/app/src/ui/shortcuts_escape.rs new file mode 100644 index 00000000..4d0f1b36 --- /dev/null +++ b/crates/app/src/ui/shortcuts_escape.rs @@ -0,0 +1,90 @@ +use super::*; + +pub(super) fn handle_escape(app: &mut PlotiumApp, now: f64) { + if app.interaction().is_active() { + let phase = matches!(app.interaction(), Interaction::Phase(_)); + app.cancel_interaction(); + app.session.status = if phase { + "Cancelled phase drag.".to_owned() + } else { + "Cancelled interaction.".to_owned() + }; + return; + } + + if app.session.ui.wheel_zoom.is_some() || app.session.ui.wheel_property.is_some() { + app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); + app.session.status = "Cancelled interaction.".to_owned(); + return; + } + if app.session.tool == plotium_core::state::Tool::ManualPhase { + app.toggle_tool(plotium_core::state::Tool::ManualPhase); + app.session.status = "Exited canvas phasing.".to_owned(); + return; + } + // Esc steps back one level: analysis region -> plot part -> panel-letter + // sub-selection -> selection -> active tool. + if app.session.ui.analysis_selection.is_some() { + if let Some(ci) = app.session.active_canvas { + canvas::clear_canvas_interaction_state( + app, + ci, + canvas::CanvasInteractionClearScope::All, + ); + } else { + app.session.ui.analysis_selection = None; + } + app.session.status = "Cleared analysis selection.".to_owned(); + return; + } + + if app.selected_plot_part().is_some() { + app.session.ui.plot_part_selection = None; + app.session.status = "Selected the whole plot.".to_owned(); + return; + } + + if app.session.ui.panel_label_selection.is_some() { + app.session.ui.panel_label_selection = None; + app.session.status = "Selection cleared.".to_owned(); + return; + } + + if app + .session + .ui + .hierarchical_selection + .lead() + .is_some_and(|path| path.panel.is_some() && path.content.is_some()) + { + app.exit_panel_scope(); + app.session.status = "Selected parent panel.".to_owned(); + return; + } + + if !app.session.ui.frame_selection.is_empty() { + app.session.ui.frame_selection.clear(); + app.session.status = "Frame selection cleared.".to_owned(); + return; + } + + if !app.session.ui.data_selection.is_empty() { + app.focus_datasets(&[], None); + app.session.status = "Dataset selection cleared.".to_owned(); + return; + } + + if !matches!(app.session.ui.selection, Selection::None) + || !app.session.ui.hierarchical_selection.paths().is_empty() + { + exit_to_page(app, "Selection cleared."); + return; + } + + let rest = app.session.tool.rest(); + if app.session.tool != rest { + app.set_tool(rest); + app.session.status = "Exited tool mode.".to_owned(); + } +} diff --git a/crates/app/src/ui/shortcuts_tests.rs b/crates/app/src/ui/shortcuts_tests.rs index b4c0cbc0..8ab9e99c 100644 --- a/crates/app/src/ui/shortcuts_tests.rs +++ b/crates/app/src/ui/shortcuts_tests.rs @@ -751,5 +751,32 @@ fn tab_from_the_cell_editor_keeps_typing_in_the_sheet() { assert!(sheet.app.session.ui.table_cell_edit.is_some()); } +#[test] +fn escape_exits_phase_before_clearing_dataset_selection() { + let mut app = PlotiumApp::new(); + app.session.ui.data_selection = vec![0]; + app.set_tool(Tool::ManualPhase); + handle_escape(&mut app, 0.0); + assert_ne!(app.session.tool, Tool::ManualPhase); + assert_eq!(app.session.ui.data_selection, vec![0]); +} + +#[test] +fn escape_leaves_phase_active_when_a_popup_owns_input() { + let mut app = PlotiumApp::new(); + app.set_tool(Tool::ManualPhase); + let ctx = egui::Context::default(); + let _ = ctx.run_ui( + egui::RawInput { + events: vec![pressed(egui::Key::Escape, egui::Modifiers::NONE)], + ..Default::default() + }, + |ui| { + egui::Popup::open_id(ui.ctx(), egui::Id::new("phase_test_popup")); + handle_escape_shortcut(&mut app, ui.ctx()); + }, + ); + assert_eq!(app.session.tool, Tool::ManualPhase); +} #[path = "shortcuts_delete_tests.rs"] mod delete; diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 03fbbe1e..5a15690e 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -664,7 +664,7 @@ fn resized_rect( resized } -pub(super) fn is_active(app: &PlotiumApp, tab: TaskDockTab) -> bool { +pub(crate) fn is_active(app: &PlotiumApp, tab: TaskDockTab) -> bool { app.session.ui.task_dock_active == Some(tab) } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 67362252..43961689 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -413,6 +413,10 @@ impl PlotiumApp { return; }; let lead = canvas.active_dataset(); + if self.session.active_canvas != Some(ci) && self.session.tool == Tool::ManualPhase { + self.cancel_interaction(); + self.set_tool(Tool::BrowseZoom); + } self.session.active_canvas = Some(ci); let lead = lead.and_then(|id| self.doc.dataset_index(id)); let datasets = self.doc.page_dataset_indices(ci); @@ -431,6 +435,10 @@ impl PlotiumApp { { return; } + if self.session.active_canvas != Some(ci) && self.session.tool == Tool::ManualPhase { + self.cancel_interaction(); + self.set_tool(Tool::BrowseZoom); + } self.session.active_canvas = Some(ci); self.reset_interaction(); self.select_object(ci, id); diff --git a/crates/core/src/state/app_impl_nmr_volume.rs b/crates/core/src/state/app_impl_nmr_volume.rs index 804457a4..dad4b1ab 100644 --- a/crates/core/src/state/app_impl_nmr_volume.rs +++ b/crates/core/src/state/app_impl_nmr_volume.rs @@ -3,6 +3,88 @@ use super::*; use plotium_processing::nmr_volume::VolumePhase; impl PlotiumApp { + /// Preserve other spectra's contour layers in a disposable phase display. + pub fn volume_preview_companion_contours( + &mut self, + series: &[SeriesBinding], + ) -> Option> { + let mut contours = Vec::new(); + for series in series.iter().filter(|s| { + s.visible && matches!(s.encoding, plotium_figure::SeriesEncoding::Contour(_)) + }) { + self.prepare_contour_series(series); + let binding = DataBinding { + coordinates: None, + series: vec![series.clone()], + }; + if self.binding_contours_pending(&binding) { + return None; + } + contours.extend(self.build_encoded_series_figure(series)?.contours); + } + Some(contours) + } + /// Resolve thresholds against committed data once, before preview rotation. + pub fn volume_preview_contour_key( + &mut self, + series: &SeriesBinding, + ) -> Result { + let plotium_figure::SeriesEncoding::Contour(spec) = &series.encoding else { + return Err("Choose a contour display for phase preview".into()); + }; + let dataset = self + .doc + .dataset_by_id(series.source.resource) + .ok_or("Spectrum was removed")?; + let field = FieldRef { + resource: series.source.resource, + field: series.source.field, + }; + let version = self + .session + .compute + .field_version_for(field) + .map_err(|e| format!("{e:?}"))?; + let snapshot = dataset + .field_snapshot(field.field, version, None) + .ok_or("Preview field is unavailable")?; + let summary = snapshot.summary.ok_or("Preview summary is unavailable")?; + let grid = std::sync::Arc::new( + snapshot + .payload + .scalar_grid() + .ok_or("Preview needs a plane")? + .clone(), + ); + if let ContourResolution::Pending(keys) = + resolve_contour_levels(snapshot.source, spec, summary, |key| { + self.session.compute.estimate_for(key).cloned() + }) + { + for key in keys { + let estimate = + super::compute::compute_field::run_estimate_field(key.clone(), grid.clone())?; + if !self + .session + .compute + .finish_estimate(key, estimate, Some(version)) + { + return Err("Preview estimate became stale".into()); + } + } + } + let ContourResolution::Ready { levels, .. } = + resolve_contour_levels(snapshot.source, spec, summary, |key| { + self.session.compute.estimate_for(key).cloned() + }) + else { + return Err("Contour thresholds are unavailable".into()); + }; + Ok(ContourGeometryCacheKey { + source: snapshot.source, + levels, + }) + } pub fn select_nmr_volume_plane( &mut self, dataset: DatasetId, diff --git a/crates/core/src/state/compute.rs b/crates/core/src/state/compute.rs index 62881956..448933d8 100644 --- a/crates/core/src/state/compute.rs +++ b/crates/core/src/state/compute.rs @@ -24,7 +24,7 @@ use super::{CraftRunId, DatasetId, DosyResultProvenance}; use crate::{IltParams, build_dosy_figure_cancellable, build_ilt_figure_cancellable}; #[path = "compute_field.rs"] -mod compute_field; +pub(super) mod compute_field; pub(crate) use compute_field::FieldEnqueueError; #[path = "compute_worker.rs"] mod compute_worker; diff --git a/crates/core/src/state/compute_field.rs b/crates/core/src/state/compute_field.rs index 46068d95..631dd983 100644 --- a/crates/core/src/state/compute_field.rs +++ b/crates/core/src/state/compute_field.rs @@ -318,7 +318,7 @@ impl ComputeService { } } -pub(super) fn run_estimate_field( +pub(in crate::state) fn run_estimate_field( key: EstimateKey, grid: Arc, ) -> Result { @@ -390,7 +390,7 @@ fn resolved_estimator(key: &EstimateKey) -> Result { /// lobes than negative ones, and would make the advice wrong — after grouping, /// raising the ladder's floor to the lowest drawn magnitude reproduces exactly /// the plot on screen. -pub(super) fn run_build_contour( +pub(in crate::state) fn run_build_contour( key: ContourGeometryCacheKey, grid: Arc, ) -> Result { diff --git a/crates/core/src/state/nmr_volume.rs b/crates/core/src/state/nmr_volume.rs index ee70b093..ce1f3ef8 100644 --- a/crates/core/src/state/nmr_volume.rs +++ b/crates/core/src/state/nmr_volume.rs @@ -15,6 +15,26 @@ pub struct NmrVolumeState { } impl Nmr2DDataset { + /// Build disposable display geometry without installing scientific state. + pub fn phase_preview_contours( + &self, + plane: NmrSource, + key: ContourGeometryCacheKey, + style: plotium_figure::ContourStyle, + ) -> Result { + let mut display = self.clone(); + display.replace_volume_plane(plane)?; + let Processed2D::Ft(spectrum) = &display.processed else { + return Err("Phase preview requires a frequency plane".into()); + }; + let grid = std::sync::Arc::new(nmr_scalar_grid(spectrum, spectrum.real())); + let geometry = super::compute::compute_field::run_build_contour(key, grid)?; + Ok(super::datasets_2d_figure::apply_contour_geometry( + (*display.processed_figure).clone(), + &geometry, + &style, + )) + } pub fn load_volume(source: NmrSource) -> Result { let phases = [VolumePhase::default(); 3]; Self::load_volume_state(source, phases, 0, None) diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index f831c7aa..df655908 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -516,12 +516,21 @@ impl PlotiumApp { /// Make `di` the sole selection (a plain Data-list click), so it also leads. pub fn focus_single(&mut self, di: usize) { + if self.active_dataset() != Some(di) && self.session.tool == Tool::ManualPhase { + self.cancel_interaction(); + self.set_tool(Tool::BrowseZoom); + } self.session.ui.data_selection = vec![di]; } /// Replace the selection with `items`; when `lead` is given it is moved to the /// end so it becomes the active dataset, otherwise `items`' last element leads. pub fn focus_datasets(&mut self, items: &[usize], lead: Option) { + let next = lead.or_else(|| items.last().copied()); + if self.active_dataset() != next && self.session.tool == Tool::ManualPhase { + self.cancel_interaction(); + self.set_tool(Tool::BrowseZoom); + } match lead { Some(lead) => { let mut set: Vec = items.iter().copied().filter(|&d| d != lead).collect(); diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 97dc6798..05915006 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 canvas_axis: usize, 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 b344b5aa..f097dd4a 100644 --- a/crates/processing/src/nmr_volume.rs +++ b/crates/processing/src/nmr_volume.rs @@ -3,6 +3,8 @@ use plotium_io::nmr_view::NmrSource; use std::sync::Arc; use nmr::ExecutionContext; +#[path = "nmr_volume_preview.rs"] +mod preview; use nmr::axis::{AxisDomain, AxisRole, AxisUnit}; use nmr::processed::{ProcessedAxis, ProcessedDataset}; use nmr::processing::{ @@ -76,6 +78,9 @@ impl NmrVolume { }); } } + if operations.is_empty() { + return Self::from_processed(source.clone()); + } let output = ProcessingPlan::new(operations) .and_then(|plan| plan.apply_with_context(source.dataset(), options, context)) .map_err(|e| e.to_string())?; diff --git a/crates/processing/src/nmr_volume_preview.rs b/crates/processing/src/nmr_volume_preview.rs new file mode 100644 index 00000000..94c0f98e --- /dev/null +++ b/crates/processing/src/nmr_volume_preview.rs @@ -0,0 +1,126 @@ +//! Phase is pointwise: retain every component of a one-index slab, then let +//! the reference engine rotate it. No FFT or full-volume copy is needed. +use super::*; +use nmr::axis::AxisCoordinates; + +impl NmrVolume { + pub fn supports_local_phase_preview(&self) -> bool { + self.axes().iter().all(|axis| { + matches!( + axis.component_basis(), + nmr::processed::ComponentBasis::Scalar | nmr::processed::ComponentBasis::Cartesian + ) + }) + } + pub fn phase_preview_plane( + &self, + fixed: usize, + index: usize, + baseline: [VolumePhase; 3], + draft: [VolumePhase; 3], + context: &mut ExecutionContext<'_>, + ) -> Result { + if !self.supports_local_phase_preview() { + return Err("Local phase preview requires independent Cartesian components; shared or encoded bases are unsupported".into()); + } + if fixed >= 3 || index >= self.shape()[fixed] { + return Err("Preview plane is outside the spectrum".into()); + } + let mut axes = self.axes().to_vec(); + let old = &axes[fixed]; + axes[fixed] = ProcessedAxis::new( + old.role(), + old.domain(), + old.unit(), + 1, + AxisCoordinates::Uniform { + start: old.coordinate(index).map_err(|e| e.to_string())?, + step: 1.0, + }, + old.component_basis().clone(), + ) + .map_err(|e| e.to_string())?; + let descriptor = + nmr::processed::ProcessedDescriptor::new(axes).map_err(|e| e.to_string())?; + let mut shape = self.shape(); + shape[fixed] = 1; + let mut samples = Vec::new(); + for a in 0..shape[0] { + context.check_cancelled().map_err(|e| e.to_string())?; + for ca in 0..self.axes()[0].component_count() { + for b in 0..shape[1] { + for cb in 0..self.axes()[1].component_count() { + for c in 0..shape[2] { + let mut position = [a, b, c]; + position[fixed] = index; + for cc in 0..self.axes()[2].component_count() { + samples.push( + self.processed() + .data() + .get(&position, &[ca, cb, cc]) + .map_err(|e| e.to_string())?, + ); + } + } + } + } + } + } + let lineage = (0..3) + .map(|axis| { + nmr::external::ExternalAxisSource::Parent(nmr::provenance::InputAxisRef::new( + nmr::provenance::InputSlot::new(0), + axis, + )) + }) + .collect(); + let declaration = nmr::external::ExternalAlgorithmDeclaration::new( + "plotium.phase-preview-slab", + "1", + "All components at one fixed acquisition index", + ) + .map_err(|e| e.to_string())?; + let slab = self + .source() + .dataset() + .derive_external_processed(descriptor, samples, lineage, declaration) + .map_err(|e| e.to_string())?; + let mut operations = Vec::new(); + for axis in 0..3 { + baseline[axis].validate()?; + draft[axis].validate()?; + // R(draft) R(baseline)^-1. Folding each pivot into the intercept + // avoids applying the committed correction a second time. + let intercept = |p: VolumePhase| p.p0_degrees - p.p1_degrees * p.pivot_fraction; + let mut p0 = intercept(draft[axis]) - intercept(baseline[axis]); + let mut p1 = draft[axis].p1_degrees - baseline[axis].p1_degrees; + if axis == fixed { + p0 += p1 * index as f64 / self.shape()[axis] as f64; + p1 = 0.0; + } + if p0 != 0.0 || p1 != 0.0 { + if !self.supports_phase(axis) { + return Err(format!( + "F{} needs complex frequency-domain components", + axis + 1 + )); + } + operations.push(ProcessingOperation::PhaseCorrection { + axis, + correction: PhaseCorrection::new(p0, p1, 0.0).map_err(|e| e.to_string())?, + }); + } + } + if operations.is_empty() { + return Self::from_processed( + NmrSource::new(Arc::new(slab)).map_err(|e| e.to_string())?, + )? + .plane_source(fixed, 0); + } + let output = ProcessingPlan::new(operations) + .and_then(|plan| plan.apply_with_context(&slab, ProcessingOptions::default(), context)) + .map_err(|e| e.to_string())?; + Self::from_processed(NmrSource::new(Arc::new(output)).map_err(|e| e.to_string())?)? + .plane_source(fixed, 0) + } +} diff --git a/crates/processing/tests/nmr_volume.rs b/crates/processing/tests/nmr_volume.rs index 54461f95..ef58f977 100644 --- a/crates/processing/tests/nmr_volume.rs +++ b/crates/processing/tests/nmr_volume.rs @@ -14,6 +14,313 @@ fn fixture() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-3d-qf") } +#[test] +fn local_phase_preview_replaces_committed_phase_and_repivots_with_engine() { + let input = source(&fixture()); + let mut baseline = [VolumePhase::default(); 3]; + baseline[2] = VolumePhase { + p0_degrees: 37.0, + p1_degrees: -81.0, + pivot_fraction: 0.27, + }; + let committed = NmrVolume::process( + &input, + baseline, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + let mut draft = baseline; + draft[2] = VolumePhase { + p0_degrees: -19.0, + p1_degrees: 131.0, + pivot_fraction: 0.73, + }; + let applied = NmrVolume::process( + &input, + draft, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + for fixed in 0..3 { + for index in 0..committed.shape()[fixed] { + let started = std::time::Instant::now(); + let preview = committed + .phase_preview_plane( + fixed, + index, + baseline, + draft, + &mut ExecutionContext::default(), + ) + .unwrap(); + eprintln!( + "local preview debug numerical profile: {:?}, fixed={fixed}, elapsed={:?}", + committed.shape(), + started.elapsed() + ); + let expected = applied.plane_source(fixed, index).unwrap(); + let mut repivoted = draft; + repivoted[2].repivot(0.11).unwrap(); + let invariant = committed + .phase_preview_plane( + fixed, + index, + baseline, + repivoted, + &mut ExecutionContext::default(), + ) + .unwrap(); + // Compare every stored component, including imaginary lanes, against + // a fresh source-to-volume execution by the actual nmr engine. + for ((actual, expected), invariant) in preview + .dataset() + .as_processed() + .unwrap() + .data() + .samples() + .iter() + .zip(expected.dataset().as_processed().unwrap().data().samples()) + .zip(invariant.dataset().as_processed().unwrap().data().samples()) + { + assert!((actual - expected).abs() < 1e-8); + assert!((actual - invariant).abs() < 1e-8); + } + } + } +} + +fn cartesian_volume(shape: [usize; 3], reverse: bool) -> plotium_io::nmr_view::NmrSource { + component_volume( + shape, + reverse, + [const { nmr::processed::ComponentBasis::Cartesian }; 3], + ) +} + +fn component_volume( + shape: [usize; 3], + reverse: bool, + bases: [nmr::processed::ComponentBasis; 3], +) -> plotium_io::nmr_view::NmrSource { + use nmr::axis::{AxisCoordinates, AxisDomain, AxisRole, AxisUnit}; + use nmr::processed::{ComponentBasis, ProcessedAxis, ProcessedDescriptor}; + let parent_volume = NmrVolume::process_source(&source(&fixture())).unwrap(); + let parent = parent_volume.source(); + let axes = shape + .into_iter() + .enumerate() + .map(|(index, points)| { + ProcessedAxis::new( + AxisRole::Signal, + AxisDomain::Frequency, + Some(AxisUnit::Ppm), + points, + AxisCoordinates::Uniform { + start: 12.0, + step: if reverse { -0.031 } else { 0.031 }, + }, + bases[index].clone(), + ) + .unwrap() + }) + .collect(); + let samples = (0..shape.iter().product::() + * bases + .iter() + .map(ComponentBasis::component_count) + .product::()) + .map(|i| (i as f64 * 0.171).sin() + (i as f64 * 0.031).cos()) + .collect(); + let lineage = (0..3) + .map(|axis| { + nmr::external::ExternalAxisSource::Parent(nmr::provenance::InputAxisRef::new( + nmr::provenance::InputSlot::new(0), + axis, + )) + }) + .collect(); + let output = parent + .dataset() + .derive_external_processed( + ProcessedDescriptor::new(axes).unwrap(), + samples, + lineage, + nmr::external::ExternalAlgorithmDeclaration::new( + "plotium.test.cartesian-volume", + "1", + "Synthetic hypercomplex fixture", + ) + .unwrap(), + ) + .unwrap(); + plotium_io::nmr_view::NmrSource::new(std::sync::Arc::new(output)).unwrap() +} + +#[test] +fn unsupported_bases_never_claim_local_phase_capability() { + use nmr::processed::ComponentBasis; + for conjugated in [false, true] { + let bases = [ + ComponentBasis::SharedComplex { + axis: nmr::acquisition::AxisIndex::new(2), + conjugated, + }, + ComponentBasis::Cartesian, + ComponentBasis::Cartesian, + ]; + let input = component_volume([3, 5, 7], true, bases); + let volume = NmrVolume::process_source(&input).unwrap(); + assert!(!volume.supports_local_phase_preview()); + assert!( + volume + .phase_preview_plane( + 0, + 1, + [VolumePhase::default(); 3], + [VolumePhase::default(); 3], + &mut ExecutionContext::default() + ) + .unwrap_err() + .contains("independent Cartesian") + ); + } + let input = component_volume([3, 5, 7], false, [const { ComponentBasis::Scalar }; 3]); + let volume = NmrVolume::process_source(&input).unwrap(); + assert!((0..3).all(|axis| !volume.supports_phase(axis))); + let mut draft = [VolumePhase::default(); 3]; + draft[0].p0_degrees = 31.0; + assert!( + volume + .phase_preview_plane( + 0, + 1, + [VolumePhase::default(); 3], + draft, + &mut ExecutionContext::default() + ) + .is_err() + ); +} + +#[test] +fn hypercomplex_local_preview_matches_engine_on_all_axes_and_reversed_grids() { + for reverse in [false, true] { + let input = cartesian_volume([7, 5, 9], reverse); + let baseline = std::array::from_fn(|axis| VolumePhase { + p0_degrees: 13.0 + axis as f64 * 11.0, + p1_degrees: -57.0, + pivot_fraction: 0.19, + }); + let committed = NmrVolume::process( + &input, + baseline, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + for changed in 0..3 { + let mut draft = baseline; + draft[changed] = VolumePhase { + p0_degrees: -41.0, + p1_degrees: 93.0, + pivot_fraction: 0.83, + }; + let applied = NmrVolume::process( + &input, + draft, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + for fixed in 0..3 { + for index in [ + 0, + committed.shape()[fixed] / 2, + committed.shape()[fixed] - 1, + ] { + let preview = committed + .phase_preview_plane( + fixed, + index, + baseline, + draft, + &mut ExecutionContext::default(), + ) + .unwrap(); + let expected = applied.plane_source(fixed, index).unwrap(); + let mut moved = draft; + moved[changed].repivot(0.07).unwrap(); + let invariant = committed + .phase_preview_plane( + fixed, + index, + baseline, + moved, + &mut ExecutionContext::default(), + ) + .unwrap(); + for ((a, b), c) in preview + .dataset() + .as_processed() + .unwrap() + .data() + .samples() + .iter() + .zip(expected.dataset().as_processed().unwrap().data().samples()) + .zip(invariant.dataset().as_processed().unwrap().data().samples()) + { + assert!( + (a - b).abs() < 1e-10, + "reverse={reverse} changed={changed} fixed={fixed} index={index}" + ); + assert!((a - c).abs() < 1e-10); + } + } + } + } + } +} + +#[test] +#[ignore = "explicit local preview benchmark"] +fn benchmark_local_phase_preview() { + let input = cartesian_volume([64, 128, 256], true); + let committed = NmrVolume::process_source(&input).unwrap(); + let baseline = [VolumePhase::default(); 3]; + let draft = [VolumePhase { + p0_degrees: 21.0, + p1_degrees: -137.0, + pivot_fraction: 0.37, + }; 3]; + for fixed in 0..3 { + let mut times = Vec::new(); + for _ in 0..10 { + let start = std::time::Instant::now(); + std::hint::black_box( + committed + .phase_preview_plane( + fixed, + 0, + baseline, + draft, + &mut ExecutionContext::default(), + ) + .unwrap(), + ); + times.push(start.elapsed()); + } + times.sort(); + eprintln!( + "shape={:?}, 8 components, fixed={fixed}, median={:?}, max={:?}", + committed.shape(), + times[5], + times[9] + ); + } +} + #[test] fn processes_unequal_axes_and_preserves_indexed_signed_peaks() { let volume = NmrVolume::process( diff --git a/crates/processing/tests/phase_modes_qa.rs b/crates/processing/tests/phase_modes_qa.rs new file mode 100644 index 00000000..03a57afe --- /dev/null +++ b/crates/processing/tests/phase_modes_qa.rs @@ -0,0 +1,81 @@ +use nmr::ExecutionContext; +use plotium_processing::nmr_volume::{NmrVolume, VolumePhase}; +#[test] +fn synthetic_encoded_modes_decode_and_preview() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../target/phase-qa-final/modes") + .join(std::process::id().to_string()); + let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + for mode in [4, 5, 6] { + let dir = root.join(mode.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::copy(fixture.join("acqus"), dir.join("acqus")).unwrap(); + for file in ["acqu2s", "acqu3s"] { + let text = std::fs::read_to_string(fixture.join(file)) + .unwrap() + .replace("FnMODE= 1", &format!("FnMODE= {mode}")) + .replace("TD= 3", "TD= 6") + .replace("TD= 5", "TD= 10"); + std::fs::write(dir.join(file), text).unwrap(); + } + let bytes: Vec = (0_i32..5 * 2 * 3 * 2 * 4 * 2) + .flat_map(|i| (i % 23 - 11).to_le_bytes()) + .collect(); + std::fs::write(dir.join("ser"), bytes).unwrap(); + let source = plotium_io::nmr_view::NmrSource::new( + plotium_io::nmr_bridge::read(&dir, &mut ExecutionContext::default()).unwrap(), + ) + .unwrap(); + let baseline = [VolumePhase::default(); 3]; + let volume = NmrVolume::process_source(&source).unwrap(); + assert!(volume.supports_local_phase_preview()); + assert!((0..3).all(|i| volume.supports_phase(i))); + let draft = [VolumePhase { + p0_degrees: 31., + p1_degrees: -71., + pivot_fraction: 0.23, + }; 3]; + let applied = NmrVolume::process( + &source, + draft, + Default::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + for fixed in 0..3 { + for index in 0..volume.shape()[fixed] { + let p = volume + .phase_preview_plane( + fixed, + index, + baseline, + draft, + &mut ExecutionContext::default(), + ) + .unwrap(); + let e = applied.plane_source(fixed, index).unwrap(); + for (a, b) in p + .dataset() + .as_processed() + .unwrap() + .data() + .samples() + .iter() + .zip(e.dataset().as_processed().unwrap().data().samples()) + { + assert!((a - b).abs() < 1e-8); + } + } + } + eprintln!( + "synthetic mode={mode} shape={:?} bases={:?}", + volume.shape(), + volume + .axes() + .iter() + .map(|a| a.component_basis()) + .collect::>() + ); + } +} diff --git a/docs/src/content/docs/guides/nmr-3d.md b/docs/src/content/docs/guides/nmr-3d.md index 47f6d346..03a7644e 100644 --- a/docs/src/content/docs/guides/nmr-3d.md +++ b/docs/src/content/docs/guides/nmr-3d.md @@ -28,17 +28,34 @@ 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 +Select the 3D spectrum and open **Process** to inspect and edit phase parameters +without changing the canvas tool. Turn on **Canvas phasing** in the card, or +choose **Process → Correct → Phase**, to show the pivot and enable canvas dragging. +Both controls toggle the same mode. Numeric editing and plane preview work with +canvas phasing off. 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. +Choose a horizontal or vertical acquisition axis in Phase. Drag vertically with +the left button for **PH0**, the right button for **PH1**, or drag the pivot line +along that axis. Numeric controls edit the same draft. The fixed axis supports +numeric editing only; canvas phasing pauses until the plane contains that +acquisition axis. Plotium does not silently choose another axis or plane. +Axes without complex frequency components are disabled. +Local preview requires independent Cartesian components. Shared-complex and +encoded volume bases are disabled rather than approximated. Show the real +contour field on the selected plot to preview. The first visible real contour +series previews the draft; all other contour series, including additional layers +of the same spectrum, retain their committed values and drawing order. Numeric controls show the selected acquisition axis. + +The current plane shows an **Unapplied** preview. During updates the previous +complete preview can remain visible; the panel says it is updating. Changing +planes uses the same acquisition-axis draft. Preview preserves the committed +contour thresholds for that plane and does not change peak intensities, +assignments, persisted data or exports. Click **Apply to 3D spectrum** to recalculate the whole +volume from the source, replacing previous settings in one undoable operation. +Draft editing is locked during Apply. Browse other planes to check the result. **Pivot** sets the reference position for first-order phase in ppm. Moving it automatically compensates PH0 so the phase correction remains unchanged. @@ -47,7 +64,15 @@ 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. +- Turn off **Canvas phasing**, click **Phase** again, or press **Esc** to return + to browsing while retaining the card, draft and preview. During a drag, Esc + cancels that drag first; press it again to exit the mode. +- Closing the panel exits canvas phasing, restores the committed display and + retains your edits. Any Apply calculation continues. Reopening Process resumes + a valid draft preview without enabling canvas phasing. +- Switching spectra or canvases exits canvas phasing and keeps each spectrum's + draft in the current session. Browsing planes of the same spectrum keeps the + mode and draft. Drafts are not saved across restarts. 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 diff --git a/docs/src/content/docs/guides/processing.md b/docs/src/content/docs/guides/processing.md index 86b94e99..3726b07d 100644 --- a/docs/src/content/docs/guides/processing.md +++ b/docs/src/content/docs/guides/processing.md @@ -249,15 +249,21 @@ until you press it. ## Phase correction +Opening **Process** opens the workspace without enabling canvas phasing. +**Phase** toggles canvas phasing; its selected state identifies the active tool. +Press **Esc** to exit (during a drag, the first Esc cancels the drag). Switching +spectra or canvases exits this mode. + 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**. +direction. Select **Horizontal** or **Vertical** above the canvas to change the +2D phase axis directly. Each choice includes its acquisition axis and nucleus. -3D spectra use a parameter panel with explicit Apply and no live preview; see +3D slices use the same canvas gestures, with a parameter panel and explicit Apply; see [3D phase correction](/guides/nmr-3d/#phase-correction). -The controls and live preview described below apply to standalone 1D/2D spectra. +Standalone 1D/2D edits retain their existing immediate processing and undo behavior; +3D drafts preview the current plane until Apply recalculates the entire volume. 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/zh-cn/guides/nmr-3d.md b/docs/src/content/docs/zh-cn/guides/nmr-3d.md index 0caae37d..74b0cebf 100644 --- a/docs/src/content/docs/zh-cn/guides/nmr-3d.md +++ b/docs/src/content/docs/zh-cn/guides/nmr-3d.md @@ -22,14 +22,28 @@ Plotium 在后台处理原始数据,并在普通工作区显示二维频率平 ## 相位校正 -选中三维谱,选择 **Process → Correct → Phase** 打开相位面板。 +选中三维谱,打开 **Process** 即可检查和编辑相位参数,不改变当前画布工具。 +开启卡片中的 **Canvas phasing**,或选择 **Process → Correct → Phase**, +才会显示枢轴并启用画布拖动。两处入口控制同一个模式;关闭画布调相时, +数值编辑和当前平面预览仍然可用。 编辑前先确认面板中的目标谱名。各采集轴(F1/F2/F3)显示核种 (缺失时为 `unknown`)和当前显示方向:横轴、纵轴或固定轴。 切换平面只更新显示方向,相位参数始终对应原来的采集轴。 -调整所需轴的 **PH0** 和 **PH1**,点击 **Apply** 后在谱图上检查结果。 -Apply 从源数据重新计算整卷,替换上次的相位设置;可浏览其他切片检查校正效果。 -当前显示的二维切片不支持拖动调相,修改参数也没有实时预览。 +在 Phase 中选择横轴或纵轴。竖直拖动时,左键调整 **PH0**、右键调整 **PH1**; +拖动枢轴线则沿该轴移动枢轴。数值控件编辑同一份草稿。固定轴仅支持数值编辑, +此时画布调相暂停,切换到包含该采集轴的平面后恢复;Plotium 不会自动改选其他轴或平面。 +缺少频域复数分量的轴禁用。 +局部预览要求独立 Cartesian 分量;共享复数或编码体数据基底禁用,不使用视觉近似。 +请在选中的谱图上显示实部等高线以查看预览。第一条可见实部等高线序列显示草稿; +其他等高线序列(包括同一谱的附加图层)保留已提交值和原有绘制顺序。 +数值控件显示当前选中的采集轴。 + +当前平面显示标记为 **Unapplied** 的未提交预览。更新期间可保留上一幅完整图像, +面板提示正在更新;切换平面沿用同一组采集轴草稿。预览固定该平面的已提交等高线 +阈值,不修改峰强度、归属、持久化数据或导出。**Apply to 3D spectrum** 从源数据重算整卷, +替换上次相位设置,产生一条可撤销操作;计算期间锁定草稿编辑。 +可浏览其他切片检查校正效果。 **Pivot** 以 ppm 设置一阶相位的参考位置。移动枢轴时会自动补偿 PH0, 保持相位校正结果不变。允许范围延伸至最后一个采样点之外一个采样间隔; @@ -37,7 +51,12 @@ Apply 从源数据重新计算整卷,替换上次的相位设置;可浏览 - **Discard changes** 恢复已应用的参数,不触发重算。 - **Stop calculation** 取消指明的计算,保留所做的修改。 -- 关闭面板保留修改,正在运行的计算继续。 +- 关闭 **Canvas phasing**、再次点击 **Phase** 或按 **Esc** 返回浏览,保留卡片、草稿和预览。 + 拖动期间按 Esc 先取消本次拖动,再按一次才退出模式。 +- 关闭面板退出画布调相、恢复已提交显示并保留修改,正在运行的 Apply 计算继续; + 重新打开 Process 恢复有效草稿预览,不自动开启画布调相。 +- 切换谱图或画布退出画布调相,各谱图的草稿在当前会话内保留。 + 浏览同一谱图的其他平面保留模式和草稿。草稿不会跨重启保存。 计算期间可以浏览平面或切换谱,结果仍应用于启动时指定的谱。 如果源数据或已应用的相位设置发生变化,未应用的修改会被丢弃; diff --git a/docs/src/content/docs/zh-cn/guides/processing.md b/docs/src/content/docs/zh-cn/guides/processing.md index e7ca1e45..ffa54b49 100644 --- a/docs/src/content/docs/zh-cn/guides/processing.md +++ b/docs/src/content/docs/zh-cn/guides/processing.md @@ -201,13 +201,17 @@ Plotium 根据受支持的 `DSPFVS`/`DECIM` 设置确定延迟;否则延迟保 ## 相位校正 +打开 **Process** 仅打开工作区,不开启画布调相。**Phase** 切换画布调相的开启和关闭, +其选中态表示当前工具。按 **Esc** 退出;拖动时第一次 Esc 先取消本次拖动。 +切换谱图或画布也会退出该模式。 + 对于一维或二维谱,先在当前画布选中谱图,再选择 **Process → Correct → Phase** 进行谱图交互调相。谱图上方显示目标谱、采集轴、核种和显示方向。 -要在二维谱中切换轴,请在 **Processing** 中打开另一条轴的 **Phase** 步骤。 +二维调相可直接在画布上方选择 **Horizontal** 或 **Vertical**,同时查看采集轴和核种。 -三维谱使用参数面板,修改后需点击 Apply,没有实时预览,详见 +三维切片支持相同的画布手势及实时预览,参数面板中点击 Apply 提交,详见 [三维相位校正](/zh-cn/guides/nmr-3d/#相位校正)。 -下述控件与实时预览适用于独立的一维、二维谱。 +独立一维、二维谱保留原有即时处理和撤销行为;三维草稿仅预览当前平面,Apply 才重算整卷。 自动相位校正默认启用;也可以切换算法,或手动调节 φ0 / φ1 并实时预览。