Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ publish = false
repository = "https://github.com/nmrtist/plotium"

[workspace.dependencies]
nmr = "=0.1.2"
nmr = "=0.1.3"
colorous = "1"
num-complex = "0.4"
rustfft = "6"
Expand All @@ -65,7 +65,7 @@ substrait = { version = "0.63", default-features = false, features = ["serde"] }
prost = "0.14"
pbjson-types = "0.8"
chrono-tz = "0.10"
calamine = { version = "0.37", default-features = false, features = ["dates"] }
calamine = { version = "0.38", default-features = false, features = ["dates"] }
rust_xlsxwriter = { version = "0.100", default-features = false, features = ["constant_memory"] }
tokio = { version = "1", features = ["rt"] }
egui = "0.34"
Expand Down
9 changes: 7 additions & 2 deletions crates/agent/src/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ use plotium_core::automation::{
};
use serde_json::{Value, json};

pub(crate) const EDIT_OPERATIONS: [&str; 26] = [
pub(crate) const EDIT_OPERATIONS: [&str; 27] = [
"analysis.diffusion.plot",
"plot.field.create",
"analysis.dosy.build",
"analysis.regions.extract",
"analysis.diffusion.fit",
"nmr.volume.edit",
"processing.edit",
"processing.apply_scheme",
"diffusion.set_input",
Expand All @@ -35,7 +36,11 @@ pub(crate) const EDIT_OPERATIONS: [&str; 26] = [

/// One line per editable operation, so a caller can map a task to an operation
/// before fetching its schema.
const PURPOSES: [(&str, &str); 29] = [
const PURPOSES: [(&str, &str); 30] = [
(
"nmr.volume.edit",
"Select a 3D spectrum plane and optionally adjust per-axis phase",
),
(
"analysis.diffusion.plot",
"Plot a diffusion fit with residuals on a new canvas",
Expand Down
23 changes: 23 additions & 0 deletions crates/agent/src/native_import_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,26 @@ fn native_reservations_are_shared_and_released_on_every_plan_consumption() {
);
assert_eq!(s.remaining_import_bytes(), NATIVE_IMPORT_BYTES);
}

#[test]
fn mcp_can_import_and_select_a_three_dimensional_plane() {
let mut app = PlotiumApp::new_with_settings(Default::default());
let mut s = session();
let p = plan(&mut s, &mut app, &[fixture("bruker-3d-qf")]);
execute(&mut s, &mut app, &p).unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
while app.compute_busy() && Instant::now() < deadline {
app.poll_compute();
std::thread::sleep(Duration::from_millis(2));
}
app.poll_compute();
assert!(!app.compute_busy());
let id = app.doc.datasets[0].resource_id().to_string();
let args = json!({"tool_id":"nmr.volume.edit","parameters":{"fixed_axis":2,"index":1},"ids":[id],"expected_revision":app.doc.automation_revision,"expected_project_context":app.session.project_context});
let p = s.call(&mut app, "plotium_plan", args).unwrap();
let result = execute(&mut s, &mut app, &p).unwrap();
assert_eq!(result["value"]["fixed_axis"], 2);
assert_eq!(result["value"]["index"], 1);
let n = app.doc.datasets[0].as_nmr2d().unwrap();
assert_eq!(n.volume.as_ref().unwrap().processed.shape(), [5, 3, 4]);
}
1 change: 1 addition & 0 deletions crates/agent/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ impl Session {
if matches!(
args.tool_id.as_str(),
PROCESSING_EDIT
| "nmr.volume.edit"
| DIFFUSION_SET_INPUT
| DOSY_BUILD
| REGIONS_EXTRACT
Expand Down
30 changes: 30 additions & 0 deletions crates/app/src/ui/canvas/cursors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,36 @@ fn inspect_text(app: &PlotiumApp, dataset: usize, point: CursorPoint) -> String
)
}
Dataset::Nmr2D(n) => {
if let Some(volume) = &n.volume {
let remaining: Vec<_> = (0..3).filter(|axis| *axis != volume.fixed_axis).collect();
let mut indices = volume.anchor;
for (axis, coordinate) in [
(remaining[0], point.y.unwrap_or_default()),
(remaining[1], point.x),
] {
match volume.processed.nearest(axis, coordinate) {
Ok(index) => indices[axis] = index,
Err(error) => return format!("Coordinate unavailable: {error}"),
}
}
let mut values = Vec::new();
for (axis, index) in indices.into_iter().enumerate() {
let value = match volume.processed.axis_coordinate(axis, index) {
Ok(value) => value,
Err(error) => return format!("Coordinate unavailable: {error}"),
};
let nucleus = volume.source.axes()[axis]
.nucleus
.as_deref()
.unwrap_or("unknown");
values.push(format!("{nucleus}(F{}) {value:.4} ppm", axis + 1));
}
return format!(
"{} · sampled I {}",
values.join(" · "),
fmt_number(point.intensity)
);
}
let Processed2D::Ft(s) = &n.processed else {
return String::new();
};
Expand Down
2 changes: 2 additions & 0 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ pub(crate) enum CanvasInteractionClearScope {
}

pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) {
super::nmr_volume::poll_phase(app, ui.ctx());
let Some(ci) = app.session.active_canvas else {
if app.doc.canvases.is_empty() && !board_frames(app).is_empty() {
board::render_sheets_only(app, ui);
Expand All @@ -144,6 +145,7 @@ pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) {
return;
};
canvas_breadcrumb(app, ci, ui);
super::nmr_volume::navigation(app, ui);
let avail = ui.available_rect_before_wrap();
let (resp, painter) = ui.allocate_painter(avail.size(), Sense::click_and_drag());
let geometry = super::workspace_geometry(app, resp.rect, ui.ctx());
Expand Down
6 changes: 6 additions & 0 deletions crates/app/src/ui/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ fn execute_inner(
}
CommandId::OpenFile => super::file_dialogs::open_file(app),
CommandId::OpenFolder => super::file_dialogs::open_folder(app),
CommandId::CancelNmrVolumePhase => super::nmr_volume::cancel_phase(ctx),
CommandId::ApplyNmrVolumePhase => super::nmr_volume::apply_phase(app, ctx),
CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => {
super::nmr_volume::step(app, id == CommandId::NextNmrPlane);
}
CommandId::ImportNmrSampling => super::file_dialogs::nmr_sampling::open(app),
CommandId::AgentConnections => {
app.open_settings();
Expand Down Expand Up @@ -99,6 +104,7 @@ fn execute_inner(
}
CommandId::ImportTiffPages => super::file_dialogs::image_import::import_tiff_pages(app),
CommandId::PasteImage => super::file_dialogs::image_import::paste_clipboard_image(app),
CommandId::CancelDataImport => app.cancel_data_imports(),
CommandId::CancelImageImport => super::file_dialogs::image_import::cancel_all(app),
CommandId::ReplaceImage => super::file_dialogs::image_import::replace_selected_image(app),
CommandId::PasteTable => {
Expand Down
30 changes: 15 additions & 15 deletions crates/app/src/ui/commands.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
pub use super::command_exec::{execute, execute_without_clipboard};
use plotium_core::actions::ZOrder;
use plotium_core::export::ExportFormat;
use plotium_core::layout::{Align, Distribute, GutterPreset, SpacingMode};
use plotium_core::properties::PropertyStep;
#[cfg(test)]
use plotium_core::state::WorkflowTab;
use plotium_core::state::{Dataset, PlotiumApp, Tool, ToolGroup};

pub use super::command_exec::{execute, execute_without_clipboard};
mod craft;
mod identity;
use identity::command_identity;
Expand All @@ -23,7 +22,6 @@ pub(crate) use ribbon::stack_row;
pub use ribbon::{Applicability, RibbonPlacement};
use roster::command_ids;
pub(crate) const MANUAL_URL: &str = "https://plotium.nmrtist.space/";
/// The public source repository, linked from About.
pub(crate) const REPOSITORY_URL: &str = "https://github.com/nmrtist/plotium";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandId {
Expand All @@ -35,6 +33,10 @@ pub enum CommandId {
OpenFile,
OpenFolder,
ImportNmrSampling,
ApplyNmrVolumePhase,
CancelNmrVolumePhase,
PreviousNmrPlane,
NextNmrPlane,
RunBatchWorkflow,
AgentConnections,
RunScientificScript,
Expand All @@ -50,6 +52,7 @@ pub enum CommandId {
ImportImageWithoutMetadata,
ImportTiffPages,
PasteImage,
CancelDataImport,
CancelImageImport,
ReplaceImage,
PasteTable,
Expand Down Expand Up @@ -163,7 +166,6 @@ pub enum CommandId {
CycleCursor,
Tool(Tool),
}

/// Architectural ownership for commands that expose or consume results. New
/// commands must choose a class instead of bypassing the automation registry
/// with an arbitrary Action.
Expand All @@ -173,7 +175,6 @@ pub enum CommandExecutionClass {
ToolEditor,
ToolBacked,
}

pub struct CommandDescriptor {
pub id: CommandId,
pub execution_class: CommandExecutionClass,
Expand All @@ -188,7 +189,6 @@ pub struct CommandDescriptor {
pub shortcut: Option<String>,
pub ribbon: Option<RibbonPlacement>,
}

pub fn catalog(app: &PlotiumApp) -> Vec<CommandDescriptor> {
command_ids(app.session.recent_files.len())
.into_iter()
Expand All @@ -206,7 +206,6 @@ pub fn catalog(app: &PlotiumApp) -> Vec<CommandDescriptor> {
})
.collect()
}

pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
let has_canvas = app.session.active_canvas.is_some();
let selected = app.session.ui.selection.objects().len();
Expand Down Expand Up @@ -238,9 +237,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
let has_selectable_analysis_trace = || has_trace() && !is_time_nmr();
let has_generic_peak_fit_trace = || has_trace() && (is_frequency_nmr() || is_table());
let range = || active_dataset.and_then(|di| app.analysis_range_for(di));

let is_series = || dataset().is_some_and(Dataset::supports_region_analysis);

let (label, icon, checked) = command_identity(app, id);
// The gate decides the enabled state and the disabled tooltip together, so a
// command can never be blocked by one requirement while explaining another.
Expand Down Expand Up @@ -296,6 +293,11 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
app.session.ui.table_import_preview.is_none(),
"Finish or cancel the current table import preview before importing another table.",
),
CommandId::CancelNmrVolumePhase => requires(app.session.ui.nmr_volume_phase_busy, "Start phase processing before cancelling it."),
CommandId::ApplyNmrVolumePhase => super::nmr_volume::phase_gate(app),
CommandId::PreviousNmrPlane | CommandId::NextNmrPlane => {
super::nmr_volume::step_gate(app, id == CommandId::NextNmrPlane)
}
CommandId::ImportNmrSampling => requires(
app.session.ui.nmr_import.is_none(),
"Finish or cancel the current NMR import before importing another acquisition.",
Expand All @@ -305,6 +307,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
| CommandId::ImportImageWithoutMetadata
| CommandId::ImportTiffPages
| CommandId::PasteImage => Ok(()),
CommandId::CancelDataImport => requires(app.session.data_imports.is_pending(), "Start a data import before cancelling it."),
CommandId::CancelImageImport => requires(
super::file_dialogs::image_import::has_active_jobs(),
"Start an image import before cancelling it.",
Expand All @@ -330,7 +333,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
dataset().is_some_and(|dataset| {
!plotium_core::data_export::DataExportAvailability::for_dataset(dataset).is_empty()
}),
"Select a dataset with processed data or analysis results to export.",
"Select a supported dataset for numerical export; save 3D data as a project or export its figure.",
),
CommandId::Export(_) => requires(has_canvas, "Open a canvas before exporting a figure."),
CommandId::CopyFigure => requires(
Expand Down Expand Up @@ -709,9 +712,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
.is_some_and(|dataset| dataset.supports_symmetry_review()),
"Select a homonuclear COSY, TOCSY, or NOESY / ROESY contour spectrum.",
),
// On-plot phasing needs a spectrum with a phase step to act on. The
// gate is the Phase settings tile's, so the tool and the settings it
// drives never disagree about when phasing is possible.
// Share the Phase settings gate so its tool stays consistent.
CommandId::Tool(Tool::ManualPhase) => requires(
super::properties::discovery::group_applies(
app,
Expand All @@ -726,6 +727,7 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
CommandId::Tool(_) => requires(has_canvas, "Open a canvas before using this tool."),
_ => Ok(()),
};
let gate = super::nmr_volume::editing_gate(app, id).and(gate);
// The palette cannot be opened over a modal or active gesture. Those states
// hide every surface that could display its disabled tooltip, so this is the
// one intentional disabled-without-reason path.
Expand Down Expand Up @@ -773,11 +775,9 @@ pub fn describe(app: &PlotiumApp, id: CommandId) -> CommandDescriptor {
ribbon,
}
}

#[cfg(test)]
#[path = "commands_alignment_tests.rs"]
mod alignment_tests;

#[cfg(test)]
#[path = "commands_craft_tests.rs"]
mod craft_tests;
Expand Down
10 changes: 10 additions & 0 deletions crates/app/src/ui/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub(super) fn command_identity(
CommandId::CloseWindow => plain("Close Window", Some(icon::X)),
CommandId::OpenFile => plain("Open File…", Some(icon::FILE)),
CommandId::OpenFolder => plain("Open Folder…", Some(icon::FOLDER)),
CommandId::CancelNmrVolumePhase => plain("Cancel 3D NMR Phase", Some(icon::X)),
CommandId::ApplyNmrVolumePhase => plain("Apply 3D NMR Phase", Some(icon::PLAY)),
CommandId::PreviousNmrPlane => plain("Previous NMR Plane", Some(icon::CARET_LEFT)),
CommandId::NextNmrPlane => plain("Next NMR Plane", Some(icon::CARET_RIGHT)),
CommandId::ImportNmrSampling => plain("Import NMR with Sampling Table…", Some(icon::FILE)),
CommandId::AgentConnections => plain("AI connections (MCP)…", None),
CommandId::RunBatchWorkflow => plain("Automation…", Some(icon::PLAY)),
Expand Down Expand Up @@ -88,6 +92,7 @@ pub(super) fn command_identity(
}
CommandId::ImportTiffPages => plain("Add All TIFF Pages…", Some(icon::IMAGES_SQUARE)),
CommandId::PasteImage => plain("Paste Image", Some(icon::CLIPBOARD_TEXT)),
CommandId::CancelDataImport => plain("Cancel Data Import", Some(icon::X)),
CommandId::CancelImageImport => plain("Cancel Image Import", Some(icon::X)),
CommandId::ReplaceImage => plain("Replace Image…", Some(icon::ARROWS_CLOCKWISE)),
CommandId::PasteTable => plain("Paste Table from Clipboard", Some(icon::CLIPBOARD_TEXT)),
Expand Down Expand Up @@ -455,6 +460,10 @@ fn simple_stable_id(id: CommandId) -> &'static str {
CommandId::OpenFile => "file.open_file",
CommandId::OpenFolder => "file.open_folder",
CommandId::ImportNmrSampling => "file.import_nmr_sampling",
CommandId::CancelNmrVolumePhase => "nmr.phase.cancel",
CommandId::ApplyNmrVolumePhase => "nmr.phase.apply",
CommandId::PreviousNmrPlane => "nmr.plane.previous",
CommandId::NextNmrPlane => "nmr.plane.next",
CommandId::RunBatchWorkflow => "tools.automation",
CommandId::AgentConnections => "file.agent_connections",
CommandId::RunScientificScript => "tools.run_scientific_script",
Expand All @@ -465,6 +474,7 @@ fn simple_stable_id(id: CommandId) -> &'static str {
CommandId::ImportImageWithoutMetadata => "figure.import_image_without_metadata",
CommandId::ImportTiffPages => "figure.import_tiff_pages",
CommandId::PasteImage => "figure.paste_image",
CommandId::CancelDataImport => "file.cancel_data_import",
CommandId::CancelImageImport => "figure.cancel_image_import",
CommandId::ReplaceImage => "figure.replace_image",
CommandId::PasteTable => "file.paste_table",
Expand Down
5 changes: 5 additions & 0 deletions crates/app/src/ui/commands/roster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub(super) fn command_ids(recent_files: usize) -> Vec<CommandId> {
CommandId::OpenFile,
CommandId::OpenFolder,
CommandId::ImportNmrSampling,
CommandId::ApplyNmrVolumePhase,
CommandId::CancelNmrVolumePhase,
CommandId::PreviousNmrPlane,
CommandId::NextNmrPlane,
CommandId::RunBatchWorkflow,
CommandId::AgentConnections,
CommandId::RunScientificScript,
Expand All @@ -32,6 +36,7 @@ pub(super) fn command_ids(recent_files: usize) -> Vec<CommandId> {
CommandId::ImportImageWithoutMetadata,
CommandId::ImportTiffPages,
CommandId::PasteImage,
CommandId::CancelDataImport,
CommandId::CancelImageImport,
CommandId::ReplaceImage,
CommandId::PasteTable,
Expand Down
4 changes: 3 additions & 1 deletion crates/app/src/ui/commands_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,9 @@ fn data_export_requires_exportable_current_data() {
assert!(!command.enabled);
assert_eq!(
command.disabled_reason,
Some("Select a dataset with processed data or analysis results to export.")
Some(
"Select a supported dataset for numerical export; save 3D data as a project or export its figure."
)
);

let table = app_with_table(1);
Expand Down
Loading
Loading