diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java b/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java index fc26338..778f82c 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java @@ -67,6 +67,17 @@ public void changedUpdate(DocumentEvent e) { getDocument().addDocumentListener(docListener); } + /** + * Marks the field valid or invalid by setting its background color. + *
+ * The caller decides what valid means and owns any tooltip explaining it. + * + * @param isValid {@code false} to show the error color + */ + public void setValid(final boolean isValid) { + setBackground(isValid ? defaultColor_ : ERROR_COLOR); + } + /** * Returns {@code true} if the filename is valid on Windows. * diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java index bb10243..8c6fcf4 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java @@ -97,7 +97,7 @@ public void createUserInterface() { txtSaveDirectory_ = new TextField(); txtSaveDirectory_.setEditable(false); txtSaveDirectory_.setColumns(18); - txtSaveDirectory_.setForeground(Color.BLACK); + txtSaveDirectory_.setForeground(Color.WHITE); txtSaveDirectory_.setText(acqSettings.saveDirectory()); txtSaveFileName_ = new TextField(); @@ -118,6 +118,7 @@ public void createUserInterface() { btnLoadSettings_ = new Button("Load", 60, 20); btnConvertSettings_ = new Button("Convert", 72, 20); + txtSaveDirectory_.setToolTipText("The directory to save images to."); btnBrowse_.setToolTipText("Select the save directory with the file browser."); btnOpen_.setToolTipText("Open the file explorer to the save directory."); btnSaveSettings_.setToolTipText("Save the current acquisition settings to JSON."); @@ -149,9 +150,13 @@ public void createEventHandlers() { if (result != null) { model_.acquisitions().settingsBuilder().saveDirectory(result.toString()); txtSaveDirectory_.setText(result.toString()); + validateSaveDirectory(); } }); + // flag a saved directory that no longer exists as soon as the plugin opens + validateSaveDirectory(); + // use the text field so we don't need to update settings btnOpen_.registerListener( () -> openDirectory(txtSaveDirectory_.getText())); @@ -214,6 +219,31 @@ public void createEventHandlers() { }); } + // Colors the save directory field and explains why in its tooltip when the path is unusable. + // Only called where the value arrives from outside the field (plugin load, browse, settings + // change); this touches the filesystem, and checking a dead network path can block the EDT. + private void validateSaveDirectory() { + final String path = txtSaveDirectory_.getText(); + final String problem; + if (path == null || path.trim().isEmpty()) { + problem = "The save directory is not set."; + } else { + final File directory = new File(path); + if (!directory.exists()) { + problem = "This directory does not exist: " + path; + } else if (!directory.isDirectory()) { + problem = "This is a file, not a directory: " + path; + } else if (!directory.canWrite()) { + problem = "This directory is not writable: " + path; + } else { + problem = null; + } + } + txtSaveDirectory_.setValid(problem == null); + txtSaveDirectory_.setToolTipText( + problem == null ? "The directory to save images to." : problem); + } + // Opens the file explorer to the save directory private void openDirectory(final String path) { final File directory = new File(path); @@ -223,21 +253,22 @@ private void openDirectory(final String path) { try { desktop.open(directory); } catch (IOException e) { - model_.studio().logs().logError( - "Could not open the save directory."); + model_.studio().logs().showError( + "Could not open the save directory:\n\n" + path); } } else { - model_.studio().logs().logError( - "Desktop is not supported on this platform."); + model_.studio().logs().showError( + "Opening a file explorer is not supported on this platform."); } } else { - model_.studio().logs().logError("Directory does not exist."); + model_.studio().logs().showError("The save directory does not exist:\n\n" + path); } } @Override public void onSettingsChanged(final AcquisitionSettings settings) { txtSaveDirectory_.setText(settings.saveDirectory()); + validateSaveDirectory(); txtSaveFileName_.setText(settings.saveNamePrefix()); cbxSaveMode_.setSelected(settings.saveMode()); cbxSaveWhileAcquiring_.setSelected(settings.isSavingImagesDuringAcquisition()); diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java index 60a29c0..bd33365 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java @@ -484,9 +484,9 @@ public boolean prepareControllerForAcquisitionSide( // The galvo/sheet offset must always land on the true imaging plane. For a stage scan the // piezo convention parks piezoCenter at home (~0); using that here would place the sheet on // the home plane instead of the imaging center, so the acquired volume is offset from the - // stage sweep -- the #404 "scan not centered" symptom. diSPIM 1.4 SCOPE never writes the - // galvo offset during a stage scan (it holds the value the Setup tab computed from the - // imaging center); LSM writes it at acq time, so derive it from the imaging center here. + // stage sweep. diSPIM 1.4 SCOPE never writes the galvo offset during a stage scan (it holds + // the value the Setup tab computed from the imaging center); LSM writes it at acq time, so + // derive it from the imaging center here. final double galvoCenter = settings.stageScan().enabled() ? imagingCenter : piezoCenter; double sliceCenter = (galvoCenter - sliceOffset) / sliceRate; @@ -727,7 +727,7 @@ public boolean setupHardwareChannelSwitching(final ScapeAcquisitionSettings sett // 2. (newer) with 7-channel TTL-triggered on PLogic card shared with single camera trigger output (i.e. not dual-view system) // however they share some things like using cells 17-24 and building a 3-input LUT which code is just copy/paste right now final boolean isSevenChannelShutter = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_SHUTTER; - final boolean isSevenChannelShutterTTL = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_SHUTTER; + final boolean isSevenChannelShutterTTL = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_TTL_SHUTTER; if (isSevenChannelShutter) { // original special 7-channel case diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java index a8c3586..8560152 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java @@ -24,6 +24,7 @@ import org.micromanager.lightsheetmanager.model.channels.ChannelSpec; import org.micromanager.lightsheetmanager.model.devices.cameras.CameraBase; +import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -56,6 +57,64 @@ public abstract class AcquisitionEngine implements AcquisitionManager, MMAcquist protected final LightSheetManager model_; + /** + * Validates that the acquisition can actually be written to disk, before anything is acquired. + *
+ * The images are written by {@code finish()}, i.e. only AFTER the run completes, so without this + * check an unusable save location is discovered at the very end and the data is lost. Observed + * 2026-07-27: a 41 s dual-camera run ended in "could not save the acquisition data to: + * D:\SCOPE\test\Test" because {@code D:\SCOPE\test} did not exist. The same path also silently + * drops {@code acq_settings.json} and {@code position_list.pos}, which are written up front. + *
+ * Called from both geometry engines' {@code setup()} before any hardware is touched, so a failure
+ * costs nothing and leaves the microscope untouched.
+ *
+ * @return true if saving is off, or the save location is usable; false to abort setup
+ */
+ protected boolean validateSaveLocation() {
+ if (!acqSettings_.isSavingImagesDuringAcquisition()) {
+ return true; // not saving => nothing to validate
+ }
+
+ final String saveNamePrefix = acqSettings_.saveNamePrefix();
+ if (saveNamePrefix == null || saveNamePrefix.trim().isEmpty()) {
+ studio_.logs().showError("The save name prefix is empty.\n\n"
+ + "Set a name on the Datastore panel, or uncheck \"Save images during acquisition\".");
+ return false;
+ }
+
+ final String saveDirectory = acqSettings_.saveDirectory();
+ if (saveDirectory == null || saveDirectory.trim().isEmpty()) {
+ studio_.logs().showError("The save directory is not set.\n\n"
+ + "Set a directory on the Datastore panel, or uncheck "
+ + "\"Save images during acquisition\".");
+ return false;
+ }
+
+ final File directory = new File(saveDirectory);
+ if (!directory.exists()) {
+ studio_.logs().showError("The save directory does not exist:\n\n" + saveDirectory
+ + "\n\nCreate it, or choose another directory on the Datastore panel.");
+ return false;
+ }
+ if (!directory.isDirectory()) {
+ studio_.logs().showError("The save directory is a file, not a directory:\n\n"
+ + saveDirectory);
+ return false;
+ }
+ // canWrite() is advisory on Windows (it reports the read-only attribute, not the ACL), so it
+ // catches the common cases without being authoritative; a real write failure still surfaces
+ // at finish(). Cheap enough to be worth keeping.
+ if (!directory.canWrite()) {
+ studio_.logs().showError("The save directory is not writable:\n\n" + saveDirectory);
+ return false;
+ }
+
+ studio_.logs().logMessage("save location validated: " + saveDirectory
+ + File.separator + saveNamePrefix);
+ return true;
+ }
+
public AcquisitionEngine(final LightSheetManager model) {
model_ = Objects.requireNonNull(model);
studio_ = model.studio();
@@ -146,7 +205,9 @@ public Future> requestRun(boolean speedTest) {
try {
if (!setup()) {
- studio_.logs().showError("Error during setup!");
+ // every setup() failure path already showed its own specific error,
+ // so log this rather than stacking a second dialog on top of it
+ studio_.logs().logMessage("Error during setup!");
return; // early exit => stop acquisition
}
} catch (Exception e) {
diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java
index d72fdd7..01c43d0 100644
--- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java
+++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java
@@ -65,6 +65,12 @@ boolean setup() {
// make settings current
updateSettings();
+ // fail before touching any hardware: the datastore is written by finish(), so an unusable
+ // save location would otherwise cost a full acquisition before it is discovered
+ if (!validateSaveLocation()) {
+ return false; // early exit => save location unusable
+ }
+
return true;
}
@@ -248,7 +254,7 @@ boolean run() {
//////////// Acquisition hooks ////////////////////
// These functions will be run on different threads during the acquisition process
- // Hooks will run on the Acquisition Engine thread--the one that controls all hardware
+ // Hooks will run on the Acquisition Engine thread, the one that controls all hardware
// TODO add any code that needs to be executed on the acquisition thread (i.e. the one
// that controls hardware)
diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java
index bbcadd1..907851d 100644
--- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java
+++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java
@@ -81,6 +81,12 @@ boolean setup() {
// make settings current
updateSettings();
+ // fail before touching any hardware: the datastore is written by finish(), so an unusable
+ // save location would otherwise cost a full acquisition before it is discovered
+ if (!validateSaveLocation()) {
+ return false; // early exit => save location unusable
+ }
+
// // check pixel size
// if (core_.getPixelSizeUm() < 1e-6) {
// studio_.logs().showError(
@@ -321,7 +327,7 @@ boolean run() {
//////////// Acquisition hooks ////////////////////
// These functions will be run on different threads during the acquisition process
- // Hooks will run on the Acquisition Engine thread--the one that controls all hardware
+ // Hooks will run on the Acquisition Engine thread, the one that controls all hardware
// TODO add any code that needs to be executed on the acquisition thread (i.e. the one
// that controls hardware)
@@ -628,7 +634,7 @@ public void close() {
// software "Every Volume" multichannel submits ONE event
// iterator PER channel, so AcqEngJ flushes a SequenceEnd between
// channels (Engine.java:187) and the controller re-fires once per
- // channel-volume -- mirroring 1.4's per-channel loop and LSM's own
+ // channel-volume, mirroring 1.4's per-channel loop and LSM's own
// per-timepoint loop. Submitting all channels in a single iterator lets
// AcqEngJ merge identical-preset channels into one sequence that fires the
// controller once, collapsing the channel dimension (hang on hardware,
@@ -643,7 +649,7 @@ public void close() {
} else {
// Hardware channel modes (SLICE_HW / VOLUME_HW with hardware timepoints
// off): the controller emits all channels in one trigger, so a single
- // merged iterator is correct here -- unchanged.
+ // merged iterator is correct here, unchanged.
currentAcquisition_.submitEventIterator(
LightSheetEventAdapter.createChannelAcqEvents(
baseEvent.copy(), acqSettings_, cameraNames, null));
@@ -959,10 +965,11 @@ private boolean doHardwareCalculations(PLogicScape plc) {
// TODO: implement multiple positions using hardware time points, currently
// set hardware time points to false if using multiple positions
if (acqSettings_.isUsingMultiplePositions()) {
- if (acqSettings_.isUsingHardwareTimePoints()) {
+ if (isUsingHardwareTimePoints) {
// || acqSettings_.numTimePoints() > 1)
// && (timepointIntervalMs < timepointDuration*1.2)) {
asb_.useHardwareTimePoints(false);
+ isUsingHardwareTimePoints = false;
// studio_.logs().showError("Time point interval may not be sufficient "
// + "depending on actual time required to change positions. "
// + "Proceed at your own risk.");
@@ -970,9 +977,10 @@ private boolean doHardwareCalculations(PLogicScape plc) {
}
// only use hardware time points when use time points is checked
- if (acqSettings_.isUsingHardwareTimePoints()) {
+ if (isUsingHardwareTimePoints) {
if (!acqSettings_.isUsingTimePoints()) {
asb_.useHardwareTimePoints(false);
+ isUsingHardwareTimePoints = false;
}
}
diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java
index 5c72f61..c4cfa3a 100644
--- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java
+++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java
@@ -22,12 +22,16 @@
*/
public final class LightSheetEventAdapter {
+ // These strings are MM's, not ours: AcqEngJ keys event coordinates on them and the datastore and
+ // viewer size/display dimensions by them, so the VALUES cannot be changed; an axis MM does not
+ // know is dropped by TIFF storage and never displayed.
+ // CAMERA_AXIS is deliberately AcqEngJ's "channel" axis: LSM packs the combined
+ // (channelIndex * numCameras + cameraIndex) slot into it, which is why the name here says camera
+ // while the value says channel. Note setChannelName() writes this same axis.
public static final String TIME_AXIS = "time";
public static final String POSITION_AXIS = "position";
public static final String CAMERA_AXIS = "channel";
- // TODO: put this in the channel iterator (should not be global)
- public static int currentChannelIndex_ = 0;
public static boolean isUsingMultipleCameras = false;
/**
@@ -213,14 +217,8 @@ public static Iterator
- * Used by the {@link #channels}-composed factories, whose behavior this leaves exactly as it was.
+ * Used by the {@link #channels}-composed factories.
*/
public static Function
- * {@code CAMERA_AXIS} is AcqEngJ's {@code "channel"} axis ({@code AcqEngMetadata.CHANNEL_AXIS}) --
+ * {@code CAMERA_AXIS} is AcqEngJ's {@code "channel"} axis ({@code AcqEngMetadata.CHANNEL_AXIS}):
* {@code AcquisitionEvent.setChannelName(s)} compiles to {@code setAxisPosition("channel", s)}. So the
* channel index and the camera index share one axis, and the coordinate written here is the combined
* slot {@code channelIndex * numCameras + cameraIndex} that {@code addMMSummaryMetadata} names and
@@ -255,9 +253,9 @@ public static Function