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 00000000..c5e37cfe Binary files /dev/null and b/crates/io/tests/fixtures/nmr/bruker-3d-qf/ser differ diff --git a/crates/processing/src/lib.rs b/crates/processing/src/lib.rs index ea1777ab..422ae39d 100644 --- a/crates/processing/src/lib.rs +++ b/crates/processing/src/lib.rs @@ -5,6 +5,7 @@ pub mod arithmetic; pub mod craft; pub mod nmr_bridge; pub mod nmr_execution; +pub mod nmr_volume; mod output; pub mod signal; pub mod slice; diff --git a/crates/processing/src/nmr_volume.rs b/crates/processing/src/nmr_volume.rs new file mode 100644 index 00000000..d13dbfa9 --- /dev/null +++ b/crates/processing/src/nmr_volume.rs @@ -0,0 +1,315 @@ +//! Signed 3D frequency spectra and indexed slices for supported Bruker inputs. +use plotium_io::nmr_view::NmrSource; +use std::sync::Arc; + +use nmr::ExecutionContext; +use nmr::axis::{AxisDomain, AxisUnit}; +use nmr::processed::{ProcessedAxis, ProcessedDataset}; +use nmr::processing::{ + DelaySource, DigitalFilterCorrection, FourierExponentSign, FourierTransform, FrequencyFrame, + PhaseCorrection, ProcessingOperation, ProcessingOptions, ProcessingPlan, ReferenceSource, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum Axis3d { + F1 = 0, + F2 = 1, + F3 = 2, +} + +impl Axis3d { + pub const ALL: [Self; 3] = [Self::F1, Self::F2, Self::F3]; +} + +#[derive(Clone, Debug)] +pub struct NmrVolume { + source: NmrSource, + shape: [usize; 3], +} + +pub struct NmrPlane { + pub fixed_axis: Axis3d, + pub fixed_index: usize, + pub fixed_ppm: f64, + /// In F1/F2/F3 order with the fixed axis omitted. + pub axes: [Axis3d; 2], + pub ppm: [Vec; 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})` 取消(需要修改权限)。状态为