From 63157c4d57a9bba4e6cc9f1293716f38ddc08cf1 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Tue, 3 Nov 2026 11:06:30 -0500 Subject: [PATCH] feat(nmr): support 3D spectra in the workspace Add dense 3D import, plane navigation and cancellable phase processing shared by the desktop, CLI and MCP. Preserve acquisition snapshots and view state in projects, isolate edits to their dataset, and cover the workflow with synthetic regressions and bilingual documentation. --- Cargo.lock | 8 +- Cargo.toml | 4 +- crates/agent/src/capabilities.rs | 9 +- crates/agent/src/native_import_tests.rs | 23 + crates/agent/src/session.rs | 1 + crates/app/src/ui/canvas/cursors.rs | 30 ++ crates/app/src/ui/canvas/mod.rs | 2 + crates/app/src/ui/command_exec.rs | 6 + crates/app/src/ui/commands.rs | 30 +- crates/app/src/ui/commands/identity.rs | 10 + crates/app/src/ui/commands/roster.rs | 5 + crates/app/src/ui/commands_tests.rs | 4 +- crates/app/src/ui/import_feedback.rs | 13 +- crates/app/src/ui/menus.rs | 1 + crates/app/src/ui/mod.rs | 2 + crates/app/src/ui/nmr_volume.rs | 455 ++++++++++++++++++ crates/app/src/ui/tools/processing/surface.rs | 7 + crates/app/src/ui/tools/task_card.rs | 8 +- crates/cli/tests/tool_cli.rs | 40 ++ .../core/src/actions/app_impl/processing.rs | 5 + crates/core/src/actions/app_impl/validate.rs | 9 + crates/core/src/automation/mod.rs | 2 + crates/core/src/automation/native_import.rs | 20 +- crates/core/src/automation/nmr_volume.rs | 219 +++++++++ crates/core/src/automation/processing_edit.rs | 3 + .../automation/properties_tests_planning.rs | 36 +- crates/core/src/automation/registry.rs | 3 + crates/core/src/automation/science.rs | 4 + crates/core/src/automation/tools.rs | 6 + crates/core/src/data_export.rs | 3 + crates/core/src/data_export/nmr.rs | 15 + crates/core/src/project/convert.rs | 58 ++- crates/core/src/project/nmr_snapshot_tests.rs | 113 +++++ crates/core/src/state/app_impl_nmr_volume.rs | 162 +++++++ crates/core/src/state/app_impl_symmetry.rs | 5 + crates/core/src/state/compute/tests.rs | 54 +++ crates/core/src/state/data_import.rs | 87 +++- crates/core/src/state/data_import_tests.rs | 36 +- crates/core/src/state/datasets.rs | 12 + crates/core/src/state/datasets_dispatch.rs | 1 + crates/core/src/state/mod.rs | 3 + crates/core/src/state/nmr_volume.rs | 137 ++++++ crates/core/src/state/ui_state.rs | 11 + crates/core/src/workflow.rs | 18 +- crates/core/src/workflow/dataset.rs | 38 ++ .../tests/fixtures/nmr/bruker-3d-qf/README.md | 11 + .../io/tests/fixtures/nmr/bruker-3d-qf/acqu2s | 9 + .../io/tests/fixtures/nmr/bruker-3d-qf/acqu3s | 9 + .../io/tests/fixtures/nmr/bruker-3d-qf/acqus | 16 + crates/io/tests/fixtures/nmr/bruker-3d-qf/ser | Bin 0 -> 480 bytes crates/processing/src/lib.rs | 1 + crates/processing/src/nmr_volume.rs | 315 ++++++++++++ crates/processing/tests/nmr_volume.rs | 176 +++++++ docs/src/content/docs/guides/nmr-3d.md | 43 ++ .../content/docs/reference/mcp/scientific.md | 8 +- docs/src/content/docs/zh-cn/guides/nmr-3d.md | 35 ++ .../docs/zh-cn/reference/mcp/scientific.md | 8 +- 57 files changed, 2247 insertions(+), 102 deletions(-) create mode 100644 crates/app/src/ui/nmr_volume.rs create mode 100644 crates/core/src/automation/nmr_volume.rs create mode 100644 crates/core/src/state/app_impl_nmr_volume.rs create mode 100644 crates/core/src/state/nmr_volume.rs create mode 100644 crates/io/tests/fixtures/nmr/bruker-3d-qf/README.md create mode 100644 crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu2s create mode 100644 crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu3s create mode 100644 crates/io/tests/fixtures/nmr/bruker-3d-qf/acqus create mode 100644 crates/io/tests/fixtures/nmr/bruker-3d-qf/ser create mode 100644 crates/processing/src/nmr_volume.rs create mode 100644 crates/processing/tests/nmr_volume.rs create mode 100644 docs/src/content/docs/guides/nmr-3d.md create mode 100644 docs/src/content/docs/zh-cn/guides/nmr-3d.md diff --git a/Cargo.lock b/Cargo.lock index 6f18a558..cf8a4135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,9 +872,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calamine" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9ca341b94bc4b29e08970b1a03914fad2dfe177e2ecb67fcd6de9046004aafb" +checksum = "f431b67cba6122a1f7d26b7adafc71bbdf07026095fe811f5efc025d8748e1d8" dependencies = [ "atoi_simd", "byteorder", @@ -3650,9 +3650,9 @@ dependencies = [ [[package]] name = "nmr" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d16859090090c0340732b2c51d22456c1cc703788ff5c7ec94ee4b89a56fc" +checksum = "3eb47f26c348fe258e733430e086fd47f3cf5427039bb571b12d2090951088df" dependencies = [ "num-complex", "rustfft", diff --git a/Cargo.toml b/Cargo.toml index cd253ffb..1a148d5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ publish = false repository = "https://github.com/nmrtist/plotium" [workspace.dependencies] -nmr = "=0.1.2" +nmr = "=0.1.3" colorous = "1" num-complex = "0.4" rustfft = "6" @@ -65,7 +65,7 @@ substrait = { version = "0.63", default-features = false, features = ["serde"] } prost = "0.14" pbjson-types = "0.8" chrono-tz = "0.10" -calamine = { version = "0.37", default-features = false, features = ["dates"] } +calamine = { version = "0.38", default-features = false, features = ["dates"] } rust_xlsxwriter = { version = "0.100", default-features = false, features = ["constant_memory"] } tokio = { version = "1", features = ["rt"] } egui = "0.34" diff --git a/crates/agent/src/capabilities.rs b/crates/agent/src/capabilities.rs index 9c9b5568..13f8e4d8 100644 --- a/crates/agent/src/capabilities.rs +++ b/crates/agent/src/capabilities.rs @@ -4,12 +4,13 @@ use plotium_core::automation::{ }; use serde_json::{Value, json}; -pub(crate) const EDIT_OPERATIONS: [&str; 26] = [ +pub(crate) const EDIT_OPERATIONS: [&str; 27] = [ "analysis.diffusion.plot", "plot.field.create", "analysis.dosy.build", "analysis.regions.extract", "analysis.diffusion.fit", + "nmr.volume.edit", "processing.edit", "processing.apply_scheme", "diffusion.set_input", @@ -35,7 +36,11 @@ pub(crate) const EDIT_OPERATIONS: [&str; 26] = [ /// One line per editable operation, so a caller can map a task to an operation /// before fetching its schema. -const PURPOSES: [(&str, &str); 29] = [ +const PURPOSES: [(&str, &str); 30] = [ + ( + "nmr.volume.edit", + "Select a 3D spectrum plane and optionally adjust per-axis phase", + ), ( "analysis.diffusion.plot", "Plot a diffusion fit with residuals on a new canvas", diff --git a/crates/agent/src/native_import_tests.rs b/crates/agent/src/native_import_tests.rs index e03511e9..eacbd3ed 100644 --- a/crates/agent/src/native_import_tests.rs +++ b/crates/agent/src/native_import_tests.rs @@ -332,3 +332,26 @@ fn native_reservations_are_shared_and_released_on_every_plan_consumption() { ); assert_eq!(s.remaining_import_bytes(), NATIVE_IMPORT_BYTES); } + +#[test] +fn mcp_can_import_and_select_a_three_dimensional_plane() { + let mut app = PlotiumApp::new_with_settings(Default::default()); + let mut s = session(); + let p = plan(&mut s, &mut app, &[fixture("bruker-3d-qf")]); + execute(&mut s, &mut app, &p).unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while app.compute_busy() && Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(Duration::from_millis(2)); + } + app.poll_compute(); + assert!(!app.compute_busy()); + let id = app.doc.datasets[0].resource_id().to_string(); + let args = json!({"tool_id":"nmr.volume.edit","parameters":{"fixed_axis":2,"index":1},"ids":[id],"expected_revision":app.doc.automation_revision,"expected_project_context":app.session.project_context}); + let p = s.call(&mut app, "plotium_plan", args).unwrap(); + let result = execute(&mut s, &mut app, &p).unwrap(); + assert_eq!(result["value"]["fixed_axis"], 2); + assert_eq!(result["value"]["index"], 1); + let n = app.doc.datasets[0].as_nmr2d().unwrap(); + assert_eq!(n.volume.as_ref().unwrap().processed.shape(), [5, 3, 4]); +} diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index ba9faed0..d04ac4eb 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -420,6 +420,7 @@ impl Session { if matches!( args.tool_id.as_str(), PROCESSING_EDIT + | "nmr.volume.edit" | DIFFUSION_SET_INPUT | DOSY_BUILD | REGIONS_EXTRACT diff --git a/crates/app/src/ui/canvas/cursors.rs b/crates/app/src/ui/canvas/cursors.rs index 81690b1c..c9a74677 100644 --- a/crates/app/src/ui/canvas/cursors.rs +++ b/crates/app/src/ui/canvas/cursors.rs @@ -383,6 +383,36 @@ fn inspect_text(app: &PlotiumApp, dataset: usize, point: CursorPoint) -> String ) } Dataset::Nmr2D(n) => { + if let Some(volume) = &n.volume { + let remaining: Vec<_> = (0..3).filter(|axis| *axis != volume.fixed_axis).collect(); + let mut indices = volume.anchor; + for (axis, coordinate) in [ + (remaining[0], point.y.unwrap_or_default()), + (remaining[1], point.x), + ] { + match volume.processed.nearest(axis, coordinate) { + Ok(index) => indices[axis] = index, + Err(error) => return format!("Coordinate unavailable: {error}"), + } + } + let mut values = Vec::new(); + for (axis, index) in indices.into_iter().enumerate() { + let value = match volume.processed.axis_coordinate(axis, index) { + Ok(value) => value, + Err(error) => return format!("Coordinate unavailable: {error}"), + }; + let nucleus = volume.source.axes()[axis] + .nucleus + .as_deref() + .unwrap_or("unknown"); + values.push(format!("{nucleus}(F{}) {value:.4} ppm", axis + 1)); + } + return format!( + "{} · sampled I {}", + values.join(" · "), + fmt_number(point.intensity) + ); + } let Processed2D::Ft(s) = &n.processed else { return String::new(); }; diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index d7e3d2d0..8c819688 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -135,6 +135,7 @@ pub(crate) enum CanvasInteractionClearScope { } pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { + super::nmr_volume::poll_phase(app, ui.ctx()); let Some(ci) = app.session.active_canvas else { if app.doc.canvases.is_empty() && !board_frames(app).is_empty() { board::render_sheets_only(app, ui); @@ -144,6 +145,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { return; }; canvas_breadcrumb(app, ci, ui); + super::nmr_volume::navigation(app, ui); 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()); diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index bf01b81f..6e8d82d2 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -60,6 +60,11 @@ fn execute_inner( } CommandId::OpenFile => super::file_dialogs::open_file(app), CommandId::OpenFolder => super::file_dialogs::open_folder(app), + CommandId::CancelNmrVolumePhase => super::nmr_volume::cancel_phase(ctx), + CommandId::ApplyNmrVolumePhase => super::nmr_volume::apply_phase(app, ctx), + CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => { + super::nmr_volume::step(app, id == CommandId::NextNmrPlane); + } CommandId::ImportNmrSampling => super::file_dialogs::nmr_sampling::open(app), CommandId::AgentConnections => { app.open_settings(); @@ -99,6 +104,7 @@ fn execute_inner( } CommandId::ImportTiffPages => super::file_dialogs::image_import::import_tiff_pages(app), CommandId::PasteImage => super::file_dialogs::image_import::paste_clipboard_image(app), + CommandId::CancelDataImport => app.cancel_data_imports(), CommandId::CancelImageImport => super::file_dialogs::image_import::cancel_all(app), CommandId::ReplaceImage => super::file_dialogs::image_import::replace_selected_image(app), CommandId::PasteTable => { diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 41bbfd5e..dded6b7c 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -1,3 +1,4 @@ +pub use super::command_exec::{execute, execute_without_clipboard}; use plotium_core::actions::ZOrder; use plotium_core::export::ExportFormat; use plotium_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; @@ -5,8 +6,6 @@ use plotium_core::properties::PropertyStep; #[cfg(test)] use plotium_core::state::WorkflowTab; use plotium_core::state::{Dataset, PlotiumApp, Tool, ToolGroup}; - -pub use super::command_exec::{execute, execute_without_clipboard}; mod craft; mod identity; use identity::command_identity; @@ -23,7 +22,6 @@ pub(crate) use ribbon::stack_row; pub use ribbon::{Applicability, RibbonPlacement}; use roster::command_ids; pub(crate) const MANUAL_URL: &str = "https://plotium.nmrtist.space/"; -/// The public source repository, linked from About. pub(crate) const REPOSITORY_URL: &str = "https://github.com/nmrtist/plotium"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommandId { @@ -35,6 +33,10 @@ pub enum CommandId { OpenFile, OpenFolder, ImportNmrSampling, + ApplyNmrVolumePhase, + CancelNmrVolumePhase, + PreviousNmrPlane, + NextNmrPlane, RunBatchWorkflow, AgentConnections, RunScientificScript, @@ -50,6 +52,7 @@ pub enum CommandId { ImportImageWithoutMetadata, ImportTiffPages, PasteImage, + CancelDataImport, CancelImageImport, ReplaceImage, PasteTable, @@ -163,7 +166,6 @@ pub enum CommandId { CycleCursor, Tool(Tool), } - /// Architectural ownership for commands that expose or consume results. New /// commands must choose a class instead of bypassing the automation registry /// with an arbitrary Action. @@ -173,7 +175,6 @@ pub enum CommandExecutionClass { ToolEditor, ToolBacked, } - pub struct CommandDescriptor { pub id: CommandId, pub execution_class: CommandExecutionClass, @@ -188,7 +189,6 @@ pub struct CommandDescriptor { pub shortcut: Option, pub ribbon: Option, } - pub fn catalog(app: &PlotiumApp) -> Vec { command_ids(app.session.recent_files.len()) .into_iter() @@ -206,7 +206,6 @@ pub fn catalog(app: &PlotiumApp) -> Vec { }) .collect() } - pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { let has_canvas = app.session.active_canvas.is_some(); let selected = app.session.ui.selection.objects().len(); @@ -238,9 +237,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { let has_selectable_analysis_trace = || has_trace() && !is_time_nmr(); let has_generic_peak_fit_trace = || has_trace() && (is_frequency_nmr() || is_table()); let range = || active_dataset.and_then(|di| app.analysis_range_for(di)); - let is_series = || dataset().is_some_and(Dataset::supports_region_analysis); - let (label, icon, checked) = command_identity(app, id); // The gate decides the enabled state and the disabled tooltip together, so a // command can never be blocked by one requirement while explaining another. @@ -296,6 +293,11 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { app.session.ui.table_import_preview.is_none(), "Finish or cancel the current table import preview before importing another table.", ), + CommandId::CancelNmrVolumePhase => requires(app.session.ui.nmr_volume_phase_busy, "Start phase processing before cancelling it."), + CommandId::ApplyNmrVolumePhase => super::nmr_volume::phase_gate(app), + CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => { + super::nmr_volume::step_gate(app, id == CommandId::NextNmrPlane) + } CommandId::ImportNmrSampling => requires( app.session.ui.nmr_import.is_none(), "Finish or cancel the current NMR import before importing another acquisition.", @@ -305,6 +307,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { | CommandId::ImportImageWithoutMetadata | CommandId::ImportTiffPages | CommandId::PasteImage => Ok(()), + CommandId::CancelDataImport => requires(app.session.data_imports.is_pending(), "Start a data import before cancelling it."), CommandId::CancelImageImport => requires( super::file_dialogs::image_import::has_active_jobs(), "Start an image import before cancelling it.", @@ -330,7 +333,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { dataset().is_some_and(|dataset| { !plotium_core::data_export::DataExportAvailability::for_dataset(dataset).is_empty() }), - "Select a dataset with processed data or analysis results to export.", + "Select a supported dataset for numerical export; save 3D data as a project or export its figure.", ), CommandId::Export(_) => requires(has_canvas, "Open a canvas before exporting a figure."), CommandId::CopyFigure => requires( @@ -709,9 +712,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { .is_some_and(|dataset| dataset.supports_symmetry_review()), "Select a homonuclear COSY, TOCSY, or NOESY / ROESY contour spectrum.", ), - // On-plot phasing needs a spectrum with a phase step to act on. The - // gate is the Phase settings tile's, so the tool and the settings it - // drives never disagree about when phasing is possible. + // Share the Phase settings gate so its tool stays consistent. CommandId::Tool(Tool::ManualPhase) => requires( super::properties::discovery::group_applies( app, @@ -726,6 +727,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { CommandId::Tool(_) => requires(has_canvas, "Open a canvas before using this tool."), _ => Ok(()), }; + let gate = super::nmr_volume::editing_gate(app, id).and(gate); // The palette cannot be opened over a modal or active gesture. Those states // hide every surface that could display its disabled tooltip, so this is the // one intentional disabled-without-reason path. @@ -773,11 +775,9 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { ribbon, } } - #[cfg(test)] #[path = "commands_alignment_tests.rs"] mod alignment_tests; - #[cfg(test)] #[path = "commands_craft_tests.rs"] mod craft_tests; diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 572bcbeb..d2e64057 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -55,6 +55,10 @@ pub(super) fn command_identity( CommandId::CloseWindow => plain("Close Window", Some(icon::X)), CommandId::OpenFile => plain("Open File…", Some(icon::FILE)), CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)), + CommandId::CancelNmrVolumePhase => plain("Cancel 3D NMR Phase", Some(icon::X)), + CommandId::ApplyNmrVolumePhase => plain("Apply 3D NMR Phase", Some(icon::PLAY)), + CommandId::PreviousNmrPlane => plain("Previous NMR Plane", Some(icon::CARET_LEFT)), + CommandId::NextNmrPlane => plain("Next NMR Plane", Some(icon::CARET_RIGHT)), CommandId::ImportNmrSampling => plain("Import NMR with Sampling Table…", Some(icon::FILE)), CommandId::AgentConnections => plain("AI connections (MCP)…", None), CommandId::RunBatchWorkflow => plain("Automation…", Some(icon::PLAY)), @@ -88,6 +92,7 @@ pub(super) fn command_identity( } CommandId::ImportTiffPages => plain("Add All TIFF Pages…", Some(icon::IMAGES_SQUARE)), CommandId::PasteImage => plain("Paste Image", Some(icon::CLIPBOARD_TEXT)), + CommandId::CancelDataImport => plain("Cancel Data Import", Some(icon::X)), CommandId::CancelImageImport => plain("Cancel Image Import", Some(icon::X)), CommandId::ReplaceImage => plain("Replace Image…", Some(icon::ARROWS_CLOCKWISE)), CommandId::PasteTable => plain("Paste Table from Clipboard", Some(icon::CLIPBOARD_TEXT)), @@ -455,6 +460,10 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::OpenFile => "file.open_file", CommandId::OpenFolder => "file.open_folder", CommandId::ImportNmrSampling => "file.import_nmr_sampling", + CommandId::CancelNmrVolumePhase => "nmr.phase.cancel", + CommandId::ApplyNmrVolumePhase => "nmr.phase.apply", + CommandId::PreviousNmrPlane => "nmr.plane.previous", + CommandId::NextNmrPlane => "nmr.plane.next", CommandId::RunBatchWorkflow => "tools.automation", CommandId::AgentConnections => "file.agent_connections", CommandId::RunScientificScript => "tools.run_scientific_script", @@ -465,6 +474,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ImportImageWithoutMetadata => "figure.import_image_without_metadata", CommandId::ImportTiffPages => "figure.import_tiff_pages", CommandId::PasteImage => "figure.paste_image", + CommandId::CancelDataImport => "file.cancel_data_import", CommandId::CancelImageImport => "figure.cancel_image_import", CommandId::ReplaceImage => "figure.replace_image", CommandId::PasteTable => "file.paste_table", diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index c3ae1553..ed6922f4 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -20,6 +20,10 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::OpenFile, CommandId::OpenFolder, CommandId::ImportNmrSampling, + CommandId::ApplyNmrVolumePhase, + CommandId::CancelNmrVolumePhase, + CommandId::PreviousNmrPlane, + CommandId::NextNmrPlane, CommandId::RunBatchWorkflow, CommandId::AgentConnections, CommandId::RunScientificScript, @@ -32,6 +36,7 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::ImportImageWithoutMetadata, CommandId::ImportTiffPages, CommandId::PasteImage, + CommandId::CancelDataImport, CommandId::CancelImageImport, CommandId::ReplaceImage, CommandId::PasteTable, diff --git a/crates/app/src/ui/commands_tests.rs b/crates/app/src/ui/commands_tests.rs index 19ba8be0..50e5957c 100644 --- a/crates/app/src/ui/commands_tests.rs +++ b/crates/app/src/ui/commands_tests.rs @@ -469,7 +469,9 @@ fn data_export_requires_exportable_current_data() { assert!(!command.enabled); assert_eq!( command.disabled_reason, - Some("Select a dataset with processed data or analysis results to export.") + Some( + "Select a supported dataset for numerical export; save 3D data as a project or export its figure." + ) ); let table = app_with_table(1); diff --git a/crates/app/src/ui/import_feedback.rs b/crates/app/src/ui/import_feedback.rs index 2cd51152..161982b5 100644 --- a/crates/app/src/ui/import_feedback.rs +++ b/crates/app/src/ui/import_feedback.rs @@ -36,7 +36,7 @@ pub(super) fn notice(app: &PlotiumApp, ctx: &Context) -> Option { let id = window_id(app).with(("notice", batch.id)); let mut state = ctx.data_mut(|data| data.get_temp::(id).unwrap_or_default()); let failed = batch.failed() > 0; - let complete = batch.phase == ImportPhase::Complete; + let complete = matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled); if complete && state.completed_at.is_none() { state.completed_at = Some(now); } @@ -119,6 +119,9 @@ pub(crate) fn cancel_on_input(app: &mut PlotiumApp, input: &egui::RawInput) { pub(crate) fn summary(batch: &ImportBatch) -> String { match batch.phase { + ImportPhase::Cancelled => { + "Data import cancelled; previously imported data is retained.".into() + } ImportPhase::Queued => "Data import queued".into(), ImportPhase::Scanning => "Scanning for data…".into(), ImportPhase::Preparing => match batch.total { @@ -222,6 +225,14 @@ pub(crate) fn results_window(app: &mut PlotiumApp, ctx: &Context) { .default_size([560.0, 360.0]) .default_pos(ctx.content_rect().center() - egui::vec2(280.0, 180.0)) .show(ctx, |ui| { + let cancel = commands::describe(app, CommandId::CancelDataImport); + if ui + .add_enabled(cancel.enabled, egui::Button::new(&cancel.label)) + .on_disabled_hover_text(cancel.disabled_reason.unwrap_or_default()) + .clicked() + { + commands::execute_without_clipboard(CommandId::CancelDataImport, app, ctx); + } if batches.is_empty() { ui.label("No imports yet. Open data to begin."); return; diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index e1301a0f..54acb9f8 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -75,6 +75,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Separator, Command(CommandId::ImportTable), Command(CommandId::ImportResults), + Command(CommandId::CancelDataImport), Command(CommandId::ImportImage), Command(CommandId::ImportImageFirstFrame), Command(CommandId::ImportImageWithoutMetadata), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 3531f6cb..d5e52e34 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -684,3 +684,5 @@ mod feedback_tests { assert_eq!(earlier, 0); } } + +mod nmr_volume; diff --git a/crates/app/src/ui/nmr_volume.rs b/crates/app/src/ui/nmr_volume.rs new file mode 100644 index 00000000..11f0bdd5 --- /dev/null +++ b/crates/app/src/ui/nmr_volume.rs @@ -0,0 +1,455 @@ +//! Plane navigation stays beside the active spectrum, not in a separate workflow. +use egui::{ComboBox, DragValue, Slider, Ui}; +use plotium_core::state::{Dataset, PlotiumApp}; + +use super::commands::{self, CommandId}; + +pub(super) fn step_gate(app: &PlotiumApp, forward: bool) -> Result<(), &'static str> { + let volume = app + .active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .and_then(Dataset::as_nmr2d) + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a three-dimensional NMR spectrum to browse planes.")?; + if forward && volume.index + 1 >= volume.processed.shape()[volume.fixed_axis] { + return Err("Choose an earlier plane to move forward."); + } + if !forward && volume.index == 0 { + return Err("Choose a later plane to move backward."); + } + Ok(()) +} + +pub(super) fn step(app: &mut PlotiumApp, forward: bool) { + let Some(di) = app.active_dataset() else { + return; + }; + let Some(volume) = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + else { + return; + }; + let axis = volume.fixed_axis; + let index = if forward { + volume.index.saturating_add(1) + } else { + volume.index.saturating_sub(1) + }; + select(app, di, axis, index); +} + +fn select(app: &mut PlotiumApp, di: usize, axis: usize, index: usize) { + let id = app.doc.datasets[di].resource_id(); + if let Err(error) = app.select_nmr_volume_plane(id, axis, index) { + app.session.status = format!("Cannot change NMR plane: {error}"); + } +} + +pub(super) fn navigation(app: &mut PlotiumApp, ui: &mut Ui) { + let Some(di) = app.active_dataset() else { + return; + }; + let Some(volume) = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + else { + return; + }; + let shape = volume.processed.shape(); + let anchor = volume.anchor; + let original_axis = volume.fixed_axis; + let original_index = volume.index; + let mut axis = original_axis; + let mut index = original_index; + let labels: Vec<_> = (0..3) + .map(|a| { + let nucleus = volume.source.axes()[a] + .nucleus + .as_deref() + .unwrap_or("unknown"); + format!("{nucleus} (F{})", a + 1) + }) + .collect(); + let mut ppm = match volume.processed.axis_coordinate(axis, index) { + Ok(ppm) => ppm, + Err(error) => { + ui.colored_label(ui.visuals().error_fg_color, error); + return; + } + }; + let mut ppm_changed = false; + ui.horizontal_wrapped(|ui| { + ui.strong("3D plane"); + ComboBox::from_id_salt("nmr_plane_axis") + .selected_text(format!("Fixed {}", labels[axis])) + .show_ui(ui, |ui| { + for (a, label) in labels.iter().enumerate() { + ui.selectable_value(&mut axis, a, format!("Fixed {label}")); + } + }); + for (command, label) in [ + (CommandId::PreviousNmrPlane, "◀"), + (CommandId::NextNmrPlane, "▶"), + ] { + let description = commands::describe(app, command); + if ui + .add_enabled(description.enabled, egui::Button::new(label)) + .on_hover_text(&description.label) + .on_disabled_hover_text(description.disabled_reason.unwrap_or_default()) + .clicked() + { + commands::execute_without_clipboard(command, app, ui.ctx()); + } + } + ui.add(Slider::new(&mut index, 0..=shape[original_axis] - 1).show_value(false)); + ui.label(format!("{} / {}", index + 1, shape[original_axis])); + ppm_changed = ui + .add( + DragValue::new(&mut ppm) + .speed(0.01) + .max_decimals(4) + .suffix(" ppm"), + ) + .changed(); + }); + if axis != original_axis { + select(app, di, axis, anchor[axis]); + } else if ppm_changed { + let volume = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .expect("local volume invariant"); + match volume.processed.nearest(axis, ppm) { + Ok(index) => select(app, di, axis, index), + Err(error) => app.session.status = format!("Cannot locate NMR plane: {error}"), + } + } else if index != original_index { + select(app, di, axis, index); + } +} + +pub(super) fn apply_phase(app: &mut PlotiumApp, ctx: &egui::Context) { + let Some(di) = app.active_dataset() else { + return; + }; + let id = app.doc.datasets[di].resource_id(); + if let Err(reason) = phase_gate(app) { + app.session.status = reason.into(); + return; + } + let draft = app.session.ui.nmr_volume_phase_drafts[&id].clone(); + let Some(mut dataset) = app.doc.datasets[di].as_nmr2d().cloned() else { + return; + }; + let expected = dataset + .volume + .as_ref() + .expect("local volume invariant") + .phases; + if phase_busy(ctx) { + return; + } + app.session.ui.nmr_volume_phase_busy = true; + let cancellation = nmr::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let output = std::sync::Arc::new(std::sync::Mutex::new(None)); + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new("volume_phase_job"), + PhaseJob { + id, + expected, + cancellation, + output: output.clone(), + }, + ) + }); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let mut context = nmr::ExecutionContext::default().with_cancellation(worker_cancellation); + let result = dataset + .set_volume_phases_with_context(draft.phases, &mut context) + .map(|()| dataset); + match output.lock() { + Ok(mut output) => *output = Some(result), + Err(error) => { + *error.into_inner() = Some(Err("NMR phase worker state was interrupted".into())) + } + } + ctx.request_repaint(); + }); +} + +pub(super) fn processing(app: &mut PlotiumApp, host: &mut Ui, di: usize) { + use super::tools::task_card; + use plotium_core::state::TaskDockTab; + let id = app.doc.datasets[di].resource_id(); + let Some(volume) = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + else { + return; + }; + let committed = volume.phases; + let mut draft = app + .session + .ui + .nmr_volume_phase_drafts + .get(&id) + .cloned() + .filter(|draft| draft.baseline == committed) + .unwrap_or(plotium_core::state::NmrVolumePhaseDraft { + baseline: committed, + phases: committed, + }); + let geometry = task_card::geometry( + app, + host, + TaskDockTab::Processing, + 300.0, + app.session.ui.processing_task_collapsed, + ); + let collapsed = app.session.ui.processing_task_collapsed; + let area_id = egui::Id::new("processing_task_card"); + let mut close = false; + task_card::area(host, area_id, geometry.pos).show(host.ctx(), |ui| { + ui.set_width(geometry.width); + crate::ui::card_frame(ui.visuals().dark_mode, egui::Margin::ZERO).show(ui, |ui| { + task_card::header(ui, area_id, "3D Processing", Some("Phase adjustment"), |ui| { + if crate::ui::panel_chrome::close(ui, "Close Processing").clicked() { close = true; } + }); + if collapsed { return; } + egui::ScrollArea::vertical().max_height(geometry.body_height).show(ui, |ui| { + ui.small("Basic transform completed. Phase is user-adjustable; an absorptive spectrum has not been independently confirmed."); + for (axis, phase) in draft.phases.iter_mut().enumerate() { + ui.separator(); + ui.strong(format!("Acquisition F{}", axis + 1)); + ui.horizontal(|ui| { + ui.label("Zero order"); + ui.add(DragValue::new(&mut phase.p0_degrees).speed(0.1).suffix("°")); + }); + ui.horizontal(|ui| { + ui.label("First order"); + ui.add(DragValue::new(&mut phase.p1_degrees).speed(0.1).suffix("°")); + }); + ui.horizontal(|ui| { + ui.label("Pivot fraction"); + ui.add(DragValue::new(&mut phase.pivot_fraction).range(0.0..=1.0).speed(0.001)); + }); + } + app.session.ui.nmr_volume_phase_drafts.insert(id, draft.clone()); + let cancel = commands::describe(app, CommandId::CancelNmrVolumePhase); + if ui.add_enabled(cancel.enabled, egui::Button::new("Cancel processing")).on_disabled_hover_text(cancel.disabled_reason.unwrap_or_default()).clicked() { + commands::execute_without_clipboard(CommandId::CancelNmrVolumePhase, app, ui.ctx()); + } + if app.session.ui.nmr_volume_phase_busy { ui.spinner(); ui.label("Processing phase…"); } + let desc = commands::describe(app, CommandId::ApplyNmrVolumePhase); + if ui.add_enabled(desc.enabled, egui::Button::new("Apply phase")) + .on_disabled_hover_text(desc.disabled_reason.unwrap_or_default()).clicked() { + commands::execute_without_clipboard(CommandId::ApplyNmrVolumePhase, app, ui.ctx()); + } + }); + }); + }); + if close { + app.session.ui.close_task_tab(TaskDockTab::Processing); + } +} + +#[derive(Clone)] +struct PhaseJob { + id: plotium_core::state::DatasetId, + expected: [plotium_processing::nmr_volume::VolumePhase; 3], + cancellation: nmr::CancellationToken, + output: + std::sync::Arc>>>, +} +fn phase_busy(ctx: &egui::Context) -> bool { + ctx.data(|data| { + data.get_temp::(egui::Id::new("volume_phase_job")) + .is_some() + }) +} +pub(super) fn poll_phase(app: &mut PlotiumApp, ctx: &egui::Context) { + let key = egui::Id::new("volume_phase_job"); + let Some(job) = ctx.data(|data| data.get_temp::(key)) else { + return; + }; + let result = match job.output.lock() { + Ok(mut output) => output.take(), + Err(error) => Some(Err(format!("NMR phase worker failed: {error}"))), + }; + let Some(result) = result else { return }; + ctx.data_mut(|data| data.remove::(key)); + app.session.ui.nmr_volume_phase_busy = false; + if job.cancellation.is_cancelled() { + app.session.status = "NMR phase processing cancelled".into(); + return; + } + let result = + result.and_then(|dataset| app.install_nmr_volume_result(job.id, job.expected, dataset)); + if let Err(error) = result { + app.session.status = format!("Cannot apply NMR phase: {error}"); + } +} + +pub(super) fn cancel_phase(ctx: &egui::Context) { + if let Some(job) = ctx.data(|data| data.get_temp::(egui::Id::new("volume_phase_job"))) + { + job.cancellation.cancel(); + } +} + +pub(super) fn phase_gate(app: &PlotiumApp) -> Result<(), &'static str> { + if app.session.ui.nmr_volume_phase_busy { + return Err( + "Wait for phase processing to finish or cancel it before applying new settings.", + ); + } + if !app + .active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .and_then(Dataset::as_nmr2d) + .is_some_and(|n| n.volume.is_some()) + { + return Err("Select a three-dimensional NMR spectrum before adjusting its phase."); + } + let data = &app.doc.datasets[app.active_dataset().expect("validated active dataset")]; + let phases = data + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .expect("validated volume") + .phases; + if !app + .session + .ui + .nmr_volume_phase_drafts + .get(&data.resource_id()) + .is_some_and(|draft| draft.baseline == phases && draft.phases != phases) + { + return Err("Open 3D Processing and edit a phase setting before applying phase."); + } + Ok(()) +} + +pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'static str> { + use plotium_core::state::Tool; + let volume = app + .active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .and_then(Dataset::as_nmr2d) + .is_some_and(|n| n.volume.is_some()); + if volume + && matches!( + id, + CommandId::DetectPeaks + | CommandId::PeakList + | CommandId::Integrate + | CommandId::Tool( + Tool::Peaks | Tool::Integrate | Tool::Symmetry | Tool::ManualPhase + ) + ) + { + return Err( + "Open a standalone spectrum for peak or integral editing; use 3D Processing to adjust volume phase.", + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn volume_navigation_renders_and_disables_endpoint_steps() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(&path).unwrap(); + let (dataset, _) = + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.focus_single(0); + let id = app.doc.datasets[0].resource_id(); + app.select_nmr_volume_plane(id, 0, 0).unwrap(); + assert!(!commands::describe(&app, CommandId::PreviousNmrPlane).enabled); + assert!(commands::describe(&app, CommandId::NextNmrPlane).enabled); + let description = commands::describe(&app, CommandId::ApplyNmrVolumePhase); + assert!(!description.enabled); + assert!( + description + .disabled_reason + .unwrap() + .contains("Open 3D Processing") + ); + let committed = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases; + let mut draft = plotium_core::state::NmrVolumePhaseDraft { + baseline: committed, + phases: committed, + }; + app.session + .ui + .nmr_volume_phase_drafts + .insert(id, draft.clone()); + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + draft.phases[0].p0_degrees = 15.0; + app.session.ui.nmr_volume_phase_drafts.insert(id, draft); + assert!(commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + // Readiness belongs to the selected dataset, not the last opened card. + app.doc.datasets.push({ + let loaded = plotium_io::load_path(&path).unwrap(); + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) + .unwrap() + .0 + }); + app.focus_single(1); + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + app.focus_single(0); + app.session + .ui + .nmr_volume_phase_drafts + .get_mut(&id) + .unwrap() + .baseline[0] + .p0_degrees = 90.0; + assert!(!commands::describe(&app, CommandId::ApplyNmrVolumePhase).enabled); + let ctx = egui::Context::default(); + let _ = ctx.run_ui(Default::default(), |ui| navigation(&mut app, ui)); + step(&mut app, true); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .index, + 1 + ); + } + + #[test] + fn plane_commands_explain_missing_volume_and_phase_cancel_tracks_worker() { + let mut app = PlotiumApp::new(); + for id in [ + CommandId::PreviousNmrPlane, + CommandId::NextNmrPlane, + CommandId::ApplyNmrVolumePhase, + ] { + let description = commands::describe(&app, id); + assert!(!description.enabled); + assert!(description.disabled_reason.unwrap().contains("Select")); + } + assert!(!commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); + app.session.ui.nmr_volume_phase_busy = true; + assert!(commands::describe(&app, CommandId::CancelNmrVolumePhase).enabled); + } +} diff --git a/crates/app/src/ui/tools/processing/surface.rs b/crates/app/src/ui/tools/processing/surface.rs index 2b49a086..9c2e4b30 100644 --- a/crates/app/src/ui/tools/processing/surface.rs +++ b/crates/app/src/ui/tools/processing/surface.rs @@ -97,6 +97,13 @@ pub(super) fn render(app: &mut PlotiumApp, host: &mut Ui) { if app.active_dataset() != Some(di) { return; } + if app.doc.datasets[di] + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + { + crate::ui::nmr_volume::processing(app, host, di); + return; + } if app.doc.datasets[di].as_xrd().is_some() { render_xrd(app, host, di); return; diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 1b213e30..9e7f788f 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -27,7 +27,7 @@ const CRAFT_MIN_WIDTH: f32 = 380.0; const STANDARD_MAX_WIDTH: f32 = 520.0; const CRAFT_MAX_WIDTH: f32 = 760.0; -pub(super) struct TaskCardGeometry { +pub(in crate::ui) struct TaskCardGeometry { pub pos: Pos2, pub width: f32, pub body_height: f32, @@ -41,7 +41,7 @@ pub(super) struct TaskCardGeometry { /// the fitted max and force the card past the canvas. `Area` then constrains it /// to the screen and slides it up over the Ribbon, hiding the very buttons that /// opened it. Clamping the min keeps a short window shrinking instead. -pub(super) fn geometry( +pub(in crate::ui) fn geometry( app: &PlotiumApp, host: &Ui, tab: TaskDockTab, @@ -230,7 +230,7 @@ struct DragOrigin { rect: egui::Rect, } -pub(super) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { +pub(in crate::ui) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { crate::ui::result_follow::register_editable_layer( host.ctx(), egui::LayerId::new(Order::Middle, id), @@ -247,7 +247,7 @@ pub(super) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { /// Renders the task-card title as one move surface with a dedicated action /// slot. Text responses participate in the same gesture while buttons remain /// ordinary controls, so the visual and interactive title bars are identical. -pub(super) fn header( +pub(in crate::ui) fn header( ui: &mut Ui, area_id: Id, title: &str, diff --git a/crates/cli/tests/tool_cli.rs b/crates/cli/tests/tool_cli.rs index 70ae1e70..a3f3730e 100644 --- a/crates/cli/tests/tool_cli.rs +++ b/crates/cli/tests/tool_cli.rs @@ -79,3 +79,43 @@ fn tool_run_without_input_requires_targets_for_dataset_tools() { assert!(!output.status.success()); assert!(String::from_utf8_lossy(&output.stderr).contains("explicit stable targets")); } + +#[test] +fn three_dimensional_plane_uses_shared_core_and_survives_project_save() { + let root = temp_dir("3d"); + let request = root.join("plane.json"); + std::fs::write( + &request, + r#"{"tool_id":"nmr.volume.edit","parameters":{"fixed_axis":2,"index":1}}"#, + ) + .unwrap(); + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let project = root.join("volume.plotium"); + let output = Command::new(env!("CARGO_BIN_EXE_plotium-cli")) + .args(["tool", "run"]) + .arg(fixture) + .arg("--request") + .arg(&request) + .arg("--output") + .arg(&project) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["value"]["fixed_axis"], 2); + assert_eq!(result["value"]["index"], 1); + let loaded = plotium_core::project::load_project(&project).unwrap(); + let volume = loaded.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + assert_eq!(volume.processed.shape(), [5, 3, 4]); + assert_eq!((volume.fixed_axis, volume.index), (2, 1)); +} diff --git a/crates/core/src/actions/app_impl/processing.rs b/crates/core/src/actions/app_impl/processing.rs index 48cd214a..db6cf202 100644 --- a/crates/core/src/actions/app_impl/processing.rs +++ b/crates/core/src/actions/app_impl/processing.rs @@ -410,6 +410,11 @@ pub(super) fn validate_processing_state( .map(|_| ()) .map_err(|error| format!("Cannot apply invalid direct processing pipeline: {error}")), (Dataset::Nmr2D(dataset), DatasetProcessingState::Nmr2D { params, .. }) => { + if dataset.volume.is_some() { + return Err( + "Use the three-dimensional processing controls to adjust this spectrum".into(), + ); + } plotium_processing::nmr_execution::validate_2d_domains(&dataset.data, params)?; Ok(()) } diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index ae4f9012..447cf9b1 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -39,6 +39,15 @@ pub(super) fn validate_action( shape: &mut ValidationShape, ) -> Result<(), ActionApplyError> { match action { + Action::SetPeaks2D { dataset, .. } | Action::SetIntegrals2D { dataset, .. } + if app + .doc + .dataset_index(*dataset) + .and_then(|i| app.doc.datasets[i].as_nmr2d()) + .is_some_and(|n| n.volume.is_some()) => + { + return Err(ActionApplyError::InvalidValue("Open a two-dimensional spectrum to edit peaks or integrals; three-dimensional evidence requires a volume-aware workflow".into())); + } Action::SetAsset { id, before, after } => { for asset in before.iter().chain(after) { if asset.id != *id || asset.pixel_size.contains(&0) { diff --git a/crates/core/src/automation/mod.rs b/crates/core/src/automation/mod.rs index e8ffca0d..aa3361cf 100644 --- a/crates/core/src/automation/mod.rs +++ b/crates/core/src/automation/mod.rs @@ -2,6 +2,8 @@ //! agents. The public boundary is deliberately semantic: callers can inspect, //! select and invoke registered tools, but cannot construct document actions. +mod nmr_volume; +pub use nmr_volume::{NMR_VOLUME_EDIT, NMR_VOLUME_INSPECT, VolumeEditParams}; mod native_import; pub use native_import::*; mod table_import; diff --git a/crates/core/src/automation/native_import.rs b/crates/core/src/automation/native_import.rs index f23ac09c..844347b9 100644 --- a/crates/core/src/automation/native_import.rs +++ b/crates/core/src/automation/native_import.rs @@ -175,7 +175,18 @@ impl PreparedNativeImport { .map_err(crate::workflow::WorkflowError::from) .and_then(|input| { prepared.params.sampling_declaration.clone().map_or_else( - || crate::workflow::load_dataset(&input), + || { + let token = nmr::CancellationToken::new(); + let mut progress = |_| { + if cancelled() { + token.cancel(); + } + }; + let mut context = nmr::ExecutionContext::default() + .with_cancellation(token.clone()) + .with_progress(&mut progress); + crate::workflow::load_dataset_with_context(&input, &mut context) + }, |declaration| { crate::workflow::load_dataset_with_sampling(&input, declaration) }, @@ -385,6 +396,13 @@ fn reservation( } } crate::state::Dataset::Nmr2D(data) => { + if let Some(volume) = &data.volume { + crate::project::write_dataset_payload( + &mut counter, + &crate::project::DatasetBlob::Nmr(volume.processed.source().dataset()), + ) + .map_err(|e| AutomationError::Execution(e.to_string()))?; + } for source in [&data.native_base, &data.native_processed] { crate::project::write_dataset_payload( &mut counter, diff --git a/crates/core/src/automation/nmr_volume.rs b/crates/core/src/automation/nmr_volume.rs new file mode 100644 index 00000000..7658f19f --- /dev/null +++ b/crates/core/src/automation/nmr_volume.rs @@ -0,0 +1,219 @@ +//! The same plane and phase operations used by the desktop workspace. +use super::*; +use serde::Deserialize; +use serde_json::json; + +pub const NMR_VOLUME_INSPECT: &str = "nmr.volume.inspect"; +pub const NMR_VOLUME_EDIT: &str = "nmr.volume.edit"; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VolumeEditParams { + pub fixed_axis: usize, + pub index: usize, + #[serde(default)] + pub phases: Option<[plotium_processing::nmr_volume::VolumePhase; 3]>, +} +pub(super) fn execute( + app: &mut crate::state::PlotiumApp, + plan: &ToolPlan, +) -> Result { + let [target] = plan.frozen_targets.targets.as_slice() else { + return Err(AutomationError::InvalidSelector( + "Select one three-dimensional spectrum".into(), + )); + }; + let dataset = app + .doc + .datasets + .iter() + .find(|d| d.resource_id().to_string() == target.id) + .ok_or_else(|| AutomationError::InvalidSelector("Select an existing spectrum".into()))?; + let id = dataset.resource_id(); + if dataset.as_nmr2d().and_then(|n| n.volume.as_ref()).is_none() { + return Err(AutomationError::InvalidSelector( + "Open a three-dimensional spectrum first".into(), + )); + } + if plan.request.tool_id == NMR_VOLUME_EDIT { + let p: VolumeEditParams = + super::registry::parse(NMR_VOLUME_EDIT, plan.request.parameters.clone())?; + // Validate the requested plane before making either edit. + let n = dataset.as_nmr2d().expect("validated above"); + n.volume + .as_ref() + .expect("validated above") + .processed + .plane_source(p.fixed_axis, p.index) + .map_err(AutomationError::Execution)?; + if p.phases.is_some() { + return start_phase_task(app, plan, id, p); + } + app.select_nmr_volume_plane(id, p.fixed_axis, p.index) + .map_err(AutomationError::Execution)?; + } + let n = app + .doc + .datasets + .iter() + .find(|d| d.resource_id() == id) + .and_then(|d| d.as_nmr2d()) + .ok_or_else(|| AutomationError::Execution("Spectrum disappeared".into()))?; + let v = n + .volume + .as_ref() + .ok_or_else(|| AutomationError::Execution("Volume disappeared".into()))?; + let axes=v.processed.axes().iter().enumerate().map(|(axis,a)|json!({"axis":axis,"nucleus":a.nucleus(),"points":v.processed.shape()[axis],"first_ppm":a.coordinate(0).ok(),"last_ppm":a.coordinate(v.processed.shape()[axis]-1).ok()})).collect::>(); + let mut result = super::tool_executors::readonly_value( + &plan.request.tool_id, + plan.frozen_targets.revision, + json!({"shape":v.processed.shape(),"axes":axes,"fixed_axis":v.fixed_axis,"index":v.index,"fixed_ppm":v.processed.axes()[v.fixed_axis].coordinate(v.index).map_err(|e|AutomationError::Execution(e.to_string()))?,"phases":v.phases,"phase_status":"user-adjustable; not independently calibrated"}), + ); + if plan.request.tool_id == NMR_VOLUME_EDIT { + result.modified.push(target.clone()); + } + result.after_revision = DocumentRevision(app.doc.automation_revision); + Ok(result) +} +fn start_phase_task( + app: &mut crate::state::PlotiumApp, + plan: &ToolPlan, + id: crate::state::DatasetId, + p: VolumeEditParams, +) -> Result { + use crate::state::{ComputeService, PlotiumApp, ScientificOutput, ScientificWork}; + let snapshots = app.doc.datasets.clone(); + let canvases = app.doc.canvases.clone(); + let settings = app.settings.clone(); + let phases = p.phases.expect("phase task requires phases"); + let work: ScientificWork = Box::new(move |token| { + let mut worker = PlotiumApp::with_compute(settings, ComputeService::inactive()); + worker.doc.datasets = snapshots; + worker.doc.canvases = canvases; + let di = worker.doc.dataset_index(id).ok_or("Spectrum was removed")?; + let mut after = worker.doc.datasets[di].clone(); + let n = after + .as_nmr2d_mut() + .ok_or("Select a three-dimensional spectrum")?; + let cancellation = nmr::CancellationToken::new(); + if token.is_cancelled() { + return Err("Processing cancelled".into()); + } + let mut progress = |_| { + if token.is_cancelled() { + cancellation.cancel(); + } + }; + let mut context = nmr::ExecutionContext::default() + .with_cancellation(cancellation.clone()) + .with_progress(&mut progress); + n.set_volume_phases_with_context(phases, &mut context)?; + n.select_volume_plane(p.fixed_axis, p.index)?; + let action = worker.volume_install_action(id, after)?; + Ok(ScientificOutput { + create_canvas: false, + action: Box::new(action), + summary: json!({"fixed_axis":p.fixed_axis,"index":p.index,"phases":phases}), + produced: vec![], + }) + }); + let receipt = TaskReceipt { + sources: vec![], + task_id: plan + .task_id + .clone() + .ok_or_else(|| AutomationError::Execution("Task identity missing".into()))?, + plan_id: plan.submitted_plan_id.clone(), + operation: NMR_VOLUME_EDIT.into(), + project_context: app.session.project_context, + targets: plan.frozen_targets.targets.clone(), + start_revision: plan.frozen_targets.revision, + end_revision: None, + status: TaskStatus::Queued, + phase: "queued".into(), + completed: None, + total: None, + failure_code: None, + message: None, + produced: vec![], + modified: vec![ResourceRef::from(id)], + summary: serde_json::Value::Null, + }; + let receipt = app + .start_prepared_scientific(id, receipt, work) + .map_err(AutomationError::Execution)?; + Ok(super::tool_executors::readonly_value( + NMR_VOLUME_EDIT, + plan.frozen_targets.revision, + json!({"task":receipt}), + )) +} +pub(super) fn descriptors() -> Vec { + [(NMR_VOLUME_INSPECT,false),(NMR_VOLUME_EDIT,true)].into_iter().map(|(id,write)|ToolDescriptor{ + id:id.into(),version:1,title:if write{"Set 3D spectrum plane and phase"}else{"Inspect 3D spectrum"}.into(),description:"Inspect or navigate an acquisition's three-dimensional frequency spectrum. Acquisition axes are zero based; no Fourier transform runs when changing only the plane.".into(), + parameter_schema:if write{json!({"type":"object","additionalProperties":false,"required":["fixed_axis","index"],"properties":{"fixed_axis":{"type":"integer","minimum":0,"maximum":2},"index":{"type":"integer","minimum":0},"phases":{"type":"array","minItems":3,"maxItems":3,"items":{"type":"object","additionalProperties":false,"required":["p0_degrees","p1_degrees","pivot_fraction"],"properties":{"p0_degrees":{"type":"number"},"p1_degrees":{"type":"number"},"pivot_fraction":{"type":"number","minimum":0,"maximum":1}}}}}})}else{json!({"type":"object","additionalProperties":false})}, + result_schema:json!({"type":"object"}),target_kinds:vec![ResourceKindId::new(KIND_DATASET)],required_capabilities:vec![CapabilityId::new(CAP_PROCESSING_SCHEME)],effect:if write{EffectLevel::Reversible}else{EffectLevel::ReadOnly},undoable:write,deterministic:true,task_kind:if write{"background"}else{"synchronous"}.into() + }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + fn run( + app: &mut crate::state::PlotiumApp, + tool: &str, + parameters: serde_json::Value, + ) -> Result { + let request = ToolRequest { + tool_id: tool.into(), + tool_version: 1, + parameters, + targets: TargetSelector::Explicit { + ids: vec![app.doc.datasets[0].resource_id().to_string()], + }, + expected_revision: DocumentRevision(app.doc.automation_revision), + caller: CallerType::Agent, + }; + let plan = plan_tool(app, request)?; + execute_tool(app, plan, ExecutionAuthority::ReversibleModify) + } + #[test] + fn automation_uses_shared_plane_and_rejects_invalid_coordinates() { + 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, _) = crate::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let mut app = crate::state::PlotiumApp::new(); + app.doc.datasets.push(dataset); + let initial = run(&mut app, NMR_VOLUME_INSPECT, json!({})).unwrap(); + assert_eq!(initial.value["shape"], json!([5, 3, 4])); + let changed = run(&mut app, NMR_VOLUME_EDIT, json!({"fixed_axis":2,"index":1})).unwrap(); + assert_eq!(changed.value["index"], 1); + assert_eq!(changed.value["fixed_axis"], 2); + let revision = app.doc.automation_revision; + assert!(run(&mut app, NMR_VOLUME_EDIT, json!({"fixed_axis":3,"index":0})).is_err()); + assert_eq!(revision, app.doc.automation_revision); + app.undo(); + let restored = run(&mut app, NMR_VOLUME_INSPECT, json!({})).unwrap(); + assert_eq!(initial.value, restored.value); + let phase = json!({"p0_degrees":30.0,"p1_degrees":0.0,"pivot_fraction":0.5}); + let queued = run( + &mut app, + NMR_VOLUME_EDIT, + json!({"fixed_axis":1,"index":2,"phases":[phase.clone(),phase.clone(),phase]}), + ) + .unwrap(); + assert!(queued.value.get("task").is_some()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while app.compute_busy() && std::time::Instant::now() < deadline { + app.poll_compute(); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + app.poll_compute(); + let changed = run(&mut app, NMR_VOLUME_INSPECT, json!({})).unwrap(); + assert_eq!(changed.value["fixed_axis"], 1); + assert_eq!(changed.value["phases"][0]["p0_degrees"], 30.0); + app.undo(); + let restored = run(&mut app, NMR_VOLUME_INSPECT, json!({})).unwrap(); + assert_eq!(initial.value, restored.value); + } +} diff --git a/crates/core/src/automation/processing_edit.rs b/crates/core/src/automation/processing_edit.rs index 73b14b48..1d36832a 100644 --- a/crates/core/src/automation/processing_edit.rs +++ b/crates/core/src/automation/processing_edit.rs @@ -149,6 +149,9 @@ fn pipelines(dataset: &Dataset) -> Result<(PipelineAxes, u64), AutomationError> vec![("direct", pipeline_to_dto(&n.pipeline))], n.next_step_id, )), + Dataset::Nmr2D(n) if n.volume.is_some() => Err(AutomationError::InvalidSelector( + "Use nmr.volume.edit to adjust a three-dimensional spectrum".into(), + )), Dataset::Nmr2D(n) => Ok(( vec![ ("f2", pipeline_to_dto(&n.params.f2)), diff --git a/crates/core/src/automation/properties_tests_planning.rs b/crates/core/src/automation/properties_tests_planning.rs index 2e47b324..e201987f 100644 --- a/crates/core/src/automation/properties_tests_planning.rs +++ b/crates/core/src/automation/properties_tests_planning.rs @@ -16,11 +16,10 @@ fn relation_plan() -> serde_json::Value { serde_json::to_value(plan).expect("a relation plan serializes") } -/// The per-tool planning seam is additive. Every tool that existed before it -/// with resource selectors must still use the shared gate alone: one planned target per frozen -/// resource, no component, and the shared reasons. +/// Resource planning preserves exact resource identity, adds no component target, +/// and retains the shared applicability gate for these representative operations. #[test] -fn the_planning_of_pre_existing_tools_is_unchanged() { +fn resource_planning_preserves_targets_and_shared_gate_reasons() { let (app, _) = contour_app(); let canvas = app.doc.canvases[0].resource_id.to_string(); let dataset = app.doc.datasets[0].resource_id().to_string(); @@ -64,35 +63,6 @@ fn the_planning_of_pre_existing_tools_is_unchanged() { &canvas, ), ]; - assert_eq!( - cases.len(), - ToolRegistry::built_in() - .descriptors() - .filter(|d| !is_native_curve_tool(&d.id) - && !is_scientific_plot_tool(&d.id) - && !matches!( - d.id.as_str(), - "data.import" - | FIGURE_EDIT - | TABLE_PATCH - | TABLE_REFRESH - | FIGURE_DIAGNOSE - | DIFFUSION_INSPECT - | DIFFUSION_SET_INPUT - | PROCESSING_INSPECT - | PROCESSING_EDIT - | REGIONS_EXTRACT - | DIFFUSION_FIT - | DOSY_BUILD - | ANALYSIS_RESULTS_INSPECT - | TOOL_SET_MANY - | DATASET_DELETE - | XAS_MERGE - )) - .count() - - 7, - "every resource-targeted tool that predates property, document discovery, image preview, table read and explicit curve binding tools is covered here" - ); for (tool_id, parameters, target) in cases { let plan = plan_tool( &app, diff --git a/crates/core/src/automation/registry.rs b/crates/core/src/automation/registry.rs index 8e27dc87..f84cf1d9 100644 --- a/crates/core/src/automation/registry.rs +++ b/crates/core/src/automation/registry.rs @@ -53,6 +53,8 @@ pub(super) fn validate_parameters( value: serde_json::Value, ) -> Result<(), AutomationError> { match tool { + NMR_VOLUME_INSPECT => parse::(tool, value).map(drop), + NMR_VOLUME_EDIT => parse::(tool, value).map(drop), FIELD_PLOT_CREATE | DIFFUSION_PLOT_CREATE => { parse::(tool, value).map(drop) } @@ -612,6 +614,7 @@ fn descriptors() -> Vec { .map(make_descriptor) .chain(super::scientific_plot::descriptors()) .chain(super::science::descriptors()) + .chain(super::nmr_volume::descriptors()) .chain(super::dosy::descriptors()) .chain(super::region_fit::descriptors()) .chain(std::iter::once(super::processing_edit::descriptor())) diff --git a/crates/core/src/automation/science.rs b/crates/core/src/automation/science.rs index 0175ad48..d63282e4 100644 --- a/crates/core/src/automation/science.rs +++ b/crates/core/src/automation/science.rs @@ -210,6 +210,10 @@ pub(super) fn execute( Dataset::Nmr(n) => vec![ json!({"axis":"direct", "input_domain":format!("{:?}", n.input_domain()), "output_domain":format!("{:?}",n.output_domain()), "pipeline":crate::project::pipeline_to_dto(&n.pipeline), "valid":n.pipeline.output_domain(n.input_domain()).is_ok(), "pending_apply":false}), ], + Dataset::Nmr2D(n) if n.volume.is_some() => { + let volume=n.volume.as_ref().unwrap(); + volume.source.axes().iter().enumerate().map(|(axis,input)| json!({"axis":axis,"input_domain":format!("{:?}",input.domain),"output_domain":"Frequency","phase":volume.phases[axis],"valid":true,"pending_apply":false,"edit_tool":"nmr.volume.phase"})).collect() + }, Dataset::Nmr2D(n) => [("f2", crate::state::PhaseAxis::F2, &n.params.f2), ("f1", crate::state::PhaseAxis::F1, &n.params.f1)] .into_iter().map(|(axis, phase_axis, pipeline)| { let input = n.input_domain(phase_axis); diff --git a/crates/core/src/automation/tools.rs b/crates/core/src/automation/tools.rs index db915053..765509f6 100644 --- a/crates/core/src/automation/tools.rs +++ b/crates/core/src/automation/tools.rs @@ -164,6 +164,11 @@ pub fn plan_tool(app: &PlotiumApp, request: ToolRequest) -> Result super::region_fit::execute(app, &plan)?, DOSY_BUILD => super::dosy::execute(app, &plan)?, ANALYSIS_RESULTS_INSPECT => super::dosy::inspect(app, &plan)?, + NMR_VOLUME_INSPECT | NMR_VOLUME_EDIT => super::nmr_volume::execute(app, &plan)?, PROCESSING_EDIT => super::processing_edit::execute(app, &plan)?, DIFFUSION_INSPECT | DIFFUSION_SET_INPUT | PROCESSING_INSPECT => { super::science::execute(app, &plan)? diff --git a/crates/core/src/data_export.rs b/crates/core/src/data_export.rs index e5ea3a1f..5c34acbf 100644 --- a/crates/core/src/data_export.rs +++ b/crates/core/src/data_export.rs @@ -126,6 +126,9 @@ pub struct DataExportAvailability { impl DataExportAvailability { pub fn for_dataset(dataset: &Dataset) -> Self { + if let Some(unavailable) = nmr_export::volume_availability(dataset) { + return unavailable; + } let mut contents = Vec::new(); if processed_data_available(dataset) { contents.push(DataExportContent::ProcessedData); diff --git a/crates/core/src/data_export/nmr.rs b/crates/core/src/data_export/nmr.rs index dd3572ef..d2c64045 100644 --- a/crates/core/src/data_export/nmr.rs +++ b/crates/core/src/data_export/nmr.rs @@ -3,6 +3,9 @@ use super::*; pub(super) fn snapshot_series( nmr: &crate::state::Nmr2DDataset, ) -> Result { + if nmr.volume.is_some() { + return Err(DataExportError::ContentUnavailable); + } match &nmr.processed { Processed2D::Ft(spectrum) => Ok(SnapshotData::True2D(Arc::clone(spectrum))), Processed2D::Stack(spectrum) => { @@ -35,3 +38,15 @@ pub(super) fn snapshot_series( } } } + +pub(super) fn volume_availability(dataset: &Dataset) -> Option { + dataset + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + .then(|| DataExportAvailability { + contents: Vec::new(), + has_channel_choice: false, + has_shape_choice: false, + default_channel: IntensityChannel::Real, + }) +} diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index c5967b57..77b08bc4 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -86,6 +86,11 @@ pub fn dataset_to_objects<'a>( DatasetObjects::primary(data, DatasetBlob::Nmr(n.data.dataset()), recipe) } Dataset::Nmr2D(n) => { + let original = n + .volume + .as_ref() + .map(|v| &v.source) + .unwrap_or_else(|| n.data.source_dataset()); let data = DataObject { id: data_id.to_owned(), role: "data".to_owned(), @@ -95,13 +100,7 @@ pub fn dataset_to_objects<'a>( payload: Payload { storage: super::nmr_snapshot::STORAGE.to_owned(), blob: format!("objects/{data_id}/data.bin"), - shape: n - .data - .source_dataset() - .axes() - .iter() - .map(|axis| axis.points) - .collect(), + shape: original.axes().iter().map(|axis| axis.points).collect(), domain: "nmr".to_owned(), }, extensions: serde_json::json!({ @@ -158,17 +157,23 @@ pub fn dataset_to_objects<'a>( classification: nmr_recipe_classification(), input: data_id.to_owned(), parameters: RecipeParameters { - dimension_count: 2, + dimension_count: if n.volume.is_some() { 3 } else { 2 }, pipelines: vec![pipeline_to_dto(&n.params.f2), pipeline_to_dto(&n.params.f1)], group_delay_correct: n.group_delay_correct, layout: Some(layout_to_str(n.params.layout).to_owned()), preset: Some(preset_to_str(n.preset).to_owned()), }, - extensions: nmr2d_recipe_extensions(n, dosy_extension), + extensions: { + let mut extensions = nmr2d_recipe_extensions(n, dosy_extension); + if let Some(volume) = &n.volume { + extensions["plotium.volume"] = serde_json::json!({"fixed_axis":volume.fixed_axis,"index":volume.index,"phases":volume.phases,"anchor":volume.anchor}); + } + extensions + }, }; DatasetObjects { data, - blob: DatasetBlob::Nmr(n.data.source_dataset().dataset()), + blob: DatasetBlob::Nmr(original.dataset()), recipe, extra_blobs, } @@ -503,6 +508,39 @@ pub fn object_to_dataset( } let source = super::nmr_snapshot::read(zip, data)?; match source.axes().len() { + 3 => { + #[derive(serde::Deserialize)] + struct VolumeView { + fixed_axis: usize, + index: usize, + anchor: [usize; 3], + phases: [plotium_processing::nmr_volume::VolumePhase; 3], + } + let view: VolumeView = + serde_json::from_value(recipe.extensions["plotium.volume"].clone())?; + let mut dataset = Nmr2DDataset::load_volume_state( + source, + view.phases, + view.fixed_axis, + Some(view.index), + ) + .map_err(ProjectError::Invalid)?; + if view + .anchor + .iter() + .zip(dataset.volume.as_ref().unwrap().processed.shape()) + .any(|(i, n)| *i >= n) + { + return Err(ProjectError::Invalid( + "Volume anchor lies outside the acquisition".into(), + )); + } + dataset.volume.as_mut().unwrap().anchor = view.anchor; + dataset.acquisition_identity = read_acquisition_identity(data)?; + dataset.field_catalog = read_field_catalog(data)?; + dataset.name = data.label.clone(); + Ok(Dataset::Nmr2D(Box::new(dataset))) + } 2 => { let initial = Params2D { layout: if source.axes()[0].domain == nmr::axis::AxisDomain::Parameter diff --git a/crates/core/src/project/nmr_snapshot_tests.rs b/crates/core/src/project/nmr_snapshot_tests.rs index d3eaa417..6a29949e 100644 --- a/crates/core/src/project/nmr_snapshot_tests.rs +++ b/crates/core/src/project/nmr_snapshot_tests.rs @@ -413,3 +413,116 @@ fn nus_reconstruction_changes_live_bindings_and_reopens_with_grid_identities() { ); } } + +#[test] +fn volume_planes_and_phases_roundtrip_without_original_files() { + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(&fixture).unwrap(); + let (mut dataset, _) = crate::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let volume = dataset.as_nmr2d().unwrap().volume.as_ref().unwrap(); + let shape = volume.processed.shape(); + assert_eq!(shape, [5, 3, 4]); + assert!(crate::data_export::DataExportAvailability::for_dataset(&dataset).is_empty()); + let n = match &mut dataset { + Dataset::Nmr2D(n) => n, + _ => unreachable!(), + }; + for (axis, count) in shape.iter().enumerate() { + n.select_volume_plane(axis, count - 1).unwrap(); + } + assert!(n.select_volume_plane(3, 0).is_err()); + assert_eq!(n.volume.as_ref().unwrap().fixed_axis, 2); + let phases = [plotium_processing::nmr_volume::VolumePhase { + p0_degrees: 30.0, + p1_degrees: 0.0, + pivot_fraction: 0.5, + }; 3]; + n.set_volume_phases(phases).unwrap(); + let expected = n + .volume + .as_ref() + .unwrap() + .processed + .signed_value([1, 1, 1]) + .unwrap(); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + let id = app.doc.datasets[0].resource_id(); + app.select_nmr_volume_plane(id, 0, 0).unwrap(); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .fixed_axis, + 0 + ); + app.undo(); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .fixed_axis, + 2 + ); + let mut updated = phases; + updated[0].p0_degrees = 45.0; + app.set_nmr_volume_phases(id, updated).unwrap(); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases, + updated + ); + app.undo(); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .phases, + phases + ); + let file = path("volume.plotium"); + save_project(&app, &file, false).unwrap(); + let restored = load_project(&file).unwrap(); + std::fs::remove_file(file).unwrap(); + let volume = restored.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + assert_eq!(volume.fixed_axis, 2); + assert_eq!(volume.index, 3); + assert_eq!(volume.phases, phases); + assert_eq!(volume.processed.signed_value([1, 1, 1]).unwrap(), expected); +} + +#[test] +fn cancelled_volume_import_stops_before_processing() { + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let token = nmr::CancellationToken::new(); + token.cancel(); + let mut context = nmr::ExecutionContext::default().with_cancellation(token); + let error = crate::workflow::load_dataset_with_context(&fixture, &mut context) + .err() + .expect("cancelled FFT must not install a dataset"); + assert!( + error.to_string().to_lowercase().contains("cancel"), + "{error}" + ); +} diff --git a/crates/core/src/state/app_impl_nmr_volume.rs b/crates/core/src/state/app_impl_nmr_volume.rs new file mode 100644 index 00000000..5ed47db1 --- /dev/null +++ b/crates/core/src/state/app_impl_nmr_volume.rs @@ -0,0 +1,162 @@ +//! Shared undoable edits for a three-dimensional spectrum's displayed plane. +use super::*; +use plotium_processing::nmr_volume::VolumePhase; + +impl PlotiumApp { + pub fn select_nmr_volume_plane( + &mut self, + dataset: DatasetId, + axis: usize, + index: usize, + ) -> Result<(), String> { + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let mut after = self.doc.datasets[di].clone(); + after + .as_nmr2d_mut() + .ok_or("Select a three-dimensional spectrum")? + .select_volume_plane(axis, index)?; + let action = self.volume_install_action(dataset, after)?; + self.try_execute_action(action).map_err(|e| e.to_string())?; + self.session.ui.inspect_cursor_pin = None; + self.session.ui.delta_cursor_anchor = None; + self.session.ui.delta_cursor_pin = None; + self.session.ui.slice = None; + Ok(()) + } + pub(crate) fn volume_install_action( + &self, + dataset: DatasetId, + after: Dataset, + ) -> Result { + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let axis = after + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a three-dimensional spectrum")? + .fixed_axis; + let old_axis = self.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a three-dimensional spectrum")? + .fixed_axis; + let mut changes = Vec::new(); + if old_axis != axis { + let old_axes: Vec<_> = (0..3).filter(|a| *a != old_axis).collect(); + let new_axes: Vec<_> = (0..3).filter(|a| *a != axis).collect(); + let full = CanvasViewport::from_figure(&after.as_nmr2d().unwrap().processed_figure); + for (ci, canvas) in self.doc.canvases.iter().enumerate() { + for object in &canvas.objects { + let Some(plot) = object.plot().filter(|p| p.display_owner == Some(dataset)) + else { + continue; + }; + let before = plot.viewport.clone(); + let mut next = before.clone(); + let ranges = [before.view_y, before.view_x]; + next.full_x = full.full_x; + next.full_y = full.full_y; + next.view_x = old_axes + .iter() + .position(|a| *a == new_axes[1]) + .map(|i| ranges[i]) + .unwrap_or(full.full_x); + next.view_y = old_axes + .iter() + .position(|a| *a == new_axes[0]) + .map(|i| ranges[i]) + .unwrap_or(full.full_y); + changes.push(crate::actions::Action::SetObjectViewport { + canvas: ci, + object: object.id, + before, + after: next, + }); + } + } + } + changes.insert( + 0, + crate::actions::Action::InstallScientificState { + before: vec![self.doc.datasets[di].clone()], + after: vec![after], + }, + ); + Ok(crate::actions::Action::Composite(changes)) + } + pub fn set_nmr_volume_phases( + &mut self, + dataset: DatasetId, + phases: [VolumePhase; 3], + ) -> Result<(), String> { + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let mut result = self.doc.datasets[di] + .as_nmr2d() + .ok_or("Select a three-dimensional spectrum")? + .clone(); + let expected = result + .volume + .as_ref() + .ok_or("Select a three-dimensional spectrum")? + .phases; + result.set_volume_phases(phases)?; + self.install_nmr_volume_result(dataset, expected, result) + } + pub fn install_nmr_volume_result( + &mut self, + dataset: DatasetId, + expected_phases: [VolumePhase; 3], + mut result: Nmr2DDataset, + ) -> Result<(), String> { + let di = self + .doc + .dataset_index(dataset) + .ok_or("The target spectrum was removed")?; + let current = self.doc.datasets[di] + .as_nmr2d() + .ok_or("The target spectrum changed")?; + let volume = current + .volume + .as_ref() + .ok_or("The target is no longer three-dimensional")?; + let incoming = result + .volume + .as_ref() + .ok_or("Processing produced no three-dimensional result")?; + if volume.phases != expected_phases + || !std::sync::Arc::ptr_eq(volume.source.dataset(), incoming.source.dataset()) + { + return Err( + "Discarded an outdated phase result; apply the current settings again".into(), + ); + } + result.select_volume_plane(volume.fixed_axis, volume.index)?; + // Navigation may have advanced while this phase job was running. + if let Some(incoming) = result.volume.as_mut() { + incoming.anchor = volume.anchor; + } + // Preserve edits made while the numerical worker was running. + let mut replacement = current.clone(); + replacement.volume = result.volume; + replacement.data = result.data; + replacement.native_base = result.native_base; + replacement.native_processed = result.native_processed; + replacement.base = result.base; + replacement.processed = result.processed; + replacement.processed_figure = result.processed_figure; + self.try_execute_action(crate::actions::Action::InstallScientificState { + before: vec![self.doc.datasets[di].clone()], + after: vec![Dataset::Nmr2D(Box::new(replacement))], + }) + .map_err(|e| e.to_string())?; + Ok(()) + } +} diff --git a/crates/core/src/state/app_impl_symmetry.rs b/crates/core/src/state/app_impl_symmetry.rs index 1a34e28a..620fe772 100644 --- a/crates/core/src/state/app_impl_symmetry.rs +++ b/crates/core/src/state/app_impl_symmetry.rs @@ -11,6 +11,11 @@ use super::*; impl Nmr2DDataset { pub fn symmetry_unavailable_reason(&self) -> Option<&'static str> { + if self.volume.is_some() { + return Some( + "Open a two-dimensional spectrum to review symmetry; three-dimensional peak evidence is not supported yet.", + ); + } if !self.is_true_2d() { return Some("Symmetry review needs a true-2D contour spectrum."); } diff --git a/crates/core/src/state/compute/tests.rs b/crates/core/src/state/compute/tests.rs index 78e3a708..27b0d6ba 100644 --- a/crates/core/src/state/compute/tests.rs +++ b/crates/core/src/state/compute/tests.rs @@ -544,3 +544,57 @@ fn held_processing_respects_estimate_to_geometry_boundary_and_cancellation() { assert!(!service.is_busy()); } } + +#[test] +fn volume_navigation_phase_and_history_leave_other_jobs_and_versions_untouched() { + use crate::state::{Dataset, PlotiumApp}; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let load = || { + let input = plotium_io::load_path(&fixture).unwrap(); + crate::workflow::dataset_from_acquisition(input.acquisition) + .unwrap() + .0 + }; + let mut app = PlotiumApp::with_compute(Default::default(), ComputeService::inactive()); + app.doc.datasets = vec![load(), load()]; + let target = app.doc.datasets[1].resource_id(); + let unrelated = app.doc.datasets[0].resource_id(); + let field = FieldRef { + resource: unrelated, + field: app.doc.datasets[0].field_descriptors()[0].id, + }; + let version = app.session.compute.field_version_for(field).unwrap(); + let tokens: Vec<_> = [ComputeKind::Dosy, ComputeKind::Ilt] + .into_iter() + .map(|kind| { + let token = CancellationToken::new(); + app.session.compute.active.insert( + (unrelated, kind), + ActiveJob { + generation: 1, + started_at: Instant::now(), + token: token.clone(), + processing_input: None, + }, + ); + token + }) + .collect(); + app.select_nmr_volume_plane(target, 2, 1).unwrap(); + app.undo(); + app.redo(); + let phases = [plotium_processing::nmr_volume::VolumePhase { + p0_degrees: 30.0, + ..Default::default() + }; 3]; + app.set_nmr_volume_phases(target, phases).unwrap(); + app.undo(); + app.redo(); + assert!(tokens.iter().all(|token| !token.is_cancelled())); + assert_eq!( + app.session.compute.current_field_version(field), + Some(version) + ); + assert!(matches!(&app.doc.datasets[0], Dataset::Nmr2D(_))); +} diff --git a/crates/core/src/state/data_import.rs b/crates/core/src/state/data_import.rs index 551b8fdf..00fdeb57 100644 --- a/crates/core/src/state/data_import.rs +++ b/crates/core/src/state/data_import.rs @@ -16,10 +16,18 @@ mod tests; impl PreparedImport { pub(super) fn new(loaded: plotium_io::LoadResult, equal_scale: bool) -> Result { - let (dataset, source) = crate::workflow::dataset_from_loaded_acquisition( + Self::new_with_context(loaded, equal_scale, &mut nmr::ExecutionContext::default()) + } + fn new_with_context( + loaded: plotium_io::LoadResult, + equal_scale: bool, + context: &mut nmr::ExecutionContext<'_>, + ) -> Result { + let (dataset, source) = crate::workflow::dataset_from_loaded_acquisition_with_context( loaded.acquisition, loaded.acquisition_identity, equal_scale, + context, ) .map_err(|error| error.to_string())?; let mut warnings = loaded.warnings; @@ -55,6 +63,7 @@ pub enum ImportPhase { Scanning, Preparing, Complete, + Cancelled, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -122,6 +131,13 @@ enum Event { struct Job { receiver: mpsc::Receiver, id: ImportBatchId, + cancellation: nmr::CancellationToken, +} + +impl Drop for Job { + fn drop(&mut self) { + self.cancellation.cancel(); + } } /// Dropping a session disconnects the bounded channel, preventing the worker @@ -180,14 +196,16 @@ impl DataImports { while self .batches .iter() - .filter(|batch| batch.phase == ImportPhase::Complete) + .filter(|batch| matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled)) .count() > 32 { let index = self .batches .iter() - .position(|batch| batch.phase == ImportPhase::Complete) + .position(|batch| { + matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled) + }) .expect("completed batch count exceeds the retention limit"); self.batches.remove(index); } @@ -219,6 +237,26 @@ impl DataImports { } impl PlotiumApp { + /// Stops active numerical work and discards queued imports; already imported data remains. + pub fn cancel_data_imports(&mut self) { + let mut imports = std::mem::take(&mut self.session.data_imports); + imports.open_intent = None; + imports.open_target = None; + let mut ids: Vec<_> = imports + .pending + .drain(..) + .map(|request| request.id) + .collect(); + if let Some(job) = imports.active.take() { + ids.push(job.id); + } + for id in ids { + imports.batch_mut(id).phase = ImportPhase::Cancelled; + } + imports.prune(); + self.session.data_imports = imports; + self.session.status = "Data import cancelled; previously imported data is retained.".into(); + } pub fn cancel_import_open_intent(&mut self) { self.session.data_imports.open_intent = None; self.session.data_imports.open_target = None; @@ -229,13 +267,9 @@ impl PlotiumApp { /// Continue an explicit synchronous import by showing its completed batch. pub fn request_import_results(&mut self, id: ImportBatchId) { - if self - .session - .data_imports - .batches - .iter() - .any(|batch| batch.id == id && batch.phase == ImportPhase::Complete) - { + if self.session.data_imports.batches.iter().any(|batch| { + batch.id == id && matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled) + }) { self.cancel_import_open_intent(); self.session.data_imports.open_target = Some(ImportOpenTarget::Batch(id)); } @@ -244,13 +278,9 @@ impl PlotiumApp { /// Continue a completed synchronous import by displaying its successful /// results together, just like an asynchronous foreground import. pub fn request_completed_import_open(&mut self, id: ImportBatchId) { - if self - .session - .data_imports - .batches - .iter() - .any(|batch| batch.id == id && batch.phase == ImportPhase::Complete) - { + if self.session.data_imports.batches.iter().any(|batch| { + batch.id == id && matches!(batch.phase, ImportPhase::Complete | ImportPhase::Cancelled) + }) { self.cancel_import_open_intent(); self.session.data_imports.open_intent = Some(id); self.session.data_imports.finish(id); @@ -380,6 +410,8 @@ impl PlotiumApp { let id = request.id; imports.batch_mut(id).phase = ImportPhase::Scanning; self.session.status = format!("Scanning {}…", request.recent.display()); + let cancellation = nmr::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); match std::thread::Builder::new() .name("data-import".into()) .spawn(move || { @@ -411,9 +443,20 @@ impl PlotiumApp { .map_or(1, usize::from) .min(4); if !prepare_paths(&discovered.paths, &sender, workers, total, &|path| { + let mut context = nmr::ExecutionContext::default() + .with_cancellation(worker_cancellation.clone()); + context + .check_cancelled() + .map_err(|error| error.to_string())?; plotium_io::load_path(path) .map_err(|error| error.to_string()) - .and_then(|loaded| PreparedImport::new(loaded, request.equal_scale)) + .and_then(|loaded| { + PreparedImport::new_with_context( + loaded, + request.equal_scale, + &mut context, + ) + }) }) { return; } @@ -421,7 +464,13 @@ impl PlotiumApp { // Document closure is normal cancellation, not an import failure. } }) { - Ok(_) => imports.active = Some(Job { receiver, id }), + Ok(_) => { + imports.active = Some(Job { + receiver, + id, + cancellation, + }) + } Err(error) => { let path = imports.batch_mut(id).recent.clone(); self.record_import_item( diff --git a/crates/core/src/state/data_import_tests.rs b/crates/core/src/state/data_import_tests.rs index 2991c3df..dfffa35a 100644 --- a/crates/core/src/state/data_import_tests.rs +++ b/crates/core/src/state/data_import_tests.rs @@ -61,7 +61,11 @@ fn app_with_channel() -> (PlotiumApp, mpsc::Sender) { .session .data_imports .add_batch("batch".into(), ImportPhase::Scanning, true); - app.session.data_imports.active = Some(Job { receiver, id }); + app.session.data_imports.active = Some(Job { + receiver, + id, + cancellation: nmr::CancellationToken::new(), + }); (app, sender) } @@ -397,3 +401,33 @@ fn background_append_preserves_dynamic_fit_to_all_intent() { super::super::ViewportMode::Fit(super::super::BoardFitTarget::AllFrames) ); } + +#[test] +fn cancelling_import_signals_worker_and_marks_batch_cancelled() { + let mut app = PlotiumApp::new(); + let id = app + .session + .data_imports + .add_batch("cancelled".into(), ImportPhase::Preparing, true); + let (sender, receiver) = std::sync::mpsc::channel(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/jcamp-ppm.dx"); + let prepared = PreparedImport::new(plotium_io::load_path(&path).unwrap(), true).unwrap(); + sender.send(Event::Item(path, Ok(prepared))).unwrap(); + let cancellation = nmr::CancellationToken::new(); + app.session.data_imports.active = Some(Job { + receiver, + id, + cancellation: cancellation.clone(), + }); + app.cancel_data_imports(); + assert!(cancellation.is_cancelled()); + assert!(!app.session.data_imports.is_pending()); + assert_eq!( + app.session.data_imports.batches().back().unwrap().phase, + ImportPhase::Cancelled + ); + assert!(app.take_import_open_target().is_none()); + assert!(!app.poll_data_import()); + assert!(app.doc.datasets.is_empty()); +} diff --git a/crates/core/src/state/datasets.rs b/crates/core/src/state/datasets.rs index 13de3d56..72c30bc4 100644 --- a/crates/core/src/state/datasets.rs +++ b/crates/core/src/state/datasets.rs @@ -256,6 +256,7 @@ impl NmrDataset { /// pre-phase cache; `processed` is the phased, display-ready result from it. #[derive(Clone)] pub struct Nmr2DDataset { + pub volume: Option, /// Stable automation and persistence identity. pub resource_id: DatasetId, /// Persisted child-field identity allocator and key mapping. @@ -431,6 +432,7 @@ impl Nmr2DDataset { ); attach_pseudo_trace_collection(&mut field_catalog, &data); let mut result = Self { + volume: None, resource_id: DatasetId::new(), field_catalog, data: Arc::new(data), @@ -479,6 +481,11 @@ impl Nmr2DDataset { } pub fn rebuild(&mut self) -> Result<(), String> { + if self.volume.is_some() { + return Err( + "Use the three-dimensional processing controls to adjust this spectrum".into(), + ); + } use plotium_processing::nmr_bridge::{DelayPolicy, RecipeRange}; let output = plotium_processing::nmr_execution::execute_2d( &self.native_base, @@ -498,6 +505,11 @@ impl Nmr2DDataset { } pub fn retransform(&mut self) -> Result<(), String> { + if self.volume.is_some() { + return Err( + "Use the three-dimensional processing controls to adjust this spectrum".into(), + ); + } use plotium_processing::nmr_bridge::{DelayPolicy, RecipeRange}; let mut work = plotium_processing::nmr_execution::processing_2d_work_ledger(); let mut context = nmr::ExecutionContext::new(&mut work); diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index 6a4b6ef6..a88d04d9 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -461,6 +461,7 @@ impl Dataset { ToolGroup::LineFit, ], Dataset::Nmr(_) => &[ToolGroup::Processing], + Dataset::Nmr2D(n) if n.volume.is_some() => &[ToolGroup::Processing], Dataset::Nmr2D(_) if self.supports_region_analysis() => &[ ToolGroup::Processing, ToolGroup::Nmr2dExperiment, diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 5873c9b0..97c9d255 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -35,6 +35,7 @@ mod app_impl_interaction; mod app_impl_io; mod app_impl_linefit; mod app_impl_multiplet; +mod app_impl_nmr_volume; mod app_impl_peaks; mod app_impl_project; mod app_impl_selection; @@ -98,6 +99,8 @@ pub use figure_edit::*; pub use plot_creation::*; mod dataset_trace; mod datasets; +mod nmr_volume; +pub use nmr_volume::NmrVolumeState; mod datasets_2d_figure; mod datasets_2d_maps; mod datasets_dispatch; diff --git a/crates/core/src/state/nmr_volume.rs b/crates/core/src/state/nmr_volume.rs new file mode 100644 index 00000000..2e6b7605 --- /dev/null +++ b/crates/core/src/state/nmr_volume.rs @@ -0,0 +1,137 @@ +//! A three-dimensional acquisition owns a reusable two-dimensional display plane. +use super::*; +use plotium_io::nmr_view::NmrSource; +use plotium_processing::nmr_volume::{NmrVolume, VolumePhase}; + +#[derive(Clone)] +pub struct NmrVolumeState { + pub source: NmrSource, + pub processed: NmrVolume, + pub fixed_axis: usize, + pub index: usize, + pub anchor: [usize; 3], + pub phases: [VolumePhase; 3], +} + +impl Nmr2DDataset { + pub fn load_volume(source: NmrSource) -> Result { + let phases = [VolumePhase::default(); 3]; + Self::load_volume_state(source, phases, 0, None) + } + + pub fn load_volume_state( + source: NmrSource, + phases: [VolumePhase; 3], + fixed_axis: usize, + index: Option, + ) -> Result { + Self::load_volume_state_with_context( + source, + phases, + fixed_axis, + index, + &mut nmr::ExecutionContext::default(), + ) + } + + pub fn load_volume_state_with_context( + source: NmrSource, + phases: [VolumePhase; 3], + fixed_axis: usize, + index: Option, + context: &mut nmr::ExecutionContext<'_>, + ) -> Result { + if fixed_axis >= 3 { + return Err("Select an acquisition axis between 0 and 2".into()); + } + let processed = NmrVolume::process(&source, phases, Default::default(), context)?; + let index = index.unwrap_or(processed.shape()[fixed_axis] / 2); + let plane = processed.plane_source(fixed_axis, index)?; + let mut dataset = Self::load_with_pipeline( + plane, + Some(Params2D { + layout: plotium_processing::Layout2D::Ft, + f1: AxisPipeline { steps: Vec::new() }, + f2: AxisPipeline { steps: Vec::new() }, + }), + Some(false), + None, + true, + )?; + dataset.acquisition_identity = source.identity(); + let mut anchor = processed.shape().map(|n| n / 2); + anchor[fixed_axis] = index; + dataset.volume = Some(NmrVolumeState { + source, + processed, + fixed_axis, + index, + phases, + anchor, + }); + Ok(dataset) + } + + pub fn select_volume_plane(&mut self, fixed_axis: usize, index: usize) -> Result<(), String> { + let volume = self + .volume + .as_ref() + .ok_or("Open a three-dimensional spectrum first")?; + let plane = volume.processed.plane_source(fixed_axis, index)?; + self.replace_volume_plane(plane)?; + let volume = self + .volume + .as_mut() + .ok_or("Three-dimensional spectrum disappeared")?; + volume.fixed_axis = fixed_axis; + volume.index = index; + volume.anchor[fixed_axis] = index; + Ok(()) + } + + pub fn set_volume_phases(&mut self, phases: [VolumePhase; 3]) -> Result<(), String> { + self.set_volume_phases_with_context(phases, &mut nmr::ExecutionContext::default()) + } + + pub fn set_volume_phases_with_context( + &mut self, + phases: [VolumePhase; 3], + context: &mut nmr::ExecutionContext<'_>, + ) -> Result<(), String> { + let volume = self + .volume + .as_ref() + .ok_or("Open a three-dimensional spectrum first")?; + let processed = NmrVolume::process(&volume.source, phases, Default::default(), context)?; + let plane = processed.plane_source(volume.fixed_axis, volume.index)?; + self.replace_volume_plane(plane)?; + let volume = self + .volume + .as_mut() + .ok_or("Three-dimensional spectrum disappeared")?; + volume.processed = processed; + volume.phases = phases; + Ok(()) + } + + fn replace_volume_plane(&mut self, plane: NmrSource) -> Result<(), String> { + let result = Self::load_with_pipeline( + plane, + Some(Params2D { + layout: plotium_processing::Layout2D::Ft, + f1: AxisPipeline { steps: Vec::new() }, + f2: AxisPipeline { steps: Vec::new() }, + }), + Some(false), + None, + true, + )?; + self.data = result.data; + self.native_base = result.native_base; + self.native_processed = result.native_processed; + self.base = result.base; + self.processed = result.processed; + self.processed_figure = result.processed_figure; + Ok(()) + } +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 5b644516..05402f44 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -41,6 +41,13 @@ pub use rename::{RenameState, RenameTarget}; #[path = "ui_state_text.rs"] mod text_edit; pub use text_edit::{PanelNoteEditState, PlotMenuPoint, PlotTextEditState, TextEditState}; +/// A desktop phase draft belongs to one dataset and one committed phase baseline. +#[derive(Clone)] +pub struct NmrVolumePhaseDraft { + pub baseline: [plotium_processing::nmr_volume::VolumePhase; 3], + pub phases: [plotium_processing::nmr_volume::VolumePhase; 3], +} + /// In-progress rubber-band while a shape Author tool draws a new object. `start` /// and `current` are page-space (pt) pointer positions. #[derive(Clone, Copy, Debug)] @@ -401,6 +408,8 @@ pub struct UiState { /// held in on-plot phase mode because a Phase step's editor is open. Edge- /// detected so a manual tool switch mid-phasing isn't fought each frame. pub phase_edit_active: bool, + pub nmr_volume_phase_busy: bool, + pub nmr_volume_phase_drafts: HashMap, /// When set, processing edits mutate the recipe but defer the recompute until /// the user presses Apply, keeping heavy transforms out of a rapid edit run. pub proc_paused: bool, @@ -570,6 +579,8 @@ impl Default for UiState { proc_expanded_step: None, reference_pick: None, phase_edit_active: false, + nmr_volume_phase_busy: false, + nmr_volume_phase_drafts: HashMap::new(), proc_paused: false, proc_pending: None, } diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 1edb6c6e..f3277de0 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -17,7 +17,8 @@ use std::time::Duration; mod dataset; pub use dataset::{ dataset_from_acquisition, dataset_from_acquisition_with_equal_scale_preference, - dataset_from_loaded_acquisition, dataset_title, + dataset_from_loaded_acquisition, dataset_from_loaded_acquisition_with_context, dataset_title, + load_dataset_with_context, }; #[path = "workflow/mass_spec_layout.rs"] mod mass_spec_layout; @@ -193,6 +194,13 @@ pub fn load_dataset_with_sampling( fn dataset_from_load_result( loaded: plotium_io::LoadResult, +) -> Result { + dataset_from_load_result_with_context(loaded, &mut nmr::ExecutionContext::default()) +} + +fn dataset_from_load_result_with_context( + loaded: plotium_io::LoadResult, + context: &mut nmr::ExecutionContext<'_>, ) -> Result { let mut inspection = inspection_report( loaded.format, @@ -201,8 +209,12 @@ fn dataset_from_load_result( &loaded.acquisition, ); let (acquisition, acquisition_identity, _, _, _) = loaded.into_parts(); - let (dataset, source) = - dataset_from_loaded_acquisition(acquisition, acquisition_identity, true)?; + let (dataset, source) = dataset_from_loaded_acquisition_with_context( + acquisition, + acquisition_identity, + true, + context, + )?; if let Some(warning) = dataset .as_nmr2d() .and_then(|data| data.reconstruction_warning.as_ref()) diff --git a/crates/core/src/workflow/dataset.rs b/crates/core/src/workflow/dataset.rs index e0e7868c..0f2ddf38 100644 --- a/crates/core/src/workflow/dataset.rs +++ b/crates/core/src/workflow/dataset.rs @@ -43,6 +43,9 @@ fn convert_acquisition( ) .map_err(WorkflowError::Nmr)?, )), + 3 => Dataset::Nmr2D(Box::new( + Nmr2DDataset::load_volume(data).map_err(WorkflowError::Nmr)?, + )), rank => { return Err(WorkflowError::Nmr(format!( "Plotium does not yet display rank-{rank} NMR data" @@ -135,3 +138,38 @@ pub fn dataset_title(dataset: &Dataset) -> String { .unwrap_or_else(|| short_name(&data.experiment.source)), } } + +/// Shared importer seam for cancellable three-dimensional processing. +pub fn dataset_from_loaded_acquisition_with_context( + acquisition: Acquisition, + identity: plotium_io::AcquisitionIdentity, + equal_scale: bool, + context: &mut nmr::ExecutionContext<'_>, +) -> Result<(Dataset, String), WorkflowError> { + if let Acquisition::Nmr(data) = &acquisition + && data.axes().len() == 3 + { + let source = data.source().to_owned(); + let mut dataset = Dataset::Nmr2D(Box::new( + Nmr2DDataset::load_volume_state_with_context( + data.clone(), + [Default::default(); 3], + 0, + None, + context, + ) + .map_err(WorkflowError::Nmr)?, + )); + dataset.set_acquisition_identity(identity); + return Ok((dataset, source)); + } + dataset_from_loaded_acquisition(acquisition, identity, equal_scale) +} + +/// Load through the ordinary importer with cancellation during rank-three transforms. +pub fn load_dataset_with_context( + path: &Path, + context: &mut nmr::ExecutionContext<'_>, +) -> Result { + dataset_from_load_result_with_context(plotium_io::load_path(path)?, context) +} diff --git a/crates/io/tests/fixtures/nmr/bruker-3d-qf/README.md b/crates/io/tests/fixtures/nmr/bruker-3d-qf/README.md new file mode 100644 index 00000000..0f313b17 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-3d-qf/README.md @@ -0,0 +1,11 @@ +# Synthetic dense 3D Bruker fixture + +This fixture contains artificial integer signals, not experimental acquisition +data. Its logical shape is 5 × 3 × 4, with QF indirect dimensions and a complex +direct dimension. `ser` stores little-endian i32 real/imaginary pairs without +block padding; the group delay is zero. + +The unequal dimensions exercise axis ordering, plane bounds, signed transforms, +complex retention and phase adjustment. Processing tests independently decode +`ser` and evaluate direct DFT/cosine sums for all 60 logical points. Core, desktop, +CLI and MCP tests reuse the same fixture for navigation and project roundtrips. diff --git a/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu2s b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu2s new file mode 100644 index 00000000..a00514f5 --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu2s @@ -0,0 +1,9 @@ +##TITLE=Plotium synthetic dense 3D QF acquisition +##$TD= 3 +##$SFO1= 400 +##$BF1= 400 +##$O1= 2000 +##$SW_h= 4000 +##$NUC1= <15N> +##$FnMODE= 1 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu3s b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu3s new file mode 100644 index 00000000..a3e4f28d --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqu3s @@ -0,0 +1,9 @@ +##TITLE=Plotium synthetic dense 3D QF acquisition +##$TD= 5 +##$SFO1= 400 +##$BF1= 400 +##$O1= 2000 +##$SW_h= 4000 +##$NUC1= <1H> +##$FnMODE= 1 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqus b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqus new file mode 100644 index 00000000..a29cb4bf --- /dev/null +++ b/crates/io/tests/fixtures/nmr/bruker-3d-qf/acqus @@ -0,0 +1,16 @@ +##TITLE=Plotium synthetic dense 3D QF acquisition +##$TD= 8 +##$SFO1= 400 +##$BF1= 400 +##$O1= 2000 +##$SW_h= 4000 +##$NUC1= <1H> +##$PARMODE= 2 +##$AQSEQ= 0 +##$FnTYPE= 0 +##$GO_block_size= +##$AQ_mod= 3 +##$BYTORDA= 0 +##$DTYPA= 0 +##$GRPDLY= 0 +##END= diff --git a/crates/io/tests/fixtures/nmr/bruker-3d-qf/ser b/crates/io/tests/fixtures/nmr/bruker-3d-qf/ser new file mode 100644 index 0000000000000000000000000000000000000000..c5e37cfeb3682950f28c23d042cd3a75f7516a96 GIT binary patch literal 480 zcmXxgJxW7C6vgpV1R^3ND5eO6AVFVa>q6XwYseC^hHN4WNt+^(B=~`aDDpsvh{ehL z8PBVJ@6OD~5&n~3fy}i1so_qH$Kc<%b`0Lx=Jlf5*gwM|; zOby@NNaGXf`Nzh~jg6_{-J9JVNxv`ElVLSAJReJ^@AhF({qR&x4PSQ3tkSnBz3|6h zxl1uMe806vt^L}Q@M0oiYMB1?rXQwf=3&lcf6k{L=b4##nV; 2], + /// Row major, second axis varies fastest. + pub signed_values: Vec, +} + +impl NmrVolume { + pub fn process_source(source: &NmrSource) -> Result { + Self::process( + source, + [VolumePhase::default(); 3], + ProcessingOptions::default(), + &mut ExecutionContext::default(), + ) + } + + pub fn process( + source: &NmrSource, + phases: [VolumePhase; 3], + options: ProcessingOptions, + context: &mut ExecutionContext<'_>, + ) -> Result { + if source.axes().len() != 3 { + return Err("3D processing requires three axes".into()); + } + let mut operations = if let Some(raw) = source.dataset().as_raw() { + recipe(raw) + } else { + Vec::new() + }; + for (axis, phase) in phases.iter().enumerate() { + PhaseCorrection::new(phase.p0_degrees, phase.p1_degrees, phase.pivot_fraction) + .map_err(|e| e.to_string())?; + if phase.p0_degrees != 0.0 || phase.p1_degrees != 0.0 { + operations.push(ProcessingOperation::PhaseCorrection { + axis, + correction: PhaseCorrection::new( + phase.p0_degrees, + phase.p1_degrees, + phase.pivot_fraction, + ) + .map_err(|e| e.to_string())?, + }); + } + } + let output = ProcessingPlan::new(operations) + .and_then(|plan| plan.apply_with_context(source.dataset(), options, context)) + .map_err(|e| e.to_string())?; + Self::from_processed(NmrSource::new(Arc::new(output)).map_err(|e| e.to_string())?) + } + + pub fn from_processed(source: NmrSource) -> Result { + let spectrum = source + .dataset() + .as_processed() + .ok_or("expected processed 3D spectrum")?; + let shape: [usize; 3] = spectrum + .descriptor() + .logical_shape() + .try_into() + .map_err(|_| "expected three axes")?; + if spectrum.descriptor().axes().iter().any(|axis| { + axis.domain() != AxisDomain::Frequency || axis.unit() != Some(AxisUnit::Ppm) + }) { + return Err("expected three frequency axes in ppm".into()); + } + Ok(Self { source, shape }) + } + + pub fn source(&self) -> &NmrSource { + &self.source + } + pub fn processed(&self) -> &ProcessedDataset { + self.source + .dataset() + .as_processed() + .expect("validated volume") + } + pub fn axes(&self) -> &[ProcessedAxis] { + self.processed().descriptor().axes() + } + + pub fn plane_source(&self, fixed_axis: usize, index: usize) -> Result { + if fixed_axis >= 3 || index >= self.shape[fixed_axis] { + return Err("plane is outside the spectrum".into()); + } + // nmr 0.1.3 native reductions accept rank two only. This checked host + // boundary records the fixed real component and surviving axis lineage. + let remaining: Vec<_> = (0..3).filter(|axis| *axis != fixed_axis).collect(); + let descriptor = nmr::processed::ProcessedDescriptor::new( + remaining + .iter() + .map(|axis| self.axes()[*axis].clone()) + .collect(), + ) + .map_err(|e| e.to_string())?; + let mut samples = Vec::new(); + let mut position = [0; 3]; + position[fixed_axis] = index; + let mut component = [0; 3]; + for row in 0..self.shape[remaining[0]] { + position[remaining[0]] = row; + for rlane in 0..self.axes()[remaining[0]].component_count() { + component[remaining[0]] = rlane; + for col in 0..self.shape[remaining[1]] { + position[remaining[1]] = col; + for clane in 0..self.axes()[remaining[1]].component_count() { + component[remaining[1]] = clane; + samples.push( + self.processed() + .data() + .get(&position, &component) + .map_err(|e| e.to_string())?, + ); + } + } + } + } + let axes = remaining + .iter() + .map(|axis| { + nmr::external::ExternalAxisSource::Parent(nmr::provenance::InputAxisRef::new( + nmr::provenance::InputSlot::new(0), + *axis, + )) + }) + .collect(); + let declaration = nmr::external::ExternalAlgorithmDeclaration::new( + "plotium.volume-plane", + "1", + "Indexed plane retaining surviving Cartesian components; fixed component zero", + ) + .and_then(|d| { + d.with_parameters( + "application/json;version=1", + format!("{{\"axis\":{fixed_axis},\"index\":{index},\"component\":0}}").into_bytes(), + ) + }) + .map_err(|e| e.to_string())?; + let output = self + .source + .dataset() + .derive_external_processed(descriptor, samples, axes, declaration) + .map_err(|e| e.to_string())?; + NmrSource::new(Arc::new(output)).map_err(|e| e.to_string()) + } + + pub fn axis_coordinate(&self, axis: usize, index: usize) -> Result { + self.axes() + .get(axis) + .ok_or("invalid axis")? + .coordinate(index) + .map_err(|e| e.to_string()) + } + + pub fn nearest(&self, axis: usize, ppm: f64) -> Result { + if axis >= 3 || !ppm.is_finite() { + return Err("invalid plane coordinate".into()); + } + let coordinates = self.source.axes()[axis] + .coordinate_values() + .map_err(|e| e.to_string())?; + let low = coordinates.iter().copied().fold(f64::INFINITY, f64::min); + let high = coordinates + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + if ppm < low || ppm > high { + return Err(format!("Enter a coordinate between {low} and {high} ppm")); + } + coordinates + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| (**a - ppm).abs().total_cmp(&(**b - ppm).abs())) + .map(|(i, _)| i) + .ok_or("empty axis".into()) + } + + pub fn shape(&self) -> [usize; 3] { + self.shape + } + + pub fn signed_value(&self, indices: [usize; 3]) -> Result { + self.processed() + .data() + .get(&indices, &[0, 0, 0]) + .map_err(|e| e.to_string()) + } + + pub fn ppm(&self, axis: Axis3d, index: usize) -> Result { + self.axes()[axis as usize] + .coordinate(index) + .map_err(|e| e.to_string()) + } + + pub fn slice(&self, fixed_axis: Axis3d, fixed_index: usize) -> Result { + let fixed_ppm = self.ppm(fixed_axis, fixed_index)?; + let axes = match fixed_axis { + Axis3d::F1 => [Axis3d::F2, Axis3d::F3], + Axis3d::F2 => [Axis3d::F1, Axis3d::F3], + Axis3d::F3 => [Axis3d::F1, Axis3d::F2], + }; + let [first, second] = axes.map(|a| a as usize); + let mut indices = [0; 3]; + indices[fixed_axis as usize] = fixed_index; + let mut signed_values = Vec::with_capacity(self.shape[first] * self.shape[second]); + for i in 0..self.shape[first] { + indices[first] = i; + for j in 0..self.shape[second] { + indices[second] = j; + signed_values.push(self.signed_value(indices)?); + } + } + let coordinates = |axis| { + (0..self.shape[axis as usize]) + .map(|i| self.ppm(axis, i)) + .collect::, _>>() + }; + Ok(NmrPlane { + fixed_axis, + fixed_index, + fixed_ppm, + axes, + ppm: [coordinates(axes[0])?, coordinates(axes[1])?], + signed_values, + }) + } +} + +fn recipe(raw: &nmr::raw::RawDataset) -> Vec { + let mut operations = Vec::new(); + for (axis, description) in raw.descriptor().axes().iter().enumerate() { + if matches!( + description.kind(), + nmr::raw::RawAxisKind::Indirect(nmr::raw::IndirectComponents::Encoded(_)) + ) { + operations.push(ProcessingOperation::ComponentTransform { axis }); + } + } + for axis in (0..3).rev() { + operations.push(ProcessingOperation::FourierTransform { + axis, + transform: FourierTransform::new(FourierExponentSign::Negative), + }); + if axis == 2 + && matches!( + raw.descriptor().axes()[axis].group_delay(), + nmr::acquisition::GroupDelayState::Pending(delay) if delay.delay_points() > 0.0 + ) + { + operations.push(ProcessingOperation::DigitalFilterCorrection { + axis, + correction: DigitalFilterCorrection::FrequencyDomainPhaseRampV1( + DelaySource::AxisEvidence, + ), + }); + } + operations.push(ProcessingOperation::ResolveFrequencyFrame { + axis, + frame: FrequencyFrame::Ppm(ReferenceSource::AxisEvidence), + }); + } + operations +} + +/// Phase convention follows nmr: exp(+i phase), position i/N. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct VolumePhase { + pub p0_degrees: f64, + pub p1_degrees: f64, + pub pivot_fraction: f64, +} +impl Default for VolumePhase { + fn default() -> Self { + Self { + p0_degrees: 0.0, + p1_degrees: 0.0, + pivot_fraction: 0.5, + } + } +} diff --git a/crates/processing/tests/nmr_volume.rs b/crates/processing/tests/nmr_volume.rs new file mode 100644 index 00000000..54461f95 --- /dev/null +++ b/crates/processing/tests/nmr_volume.rs @@ -0,0 +1,176 @@ +use std::path::PathBuf; + +use nmr::ExecutionContext; +use nmr::processing::ProcessingOptions; +use plotium_processing::nmr_volume::{Axis3d, NmrVolume, VolumePhase}; +fn source(path: &std::path::Path) -> plotium_io::nmr_view::NmrSource { + plotium_io::nmr_view::NmrSource::new( + plotium_io::nmr_bridge::read(path, &mut ExecutionContext::default()).unwrap(), + ) + .unwrap() +} + +fn fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../io/tests/fixtures/nmr/bruker-3d-qf") +} + +#[test] +fn processes_unequal_axes_and_preserves_indexed_signed_peaks() { + let volume = NmrVolume::process( + &source(&fixture()), + [VolumePhase::default(); 3], + ProcessingOptions::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + assert_eq!(volume.shape(), [5, 3, 4]); + assert!(volume.processed().provenance().history().is_some()); + for fixed in Axis3d::ALL { + let plane = volume.slice(fixed, 1).unwrap(); + let expected: Vec<_> = Axis3d::ALL + .into_iter() + .filter(|axis| *axis != fixed) + .collect(); + assert_eq!(plane.axes.as_slice(), expected); + assert_eq!(plane.fixed_ppm, volume.ppm(fixed, 1).unwrap()); + assert!(volume.slice(fixed, volume.shape()[fixed as usize]).is_err()); + for (i, x) in plane.ppm[0].iter().enumerate() { + assert_eq!(*x, volume.ppm(plane.axes[0], i).unwrap()); + for (j, y) in plane.ppm[1].iter().enumerate() { + assert_eq!(*y, volume.ppm(plane.axes[1], j).unwrap()); + let mut indices = [0; 3]; + indices[fixed as usize] = 1; + indices[plane.axes[0] as usize] = i; + indices[plane.axes[1] as usize] = j; + assert_eq!( + plane.signed_values[i * plane.ppm[1].len() + j], + volume.signed_value(indices).unwrap(), + ); + } + } + } + let mut strongest = ([0; 3], 0.0_f64); + for a in 0..5 { + for b in 0..3 { + for c in 0..4 { + let indices = [a, b, c]; + let intensity = volume.signed_value(indices).unwrap(); + if intensity.abs() > strongest.1.abs() { + strongest = (indices, intensity); + } + } + } + } + assert!(strongest.1 < 0.0); +} + +#[test] +fn rejects_two_dimensional_inputs_and_cancellation() { + let two_dimensional = fixture().with_file_name("bruker-states"); + assert!( + NmrVolume::process( + &source(&two_dimensional), + [VolumePhase::default(); 3], + ProcessingOptions::default(), + &mut ExecutionContext::default(), + ) + .is_err() + ); + let mut context = ExecutionContext::default(); + context.cancellation().cancel(); + assert!( + NmrVolume::process( + &source(&fixture()), + [VolumePhase::default(); 3], + ProcessingOptions::default(), + &mut context, + ) + .is_err() + ); +} + +#[test] +fn retains_complex_lanes_and_phases_before_slice() { + let input = source(&fixture()); + let original = NmrVolume::process_source(&input).unwrap(); + assert!(original.axes().iter().any(|a| a.component_count() > 1)); + let mut phases = [VolumePhase::default(); 3]; + phases[2].p0_degrees = 180.0; + let inverted = NmrVolume::process( + &input, + phases, + ProcessingOptions::default(), + &mut ExecutionContext::default(), + ) + .unwrap(); + for a in 0..5 { + for b in 0..3 { + for c in 0..4 { + assert!( + (original.signed_value([a, b, c]).unwrap() + + inverted.signed_value([a, b, c]).unwrap()) + .abs() + < 1e-8 + ); + } + } + } + for axis in 0..3 { + let plane = original.plane_source(axis, 1).unwrap(); + assert_eq!(plane.axes().len(), 2); + let slice = original.slice(Axis3d::ALL[axis], 1).unwrap(); + let data = plane.dataset().as_processed().unwrap(); + for (i, value) in data.data().component_plane(&[0, 0]).unwrap().enumerate() { + assert_eq!(*value, slice.signed_values[i]); + } + } + assert!(original.nearest(0, f64::NAN).is_err()); + assert!(original.nearest(3, 0.0).is_err()); +} + +#[test] +fn signed_volume_matches_independent_direct_dft() { + use std::f64::consts::TAU; + let input = std::fs::read(fixture().join("ser")).unwrap(); + let integers: Vec = input + .as_chunks::<4>() + .0 + .iter() + .map(|bytes| i32::from_le_bytes(*bytes) as f64) + .collect(); + let volume = NmrVolume::process_source(&source(&fixture())).unwrap(); + let shape = volume.shape(); + for a in 0..shape[0] { + for b in 0..shape[1] { + for c in 0..shape[2] { + let frequencies = [ + a as isize - (shape[0] / 2) as isize, + b as isize - (shape[1] / 2) as isize, + c as isize - (shape[2] / 2) as isize, + ]; + let mut expected = nmr::Complex64::new(0.0, 0.0); + for i in 0..shape[0] { + for j in 0..shape[1] { + for k in 0..shape[2] { + let offset = ((i * shape[1] + j) * shape[2] + k) * 2; + let value = nmr::Complex64::new(integers[offset], integers[offset + 1]); + // QF indirect axes are scalar: each contributes its cosine + // transform independently to each direct Cartesian lane. + let angle = -TAU * frequencies[2] as f64 * k as f64 / shape[2] as f64; + let weight = (TAU * frequencies[0] as f64 * i as f64 / shape[0] as f64) + .cos() + * (TAU * frequencies[1] as f64 * j as f64 / shape[1] as f64).cos(); + expected += value * nmr::Complex64::from_polar(weight, angle); + } + } + } + assert!( + (volume.signed_value([a, b, c]).unwrap() - expected.re).abs() < 1e-8, + "{a} {b} {c}: {} vs {}", + volume.signed_value([a, b, c]).unwrap(), + expected.re + ); + } + } + } +} diff --git a/docs/src/content/docs/guides/nmr-3d.md b/docs/src/content/docs/guides/nmr-3d.md new file mode 100644 index 00000000..6f30baac --- /dev/null +++ b/docs/src/content/docs/guides/nmr-3d.md @@ -0,0 +1,43 @@ +--- +title: Three-dimensional NMR spectra +description: Open, process and navigate a three-dimensional NMR acquisition. +--- + +Open a supported dense Bruker 3D experiment directory with the normal file-open +or drop action. Plotium processes the raw acquisition in the background and +shows a two-dimensional frequency plane in the ordinary workspace. Opening an +explicit processed file respects that selection; an extracted 2D spectrum remains +2D and does not stand in for a complete 3D acquisition. + +Use the plane controls beside the spectrum to choose the fixed acquisition axis, +step through planes, or enter a chemical shift in ppm. Nuclear labels include the +acquisition dimension to distinguish experiments with two proton axes. A ppm +entry selects the nearest sampled plane and displays its actual coordinate. +Changing the plane reuses the processed volume. Existing zoom and contour settings +remain stable while browsing. Changing orientation retains the position on each +acquisition axis. + +Positive and negative contours retain their sign. Adjust contour sensitivity with +the existing controls. Basic processing does not establish that the spectrum has +an experimentally verified absorptive phase. Use the per-axis phase controls to +adjust zero-order phase, first-order phase and pivot; apply explicitly and cancel +unwanted processing. Complex components remain available for phase adjustment. + +Save a normal `.plotium` project to retain the acquisition snapshot, processing +parameters and current plane. The project can reopen without the original +experiment directory. The first implementation targets dense 3D acquisitions; +it does not add 3D NUS reconstruction, 4D browsing or protein assignments. + +## Automation + +The CLI tool registry and MCP use the same core operations as the desktop. +Import through `data.import`. Inspect acquisition dimensions and phase settings +through `processing.inspect`. Select a plane through `nmr.volume.edit`, with +zero-based `fixed_axis` and `index`. Its optional `phases` array contains three +objects with `p0_degrees`, `p1_degrees` and `pivot_fraction` (0–1). Inspect the +operation schema before invocation. Use stable dataset IDs and current document +revisions. Subsequent data preview and rendering address the current plane. + +Generic numerical table export is unavailable for 3D volumes because the current +2D table format cannot preserve the fixed coordinate and acquisition-axis +identities. Save the project to retain the data, or export the figure for display. diff --git a/docs/src/content/docs/reference/mcp/scientific.md b/docs/src/content/docs/reference/mcp/scientific.md index 4f6782b0..5356bb38 100644 --- a/docs/src/content/docs/reference/mcp/scientific.md +++ b/docs/src/content/docs/reference/mcp/scientific.md @@ -4,7 +4,7 @@ description: Run processing, diffusion fitting and DOSY through MCP, and plot th --- These operations are the ones the desktop runs for -[processing](/guides/processing/) and [DOSY](/guides/dosy/). The command line +[processing](/guides/processing/), [3D NMR](/guides/nmr-3d/) and [DOSY](/guides/dosy/). The command line runs them too, with [`plotium-cli tool run`](/reference/cli/#single-step-scientific-operations). ## Scientific operations and tasks @@ -21,8 +21,9 @@ Scientific edits use `plotium_plan` / `plotium_execute`: | Operation | Parameters and target | | --- | --- | | `xas.merge` | Two or more XAS datasets in `ids`, first one sets the energy grid; takes `{}`. Aligns on reference foils when each scan has a usable one, otherwise preflight and `value.reason` say why not. Adds a dataset and canvas in one Undo step | -| `processing.edit` | One NMR, XRD, XPS or XAS dataset; `edits` uses `replace`, `enable`, `add`, `remove` or `move` on stable StepIds; XPS `add` names a region ID as `axis` and takes `Window`, `Smooth` or `Normalize`; XRD uses `set_xrd` with complete `processing` settings, plus optional `axis` and `wavelength_angstrom`; XAS uses `set_xas` with `signal` and complete `processing` settings copied from `processing.inspect`, or `apply_xas_preset` with `athena` or `larch` | +| `processing.edit` | One 1D/2D NMR, XRD, XPS or XAS dataset; `edits` uses `replace`, `enable`, `add`, `remove` or `move` on stable StepIds; XPS `add` names a region ID as `axis` and takes `Window`, `Smooth` or `Normalize`; XRD uses `set_xrd` with complete `processing` settings, plus optional `axis` and `wavelength_angstrom`; XAS uses `set_xas` with `signal` and complete `processing` settings copied from `processing.inspect`, or `apply_xas_preset` with `athena` or `larch` | | `processing.apply_scheme` | `path`, optional `compatible_only`; preflight freezes scheme bytes, fingerprint and compatible target configurations; a scheme applies to datasets of its own kind, and an XPS scheme replaces the active region's recipe | +| `nmr.volume.edit` | One 3D NMR dataset; zero-based `fixed_axis` and `index`; optional `phases` is three objects with `p0_degrees`, `p1_degrees` and `pivot_fraction` (0–1). Plane navigation reuses the processed volume; phase edits run as background tasks. Inspect original axes and phase settings with `plotium_processing_inspect` | | `diffusion.set_input` | One dataset; complete `input`; invalid input errors include a structured validation report | | `analysis.regions.extract` | One region-series field; `regions` references RegionIds or explicit bounds and metrics; optional explicit `input` | | `analysis.diffusion.fit` | One native table; stable `response_column` ColumnId, optional native `options`; the x column must use `s/m²` | @@ -35,7 +36,8 @@ are `Area`, `Mean`, `Max` and `Min`. Extraction creates an independent native ta with source evidence, region-to-column correspondence and diffusion increment bindings. It does not create a plot. -Except for input updates, these edits return `ToolResult.value.task`. Preflight +Input updates, scan merges and plane-only volume edits are synchronous. Other +edits return `ToolResult.value.task`. Preflight allocates `task_id`; `queued` means registered. Query with `plotium_tasks_get({task_id})` or `plotium_tasks_list({offset,limit})`; cancel with `plotium_tasks_cancel({task_id})` (modify permission required). States are diff --git a/docs/src/content/docs/zh-cn/guides/nmr-3d.md b/docs/src/content/docs/zh-cn/guides/nmr-3d.md new file mode 100644 index 00000000..0361ea2b --- /dev/null +++ b/docs/src/content/docs/zh-cn/guides/nmr-3d.md @@ -0,0 +1,35 @@ +--- +title: 三维 NMR 谱 +description: 打开、处理和浏览三维 NMR 采集数据。 +--- + +通过常规打开或拖放入口导入受支持的 Bruker 常规采样三维实验目录。 +Plotium 在后台处理原始数据,并在普通工作区显示二维频率平面。 +明确选择处理后的文件时尊重该选择;提取出的二维谱仍按二维数据打开, +不会替代完整三维采集。 + +使用谱图旁的平面控件选择固定采集轴、逐层浏览或输入 ppm。 +核种标签包含采集维度,以区分具有两个氢轴的实验。 +输入 ppm 后选择最近的采样平面,并显示实际坐标。 +切层复用处理后的体数据,保留缩放和等高线设置;切换方向时保留各采集轴的位置。 + +正负等高线保留信号符号,灵敏度通过已有控件调整。 +基础处理不代表已获得经过实验确认的吸收相位。 +可逐轴调整零阶相位、一阶相位和枢轴,再明确应用;不需要的处理可以取消。 +复数分量保留用于相位调整。 + +保存普通 `.plotium` 项目会保留采集快照、处理参数和当前平面。 +重新打开项目不要求原始实验目录仍可访问。 +首版面向常规采样三维数据,不扩展三维 NUS 重建、四维浏览或蛋白指认。 + +## 自动化 + +CLI 工具目录和 MCP 与桌面使用相同核心操作。 +通过 `data.import` 导入,通过 `processing.inspect` 查看维度和相位参数。 +`nmr.volume.edit` 使用从零开始的 `fixed_axis` 和 `index` 选择平面。 +可选的 `phases` 数组包含三个对象,各有 `p0_degrees`、`p1_degrees` +和 `pivot_fraction`(0–1)。调用前查看操作 schema,并使用稳定的数据集 ID +和当前文档版本。之后的数据预览和渲染面向当前平面。 + +三维体数据暂不提供通用数值表格导出,因为现有二维表格格式无法保留固定坐标 +和采集轴身份。保存项目可保留数据,展示用途可导出图像。 diff --git a/docs/src/content/docs/zh-cn/reference/mcp/scientific.md b/docs/src/content/docs/zh-cn/reference/mcp/scientific.md index b4eb7a51..f871aeda 100644 --- a/docs/src/content/docs/zh-cn/reference/mcp/scientific.md +++ b/docs/src/content/docs/zh-cn/reference/mcp/scientific.md @@ -3,7 +3,7 @@ title: MCP 科学运算 description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图。 --- -这些操作与桌面端的[处理](/zh-cn/guides/processing/)和 [DOSY](/zh-cn/guides/dosy/) +这些操作与桌面端的[处理](/zh-cn/guides/processing/)、[三维 NMR](/zh-cn/guides/nmr-3d/) 和 [DOSY](/zh-cn/guides/dosy/) 相同,命令行也可以通过 [`plotium-cli tool run`](/zh-cn/reference/cli/#单步科学操作)运行。 ## 科学操作与任务 @@ -19,8 +19,9 @@ description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图 | 操作 | 参数与目标 | | --- | --- | | `xas.merge` | `ids` 中给出两个或更多 XAS 数据集,第一个决定能量网格;参数为 `{}`。每条扫描都有可用参考箔时按参考对齐,否则 preflight 和 `value.reason` 会说明原因。一步撤销可移除新增的数据集和画布 | -| `processing.edit` | 一个 NMR、XRD、XPS 或 XAS 数据集;`edits` 中对稳定 StepId 使用 `replace`、`enable`、`add`、`remove` 或 `move`;XPS 的 `add` 以区域 ID 作为 `axis`,步骤为 `Window`、`Smooth` 或 `Normalize`;XRD 使用 `set_xrd`,给出完整的 `processing` 设置,可选 `axis` 和 `wavelength_angstrom`;XAS 使用 `set_xas`,给出 `signal` 和从 `processing.inspect` 复制的完整 `processing` 设置,或用 `apply_xas_preset` 选择 `athena` 或 `larch` | +| `processing.edit` | 一个一维/二维 NMR、XRD、XPS 或 XAS 数据集;`edits` 中对稳定 StepId 使用 `replace`、`enable`、`add`、`remove` 或 `move`;XPS 的 `add` 以区域 ID 作为 `axis`,步骤为 `Window`、`Smooth` 或 `Normalize`;XRD 使用 `set_xrd`,给出完整的 `processing` 设置,可选 `axis` 和 `wavelength_angstrom`;XAS 使用 `set_xas`,给出 `signal` 和从 `processing.inspect` 复制的完整 `processing` 设置,或用 `apply_xas_preset` 选择 `athena` 或 `larch` | | `processing.apply_scheme` | `path`,可选 `compatible_only`;方案字节、指纹和兼容目标配置在预检时冻结;方案只应用于同类数据集,XPS 方案替换当前区域的配方 | +| `nmr.volume.edit` | 一个三维 NMR 数据集;`fixed_axis` 和 `index` 从零开始;可选 `phases` 包含三个对象,各有 `p0_degrees`、`p1_degrees` 和 `pivot_fraction`(0–1)。切层复用体数据,相位调整作为后台任务执行。通过 `plotium_processing_inspect` 查看原始轴和相位设置 | | `diffusion.set_input` | 一个数据集;完整 `input`;非法输入错误带结构化校验报告 | | `analysis.regions.extract` | 一个支持区域序列的字段;`regions` 引用已有 RegionId 或给出边界和指标;可选显式 `input` | | `analysis.diffusion.fit` | 一个原生表格;`response_column` 为稳定 ColumnId;可选原生 `options`;x 列单位必须为 `s/m²` | @@ -32,7 +33,8 @@ description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图 `Mean`、`Max` 和 `Min`。区域提取生成独立原生表格,保留来源、区域到列的 对应及扩散增量证据,不自动创建绘图。 -除输入设置外,上述写操作返回 `ToolResult.value.task`。预检即分配 +输入设置、扫描合并和仅切层的三维操作同步执行;其他写操作返回 +`ToolResult.value.task`。预检即分配 `task_id`;`queued` 只表示已登记。使用 `plotium_tasks_get({task_id})`、 `plotium_tasks_list({offset,limit})` 查询,使用 `plotium_tasks_cancel({task_id})` 取消(需要修改权限)。状态为