diff --git a/crates/agent/src/capabilities.rs b/crates/agent/src/capabilities.rs index 13f8e4d8..355da879 100644 --- a/crates/agent/src/capabilities.rs +++ b/crates/agent/src/capabilities.rs @@ -4,13 +4,14 @@ use plotium_core::automation::{ }; use serde_json::{Value, json}; -pub(crate) const EDIT_OPERATIONS: [&str; 27] = [ +pub(crate) const EDIT_OPERATIONS: [&str; 28] = [ "analysis.diffusion.plot", "plot.field.create", "analysis.dosy.build", "analysis.regions.extract", "analysis.diffusion.fit", "nmr.volume.edit", + "nmr.peaks3d", "processing.edit", "processing.apply_scheme", "diffusion.set_input", @@ -36,7 +37,9 @@ pub(crate) const EDIT_OPERATIONS: [&str; 27] = [ /// One line per editable operation, so a caller can map a task to an operation /// before fetching its schema. -const PURPOSES: [(&str, &str); 30] = [ +const PURPOSES: [(&str, &str); 32] = [ + ("nmr.peaks3d", "Add, edit, delete or locate manual 3D peaks"), + ("nmr.peaks3d.list", "Read with plotium_peaks3d_inspect"), ( "nmr.volume.edit", "Select a 3D spectrum plane and optionally adjust per-axis phase", @@ -193,6 +196,7 @@ fn tool_index() -> Vec { "plotium_tasks_cancel"=>(vec!["tasks.cancel"],"{task:TaskReceipt}; task_not_found never authorizes replay","TaskId; callable while busy or after project change; requires reversible_modify"), "plotium_analysis_results_inspect"=>(vec!["analysis.results.inspect"],"ToolResult.value: paginated native DOSY/ILT columns, validity, counts and provenance","one result field ID"), "plotium_diffusion_inspect"=>(vec!["diffusion.inspect"],"ToolResult.value: {resolution_source,increment_count,input:DiffusionInputReport,field_requirement,executable,acquisition_evidence}","one dataset or field ID"), + "plotium_peaks3d_inspect"=>(vec!["nmr.peaks3d.list"],"ToolResult.value: {dataset,dataset_name,source,processing,axes,peaks,position,intensity}","one 3D dataset ID"), "plotium_processing_inspect"=>(vec!["processing.inspect"],"ToolResult.value: {dataset,axes:[{axis,pipeline|settings,pending_apply}]}","one dataset ID"), "plotium_get_state"=>(vec!["state.get"],"{project_context,revision,availability,project:{dataset_count,canvas_count},selection:{items,total,next_offset,canvas:{items:[{target,kind,name,visible}],total,part}},hand_edits:{items:[{target,key,label,value}],total,next_offset},permissions,capability_fingerprint,save_state}; latest state per call, not a frozen selection snapshot","application; project, selection and hand_edits are null while busy"), "plotium_capabilities_get"=>(vec!["capabilities.get"],"{} returns {capability_fingerprint,connection_capabilities:index}; {section,key?} returns {capability_fingerprint,section,key?,contract}; immutable contracts, not current authorization","static; no project or revision required; callable while busy"), @@ -238,10 +242,11 @@ fn operation_constraints() -> serde_json::Map { .get(id) .expect("editable operations are registered") .parameter_schema; - // Scientific edits start background work at preflight, so planning them needs modify. + // Keep discovery aligned with the scientific-plan permission gate. let scientific = id.starts_with("processing.") || id.starts_with("diffusion.") - || id.starts_with("analysis."); + || id.starts_with("analysis.") + || id.starts_with("nmr."); constraints.insert(id.into(), json!({"schema":schema,"preflight_permission":if scientific {"reversible_modify"} else {"read"},"execute_permission":"reversible_modify"})); } constraints["dataset.delete"]["result_contract"] = json!( @@ -258,7 +263,7 @@ fn sections() -> serde_json::Map { let constraints = operation_constraints(); let details = crate::operation_details::build(&constraints["figure.edit"]); let scientific = plotium_core::automation::ToolRegistry::built_in().descriptors() - .filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")) + .filter(|d|d.id.starts_with("processing.")||d.id.starts_with("diffusion.")||d.id.starts_with("analysis.")||d.id.starts_with("nmr.")) .map(|d| { let mut entry = json!({"descriptor":d,"required_permission":if d.effect==plotium_core::automation::EffectLevel::ReadOnly{"read"}else{"reversible_modify"}}); if EDIT_OPERATIONS.contains(&d.id.as_str()) { diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index e0db9639..609191ba 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -448,3 +448,6 @@ mod spectral_processing_tests; #[cfg(test)] mod scientific_plot_tests; + +#[cfg(test)] +mod peaks3d_tests; diff --git a/crates/agent/src/peaks3d_tests.rs b/crates/agent/src/peaks3d_tests.rs new file mode 100644 index 00000000..1cc48702 --- /dev/null +++ b/crates/agent/src/peaks3d_tests.rs @@ -0,0 +1,83 @@ +use crate::Session; +use plotium_core::state::PlotiumApp; +use serde_json::{Value, json}; + +fn execute(session: &mut Session, app: &mut PlotiumApp, tool: &str, parameters: Value) -> Value { + let request = json!({"tool_id":tool,"parameters":parameters, + "ids":[app.doc.datasets[0].resource_id().to_string()], + "expected_revision":app.doc.automation_revision, + "expected_project_context":app.session.project_context}); + let plan = session.call(app, "plotium_plan", request).unwrap(); + session + .call(app, "plotium_execute", json!({"plan_id":plan["plan_id"]})) + .unwrap() +} +#[test] +fn mcp_peak_edits_and_readonly_export_match_desktop_core() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition( + plotium_io::load_path(path).unwrap().acquisition, + ) + .unwrap(); + let owner = dataset.resource_id(); + let volume = dataset.as_nmr2d().unwrap().volume.as_ref().unwrap(); + let ppm: [f64; 3] = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap()); + let mut direct = PlotiumApp::new(); + direct.doc.datasets.push(dataset.clone()); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + let mut session = Session::default(); + session.set_allow_modify(true); + let id = direct.add_peak_3d(owner, ppm).unwrap(); + execute( + &mut session, + &mut app, + "nmr.peaks3d", + json!({"operation":"add","ppm":ppm}), + ); + direct + .edit_peak_3d(owner, id, ppm, "overlap".into()) + .unwrap(); + execute( + &mut session, + &mut app, + "nmr.peaks3d", + json!({"operation":"edit","id":id,"note":"overlap"}), + ); + direct.locate_peak_3d(owner, id).unwrap(); + execute( + &mut session, + &mut app, + "nmr.peaks3d", + json!({"operation":"locate","id":id}), + ); + assert_eq!( + app.session.ui.selected_peak_3d, + direct.session.ui.selected_peak_3d + ); + session.set_allow_modify(false); + let revision = app.doc.automation_revision; + let query = json!({"ids":[owner.to_string()],"expected_revision":revision, + "expected_project_context":app.session.project_context}); + let result = session + .call(&mut app, "plotium_peaks3d_inspect", query) + .unwrap(); + assert_eq!(app.doc.automation_revision, revision); + assert_eq!(result["value"], direct.peak_table_3d(owner).unwrap()); + session.set_allow_modify(true); + direct.delete_peak_3d(owner, id).unwrap(); + let result = execute( + &mut session, + &mut app, + "nmr.peaks3d", + json!({"operation":"delete","id":id}), + ); + assert_eq!(result["value"], direct.peak_table_3d(owner).unwrap()); + app.undo(); + direct.undo(); + assert_eq!( + app.peak_table_3d(owner).unwrap(), + direct.peak_table_3d(owner).unwrap() + ); +} diff --git a/crates/agent/src/protocol.rs b/crates/agent/src/protocol.rs index 05e1f46c..86bf1f3e 100644 --- a/crates/agent/src/protocol.rs +++ b/crates/agent/src/protocol.rs @@ -179,6 +179,7 @@ pub(crate) fn definitions() -> Vec { tools.push(Tool::new(name,"Read native table data at explicit document and table revisions; no selection fallback.",schema.as_object().expect("local schema").clone())); } for (name, id) in [ + ("plotium_peaks3d_inspect", "nmr.peaks3d.list"), ( "plotium_analysis_results_inspect", plotium_core::automation::ANALYSIS_RESULTS_INSPECT, diff --git a/crates/agent/src/session.rs b/crates/agent/src/session.rs index d04ac4eb..b0fc183b 100644 --- a/crates/agent/src/session.rs +++ b/crates/agent/src/session.rs @@ -411,22 +411,14 @@ impl Session { name, "plotium_diffusion_inspect" | "plotium_processing_inspect" + | "plotium_peaks3d_inspect" | "plotium_analysis_results_inspect" ) { return self.scientific_read(app, name, arguments); } if name == "plotium_plan" { let args = Plan::parse(arguments)?; - if matches!( - args.tool_id.as_str(), - PROCESSING_EDIT - | "nmr.volume.edit" - | DIFFUSION_SET_INPUT - | DOSY_BUILD - | REGIONS_EXTRACT - | DIFFUSION_FIT - | "processing.apply_scheme" - ) { + if science::is_scientific_edit(&args.tool_id) { return self.scientific_plan(app, args); } if args.tool_id == "figure.export" { diff --git a/crates/agent/src/session/science.rs b/crates/agent/src/session/science.rs index f7e6f9b6..51e4a61e 100644 --- a/crates/agent/src/session/science.rs +++ b/crates/agent/src/session/science.rs @@ -1,6 +1,20 @@ //! Scientific calls use core descriptors and the existing plan/execute gate. use super::*; +pub(super) fn is_scientific_edit(tool: &str) -> bool { + matches!( + tool, + PROCESSING_EDIT + | "nmr.volume.edit" + | "nmr.peaks3d" + | DIFFUSION_SET_INPUT + | DOSY_BUILD + | REGIONS_EXTRACT + | DIFFUSION_FIT + | "processing.apply_scheme" + ) +} + impl Session { pub(super) fn scientific_tasks( &mut self, @@ -114,6 +128,8 @@ impl Session { let query: Query = decode(args)?; let id = if name == "plotium_analysis_results_inspect" { ANALYSIS_RESULTS_INSPECT + } else if name == "plotium_peaks3d_inspect" { + "nmr.peaks3d.list" } else if name == "plotium_diffusion_inspect" { DIFFUSION_INSPECT } else { diff --git a/crates/agent/src/tool_effects.rs b/crates/agent/src/tool_effects.rs index d616e9f4..c2cfd349 100644 --- a/crates/agent/src/tool_effects.rs +++ b/crates/agent/src/tool_effects.rs @@ -26,6 +26,7 @@ impl ToolEffect { | "plotium_analysis_results_inspect" | "plotium_diffusion_inspect" | "plotium_processing_inspect" + | "plotium_peaks3d_inspect" | "plotium_tasks_get" | "plotium_tasks_list" | "plotium_save_status" diff --git a/crates/agent/src/tool_effects_tests.rs b/crates/agent/src/tool_effects_tests.rs index f8e01052..cc24c1dd 100644 --- a/crates/agent/src/tool_effects_tests.rs +++ b/crates/agent/src/tool_effects_tests.rs @@ -18,6 +18,7 @@ fn every_advertised_tool_has_explicit_conservative_annotations() { "plotium_analysis_results_inspect", "plotium_diffusion_inspect", "plotium_processing_inspect", + "plotium_peaks3d_inspect", "plotium_tasks_get", "plotium_tasks_list", "plotium_save_status", diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 8c819688..fcc59257 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -57,6 +57,7 @@ mod panel_interactions; mod panel_notes; mod panel_selection; mod peaks; +mod peaks3d; mod phase; mod plot_annotation_menu; mod plot_labels; @@ -390,7 +391,16 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { handle_integral_drag(app, ci, object_id, di, plot, ui, &resp) } } - Tool::Peaks => handle_peaks(app, ci, object_id, di, plot, ui, &resp), + Tool::Peaks => { + if app.doc.datasets[di] + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + { + peaks3d::handle_volume_peaks(app, ci, object_id, di, plot, ui, &resp); + } else { + handle_peaks(app, ci, object_id, di, plot, ui, &resp); + } + } Tool::InspectCursor | Tool::DeltaCursor => { handle_cursor_tool(app, ci, object_id, di, plot, ui) } @@ -411,6 +421,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { paint_integrals(app, ci, object_id, di, plot, &painter, chrome); paint_integrals_2d(app, ci, object_id, di, plot, &painter, chrome); paint_peaks(app, ci, object_id, di, plot, &painter, chrome); + peaks3d::paint_volume_peaks(app, ci, object_id, di, plot, &painter); if pointer_owned { paint_reference_pick(app, ci, object_id, di, plot, ui, &painter, chrome); } diff --git a/crates/app/src/ui/canvas/peaks3d.rs b/crates/app/src/ui/canvas/peaks3d.rs new file mode 100644 index 00000000..7557885a --- /dev/null +++ b/crates/app/src/ui/canvas/peaks3d.rs @@ -0,0 +1,188 @@ +use super::*; + +pub(crate) fn handle_volume_peaks( + app: &mut PlotiumApp, + ci: usize, + object: ObjectId, + di: usize, + plot: PlotRect, + ui: &Ui, + response: &egui::Response, +) { + let Some(nmr) = app.doc.datasets[di].as_nmr2d() else { + return; + }; + let Some(volume) = nmr.volume.as_ref() else { + return; + }; + let dataset = app.doc.datasets[di].resource_id(); + let Some(figure) = app.doc.canvases[ci] + .object(object) + .and_then(|o| o.plot()) + .map(|p| p.figure()) + else { + return; + }; + let x = figure.x.clone(); + let y = figure.y.clone(); + let axes: Vec<_> = (0..3).filter(|a| *a != volume.fixed_axis).collect(); + let marks: Vec<_> = volume + .peaks + .marks + .iter() + .filter(|m| { + volume + .peak_indices(m.ppm) + .is_ok_and(|i| i[volume.fixed_axis] == volume.index) + }) + .cloned() + .collect(); + if ui.ctx().egui_wants_keyboard_input() { + return; + } + if ui.input(|i| i.key_pressed(egui::Key::Delete)) + && let Some((owner, id)) = app.session.ui.selected_peak_3d + && owner == dataset + { + if let Err(e) = app.delete_peak_3d(dataset, id) { + app.session.status = e; + } + return; + } + let Some(pointer) = response + .interact_pointer_pos() + .filter(|p| plot_contains(plot, *p)) + else { + return; + }; + let hit = marks.iter().find(|m| { + Pos2::new( + to_screen_x(m.ppm[axes[1]], plot, &x), + to_screen_y(m.ppm[axes[0]], plot, &y), + ) + .distance(pointer) + < 9.0 + }); + let drag_id = egui::Id::new(("peak3d_drag", dataset, object)); + if response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()); + ui.ctx().data_mut(|d| { + d.remove::(drag_id); + if let Some(origin) = origin + && let Some(mark) = marks.iter().find(|m| { + Pos2::new( + to_screen_x(m.ppm[axes[1]], plot, &x), + to_screen_y(m.ppm[axes[0]], plot, &y), + ) + .distance(origin) + < 9. + }) + { + app.session.ui.selected_peak_3d = Some((dataset, mark.id)); + d.insert_temp(drag_id, mark.id); + } + }); + } + if response.clicked() { + if let Some(mark) = hit { + app.session.ui.selected_peak_3d = Some((dataset, mark.id)); + } else { + let mut ppm = [0.; 3]; + ppm[volume.fixed_axis] = match volume + .processed + .axis_coordinate(volume.fixed_axis, volume.index) + { + Ok(v) => v, + Err(e) => { + app.session.status = e; + return; + } + }; + ppm[axes[1]] = from_screen_x(pointer.x, plot, &x); + ppm[axes[0]] = from_screen_y(pointer.y, plot, &y); + match app.add_peak_3d(dataset, ppm) { + Ok(id) => app.session.ui.selected_peak_3d = Some((dataset, id)), + Err(e) => app.session.status = e, + } + } + } + if response.drag_stopped() + && let Some(id) = ui.ctx().data_mut(|d| { + let p = d.get_temp::(drag_id); + d.remove::(drag_id); + p + }) + && let Some(mark) = marks.iter().find(|m| m.id == id) + { + let mut ppm = mark.ppm; + ppm[axes[1]] = from_screen_x(pointer.x, plot, &x); + ppm[axes[0]] = from_screen_y(pointer.y, plot, &y); + if let Err(e) = app.edit_peak_3d(dataset, id, ppm, mark.note.clone()) { + app.session.status = e; + } + } +} + +pub(crate) fn paint_volume_peaks( + app: &PlotiumApp, + ci: usize, + object: ObjectId, + di: usize, + plot: PlotRect, + painter: &egui::Painter, +) { + let Some(nmr) = app.doc.datasets[di].as_nmr2d() else { + return; + }; + let Some(volume) = nmr.volume.as_ref() else { + return; + }; + let dataset = app.doc.datasets[di].resource_id(); + let Some(figure) = app.doc.canvases[ci] + .object(object) + .and_then(|o| o.plot()) + .map(|p| p.figure()) + else { + return; + }; + let x = figure.x.clone(); + let y = figure.y.clone(); + let axes: Vec<_> = (0..3).filter(|a| *a != volume.fixed_axis).collect(); + let marks: Vec<_> = volume + .peaks + .marks + .iter() + .filter(|m| { + volume + .peak_indices(m.ppm) + .is_ok_and(|i| i[volume.fixed_axis] == volume.index) + }) + .cloned() + .collect(); + for mark in &marks { + let pos = Pos2::new( + to_screen_x(mark.ppm[axes[1]], plot, &x), + to_screen_y(mark.ppm[axes[0]], plot, &y), + ); + if plot_contains(plot, pos) { + let selected = app.session.ui.selected_peak_3d == Some((dataset, mark.id)); + let color = if selected { + Color32::YELLOW + } else { + Color32::LIGHT_GREEN + }; + painter.line_segment( + [pos - Vec2::new(5., 0.), pos + Vec2::new(5., 0.)], + Stroke::new(1.5_f32, color), + ); + painter.line_segment( + [pos - Vec2::new(0., 5.), pos + Vec2::new(0., 5.)], + Stroke::new(1.5_f32, color), + ); + } + } +} + +#[cfg(test)] +#[path = "peaks3d_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/canvas/peaks3d_tests.rs b/crates/app/src/ui/canvas/peaks3d_tests.rs new file mode 100644 index 00000000..97c28380 --- /dev/null +++ b/crates/app/src/ui/canvas/peaks3d_tests.rs @@ -0,0 +1,191 @@ +use super::*; +use egui::{Event, PointerButton, RawInput}; + +fn render(app: &mut PlotiumApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + ctx.run_ui( + RawInput { + events, + ..Default::default() + }, + |ui| { + let response = + ui.allocate_response(egui::vec2(800., 400.), egui::Sense::click_and_drag()); + let plot = PlotRect { + left: response.rect.left(), + top: response.rect.top(), + width: 800., + height: 400., + }; + let object = app.doc.canvases[0].objects[0].id; + handle_volume_peaks(app, 0, object, 0, plot, ui, &response); + paint_volume_peaks(app, 0, object, 0, plot, ui.painter()); + }, + ) +} +fn pointer(app: &mut PlotiumApp, ctx: &egui::Context, pos: Pos2, pressed: bool) { + render( + app, + ctx, + vec![ + Event::PointerMoved(pos), + Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); +} +fn peaks(app: &PlotiumApp) -> &plotium_core::state::Peak3DSet { + &app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks +} +fn locate_from_table(app: &mut PlotiumApp, peak: plotium_core::state::Peak3DId) { + let ctx = egui::Context::default(); + let frame = |app: &mut PlotiumApp, events| { + ctx.run_ui( + RawInput { + events, + ..Default::default() + }, + |ui| crate::ui::peaks3d::panel(app, 0, ui), + ) + }; + frame(app, vec![]); + let output = frame(app, vec![]); + let row = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().starts_with(&format!("{} ", peak.0)) => { + Some(text.pos + egui::vec2(5., 5.)) + } + _ => None, + }) + .expect("peak table row"); + for pressed in [true, false] { + frame( + app, + vec![ + Event::PointerMoved(row), + Event::PointerButton { + pos: row, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } +} +fn scenario(path: &std::path::Path) { + let loaded = plotium_io::load_path(path).unwrap(); + let (dataset, _) = + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let owner = dataset.resource_id(); + let canvas = plotium_core::workflow::build_default_canvas(&dataset, "3D peak acceptance"); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + app.doc.canvases.push(canvas); + app.focus_single(0); + app.set_tool(Tool::Peaks); + let ctx = egui::Context::default(); + render(&mut app, &ctx, vec![]); + // Drive the actual canvas event handler, including hit-testing and drag release. + for pos in [ + Pos2::new(250., 160.), + Pos2::new(450., 260.), + Pos2::new(650., 120.), + ] { + pointer(&mut app, &ctx, pos, true); + pointer(&mut app, &ctx, pos, false); + } + assert_eq!(peaks(&app).marks.len(), 3, "{}", app.session.status); + let original = peaks(&app).clone(); + let start = Pos2::new(450., 260.); + let end = Pos2::new(490., 290.); + pointer(&mut app, &ctx, start, true); + render(&mut app, &ctx, vec![Event::PointerMoved(end)]); + pointer(&mut app, &ctx, end, false); + let moved = peaks(&app).clone(); + assert_eq!( + app.session.ui.selected_peak_3d, + Some((owner, original.marks[1].id)) + ); + assert_eq!(moved.marks[1].id, original.marks[1].id); + assert_ne!(moved.marks[1].ppm, original.marks[1].ppm); + assert_eq!(moved.marks[1].ppm[0], original.marks[1].ppm[0]); + app.undo(); + assert_eq!(peaks(&app), &original); + app.redo(); + assert_eq!(peaks(&app), &moved); + for axis in 0..3 { + app.select_nmr_volume_plane(owner, axis, 0).unwrap(); + for mark in &moved.marks { + locate_from_table(&mut app, mark.id); + let v = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + assert_eq!(v.index, v.peak_indices(mark.ppm).unwrap()[axis]); + assert_eq!(app.session.ui.selected_peak_3d, Some((owner, mark.id))); + } + assert_eq!(peaks(&app), &moved); + } + let selected = app.session.ui.selected_peak_3d.unwrap().1; + render( + &mut app, + &ctx, + vec![Event::Key { + key: egui::Key::Delete, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }], + ); + assert!(peaks(&app).marks.iter().all(|m| m.id != selected)); + app.undo(); + assert_eq!(peaks(&app), &moved); + let root = std::env::temp_dir().join(format!("plotium-ui-peaks-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let project = root.join("peaks.plotium"); + plotium_core::project::save_project(&app, &project, false).unwrap(); + let reopened = plotium_core::project::load_project(&project).unwrap(); + assert_eq!(peaks(&reopened), &moved); + let table = reopened.peak_table_3d(owner).unwrap(); + let export = root.join("peaks.json"); + std::fs::write(&export, serde_json::to_vec_pretty(&table).unwrap()).unwrap(); + let exported: serde_json::Value = + serde_json::from_slice(&std::fs::read(&export).unwrap()).unwrap(); + assert_eq!(exported, app.peak_table_3d(owner).unwrap()); + std::fs::remove_dir_all(root).unwrap(); +} +#[test] +fn canvas_peak_gestures_navigation_and_project_export_roundtrip() { + scenario( + &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"), + ); +} +#[test] +#[ignore = "requires local ubiquitin data; set PLOTIUM_UBIQUITIN"] +fn host_ubiquitin_peak_ui_acceptance() { + let root = std::path::PathBuf::from( + std::env::var_os("PLOTIUM_UBIQUITIN").expect("Set PLOTIUM_UBIQUITIN"), + ); + for experiment in [2, 14, 16] { + scenario(&root.join(experiment.to_string())); + println!( + "experiment={experiment}: canvas clicks/drag/delete, three orientations, undo/redo, project and JSON export passed" + ); + } +} diff --git a/crates/app/src/ui/canvas/phase.rs b/crates/app/src/ui/canvas/phase.rs index b0f642ea..0db8b61a 100644 --- a/crates/app/src/ui/canvas/phase.rs +++ b/crates/app/src/ui/canvas/phase.rs @@ -19,6 +19,12 @@ pub(crate) fn displayed_phase_pivot_ppm( dataset: usize, axis: PhaseAxis, ) -> Option { + if app.doc.datasets[dataset] + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + { + return None; + } match app.interaction() { Interaction::Phase(drag) if drag.dataset == app.doc.datasets[dataset].resource_id() diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 6e8d82d2..00a241e7 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -61,6 +61,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::Peak3DExport + | CommandId::Peak3DDelete + | CommandId::Peak3DLocate + | CommandId::Peak3DPrevious + | CommandId::Peak3DNext => super::peaks3d::command(app, ctx, id), CommandId::ApplyNmrVolumePhase => super::nmr_volume::apply_phase(app, ctx), CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => { super::nmr_volume::step(app, id == CommandId::NextNmrPlane); @@ -349,6 +354,9 @@ fn execute_inner( CommandId::Tool(Tool::Symmetry) => { reveal_tool_group(app, Tool::Symmetry, ToolGroup::Nmr2dExperiment); } + CommandId::OpenNmrVolumePhase => { + super::nmr_volume::open_phase(app); + } CommandId::Tool(tool) => app.toggle_tool(tool), } } diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index dded6b7c..5f2045d6 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -34,9 +34,15 @@ pub enum CommandId { OpenFolder, ImportNmrSampling, ApplyNmrVolumePhase, + OpenNmrVolumePhase, CancelNmrVolumePhase, PreviousNmrPlane, NextNmrPlane, + Peak3DExport, + Peak3DDelete, + Peak3DLocate, + Peak3DPrevious, + Peak3DNext, RunBatchWorkflow, AgentConnections, RunScientificScript, @@ -293,7 +299,9 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { app.session.ui.table_import_preview.is_none(), "Finish or cancel the current table import preview before importing another table.", ), + CommandId::OpenNmrVolumePhase => requires(super::nmr_volume::active_volume(app), "Select a three-dimensional NMR spectrum before opening phase settings."), CommandId::CancelNmrVolumePhase => requires(app.session.ui.nmr_volume_phase_busy, "Start phase processing before cancelling it."), + CommandId::Peak3DExport | CommandId::Peak3DDelete | CommandId::Peak3DLocate | CommandId::Peak3DPrevious | CommandId::Peak3DNext => super::peaks3d::gate(app, id), CommandId::ApplyNmrVolumePhase => super::nmr_volume::phase_gate(app), CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => { super::nmr_volume::step_gate(app, id == CommandId::NextNmrPlane) @@ -573,8 +581,8 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { "Select a plotted 1D spectrum or table column before detecting peaks.", ), CommandId::PeakList => requires( - has_generic_peak_fit_trace(), - "Plot frequency-domain or tabular 1D data before opening the peak list.", + has_generic_peak_fit_trace() || dataset().and_then(Dataset::as_nmr2d).is_some_and(|n| n.volume.is_some()), + "Select a 3D spectrum or plot frequency-domain or tabular 1D data before opening the peak list.", ), CommandId::LineFit => requires( has_generic_peak_fit_trace(), @@ -731,15 +739,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { // 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. - let palette_available = id != CommandId::CommandPalette - || (app.session.ui.processing_scheme_dialog.is_none() - && app.session.ui.processing_template_dialog.is_none() - && app.session.ui.canvas_style_dialog.is_none() - && app.session.ui.spectrum_arithmetic_dialog.is_none() - && app.session.ui.align_spectra_dialog.is_none() - && app.session.ui.trace_alignment_dialog.is_none() - && app.session.ui.trace_composer.is_none() - && !app.session.ui.interaction.is_active()); + let palette_available = helpers::palette_available(app, id); // Activation requirements must not trap an already-active tool after the // dataset context changes: its command remains available for deactivation. let active_tool = id @@ -752,6 +752,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor { // `groups_for_tab`, pushing usable groups into the overflow menu. let ribbon = ribbon_placement(id).filter(|placement| match placement.applicability { Applicability::Always => true, + Applicability::NmrVolumeOnly => super::nmr_volume::active_volume(app), Applicability::LineAlignmentOnly => app.trace_alignment_target().is_some(), Applicability::TableOnly => is_table(), Applicability::XasOnly => dataset().and_then(Dataset::as_xas).is_some(), diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs index 7a64e3bf..f4d5b3cd 100644 --- a/crates/app/src/ui/commands/helpers.rs +++ b/crates/app/src/ui/commands/helpers.rs @@ -153,3 +153,16 @@ pub(super) fn xas_summary_gate(dataset: Option<&Dataset>) -> Result<(), &'static "Select an XAS scan with an EXAFS region before building the summary figure.", ) } + +pub(super) fn palette_available(app: &PlotiumApp, id: super::CommandId) -> bool { + use super::CommandId; + id != CommandId::CommandPalette + || (app.session.ui.processing_scheme_dialog.is_none() + && app.session.ui.processing_template_dialog.is_none() + && app.session.ui.canvas_style_dialog.is_none() + && app.session.ui.spectrum_arithmetic_dialog.is_none() + && app.session.ui.align_spectra_dialog.is_none() + && app.session.ui.trace_alignment_dialog.is_none() + && app.session.ui.trace_composer.is_none() + && !app.session.ui.interaction.is_active()) +} diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index d2e64057..99cec38a 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -55,9 +55,15 @@ pub(super) fn command_identity( CommandId::CloseWindow => plain("Close Window", Some(icon::X)), CommandId::OpenFile => plain("Open File…", Some(icon::FILE)), CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)), + CommandId::OpenNmrVolumePhase => plain("3D Phase settings", Some(icon::ANGLE)), CommandId::CancelNmrVolumePhase => plain("Cancel 3D NMR Phase", Some(icon::X)), CommandId::ApplyNmrVolumePhase => plain("Apply 3D NMR Phase", Some(icon::PLAY)), CommandId::PreviousNmrPlane => plain("Previous NMR Plane", Some(icon::CARET_LEFT)), + CommandId::Peak3DExport => plain("Export 3D Peak Table…", None), + CommandId::Peak3DDelete => plain("Delete 3D Peak", None), + CommandId::Peak3DLocate => plain("Locate 3D Peak", None), + CommandId::Peak3DPrevious => plain("Previous 3D Peak", None), + CommandId::Peak3DNext => plain("Next 3D Peak", None), 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), @@ -374,6 +380,11 @@ pub(super) fn command_identity( ) } CommandId::CycleCursor => plain("Next Cursor", Some(icon::CROSSHAIR)), + CommandId::Tool(Tool::ManualPhase) => ( + "Interactive phase".into(), + tool_icon(Tool::ManualPhase), + Some(app.session.tool == Tool::ManualPhase), + ), CommandId::Tool(tool) => ( format!("Tool: {}", tool.label()), tool_icon(tool), @@ -460,10 +471,17 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::OpenFile => "file.open_file", CommandId::OpenFolder => "file.open_folder", CommandId::ImportNmrSampling => "file.import_nmr_sampling", + CommandId::OpenNmrVolumePhase => "nmr.phase.open", CommandId::CancelNmrVolumePhase => "nmr.phase.cancel", CommandId::ApplyNmrVolumePhase => "nmr.phase.apply", CommandId::PreviousNmrPlane => "nmr.plane.previous", CommandId::NextNmrPlane => "nmr.plane.next", + CommandId::Peak3DExport => "nmr.peak3d.export", + CommandId::Peak3DDelete => "nmr.peak3d.delete", + CommandId::Peak3DLocate => "nmr.peak3d.locate", + CommandId::Peak3DPrevious => "nmr.peak3d.previous", + CommandId::Peak3DNext => "nmr.peak3d.next", + CommandId::RunBatchWorkflow => "tools.automation", CommandId::AgentConnections => "file.agent_connections", CommandId::RunScientificScript => "tools.run_scientific_script", diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs index 7f4703aa..7307b93f 100644 --- a/crates/app/src/ui/commands/ribbon.rs +++ b/crates/app/src/ui/commands/ribbon.rs @@ -21,6 +21,7 @@ pub enum Applicability { XasOnly, SeriesOnly, Homonuclear2dOnly, + NmrVolumeOnly, ToolGroup(ToolGroup), } @@ -65,6 +66,7 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { CommandId::Tool(Tool::Symmetry) => (Analyze, "Review", 1, Homonuclear2dOnly), CommandId::AlignTraces => (Analyze, "Overlay", 1, LineAlignmentOnly), CommandId::XpsWorkbench(_) => (Analyze, "XPS", 0, Applicability::ToolGroup(ToolGroup::Xps)), + CommandId::OpenNmrVolumePhase => (Process, "3D Phase", 0, Applicability::NmrVolumeOnly), CommandId::Tool(Tool::ManualPhase) => (Process, "Correct", 0, Always), CommandId::SpectrumArithmetic | CommandId::AlignSpectra | CommandId::Craft => { (Process, "Transform", 1, Always) diff --git a/crates/app/src/ui/commands/roster.rs b/crates/app/src/ui/commands/roster.rs index ed6922f4..77ec7933 100644 --- a/crates/app/src/ui/commands/roster.rs +++ b/crates/app/src/ui/commands/roster.rs @@ -21,9 +21,15 @@ pub(super) fn command_ids(recent_files: usize) -> Vec { CommandId::OpenFolder, CommandId::ImportNmrSampling, CommandId::ApplyNmrVolumePhase, + CommandId::OpenNmrVolumePhase, CommandId::CancelNmrVolumePhase, CommandId::PreviousNmrPlane, CommandId::NextNmrPlane, + CommandId::Peak3DExport, + CommandId::Peak3DDelete, + CommandId::Peak3DLocate, + CommandId::Peak3DPrevious, + CommandId::Peak3DNext, CommandId::RunBatchWorkflow, CommandId::AgentConnections, CommandId::RunScientificScript, diff --git a/crates/app/src/ui/commands_identity_tests.rs b/crates/app/src/ui/commands_identity_tests.rs index 8235f5e7..bc2cef83 100644 --- a/crates/app/src/ui/commands_identity_tests.rs +++ b/crates/app/src/ui/commands_identity_tests.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; -use plotium_core::state::PlotiumApp; +use plotium_core::state::{PlotiumApp, Tool}; use super::{CommandId, command_ids, describe, ribbon_placement}; @@ -45,3 +45,16 @@ fn ribbon_group_icons_are_unique_within_the_group() { } } } + +#[test] +fn phase_label_retains_interactive_tool_toggle_identity() { + let mut app = PlotiumApp::new(); + let id = CommandId::Tool(Tool::ManualPhase); + let description = describe(&app, id); + assert_eq!(description.label, "Interactive phase"); + assert_eq!(description.checked, Some(false)); + app.session.tool = Tool::ManualPhase; + let description = describe(&app, id); + assert_eq!(description.label, "Interactive phase"); + assert_eq!(description.checked, Some(true)); +} diff --git a/crates/app/src/ui/commands_ribbon_tests.rs b/crates/app/src/ui/commands_ribbon_tests.rs index f9ec113f..07c4d14e 100644 --- a/crates/app/src/ui/commands_ribbon_tests.rs +++ b/crates/app/src/ui/commands_ribbon_tests.rs @@ -50,3 +50,33 @@ fn manual_phase_needs_a_spectrum_with_a_phase_step() { } assert!(describe(&app_with_nmr(), CommandId::Tool(Tool::ManualPhase)).enabled); } + +#[test] +fn phase_settings_and_interactive_tool_keep_distinct_ribbon_entries() { + let app = app_with_nmr(); + let interactive = describe(&app, CommandId::Tool(Tool::ManualPhase)); + let settings = describe( + &app, + CommandId::PropertyGroup(super::super::properties::panel::PHASE_SECTION), + ); + assert_eq!(interactive.label, "Interactive phase"); + assert_eq!(settings.label, "Phase settings"); + assert!(interactive.enabled && settings.enabled); + assert!(interactive.ribbon.is_some() && settings.ribbon.is_some()); + assert_eq!(interactive.checked, Some(false)); + assert_eq!(settings.checked, None); + assert!(!describe(&app, CommandId::OpenNmrVolumePhase).enabled); +} + +#[test] +fn interactive_phase_remains_a_toggle_for_standalone_spectra() { + let mut app = app_with_nmr(); + let ctx = egui::Context::default(); + let tool = CommandId::Tool(Tool::ManualPhase); + execute_without_clipboard(tool, &mut app, &ctx); + assert_eq!(app.session.tool, Tool::ManualPhase); + assert_eq!(describe(&app, tool).checked, Some(true)); + execute_without_clipboard(tool, &mut app, &ctx); + assert_ne!(app.session.tool, Tool::ManualPhase); + assert_eq!(describe(&app, tool).checked, Some(false)); +} diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index d5e52e34..f42f8f15 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -686,3 +686,4 @@ mod feedback_tests { } mod nmr_volume; +mod peaks3d; diff --git a/crates/app/src/ui/nmr_volume.rs b/crates/app/src/ui/nmr_volume.rs index 11f0bdd5..65d6e251 100644 --- a/crates/app/src/ui/nmr_volume.rs +++ b/crates/app/src/ui/nmr_volume.rs @@ -4,6 +4,20 @@ use plotium_core::state::{Dataset, PlotiumApp}; use super::commands::{self, CommandId}; +pub(super) fn active_volume(app: &PlotiumApp) -> bool { + app.active_dataset() + .and_then(|di| app.doc.datasets.get(di)) + .and_then(Dataset::as_nmr2d) + .is_some_and(|n| n.volume.is_some()) +} + +pub(super) fn open_phase(app: &mut PlotiumApp) { + app.cancel_interaction(); + app.set_tool(plotium_core::state::Tool::BrowseZoom); + super::tools::expand_processing_surface(app); + app.session.ui.processing_task_collapsed = false; +} + pub(super) fn step_gate(app: &PlotiumApp, forward: bool) -> Result<(), &'static str> { let volume = app .active_dataset() @@ -340,15 +354,15 @@ pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'stat .and_then(|di| app.doc.datasets.get(di)) .and_then(Dataset::as_nmr2d) .is_some_and(|n| n.volume.is_some()); + if volume && id == CommandId::Tool(Tool::ManualPhase) { + return Err("Open 3D Phase settings to adjust the full volume."); + } if volume && matches!( id, CommandId::DetectPeaks - | CommandId::PeakList | CommandId::Integrate - | CommandId::Tool( - Tool::Peaks | Tool::Integrate | Tool::Symmetry | Tool::ManualPhase - ) + | CommandId::Tool(Tool::Integrate | Tool::Symmetry) ) { return Err( @@ -362,6 +376,38 @@ pub(super) fn editing_gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'stat mod tests { use super::*; + #[test] + fn volume_phase_settings_end_peak_editing_and_open_processing() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let loaded = plotium_io::load_path(path).unwrap(); + let (dataset, _) = + plotium_core::workflow::dataset_from_acquisition(loaded.acquisition).unwrap(); + let mut app = PlotiumApp::new(); + let owner = dataset.resource_id(); + app.doc.datasets.push(dataset); + app.focus_single(0); + app.session.ui.processing_task_collapsed = true; + let id = CommandId::OpenNmrVolumePhase; + let description = commands::describe(&app, id); + assert!(description.enabled, "{:?}", description.disabled_reason); + assert!(description.ribbon.is_some()); + assert_eq!(description.checked, None); + assert_eq!(description.label, "3D Phase settings"); + app.set_tool(plotium_core::state::Tool::Peaks); + commands::execute_without_clipboard(id, &mut app, &egui::Context::default()); + assert_eq!(app.session.tool, plotium_core::state::Tool::BrowseZoom); + assert!( + !commands::describe( + &app, + CommandId::Tool(plotium_core::state::Tool::ManualPhase) + ) + .enabled + ); + assert_eq!(app.session.ui.processing_task_dataset, Some(owner)); + assert!(!app.session.ui.processing_task_collapsed); + } + #[test] fn volume_navigation_renders_and_disables_endpoint_steps() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/app/src/ui/peaks3d.rs b/crates/app/src/ui/peaks3d.rs new file mode 100644 index 00000000..c8b4f9dc --- /dev/null +++ b/crates/app/src/ui/peaks3d.rs @@ -0,0 +1,235 @@ +//! The 3D peak table shares the active spectrum and its persistent peak records. +use super::commands::{self, CommandId}; +use egui::{DragValue, Ui}; +use plotium_core::state::{Peak3DId, Peak3DMark, PlotiumApp}; + +fn target(app: &PlotiumApp) -> Option<(usize, Peak3DId)> { + let di = app.active_dataset()?; + let (dataset, peak) = app.session.ui.selected_peak_3d?; + (app.doc.datasets[di].resource_id() == dataset).then_some((di, peak)) +} +pub(super) fn gate(app: &PlotiumApp, id: CommandId) -> Result<(), &'static str> { + let di = app + .active_dataset() + .ok_or("Select a 3D spectrum before editing peaks.")?; + let volume = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a 3D spectrum before editing peaks.")?; + if id == CommandId::Peak3DExport { + return if volume.peaks.marks.is_empty() { + Err("Add a peak before exporting the peak table.") + } else { + Ok(()) + }; + } + if matches!(id, CommandId::Peak3DPrevious | CommandId::Peak3DNext) { + return if volume.peaks.marks.is_empty() { + Err("Add a peak before inspecting the peak list.") + } else { + Ok(()) + }; + } + let (_, peak) = target(app).ok_or("Select a peak in the spectrum or peak table first.")?; + if volume.peaks.marks.iter().any(|p| p.id == peak) { + Ok(()) + } else { + Err("Select an existing peak first.") + } +} +pub(super) fn command(app: &mut PlotiumApp, _ctx: &egui::Context, id: CommandId) { + if let Err(error) = gate(app, id) { + app.session.status = error.into(); + return; + } + let di = app.active_dataset().expect("gated dataset"); + let dataset = app.doc.datasets[di].resource_id(); + let marks = app.doc.datasets[di] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks + .clone(); + if id == CommandId::Peak3DExport { + let value = match app.peak_table_3d(dataset) { + Ok(value) => value, + Err(error) => { + app.session.status = error; + return; + } + }; + if let Some(path) = rfd::FileDialog::new() + .set_file_name("peaks3d.json") + .save_file() + { + let result = serde_json::to_vec_pretty(&value) + .map_err(|e| e.to_string()) + .and_then(|bytes| std::fs::write(path, bytes).map_err(|e| e.to_string())); + if let Err(e) = result { + app.session.status = e; + } + } + return; + } + let selected = target(app).map(|(_, id)| id); + if matches!(id, CommandId::Peak3DPrevious | CommandId::Peak3DNext) { + let current = selected.and_then(|id| marks.iter().position(|m| m.id == id)); + let next = match current { + None => 0, + Some(i) if id == CommandId::Peak3DPrevious => i.saturating_sub(1), + Some(i) => (i + 1).min(marks.len() - 1), + }; + app.session.ui.selected_peak_3d = Some((dataset, marks[next].id)); + if let Err(e) = app.locate_peak_3d(dataset, marks[next].id) { + app.session.status = e; + } + return; + } + let peak = selected.unwrap(); + let result = match id { + CommandId::Peak3DDelete => app.delete_peak_3d(dataset, peak), + CommandId::Peak3DLocate => app.locate_peak_3d(dataset, peak), + _ => Ok(()), + }; + if let Err(e) = result { + app.session.status = e; + } +} +fn button(app: &mut PlotiumApp, ui: &mut Ui, id: CommandId, label: &str) { + let description = commands::describe(app, id); + if ui + .add_enabled( + description.enabled, + egui::Button::new(label).selected(description.checked.unwrap_or(false)), + ) + .on_disabled_hover_text(description.disabled_reason.unwrap_or("")) + .clicked() + { + commands::execute_without_clipboard(id, app, ui.ctx()); + } +} +pub(super) fn panel(app: &mut PlotiumApp, di: usize, ui: &mut Ui) { + let dataset = app.doc.datasets[di].resource_id(); + let marks = app.doc.datasets[di] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks + .clone(); + let editing = target(app).map(|(_, id)| id); + ui.small("Click the spectrum to place a peak. Drag a selected mark to move it in this plane."); + ui.horizontal(|ui| { + button( + app, + ui, + CommandId::Tool(plotium_core::state::Tool::Peaks), + "Pick peaks", + ); + button(app, ui, CommandId::Peak3DPrevious, "Previous"); + button(app, ui, CommandId::Peak3DNext, "Next"); + }); + button(app, ui, CommandId::Peak3DExport, "Export peak table…"); + if marks.is_empty() { + ui.label("No peaks yet — click a signal in the spectrum to add one."); + return; + } + let axes = app.doc.datasets[di] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .processed + .axes() + .iter() + .enumerate() + .map(|(axis, a)| format!("F{} {} (ppm)", axis + 1, a.nucleus().unwrap_or("?"))) + .collect::>(); + ui.label(format!( + "ID {} / {} / {} Signed height", + axes[0], axes[1], axes[2] + )); + for mark in &marks { + let selected = app.session.ui.selected_peak_3d == Some((dataset, mark.id)); + let response = ui.selectable_label( + selected, + format!( + "{} {:.3} / {:.3} / {:.3} {:+.3e}", + mark.id.0, mark.ppm[0], mark.ppm[1], mark.ppm[2], mark.intensity + ), + ); + if response.clicked() { + app.session.ui.selected_peak_3d = Some((dataset, mark.id)); + commands::execute_without_clipboard(CommandId::Peak3DLocate, app, ui.ctx()); + } + } + if let Some(id) = editing + && let Some(mark) = marks.iter().find(|m| m.id == id) + { + ui.separator(); + edit_fields(app, dataset, mark, &axes, ui); + ui.horizontal(|ui| { + button(app, ui, CommandId::Peak3DLocate, "Locate"); + button(app, ui, CommandId::Peak3DDelete, "Delete"); + }); + } +} + +#[derive(Clone)] +struct EditDraft { + baseline: Peak3DMark, + ppm: [f64; 3], + note: String, +} + +fn edit_fields( + app: &mut PlotiumApp, + dataset: plotium_core::state::DatasetId, + mark: &Peak3DMark, + axes: &[String], + ui: &mut Ui, +) { + let key = egui::Id::new(("peak3d_edit", dataset, mark.id)); + let mut draft = ui + .ctx() + .data(|d| d.get_temp::(key)) + .filter(|d| d.baseline.ppm == mark.ppm && d.baseline.note == mark.note) + .unwrap_or_else(|| EditDraft { + baseline: mark.clone(), + ppm: mark.ppm, + note: mark.note.clone(), + }); + let mut finished = false; + ui.push_id((dataset, mark.id), |ui| { + for (axis, value) in draft.ppm.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.label(&axes[axis]); + let response = ui.add(DragValue::new(value).speed(0.001)); + finished |= response.drag_stopped() || response.lost_focus(); + }); + } + ui.label("Note"); + finished |= ui.text_edit_singleline(&mut draft.note).lost_focus(); + }); + // Commit at gesture end, so typing and dragging each have one undo step. + if finished { + ui.ctx().data_mut(|d| d.remove::(key)); + if (draft.ppm != mark.ppm || draft.note != mark.note) + && let Err(error) = app.edit_peak_3d(dataset, mark.id, draft.ppm, draft.note) + { + app.session.status = error; + } + } else { + ui.ctx().data_mut(|d| d.insert_temp(key, draft)); + } +} + +#[cfg(test)] +#[path = "peaks3d_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/peaks3d_tests.rs b/crates/app/src/ui/peaks3d_tests.rs new file mode 100644 index 00000000..75d12406 --- /dev/null +++ b/crates/app/src/ui/peaks3d_tests.rs @@ -0,0 +1,226 @@ +use super::*; +use egui::{Event, PointerButton, Pos2, RawInput}; +use plotium_core::state::Tool; + +fn fixture() -> PlotiumApp { + 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); + app +} +fn frame(app: &mut PlotiumApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + ctx.run_ui( + RawInput { + events, + ..Default::default() + }, + |ui| panel(app, 0, ui), + ) +} +fn text_position(output: &egui::FullOutput, text: &str) -> Pos2 { + output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text().starts_with(text) => { + Some(t.pos + egui::vec2(5., 5.)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("Missing UI text: {text}")) +} +fn click(app: &mut PlotiumApp, ctx: &egui::Context, position: Pos2) { + for pressed in [true, false] { + frame( + app, + ctx, + vec![ + Event::PointerMoved(position), + Event::PointerButton { + pos: position, + button: PointerButton::Primary, + pressed, + modifiers: Default::default(), + }, + ], + ); + } +} +#[test] +fn empty_peak_panel_button_toggles_the_tool_and_single_click_locates_a_peak() { + let mut app = fixture(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let output = frame(&mut app, &ctx, vec![]); + let pick = text_position(&output, "Pick peaks"); + click(&mut app, &ctx, pick); + assert_eq!(app.session.tool, Tool::Peaks); + assert_eq!( + commands::describe(&app, CommandId::Tool(Tool::Peaks)).checked, + Some(true) + ); + click(&mut app, &ctx, pick); + assert_ne!(app.session.tool, Tool::Peaks); + let dataset = app.doc.datasets[0].resource_id(); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap()); + let peak = app.add_peak_3d(dataset, ppm).unwrap(); + app.select_nmr_volume_plane(dataset, 0, 0).unwrap(); + frame(&mut app, &ctx, vec![]); + let output = frame(&mut app, &ctx, vec![]); + click( + &mut app, + &ctx, + text_position(&output, &format!("{} ", peak.0)), + ); + assert_eq!(app.session.ui.selected_peak_3d, Some((dataset, peak))); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .index, + 1 + ); +} + +#[test] +fn note_typing_commits_once_and_undo_redo_restore_the_whole_edit() { + let mut app = fixture(); + let owner = app.doc.datasets[0].resource_id(); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap()); + let id = app.add_peak_3d(owner, ppm).unwrap(); + app.locate_peak_3d(owner, id).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let output = frame(&mut app, &ctx, vec![]); + click( + &mut app, + &ctx, + text_position(&output, "Note") + egui::vec2(10., 22.), + ); + let revision = app.doc.automation_revision; + for text in ["over", "lap"] { + frame(&mut app, &ctx, vec![Event::Text(text.into())]); + assert_eq!(app.doc.automation_revision, revision); + } + click(&mut app, &ctx, text_position(&output, "Pick peaks")); + let note = |app: &PlotiumApp| { + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks[0] + .note + .clone() + }; + assert_eq!(note(&app), "overlap"); + app.undo(); + assert_eq!(note(&app), ""); + app.redo(); + assert_eq!(note(&app), "overlap"); +} + +#[test] +fn coordinate_drag_commits_once_and_undo_restores_all_coordinates() { + let mut app = fixture(); + let owner = app.doc.datasets[0].resource_id(); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm = std::array::from_fn(|a| volume.processed.axis_coordinate(a, 1).unwrap()); + let id = app.add_peak_3d(owner, ppm).unwrap(); + app.locate_peak_3d(owner, id).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let output = frame(&mut app, &ctx, vec![]); + let label = text_position(&output, "F1 ") - egui::vec2(5., 5.); + let start = output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) + if t.galley.text().parse::().is_ok() + && (t.pos.y - label.y).abs() < 4. + && t.pos.x > label.x => + { + Some(t.pos + egui::vec2(5., 5.)) + } + _ => None, + }) + .expect("coordinate input"); + let revision = app.doc.automation_revision; + frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(start), + Event::PointerButton { + pos: start, + button: PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + ); + for distance in [15., 30., 45.] { + frame( + &mut app, + &ctx, + vec![Event::PointerMoved(start + egui::vec2(distance, 0.))], + ); + assert_eq!(app.doc.automation_revision, revision); + } + let end = start + egui::vec2(45., 0.); + frame( + &mut app, + &ctx, + vec![Event::PointerButton { + pos: end, + button: PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }], + ); + let position = |app: &PlotiumApp| { + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks[0] + .ppm + }; + assert_ne!(position(&app), ppm); + let moved = position(&app); + app.undo(); + assert_eq!(position(&app), ppm); + app.redo(); + assert_eq!(position(&app), moved); +} diff --git a/crates/app/src/ui/properties/discovery_tests.rs b/crates/app/src/ui/properties/discovery_tests.rs index e63a1367..1c2dca2d 100644 --- a/crates/app/src/ui/properties/discovery_tests.rs +++ b/crates/app/src/ui/properties/discovery_tests.rs @@ -129,10 +129,9 @@ fn overlaid_display_encodings_are_reported_as_ambiguous() { assert_eq!(labels, vec!["Lowest level", "Colour range"]); } -/// Every declared group has a Ribbon button, a menu entry and a palette hit — -/// derived from `GROUPS`, so declaring a group is the whole registration. +/// Parameter groups retain their own discoverable Ribbon entries. #[test] -fn every_declared_group_has_a_ribbon_entry() { +fn every_declared_group_has_a_ribbon_home() { let app = PlotiumApp::new_with_settings(plotium_core::settings::Settings::default()); let catalog = commands::catalog(&app); for group in GROUPS { diff --git a/crates/app/src/ui/ribbon/layout.rs b/crates/app/src/ui/ribbon/layout.rs index 938f6755..08f16d58 100644 --- a/crates/app/src/ui/ribbon/layout.rs +++ b/crates/app/src/ui/ribbon/layout.rs @@ -385,7 +385,14 @@ pub(super) fn groups_for_tab( pub(super) fn group_order(tab: WorkflowTab, group: &str) -> u8 { let order: &[&str] = match tab { WorkflowTab::Data => &["Import", "Build", "Export"], - WorkflowTab::Process => &["Processing", "Correct", "Transform", "XAS", "Recipes"], + WorkflowTab::Process => &[ + "Processing", + "Correct", + "3D Phase", + "Transform", + "XAS", + "Recipes", + ], WorkflowTab::Analyze => &[ "Range", "XPS", diff --git a/crates/app/src/ui/tools/mod.rs b/crates/app/src/ui/tools/mod.rs index 41491c43..8ca63aac 100644 --- a/crates/app/src/ui/tools/mod.rs +++ b/crates/app/src/ui/tools/mod.rs @@ -220,6 +220,13 @@ fn analysis_group(app: &mut PlotiumApp, di: usize, ui: &mut Ui) -> bool { } fn peaks_group(app: &mut PlotiumApp, di: usize, ui: &mut Ui) -> bool { + if app.doc.datasets[di] + .as_nmr2d() + .is_some_and(|n| n.volume.is_some()) + { + super::peaks3d::panel(app, di, ui); + return false; + } let column = app.session.ui.peak_column; if app .doc diff --git a/crates/cli/tests/nmr_peaks.rs b/crates/cli/tests/nmr_peaks.rs new file mode 100644 index 00000000..52971333 --- /dev/null +++ b/crates/cli/tests/nmr_peaks.rs @@ -0,0 +1,78 @@ +use serde_json::{Value, json}; +use std::process::Command; + +#[test] +fn cli_peak_edits_and_export_match_direct_core_and_persist() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition( + plotium_io::load_path(fixture).unwrap().acquisition, + ) + .unwrap(); + let mut direct = plotium_core::state::PlotiumApp::new(); + let owner = dataset.resource_id(); + direct.doc.datasets.push(dataset); + let v = direct.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm: [f64; 3] = std::array::from_fn(|a| v.processed.axis_coordinate(a, 1).unwrap()); + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("input.plotium"); + plotium_core::project::save_project(&direct, &project, false).unwrap(); + let id = direct.add_peak_3d(owner, ppm).unwrap(); + let mut requests = vec![json!({"operation":"add","ppm":ppm})]; + direct + .edit_peak_3d(owner, id, ppm, "manual observation".into()) + .unwrap(); + requests.push(json!({"operation":"edit","id":id,"note":"manual observation"})); + direct.locate_peak_3d(owner, id).unwrap(); + requests.push(json!({"operation":"locate","id":id})); + for (index, parameters) in requests.into_iter().enumerate() { + let request = root.path().join("request.json"); + std::fs::write( + &request, + json!({"tool_id":"nmr.peaks3d","parameters":parameters,"targets":plotium_core::automation::TargetSelector::Explicit { ids:vec![owner.to_string()] }}).to_string(), + ) + .unwrap(); + let output = root.path().join(format!("step-{index}.plotium")); + let result = Command::new(env!("CARGO_BIN_EXE_plotium-cli")) + .args(["tool", "run"]) + .arg(&project) + .arg("--request") + .arg(&request) + .arg("--output") + .arg(&output) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + std::fs::copy(output, &project).unwrap(); + } + let request = root.path().join("list.json"); + std::fs::write(&request, json!({"tool_id":"nmr.peaks3d.list","targets":plotium_core::automation::TargetSelector::Explicit { ids:vec![owner.to_string()] }}).to_string()).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_plotium-cli")) + .args(["tool", "run"]) + .arg(&project) + .arg("--request") + .arg(request) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let result: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(result["value"], direct.peak_table_3d(owner).unwrap()); + let reopened = plotium_core::project::load_project(&project).unwrap(); + assert_eq!( + reopened.peak_table_3d(owner).unwrap(), + direct.peak_table_3d(owner).unwrap() + ); +} diff --git a/crates/core/examples/nmr_peak_acceptance.rs b/crates/core/examples/nmr_peak_acceptance.rs new file mode 100644 index 00000000..712db9de --- /dev/null +++ b/crates/core/examples/nmr_peak_acceptance.rs @@ -0,0 +1,64 @@ +//! Host-data acceptance: cargo run -p plotium-core --example nmr_peak_acceptance -- /path/to/ubiquitin +use plotium_core::state::PlotiumApp; +fn main() -> Result<(), String> { + let root = std::path::PathBuf::from( + std::env::args() + .nth(1) + .ok_or("Supply an ubiquitin experiment root")?, + ); + for experiment in [2, 14, 16] { + let loaded = + plotium_io::load_path(root.join(experiment.to_string())).map_err(|e| e.to_string())?; + let (dataset, _) = plotium_core::workflow::dataset_from_acquisition(loaded.acquisition) + .map_err(|e| e.to_string())?; + let mut app = PlotiumApp::new(); + let id = dataset.resource_id(); + app.doc.datasets.push(dataset); + let v = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let indices = v.processed.shape().map(|n| n / 2); + let ppm = [ + v.processed.axis_coordinate(0, indices[0])?, + v.processed.axis_coordinate(1, indices[1])?, + v.processed.axis_coordinate(2, indices[2])?, + ]; + let expected = v.processed.signed_value(indices)?; + let peak = app.add_peak_3d(id, ppm)?; + app.edit_peak_3d(id, peak, ppm, "host acceptance observation".into())?; + for axis in 0..3 { + app.select_nmr_volume_plane(id, axis, 0)?; + app.locate_peak_3d(id, peak)?; + } + app.delete_peak_3d(id, peak)?; + app.undo(); + let path = std::env::temp_dir().join(format!( + "plotium-host-peaks-{}-{experiment}.plotium", + std::process::id() + )); + plotium_core::project::save_project(&app, &path, false).map_err(|e| e.to_string())?; + let reopened = plotium_core::project::load_project(&path).map_err(|e| e.to_string())?; + std::fs::remove_file(path).map_err(|e| e.to_string())?; + let peak = &reopened.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks[0]; + if peak.ppm != ppm + || peak.intensity != expected + || peak.note != "host acceptance observation" + { + return Err("Peak roundtrip differs".into()); + } + println!( + "experiment={experiment} ppm={ppm:?} signed_intensity={expected} navigation=3axes undo=pass project_roundtrip=pass" + ); + } + Ok(()) +} diff --git a/crates/core/src/automation/mod.rs b/crates/core/src/automation/mod.rs index aa3361cf..5667e464 100644 --- a/crates/core/src/automation/mod.rs +++ b/crates/core/src/automation/mod.rs @@ -94,3 +94,5 @@ mod tests; #[cfg(test)] mod table_tests; + +mod peaks3d; diff --git a/crates/core/src/automation/peaks3d.rs b/crates/core/src/automation/peaks3d.rs new file mode 100644 index 00000000..b032f5ed --- /dev/null +++ b/crates/core/src/automation/peaks3d.rs @@ -0,0 +1,107 @@ +//! Shared CLI/MCP operations on complete three-axis peak observations. +use super::*; +use crate::state::Peak3DId; +use serde::Deserialize; +use serde_json::json; +pub const PEAKS_3D_LIST: &str = "nmr.peaks3d.list"; +pub const PEAKS_3D: &str = "nmr.peaks3d"; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Params { + #[serde(default)] + pub operation: String, + pub id: Option, + pub ppm: Option<[f64; 3]>, + pub note: Option, +} +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 3D spectrum".into(), + )); + }; + let di = app + .doc + .datasets + .iter() + .position(|d| d.resource_id().to_string() == target.id) + .ok_or_else(|| AutomationError::InvalidSelector("Select an existing spectrum".into()))?; + let dataset = app.doc.datasets[di].resource_id(); + let mut p: Params = super::registry::parse(PEAKS_3D, plan.request.parameters.clone())?; + if plan.request.tool_id == PEAKS_3D_LIST { + p.operation = "list".into(); + } + let fail = |s: &str| AutomationError::Execution(s.into()); + let mut modified = false; + match p.operation.as_str() { + "list" => {} + "add" => { + app.add_peak_3d( + dataset, + p.ppm.ok_or_else(|| fail("Supply three ppm coordinates"))?, + ) + .map_err(AutomationError::Execution)?; + modified = true; + } + "edit" => { + let id = p.id.ok_or_else(|| fail("Supply a peak ID"))?; + let old = app.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .and_then(|v| v.peaks.marks.iter().find(|m| m.id == id)) + .cloned() + .ok_or_else(|| fail("Select an existing 3D peak"))?; + app.edit_peak_3d( + dataset, + id, + p.ppm.unwrap_or(old.ppm), + p.note.unwrap_or(old.note), + ) + .map_err(AutomationError::Execution)?; + modified = true; + } + "delete" => { + app.delete_peak_3d(dataset, p.id.ok_or_else(|| fail("Supply a peak ID"))?) + .map_err(AutomationError::Execution)?; + modified = true; + } + "locate" => { + app.locate_peak_3d(dataset, p.id.ok_or_else(|| fail("Supply a peak ID"))?) + .map_err(AutomationError::Execution)?; + modified = true; + } + _ => { + return Err(fail("Choose list, add, edit, delete or locate")); + } + } + let mut result = super::tool_executors::readonly_value( + &plan.request.tool_id, + plan.frozen_targets.revision, + app.peak_table_3d(dataset) + .map_err(AutomationError::Execution)?, + ); + if modified { + result.modified.push(target.clone()); + } + result.after_revision = DocumentRevision(app.doc.automation_revision); + Ok(result) +} +pub(super) fn descriptors() -> Vec { + let mut edit = ToolDescriptor{id:PEAKS_3D.into(),version:1,title:"Edit or inspect 3D peaks".into(),description:"Persistent peak coordinates in acquisition-axis order; list returns an exportable peak table with axis identities.".into(),parameter_schema:json!({"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"enum":["list","add","edit","delete","locate"]},"id":{"type":"integer","minimum":0},"ppm":{"type":"array","minItems":3,"maxItems":3,"items":{"type":"number"}},"note":{"type":"string"}}}),result_schema:json!({"type":"object"}),target_kinds:vec![ResourceKindId::new(KIND_DATASET)],required_capabilities:vec![CapabilityId::new(CAP_PROCESSING_SCHEME)],effect:EffectLevel::Reversible,undoable:true,deterministic:true,task_kind:"synchronous".into()}; + let mut list = edit.clone(); + list.id = PEAKS_3D_LIST.into(); + list.title = "List and export 3D peaks".into(); + list.effect = EffectLevel::ReadOnly; + list.undoable = false; + list.parameter_schema = json!({"type":"object","additionalProperties":false}); + edit.description + .push_str(" Use nmr.peaks3d.list for read-only export."); + vec![list, edit] +} + +#[cfg(test)] +#[path = "peaks3d_tests.rs"] +mod tests; diff --git a/crates/core/src/automation/peaks3d_tests.rs b/crates/core/src/automation/peaks3d_tests.rs new file mode 100644 index 00000000..c7983cf1 --- /dev/null +++ b/crates/core/src/automation/peaks3d_tests.rs @@ -0,0 +1,60 @@ +use super::*; +#[test] +fn automation_and_direct_edits_share_peak_records_and_readonly_export() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = crate::workflow::dataset_from_acquisition( + plotium_io::load_path(&fixture).unwrap().acquisition, + ) + .unwrap(); + let mut app = crate::state::PlotiumApp::new(); + let id = dataset.resource_id(); + app.doc.datasets.push(dataset); + let volume = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm = [ + volume.processed.axis_coordinate(0, 1).unwrap(), + volume.processed.axis_coordinate(1, 1).unwrap(), + volume.processed.axis_coordinate(2, 2).unwrap(), + ]; + let run = |app: &mut crate::state::PlotiumApp, tool: &str, parameters: serde_json::Value| { + let request = ToolRequest { + tool_id: tool.into(), + tool_version: 1, + parameters, + targets: TargetSelector::Explicit { + ids: vec![id.to_string()], + }, + expected_revision: DocumentRevision(app.doc.automation_revision), + caller: CallerType::Agent, + }; + let plan = plan_tool(app, request).unwrap(); + execute_tool(app, plan, ExecutionAuthority::ReversibleModify).unwrap() + }; + run(&mut app, PEAKS_3D, json!({"operation":"add","ppm":ppm})); + let revision = app.doc.automation_revision; + let result = run(&mut app, PEAKS_3D_LIST, json!({})); + assert_eq!(revision, app.doc.automation_revision); + assert!(result.modified.is_empty()); + let mark = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks + .marks[0] + .clone(); + assert_eq!( + result.value["peaks"][0], + serde_json::to_value(&mark).unwrap() + ); + app.edit_peak_3d(id, mark.id, ppm, "manual".into()).unwrap(); + let result = run(&mut app, PEAKS_3D_LIST, json!({})); + assert_eq!(result.value["peaks"][0]["note"], "manual"); + assert_eq!(result.value["axes"].as_array().unwrap().len(), 3); +} diff --git a/crates/core/src/automation/registry.rs b/crates/core/src/automation/registry.rs index f84cf1d9..531e11f0 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 { + super::peaks3d::PEAKS_3D_LIST => parse::(tool, value).map(drop), + super::peaks3d::PEAKS_3D => parse::(tool, value).map(drop), NMR_VOLUME_INSPECT => parse::(tool, value).map(drop), NMR_VOLUME_EDIT => parse::(tool, value).map(drop), FIELD_PLOT_CREATE | DIFFUSION_PLOT_CREATE => { @@ -615,6 +617,7 @@ fn descriptors() -> Vec { .chain(super::scientific_plot::descriptors()) .chain(super::science::descriptors()) .chain(super::nmr_volume::descriptors()) + .chain(super::peaks3d::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/tools.rs b/crates/core/src/automation/tools.rs index 765509f6..e6c26dc5 100644 --- a/crates/core/src/automation/tools.rs +++ b/crates/core/src/automation/tools.rs @@ -525,6 +525,9 @@ pub(super) fn execute_planned_tool( REGIONS_EXTRACT | DIFFUSION_FIT => super::region_fit::execute(app, &plan)?, DOSY_BUILD => super::dosy::execute(app, &plan)?, ANALYSIS_RESULTS_INSPECT => super::dosy::inspect(app, &plan)?, + super::peaks3d::PEAKS_3D_LIST | super::peaks3d::PEAKS_3D => { + super::peaks3d::execute(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 => { diff --git a/crates/core/src/project/convert.rs b/crates/core/src/project/convert.rs index 77b08bc4..3ac10d4c 100644 --- a/crates/core/src/project/convert.rs +++ b/crates/core/src/project/convert.rs @@ -166,7 +166,7 @@ pub fn dataset_to_objects<'a>( 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["plotium.volume"] = serde_json::json!({"fixed_axis":volume.fixed_axis,"index":volume.index,"phases":volume.phases,"anchor":volume.anchor,"peaks":volume.peaks}); } extensions }, @@ -536,6 +536,11 @@ pub fn object_to_dataset( )); } dataset.volume.as_mut().unwrap().anchor = view.anchor; + if let Some(peaks) = recipe.extensions["plotium.volume"].get("peaks") { + let volume = dataset.volume.as_mut().unwrap(); + volume.peaks = serde_json::from_value(peaks.clone())?; + volume.refresh_peaks().map_err(ProjectError::Invalid)?; + } dataset.acquisition_identity = read_acquisition_identity(data)?; dataset.field_catalog = read_field_catalog(data)?; dataset.name = data.label.clone(); diff --git a/crates/core/src/state/app_impl_nmr_volume.rs b/crates/core/src/state/app_impl_nmr_volume.rs index 5ed47db1..804457a4 100644 --- a/crates/core/src/state/app_impl_nmr_volume.rs +++ b/crates/core/src/state/app_impl_nmr_volume.rs @@ -142,6 +142,8 @@ impl PlotiumApp { // Navigation may have advanced while this phase job was running. if let Some(incoming) = result.volume.as_mut() { incoming.anchor = volume.anchor; + incoming.peaks = volume.peaks.clone(); + incoming.refresh_peaks()?; } // Preserve edits made while the numerical worker was running. let mut replacement = current.clone(); diff --git a/crates/core/src/state/datasets_dispatch.rs b/crates/core/src/state/datasets_dispatch.rs index a88d04d9..50cf4261 100644 --- a/crates/core/src/state/datasets_dispatch.rs +++ b/crates/core/src/state/datasets_dispatch.rs @@ -461,7 +461,7 @@ impl Dataset { ToolGroup::LineFit, ], Dataset::Nmr(_) => &[ToolGroup::Processing], - Dataset::Nmr2D(n) if n.volume.is_some() => &[ToolGroup::Processing], + Dataset::Nmr2D(n) if n.volume.is_some() => &[ToolGroup::Processing, ToolGroup::Peaks], 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 97c9d255..420c2aeb 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -395,3 +395,6 @@ pub use external_save::{ExternalSaveStatus, ExternalSaves, SaveReceipt}; mod scientific_actions; mod scientific_desktop; + +mod peaks3d; +pub use peaks3d::*; diff --git a/crates/core/src/state/nmr_volume.rs b/crates/core/src/state/nmr_volume.rs index 2e6b7605..ee70b093 100644 --- a/crates/core/src/state/nmr_volume.rs +++ b/crates/core/src/state/nmr_volume.rs @@ -6,6 +6,7 @@ use plotium_processing::nmr_volume::{NmrVolume, VolumePhase}; #[derive(Clone)] pub struct NmrVolumeState { pub source: NmrSource, + pub peaks: Peak3DSet, pub processed: NmrVolume, pub fixed_axis: usize, pub index: usize, @@ -63,6 +64,7 @@ impl Nmr2DDataset { anchor[fixed_axis] = index; dataset.volume = Some(NmrVolumeState { source, + peaks: Peak3DSet::default(), processed, fixed_axis, index, @@ -111,6 +113,7 @@ impl Nmr2DDataset { .ok_or("Three-dimensional spectrum disappeared")?; volume.processed = processed; volume.phases = phases; + volume.refresh_peaks()?; Ok(()) } diff --git a/crates/core/src/state/peaks3d.rs b/crates/core/src/state/peaks3d.rs new file mode 100644 index 00000000..c632b913 --- /dev/null +++ b/crates/core/src/state/peaks3d.rs @@ -0,0 +1,236 @@ +//! Persistent observations in acquisition-axis order, independent of display orientation. +use super::*; +use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Peak3DId(pub u64); +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Peak3DMark { + pub id: Peak3DId, + pub ppm: [f64; 3], + pub intensity: f64, + pub note: String, +} +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Peak3DSet { + pub marks: Vec, + pub next_id: u64, +} +impl NmrVolumeState { + pub fn peak_indices(&self, ppm: [f64; 3]) -> Result<[usize; 3], String> { + Ok([ + self.processed.nearest(0, ppm[0])?, + self.processed.nearest(1, ppm[1])?, + self.processed.nearest(2, ppm[2])?, + ]) + } + pub fn refresh_peaks(&mut self) -> Result<(), String> { + let mut ids = std::collections::HashSet::new(); + let mut peaks = self.peaks.clone(); + for peak in &mut peaks.marks { + if !ids.insert(peak.id) { + return Err("Duplicate 3D peak ID".into()); + } + peak.intensity = self.processed.signed_value(self.peak_indices(peak.ppm)?)?; + } + let minimum = peaks + .marks + .iter() + .map(|p| p.id.0.checked_add(1).ok_or("3D peak ID exhausted")) + .collect::, _>>()? + .into_iter() + .max() + .unwrap_or(0); + peaks.next_id = peaks.next_id.max(minimum); + self.peaks = peaks; + Ok(()) + } +} +impl PlotiumApp { + /// A standalone observation table with acquisition and processing provenance. + pub fn peak_table_3d(&self, dataset: DatasetId) -> Result { + let nmr = self + .doc + .dataset_index(dataset) + .and_then(|i| self.doc.datasets[i].as_nmr2d()) + .ok_or("Select a three-dimensional spectrum")?; + let volume = nmr + .volume + .as_ref() + .ok_or("Select a three-dimensional spectrum")?; + let axes = volume + .processed + .axes() + .iter() + .enumerate() + .map(|(axis, a)| { + serde_json::json!({"axis":axis,"dimension":format!("F{}", axis + 1), + "nucleus":a.nucleus(),"unit":"ppm"}) + }) + .collect::>(); + let digest = |source: &plotium_io::nmr_view::NmrSource| -> String { + source + .dataset() + .canonical_digests() + .dataset() + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + }; + Ok(serde_json::json!({ + "version":1, "dataset":dataset, "dataset_name":nmr.name, + "source": { + "identity":nmr.acquisition_identity, + "canonical_dataset_sha256":digest(&volume.source) + }, + "processing": { + "phases":volume.phases, + "canonical_dataset_sha256":digest(volume.processed.source()) + }, + "axes":axes, "peaks":volume.peaks.marks, + "position":"manual observation in acquisition-axis order; not a fitted centre", + "intensity":"signed nearest-grid sample; not integrated volume" + })) + } + + pub fn add_peak_3d(&mut self, dataset: DatasetId, ppm: [f64; 3]) -> Result { + let mut created = Peak3DId(0); + self.edit_volume_peaks(dataset, |v| { + let intensity = v.processed.signed_value(v.peak_indices(ppm)?)?; + created = Peak3DId(v.peaks.next_id); + v.peaks.next_id = v + .peaks + .next_id + .checked_add(1) + .ok_or("3D peak ID exhausted")?; + v.peaks.marks.push(Peak3DMark { + id: created, + ppm, + intensity, + note: String::new(), + }); + Ok(()) + })?; + Ok(created) + } + pub fn edit_peak_3d( + &mut self, + dataset: DatasetId, + id: Peak3DId, + ppm: [f64; 3], + note: String, + ) -> Result<(), String> { + self.edit_volume_peaks(dataset, |v| { + let intensity = v.processed.signed_value(v.peak_indices(ppm)?)?; + let mark = v + .peaks + .marks + .iter_mut() + .find(|p| p.id == id) + .ok_or("Select an existing 3D peak")?; + *mark = Peak3DMark { + id, + ppm, + intensity, + note, + }; + Ok(()) + }) + } + pub fn delete_peak_3d(&mut self, dataset: DatasetId, id: Peak3DId) -> Result<(), String> { + self.edit_volume_peaks(dataset, |v| { + let i = v + .peaks + .marks + .iter() + .position(|p| p.id == id) + .ok_or("Select an existing 3D peak")?; + v.peaks.marks.remove(i); + Ok(()) + }) + } + pub fn locate_peak_3d(&mut self, dataset: DatasetId, id: Peak3DId) -> Result<(), String> { + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let v = self.doc.datasets[di] + .as_nmr2d() + .and_then(|n| n.volume.as_ref()) + .ok_or("Select a three-dimensional spectrum")?; + let peak = v + .peaks + .marks + .iter() + .find(|p| p.id == id) + .ok_or("Select an existing 3D peak")?; + let ppm = peak.ppm; + let axis = v.fixed_axis; + let index = v.peak_indices(ppm)?[axis]; + let remaining: Vec<_> = (0..3).filter(|a| *a != axis).collect(); + 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 mut actions = vec![self.volume_install_action(dataset, after)?]; + 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 after = before.clone(); + let center = |range: AxisRange, full: AxisRange, value: f64| { + let half = range.span() * 0.5; + AxisRange::new(value - half, value + half).clamp_to(full) + }; + after.view_x = center(before.view_x, before.full_x, ppm[remaining[1]]); + after.view_y = center(before.view_y, before.full_y, ppm[remaining[0]]); + after.auto_y = false; + actions.push(crate::actions::Action::SetObjectViewport { + canvas: ci, + object: object.id, + before, + after, + }); + } + } + self.try_execute_action(crate::actions::Action::Composite(actions)) + .map_err(|e| e.to_string())?; + self.session.ui.selected_peak_3d = Some((dataset, id)); + 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 edit_volume_peaks( + &mut self, + dataset: DatasetId, + edit: impl FnOnce(&mut NmrVolumeState) -> Result<(), String>, + ) -> Result<(), String> { + let di = self + .doc + .dataset_index(dataset) + .ok_or("Select an existing spectrum")?; + let before = self.doc.datasets[di].clone(); + let mut after = before.clone(); + let v = after + .as_nmr2d_mut() + .and_then(|n| n.volume.as_mut()) + .ok_or("Select a three-dimensional spectrum")?; + edit(v)?; + self.try_execute_action(crate::actions::Action::InstallScientificState { + before: vec![before], + after: vec![after], + }) + .map_err(|e| e.to_string())?; + Ok(()) + } +} + +#[cfg(test)] +#[path = "peaks3d_tests.rs"] +mod tests; diff --git a/crates/core/src/state/peaks3d_tests.rs b/crates/core/src/state/peaks3d_tests.rs new file mode 100644 index 00000000..53bbeecf --- /dev/null +++ b/crates/core/src/state/peaks3d_tests.rs @@ -0,0 +1,229 @@ +use super::*; +#[test] +fn peak_identity_coordinates_and_notes_survive_navigation_undo_and_project() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let acquisition = plotium_io::load_path(&fixture).unwrap().acquisition; + let (dataset, _) = crate::workflow::dataset_from_acquisition(acquisition).unwrap(); + let mut app = PlotiumApp::new(); + let dataset_id = dataset.resource_id(); + app.doc.datasets.push(dataset); + let v = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + let ppm = [ + v.processed.axis_coordinate(0, 1).unwrap(), + v.processed.axis_coordinate(1, 1).unwrap(), + v.processed.axis_coordinate(2, 2).unwrap(), + ]; + let intensity = v.processed.signed_value([1, 1, 2]).unwrap(); + let id = app.add_peak_3d(dataset_id, ppm).unwrap(); + app.edit_peak_3d(dataset_id, id, ppm, "overlap".into()) + .unwrap(); + for axis in 0..3 { + app.select_nmr_volume_plane(dataset_id, axis, 0).unwrap(); + app.locate_peak_3d(dataset_id, id).unwrap(); + } + let current = app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap(); + assert_eq!(current.peaks.marks[0].intensity, intensity); + assert_eq!(current.index, 2); + let before = current.peaks.clone(); + assert!( + app.edit_peak_3d(dataset_id, id, [f64::NAN; 3], String::new()) + .is_err() + ); + app.delete_peak_3d(dataset_id, id).unwrap(); + app.undo(); + assert_eq!( + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks, + before + ); + let path = + std::env::temp_dir().join(format!("plotium-peaks3d-{}.plotium", uuid::Uuid::new_v4())); + crate::project::save_project(&app, &path, false).unwrap(); + let restored = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!( + restored.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() + .peaks, + before + ); +} + +fn fixture() -> (PlotiumApp, DatasetId) { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf"); + let (dataset, _) = + crate::workflow::dataset_from_acquisition(plotium_io::load_path(path).unwrap().acquisition) + .unwrap(); + let id = dataset.resource_id(); + let mut app = PlotiumApp::new(); + app.doc.datasets.push(dataset); + (app, id) +} +fn volume(app: &PlotiumApp) -> &NmrVolumeState { + app.doc.datasets[0] + .as_nmr2d() + .unwrap() + .volume + .as_ref() + .unwrap() +} + +// An independent direct DFT of the small QF acquisition gives numerical answers +// without using the volume sampler or FFT that peak operations exercise. +fn expected_height(indices: [usize; 3]) -> f64 { + use std::f64::consts::TAU; + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../io/tests/fixtures/nmr/bruker-3d-qf/ser"); + let bytes = std::fs::read(path).unwrap(); + let samples: Vec<_> = bytes + .as_chunks::<4>() + .0 + .iter() + .map(|b| f64::from(i32::from_le_bytes(*b))) + .collect(); + let frequencies = [ + indices[0] as f64 - 2., + indices[1] as f64 - 1., + indices[2] as f64 - 2., + ]; + let mut expected = 0.; + for i in 0..5 { + for j in 0..3 { + for k in 0..4 { + let offset = ((i * 3 + j) * 4 + k) * 2; + let angle = -TAU * frequencies[2] * k as f64 / 4.; + let weight = (TAU * frequencies[0] * i as f64 / 5.).cos() + * (TAU * frequencies[1] * j as f64 / 3.).cos(); + expected += + weight * (samples[offset] * angle.cos() - samples[offset + 1] * angle.sin()); + } + } + } + expected +} + +#[test] +fn manual_peaks_map_unequal_axes_duplicate_nuclei_signed_samples_and_boundaries() { + let (mut app, owner) = fixture(); + assert_eq!(volume(&app).processed.shape(), [5, 3, 4]); + assert_eq!( + volume(&app).processed.axes()[0].nucleus(), + volume(&app).processed.axes()[2].nucleus() + ); + let mut negative = false; + for a in 0..5 { + for b in 0..3 { + for c in 0..4 { + let indices = [a, b, c]; + let ppm = std::array::from_fn(|axis| { + volume(&app) + .processed + .axis_coordinate(axis, indices[axis]) + .unwrap() + }); + let id = app.add_peak_3d(owner, ppm).unwrap(); + let mark = volume(&app).peaks.marks.last().unwrap(); + assert_eq!((mark.id, mark.ppm), (id, ppm)); + let expected = expected_height(indices); + assert!((mark.intensity - expected).abs() < 1e-8); + negative |= mark.intensity < -1e-8; + assert_eq!(volume(&app).peak_indices(ppm).unwrap(), indices); + for (axis, index) in indices.into_iter().enumerate() { + app.select_nmr_volume_plane(owner, axis, 0).unwrap(); + app.locate_peak_3d(owner, id).unwrap(); + assert_eq!(volume(&app).index, index); + assert_eq!(app.session.ui.selected_peak_3d, Some((owner, id))); + } + } + } + } + assert!(negative); + let v = volume(&app); + let mut ppm = std::array::from_fn(|axis| v.processed.axis_coordinate(axis, 0).unwrap()); + ppm[0] += (v.processed.axis_coordinate(0, 1).unwrap() - ppm[0]) * 0.2; + let id = app.add_peak_3d(owner, ppm).unwrap(); + assert_eq!(volume(&app).peaks.marks.last().unwrap().ppm, ppm); + assert!( + (volume(&app).peaks.marks.last().unwrap().intensity - expected_height([0; 3])).abs() < 1e-8 + ); + let before = volume(&app).peaks.clone(); + for invalid in [[f64::NAN; 3], [f64::INFINITY; 3], [1e10; 3]] { + assert!(app.add_peak_3d(owner, invalid).is_err()); + assert!( + app.edit_peak_3d(owner, id, invalid, "invalid".into()) + .is_err() + ); + assert_eq!(volume(&app).peaks, before); + } +} + +#[test] +fn phase_refresh_preserves_edits_made_during_worker_and_roundtrips_export() { + let (mut app, owner) = fixture(); + let indices = [1, 1, 2]; + let ppm = std::array::from_fn(|axis| { + volume(&app) + .processed + .axis_coordinate(axis, indices[axis]) + .unwrap() + }); + let id = app.add_peak_3d(owner, ppm).unwrap(); + let before_export = app.peak_table_3d(owner).unwrap(); + let baseline = volume(&app).phases; + let mut phases = baseline; + phases[2].p0_degrees = 180.; + let mut worker = app.doc.datasets[0].as_nmr2d().unwrap().clone(); + worker.set_volume_phases(phases).unwrap(); + app.edit_peak_3d(owner, id, ppm, "edited during phase processing".into()) + .unwrap(); + let other = app.add_peak_3d(owner, ppm).unwrap(); + app.install_nmr_volume_result(owner, baseline, worker) + .unwrap(); + for mark in &volume(&app).peaks.marks { + assert_eq!(mark.ppm, ppm); + assert!((mark.intensity + expected_height(indices)).abs() < 1e-8); + } + assert_eq!(volume(&app).peaks.marks[0].id, id); + assert_eq!(volume(&app).peaks.marks[1].id, other); + let after = volume(&app).peaks.clone(); + app.undo(); + assert!((volume(&app).peaks.marks[0].intensity - expected_height(indices)).abs() < 1e-8); + app.redo(); + assert_eq!(volume(&app).peaks, after); + let table = app.peak_table_3d(owner).unwrap(); + assert_eq!(table["source"], before_export["source"]); + assert_ne!( + table["processing"]["canonical_dataset_sha256"], + before_export["processing"]["canonical_dataset_sha256"] + ); + assert_eq!(table["processing"]["phases"][2]["p0_degrees"], 180.); + let path = std::env::temp_dir().join(format!( + "plotium-peaks-phase-{}.plotium", + uuid::Uuid::new_v4() + )); + crate::project::save_project(&app, &path, false).unwrap(); + let restored = crate::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(restored.peak_table_3d(owner).unwrap(), table); +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 05402f44..536eece4 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -181,6 +181,7 @@ impl Default for ArrangeGridDraft { } pub struct UiState { + pub selected_peak_3d: Option<(DatasetId, super::Peak3DId)>, /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, /// 2D axis targeted by the Phase panel and canvas drag; re-clamped when rendered. @@ -443,6 +444,7 @@ impl UiState { impl Default for UiState { fn default() -> Self { Self { + selected_peak_3d: None, interaction: Interaction::Idle, phase_axis: PhaseAxis::F2, analysis_selection: None, diff --git a/docs/src/content/docs/guides/nmr-3d.md b/docs/src/content/docs/guides/nmr-3d.md index 6f30baac..83032cf9 100644 --- a/docs/src/content/docs/guides/nmr-3d.md +++ b/docs/src/content/docs/guides/nmr-3d.md @@ -41,3 +41,39 @@ 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. + +## 3D peak editing + +Choose **Analyze → Peaks** or **Pick peaks** in the peak panel, then click the +spectrum to add a manual observation. Click a mark to select it, drag it to move +its two displayed coordinates, and press Delete to remove it. The peak panel also +provides a Delete button and editors for all three coordinates and a note. +Each drag or completed text edit creates one undo step. + +Single-click a peak-table row to select it, locate its slice and centre the view +while retaining the zoom scale. Previous and Next step through the same records. +Column labels include acquisition dimension, nucleus and ppm, so two proton axes +remain distinct. Each peak retains its ID and three acquisition-axis coordinates +across orientation changes. Only marks on the current slice are shown. + +These positions are manual observations, not fitted peak centres. Intensities are +signed nearest-grid sample heights, not integrated volumes. Peak edits support +undo and redo and are saved in `.plotium` projects. Phase changes retain peak +identities and positions and refresh their sampled signed intensities. + +**Export peak table…** writes JSON with the dataset ID and name, acquisition +identity, source and processed-volume canonical SHA-256 digests, applied phase +settings, axis identities, coordinates, notes and signed heights. These fields +identify the source and processed state even outside the project. + +Automation uses `nmr.peaks3d.list` for read-only inspection and JSON export, and +`nmr.peaks3d` for add/edit/delete/locate operations. Inspect the tool schemas for +parameters, stable peak IDs and acquisition-axis ordering. + +For a 3D spectrum, **Process → 3D Phase → 3D Phase settings** opens the per-axis +phase controls and ends peak picking. Apply phase explicitly after editing. +**Interactive phase** is the on-plot tool for standalone 1D/2D spectra; +**Phase settings** remains their separate parameter-panel entry. + +MCP exposes the read-only table through `plotium_peaks3d_inspect`. Use +`plotium_plan` / `plotium_execute` with `nmr.peaks3d` for edits. diff --git a/docs/src/content/docs/reference/mcp/scientific.md b/docs/src/content/docs/reference/mcp/scientific.md index 5356bb38..53df4b56 100644 --- a/docs/src/content/docs/reference/mcp/scientific.md +++ b/docs/src/content/docs/reference/mcp/scientific.md @@ -9,7 +9,7 @@ runs them too, with [`plotium-cli tool run`](/reference/cli/#single-step-scienti ## Scientific operations and tasks -`plotium_processing_inspect`, `plotium_diffusion_inspect` and +`plotium_processing_inspect`, `plotium_diffusion_inspect`, `plotium_peaks3d_inspect` and `plotium_analysis_results_inspect` are read-only queries with explicit `ids`, `expected_project_context` and `expected_revision`. Result inspection accepts `offset` and `limit` (1–100); use smaller pages for large ILT grids. Discovery's @@ -24,6 +24,7 @@ Scientific edits use `plotium_plan` / `plotium_execute`: | `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` | +| `nmr.peaks3d` | One 3D NMR dataset; `operation` is `add`, `edit`, `delete` or `locate`. `ppm` contains three coordinates in acquisition-axis order; `id` is a stable peak ID; `note` is optional. Read the complete peak table and its provenance with `plotium_peaks3d_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²` | @@ -36,7 +37,7 @@ 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. -Input updates, scan merges and plane-only volume edits are synchronous. Other +Input updates, manual 3D peak edits, 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 diff --git a/docs/src/content/docs/reference/ribbon.md b/docs/src/content/docs/reference/ribbon.md index 5ae2e685..d0aa40b9 100644 --- a/docs/src/content/docs/reference/ribbon.md +++ b/docs/src/content/docs/reference/ribbon.md @@ -18,8 +18,8 @@ available. - **Data** — **Import** (open files, folders, tables, and images), **Build** (new tables, stacking), **Export** (data export). - **Process** — **Processing** (the pipeline steps: apodization, zero fill, - phase, baseline, reference, and friends), **Correct** (the interactive - manual-phase tool), **Transform** (arithmetic, spectrum alignment, CRAFT), + baseline, reference, and friends), **Correct** (**Interactive phase**, the on-plot 1D/2D tool), + **3D Phase** (**3D Phase settings**, for the full volume), **Transform** (arithmetic, spectrum alignment, CRAFT), **Recipes** (processing templates). - **Analyze** — **Range** (the analysis range every fit reads), then the groups your active dataset supports: **XPS** (the XPS workbench pages), diff --git a/docs/src/content/docs/zh-cn/guides/nmr-3d.md b/docs/src/content/docs/zh-cn/guides/nmr-3d.md index 0361ea2b..74610a4c 100644 --- a/docs/src/content/docs/zh-cn/guides/nmr-3d.md +++ b/docs/src/content/docs/zh-cn/guides/nmr-3d.md @@ -33,3 +33,34 @@ CLI 工具目录和 MCP 与桌面使用相同核心操作。 三维体数据暂不提供通用数值表格导出,因为现有二维表格格式无法保留固定坐标 和采集轴身份。保存项目可保留数据,展示用途可导出图像。 + +## 三维峰编辑 + +选择 **Analyze → Peaks** 或峰面板中的 **Pick peaks**,然后点击谱图添加人工观测。 +点击标记选中峰,拖动改变当前显示的两个坐标,按 Delete 删除。 +峰面板也提供 Delete 按钮,以及三轴坐标和备注编辑器。 +每次拖动或完成一次文字编辑产生一条撤销记录。 + +单击峰表记录即可选中、定位到对应切片并居中,同时保留缩放尺度。 +Previous 和 Next 用于逐峰浏览。列标签包含采集维度、核种和 ppm, +因此两个氢轴仍能区分。切换方向后,每个峰保留相同 ID 和三个采集轴坐标。 +谱图只显示属于当前切片的峰。 + +这些位置是人工观测,不是拟合峰中心。强度是最近采样网格点的有符号高度, +不是积分体积。峰编辑支持撤销、重做,并随 `.plotium` 项目保存。 +相位变化保留峰身份与位置,更新有符号采样强度。 + +**Export peak table…** 导出 JSON,包含数据集 ID 和名称、采集身份、原始数据与 +处理后体数据的规范 SHA-256 摘要、已应用的相位参数、轴身份、坐标、备注及 +有符号高度。这些信息可在离开项目后识别来源和处理状态。 + +自动化通过 `nmr.peaks3d.list` 只读检查和导出 JSON,通过 `nmr.peaks3d` +增删改峰或定位。参数、稳定峰 ID 与采集轴顺序请参阅工具 schema。 + +选中三维谱时,**Process → 3D Phase → 3D Phase settings** 打开三轴相位控件, +并结束标峰模式。编辑参数后明确应用相位。 +**Interactive phase** 是独立 1D/2D 谱的谱图交互调相工具; +**Phase settings** 保留为独立的参数面板入口。 + +MCP 通过 `plotium_peaks3d_inspect` 提供只读峰表;编辑操作使用 +`plotium_plan` / `plotium_execute`,指定 `nmr.peaks3d`。 diff --git a/docs/src/content/docs/zh-cn/reference/mcp/scientific.md b/docs/src/content/docs/zh-cn/reference/mcp/scientific.md index f871aeda..32dd17ed 100644 --- a/docs/src/content/docs/zh-cn/reference/mcp/scientific.md +++ b/docs/src/content/docs/zh-cn/reference/mcp/scientific.md @@ -8,7 +8,7 @@ description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图 ## 科学操作与任务 -`plotium_processing_inspect`、`plotium_diffusion_inspect` 和 +`plotium_processing_inspect`、`plotium_diffusion_inspect`、`plotium_peaks3d_inspect` 和 `plotium_analysis_results_inspect` 是只读查询,使用显式 `ids`、 `expected_project_context` 和 `expected_revision`。结果检查接受 `offset`、 `limit`(1–100);ILT 网格较大时请减小页长。发现信息中的 @@ -22,6 +22,7 @@ description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图 | `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` 查看原始轴和相位设置 | +| `nmr.peaks3d` | 一个三维 NMR 数据集;`operation` 为 `add`、`edit`、`delete` 或 `locate`;`ppm` 为按采集轴顺序排列的三个坐标,`id` 为稳定峰 ID,可选 `note` 为备注。使用 `plotium_peaks3d_inspect` 只读获取完整峰表和来源信息 | | `diffusion.set_input` | 一个数据集;完整 `input`;非法输入错误带结构化校验报告 | | `analysis.regions.extract` | 一个支持区域序列的字段;`regions` 引用已有 RegionId 或给出边界和指标;可选显式 `input` | | `analysis.diffusion.fit` | 一个原生表格;`response_column` 为稳定 ColumnId;可选原生 `options`;x 列单位必须为 `s/m²` | @@ -33,7 +34,7 @@ description: 通过 MCP 运行处理、扩散拟合和 DOSY,并为结果建图 `Mean`、`Max` 和 `Min`。区域提取生成独立原生表格,保留来源、区域到列的 对应及扩散增量证据,不自动创建绘图。 -输入设置、扫描合并和仅切层的三维操作同步执行;其他写操作返回 +输入设置、人工三维峰编辑、扫描合并和仅切层的三维操作同步执行;其他写操作返回 `ToolResult.value.task`。预检即分配 `task_id`;`queued` 只表示已登记。使用 `plotium_tasks_get({task_id})`、 `plotium_tasks_list({offset,limit})` 查询,使用 diff --git a/docs/src/content/docs/zh-cn/reference/ribbon.md b/docs/src/content/docs/zh-cn/reference/ribbon.md index 9b56c471..648dc629 100644 --- a/docs/src/content/docs/zh-cn/reference/ribbon.md +++ b/docs/src/content/docs/zh-cn/reference/ribbon.md @@ -15,8 +15,9 @@ Analyze → Figure → Arrange**,**View** 排在最后。它是快捷入口— - **Data**——**Import**(打开文件、文件夹、表格与图片)、**Build**(新建 表格、堆叠)、**Export**(数据导出)。 -- **Process**——**Processing**(处理管线各步骤:切趾、补零、相位、基线、 - 参考等)、**Correct**(交互式手动相位工具)、**Transform**(谱运算、谱 +- **Process**——**Processing**(处理管线各步骤:切趾、补零、基线、 + 参考等)、**Correct**(**Interactive phase**:1D/2D 谱图交互调相)、 + **3D Phase**(**3D Phase settings**:完整体数据的相位参数面板)、**Transform**(谱运算、谱 对齐、CRAFT)、**Recipes**(处理模板)。 - **Analyze**——**Range**(所有拟合读取的分析范围),然后是当前数据集支持 的分组:**XPS**(XPS 工作台各页)、**Extract**(质谱提取)、**Regions**、