From 920bc64afacc96808bf7bb20a2527ba44f73e064 Mon Sep 17 00:00:00 2001 From: Tiago Ferreira Date: Sat, 10 Oct 2026 11:28:30 -0400 Subject: [PATCH 1/3] Extend edit controls to Bdv/Bvv This is major refactoring: the node-editing logic of the ImagePlus tracing canvas now lives in a viewer-agnostic class, so the same actions of the ImagePlus contextual menu are available in Bdv & Bvv (context menu, single-key shortcuts, hover-based active node, undo, etc.). New classes - PathNodeEditor: Operations on a single Path (delete, insert, move, lock, radius, tag, re-root, split, connect, undo). Returns a Result that front-ends route with Result#dispatch(). Shared helpers: deleteNodeOrPath(gui), tagNode(path, index, color), replaceTree(), structuralChange() - NodeEditCommand / ContextMenuCommand: Host labels, icons, default shortcuts and menu population, shared by the canvas and the viewers - BigViewerEditor / BigViewerMenu: Bdv/Bvv interaction layer (hover picking, key bindings, right-click menu) on top of PathNodeEditor SNT / SNTUI changes: - SNT holds the PathNodeEditor (editing path, active node, undo history) shared by canvas, Bdv and Bvv; adds enterEditMode(Path), exitEditMode(), getEditModeBlocker(), notifyActiveNodeChanged() and notifyEditingPathChanged() (throttled) - enterEditMode(Path) selects the path first, as all edit operations require the editing path to be selected - editModeAllowed() no longer swaps the editing path while already editing; Stream mode restores its tracing state on exit - SNTUI fans Edit Mode notifications out to open viewers only (getOpenBigViewers() skips disposed ones) and tracks the most recently activated viewer Fixes / Behavior changes: - Bvv AnnRenderer: screen data and layer ranges are published as one immutable Frame/Source pair, fixing the unsynchronized screenData race (NPE in tests) - Bvv#getClickedPosition(null) no longer returns a stale cached position - SNT#startSholl() converts calibrated coordinates to pixel indices - Sholl at nearest node in viewers: selected paths, else the rendered path nearest to the cursor (same lookup as Grab Nearest Path) - Bookmarks added from a viewer in classic mode use the viewer's current timepoint; BookmarkManager moves open viewers and image windows, and reports locations outside the (materialized crop of the) image - Radius prompt: tolerate a corrupted saved value, message says "non-negative" (0 is accepted) - Deleting a whole path from the canvas now leaves Edit Mode; refusing to delete a junction node opens a dialog - Right-clicks no longer trigger tracing clicks in the viewers; M adds a bookmark in classic mode (it was blocked) - Removed the "Continue Extending Path" toolbar toggle from Bvv/Bdv (the action remains in the context menu) - Misc fixes --- .../java/sc/fiji/snt/BookmarkManager.java | 94 +- .../sc/fiji/snt/InteractiveTracerCanvas.java | 975 ++++-------------- .../java/sc/fiji/snt/PathAndFillManager.java | 72 +- src/main/java/sc/fiji/snt/PathNodeCanvas.java | 15 + src/main/java/sc/fiji/snt/PathNodeEditor.java | 868 ++++++++++++++++ src/main/java/sc/fiji/snt/SNT.java | 187 +++- src/main/java/sc/fiji/snt/SNTUI.java | 68 +- .../sc/fiji/snt/gui/ContextMenuCommand.java | 115 +++ src/main/java/sc/fiji/snt/gui/GuiUtils.java | 26 +- .../java/sc/fiji/snt/gui/NodeEditCommand.java | 214 ++++ .../fiji/snt/plugin/ShollAnalysisTreeCmd.java | 9 +- .../sc/fiji/snt/viewer/AbstractBigViewer.java | 398 +++++-- src/main/java/sc/fiji/snt/viewer/Bdv.java | 30 +- .../sc/fiji/snt/viewer/BigViewerEditor.java | 543 ++++++++++ .../sc/fiji/snt/viewer/BigViewerMenu.java | 223 ++++ src/main/java/sc/fiji/snt/viewer/Bvv.java | 120 ++- 16 files changed, 2913 insertions(+), 1044 deletions(-) create mode 100644 src/main/java/sc/fiji/snt/PathNodeEditor.java create mode 100644 src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java create mode 100644 src/main/java/sc/fiji/snt/gui/NodeEditCommand.java create mode 100644 src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java create mode 100644 src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java diff --git a/src/main/java/sc/fiji/snt/BookmarkManager.java b/src/main/java/sc/fiji/snt/BookmarkManager.java index 9bd9748b6..e49876a99 100644 --- a/src/main/java/sc/fiji/snt/BookmarkManager.java +++ b/src/main/java/sc/fiji/snt/BookmarkManager.java @@ -451,17 +451,14 @@ private void goToRow(final int tableRow) { // viewer mode: animate camera to marker world position flyTo(tableRow); } else { + // Classic mode: move the image windows and any open Bvv/Bdv viewer + final Bookmark b = model.getDataList().get(table.convertRowIndexToModel(tableRow)); + final int nViewers = flyOpenViewers(b); final ImagePlus imp = sntui.plugin.getImagePlus(); if (imp == null) { - noImageOpenError(); + if (nViewers == 0) noImageOpenError(); } else { - goTo(tableRow, imp); - if (!sntui.plugin.getSinglePane()) { - final ImagePlus zyImp = sntui.plugin.getImagePlus(SNT.ZY_PLANE); - if (zyImp != null) goTo(tableRow, zyImp, SNT.ZY_PLANE); - final ImagePlus xzImp = sntui.plugin.getImagePlus(SNT.XZ_PLANE); - if (xzImp != null) goTo(tableRow, xzImp, SNT.XZ_PLANE); - } + goToAllPlanes(sntui.plugin, b, false); } } } @@ -1303,7 +1300,7 @@ public void actionPerformed(final ActionEvent e) { viewer.promptGoTo(); return; } - if (sntui.plugin.getImagePlus() == null) { + if (sntui.plugin.getImagePlus() == null && sntui.getOpenBigViewers().isEmpty()) { noImageOpenError(); return; } @@ -1317,13 +1314,8 @@ public void actionPerformed(final ActionEvent e) { final PointInImage pim = SNTPoint.fromString(pos); final Bookmark b = new Bookmark(String.format("%.2f, %.2f, %.2f", pim.x, pim.y, pim.z), pim.x, pim.y, pim.z, 1, 1); - goTo(b, sntui.plugin.getImagePlus(), SNT.XY_PLANE); - if (!sntui.plugin.getSinglePane()) { - final ImagePlus zyImp = sntui.plugin.getImagePlus(SNT.ZY_PLANE); - if (zyImp != null) goTo(b, zyImp, SNT.ZY_PLANE); - final ImagePlus xzImp = sntui.plugin.getImagePlus(SNT.XZ_PLANE); - if (xzImp != null) goTo(b, xzImp, SNT.XZ_PLANE); - } + flyOpenViewers(b); + goToAllPlanes(sntui.plugin, b, false); sntui.showStatus(String.format("Zoomed to %s", b.label), true); } catch (final Throwable ex) { getGuiUtils().error("Could not extract a valid location from \"" + pos + "\"."); @@ -1367,11 +1359,14 @@ private boolean noBookmarksError() { return false; } - private void goTo(final int row, final ImagePlus imp, final int plane) { - goTo(model.getDataList().get(table.convertRowIndexToModel(row)), imp, plane); - } - private void goTo(final Bookmark b, final ImagePlus imp, final int plane) { + /** + * Moves the display of an image to a bookmark + * + * @param silent if true, no error is displayed if the bookmark lies outside the image + * @return true if the bookmark is within the image (and the display was moved) + */ + private boolean goTo(final Bookmark b, final ImagePlus imp, final int plane, final boolean silent) { assert imp != null; // Bookmark coordinates are always world/calibrated; convert to pixel space, which is @@ -1400,12 +1395,19 @@ private void goTo(final Bookmark b, final ImagePlus imp, final int plane) { } }; - if (viewX > imp.getWidth() || viewY > imp.getHeight()) { + final boolean outsideXY = viewX < 0 || viewY < 0 || viewX > imp.getWidth() || viewY > imp.getHeight(); + // Z is only checked on the main XY plane: side views show all Z by definition + final boolean outsideZ = plane == SNT.XY_PLANE && (pz < 0 || pz >= imp.getNSlices()); + if (outsideXY || outsideZ) { // Only show error for the main XY plane - if (plane == SNT.XY_PLANE) { - sntui.guiUtils.error("Location is outside image XY dimensions"); + if (plane == SNT.XY_PLANE && !silent) { + final SNT snt = snt(); + final String what = (snt != null && snt.isMaterializedCrop()) ? "the materialized crop" : "image"; + getGuiUtils().error((outsideXY && outsideZ) ? "Location is outside " + what + " bounds" + : (outsideXY) ? "Location is outside " + what + " XY dimensions" + : "Location is outside " + what + " Z range"); } - return; + return false; } if (plane == SNT.XY_PLANE) { // NB: ImagePlus#setPosition uses 1-based slice indices; pz is 0-based (see worldToPixel) @@ -1413,10 +1415,37 @@ private void goTo(final Bookmark b, final ImagePlus imp, final int plane) { } // Side views don't need setPosition - they show all Z by definition ImpUtils.zoomTo(imp, visitingZoom.fraction(), (int) viewX, (int) viewY); + return true; } - private void goTo(final int row, final ImagePlus imp) { - goTo(row, imp, SNT.XY_PLANE); + /** + * Viewer mode: moves the image windows of a materialized crop (Stream mode), if any, to a bookmark. Bookmarks + * outside the crop, which is only a portion of the streamed data, are skipped with a notice in the viewer + */ + private void goToMaterializedCrop(final Bookmark b) { + final SNT snt = snt(); + if (snt == null || !snt.isStreamMode() || !snt.isMaterializedCrop()) return; + if (snt.getImagePlus() == null) return; + if (!goToAllPlanes(snt, b, true)) viewer.showViewerMessage("Bookmark is outside the materialized crop"); + } + + /** + * Moves the image of the main (XY) view and, if displayed, the side views to a bookmark. Side views are only + * moved if the bookmark lies within the main view + * + * @param silent if true, no error is displayed if the bookmark lies outside the image + * @return true if the bookmark is within the image (and the displays were moved). False if there is no image + */ + private boolean goToAllPlanes(final SNT snt, final Bookmark b, final boolean silent) { + final ImagePlus imp = snt.getImagePlus(); + if (imp == null || !goTo(b, imp, SNT.XY_PLANE, silent)) return false; + if (!snt.getSinglePane()) { + final ImagePlus zyImp = snt.getImagePlus(SNT.ZY_PLANE); + if (zyImp != null) goTo(b, zyImp, SNT.ZY_PLANE, silent); + final ImagePlus xzImp = snt.getImagePlus(SNT.XZ_PLANE); + if (xzImp != null) goTo(b, xzImp, SNT.XZ_PLANE, silent); + } + return true; } /** Viewer mode: animates the camera to the world position of the selected marker row. */ @@ -1424,9 +1453,22 @@ private void flyTo(final int row) { flyTo(model.getDataList().get(table.convertRowIndexToModel(row))); } + /** + * SNT mode: animates every open Bvv/Bdv viewer to the location of the bookmark + * + * @return the number of viewers that were moved + */ + private int flyOpenViewers(final Bookmark b) { + if (sntui == null) return 0; + final java.util.List viewers = sntui.getOpenBigViewers(); + for (final AbstractBigViewer v : viewers) v.flyTo(b.getX(), b.getY(), b.getZ(), b.t, b.label); + return viewers.size(); + } + private void flyTo(final Bookmark b) { if (viewer == null) return; viewer.flyTo(b.getX(), b.getY(), b.getZ(), b.t, b.label); + goToMaterializedCrop(b); // Stream mode only } private void loadBookmarksFromFile(final File file) { diff --git a/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java b/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java index f72878a14..0a837736d 100644 --- a/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java +++ b/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java @@ -25,18 +25,13 @@ import ij.IJ; import ij.ImagePlus; import ij.gui.Toolbar; -import ij.measure.Calibration; -import org.apache.commons.lang3.StringUtils; -import org.jgrapht.Graphs; -import org.jgrapht.traverse.DepthFirstIterator; import org.scijava.util.PlatformUtils; -import sc.fiji.snt.analysis.graph.DirectedWeightedGraph; -import sc.fiji.snt.analysis.graph.DirectedWeightedSubgraph; -import sc.fiji.snt.analysis.graph.SWCWeightedEdge; import sc.fiji.snt.analysis.curation.PlausibilityCheck; import sc.fiji.snt.analysis.curation.PlausibilityMonitor; import sc.fiji.snt.gui.GuiUtils; +import sc.fiji.snt.gui.ContextMenuCommand; +import sc.fiji.snt.gui.NodeEditCommand; import sc.fiji.snt.gui.IconFactory; import sc.fiji.snt.hyperpanes.MultiDThreePanes; import sc.fiji.snt.tracing.artist.FillerThreadArtist; @@ -103,7 +98,6 @@ class InteractiveTracerCanvas extends TracerCanvas implements MouseWheelListener private boolean clickHandledByRelease; private boolean popupTriggered; private boolean altDraggingNode; - private final Deque editUndoStack = new ArrayDeque<>(); private boolean scrollRadiusUndoPushed; // true once undo is pushed for the current scroll gesture private Timer scrollRadiusUndoResetTimer; // resets the flag after scroll gesture ends @@ -147,57 +141,58 @@ private void buildPopupMenu() { pMenu.setLightWeightPopupEnabled(false); final AListener listener = new AListener(); - final JMenuItem selectNearest = menuItem(AListener.SELECT_NEAREST, listener, - KeyStroke.getKeyStroke(KeyEvent.VK_G, 0), '\uf076', true, IconFactory.defaultColor()); + final JMenuItem selectNearest = menuItem(ContextMenuCommand.GRAB, listener, + KeyStroke.getKeyStroke(KeyEvent.VK_G, 0)); selectNearest.setToolTipText("Select nearest path to cursor"); pMenu.add(selectNearest); - final JMenuItem appendNearest = menuItem(AListener.APPEND_NEAREST, listener, - KeyStroke.getKeyStroke(KeyEvent.VK_G, KeyEvent.SHIFT_DOWN_MASK), - '\uf076', true, IconFactory.selectedColor()); + final JMenuItem appendNearest = menuItem(ContextMenuCommand.APPEND, listener, + KeyStroke.getKeyStroke(KeyEvent.VK_G, KeyEvent.SHIFT_DOWN_MASK)); appendNearest.setToolTipText("Add nearest path to current selection"); pMenu.add(appendNearest); final JMenuItem selectByRoi = getSelectRoiMenuItem(); pMenu.add(selectByRoi); pMenu.addSeparator(); - pMenu.add(menuItem(AListener.HIDE_ALL, listener, KeyStroke.getKeyStroke(KeyEvent.VK_H, 0), - IconFactory.GLYPH.EYE)); - pMenu.add(menuItem(AListener.SHOW_ARROWS, listener, KeyStroke.getKeyStroke(KeyEvent.VK_O, 0), - IconFactory.GLYPH.NAVIGATE)); + pMenu.add(menuItem(ContextMenuCommand.HIDE, listener, KeyStroke.getKeyStroke(KeyEvent.VK_H, 0))); + pMenu.add(menuItem(ContextMenuCommand.ORIENTATION, listener, KeyStroke.getKeyStroke(KeyEvent.VK_O, 0))); pMenu.addSeparator(); - pMenu.add(menuItem(AListener.BOOKMARK_CURSOR, listener, - KeyStroke.getKeyStroke(KeyEvent.VK_B, KeyEvent.SHIFT_DOWN_MASK), - '\uf02e', false, IconFactory.defaultColor())); - pMenu.add(menuItem(AListener.START_SHOLL, listener, - KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK + KeyEvent.ALT_DOWN_MASK), - IconFactory.GLYPH.BULLSEYE)); + pMenu.add(menuItem(ContextMenuCommand.BOOKMARK, listener, + KeyStroke.getKeyStroke(KeyEvent.VK_B, KeyEvent.SHIFT_DOWN_MASK))); + pMenu.add(menuItem(ContextMenuCommand.SHOLL, listener, + KeyStroke.getKeyStroke(KeyEvent.VK_A, KeyEvent.SHIFT_DOWN_MASK + KeyEvent.ALT_DOWN_MASK))); pMenu.addSeparator(); - JMenuItem mi = menuItem(AListener.CLICK_AT_MAX, listener, KeyEvent.VK_V, IconFactory.GLYPH.SUN); + JMenuItem mi = menuItem(ContextMenuCommand.BRIGHTEST, listener, KeyStroke.getKeyStroke(KeyEvent.VK_V, 0)); pMenu.add(mi); - extendPathMenuItem = menuItem(AListener.EXTEND_SELECTED, listener, null, IconFactory.GLYPH.TAPE); + extendPathMenuItem = menuItem(ContextMenuCommand.EXTEND, listener, null); pMenu.add(extendPathMenuItem); - mi = menuItem(AListener.FORK_NEAREST, listener, null, - '\ue13b', true, IconFactory.defaultColor()); + mi = menuItem(ContextMenuCommand.FORK, listener, null); mi.setToolTipText("Branches off a child path at nearest node"); // alternative discovery text for command palette pMenu.add(mi); pMenu.addSeparator(); - undoSegmentMenuItem = menuItem(AListener.UNDO_LAST_SEGMENT, listener, KeyEvent.VK_Z, IconFactory.GLYPH.UNDO); + undoSegmentMenuItem = menuItem(ContextMenuCommand.UNDO_SEGMENT, listener, KeyStroke.getKeyStroke(KeyEvent.VK_Z, 0)); pMenu.add(undoSegmentMenuItem); - final JMenuItem finishPathMenuItem = menuItem(AListener.FINISH_PATH, listener, - KeyStroke.getKeyStroke(KeyEvent.VK_F, 0), - '\uf11e', true, IconFactory.defaultColor()); + final JMenuItem finishPathMenuItem = menuItem(ContextMenuCommand.FINISH, listener, + KeyStroke.getKeyStroke(KeyEvent.VK_F, 0)); pMenu.add(finishPathMenuItem); pMenu.addSeparator(); - toggleEditModeMenuItem = new JCheckBoxMenuItem(AListener.EDIT_TOGGLE_FORMATTER, + toggleEditModeMenuItem = new JCheckBoxMenuItem(NodeEditCommand.editToggleLabel(null), IconFactory.menuIcon(IconFactory.GLYPH.PEN)); toggleEditModeMenuItem.addItemListener(listener); toggleEditModeMenuItem.setAccelerator(KeyStroke.getKeyStroke("shift E")); toggleEditModeMenuItem.setMnemonic(KeyEvent.VK_E); pMenu.add(toggleEditModeMenuItem); + // Node commands: same entries, labels and order as in the Bdv/Bvv context menu (see NodeEditCommand) + for (final NodeEditCommand cmd : NodeEditCommand.values()) { + if (cmd == NodeEditCommand.UNDO) continue; // see undoSegmentMenuItem + final JMenuItem item = cmd.createMenuItem(listener); + item.setText(AListener.INDENT + item.getText()); + pMenu.add(item); + } + // Canvas-only commands pMenu.add(menuItem(AListener.NODE_MOVE_Z, listener, KeyEvent.VK_B, null)); connectToSecondaryEditingPath = menuItem(AListener.NODE_CONNECT_TO_PREV_EDITING_PATH_PLACEHOLDER, listener, null, null); @@ -205,41 +200,29 @@ private void buildPopupMenu() { connectToSecondaryEditingPath.setMnemonic(KeyEvent.VK_C); pMenu.add(connectToSecondaryEditingPath); pMenu.add(helpOnConnectingMenuItem()); - pMenu.add(menuItem(AListener.NODE_DELETE, listener, KeyEvent.VK_D, null)); - pMenu.add(menuItem(AListener.NODE_INSERT, listener, KeyEvent.VK_I, null)); - pMenu.add(menuItem(AListener.NODE_LOCK, listener, KeyEvent.VK_L, null)); - pMenu.add(menuItem(AListener.NODE_MOVE, listener, KeyEvent.VK_M, null)); togglePaintModeMenuItem = new JCheckBoxMenuItem(AListener.NODE_PAINT); togglePaintModeMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_P, 0)); togglePaintModeMenuItem.setMnemonic(KeyEvent.VK_P); togglePaintModeMenuItem.addItemListener(listener); pMenu.add(togglePaintModeMenuItem); - final JMenuItem jmi = menuItem(AListener.NODE_COLOR, listener, KeyStroke.getKeyStroke(KeyEvent.VK_T, 0), null); - jmi.setToolTipText("Assigns a unique color to active node.
" - + "NB: Overall rendering of path may change once tag is applied"); - pMenu.add(jmi); - - // Structural operations (require confirmation, these are not undoable) - pMenu.add(menuItem(AListener.NODE_RADIUS, listener, KeyEvent.VK_R, null)); - pMenu.add(menuItem(AListener.NODE_SET_ROOT, listener, null, null)); - pMenu.add(menuItem(AListener.NODE_SPLIT, listener, KeyEvent.VK_X, null)); // Pause tracing/SNT pMenu.addSeparator(); - togglePauseTracingMenuItem = new JCheckBoxMenuItem(AListener.PAUSE_TRACING_TOGGLE); - IconFactory.assignIcon(togglePauseTracingMenuItem, '\uf04c', true, IconFactory.defaultColor()); + togglePauseTracingMenuItem = new JCheckBoxMenuItem(ContextMenuCommand.PAUSE_TRACING.label()); + ContextMenuCommand.PAUSE_TRACING.applyIcon(togglePauseTracingMenuItem); togglePauseTracingMenuItem.setAccelerator(KeyStroke.getKeyStroke("shift P")); togglePauseTracingMenuItem.setMnemonic(KeyEvent.VK_P); togglePauseTracingMenuItem.addItemListener(listener); pMenu.add(togglePauseTracingMenuItem); - togglePauseSNTMenuItem = new JCheckBoxMenuItem(AListener.PAUSE_SNT_TOGGLE, IconFactory.menuIcon(IconFactory.GLYPH.PAUSE2)); + togglePauseSNTMenuItem = new JCheckBoxMenuItem(ContextMenuCommand.PAUSE_SNT.label()); + ContextMenuCommand.PAUSE_SNT.applyIcon(togglePauseSNTMenuItem); togglePauseSNTMenuItem.addItemListener(listener); pMenu.add(togglePauseSNTMenuItem); } private JMenuItem getSelectRoiMenuItem() { - final JMenuItem selectByRoi = new JMenuItem(AListener.SELECT_BY_ROI); - IconFactory.assignIcon(selectByRoi, '\uf5ee', true, IconFactory.defaultColor()); + final JMenuItem selectByRoi = new JMenuItem(ContextMenuCommand.LASSO.label()); + ContextMenuCommand.LASSO.applyIcon(selectByRoi); selectByRoi.addActionListener( e -> { if (pathAndFillManager.size() == 0) { getGuiUtils().error("There are no traced paths.", "Nothing to Select"); @@ -264,9 +247,9 @@ private void showPopupMenu(final int x, final int y) { final boolean tracingActive = !editMode && !tracerPlugin.tracingHalted; extendPathMenuItem.setText( - (activePath != null) ? "Continue Extending " + getShortName(activePath) : AListener.EXTEND_SELECTED); - toggleEditModeMenuItem.setText(String.format(AListener.EDIT_TOGGLE_FORMATTER, - (activePath == null) ? " Mode" : getShortName(activePath))); + (activePath != null) ? "Continue Extending " + NodeEditCommand.shortName(activePath) : AListener.EXTEND_SELECTED); + toggleEditModeMenuItem.setText(NodeEditCommand.editToggleLabel( + (activePath == null) ? null : NodeEditCommand.shortName(activePath))); toggleEditModeMenuItem.setState(be && editMode); togglePaintModeMenuItem.setSelected(paintMode); togglePaintModeMenuItem.setEnabled(be && editMode); @@ -303,7 +286,7 @@ else if (undoSegmentMenuItem == mItem) { mItem.setText((editMode) ? AListener.UNDO_LAST_EDIT : AListener.UNDO_LAST_SEGMENT); mItem.setEnabled( tracingActive && !tracerPlugin.confirmedSegmentSizes.isEmpty() // condition 1: tracing mode - || editMode && !editUndoStack.isEmpty() // condition 2: edit mode + || editMode && tracerPlugin.getNodeEditor().canUndo() // condition 2: edit mode ); } else if (cmd.startsWith(AListener.FORK_NEAREST)) { @@ -327,12 +310,10 @@ else if (cmd.equals(AListener.APPEND_NEAREST) || cmd.equals(AListener.SELECT_BY_ else if (cmd.equals(AListener.NODE_MOVE_Z)) { mItem.setEnabled(be && editMode && !tracerPlugin.is2D()); } - else if (cmd.equals(AListener.NODE_DELETE) || cmd.equals(AListener.NODE_INSERT) - || cmd.equals(AListener.NODE_LOCK) || cmd.equals(AListener.NODE_MOVE) - || cmd.equals(AListener.NODE_SET_ROOT) || cmd.equals(AListener.NODE_SPLIT) - || cmd.equals(AListener.NODE_RADIUS) || cmd.equals(AListener.NODE_COLOR) - || cmd.equals(AListener.NODE_CONNECT_HELP) + else if (NodeEditCommand.fromActionId(cmd) != null || cmd.equals(AListener.NODE_CONNECT_HELP) || cmd.equals(AListener.NODE_PAINT)) { + if (cmd.equals(NodeEditCommand.LOCK.actionId())) + mItem.setText(AListener.INDENT + NodeEditCommand.LOCK.label(editMode && editor().isActiveNodeLocked())); mItem.setEnabled(be && editMode); } // Everything else always enabled (SELECT_NEAREST, APPEND_NEAREST, @@ -346,117 +327,39 @@ else if (cmd.equals(AListener.NODE_DELETE) || cmd.equals(AListener.NODE_INSERT) } protected void connectEditingPathToPreviousEditingPath(final boolean showDialog) { - // Validate preconditions - if (!editMode || // - tracerPlugin.getEditingPath() == null || // - tracerPlugin.getEditingPath().getEditableNodeIndex() == -1 || // - tracerPlugin.getPreviousEditingPath() == null || // - tracerPlugin.getPreviousEditingPath().getEditableNodeIndex() == -1) // - { + final PathNodeEditor ed = editor(); + if (!editMode) { getGuiUtils().error(AListener.NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX + ": No connectable node exist.", "No Connectable Nodes"); return; } - if (unsupportedOnFittedPath("Connect paths")) return; - - // Default assignment: current path = child, previous path = parent - Path childPath = tracerPlugin.getEditingPath(); - Path parentPath = tracerPlugin.getPreviousEditingPath(); - - // Check for loop creation - if (wouldCreateLoop(childPath, parentPath)) { - getGuiUtils().error("Cannot connect selected nodes: A loop would be created!", - "Reconstruction Cannot Contain Loops"); - return; - } - - // Get connection points - final int childNodeIdx = childPath.getEditableNodeIndex(); - final int parentNodeIdx = parentPath.getEditableNodeIndex(); - - /// Create preview path from parent to child (showing direction of connection) - // Use canvas (unscaled) coordinates to avoid offset issues between paths - final PointInCanvas parentUnscaled = parentPath.getUnscaledNodes().get(parentNodeIdx); - final PointInCanvas childUnscaled = childPath.getUnscaledNodes().get(childNodeIdx); - - connectionPreview = new Path(tracerPlugin.x_spacing, tracerPlugin.y_spacing, - tracerPlugin.z_spacing, tracerPlugin.spacing_units); - // Convert canvas coords back to world coords for the preview (with zero offset) - connectionPreview.addNode(new PointInImage( - parentUnscaled.x * tracerPlugin.x_spacing, - parentUnscaled.y * tracerPlugin.y_spacing, - parentUnscaled.z * tracerPlugin.z_spacing)); - connectionPreview.addNode(new PointInImage( - childUnscaled.x * tracerPlugin.x_spacing, - childUnscaled.y * tracerPlugin.y_spacing, - childUnscaled.z * tracerPlugin.z_spacing)); - - // Enable direction arrows and repaint to show preview + final Path childPath = ed.getPath(); + final Path parentPath = ed.getSecondaryPath(); + final boolean previewable = childPath != null && parentPath != null // + && childPath.getEditableNodeIndex() != -1 && parentPath.getEditableNodeIndex() != -1 // + && !tracerPlugin.isEditingFittedPath(); final boolean previousArrowState = PathNodeCanvas.isShowDirectionArrows(); - PathNodeCanvas.setShowDirectionArrows(true); - tracerPlugin.updateTracingViewers(false); - + if (previewable) { + // Preview path from parent to child (showing direction of connection). Uses canvas (unscaled) + // coordinates to avoid offset issues between paths + final PointInCanvas parentUnscaled = parentPath.getUnscaledNodes().get(parentPath.getEditableNodeIndex()); + final PointInCanvas childUnscaled = childPath.getUnscaledNodes().get(childPath.getEditableNodeIndex()); + connectionPreview = new Path(tracerPlugin.x_spacing, tracerPlugin.y_spacing, tracerPlugin.z_spacing, + tracerPlugin.spacing_units); + // Convert canvas coords back to world coords for the preview (with zero offset) + connectionPreview.addNode(new PointInImage(parentUnscaled.x * tracerPlugin.x_spacing, + parentUnscaled.y * tracerPlugin.y_spacing, parentUnscaled.z * tracerPlugin.z_spacing)); + connectionPreview.addNode(new PointInImage(childUnscaled.x * tracerPlugin.x_spacing, + childUnscaled.y * tracerPlugin.y_spacing, childUnscaled.z * tracerPlugin.z_spacing)); + // Enable direction arrows and repaint to show preview + PathNodeCanvas.setShowDirectionArrows(true); + tracerPlugin.updateTracingViewers(false); + } try { - // Auto-swap heuristics - boolean shouldSwap; - final int childMaxOrder = TreeUtils.getMaxOrder(childPath); - final int parentMaxOrder = TreeUtils.getMaxOrder(parentPath); - if (childMaxOrder != parentMaxOrder) { - // Deeper tree (higher max order) is likely the main structure - shouldSwap = childMaxOrder > parentMaxOrder; - } else { - // Same depth: larger path is likely the parent - shouldSwap = childPath.size() > parentPath.size(); - } - - if (showDialog) { - // Analyze connection for prompt - final boolean childAtStart = childNodeIdx == 0; - final boolean childAtEnd = childNodeIdx == childPath.size() - 1; - final boolean childAtEndpoint = childAtStart || childAtEnd; - - // Build detailed prompt - final StringBuilder msgBuilder = new StringBuilder(""); - msgBuilder.append("Connection Summary:
"); - msgBuilder.append("Parent: ").append(StringUtils.abbreviate(parentPath.getName(), 30)); - msgBuilder.append(" (branch at node #").append(parentNodeIdx).append(")
"); - msgBuilder.append("Child: ").append(StringUtils.abbreviate(childPath.getName(), 30)); - msgBuilder.append(" (connect from node #").append(childNodeIdx).append(")
"); - msgBuilder.append("Note:
"); - - if (!childAtEndpoint && childPath.size() > 2) { - msgBuilder.append("  - Child connects from a mid-path node
"); - } - msgBuilder.append("  - This operation cannot be undone

"); - - // Show dialog with checkbox for swapping - final boolean[] result = getGuiUtils().getConfirmationAndOption( - msgBuilder.toString(), - "Connect Paths", - "Swap parent ↔ child", - shouldSwap, - new String[]{"Connect", "Cancel"}); - - if (result == null || !result[0]) { - return; // User cancelled - } - - if (result[1]) { // Swap if requested - final Path temp = childPath; - childPath = parentPath; - parentPath = temp; - if (wouldCreateLoop(childPath, parentPath)) { - getGuiUtils().error("Cannot connect with swapped parent ↔ child: A loop would be created!", - "Reconstruction Cannot Contain Loops"); - return; - } - } - } - connectionPreview = null; // Clear preview before executing connection to avoid duplicate rendering - connectToEditingPath(childPath, parentPath); - // Trigger UI refresh to show updated hierarchy - if (tracerPlugin.getUI() != null) - tracerPlugin.getUI().getPathManager().setPathList(null, false); + // Validation, prompt and connection are handled by the shared editor + final PathNodeEditor.Result r = ed.connectToSecondaryPath(getGuiUtils(), showDialog); + connectionPreview = null; // Clear preview before updating the viewers to avoid duplicate rendering + report(r, true); } finally { // Always restore state and clear preview (in case of early return) connectionPreview = null; @@ -465,19 +368,6 @@ protected void connectEditingPathToPreviousEditingPath(final boolean showDialog) } } - /** - * Checks if connecting child to parent would create a loop. - */ - private boolean wouldCreateLoop(final Path child, final Path parent) { - if (child == parent) { - return true; - } - if (child.getChildren().isEmpty()) { - return false; - } - return TreeUtils.isInSubtree(parent, child); - } - private void updateConnectToSecondaryEditingPathMenuItem() { if (!editMode || // tracerPlugin.getEditingPath() == null || // @@ -489,195 +379,11 @@ private void updateConnectToSecondaryEditingPathMenuItem() { return; } connectToSecondaryEditingPath.setEnabled(true); - final String label = getShortName(tracerPlugin.getPreviousEditingPath()) + " (node " + final String label = NodeEditCommand.shortName(tracerPlugin.getPreviousEditingPath()) + " (node " + tracerPlugin.getPreviousEditingPath().getEditableNodeIndex() + ")"; connectToSecondaryEditingPath.setText(AListener.NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX + label); } - /** - * Connects child path to parent path, creating physical connection segment - * and proper T-junction topology when needed. - *

- * If the child's selected node is a slab (middle) node, the child path is split - * at that point to create proper branching topology. - * - * @param child The path to be connected as a child branch - * @param parent The path where the child will branch from - */ - private void connectToEditingPath(final Path child, final Path parent) { - try { - long start = System.currentTimeMillis(); - - final int childNodeIdx = child.getEditableNodeIndex(); - final int parentNodeIdx = parent.getEditableNodeIndex(); - final PointInImage parentPoint = parent.getNode(parentNodeIdx); - - // Check if child's selected node is a slab (middle) node - final boolean childAtStart = childNodeIdx == 0; - final boolean childAtEnd = childNodeIdx == child.size() - 1; - final boolean childAtSlab = !childAtStart && !childAtEnd && child.size() > 2; - - final boolean existingEnableUiUpdates = pathAndFillManager.enableUIupdates; - pathAndFillManager.enableUIupdates = false; - - if (childAtSlab) { - // T-JUNCTION: Split child path and handle directly without graph transformation - connectWithTJunction(child, parent, childNodeIdx, parentNodeIdx, parentPoint); - } else { - // ENDPOINT CONNECTION: Use direct path manipulation - connectAtEndpoint(child, parent, childAtStart, parentNodeIdx, parentPoint); - } - - tracerPlugin.setEditingPath(null); - SNTUtils.log("Finished connection in " + (System.currentTimeMillis() - start) + "ms"); - pathAndFillManager.enableUIupdates = existingEnableUiUpdates; - - } catch (final IllegalArgumentException e) { - getGuiUtils().error( - "Cannot connect these paths:

" + - "" + e.getMessage() + "", - "Connection Failed"); - } - } - - /** - * Recursively updates the tree ID of a path and all its descendants. - */ - private void updateTreeIDRecursively(final Path root, final int treeID) { - root.setIDs(root.getID(), treeID); - for (final Path child : root.getChildren()) { - updateTreeIDRecursively(child, treeID); - } - } - - /** - * Connects child path to parent at an endpoint (start or end of child). - */ - private void connectAtEndpoint(final Path child, final Path parent, - final boolean childAtStart, - final int parentNodeIdx, final PointInImage parentPoint) { - - // Sync canvas offset before any operations - TreeUtils.syncCanvasOffset(child, parent); - - // Check if we need to reverse the child to connect from its start - Path childToConnect = child; - if (!childAtStart) { - // Child connects from end - reverse it so it connects from start - childToConnect = child.reversed(); - - // Transfer children with updated branch points - for (final Path grandchild : new ArrayList<>(child.getChildren())) { - final int oldBranchIdx = grandchild.getBranchPointIndex(); - final int newBranchIdx = child.size() - 1 - oldBranchIdx; - grandchild.detachFromParent(); - grandchild.setBranchFrom(childToConnect, childToConnect.getNode(newBranchIdx)); - } - - // Remove original and add reversed - pathAndFillManager.deletePath(child); - pathAndFillManager.addPath(childToConnect, true, true); - } - - // Check if we need a bridging node - final double distance = childToConnect.firstNode().distanceTo(parentPoint); - final double threshold = tracerPlugin.getMinimumSeparation() * 2; - if (distance > threshold) { - // Insert bridging node at start of child - final PointInImage bridgePoint = new PointInImage(parentPoint.x, parentPoint.y, parentPoint.z); - childToConnect.insertNode(0, bridgePoint); - } - - // Connect child to parent - childToConnect.setBranchFrom(parent, parent.getNode(parentNodeIdx)); - // Recursively update tree ID for child and all descendants - updateTreeIDRecursively(childToConnect, parent.getTreeID()); - } - - /** - * Connects child path to parent by splitting child at a mid-path node (T-junction). - */ - private void connectWithTJunction(final Path child, final Path parent, - final int childNodeIdx, final int parentNodeIdx, - final PointInImage parentPoint) { - - // Sync canvas offset before any operations - TreeUtils.syncCanvasOffset(child, parent); - - // Store children info before we modify anything - final List grandchildren = new ArrayList<>(child.getChildren()); - final Map grandchildBranchIndices = new HashMap<>(); - for (final Path gc : grandchildren) { - grandchildBranchIndices.put(gc, gc.getBranchPointIndex()); - gc.detachFromParent(); - } - - // Create segment 1: nodes [0, childNodeIdx] - final Path segment1 = child.createPath(); - for (int i = 0; i <= childNodeIdx; i++) { - segment1.addNode(child.getNode(i)); - } - // Create segment 2: nodes [childNodeIdx, end] - includes junction node for continuity - Path segment2 = null; - if (childNodeIdx < child.size() - 1) { - segment2 = child.createPath(); - for (int i = childNodeIdx; i < child.size(); i++) { - segment2.addNode(child.getNode(i)); - } - } - - // Check if we need a bridging node for the connection to parent - final PointInImage junctionPoint = segment1.lastNode(); - final double distance = junctionPoint.distanceTo(parentPoint); - final double threshold = tracerPlugin.getMinimumSeparation() * 2; - if (distance > threshold) { - // Add bridging node at the END of segment1 (after junction point) - // This extends segment1 toward the parent - final PointInImage bridgePoint = new PointInImage(parentPoint.x, parentPoint.y, parentPoint.z); - segment1.addNode(bridgePoint); - } - - // Delete original child path - pathAndFillManager.deletePath(child); - - // Add segment1 and connect it to parent - pathAndFillManager.addPath(segment1, true, true); - segment1.setBranchFrom(parent, parent.getNode(parentNodeIdx)); - // Recursively update tree ID for segment1 and its subtree - updateTreeIDRecursively(segment1, parent.getTreeID()); - - // Add segment2 and connect it to segment1 at the junction (before the bridge if present) - if (segment2 != null) { - pathAndFillManager.addPath(segment2, true, true); - // segment2 branches from segment1 at the junction point - // The junction is at index childNodeIdx in segment1 (which has nodes 0..childNodeIdx, possibly + bridge) - segment2.setBranchFrom(segment1, segment1.getNode(childNodeIdx)); - // Recursively update tree ID for segment2 and its subtree - updateTreeIDRecursively(segment2, parent.getTreeID()); - } - - // Reassign grandchildren to appropriate segment - for (final Path gc : grandchildren) { - final int origBranchIdx = grandchildBranchIndices.get(gc); - - if (origBranchIdx <= childNodeIdx) { - // Grandchild branches from segment1 portion - // Index in segment1 is same as original - gc.setBranchFrom(segment1, segment1.getNode(origBranchIdx)); - } else if (segment2 != null) { - // Grandchild branches from segment2 portion - // Index in segment2: origBranchIdx - childNodeIdx (since segment2 starts at childNodeIdx) - final int newIdx = origBranchIdx - childNodeIdx; - gc.setBranchFrom(segment2, segment2.getNode(newIdx)); - } - // Tree ID already propagated recursively above; no need to update grandchildren here - } - } - - private static String getShortName(final Path p) { - return StringUtils.abbreviate(p.getName(), 30); - } - private JMenuItem helpOnConnectingMenuItem() { final String msg = "To connect two paths in Edit Mode:

    " + "
  1. Press Shift+E to enter Edit Mode
  2. " + @@ -710,6 +416,13 @@ public void windowClosing(final WindowEvent e) { return helpItem; } + /** Menu item for a command shared with the Bdv/Bvv context menu: label and icon come from the enum */ + private JMenuItem menuItem(final ContextMenuCommand cmd, final ActionListener lstnr, final KeyStroke keystroke) { + final JMenuItem mi = GuiUtils.MenuItems.menuItem(cmd.label(), lstnr, keystroke, (IconFactory.GLYPH) null); + cmd.applyIcon(mi); + return mi; + } + private JMenuItem menuItem(final String cmdName, final ActionListener lstnr, final int keycode, final IconFactory.GLYPH glyph) { return GuiUtils.MenuItems.menuItem(cmdName, lstnr, KeyStroke.getKeyStroke(keycode, 0), glyph); @@ -857,8 +570,10 @@ protected void startShollAnalysis() { // p[] is a pane-local pixel index; correct against the active canvas offset before // comparing to existing paths' true-world node coordinates (see SNT#getActiveCanvasPixelOffset()). final PointInCanvas offset = tracerPlugin.getActiveCanvasPixelOffset(); - final PathAndFillManager.NodeRef ref = pathAndFillManager - .nearestJoinNodeRefOnSelectedPaths(p[0] - offset.x, p[1] - offset.y, p[2] - offset.z); + // The version in use (fitted or not) is the one displayed and analyzed by the Sholl command + final PathAndFillManager.NodeRef ref = pathAndFillManager.nearestNodeInUse( + pathAndFillManager.getSelectedPaths(), (p[0] - offset.x) * tracerPlugin.x_spacing, + (p[1] - offset.y) * tracerPlugin.y_spacing, (p[2] - offset.z) * tracerPlugin.z_spacing); if (ref != null) { centerPath = ref.path(); centerIndex = ref.index(); @@ -956,7 +671,7 @@ public Path selectNearestPathToMousePointer(final boolean addToExistingSelection } final Path selectedPath = nearPoint.getPath(); tracerPlugin.selectPath(selectedPath, addToExistingSelection); - canvasInfo(getShortName(selectedPath) + " selected"); + canvasInfo(NodeEditCommand.shortName(selectedPath) + " selected"); return selectedPath; } @@ -1318,8 +1033,9 @@ public void mouseDragged(final MouseEvent e) { else brushMoveNodes(editingPath); repaint(); + tracerPlugin.notifyEditingPathChanged(); // Bdv/Bvv } else { - moveEditingNodeToLastCanvasPosition(false); // silent: no tempMsg per drag event + moveEditingNodeToLastCanvasPosition(false, false); // silent: no tempMsg per drag event. Undo pushed once, above } return; // don't pan or do anything else } @@ -1398,6 +1114,7 @@ private boolean unsupportedOnFittedPath(final String operationLabel) { private void redrawEditingPath(final String msg) { redrawEditingPath(getGraphics2D(getGraphics())); repaint(); + tracerPlugin.notifyEditingPathChanged(); // Bdv/Bvv if (msg != null) canvasInfo(msg); } @@ -1789,36 +1506,28 @@ protected void synchronizeControls() { private class AListener implements ActionListener, ItemListener { /* Listed shortcuts are specified in QueueJumpingKeyListener */ - private static final String UNDO_LAST_SEGMENT = "Undo Last Segment"; - private static final String UNDO_LAST_EDIT= "Undo Last Edit"; - private static final String FINISH_PATH = "Finish Path"; - private static final String HIDE_ALL = "Hide Paths (Hold H)"; - private static final String SHOW_ARROWS = "Path Orientation (Hold O)"; - private static final String CLICK_AT_MAX = "Click on Brightest Voxel Above/Below Cursor"; - private static final String FORK_NEAREST = "Fork at Nearest Node"; - private static final String BOOKMARK_CURSOR = "Bookmark Cursor Position"; - private static final String SELECT_NEAREST = "Grab Nearest Path"; - private static final String APPEND_NEAREST = "Add Nearest Path to Selection"; - private static final String EXTEND_SELECTED = "Continue Extending Path"; - private static final String PAUSE_SNT_TOGGLE = "Pause SNT"; - private static final String PAUSE_TRACING_TOGGLE = "Pause Tracing"; - private static final String EDIT_TOGGLE_FORMATTER = "Edit %s"; - private final static String NODE_DELETE = " Delete Active Node"; - private final static String NODE_INSERT = " Insert New Node at Cursor Position"; - private final static String NODE_LOCK = " Lock Active Node"; - private final static String NODE_MOVE = " Move Active Node to Cursor Position"; - private final static String NODE_MOVE_Z = " Bring Active Node to Current Z-plane"; - private final static String NODE_RADIUS = " Set Active Node Radius..."; - private final static String NODE_COLOR = " Tag Active Node"; - private final static String NODE_SET_ROOT = " Set Active Node as Tree Root..."; - private final static String NODE_SPLIT = " Split Tree at Active Node..."; - private final static String NODE_PAINT = " Paint Mode"; - private final static String NODE_CONNECT_HELP = " Connect to Help..."; - private final static String NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX = " Connect to "; + private static final String UNDO_LAST_SEGMENT = ContextMenuCommand.Labels.UNDO_SEGMENT; + private static final String UNDO_LAST_EDIT = NodeEditCommand.UNDO_LABEL; + private static final String FINISH_PATH = ContextMenuCommand.Labels.FINISH; + private static final String HIDE_ALL = ContextMenuCommand.Labels.HIDE; + private static final String SHOW_ARROWS = ContextMenuCommand.Labels.ORIENTATION; + private static final String CLICK_AT_MAX = ContextMenuCommand.Labels.BRIGHTEST; + private static final String FORK_NEAREST = ContextMenuCommand.Labels.FORK; + private static final String BOOKMARK_CURSOR = ContextMenuCommand.Labels.BOOKMARK; + private static final String SELECT_NEAREST = ContextMenuCommand.Labels.GRAB; + private static final String APPEND_NEAREST = ContextMenuCommand.Labels.APPEND; + private static final String EXTEND_SELECTED = ContextMenuCommand.Labels.EXTEND; + private static final String PAUSE_SNT_TOGGLE = ContextMenuCommand.Labels.PAUSE_SNT; + private static final String PAUSE_TRACING_TOGGLE = ContextMenuCommand.Labels.PAUSE_TRACING; + private final static String INDENT = NodeEditCommand.MENU_INDENT; + private final static String NODE_MOVE_Z = INDENT + "Bring Active Node to Current Z-plane"; + private final static String NODE_PAINT = INDENT + "Paint Mode"; + private final static String NODE_CONNECT_HELP = INDENT + "Connect to Help..."; + private final static String NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX = INDENT + "Connect to "; private final static String NODE_CONNECT_TO_PREV_EDITING_PATH_PLACEHOLDER = NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX + "..."; - private final static String START_SHOLL = "Sholl Analysis at Nearest Node"; - private final static String SELECT_BY_ROI = "Lasso Select Paths"; + private final static String START_SHOLL = ContextMenuCommand.Labels.SHOLL; + private final static String SELECT_BY_ROI = ContextMenuCommand.Labels.LASSO; @Override public void itemStateChanged(final ItemEvent e) { @@ -1994,45 +1703,24 @@ private boolean handleEditCommands(final String command, final ActionEvent e) { } // Handle specific edit commands - return switch (command) { - case NODE_DELETE -> { - deleteEditingNode(true); - yield true; - } - case NODE_INSERT -> { - appendLastCanvasPositionToEditingNode(true); - yield true; - } - case NODE_LOCK -> { - toggleEditingNode(true); - yield true; - } - case NODE_MOVE -> { - moveEditingNodeToLastCanvasPosition(true); - yield true; - } - case NODE_RADIUS -> { - assignRadiusToEditingNode(true); - yield true; - } - case NODE_COLOR -> { - assignColorToEditingNode(); - yield true; - } - case NODE_MOVE_Z -> { - assignLastCanvasZPositionToEditNode(true); - yield true; - } - case NODE_SET_ROOT -> { - assignTreeRootToEditingNode(true); - yield true; - } - case NODE_SPLIT -> { - splitTreeAtEditingNode(true); - yield true; - } - default -> false; // Command not recognized as an edit command - }; + if (NODE_MOVE_Z.equals(command)) { + assignLastCanvasZPositionToEditNode(true); + return true; + } + final NodeEditCommand nec = NodeEditCommand.fromActionId(command); + if (nec == null) return false; // Command not recognized as an edit command + switch (nec) { + case DELETE -> deleteEditingNode(true); + case INSERT -> appendLastCanvasPositionToEditingNode(true); + case MOVE -> moveEditingNodeToLastCanvasPosition(true); + case LOCK -> toggleEditingNode(true); + case RADIUS -> assignRadiusToEditingNode(true); + case TAG -> assignColorToEditingNode(); + case SET_ROOT -> assignTreeRootToEditingNode(true); + case SPLIT -> splitTreeAtEditingNode(true); + case UNDO -> undoLastEditOperation(); + } + return true; } } @@ -2064,7 +1752,6 @@ protected void setEditMode(final boolean editMode) { final String saved = tracerPlugin.getPrefs().getTemp(MANUAL_RADIUS_TEMP_KEY, "0"); try { tracerPlugin.manualRadius = Double.parseDouble(saved); } catch (final NumberFormatException ignored) { tracerPlugin.manualRadius = 0; } - editUndoStack.clear(); scrollRadiusUndoPushed = false; if (scrollRadiusUndoResetTimer != null) scrollRadiusUndoResetTimer.stop(); } @@ -2133,201 +1820,94 @@ protected int getPaintBrushRadius() { return paintBrushRadius; } + private PathNodeEditor editor() { + return tracerPlugin.getNodeEditor(); + } + + /** + * Reports the outcome of an operation of the shared node editor: redraws (and informs the Bdv/Bvv) on success, + * warns on failure + * + * @param r the outcome + * @param showMessage whether the message of a successful operation should be displayed + */ + private void report(final PathNodeEditor.Result r, final boolean showMessage) { + if (r == null) return; + r.dispatch(res -> { + // Paths were added/removed/replaced: the editing path is no longer valid. Leave Edit Mode on all + // front-ends (canvas, Bdv and Bvv), as the viewers do (see BigViewerEditor#report) + tracerPlugin.setEditingPath(null); + tracerPlugin.updateAllViewers(); + tracerPlugin.exitEditMode(); + repaint(); + if (res.message() != null) { + if (!res.ok()) getGuiUtils().error(res.message()); // failure after the path was released + else if (showMessage) canvasInfo(res.message()); + } + }, res -> redrawEditingPath((showMessage) ? res.message() : null), res -> { + // Silent callers (e.g., node drags, which may fail on every mouse event) must not open dialogs + if (res.message() == null) return; + if (res.modal() && showMessage) getGuiUtils().error(res.message()); + else canvasWarning(res.message()); + }); + } + + /** @return the last canvas position, in the pixel grid of the nodes of {@code path} (i.e., without canvas offset) */ + private double[] lastCanvasPositionInPathPixels(final Path path) { + final double[] p = new double[3]; + tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise, last_y_in_pane_precise, plane, p); + final PointInCanvas offset = path.getCanvasOffset(); + return new double[] { p[0] - offset.x, p[1] - offset.y, p[2] - offset.z }; + } + + /** Records the editing path in the undo history shared with the Bdv/Bvv, see {@link PathNodeEditor#pushUndo()} */ private void pushEditUndo() { if (impossibleEdit(false)) return; - editUndoStack.push(tracerPlugin.getEditingPath().clone()); - if (editUndoStack.size() > SNTPrefs.MAX_UNDO_STEPS) - editUndoStack.removeLast(); + editor().pushUndo(); } protected void undoLastEditOperation() { - if (editUndoStack.isEmpty()) { - canvasWarning("Nothing to undo"); - return; - } - final Path editingPath = tracerPlugin.getEditingPath(); - if (editingPath == null) { - editUndoStack.clear(); - canvasWarning("Nothing to undo"); - return; - } - editingPath.replaceNodes(editUndoStack.pop()); - redrawEditingPath("Edit undone"); + final PathNodeEditor.Result r = editor().undo(); + if (r.ok()) redrawEditingPath(r.message()); + else canvasWarning(r.message()); } protected void toggleEditingNode(final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - final Path editingPath = tracerPlugin.getEditingPath(); - if (editingPath.getEditableNodeIndex() < 0) { - canvasWarning("No editable node detected!"); - } else { - editingPath.setEditableNodeLocked(!editingPath.isEditableNodeLocked()); - redrawEditingPath("Lock toggled on active node"); - } - } - - /** - * Hook 4: notifies {@link PlausibilityMonitor#onNodeEdited(Path, int)} after a node has been - * moved, inserted, or deleted here in edit mode - the edit-mode equivalent of Hooks 1-3, which - * {@code SNT#startPath()}/{@code searchFinished()}/{@code finishPath()} call directly during - * interactive tracing (see those methods' own "Hook" comments). Without this, live monitoring - * would never re-check a fork after the user manually adjusts nodes near it post-tracing. - * - * @param editedPath the path whose node was just mutated - * @param nodeIndex the index of the affected node (before removal, for a delete) - */ - private void notifyPlausibilityMonitorNodeEdited(final Path editedPath, final int nodeIndex) { - if (tracerPlugin.getUI() != null && tracerPlugin.getUI().getPlausibilityMonitor().isEnabled()) { - tracerPlugin.getUI().getPlausibilityMonitor().onNodeEdited(editedPath, nodeIndex); - } + report(editor().toggleLock(), true); } protected void deleteEditingNode(final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - if (unsupportedOnFittedPath("Delete node")) return; - final Path editingPath = tracerPlugin.getEditingPath(); - final PointInImage editingNode = editingPath.getNode(editingPath.getEditableNodeIndex()); - if (editingPath.size() > 2) { - if (editingPath.getBranchPoints().stream().anyMatch(n -> n.equals(editingNode))) { - canvasWarning("Cannot delete junction node. Try to split instead."); - return; - } - // Only push undo after validation passes - pushEditUndo(); - try { - final int editedIndex = editingPath.getEditableNodeIndex(); - editingPath.removeNode(editedIndex); - redrawEditingPath("Node deleted"); - notifyPlausibilityMonitorNodeEdited(editingPath, editedIndex); - } - catch (final IllegalArgumentException exc) { - canvasWarning("Node deletion failed!"); - } - } - else if (getGuiUtils().getConfirmation("Delete " + - editingPath + "?", "Delete Path?")) - { - boolean rebuild = false; - for (final Path p : editingPath.connectedPaths) { - if (p.getParentPath() == editingPath) { - rebuild = true; - break; - } - } - editingPath.disconnectFromAll(); // Fixes ghost connection at canvas origin after deleting last node - // in a forked path - tracerPlugin.getPathAndFillManager().deletePath(editingPath); - if (rebuild) tracerPlugin.getPathAndFillManager().rebuildRelationships(); - tracerPlugin.setEditingPath(null); - tracerPlugin.updateAllViewers(); - } + report(editor().deleteNodeOrPath(getGuiUtils()), true); } protected void appendLastCanvasPositionToEditingNode( final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - if (unsupportedOnFittedPath("Insert node")) return; - pushEditUndo(); - final Path editingPath = tracerPlugin.getEditingPath(); - final int editingNode = editingPath.getEditableNodeIndex(); - final double[] p = new double[3]; - tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise, - last_y_in_pane_precise, plane, p); - final PointInCanvas offset = editingPath.getCanvasOffset(); - try { - editingPath.insertNode(editingNode, new PointInImage((p[0] - offset.x) * - tracerPlugin.x_spacing, (p[1] - offset.y) * tracerPlugin.y_spacing, - (p[2] - offset.z) * tracerPlugin.z_spacing)); - editingPath.setEditableNode(editingNode + 1); - redrawEditingPath("New node inserted (N=" + editingNode + ")"); - notifyPlausibilityMonitorNodeEdited(editingPath, editingNode); - } - catch (final IllegalArgumentException exc) { - canvasWarning("Node insertion failed!"); - } + final double[] p = lastCanvasPositionInPathPixels(tracerPlugin.getEditingPath()); + report(editor().insertNode(p[0] * tracerPlugin.x_spacing, p[1] * tracerPlugin.y_spacing, + p[2] * tracerPlugin.z_spacing), true); } protected void assignColorToEditingNode() { if (impossibleEdit(true)) return; if (unsupportedOnFittedPath("Tag node")) return; - final Path edPath = tracerPlugin.getEditingPath(); - final int edNode = edPath.getEditableNodeIndex(); - final JPopupMenu popup = GuiUtils.MenuItems.colorTagPopup(this, chosen -> { - pushEditUndo(); - edPath.setNodeColor(chosen, edNode); - redrawEditingPath((chosen == null) ? String.format("Tag removed from node #%d", edNode) : null); - if (tracerPlugin.getUI() != null) { - tracerPlugin.getUI().getPathManager().update(true); - final BookmarkManager bm = tracerPlugin.getUI().getBookmarkManager(); - if (chosen == null) - bm.remove(edPath, edNode); - else - bm.add(edPath, edNode); - } - }); + final PathNodeEditor ed = editor(); + final Path edPath = ed.getPath(); + final int edNode = ed.getActiveNode(); + // The active node follows the cursor while the popup is open: tagNode restores the one it was opened for + final JPopupMenu popup = GuiUtils.MenuItems.colorTagPopup(this, + chosen -> report(ed.tagNode(edPath, edNode, chosen), true)); final Point p = MouseInfo.getPointerInfo().getLocation(); SwingUtilities.convertPointFromScreen(p, this); popup.show(this, p.x, p.y); } protected void assignRadiusToEditingNode(final boolean warnOnFailure) { - if (impossibleEdit(warnOnFailure)) - return; - final Path edPath = tracerPlugin.getEditingPath(); - final int edNode = edPath.getEditableNodeIndex(); - final boolean hasRadii = edPath.hasRadii(); - final String[] defChoices = new String[(hasRadii) ? 5 : 3]; - defChoices[0] = "Assign average of flanking nodes (if any)"; - defChoices[1] = "Assign half of minimum voxel separation"; - defChoices[2] = "Assign value specified below:"; - if (hasRadii) { - defChoices[3] = "Multiply existing radius by multiplier specified below:"; - defChoices[4] = "Assign mean path radius"; - } - String msg = "Which value should be set as radius for node #" + edNode + "?
    "; - if (hasRadii) - msg += String.format("(Current radius is %02f)", edPath.getNodeRadius(edNode)); - else - msg += "NB: No nodes in " + edPath.getName() + " have assigned radii!"; - - final String prevChoice = tracerPlugin.getPrefs().getTemp("lastRadChoice", defChoices[0]); - final String prevDouble = tracerPlugin.getPrefs().getTemp("lastRadDouble", null); - final Object[] usrChoice = getGuiUtils().getChoiceAndDouble(msg, edPath.getName() + ": Ad hoc Radius", - defChoices, prevChoice, - (prevDouble == null) ? edPath.getNodeRadius(edNode) : Double.parseDouble(prevDouble)); - if (usrChoice == null) - return; - - double r = Double.NaN; - if (defChoices[0].equals(usrChoice[0])) { - if (edPath.size() == 1) { - guiUtils.error("Path has only one node. No flanking nodes exist."); - return; - } else { - final int n1 = Math.max(0, edNode - 1); - final int n2 = Math.min(edPath.size() - 1, edNode + 1); - r = (edPath.getNodeRadius(n1) + edPath.getNodeRadius(n2)) / 2; - } - } else if (defChoices[1].equals(usrChoice[0])) { - r = tracerPlugin.getMinimumSeparation() / 2; - } else if (defChoices[2].equals(usrChoice[0])) { - r = (double) usrChoice[1]; - tracerPlugin.getPrefs().setTemp("lastRadDouble", Double.toString((double) usrChoice[1])); - } else if (hasRadii && defChoices[3].equals(usrChoice[0])) { - r = edPath.getNodeRadius(edNode) * (double) usrChoice[1]; - tracerPlugin.getPrefs().setTemp("lastRadDouble", Double.toString((double) usrChoice[1])); - } else if (hasRadii && defChoices[4].equals(usrChoice[0])) { - r = edPath.getMeanRadius(); - } - tracerPlugin.getPrefs().setTemp("lastRadChoice", (String) usrChoice[0]); - if (Double.isNaN(r) || r < 0d) { - guiUtils.error("Invalid radius. Must be a positive, floating-point value."); - } else { - pushEditUndo(); - edPath.setRadius(r, edNode); - redrawEditingPath(String.format("Radius set to %02f", r)); - } + if (impossibleEdit(warnOnFailure)) return; + report(editor().promptAndSetRadius(getGuiUtils()), true); } /** @@ -2375,209 +1955,50 @@ private void brushBlendRadii(final Path editingPath) { paintCursorLabel = String.format("r=%.2f %s", centerRadius, tracerPlugin.spacing_units); } - protected void moveEditingNodeToLastCanvasPosition( - final boolean warnOnFailure) - { + protected void moveEditingNodeToLastCanvasPosition(final boolean warnOnFailure) { + moveEditingNodeToLastCanvasPosition(warnOnFailure, true); + } + + /** + * @param warnOnFailure whether to warn if there is no active node, and to display a message on success + * @param recordUndo false if the caller has already recorded the undo history for the gesture (e.g., a drag) + */ + protected void moveEditingNodeToLastCanvasPosition(final boolean warnOnFailure, final boolean recordUndo) { if (impossibleEdit(warnOnFailure)) return; final Path editingPath = tracerPlugin.getEditingPath(); - final int editingNode = editingPath.getEditableNodeIndex(); - final double[] p = new double[3]; - tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise, - last_y_in_pane_precise, plane, p); - final PointInCanvas offset = editingPath.getCanvasOffset(); - try { - editingPath.moveNode(editingNode, new PointInImage((p[0] - offset.x) * - editingPath.x_spacing, (p[1] - offset.y) * editingPath.y_spacing, - (p[2] - offset.z) * editingPath.z_spacing)); - redrawEditingPath((warnOnFailure) ? "Node moved" : null); - notifyPlausibilityMonitorNodeEdited(editingPath, editingNode); - } - catch (final IllegalArgumentException exc) { - canvasWarning("Node displacement failed!"); - } + final double[] p = lastCanvasPositionInPathPixels(editingPath); + report(editor().moveNode(p[0] * editingPath.x_spacing, p[1] * editingPath.y_spacing, + p[2] * editingPath.z_spacing, recordUndo), warnOnFailure); } protected void assignLastCanvasZPositionToEditNode( final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - pushEditUndo(); final Path editingPath = tracerPlugin.getEditingPath(); final int editingNode = editingPath.getEditableNodeIndex(); final PointInCanvas offset = editingPath.getCanvasOffset(); final PointInImage currentNode = editingPath.getNodeWithoutChecks(editingNode); - double newZ = switch (plane) { + final double newZ = switch (plane) { case MultiDThreePanes.XY_PLANE -> (imp.getZ() - 1 - offset.z) * tracerPlugin.z_spacing; case MultiDThreePanes.XZ_PLANE -> (last_y_in_pane_precise - offset.y) * tracerPlugin.y_spacing; case MultiDThreePanes.ZY_PLANE -> (last_x_in_pane_precise - offset.x) * tracerPlugin.x_spacing; default -> currentNode.z; }; - try { - editingPath.moveNode(editingNode, new PointInImage( - currentNode.x, - currentNode.y, newZ)); - redrawEditingPath(String.format("Node %d moved to Z=%3f", editingNode, newZ)); - notifyPlausibilityMonitorNodeEdited(editingPath, editingNode); - } - catch (final IllegalArgumentException exc) { - canvasWarning("Adjustment of Z-position failed!"); - } + // Undo history, validity checks and plausibility monitoring are handled by the shared editor + final PathNodeEditor.Result r = editor().moveNode(currentNode.x, currentNode.y, newZ); + if (r.ok()) redrawEditingPath(String.format("Node %d moved to Z=%3f", editingNode, newZ)); + else canvasWarning("Adjustment of Z-position failed!"); } protected void assignTreeRootToEditingNode(final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - if (unsupportedOnFittedPath("Set tree root")) return; - if (!getGuiUtils().getConfirmation( - "Set active node as tree root? This operation cannot be undone.", - "Confirm Re-root")) - return; - final Path editingPath = tracerPlugin.getEditingPath(); - - try { - long start = System.currentTimeMillis(); - - // Use getConnectedTree - it gets exactly the tree containing the editing path - final Tree editingTree = TreeUtils.getConnectedTree(editingPath); - - final boolean existingEnableUiUpdates = pathAndFillManager.enableUIupdates; - pathAndFillManager.enableUIupdates = false; - - final PointInImage editingNode = editingPath.getNode(editingPath.getEditableNodeIndex()); - final DirectedWeightedGraph editingGraph = new DirectedWeightedGraph(editingTree, false); - SWCPoint newRoot = getMatchingPointInGraph(editingNode, editingGraph); - - if (newRoot == null) { - SWCPoint nearest = getNearestPointInGraph(editingNode, editingGraph); - if (nearest == null) { - pathAndFillManager.enableUIupdates = existingEnableUiUpdates; - getGuiUtils().error("Could not find a valid node for the new root."); - return; - } - final int uniqueId = editingGraph.vertexSet().stream().mapToInt(v -> v.id).max().orElse(-2) + 1; - newRoot = new SWCPoint(uniqueId, nearest.type, editingNode.x, editingNode.y, editingNode.z, - editingPath.getNodeRadius(editingPath.getEditableNodeIndex()), nearest.id); - newRoot.setPath(editingPath); - editingGraph.addVertex(newRoot); - editingGraph.addEdge(nearest, newRoot); - if (editingPath.size() == 1) - pathAndFillManager.deletePath(editingPath); - else - editingPath.removeNode(editingPath.getEditableNodeIndex()); - } - - editingGraph.setRoot(newRoot); - final Tree newTree = editingGraph.getTreeWithSamePathStructure(); - tracerPlugin.setEditingPath(null); - - final Calibration cal = tracerPlugin.getCalibration(); - pathAndFillManager.deletePaths(editingTree.list()); - newTree.list().forEach(p -> { - p.setSpacing(cal); - pathAndFillManager.addPath(p, false, true); - }); - - pathAndFillManager.enableUIupdates = existingEnableUiUpdates; - SNTUtils.log("Finished re-root in " + (System.currentTimeMillis() - start) + "ms"); - - } catch (final IllegalArgumentException e) { - getGuiUtils().error("Could not re-root tree:
    " + e.getMessage() + "", - "Re-root Failed"); - } + report(editor().setActiveNodeAsRoot(getGuiUtils()), true); } protected void splitTreeAtEditingNode(final boolean warnOnFailure) { if (impossibleEdit(warnOnFailure)) return; - if (unsupportedOnFittedPath("Split tree")) return; - final Path editingPath = tracerPlugin.getEditingPath(); - - try { - long start = System.currentTimeMillis(); - - // Use getConnectedTree to avoid issues with disconnected paths sharing tree ID - final Tree editingTree = TreeUtils.getConnectedTree(editingPath); - // Warn user that this operation cannot be undone - if (!getGuiUtils().getConfirmation( - "Split tree at active node? This operation cannot be undone.", - "Confirm Split")) - return; - final PointInImage editingPoint = editingPath.getNode(editingPath.getEditableNodeIndex()); - - if (editingTree.getRoot().equals(editingPoint)) { - canvasWarning("Cannot split tree at root node."); - return; - } - - final DirectedWeightedGraph editingGraph = new DirectedWeightedGraph(editingTree, false); - final SWCPoint editingVertex = getMatchingPointInGraph(editingPoint, editingGraph); - - if (editingVertex == null) { - getGuiUtils().error("Could not locate the selected node in the tree graph."); - return; - } - - final Set incomingEdges = editingGraph.incomingEdgesOf(editingVertex); - if (incomingEdges.isEmpty()) { - getGuiUtils().error("Selected node has no incoming edges. Cannot split here."); - return; - } - - editingGraph.removeEdge(incomingEdges.iterator().next()); - final DepthFirstIterator depthFirstIterator = - editingGraph.getDepthFirstIterator(editingVertex); - final Set descendantVertexSet = new HashSet<>(); - while (depthFirstIterator.hasNext()) { - descendantVertexSet.add(depthFirstIterator.next()); - } - - final DirectedWeightedSubgraph descendantSubgraph = editingGraph.getSubgraph(descendantVertexSet); - final DirectedWeightedGraph descendantGraph = new DirectedWeightedGraph(); - Graphs.addGraph(descendantGraph, descendantSubgraph); - editingGraph.removeAllVertices(descendantVertexSet); - - final Tree ancestorTree = editingGraph.getTreeWithSamePathStructure(); - final Tree descendantTree = descendantGraph.getTreeWithSamePathStructure(); - - tracerPlugin.setEditingPath(null); - - final Calibration cal = tracerPlugin.getCalibration(); - ancestorTree.list().forEach(p -> p.setSpacing(cal)); - descendantTree.list().forEach(p -> p.setSpacing(cal)); - - final boolean existingEnableUiUpdates = pathAndFillManager.enableUIupdates; - pathAndFillManager.enableUIupdates = false; - pathAndFillManager.deletePaths(editingTree.list()); - ancestorTree.list().forEach(p -> pathAndFillManager.addPath(p, false, true)); - descendantTree.list().forEach(p -> pathAndFillManager.addPath(p, false, true)); - pathAndFillManager.enableUIupdates = existingEnableUiUpdates; - - SNTUtils.log("Finished split in " + (System.currentTimeMillis() - start) + "ms"); - - } catch (final IllegalArgumentException e) { - getGuiUtils().error("Could not split tree:
    " + e.getMessage() + "", - "Split Failed"); - } - } - - private SWCPoint getNearestPointInGraph(final PointInImage point, final DirectedWeightedGraph graph) { - double distanceSquaredToNearestParentPoint = Double.MAX_VALUE; - SWCPoint nearest = null; - for (final SWCPoint p : graph.vertexSet()) { - final double distanceSquared = point.distanceSquaredTo(p.x, p.y, p.z); - if (distanceSquared < distanceSquaredToNearestParentPoint) { - nearest = p; - distanceSquaredToNearestParentPoint = distanceSquared; - } - } - return nearest; - } - - private SWCPoint getMatchingPointInGraph(final PointInImage point, final DirectedWeightedGraph graph) { - for (final SWCPoint p : graph.vertexSet()) { - if (p.isSameLocation(point) && p.getPath().equals(point.getPath())) { - return p; - } - } - return null; + report(editor().splitTreeAtActiveNode(getGuiUtils()), true); } public void setLookAndFeel(final String lookAndFeelName) { diff --git a/src/main/java/sc/fiji/snt/PathAndFillManager.java b/src/main/java/sc/fiji/snt/PathAndFillManager.java index f8c46b1e2..704ffbb3f 100644 --- a/src/main/java/sc/fiji/snt/PathAndFillManager.java +++ b/src/main/java/sc/fiji/snt/PathAndFillManager.java @@ -3232,35 +3232,75 @@ synchronized PointInImage nearestJoinPointOnSelectedPaths(final double x, * which Path/node index the resolved point corresponds to. */ synchronized NodeRef nearestJoinNodeRefOnSelectedPaths(final double x, final double y, final double z) { + return nearestNodeOnPaths(selectedPathsSet, x * x_spacing, y * y_spacing, z * z_spacing); + } + + /** + * Finds the node nearest to a position, across the given paths as they are (no fitted/unfitted resolution: + * see {@link #nearestNodeInUse(Collection, double, double, double)}). + * + * @param paths the paths to be searched. Empty paths are skipped + * @param x the x-coordinate (calibrated/world units) + * @param y the y-coordinate (calibrated/world units) + * @param z the z-coordinate (calibrated/world units) + * @return the nearest node, or null if the paths hold no nodes + */ + public synchronized NodeRef nearestNodeOnPaths(final Collection paths, final double x, final double y, + final double z) { Path resultPath = null; int resultIndex = -1; double minimumDistanceSquared = Double.MAX_VALUE; - - for (final Path p : allPaths) { - - if (!selectedPathsSet.contains(p)) continue; - - if (0 == p.size()) continue; - - final int i = p.indexNearestTo(x * x_spacing, y * y_spacing, z * z_spacing); - - final PointInImage nearestOnPath = p.getNodeWithoutChecks(i); - - final double distanceSquared = nearestOnPath.distanceSquaredTo(x * - x_spacing, y * y_spacing, z * z_spacing); - + for (final Path p : paths) { + if (p == null || p.size() == 0) continue; + final int i = p.indexNearestTo(x, y, z); + if (i < 0) continue; + final double distanceSquared = p.getNodeWithoutChecks(i).distanceSquaredTo(x, y, z); if (distanceSquared < minimumDistanceSquared) { resultPath = p; resultIndex = i; minimumDistanceSquared = distanceSquared; } } - return (resultPath == null) ? null : new NodeRef(resultPath, resultIndex); } + /** + * Returns the version of a path that is in use, i.e., the one that is displayed and analyzed: its fitted + * flavor if {@link Path#getUseFitted()}, otherwise the path itself. Same rule as {@link Tree#Tree(Collection)}. + * A fitted copy whose original is registered with this manager is first resolved to that original (see + * {@link #isDeFactoPath(Path)}), so that both flavors of a path always resolve to the same version. + * + * @param p the path + * @return the version in use. null if {@code p} is null + */ + public synchronized Path getVersionInUse(final Path p) { + if (p == null) return null; + final Path base = (p.isFittedVersionOfAnotherPath() && !isDeFactoPath(p)) ? p.getUnfitted() : p; + return base.getUseFitted() ? base.getFitted() : base; + } + + /** + * As {@link #nearestNodeOnPaths(Collection, double, double, double)}, but searching the version in use of each + * path (see {@link #getVersionInUse(Path)}). The returned {@link NodeRef#path()} is that version. + * + * @param paths the paths to be searched + * @param x the x-coordinate (calibrated/world units) + * @param y the y-coordinate (calibrated/world units) + * @param z the z-coordinate (calibrated/world units) + * @return the nearest node, or null if the paths hold no nodes + */ + public synchronized NodeRef nearestNodeInUse(final Collection paths, final double x, final double y, + final double z) { + final Set inUse = new LinkedHashSet<>(); + for (final Path p : paths) { + final Path v = getVersionInUse(p); + if (v != null) inUse.add(v); + } + return nearestNodeOnPaths(inUse, x, y, z); + } + /** Immutable reference to a specific node of a specific Path. */ - record NodeRef(Path path, int index) {} + public record NodeRef(Path path, int index) {} /** * Returns the "main" paths managed by this PathAndFillManager. This is a diff --git a/src/main/java/sc/fiji/snt/PathNodeCanvas.java b/src/main/java/sc/fiji/snt/PathNodeCanvas.java index e23cda859..16caf4c66 100644 --- a/src/main/java/sc/fiji/snt/PathNodeCanvas.java +++ b/src/main/java/sc/fiji/snt/PathNodeCanvas.java @@ -238,9 +238,24 @@ private void assignColor(final Graphics2D g, final Color fallbackColor) { // Reset color to avoid stale values, and check bounds before accessing color = (index >= 0 && index < path.size()) ? path.getNodeColor(index) : null; if (color == null) color = fallbackColor; + // Locked active node is flagged in red (as in the Bvv/Bdv editor), unless the node itself is reddish + if (editable && path.isEditableNodeLocked()) color = lockColor(color); g.setColor(color); } + /** + * Color flagging a locked active node: red, unless {@code nodeColor} is itself reddish. Shared by all front-ends + * (tracing canvas, Bdv/Bvv) so that locked nodes look alike everywhere + * + * @param nodeColor the color the node would otherwise be rendered with + * @return the color to flag the locked node with + */ + public static Color lockColor(final Color nodeColor) { + final float[] hsb = Color.RGBtoHSB(nodeColor.getRed(), nodeColor.getGreen(), nodeColor.getBlue(), null); + final boolean reddish = hsb[1] > 0.4f && hsb[2] > 0.3f && (hsb[0] < 0.07f || hsb[0] > 0.93f); + return reddish ? Color.CYAN : Color.RED; + } + private void assignRenderingSize() { if (size > -1) return; // size already specified via setSize() final double baseline = canvas.nodeDiameter(); diff --git a/src/main/java/sc/fiji/snt/PathNodeEditor.java b/src/main/java/sc/fiji/snt/PathNodeEditor.java new file mode 100644 index 000000000..73f0ee5a5 --- /dev/null +++ b/src/main/java/sc/fiji/snt/PathNodeEditor.java @@ -0,0 +1,868 @@ +/*- + * #%L + * Fiji distribution of ImageJ for the life sciences. + * %% + * Copyright (C) 2010 - 2026 Fiji developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +package sc.fiji.snt; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.apache.commons.lang3.StringUtils; +import org.jgrapht.Graphs; +import org.jgrapht.traverse.DepthFirstIterator; + +import ij.measure.Calibration; +import sc.fiji.snt.analysis.graph.DirectedWeightedGraph; +import sc.fiji.snt.analysis.graph.DirectedWeightedSubgraph; +import sc.fiji.snt.analysis.graph.SWCWeightedEdge; +import sc.fiji.snt.gui.GuiUtils; +import sc.fiji.snt.util.PointInImage; +import sc.fiji.snt.util.SWCPoint; +import sc.fiji.snt.util.TreeUtils; + +/** + * Viewer-agnostic node-editing operations on a single {@link Path}: the logic behind the 'Edit Mode' commands of + * {@link InteractiveTracerCanvas} (delete, insert, move, lock, radius, tag, re-root, split, undo), but free of any + * canvas/ImagePlus dependency so that it can be driven by other front-ends (e.g., Bdv/Bvv). + *

    + * This class lives in {@code sc.fiji.snt} because several of the {@link Path}/{@link PathAndFillManager} members it + * relies on are package-scoped. It is a front-end helper: it holds no GUI state beyond the (optional) dialogs it is + * explicitly handed. All coordinates are calibrated (world) coordinates, i.e., the same space as + * {@link Path#getNode(int)}. + *

    + *

    + * Every operation returns a {@link Result}: callers are expected to display {@link Result#message()} (if any) and, + * when {@link Result#pathsChanged()} is {@code true}, re-sync any rendering of the Path Manager. + *

    + * + * @author Tiago Ferreira + */ +public final class PathNodeEditor { + + /** + * Outcome of an edit operation + * + * @param ok whether the operation was performed + * @param message message for the user, or null + * @param pathsChanged whether paths were added/removed/replaced in the Path Manager (as opposed to a node of + * the editing path having merely changed). If true, the editing path is no longer valid + * and {@link #getPath()} returns null + * @param modal whether the message reports an aborted operation that should not be missed, i.e., + * should be displayed in a dialog rather than in a transient notification + */ + public record Result(boolean ok, String message, boolean pathsChanged, boolean modal) { + Result(final boolean ok, final String message, final boolean pathsChanged) { + this(ok, message, pathsChanged, false); + } + + static Result ok(final String msg) { + return new Result(true, msg, false); + } + + /** A refusal that merely needs a transient notification (e.g., precondition not met) */ + static Result fail(final String msg) { + return new Result(false, msg, false); + } + + /** An aborted operation: the message should be displayed in a dialog */ + static Result abort(final String msg) { + return new Result(false, msg, false, true); + } + + /** + * Routes this outcome to the front-end reaction that applies, in order of precedence: paths changed + * (whether or not the operation succeeded), nodes changed, or operation refused/failed with the paths + * intact. Keeps that precedence in one place for all front-ends (canvas, Bdv, Bvv) + * + * @param onPathsChanged called if {@link #pathsChanged()}: the editing path is no longer valid + * @param onNodesChanged called if the operation succeeded and the editing path remains valid + * @param onRefused called if the operation failed or was refused and the paths are intact + */ + public void dispatch(final Consumer onPathsChanged, final Consumer onNodesChanged, + final Consumer onRefused) { + if (pathsChanged) onPathsChanged.accept(this); + else if (ok) onNodesChanged.accept(this); + else onRefused.accept(this); + } + } + + private final SNT snt; + private Path path; + private final Deque undoStack = new ArrayDeque<>(); + + public PathNodeEditor(final SNT snt) { + if (snt == null) throw new IllegalArgumentException("SNT instance cannot be null"); + this.snt = snt; + } + + /** + * Sets the path to be edited, resetting the active node of the previous path. See {@link #attach(Path)} for a + * variant without side effects on the previous path. Package-scoped: the editing path is managed by {@link SNT} + * + * @param p the path to be edited. null to release the current path + */ + void setPath(final Path p) { + if (path != null) { + path.setEditableNode(-1); + path.setEditableNodeLocked(false); + } + attach(p); + } + + /** Sets the path to be edited, discarding the undo history if it differs from the previous one */ + void attach(final Path p) { + if (p != path) undoStack.clear(); + path = p; + } + + /** @return the path being edited (see {@link SNT#getEditingPath()}), or null */ + public Path getPath() { + return path; + } + + /** + * Tags a node of the editing path as 'active', i.e., the target of subsequent operations. Does nothing if the + * active node is locked. + * + * @param index the node index or -1 for none + */ + public void setActiveNode(final int index) { + if (path == null || path.isEditableNodeLocked()) return; + path.setEditableNode((index < 0 || index >= path.size()) ? -1 : index); + } + + /** @return the index of the active node, or -1 */ + public int getActiveNode() { + return (path == null) ? -1 : path.getEditableNodeIndex(); + } + + /** @return whether the active node is locked */ + public boolean isActiveNodeLocked() { + return path != null && path.isEditableNodeLocked(); + } + + /** @return whether there are edits that can be undone */ + public boolean canUndo() { + return !undoStack.isEmpty(); + } + + /** @return whether the editing path is in fitted-display mode (structural edits are unsupported) */ + public boolean isFitted() { + return path != null && path.getUseFitted(); + } + + private Result notReady(final boolean needNode) { + if (path == null || !snt.getPathAndFillManager().isSelected(path)) return Result.fail("Editing path not selected"); + if (needNode && (path.getEditableNodeIndex() < 0 || path.getEditableNodeIndex() >= path.size())) + return Result.fail("No node selected"); + return null; + } + + private Result unsupportedIfFitted(final String label) { + return isFitted() ? Result.fail(label + " not supported on fitted paths. Un-fit first.") : null; + } + + /** + * Records the current state of the editing path so that the next edit can be undone. Operations of this class + * do it themselves. It is only needed by front-ends modifying the path directly, e.g., once for a whole gesture + * (a drag) made of many small changes + */ + public void pushUndo() { + if (path == null) return; + undoStack.push(path.clone()); + if (undoStack.size() > SNTPrefs.MAX_UNDO_STEPS) undoStack.removeLast(); + } + + /** Drops the snapshot of the last {@link #pushUndo()}, for operations that failed without altering the path */ + private void discardLastUndo() { + if (!undoStack.isEmpty()) undoStack.pop(); + } + + private void notifyPlausibilityMonitor(final int nodeIndex) { + if (snt.getUI() != null && snt.getUI().getPlausibilityMonitor().isEnabled()) + snt.getUI().getPlausibilityMonitor().onNodeEdited(path, nodeIndex); + } + + /** Undoes the last node edit */ + public Result undo() { + if (path == null || undoStack.isEmpty()) return Result.fail("Nothing to undo"); + path.replaceNodes(undoStack.pop()); + // The affected node(s) are unknown: index 0 makes the monitor re-check the fork neighborhood + notifyPlausibilityMonitor(0); + return Result.ok("Edit undone"); + } + + /** Toggles the locked state of the active node. A locked node remains active regardless of the cursor */ + public Result toggleLock() { + Result r = notReady(true); + if (r != null) return r; + path.setEditableNodeLocked(!path.isEditableNodeLocked()); + return Result.ok(path.isEditableNodeLocked() ? "Active node locked" : "Active node unlocked"); + } + + /** + * Deletes the active node. Single-node/two-node paths cannot be shortened: use {@link #deleteWholePath()} + * if {@link #isTooShortToDeleteNode()} is true (after confirming with the user) + */ + public Result deleteNode() { + Result r = notReady(true); + if (r == null) r = unsupportedIfFitted("Delete node"); + if (r != null) return r; + if (path.size() <= 2) return Result.fail("Path too short: Delete the entire path?"); + final int idx = path.getEditableNodeIndex(); + final PointInImage node = path.getNode(idx); + if (path.getBranchPoints().stream().anyMatch(n -> n.equals(node))) + return Result.abort("Cannot delete junction node. Try to split instead."); + pushUndo(); + try { + path.removeNode(idx); + path.setEditableNodeLocked(false); // the lock belongs to the deleted node, not to its neighbor + path.setEditableNode(Math.min(idx, path.size() - 1)); + notifyPlausibilityMonitor(idx); + return Result.ok("Node deleted"); + } catch (final IllegalArgumentException ex) { + discardLastUndo(); + return Result.abort("Node deletion failed!"); + } + } + + /** + * Deletes the active node or, if the editing path is too short to lose nodes (see + * {@link #isTooShortToDeleteNode()}), the entire path after asking for confirmation + * + * @param gui the GuiUtils instance used to display the confirmation prompt + */ + public Result deleteNodeOrPath(final GuiUtils gui) { + final Result r = notReady(true); + if (r != null) return r; + if (isTooShortToDeleteNode() && !isFitted()) { + if (!gui.getConfirmation("Delete " + path + "?", "Delete Path?")) return Result.fail(null); + return deleteWholePath(); + } + return deleteNode(); + } + + /** @return true if the editing path cannot lose further nodes (see {@link #deleteNode()}) */ + public boolean isTooShortToDeleteNode() { + return path != null && path.size() <= 2; + } + + /** Deletes the entire editing path from the Path Manager. Callers should confirm with the user first */ + public Result deleteWholePath() { + if (path == null) return Result.fail("No path"); + final PathAndFillManager pafm = snt.getPathAndFillManager(); + boolean rebuild = false; + if (path.connectedPaths != null) { + for (final Path p : path.connectedPaths) { + if (p.getParentPath() == path) { + rebuild = true; + break; + } + } + } + path.disconnectFromAll(); // fixes ghost connection after deleting last node in a forked path + pafm.deletePath(path); + if (rebuild) pafm.rebuildRelationships(); + path = null; + undoStack.clear(); + releaseSecondaryPath(); + return new Result(true, "Path deleted", true); + } + + /** + * Inserts a node at the specified position, immediately before the active node. The active node remains active. + */ + public Result insertNode(final double x, final double y, final double z) { + Result r = notReady(true); + if (r == null) r = unsupportedIfFitted("Insert node"); + if (r != null) return r; + pushUndo(); + final int idx = path.getEditableNodeIndex(); + try { + path.insertNode(idx, new PointInImage(x, y, z)); + path.setEditableNode(idx + 1); + notifyPlausibilityMonitor(idx); + return Result.ok("New node inserted (N=" + idx + ")"); + } catch (final IllegalArgumentException ex) { + discardLastUndo(); + return Result.abort("Node insertion failed!"); + } + } + + /** Moves the active node to the specified position */ + public Result moveNode(final double x, final double y, final double z) { + return moveNode(x, y, z, true); + } + + /** + * Moves the active node to the specified position + * + * @param recordUndo if false, the move is not recorded in the undo history (see {@link #pushUndo()}) + */ + public Result moveNode(final double x, final double y, final double z, final boolean recordUndo) { + final Result r = notReady(true); + if (r != null) return r; + if (recordUndo) pushUndo(); + final int idx = path.getEditableNodeIndex(); + try { + path.moveNode(idx, new PointInImage(x, y, z)); + notifyPlausibilityMonitor(idx); + return Result.ok("Node moved"); + } catch (final IllegalArgumentException ex) { + if (recordUndo) discardLastUndo(); + return Result.abort("Node displacement failed!"); + } + } + + /** Assigns (or removes, if null) a color tag to the active node */ + public Result tagNode(final Color color) { + Result r = notReady(true); + if (r == null) r = unsupportedIfFitted("Tag node"); + if (r != null) return r; + final int idx = path.getEditableNodeIndex(); + pushUndo(); + path.setNodeColor(color, idx); + if (snt.getUI() != null) { + snt.getUI().getPathManager().update(true); + final BookmarkManager bm = snt.getUI().getBookmarkManager(); + if (color == null) bm.remove(path, idx); + else bm.add(path, idx); + } + return Result.ok((color == null) ? String.format("Tag removed from node #%d", idx) : null); + } + + /** + * As {@link #tagNode(Color)}, for a node that was the active one when a color chooser was opened: the active + * node follows the cursor while a chooser is open, so it is restored first + * + * @param target the editing path the chooser was opened for. Nothing is done if it is no longer the editing path + * @param index the index of the node the chooser was opened for + * @param color the color, or null to remove the tag + */ + public Result tagNode(final Path target, final int index, final Color color) { + if (target != path) return Result.fail(null); + setActiveNode(index); + return tagNode(color); + } + + /** + * Prompts for, and assigns, a radius to the active node (mirrors the dialog of the classic canvas) + * + * @param gui the GuiUtils instance used to display dialogs + */ + public Result promptAndSetRadius(final GuiUtils gui) { + final Result nr = notReady(true); + if (nr != null) return nr; + final Path edPath = path; + final int edNode = edPath.getEditableNodeIndex(); + final boolean hasRadii = edPath.hasRadii(); + final String[] defChoices = new String[(hasRadii) ? 5 : 3]; + defChoices[0] = "Assign average of flanking nodes (if any)"; + defChoices[1] = "Assign half of minimum voxel separation"; + defChoices[2] = "Assign value specified below:"; + if (hasRadii) { + defChoices[3] = "Multiply existing radius by multiplier specified below:"; + defChoices[4] = "Assign mean path radius"; + } + String msg = "Which value should be set as radius for node #" + edNode + "?
    "; + if (hasRadii) msg += String.format("(Current radius is %.2f)", edPath.getNodeRadius(edNode)); + else msg += "NB: No nodes in " + edPath.getName() + " have assigned radii!"; + + final String prevChoice = snt.getPrefs().getTemp("lastRadChoice", defChoices[0]); + final String prevDouble = snt.getPrefs().getTemp("lastRadDouble", null); + double defValue = edPath.getNodeRadius(edNode); + if (prevDouble != null) { + try { + defValue = Double.parseDouble(prevDouble); + } catch (final NumberFormatException ignored) { + // corrupted preference: fall back to the radius of the active node + } + } + final Object[] usrChoice = gui.getChoiceAndDouble(msg, edPath.getName() + ": Ad hoc Radius", defChoices, + prevChoice, defValue); + if (usrChoice == null) return Result.fail(null); + + double r = Double.NaN; + if (defChoices[0].equals(usrChoice[0])) { + if (edPath.size() == 1) return Result.abort("Path has only one node. No flanking nodes exist."); + final int n1 = Math.max(0, edNode - 1); + final int n2 = Math.min(edPath.size() - 1, edNode + 1); + r = (edPath.getNodeRadius(n1) + edPath.getNodeRadius(n2)) / 2; + } else if (defChoices[1].equals(usrChoice[0])) { + r = snt.getMinimumSeparation() / 2; + } else if (defChoices[2].equals(usrChoice[0])) { + r = (double) usrChoice[1]; + snt.getPrefs().setTemp("lastRadDouble", Double.toString((double) usrChoice[1])); + } else if (hasRadii && defChoices[3].equals(usrChoice[0])) { + r = edPath.getNodeRadius(edNode) * (double) usrChoice[1]; + snt.getPrefs().setTemp("lastRadDouble", Double.toString((double) usrChoice[1])); + } else if (hasRadii && defChoices[4].equals(usrChoice[0])) { + r = edPath.getMeanRadius(); + } + snt.getPrefs().setTemp("lastRadChoice", (String) usrChoice[0]); + if (Double.isNaN(r) || r < 0d) return Result.abort("Invalid radius. Must be a non-negative, floating-point value."); + pushUndo(); + edPath.setRadius(r, edNode); + return Result.ok(String.format("Radius set to %.2f", r)); + } + + /** + * Makes the active node the root of its tree. Asks for confirmation (the operation cannot be undone). On + * success the editing path is released: Callers must re-sync and leave edit mode. + */ + public Result setActiveNodeAsRoot(final GuiUtils gui) { + Result r = notReady(true); + if (r == null) r = unsupportedIfFitted("Set tree root"); + if (r != null) return r; + if (!gui.getConfirmation("Set active node as tree root? This operation cannot be undone.", "Confirm Re-root")) + return Result.fail(null); + final PathAndFillManager pafm = snt.getPathAndFillManager(); + final Path editingPath = path; + try { + final Tree editingTree = TreeUtils.getConnectedTree(editingPath); + final boolean existingEnableUiUpdates = pafm.enableUIupdates; + pafm.enableUIupdates = false; + try { + final PointInImage editingNode = editingPath.getNode(editingPath.getEditableNodeIndex()); + final DirectedWeightedGraph editingGraph = new DirectedWeightedGraph(editingTree, false); + SWCPoint newRoot = getMatchingPointInGraph(editingNode, editingGraph); + if (newRoot == null) { + final SWCPoint nearest = getNearestPointInGraph(editingNode, editingGraph); + if (nearest == null) return Result.abort("Could not find a valid node for the new root."); + final int uniqueId = editingGraph.vertexSet().stream().mapToInt(v -> v.id).max().orElse(-2) + 1; + newRoot = new SWCPoint(uniqueId, nearest.type, editingNode.x, editingNode.y, editingNode.z, + editingPath.getNodeRadius(editingPath.getEditableNodeIndex()), nearest.id); + newRoot.setPath(editingPath); + editingGraph.addVertex(newRoot); + editingGraph.addEdge(nearest, newRoot); + if (editingPath.size() == 1) pafm.deletePath(editingPath); + else editingPath.removeNode(editingPath.getEditableNodeIndex()); + } + editingGraph.setRoot(newRoot); + replaceTree(editingTree, editingGraph.getTreeWithSamePathStructure()); + } finally { + pafm.enableUIupdates = existingEnableUiUpdates; + } + return structuralChange("Tree re-rooted"); + } catch (final IllegalArgumentException e) { + // If the failure occurred after the editing path was released, callers must still re-sync + if (path == null) releaseSecondaryPath(); + return new Result(false, "Could not re-root tree:
    " + e.getMessage() + "", path == null, true); + } + } + + /** + * Splits the tree at the active node. Asks for confirmation (the operation cannot be undone). On success the + * editing path is released: Callers must re-sync and leave edit mode. + */ + public Result splitTreeAtActiveNode(final GuiUtils gui) { + Result r = notReady(true); + if (r == null) r = unsupportedIfFitted("Split tree"); + if (r != null) return r; + final Path editingPath = path; + try { + final Tree editingTree = TreeUtils.getConnectedTree(editingPath); + if (!gui.getConfirmation("Split tree at active node? This operation cannot be undone.", "Confirm Split")) + return Result.fail(null); + final PointInImage editingPoint = editingPath.getNode(editingPath.getEditableNodeIndex()); + if (editingTree.getRoot().equals(editingPoint)) return Result.abort("Cannot split tree at root node."); + + final DirectedWeightedGraph editingGraph = new DirectedWeightedGraph(editingTree, false); + final SWCPoint editingVertex = getMatchingPointInGraph(editingPoint, editingGraph); + if (editingVertex == null) return Result.abort("Could not locate the selected node in the tree graph."); + final Set incomingEdges = editingGraph.incomingEdgesOf(editingVertex); + if (incomingEdges.isEmpty()) return Result.abort("Selected node has no incoming edges. Cannot split here."); + + editingGraph.removeEdge(incomingEdges.iterator().next()); + final DepthFirstIterator it = editingGraph.getDepthFirstIterator(editingVertex); + final Set descendantVertexSet = new HashSet<>(); + while (it.hasNext()) + descendantVertexSet.add(it.next()); + final DirectedWeightedSubgraph descendantSubgraph = editingGraph.getSubgraph(descendantVertexSet); + final DirectedWeightedGraph descendantGraph = new DirectedWeightedGraph(); + Graphs.addGraph(descendantGraph, descendantSubgraph); + editingGraph.removeAllVertices(descendantVertexSet); + + final Tree ancestorTree = editingGraph.getTreeWithSamePathStructure(); + final Tree descendantTree = descendantGraph.getTreeWithSamePathStructure(); + replaceTree(editingTree, ancestorTree, descendantTree); + return structuralChange("Tree split"); + } catch (final IllegalArgumentException e) { + // If the failure occurred after the editing path was released, callers must still re-sync + if (path == null) releaseSecondaryPath(); + return new Result(false, "Could not split tree:
    " + e.getMessage() + "", path == null, true); + } + } + + private static void runWithoutUiUpdates(final PathAndFillManager pafm, final Runnable task) { + final boolean existing = pafm.enableUIupdates; + pafm.enableUIupdates = false; + try { + task.run(); + } finally { + pafm.enableUIupdates = existing; + } + } + + /** Releases the editing path and replaces the paths of a tree by the paths of the trees that supersede it */ + private void replaceTree(final Tree old, final Tree... added) { + final PathAndFillManager pafm = snt.getPathAndFillManager(); + setPath(null); + final Calibration cal = snt.getCalibration(); + runWithoutUiUpdates(pafm, () -> { + pafm.deletePaths(old.list()); + for (final Tree t : added) { + for (final Path p : t.list()) { + p.setSpacing(cal); + pafm.addPath(p, false, true); + } + } + }); + } + + /** Bookkeeping common to operations that replaced paths, which cannot be undone */ + private Result structuralChange(final String msg) { + undoStack.clear(); + releaseSecondaryPath(); + return new Result(true, msg, true); + } + + /** + * Forgets the 'secondary' path. To be called whenever paths were deleted/replaced, since the secondary path may + * no longer be in the Path Manager. Must be called after the editing path was released + */ + private void releaseSecondaryPath() { + snt.setEditingPath(null); // previous path := current (null) + } + + /** @return the 'secondary' path, i.e., the path that was being edited before the current one, or null */ + public Path getSecondaryPath() { + return snt.getPreviousEditingPath(); + } + + /** + * @return whether a secondary path with an active node exists, i.e., whether + * {@link #connectToSecondaryPath(GuiUtils, boolean)} may be applicable + */ + public boolean hasSecondaryNode() { + final Path secondary = snt.getPreviousEditingPath(); + return path != null && secondary != null && secondary != path && secondary.getEditableNodeIndex() != -1; + } + + /** + * Connects the active node of the editing path to the active node of the secondary path (the one edited + * previously). By default the editing path becomes the child (branching from the secondary path), but a + * heuristic and the user may swap roles. Asks for confirmation (the operation cannot be undone). On success the + * editing path is released: Callers must re-sync and leave edit mode. + * + * @param gui used to display prompts and errors + * @param showDialog whether the user should be prompted to confirm (and possibly swap) the parent and child + * @return the outcome. A failure with no message means the operation was canceled or reported to the user + */ + public Result connectToSecondaryPath(final GuiUtils gui, final boolean showDialog) { + final Path secondary = snt.getPreviousEditingPath(); + if (path == null || path.getEditableNodeIndex() == -1 || secondary == null + || secondary.getEditableNodeIndex() == -1) { + gui.error("Connect to Secondary Editing Path: No connectable node exist.", "No Connectable Nodes"); + return Result.fail(null); + } + final Result unsupported = unsupportedIfFitted("Connect paths"); + if (unsupported != null) return unsupported; + + // Default assignment: current path = child, previous path = parent + Path childPath = path; + Path parentPath = secondary; + if (wouldCreateLoop(childPath, parentPath)) { + gui.error("Cannot connect selected nodes: A loop would be created!", + "Reconstruction Cannot Contain Loops"); + return Result.fail(null); + } + final int childNodeIdx = childPath.getEditableNodeIndex(); + final int parentNodeIdx = parentPath.getEditableNodeIndex(); + + // Auto-swap heuristics + final boolean shouldSwap; + final int childMaxOrder = TreeUtils.getMaxOrder(childPath); + final int parentMaxOrder = TreeUtils.getMaxOrder(parentPath); + if (childMaxOrder != parentMaxOrder) { + // Deeper tree (higher max order) is likely the main structure + shouldSwap = childMaxOrder > parentMaxOrder; + } else { + // Same depth: larger path is likely the parent + shouldSwap = childPath.size() > parentPath.size(); + } + + if (showDialog) { + final boolean childAtEndpoint = childNodeIdx == 0 || childNodeIdx == childPath.size() - 1; + final StringBuilder msg = new StringBuilder(""); + msg.append("Connection Summary:
    "); + msg.append("Parent: ").append(StringUtils.abbreviate(parentPath.getName(), 30)); + msg.append(" (branch at node #").append(parentNodeIdx).append(")
    "); + msg.append("Child: ").append(StringUtils.abbreviate(childPath.getName(), 30)); + msg.append(" (connect from node #").append(childNodeIdx).append(")
    "); + msg.append("Note:
    "); + if (!childAtEndpoint && childPath.size() > 2) { + msg.append("  - Child connects from a mid-path node
    "); + } + msg.append("  - This operation cannot be undone

    "); + final boolean[] result = gui.getConfirmationAndOption(msg.toString(), "Connect Paths", + "Swap parent \u2194 child", shouldSwap, new String[] { "Connect", "Cancel" }); + if (result == null || !result[0]) return Result.fail(null); // User canceled + if (result[1]) { // Swap if requested + final Path temp = childPath; + childPath = parentPath; + parentPath = temp; + if (wouldCreateLoop(childPath, parentPath)) { + gui.error("Cannot connect with swapped parent \u2194 child: A loop would be created!", + "Reconstruction Cannot Contain Loops"); + return Result.fail(null); + } + } + } + final Result r = connectToEditingPath(childPath, parentPath, gui); + if (r.pathsChanged()) { + setPath(null); + releaseSecondaryPath(); + if (snt.getUI() != null) snt.getUI().getPathManager().setPathList(null, false); + } + return r; + } + + /** + * Checks if connecting child to parent would create a loop. + */ + private boolean wouldCreateLoop(final Path child, final Path parent) { + if (child == parent) { + return true; + } + if (child.getChildren().isEmpty()) { + return false; + } + return TreeUtils.isInSubtree(parent, child); + } + + /** + * Connects child path to parent path, creating physical connection segment + * and proper T-junction topology when needed. + *

    + * If the child's selected node is a slab (middle) node, the child path is split + * at that point to create proper branching topology. + * + * @param child The path to be connected as a child branch + * @param parent The path where the child will branch from + */ + private Result connectToEditingPath(final Path child, final Path parent, final GuiUtils gui) { + final PathAndFillManager pafm = snt.getPathAndFillManager(); + try { + final long start = System.currentTimeMillis(); + + final int childNodeIdx = child.getEditableNodeIndex(); + final int parentNodeIdx = parent.getEditableNodeIndex(); + final PointInImage parentPoint = parent.getNode(parentNodeIdx); + + // Check if child's selected node is a slab (middle) node + final boolean childAtStart = childNodeIdx == 0; + final boolean childAtEnd = childNodeIdx == child.size() - 1; + final boolean childAtSlab = !childAtStart && !childAtEnd && child.size() > 2; + + runWithoutUiUpdates(pafm, () -> { + if (childAtSlab) { + // T-JUNCTION: Split child path and handle directly without graph transformation + connectWithTJunction(child, parent, childNodeIdx, parentNodeIdx, parentPoint); + } else { + // ENDPOINT CONNECTION: Use direct path manipulation + connectAtEndpoint(child, parent, childAtStart, parentNodeIdx, parentPoint); + } + }); + SNTUtils.log("Finished connection in " + (System.currentTimeMillis() - start) + "ms"); + return new Result(true, "Paths connected", true); + + } catch (final IllegalArgumentException e) { + gui.error("Cannot connect these paths:

    " + e.getMessage() + "", "Connection Failed"); + return new Result(false, null, true); // topology may have been partially altered: callers must re-sync + } + } + + /** + * Recursively updates the tree ID of a path and all its descendants. + */ + private void updateTreeIDRecursively(final Path root, final int treeID) { + root.setIDs(root.getID(), treeID); + for (final Path child : root.getChildren()) { + updateTreeIDRecursively(child, treeID); + } + } + + /** + * Connects child path to parent at an endpoint (start or end of child). + */ + private void connectAtEndpoint(final Path child, final Path parent, + final boolean childAtStart, + final int parentNodeIdx, final PointInImage parentPoint) { + final PathAndFillManager pafm = snt.getPathAndFillManager(); + + // Sync canvas offset before any operations + TreeUtils.syncCanvasOffset(child, parent); + + // Check if we need to reverse the child to connect from its start + Path childToConnect = child; + if (!childAtStart) { + // Child connects from end - reverse it so it connects from start + childToConnect = child.reversed(); + + // Transfer children with updated branch points + for (final Path grandchild : new ArrayList<>(child.getChildren())) { + final int oldBranchIdx = grandchild.getBranchPointIndex(); + final int newBranchIdx = child.size() - 1 - oldBranchIdx; + grandchild.detachFromParent(); + grandchild.setBranchFrom(childToConnect, childToConnect.getNode(newBranchIdx)); + } + + // Remove original and add reversed + pafm.deletePath(child); + pafm.addPath(childToConnect, true, true); + } + + // Check if we need a bridging node + final double distance = childToConnect.firstNode().distanceTo(parentPoint); + final double threshold = snt.getMinimumSeparation() * 2; + if (distance > threshold) { + // Insert bridging node at start of child + final PointInImage bridgePoint = new PointInImage(parentPoint.x, parentPoint.y, parentPoint.z); + childToConnect.insertNode(0, bridgePoint); + } + + // Connect child to parent + childToConnect.setBranchFrom(parent, parent.getNode(parentNodeIdx)); + // Recursively update tree ID for child and all descendants + updateTreeIDRecursively(childToConnect, parent.getTreeID()); + } + + /** + * Connects child path to parent by splitting child at a mid-path node (T-junction). + */ + private void connectWithTJunction(final Path child, final Path parent, + final int childNodeIdx, final int parentNodeIdx, + final PointInImage parentPoint) { + final PathAndFillManager pafm = snt.getPathAndFillManager(); + + // Sync canvas offset before any operations + TreeUtils.syncCanvasOffset(child, parent); + + // Store children info before we modify anything + final List grandchildren = new ArrayList<>(child.getChildren()); + final Map grandchildBranchIndices = new HashMap<>(); + for (final Path gc : grandchildren) { + grandchildBranchIndices.put(gc, gc.getBranchPointIndex()); + gc.detachFromParent(); + } + + // Create segment 1: nodes [0, childNodeIdx] + final Path segment1 = child.createPath(); + for (int i = 0; i <= childNodeIdx; i++) { + segment1.addNode(child.getNode(i)); + } + // Create segment 2: nodes [childNodeIdx, end] - includes junction node for continuity + Path segment2 = null; + if (childNodeIdx < child.size() - 1) { + segment2 = child.createPath(); + for (int i = childNodeIdx; i < child.size(); i++) { + segment2.addNode(child.getNode(i)); + } + } + + // Check if we need a bridging node for the connection to parent + final PointInImage junctionPoint = segment1.lastNode(); + final double distance = junctionPoint.distanceTo(parentPoint); + final double threshold = snt.getMinimumSeparation() * 2; + if (distance > threshold) { + // Add bridging node at the END of segment1 (after junction point) + // This extends segment1 toward the parent + final PointInImage bridgePoint = new PointInImage(parentPoint.x, parentPoint.y, parentPoint.z); + segment1.addNode(bridgePoint); + } + + // Delete original child path + pafm.deletePath(child); + + // Add segment1 and connect it to parent + pafm.addPath(segment1, true, true); + segment1.setBranchFrom(parent, parent.getNode(parentNodeIdx)); + // Recursively update tree ID for segment1 and its subtree + updateTreeIDRecursively(segment1, parent.getTreeID()); + + // Add segment2 and connect it to segment1 at the junction (before the bridge if present) + if (segment2 != null) { + pafm.addPath(segment2, true, true); + // segment2 branches from segment1 at the junction point + // The junction is at index childNodeIdx in segment1 (which has nodes 0..childNodeIdx, possibly + bridge) + segment2.setBranchFrom(segment1, segment1.getNode(childNodeIdx)); + // Recursively update tree ID for segment2 and its subtree + updateTreeIDRecursively(segment2, parent.getTreeID()); + } + + // Reassign grandchildren to appropriate segment + for (final Path gc : grandchildren) { + final int origBranchIdx = grandchildBranchIndices.get(gc); + + if (origBranchIdx <= childNodeIdx) { + // Grandchild branches from segment1 portion + // Index in segment1 is same as original + gc.setBranchFrom(segment1, segment1.getNode(origBranchIdx)); + } else if (segment2 != null) { + // Grandchild branches from segment2 portion + // Index in segment2: origBranchIdx - childNodeIdx (since segment2 starts at childNodeIdx) + final int newIdx = origBranchIdx - childNodeIdx; + gc.setBranchFrom(segment2, segment2.getNode(newIdx)); + } + // Tree ID already propagated recursively above; no need to update grandchildren here + } + } + + + private static SWCPoint getNearestPointInGraph(final PointInImage point, final DirectedWeightedGraph graph) { + double best = Double.MAX_VALUE; + SWCPoint nearest = null; + for (final SWCPoint p : graph.vertexSet()) { + final double d2 = point.distanceSquaredTo(p.x, p.y, p.z); + if (d2 < best) { + nearest = p; + best = d2; + } + } + return nearest; + } + + private static SWCPoint getMatchingPointInGraph(final PointInImage point, final DirectedWeightedGraph graph) { + for (final SWCPoint p : graph.vertexSet()) { + if (p.isSameLocation(point) && p.getPath().equals(point.getPath())) return p; + } + return null; + } +} diff --git a/src/main/java/sc/fiji/snt/SNT.java b/src/main/java/sc/fiji/snt/SNT.java index 309f5f739..303cb1032 100644 --- a/src/main/java/sc/fiji/snt/SNT.java +++ b/src/main/java/sc/fiji/snt/SNT.java @@ -387,7 +387,8 @@ public String toString() { * point.) //FIXME: this may be redundant - check that. */ private volatile boolean pathUnfinished = false; - private Path editingPath; // Path being edited when in 'Edit Mode' + // Holds the Path being edited when in 'Edit Mode' (see getEditingPath()). Shared by all front-ends (canvas, Bdv, Bvv) + private final PathNodeEditor nodeEditor = new PathNodeEditor(this); private Path previousEditingPath; // reference to the 'last selected' path when in 'Edit Mode' /* Labels */ @@ -1891,7 +1892,8 @@ protected void dispose() { colorImage = null; ctSlice3d = null; currentPath = null; - editingPath = null; + if (editedPathSyncTimer != null) editedPathSyncTimer.stop(); + nodeEditor.setPath(null); // also resets active node and lock state fillerSet = null; imageContent = null; labelData = null; @@ -2183,7 +2185,17 @@ protected boolean uiReadyForModeChange() { } protected Path getEditingPath() { - return editingPath; + return nodeEditor.getPath(); + } + + /** + * Returns the node editor shared by all front-ends of this instance (tracing canvas, Bdv and Bvv). It holds the + * path being edited, its active node and the undo history of 'Edit Mode'. + * + * @return the node editor + */ + public PathNodeEditor getNodeEditor() { + return nodeEditor; } protected Path getPreviousEditingPath() { @@ -2220,23 +2232,62 @@ protected boolean editModeAllowed(final boolean warnUserIfNot) { * @return true if edit mode is allowed, false otherwise */ protected boolean editModeAllowed(final boolean warnUserIfNot, final Path pathToEdit) { - final boolean uiReady = uiReadyForModeChange() || isEditModeEnabled(); - if (warnUserIfNot && !uiReady) { - showCanvasWarning("Please finish current operation before editing paths"); + // Already editing: this is only a query. Re-attaching would swap the editing path (and drop its undo history) + if (isEditModeEnabled()) return getEditingPath() != null; + final boolean uiReady = uiReadyForModeChange(); + if (!uiReady) { + if (warnUserIfNot) showCanvasWarning("Please finish current operation before editing paths"); return false; } // Use provided path or detect from selection if (pathToEdit != null) { - editingPath = pathToEdit; + nodeEditor.attach(pathToEdit); } else { detectEditingPath(); } - final boolean pathExists = editingPath != null; - if (warnUserIfNot && !pathExists) { - showCanvasWarning("You must select a single path in order to edit it"); + final Path p = getEditingPath(); + if (p == null) { + if (warnUserIfNot) showCanvasWarning("You must select a single path in order to edit it"); return false; } - return uiReady && pathExists; + return true; + } + + /** + * Explains why 'Edit Mode' cannot be enabled for the currently selected path. For use by front-ends other than + * the tracing canvas (e.g., Bdv/Bvv) that display their own messages + * + * @return a user-friendly message, or null if 'Edit Mode' can be enabled + */ + public String getEditModeBlocker() { + if (!(uiReadyForModeChange() || isEditModeEnabled())) + return "Please finish the current operation before editing paths"; + final Path p = getSingleSelectedPath(); + return (p == null) ? "You must select a single path in order to edit it" : null; + } + + /** + * Enables 'Edit Mode' on all front-ends (tracing canvas, Bdv and Bvv) + * + * @param pathToEdit the path to be edited. It is selected (replacing the current selection) if not already the + * single selected path, since all edit operations require the editing path to be selected. + * If null, the single selected path is used + * @return true if 'Edit Mode' was enabled. See {@link #getEditModeBlocker()} if not + */ + public boolean enterEditMode(final Path pathToEdit) { + if (isEditModeEnabled() || !uiReadyForModeChange()) return false; + if (pathToEdit != null && pathToEdit != getSingleSelectedPath()) { + selectPath(pathToEdit, false); + if (!pathAndFillManager.isSelected(pathToEdit)) return false; // e.g., not a selectable path + } + if (!editModeAllowed(false, pathToEdit)) return false; + enableEditMode(true); + return isEditModeEnabled(); + } + + /** Disables 'Edit Mode' on all front-ends (tracing canvas, Bdv and Bvv) */ + public void exitEditMode() { + if (isEditModeEnabled()) enableEditMode(false); } /** @@ -2249,59 +2300,105 @@ protected boolean editModeAllowed(final boolean warnUserIfNot, final Path pathTo * @return true if the editing path is in fitted-display mode */ protected boolean isEditingFittedPath() { - return editingPath != null && editingPath.getUseFitted(); + return getEditingPath() != null && getEditingPath().getUseFitted(); } protected void setEditingPath(final Path path) { + // Re-selecting the path being edited must not shift the 'secondary' path, otherwise the + // secondary path (and its active node) would be replaced by the current one + if (path != null && path == getEditingPath()) { + if (ui != null) ui.refreshBigViewersEditNodes(); + return; + } if (previousEditingPath != null) { previousEditingPath.setEditableNode(-1); previousEditingPath.setEditableNodeLocked(false); } - previousEditingPath = editingPath; - editingPath = path; + previousEditingPath = getEditingPath(); + nodeEditor.attach(path); + if (ui != null) ui.refreshBigViewersEditNodes(); } protected void detectEditingPath() { - editingPath = getSingleSelectedPath(); + nodeEditor.attach(getSingleSelectedPath()); } - protected Path getSingleSelectedPath() { + public Path getSingleSelectedPath() { final Collection sPaths = getSelectedPaths(); if (sPaths == null || sPaths.size() != 1) return null; return getSelectedPaths().iterator().next(); } + /** + * Refreshes the active-node (and lock) markers of the Bdv/Bvv viewers after the active node of the editing path + * was changed or (un)locked by another front-end + * + * @param source the viewer that originated the change, which is not notified. null if the change was not made + * by a viewer + */ + public void notifyActiveNodeChanged(final sc.fiji.snt.viewer.AbstractBigViewer source) { + if (ui != null) ui.refreshBigViewersActiveNode(source); + } + + private javax.swing.Timer editedPathSyncTimer; + + /** + * Refreshes the Bdv/Bvv rendering of the path being edited after it was modified by the tracing canvas. + * Throttled so that continuous gestures (e.g., dragging a node) do not flood the viewers + */ + public void notifyEditingPathChanged() { + if (ui == null) return; + if (editedPathSyncTimer == null) { + editedPathSyncTimer = new javax.swing.Timer(40, e -> { + if (ui != null) ui.refreshBigViewersEditedPath(); + }); + editedPathSyncTimer.setRepeats(false); + } + if (!editedPathSyncTimer.isRunning()) editedPathSyncTimer.start(); + } + + /** Restores the tracing state (active/halted) that preceded 'Edit Mode' */ + private void resetStateAfterEditMode() { + if (ui == null) return; + // ui.resetState() halts tracing if there is no resident image data, which is the case in Stream mode + if (isStreamMode()) pauseTracing(tracingHalted, false); + else ui.resetState(); + } + protected void enableEditMode(final boolean enable) { if (enable) { changeUIState(SNTUI.EDITING); } else { - if (ui != null) ui.resetState(); + resetStateAfterEditMode(); } // Only re-detect editingPath if not already set (e.g., by editModeAllowed) if (enable) { - if (editingPath == null && pathAndFillManager.getSelectedPaths().size() == 1) { - editingPath = getSelectedPaths().iterator().next(); + if (getEditingPath() == null && pathAndFillManager.getSelectedPaths().size() == 1) { + nodeEditor.attach(getSelectedPaths().iterator().next()); } // If still null, edit mode shouldn't be enabled - if (editingPath == null) { - if (ui != null) ui.resetState(); + if (getEditingPath() == null) { + resetStateAfterEditMode(); return; } } else { - if (editingPath != null) editingPath.setEditableNode(-1); - editingPath = null; + nodeEditor.setPath(null); // also resets active node and lock state } setDrawCrosshairsAllPanes(!enable); - setLockCursorAllPanes(enable); - getXYCanvas().setEditMode(enable); - if (!single_pane) { - getXZCanvas().setEditMode(enable); - getZYCanvas().setEditMode(enable); + // No canvases in pure Stream mode + if (getXYCanvas() != null) { + setLockCursorAllPanes(enable); + getXYCanvas().setEditMode(enable); + if (!single_pane) { + getXZCanvas().setEditMode(enable); + getZYCanvas().setEditMode(enable); + } } updateTracingViewers(false); + if (ui != null) ui.setBigViewersEditMode(enable); } protected void pause(final boolean pause, final boolean hideSideViewsOnPause) { @@ -2369,7 +2466,11 @@ protected void pauseTracing(final boolean pause, } } - protected boolean isEditModeEnabled() { + /** + * @return true if 'Edit Mode' is currently enabled, i.e., the path returned by {@link #getEditingPath()} is being + * edited on all front-ends + */ + public boolean isEditModeEnabled() { return isUIready() && SNTUI.EDITING == getUIState(); } @@ -2472,6 +2573,7 @@ public void mouseMovedTo(final double x_in_pane, final double y_in_pane, y = pd[1]; z = pd[2]; + final Path editingPath = getEditingPath(); final boolean editing = isEditModeEnabled() && editingPath != null && editingPath.isSelected(); final boolean joining = !editing && join_modifier_down && pathAndFillManager @@ -2495,7 +2597,10 @@ else if (editing && !editingPath.isEditableNodeLocked()) { getXYCanvas().nodeDiameter()); if (eNode != -1) { pim = matchPath.getNodeWithoutChecks(eNode); - editingPath.setEditableNode(eNode); + if (eNode != editingPath.getEditableNodeIndex()) { + editingPath.setEditableNode(eNode); + notifyActiveNodeChanged(null); // Bdv/Bvv + } } } if (pim != null) { @@ -4157,10 +4262,15 @@ private boolean justFirstPoint() { return pathUnfinished && (currentPath.size() == 0); } - protected void startSholl(final PointInImage centerScaled) { + public void startSholl(final PointInImage centerScaled) { SwingUtilities.invokeLater(() -> { - setZPositionAllPanes((int) Math.round(centerScaled.x), (int) Math.round(centerScaled.y), - (int) Math.round(centerScaled.z)); + if (getXYCanvas() != null) { // no canvases in pure Stream mode + // Panes are positioned in pixel indices of the (active) canvas: invert (pixel - offset) * spacing + final PointInCanvas offset = getActiveCanvasPixelOffset(); + setZPositionAllPanes((int) Math.round(centerScaled.x / x_spacing + offset.x), + (int) Math.round(centerScaled.y / y_spacing + offset.y), + (int) Math.round(centerScaled.z / z_spacing + offset.z)); + } setShowOnlySelectedPaths(false); SNTUtils.log("Starting Sholl Analysis centered at " + centerScaled); final Map input = new HashMap<>(); @@ -4739,8 +4849,7 @@ public void enableSecondaryLayerTracing(final boolean enable) { // does BVV/BDV Swing/OpenGL setup that. Calling it directly from a worker thread is a silent EDT/GL // threading violation (no exception, just a hang), hence the explicit invokeLater() below if (isStreamMode() && getUI() != null) { - final var viewer = getUI().getActiveBigViewer(); - if (viewer != null) { + for (final var viewer : getUI().getOpenBigViewers()) { // all open viewers (Bvv and/or Bdv) SwingUtilities.invokeLater(() -> { try { viewer.showSecondaryData(); @@ -4793,8 +4902,7 @@ public void flushSecondaryData() { // here should remove that source toot. Same invokeLater() rationale as that hook: // hideSecondaryData() touches Swing/OpenGL state and may be reached from off-EDT callers. if (isStreamMode()) { - final var viewer = getUI().getActiveBigViewer(); - if (viewer != null) { + for (final var viewer : getUI().getOpenBigViewers()) { // all open viewers (Bvv and/or Bdv) SwingUtilities.invokeLater(() -> { try { viewer.hideSecondaryData(); @@ -5019,6 +5127,11 @@ public boolean isTreeIsolationActive() { return isolatedTreeID != -1; } + /** @return whether tracing functions are currently paused (see {@link SNTUI#pauseTracing(boolean)}) */ + public boolean isTracingPaused() { + return tracingHalted; + } + /** @return whether tracing functions are currently allowed */ public boolean isTracingActive() { return !tracingHalted && diff --git a/src/main/java/sc/fiji/snt/SNTUI.java b/src/main/java/sc/fiji/snt/SNTUI.java index 2dd0260c8..d45342011 100644 --- a/src/main/java/sc/fiji/snt/SNTUI.java +++ b/src/main/java/sc/fiji/snt/SNTUI.java @@ -179,6 +179,7 @@ public class SNTUI extends JDialog { /* AbstractBigViewers */ protected Bvv bvvSNT; + private AbstractBigViewer lastActiveViewer; protected Bdv bdvSNT; /** Bridges SeedOverlay into bvvSNT/bdvSNT's AnnotationOverlay; see setBvvOnEDT/setBdvOnEDT */ private SeedOverlayBigViewerHandler bvvSeedHandler; @@ -189,9 +190,27 @@ public class SNTUI extends JDialog { * particular, Stream mode's own viewer or {@code null} if neither is open. */ public AbstractBigViewer getActiveBigViewer() { + // With both viewers open, the most recently activated one (see setBvvOnEDT/setBdvOnEDT) + final AbstractBigViewer last = lastActiveViewer; + if (last != null && !last.isDisposed() && (last == bvvSNT || last == bdvSNT)) return last; return (bvvSNT != null) ? bvvSNT : bdvSNT; } + /** @return all tethered Bvv/Bdv viewers (zero, one or two), for operations that must reach every open viewer */ + public java.util.List getOpenBigViewers() { + final java.util.List viewers = new java.util.ArrayList<>(2); + if (bvvSNT != null && !bvvSNT.isDisposed()) viewers.add(bvvSNT); + if (bdvSNT != null && !bdvSNT.isDisposed()) viewers.add(bdvSNT); + return viewers; + } + + /** Flags a viewer as the one the user is currently working with (matters when both Bvv and Bdv are open) */ + private void setLastActiveViewer(final AbstractBigViewer viewer) { + if (viewer == null || lastActiveViewer == viewer) return; + lastActiveViewer = viewer; + syncBookmarksTabContent(); + } + /** * Bvv/Bdv counterpart of {@link sc.fiji.snt.SNT#setCanvasLabelAllPanes(String)}'s "Tracing * Paused"/"SNT Paused" canvas label: locks/unlocks whichever of {@link #bvvSNT}/{@link #bdvSNT} @@ -207,6 +226,36 @@ void setBigViewersLockedByPause(final boolean locked) { if (bdvSNT != null) bdvSNT.setTracingLockedByPause(locked); } + /** Bvv/Bdv counterpart of {@link SNT#enableEditMode(boolean)}: (de)activates their Edit Mode interaction layer */ + void setBigViewersEditMode(final boolean enabled) { + for (final AbstractBigViewer v : getOpenBigViewers()) v.onSntEditModeChanged(enabled); + } + + /** @return whether a Bvv/Bdv other than {@code viewer} is still open and in Edit Mode */ + public boolean isOtherBigViewerEditing(final AbstractBigViewer viewer) { + for (final AbstractBigViewer v : getOpenBigViewers()) { + if (v != viewer && v.isEditModeEnabled()) return true; + } + return false; + } + + /** Re-renders the edited path in Bvv/Bdv after in-place changes made elsewhere (e.g., by the tracing canvas) */ + void refreshBigViewersEditedPath() { + for (final AbstractBigViewer v : getOpenBigViewers()) v.onSntEditedPathModified(); + } + + /** Updates the active-node markers of Bvv/Bdv, except {@code source}, after the active node has changed */ + void refreshBigViewersActiveNode(final AbstractBigViewer source) { + for (final AbstractBigViewer v : getOpenBigViewers()) { + if (v != source) v.onSntActiveNodeChanged(); + } + } + + /** Notifies Bvv/Bdv that the path being edited has changed while in Edit Mode */ + void refreshBigViewersEditNodes() { + for (final AbstractBigViewer v : getOpenBigViewers()) v.onSntEditingPathChanged(); + } + /** * Swaps the "Bookmarks" tab's content between this UI's own {@link BookmarkManager} panel * (Standard mode, or Stream mode with no active viewer) and the active {@link AbstractBigViewer}'s @@ -1367,6 +1416,7 @@ public void setBvv(final Bvv bvv) { private void setBvvOnEDT(final Bvv bvv) { this.bvvSNT = bvv; + if (bvv != null) lastActiveViewer = bvv; // Re-tether the seed overlay bridge to whichever viewer (if any) is now active if (bvvSeedHandler != null) { bvvSeedHandler.dispose(); bvvSeedHandler = null; } if (bvv != null) bvvSeedHandler = SeedOverlayBigViewerHandler.install(bvv, plugin.getSeedOverlay()); @@ -1390,6 +1440,10 @@ public void windowOpened(final WindowEvent e) { } } @Override + public void windowActivated(final WindowEvent e) { + if (bvvSNT == bvv) setLastActiveViewer(bvv); + } + @Override public void windowClosing(final WindowEvent e) { if (plugin.isStreamMode()) { exitRequested(); @@ -1439,6 +1493,7 @@ public void setBdv(final Bdv bdv) { private void setBdvOnEDT(final Bdv bdv) { this.bdvSNT = bdv; + if (bdv != null) lastActiveViewer = bdv; // Re-tether the seed overlay bridge to whichever viewer (if any) is now active if (bdvSeedHandler != null) { bdvSeedHandler.dispose(); bdvSeedHandler = null; } if (bdv != null) bdvSeedHandler = SeedOverlayBigViewerHandler.install(bdv, plugin.getSeedOverlay()); @@ -1459,6 +1514,10 @@ public void windowOpened(final WindowEvent e) { } } @Override + public void windowActivated(final WindowEvent e) { + if (bdvSNT == bdv) setLastActiveViewer(bdv); + } + @Override public void windowClosing(final WindowEvent e) { if (plugin.isStreamMode()) { exitRequested(); @@ -3326,8 +3385,10 @@ protected void done() { return; } // Only promote to field once show() succeeded - bvvSNT = isBvv ? (Bvv) viewer : null; - bdvSNT = isBvv ? null : (Bdv) viewer; + // NB: the other viewer type, if open, must remain tracked (it still needs to follow Edit Mode, pauses, etc.) + if (isBvv) bvvSNT = (Bvv) viewer; + else bdvSNT = (Bdv) viewer; + setLastActiveViewer(viewer); // NB: WindowListener added in Bvv#attachControlPanel() viewer.syncPathManagerList(); } @@ -6445,8 +6506,7 @@ protected void setSecondaryLayerTracingSelected(final boolean enable) { showStatus("Tracing on secondary layer enabled", true); updateSecLayerActionButtonIcon(enable); if (plugin.isStreamMode()) { - final AbstractBigViewer viewer = getActiveBigViewer(); - if (viewer != null) viewer.updateSecondaryLayerIndicator(); + for (final AbstractBigViewer viewer : getOpenBigViewers()) viewer.updateSecondaryLayerIndicator(); } } diff --git a/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java b/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java new file mode 100644 index 000000000..fdf80e4c5 --- /dev/null +++ b/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java @@ -0,0 +1,115 @@ +/*- + * #%L + * Fiji distribution of ImageJ for the life sciences. + * %% + * Copyright (C) 2010 - 2026 Fiji developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + + +package sc.fiji.snt.gui; + +import javax.swing.JMenuItem; + +/** + * Non-editing commands shared by the contextual menus of the tracing canvas and of the Bdv/Bvv viewers: single + * source of truth for their labels and icons, so that both front-ends expose the same entries. Shortcuts and + * tooltips are not defined here because they differ between front-ends (e.g., bookmarking is + * {@code Shift+B} in the canvas and {@code M} in the viewers). Node-editing commands are defined in + * {@link NodeEditCommand}. + * + * @author Tiago Ferreira + */ +public enum ContextMenuCommand { + + GRAB(Labels.GRAB, '\uf076', true, false), + APPEND(Labels.APPEND, '\uf076', true, true), + LASSO(Labels.LASSO, '\uf5ee', true, false), + HIDE(Labels.HIDE, IconFactory.GLYPH.EYE), + ORIENTATION(Labels.ORIENTATION, IconFactory.GLYPH.NAVIGATE), + BOOKMARK(Labels.BOOKMARK, '\uf02e', false, false), + SHOLL(Labels.SHOLL, IconFactory.GLYPH.BULLSEYE), + BRIGHTEST(Labels.BRIGHTEST, IconFactory.GLYPH.SUN), + EXTEND(Labels.EXTEND, IconFactory.GLYPH.TAPE), + FORK(Labels.FORK, '\ue13b', true, false), + UNDO_SEGMENT(Labels.UNDO_SEGMENT, IconFactory.GLYPH.UNDO), + FINISH(Labels.FINISH, '\uf11e', true, false), + PAUSE_TRACING(Labels.PAUSE_TRACING, '\uf04c', true, false), + PAUSE_SNT(Labels.PAUSE_SNT, IconFactory.GLYPH.PAUSE2); + + /** The labels as compile-time constants, for front-ends that dispatch on them (e.g., in a {@code switch}) */ + public static final class Labels { + public static final String GRAB = "Grab Nearest Path"; + public static final String APPEND = "Add Nearest Path to Selection"; + public static final String LASSO = "Lasso Select Paths"; + public static final String HIDE = "Hide Paths (Hold H)"; + public static final String ORIENTATION = "Path Orientation (Hold O)"; + public static final String BOOKMARK = "Bookmark Cursor Position"; + public static final String SHOLL = "Sholl Analysis at Nearest Node"; + public static final String BRIGHTEST = "Click on Brightest Voxel Above/Below Cursor"; + public static final String EXTEND = "Continue Extending Path"; + public static final String FORK = "Fork at Nearest Node"; + public static final String UNDO_SEGMENT = "Undo Last Segment"; + public static final String FINISH = "Finish Path"; + public static final String PAUSE_TRACING = "Pause Tracing"; + public static final String PAUSE_SNT = "Pause SNT"; + + private Labels() {} + } + + private final String label; + private final IconFactory.GLYPH glyph; + private final char symbol; + private final boolean solid; + private final boolean selectedColor; + + ContextMenuCommand(final String label, final IconFactory.GLYPH glyph) { + this.label = label; + this.glyph = glyph; + this.symbol = ' '; + this.solid = false; + this.selectedColor = false; + } + + ContextMenuCommand(final String label, final char symbol, final boolean solid, final boolean selectedColor) { + this.label = label; + this.glyph = null; + this.symbol = symbol; + this.solid = solid; + this.selectedColor = selectedColor; + } + + /** @return the menu label */ + public String label() { + return label; + } + + /** + * Assigns the icon of this command to a menu item. Colors are resolved on every call, so that the icon matches + * the current look and feel + * + * @param item the menu item + */ + public void applyIcon(final JMenuItem item) { + if (glyph != null) { + IconFactory.assignIcon(item, glyph); + } else { + IconFactory.assignIcon(item, symbol, solid, + (selectedColor) ? IconFactory.selectedColor() : IconFactory.defaultColor()); + } + } +} diff --git a/src/main/java/sc/fiji/snt/gui/GuiUtils.java b/src/main/java/sc/fiji/snt/gui/GuiUtils.java index 40b75c903..59c038e69 100644 --- a/src/main/java/sc/fiji/snt/gui/GuiUtils.java +++ b/src/main/java/sc/fiji/snt/gui/GuiUtils.java @@ -1971,6 +1971,24 @@ public static void setLocationNextTo(final Window target, final Window reference } public static void displayBanner(final String msg, final Color background, final Component parent) { + displayBanner(msg, background, parent, SwingConstants.CENTER, true); + } + + /** + * Displays a transient banner over {@code parent}. + * + * @param msg the banner text (HTML is supported) + * @param background the background color + * @param parent the component the banner is displayed over + * @param position a {@link SwingConstants} location flag (e.g., + * {@code NORTH_EAST}) as accepted by + * {@link JidePopup#showPopup(int, Component)} + * @param dismissOnMove if true, the banner is dismissed by mouse + * movements over {@code parent}. If false, only + * clicks dismiss it (and it stays up longer) + */ + public static void displayBanner(final String msg, final Color background, final Component parent, + final int position, final boolean dismissOnMove) { final JLabel label = new JLabel(msg); label.setHorizontalAlignment(SwingConstants.CENTER); if (!msg.startsWith("")) @@ -1982,7 +2000,7 @@ public static void displayBanner(final String msg, final Color background, final final MouseAdapter adapter = new MouseAdapter() { @Override public void mouseMoved(final MouseEvent e) { - dismiss(); + if (dismissOnMove) dismiss(); } @Override public void mouseClicked(final MouseEvent e) { @@ -1990,7 +2008,7 @@ public void mouseClicked(final MouseEvent e) { } @Override public void mouseDragged(final MouseEvent e) { - dismiss(); + if (dismissOnMove) dismiss(); } @Override public void mousePressed(final MouseEvent e) { @@ -2016,8 +2034,8 @@ void dismiss() { popup.setTransient(true); popup.setMovable(false); popup.setDefaultMoveOperation(JidePopup.HIDE_ON_MOVED); - popup.setTimeout(4000); - SwingUtilities.invokeLater(() -> popup.showPopup(SwingConstants.CENTER, parent)); + popup.setTimeout((dismissOnMove) ? 4000 : 10000); + SwingUtilities.invokeLater(() -> popup.showPopup(position, parent)); } public static void addTooltip(final JComponent c, final String text) { diff --git a/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java b/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java new file mode 100644 index 000000000..ef16ba8d4 --- /dev/null +++ b/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java @@ -0,0 +1,214 @@ +/*- + * #%L + * Fiji distribution of ImageJ for the life sciences. + * %% + * Copyright (C) 2010 - 2026 Fiji developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +package sc.fiji.snt.gui; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; +import java.util.function.Consumer; + +import javax.swing.AbstractAction; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.KeyStroke; + +import org.apache.commons.lang3.StringUtils; + +import sc.fiji.snt.Path; + +/** + * Node-editing commands of 'Edit Mode': single source of truth for their labels, default shortcuts and + * enabled-state logic, so that every front-end (classic canvas, Bdv, Bvv) exposes the same menu and key bindings. + * The operations themselves live in {@link sc.fiji.snt.PathNodeEditor}; hosts only map a command to a call and + * supply the cursor position (see {@code BigViewerEditor}). + * + * @author Tiago Ferreira + */ +public enum NodeEditCommand { + + DELETE("Delete Active Node", KeyEvent.VK_D), + INSERT("Insert New Node at Cursor Position", KeyEvent.VK_I), + MOVE("Move Active Node to Cursor Position", KeyEvent.VK_M), + LOCK("Lock Active Node", KeyEvent.VK_L), + RADIUS("Set Active Node Radius...", KeyEvent.VK_R), + TAG("Tag Active Node", KeyEvent.VK_T, "Assigns a unique color to active node.
    " + + "NB: Overall rendering of path may change once tag is applied"), + SET_ROOT("Set Active Node as Tree Root...", 0), + SPLIT("Split Tree at Active Node...", KeyEvent.VK_X), + UNDO(NodeEditCommand.UNDO_LABEL, KeyEvent.VK_Z); + + /** Label of {@link #UNDO} as a compile-time constant (e.g., for {@code switch} dispatch) */ + public static final String UNDO_LABEL = "Undo Last Edit"; + private static final String UNLOCK_LABEL = "Unlock Active Node"; + + private final String label; + private final int keyCode; + private final String tooltip; + + NodeEditCommand(final String label, final int keyCode) { + this(label, keyCode, null); + } + + NodeEditCommand(final String label, final int keyCode, final String tooltip) { + this.label = label; + this.keyCode = keyCode; + this.tooltip = tooltip; + } + + /** + * @param nodeLocked whether the active node is locked + * @return the menu label, which for {@link #LOCK} depends on the state of the active node + */ + public String label(final boolean nodeLocked) { + return (this == LOCK && nodeLocked) ? UNLOCK_LABEL : label; + } + + /** @return the tooltip, or null */ + public String tooltip() { + return tooltip; + } + + /** + * @param id an id as returned by {@link #actionId()} + * @return the matching command, or null + */ + public static NodeEditCommand fromActionId(final String id) { + if (id != null) { + for (final NodeEditCommand cmd : values()) { + if (cmd.actionId().equals(id)) return cmd; + } + } + return null; + } + + /** + * Creates a menu item for this command (label, shortcut and tooltip). Its action command is {@link #actionId()} + * + * @param listener the listener to be notified when the item is chosen + * @return the menu item + */ + public JMenuItem createMenuItem(final ActionListener listener) { + final JMenuItem item = new JMenuItem(label); + item.setActionCommand(actionId()); + if (keyCode != 0) item.setAccelerator(KeyStroke.getKeyStroke(keyCode, 0)); + if (tooltip != null) item.setToolTipText(tooltip); + item.addActionListener(listener); + return item; + } + + /** @return the menu label */ + public String label() { + return label; + } + + /** @return the default (unmodified) key code, or 0 if the command has no shortcut */ + public int keyCode() { + return keyCode; + } + + /** @return the id used to register the command in an {@link InputMap}/{@link ActionMap} */ + public String actionId() { + return "snt-edit-" + name().toLowerCase().replace('_', '-'); + } + + /** + * The editor state that determines which commands are enabled + * + * @param hasNode whether there is an active node + * @param nodeLocked whether the active node is locked + * @param canUndo whether there are edits that can be undone + */ + public record MenuState(boolean hasNode, boolean nodeLocked, boolean canUndo) { + /** @return whether {@code cmd} is currently applicable */ + public boolean isEnabled(final NodeEditCommand cmd) { + return (cmd == UNDO) ? canUndo : hasNode; + } + } + + /** + * @param pathName the (abbreviated) name of the path to be edited, or null + * @return the label of the Edit Mode toggle in context menus + */ + public static String editToggleLabel(final String pathName) { + return "Edit " + ((pathName == null) ? "Mode" : pathName); + } + + /** + * @param p the path + * @return the (abbreviated) name of the path, for use in menu labels + */ + public static String shortName(final Path p) { + return StringUtils.abbreviate(p.getName(), 30); + } + + /** Prefix used to visually nest edit commands under the Edit Mode entry of a menu */ + public static final String MENU_INDENT = " "; + + /** + * Appends the node-editing commands to a menu, grouped by separators. Commands that are not applicable in + * the given state are disabled, not omitted + * + * @param menu the menu to be populated + * @param state the editor state + * @param handler the callback executed when an item is chosen + */ + public static void addMenuItems(final JPopupMenu menu, final MenuState state, + final Consumer handler) { + for (final NodeEditCommand cmd : values()) { + if (cmd == UNDO) continue; // shared with the tracing 'Undo Last Segment' entry (mode-aware) + if (cmd == RADIUS || cmd == SET_ROOT) menu.addSeparator(); + final JMenuItem item = cmd.createMenuItem(e -> handler.accept(cmd)); + item.setText(MENU_INDENT + cmd.label(state.nodeLocked())); + item.setEnabled(state.isEnabled(cmd)); + menu.add(item); + } + } + + /** @return a new map with the default shortcut of every command that has one */ + public static InputMap inputMap() { + final InputMap im = new InputMap(); + for (final NodeEditCommand cmd : values()) { + if (cmd.keyCode != 0) im.put(KeyStroke.getKeyStroke(cmd.keyCode, 0), cmd.actionId()); + } + return im; + } + + /** + * @param handler the callback executed when a command is triggered + * @return a new map associating every command id of {@link #inputMap()} with an action calling {@code handler} + */ + public static ActionMap actionMap(final Consumer handler) { + final ActionMap am = new ActionMap(); + for (final NodeEditCommand cmd : values()) { + am.put(cmd.actionId(), new AbstractAction(cmd.label) { + @Override + public void actionPerformed(final ActionEvent e) { + handler.accept(cmd); + } + }); + } + return am; + } +} diff --git a/src/main/java/sc/fiji/snt/plugin/ShollAnalysisTreeCmd.java b/src/main/java/sc/fiji/snt/plugin/ShollAnalysisTreeCmd.java index 7394ecf1d..d199a5d33 100644 --- a/src/main/java/sc/fiji/snt/plugin/ShollAnalysisTreeCmd.java +++ b/src/main/java/sc/fiji/snt/plugin/ShollAnalysisTreeCmd.java @@ -441,7 +441,8 @@ protected void init() { noFocalPointSpecified = center == null; // we'll use pre-determined centers; if (snt != null) { - if (noFocalPointSpecified) + // Shell previews are drawn on the image: unavailable in Stream mode without a (materialized) image + if (noFocalPointSpecified || snt.getImagePlus() == null) resolveInput("previewShells"); else previewOverlay = new PreviewOverlay(snt.getImagePlus(), center.getUnscaledPoint()); @@ -702,7 +703,7 @@ private void stepSizeChanged() { /* Callback for stepSize && previewShells */ private void overlayShells() { - threadService.run(previewOverlay); + if (previewOverlay != null) threadService.run(previewOverlay); } @SuppressWarnings("unused") @@ -902,7 +903,9 @@ else if (polynomialChoice.contains("degree") && polynomialDegree > 1) { if (snt != null && !"None".equalsIgnoreCase(annotationsDescription)) { - if (annotationsDescription.contains("labels image")) { + if (annotationsDescription.contains("labels image") && snt.getImagePlus() == null) { + helper.error("A labels image requires an image, which is not available.", "Image Not Available"); + } else if (annotationsDescription.contains("labels image")) { showStatus("Creating labels image..."); final ImagePlus labelsImage = parser.getLabelsImage(snt.getImagePlus(), lutTable); outputs.add(labelsImage); diff --git a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java index 1e51f1cf6..5ee48293e 100644 --- a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java +++ b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java @@ -30,6 +30,7 @@ import sc.fiji.snt.*; import sc.fiji.snt.gui.ColorChooserButton; import sc.fiji.snt.gui.FileDrop; +import sc.fiji.snt.gui.ContextMenuCommand; import sc.fiji.snt.gui.GuiUtils; import sc.fiji.snt.io.SpimDataUtils; import sc.fiji.snt.gui.ScriptInstaller; @@ -83,6 +84,7 @@ public abstract class AbstractBigViewer { protected boolean tracingEnabled; private KeyframeRecorder keyframeRecorder; + private GuiUtils guiUtils; /** @return SNT instance this viewer is tethered to, or null if no SNT instance is available. */ public SNT getSNT() { return snt; } @@ -98,6 +100,219 @@ public abstract class AbstractBigViewer { */ public boolean isTracingEnabled() { return tracingEnabled; } + // ---- Edit Mode (node-level editing of a single path; see BigViewerEditor) ---- + + private BigViewerEditor editor; + /** Key bindings id used for the (transient) Edit Mode shortcuts. See {@link #installEditKeybindings} */ + protected static final String EDIT_KEYBINDINGS_ID = "snt-edit"; + + /** @return whether this viewer is currently in Edit Mode (node-level editing of a single selected path) */ + public boolean isEditModeEnabled() { + return editor != null && editor.isActive(); + } + + /** + * Toggles Edit Mode: node-level editing of the single selected path, mirroring the Edit-path commands of the + * classic canvas. Only available in SNT-tethered viewers. Entering requires exactly one selected path and + * suspends tracing for the duration. + */ + public void toggleEditMode() { + if (snt == null) { + getGuiUtils().error("Edit Mode is only available in SNT-tethered viewers"); + return; + } + if (editor == null) editor = new BigViewerEditor(this, snt); + editor.toggle(); + } + + /** + * Called by SNT when Edit Mode is (de)activated from any front-end, including this one + * (see {@code SNT#enterEditMode(Path)}) + */ + public void onSntEditModeChanged(final boolean enabled) { + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(() -> onSntEditModeChanged(enabled)); + return; + } + if (disposed) return; // see BigViewerEditor#onViewerDisposed + if (editor == null) { + if (!enabled || snt == null) return; + editor = new BigViewerEditor(this, snt); + } + editor.onSntEditModeChanged(enabled); + } + + /** Called by SNT when nodes of the path being edited were modified in place by another front-end */ + public void onSntEditedPathModified() { + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(this::onSntEditedPathModified); + return; + } + if (disposed) return; + invalidatePathGeometry(); + if (editor != null && editor.isActive()) editor.refreshNodes(); + } + + /** Called by SNT when the active node (or its lock state) was changed by another front-end */ + public void onSntActiveNodeChanged() { + if (!SwingUtilities.isEventDispatchThread()) { + SwingUtilities.invokeLater(this::onSntActiveNodeChanged); + return; + } + if (!disposed && editor != null && editor.isActive()) editor.refreshActiveMarker(); + } + + /** Called by SNT when the path being edited has changed while in Edit Mode */ + public void onSntEditingPathChanged() { + if (editor == null || !editor.isActive()) return; + if (SwingUtilities.isEventDispatchThread()) editor.refreshNodes(); + else SwingUtilities.invokeLater(editor::refreshNodes); + } + + /** + * World position of the cursor, as used by Edit Mode to place/move nodes. The default is the position reported + * by {@link #getGlobalMouseCoordinates(RealPoint)} (appropriate for an orthogonal slicing view); {@link Bvv} + * overrides it to resolve depth the same way its tracer does. + * + * @return calibrated (x,y,z) position, or null if it could not be resolved + */ + protected double[] resolveCursorWorldPosition() { + final RealPoint pos = new RealPoint(3); + getGlobalMouseCoordinates(pos); + return pos.positionAsDoubleArray(); + } + + /** + * Discards cached path geometry and repaints. Needed after in-place edits of a Path (e.g., a moved node), which + * the structural fingerprint of the cached screen data does not detect + */ + protected void invalidatePathGeometry() { + if (pathOverlay != null) pathOverlay.overlayRenderer.invalidateCache(); + syncOverlays(); + } + + private BigViewerMenu contextMenu; + /** The viewer's tracer (set when it is built), or null if tracing is unavailable. See {@link #menuTracer()} */ + private AbstractTracer menuTracer; + /** Path rendering layer, shared by Bvv and Bdv */ + Bvv.PathOverlay pathOverlay; + + /** + * Attaches the context menu (see {@link BigViewerMenu}) to the viewer's display. To be called once the display + * exists. No-op in standalone viewers + */ + protected void installContextMenu() { + if (snt == null) return; + if (editor == null) editor = new BigViewerEditor(this, snt); + // Joining a session that is already in Edit Mode (e.g., started from the tracing canvas) + if (snt.isEditModeEnabled() && !editor.isActive()) SwingUtilities.invokeLater(() -> { + if (!disposed) editor.onSntEditModeChanged(true); + }); + if (contextMenu == null) contextMenu = new BigViewerMenu(this, snt, editor); + contextMenu.install(); + } + + /** @return the tracer, or null if tracing is unavailable. For use by the context menu */ + AbstractTracer menuTracer() { + return menuTracer; + } + + /** + * Starts a Sholl analysis centered at the node nearest to a position: as in the tracing canvas, the nearest + * node of the selected paths or, if none is selected, of the rendered path nearest to it + * + * @param pos the calibrated (x,y,z) position, or null if it could not be resolved + */ + void startShollAtNearestNode(final double[] pos) { + if (snt == null) return; + if (pos == null) { + showViewerMessage("Could not resolve cursor position"); + return; + } + // As in the tracing canvas: nearest node of the selected paths or, if none is selected, of the + // rendered path nearest to the position (see AbstractTracer#findNearestRenderedPath). The version in + // use (fitted or not) is searched, since that is the one analyzed by the Sholl command + final Collection selected = snt.getSelectedPaths(); + final Collection candidates; + if (selected != null && !selected.isEmpty()) { + candidates = selected; + } else { + final Path nearest = (menuTracer == null) ? null : menuTracer.findNearestRenderedPath(pos); + candidates = (nearest == null) ? List.of() : List.of(nearest); + } + final PathAndFillManager.NodeRef ref = snt.getPathAndFillManager().nearestNodeInUse(candidates, pos[0], + pos[1], pos[2]); + if (ref == null) { + showViewerMessage("No nodes to analyze: No rendered paths near cursor"); + return; + } + final PointInImage center = ref.path().getNode(ref.index()); + snt.startSholl(center); + } + + /** + * Places a marker (bookmark) at a position captured earlier than this call (e.g., by the context menu, since + * the live cursor position is unreliable once a menu is open) + * + * @param pos the calibrated (x,y,z) position, or null if it could not be resolved + */ + void placeMarker(final double[] pos) { + if (pos == null) { + showViewerMessage("Could not resolve cursor position"); + return; + } + if (snt != null && snt.getUI() != null && !snt.isStreamMode()) { + // Classic mode: bookmarks live in the Bookmarks tab of SNT's UI (the same used by Shift+B on the image + // canvas), which takes calibrated (world) coordinates + snt.getUI().getBookmarkManager().add(pos[0], pos[1], pos[2], snt.getChannel(), getCurrentTimepoint()); + showViewerMessage(String.format("Bookmark added at (%.1f, %.1f, %.1f)", pos[0], pos[1], pos[2])); + return; + } + getMarkerManager().add(pos[0], pos[1], pos[2]); + showViewerMessage(String.format("Marker placed at (%.1f, %.1f, %.1f)", pos[0], pos[1], pos[2])); + } + + /** + * Locks/unlocks this viewer's tracing-mode toolbar buttons to "No tracing", as for a global SNT pause. Also used + * to suspend tracing-by-click while in Edit Mode. Does nothing if this viewer has no tracer + * + * @param locked whether tracing is currently suspended + */ + public void setTracingLockedByPause(final boolean locked) { + if (menuTracer != null) menuTracer.setLockedByPause(locked); + } + + /** @return the key bindings of the viewer's frame, or null if the frame does not exist (yet) */ + protected abstract org.scijava.ui.behaviour.util.InputActionBindings getKeybindings(); + + /** + * Adds transient key bindings (taking precedence over the viewer's own and the permanent SNT ones) for the + * duration of Edit Mode. Counterpart of {@link #removeEditKeybindings()} + */ + protected void installEditKeybindings(final InputMap inputMap, final ActionMap actionMap) { + final org.scijava.ui.behaviour.util.InputActionBindings kb = getKeybindings(); + if (kb == null) return; + kb.addInputMap(EDIT_KEYBINDINGS_ID, inputMap); + kb.addActionMap(EDIT_KEYBINDINGS_ID, actionMap); + } + + /** Removes the bindings added by {@link #installEditKeybindings(InputMap, ActionMap)} */ + protected void removeEditKeybindings() { + final org.scijava.ui.behaviour.util.InputActionBindings kb = getKeybindings(); + if (kb == null) return; + kb.removeInputMap(EDIT_KEYBINDINGS_ID); + kb.removeActionMap(EDIT_KEYBINDINGS_ID); + } + + protected GuiUtils getGuiUtils() { + // Re-create if the frame is not the one the instance was parented to (not yet available, or recreated) + final JFrame frame = getViewerFrame(); + if (guiUtils == null || guiUtils.getParent() != frame) { + guiUtils = new GuiUtils(frame); + } + return guiUtils; + } + /** * Enables tracing on this viewer, as if the user had clicked its manual/interactive * tracing-mode toggle button (see {@code AbstractTracer#getToggleAction(boolean)}), so a @@ -110,7 +325,9 @@ public abstract class AbstractBigViewer { * semi-automated (auto-trace) tracing * @see #isTracingEnabled() */ - public abstract void enableTracing(boolean manual); + public void enableTracing(final boolean manual) { + if (menuTracer != null) menuTracer.enableTracingExternally(manual); + } /** * @return whether this viewer currently has a path in progress, i.e. the user has clicked to start @@ -118,7 +335,9 @@ public abstract class AbstractBigViewer { * this viewer has no tracer (see {@link #enableTracing(boolean)}). * @see #isTracingEnabled() */ - public abstract boolean isPathInProgress(); + public boolean isPathInProgress() { + return menuTracer != null && menuTracer.isPathInProgress(); + } // AWT only synthesizes mouseClicked if the pointer does not move *at all* between press and release. // Trackpads (macOS in particular) routinely introduce a pixel or two of drift during what feels like a @@ -194,7 +413,6 @@ public void registerOmeZarrSource(final String path) { protected JProgressBar tracingStatusBar; protected JButton tracingCancelButton; protected JButton tracingUndoButton; - protected JToggleButton tracingExtendButton; protected JToggleButton secondaryLayerIndicator; // Persistent indicator of SNT#isTracingOnSecondaryImageActive() protected AbstractBigViewer() { @@ -225,6 +443,7 @@ public final void dispose() { syncedPathManagerLabels.clear(); } try { + if (editor != null) editor.onViewerDisposed(); disposeViewer(); } catch (final RuntimeException ex) { SNTUtils.log("Viewer disposal failed: " + ex.getMessage()); @@ -688,9 +907,8 @@ public void flyTo(final double x, final double y, final double z, final int t, f * these look like coordinates, and flies to it at the current timepoint. */ public void promptGoTo() { - final GuiUtils gui = new GuiUtils(getViewerFrame()); final String clipText = GuiUtils.Text.getClipboard(); - final String pos = gui.getString( + final String pos = getGuiUtils().getString( "Location XYZ coordinates, in physical units (comma/space separated): ", "Go To Location...", (GuiUtils.Text.containsNumber(clipText) && GuiUtils.Text.containsSeparator(clipText)) @@ -701,7 +919,7 @@ public void promptGoTo() { flyTo(pim.x, pim.y, pim.z, getCurrentTimepoint(), String.format("%.2f, %.2f, %.2f", pim.x, pim.y, pim.z)); } catch (final Throwable ex) { - gui.error("Could not extract a valid location from \"" + pos + "\"."); + getGuiUtils().error("Could not extract a valid location from \"" + pos + "\"."); } } @@ -911,6 +1129,15 @@ protected void registerSntKeybindings(final InputMap sntIMap, final ActionMap sn final Actions actions, final Action addMarker, final AbstractTracer tracer) { sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_M, 0), "snt-add-marker"); sntAMap.put("snt-add-marker", addMarker); + // Same shortcut as in the classic canvas + sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.SHIFT_DOWN_MASK | InputEvent.ALT_DOWN_MASK), + "snt-sholl-nearest-node"); + sntAMap.put("snt-sholl-nearest-node", new AbstractAction("Sholl Analysis at Nearest Node") { + @Override + public void actionPerformed(final ActionEvent e) { + startShollAtNearestNode(resolveCursorWorldPosition()); + } + }); sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_H, 0, false), "snt-hide-annotations-press"); sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_H, 0, true), "snt-hide-annotations-release"); sntAMap.put("snt-hide-annotations-press", actions.hideAnnotationsPressAction()); @@ -921,6 +1148,13 @@ protected void registerSntKeybindings(final InputMap sntIMap, final ActionMap sn sntAMap.put("snt-pick-sigma-point", actions.pickSigmaPointAction()); sntIMap.put(GO_TO_KEYSTROKE, "snt-go-to-location"); sntAMap.put("snt-go-to-location", actions.goToAction()); + sntIMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.SHIFT_DOWN_MASK), "snt-toggle-edit-mode"); + sntAMap.put("snt-toggle-edit-mode", new AbstractAction("Toggle Edit Mode") { + @Override + public void actionPerformed(final ActionEvent e) { + toggleEditMode(); + } + }); if (tracer == null) return; // Grab Nearest Path/Add Nearest Path to Selection: mirrors InteractiveTracerCanvas's // G/Shift+G shortcuts on the classic canvas (see AbstractTracer#getSelectNearestPathAction). @@ -1147,7 +1381,7 @@ private void loadSettingsFile(final File f) { showViewerMessage(String.format("%s loaded", f.getName())); Actions.setDefaultDir(f); } catch (final Exception ex) { - new GuiUtils(getViewerFrame()).error(ex.getMessage()); + getGuiUtils().error(ex.getMessage()); } } @@ -1179,7 +1413,7 @@ protected void installFileDrop(final bdv.ui.CardPanel c) { if (!recs.isEmpty()) { add(recs.toArray(new File[0])); } else if (sf == null && csvs.isEmpty()) { - SwingUtilities.invokeLater(() -> new GuiUtils(getViewerFrame()) + SwingUtilities.invokeLater(() -> getGuiUtils() .error("None of the dropped item(s) could be imported.")); } }); @@ -1227,7 +1461,7 @@ private boolean restoreSavedSettings(final bdv.viewer.ViewerState state, final b runOnEdt(() -> { boolean load = "always".equals(pref); if (!load) { - final boolean[] reply = new GuiUtils(getViewerFrame()).getConfirmationAndOption( + final boolean[] reply = getGuiUtils().getConfirmationAndOption( "Saved display settings were found for this dataset (" + f.getName() + "). Load them?", "Saved Settings Found", "Remember my choice", false, new String[] { "Load", "Skip" }); load = reply[0]; @@ -1712,23 +1946,6 @@ protected String getUniqueLabel(final Tree tree) { /** Returns the rendering options shared across this viewer's overlays. */ public PathRenderingOptions getRenderingOptions() { return renderingOptions; } - /** - * Whether {@code M}-key marker placement should be blocked in the current mode, showing a - * viewer message if so. True whenever SNTUI is present in "classic-mode-tracing", since the image canvas - * already has a fully working Bookmarks tab (Shift+B, right-click, etc.). - * Shared by Bvv/Bdv's own {@code M}-key bindings. - * - * @return true if placement was blocked (and a message shown); callers should return - * immediately without placing a marker - */ - protected boolean blockMarkerPlacement() { - if (snt != null && snt.getUI() != null && !snt.isStreamMode()) { - showViewerMessage("Use Shift+B directly on the image"); - return true; - } - return false; - } - /** * Builds the "Options" button (Import Reconstructions.../Remove All Annotations...) shared by * Bvv/Bdv's own SNT Annotations toolbar. @@ -1779,14 +1996,6 @@ protected JComponent tracingStatusRow(final Actions actions, final AbstractTrace IconFactory.assignIcon(tracingUndoButton, IconFactory.GLYPH.UNDO, (Color) null, .9f); tracingUndoButton.setToolTipText("Tracing: Undo last segment (or press Z)"); tracer.updateUndoButtonState(); - tracingExtendButton = new JToggleButton(tracer.getExtendSelectedPathAction()); - tracingExtendButton.setText(null); - IconFactory.assignIcon(tracingExtendButton, IconFactory.GLYPH.CIRCLE_CHEVRON_RIGHT, IconFactory.GLYPH.CIRCLE_CHEVRON_RIGHT); - tracingExtendButton.setToolTipText("Continue extending the selected path"); - // Same reasoning as b0/b1/b2 in Bvv#sntToolbar/Bdv#sntAnnotationsCard: non-focusable so - // Space (hold-to-toggle-tracing) isn't also captured as this button's own activation key - tracingExtendButton.setFocusable(false); - tracer.installExtendPathButton(tracingExtendButton); secondaryLayerIndicator = new JToggleButton(); secondaryLayerIndicator.setEnabled(snt != null); secondaryLayerIndicator.setSelected(snt != null && snt.isTracingOnSecondaryImageActive()); @@ -1797,8 +2006,6 @@ protected JComponent tracingStatusRow(final Actions actions, final AbstractTrace row.setFloatable(false); row.add(tracingUndoButton); row.addSeparator(); - row.add(tracingExtendButton); - row.addSeparator(); row.add(secondaryLayerIndicator); row.addSeparator(); row.add(tracingStatusBar); @@ -1808,7 +2015,6 @@ protected JComponent tracingStatusRow(final Actions actions, final AbstractTrace } protected class Actions { - private GuiUtils guiUtils; // State for hide-annotations (H key) press/release tracking float lastClippingDistance = 100f; private boolean hideActive; @@ -2301,11 +2507,6 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { }; } - GuiUtils getGuiUtils() { - if (guiUtils == null) guiUtils = new GuiUtils(getViewerFrame()); - return guiUtils; - } - protected static File getDefaultDir() { return SNTPrefs.lastKnownDir(); // never null } @@ -2499,7 +2700,7 @@ private void promptTextOverlayPosition(final JToggleButton toggle) { final String[] choices = {"Top center", "Top right"}; final String current = (Prefs.sourceNameOverlayPosition() == Prefs.OverlayPosition.TOP_RIGHT) ? choices[1] : choices[0]; - final String choice = new GuiUtils(getViewerFrame()).getChoice( + final String choice = getGuiUtils().getChoice( "Position of the source name overlay:", "Text Overlay Position", choices, current); if (choice == null) return; Prefs.sourceNameOverlayPosition(choices[1].equals(choice) @@ -2936,10 +3137,6 @@ protected abstract class AbstractTracer extends MouseAdapter { // state directly, regardless of whether a change originates from a click or from a global // SNT_PAUSED/TRACING_PAUSED transition (see SNT#pause/SNT#pauseTracing). private JToggleButton noTracingButton, manualTracingButton, interactiveTracingButton; - // Mirrors whether a "Continue Extending" is currently pending (see #toBeCombinedPath); - // kept selected for the duration and disabled outright whenever tracing is off, set via - // #installExtendPathButton - private JToggleButton extendPathButton; // Whether a global SNT_PAUSED/TRACING_PAUSED state is currently forcing "No tracing" here (see #setLockedByPause). // While true, manualTracingButton/interactiveTracingButton are disabled - not just deselected - so the toolbar @@ -3000,6 +3197,7 @@ protected AbstractTracer(final SNT snt) { if (snt == null || snt.getPathAndFillManager() == null) { throw new IllegalArgumentException("Tracer can only be initialized with a valid SNT instance"); } + menuTracer = this; addMouseListenerToDisplay(this); } @@ -3015,6 +3213,7 @@ public void mouseReleased(final MouseEvent e) { final Point start = pressPoint; pressPoint = null; if (start == null) return; // press wasn't seen by this listener; nothing to compare against + if (!SwingUtilities.isLeftMouseButton(e)) return; // right-click opens the Edit Mode context menu if (start.distance(e.getPoint()) > CLICK_MOVE_TOLERANCE_PX) return; // a real drag, not a click handleClick(e); } @@ -3078,7 +3277,7 @@ private void handleClick(final MouseEvent e) { if (batchLock != null) { final int[] candidate = peekActiveChannelFrame(); if (candidate == null || candidate[0] != batchLock[0] || candidate[1] != batchLock[1]) { - new GuiUtils(getViewerFrame()).error("An A* batch re-trace is running on channel " + getGuiUtils().error("An A* batch re-trace is running on channel " + batchLock[0] + " / frame " + batchLock[1] + ". Starting a new path on a " + "different channel/frame is disabled until it finishes."); return; @@ -3125,7 +3324,7 @@ private void handleClick(final MouseEvent e) { : String.format("No point within %.2f%s of the cursor was found on the nearest path ('%s').", pickRadius, calUnit, nearestPath.getName()); final String mod = (renderingOptions.requireShiftToFork) ? "Alt+Shift" : "Alt"; - new GuiUtils(getViewerFrame()).error("" + detail + getGuiUtils().error("" + detail + "
    Move closer to the target path, or click without " + mod + " to start an unconnected path.", "No Fork Point Found"); return; @@ -3320,29 +3519,44 @@ protected Path findNearestRenderedPath(final double[] worldPos) { * @see #findNearestRenderedPath(double[]) */ protected AbstractAction getSelectNearestPathAction(final boolean addToExistingSelection) { - return new AbstractAction((addToExistingSelection) ? "Add Nearest Path to Selection" : "Grab Nearest Path") { + return new AbstractAction((addToExistingSelection) ? ContextMenuCommand.APPEND.label() + : ContextMenuCommand.GRAB.label()) { @Override public void actionPerformed(final java.awt.event.ActionEvent e) { if (snt == null || snt.getPathAndFillManager().size() == 0) { showViewerMessage("Nothing to select: There are no traced paths"); return; } - final double[] worldPos = resolveClickWorldPosition(null); - if (worldPos == null) { - showViewerMessage("Could not resolve cursor position"); - return; - } - final Path nearest = findNearestRenderedPath(worldPos); - if (nearest == null) { - showViewerMessage("No rendered paths near cursor"); - return; - } - snt.selectPath(nearest, addToExistingSelection); - showViewerMessage(nearest.getName() + " selected"); + selectNearestPath(resolveClickWorldPosition(null), addToExistingSelection); } }; } + /** + * Selects the rendered path nearest to a position. The core of {@link #getSelectNearestPathAction(boolean)}, + * for callers (e.g., the context menu) that need a position captured earlier than the action's invocation + * + * @param worldPos the calibrated position, or null if it could not be resolved + * @param addToExistingSelection whether to add to (true) or replace (false) the current selection + */ + protected void selectNearestPath(final double[] worldPos, final boolean addToExistingSelection) { + if (snt == null || snt.getPathAndFillManager().size() == 0) { + showViewerMessage("Nothing to select: There are no traced paths"); + return; + } + if (worldPos == null) { + showViewerMessage("Could not resolve cursor position"); + return; + } + final Path nearest = findNearestRenderedPath(worldPos); + if (nearest == null) { + showViewerMessage("No rendered paths near cursor"); + return; + } + snt.selectPath(nearest, addToExistingSelection); + showViewerMessage(nearest.getName() + " selected"); + } + /** * Converts a screen-space pixel radius into a world-space distance, using the scale of the * current viewer transform. Used to make on-screen "pick" tolerances (e.g. snapping to an @@ -3434,7 +3648,7 @@ private void combineWithPendingPath(final Path newSegment) { if (newSegment.size() < 2 || dist > tolerance) { snt.getPathAndFillManager().addPath(newSegment); if (renderingOptions.activateFinishedPath) snt.selectPath(newSegment, false); - new GuiUtils(getViewerFrame()).error((newSegment.size() < 2) + getGuiUtils().error((newSegment.size() < 2) ? "Nothing traced to extend " + base.getName() + " with; kept as a separate path" : String.format("Too far from %s's end (%.2f%s); kept as a separate path", base.getName(), dist, calUnit)); @@ -3628,6 +3842,11 @@ protected void updateUndoButtonState() { updateUndoButtonState(!confirmedSegmentSizes.isEmpty()); } + /** @return whether {@link #undoLastSegment()} would currently do something */ + protected boolean canUndoSegment() { + return tracingEnabled && !confirmedSegmentSizes.isEmpty(); + } + /** * Undoes the most recently confirmed segment, one click at a time (single-step; no redo). * Ported from {@code SNT#undoLastSegment()}: pops the last segment's node count off {@link @@ -3687,27 +3906,21 @@ protected AbstractAction getExtendSelectedPathAction() { @Override public void actionPerformed(final java.awt.event.ActionEvent e) { if (snt == null || !snt.isTracingActive()) { - new GuiUtils(getViewerFrame()).error("Tracing functions currently disabled."); - if (extendPathButton != null) extendPathButton.setSelected(false); + getGuiUtils().error("Tracing functions currently disabled."); return; } if (!tracingEnabled) { - new GuiUtils(getViewerFrame()).error("Enable tracing (manual or interactive) first."); - if (extendPathButton != null) extendPathButton.setSelected(false); + getGuiUtils().error("Enable tracing (manual or interactive) first."); return; } if (previousNode != null || computing) { - new GuiUtils(getViewerFrame()).error("Please finish or discard the current path first."); - if (extendPathButton != null) extendPathButton.setSelected(false); + getGuiUtils().error("Please finish or discard the current path first."); return; } - final Collection selected = snt.getSelectedPaths(); - final Path activePath = (selected != null && selected.size() == 1) - ? selected.iterator().next() : null; + final Path activePath = snt.getSingleSelectedPath(); if (activePath == null) { - new GuiUtils(getViewerFrame()).error( + getGuiUtils().error( "No path selected. Please select a single path to be extended."); - if (extendPathButton != null) extendPathButton.setSelected(false); return; } setToBeCombinedPath(activePath); @@ -3767,15 +3980,13 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { final String tracingDescription = (manualTraceFlag) ? "Manual tracing" : "Semi-automated tracing"; if (!tracingPossible) { - new GuiUtils(getViewerFrame()).error(tracingDescription + " is not available."); + getGuiUtils().error(tracingDescription + " is not available."); tracingEnabled = false; AbstractTracer.this.manualTrace = true; if (button != null) button.setEnabled(false); if (noTracingButton != null) noTracingButton.setSelected(true); - if (extendPathButton != null) extendPathButton.setEnabled(false); } else { showViewerMessage(tracingDescription + " enabled"); - if (extendPathButton != null) extendPathButton.setEnabled(true); } } }; @@ -3787,9 +3998,9 @@ protected AbstractAction getDisableTracingAction() { @Override public void actionPerformed(final java.awt.event.ActionEvent e) { if (snt != null && !snt.isTracingActive()) { - new GuiUtils(getViewerFrame()).error( + getGuiUtils().error( "SNT is currently paused.
    " + - "Use the Quick Toggles drop-down menu to resume tracing functions."); + "Use Quick Toggles or the right-click menu to resume tracing functions."); } disableTracing(); } @@ -3805,10 +4016,8 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { private void disableTracing() { if (!tracingEnabled) return; tracingEnabled = false; - if (extendPathButton != null) extendPathButton.setEnabled(false); - final boolean promptUser = tempPath != null && tempPath.size() > 0; - if (promptUser) { - final int ans = new GuiUtils(getViewerFrame()) + if (isPathInProgress()) { // includes a lone first node (previousNode set, tempPath still empty) + final int ans = getGuiUtils() .yesNoDialog("An unfinished path exists. Would you like to finish it?", "Unfinished Path", "Yes. Finish Path", "No. Discard Path"); if (ans == JOptionPane.YES_OPTION) { @@ -3910,7 +4119,6 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { */ private void setTracingActiveForHold(final boolean active) { tracingEnabled = active; - if (extendPathButton != null) extendPathButton.setEnabled(active); if (active) { final JToggleButton modeButton = (manualTrace) ? manualTracingButton : interactiveTracingButton; if (modeButton != null) modeButton.setSelected(true); @@ -3954,27 +4162,12 @@ protected void enableTracingExternally(final boolean manual) { } /** - * Wires the "Continue Extending Path" toggle button built by the viewer's own toolbar code into - * this tracer: kept selected for as long as {@link #toBeCombinedPath} is set, and disabled - * outright while tracing is off (mirrors {@link #installTracingModeButtons}). Call once, right - * after adding the button to the toolbar - * - * @param button the "Continue Extending Path" toggle button - * @see #setToBeCombinedPath(Path) - */ - protected void installExtendPathButton(final JToggleButton button) { - this.extendPathButton = button; - if (button != null) button.setEnabled(tracingEnabled); - } - - /** - * Sets (or clears) {@link #toBeCombinedPath}, keeping {@link #extendPathButton} (if installed) - * in sync: selected while a combine is pending, deselected the moment it is consumed or - * canceled (successful combine, fallback to a standalone path, or discard) + * Sets (or clears) {@link #toBeCombinedPath}: pending from "Continue Extending Path" (see + * {@link #getExtendSelectedPathAction()}) until consumed or canceled (successful combine, fallback to a + * standalone path, or discard) */ private void setToBeCombinedPath(final Path path) { toBeCombinedPath = path; - if (extendPathButton != null) extendPathButton.setSelected(path != null); } /** @@ -4000,7 +4193,6 @@ protected void setLockedByPause(final boolean locked) { if (noTracingButton != null) noTracingButton.setSelected(true); if (manualTracingButton != null) manualTracingButton.setEnabled(false); if (interactiveTracingButton != null) interactiveTracingButton.setEnabled(false); - if (extendPathButton != null) extendPathButton.setEnabled(false); } else { if (manualTracingButton != null) manualTracingButton.setEnabled(true); if (interactiveTracingButton != null) interactiveTracingButton.setEnabled(true); diff --git a/src/main/java/sc/fiji/snt/viewer/Bdv.java b/src/main/java/sc/fiji/snt/viewer/Bdv.java index 17b986174..692388aa2 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bdv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bdv.java @@ -116,7 +116,6 @@ public static Bdv getInstance() { private JComponent sntControlsCard; // Shared overlay infrastructure (same classes as BVV; dCam = MAX_VALUE => orthographic) - private Bvv.PathOverlay pathOverlay; private Bvv.AnnotationOverlay annotationOverlay; // Guard so addTransformListener is called only once across reinits @@ -716,25 +715,10 @@ public void showViewerMessage(final String msg) { if (viewerPanel != null) viewerPanel.showMessage(msg); } - /** - * Locks/unlocks this viewer's tracing-mode toolbar buttons to "No tracing" - see - * {@code AbstractTracer#setLockedByPause}. No-op if this viewer has no tracer (e.g. a standalone - * viewer with no attached SNT instance, or the toolbar has not been built yet). - * - * @param locked whether a global SNT_PAUSED/TRACING_PAUSED pause is currently in effect - */ - public void setTracingLockedByPause(final boolean locked) { - if (tracer != null) tracer.setLockedByPause(locked); - } - @Override - public void enableTracing(final boolean manual) { - if (tracer != null) tracer.enableTracingExternally(manual); - } - - @Override - public boolean isPathInProgress() { - return tracer != null && tracer.isPathInProgress(); + protected org.scijava.ui.behaviour.util.InputActionBindings getKeybindings() { + final ViewerFrame vf = getViewerFrame(); + return (vf == null) ? null : vf.getKeybindings(); } @Override @@ -947,6 +931,7 @@ public void windowClosed(final java.awt.event.WindowEvent e) { // so this can't leave a stale Tracer's mouse listener attached alongside a new one if (snt != null && tracer == null) { tracer = new Tracer(snt); + installContextMenu(); if (snt.getUI() != null) snt.getUI().setBdv(this); // setBdv again, now that its window is available } @@ -1047,14 +1032,9 @@ private Action addMarkerAction() { return new AbstractAction("Add Marker") { @Override public void actionPerformed(final java.awt.event.ActionEvent e) { - if (blockMarkerPlacement()) return; final RealPoint pos = new RealPoint(3); getGlobalMouseCoordinates(pos); - final double x = pos.getDoublePosition(0); - final double y = pos.getDoublePosition(1); - final double z = pos.getDoublePosition(2); - getMarkerManager().add(x, y, z); - showViewerMessage(String.format("Marker placed at (%.1f, %.1f, %.1f)", x, y, z)); + placeMarker(pos.positionAsDoubleArray()); } }; } diff --git a/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java b/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java new file mode 100644 index 000000000..d3e4e7292 --- /dev/null +++ b/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java @@ -0,0 +1,543 @@ +/*- + * #%L + * Fiji distribution of ImageJ for the life sciences. + * %% + * Copyright (C) 2010 - 2026 Fiji developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +package sc.fiji.snt.viewer; + +import java.awt.Color; +import java.awt.Component; +import java.awt.Point; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import javax.swing.AbstractAction; +import javax.swing.ActionMap; +import javax.swing.InputMap; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.KeyStroke; +import javax.swing.SwingUtilities; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; + +import sc.fiji.snt.Path; +import sc.fiji.snt.PathNodeCanvas; +import sc.fiji.snt.PathNodeEditor; +import sc.fiji.snt.SNT; +import sc.fiji.snt.SNTPrefs; +import sc.fiji.snt.gui.GuiUtils; +import sc.fiji.snt.gui.IconFactory; +import sc.fiji.snt.gui.NodeEditCommand; +import sc.fiji.snt.util.SNTColor; +import sc.fiji.snt.util.SNTPoint; + +/** + * 'Edit Mode' for {@link Bdv}/{@link Bvv}: node-level editing of a single, selected path, mirroring the Edit-path + * commands of the classic canvas (see {@link PathNodeEditor} for the operations themselves). This class only + * handles the interaction layer: + *

      + *
    • Activation: gated on a single selected path. While active, tracing is suspended in the viewer.
    • + *
    • Active node: set by hovering over the nodes of the editing path (screen-space hit test against a + * dedicated {@link AbstractBigViewer.AnnotationOverlay} layer, so it is perspective-correct in Bvv). A locked node + * stays active irrespective of the cursor.
    • + *
    • Commands: single-key shortcuts (same keys as the classic canvas) and a context menu opened by a + * right-click that is not a drag (so that right-drag panning is unaffected). The menu is always available (node + * commands are disabled unless in Edit Mode).
    • + *
    + * Not covered (yet): paint mode and "bring node to current Z-plane" (no meaningful analogue in a 3D scene). + * + * @author Tiago Ferreira + */ +final class BigViewerEditor { + + /** Annotation layer holding all the nodes of the editing path (hit-test target) */ + private static final class NodesLayer {} + + /** Annotation layer holding the active node only (cheap to refresh on hover) */ + private static final class ActiveLayer {} + + /** Annotation layer holding the active node of the secondary editing path (see {@link #connect()}) */ + private static final class SecondaryLayer {} + + private static final float NODE_SCALE = 0.5f; + private static final float ACTIVE_SCALE = 1.2f; + private static final double NODE_ALPHA_PERCENT = 80; + private static final Color SECONDARY_COLOR = new Color(255, 140, 0, 230); + + + /** Node markers use the color of selected paths, as the nodes of the tracing canvas. Resolved on demand: users can change it */ + private static Color nodeColor() { + return SNTColor.alphaColor(SNTPrefs.selectedPathColor(), NODE_ALPHA_PERCENT); + } + + /** The active node stands out against the other nodes, whatever the selection color is */ + private static Color activeColor() { + return SNTColor.contrastHueColor(SNTPrefs.selectedPathColor()); + } + + private static Color lockedColor() { + return PathNodeCanvas.lockColor(activeColor()); + } + + private final AbstractBigViewer viewer; + private final SNT snt; + private final PathNodeEditor ed; + private boolean active; + private boolean popupShowing; + private JPopupMenu popup; + private String exitMessage; + + /** Tracks whether one of our popups is showing: active node picking is suspended while it is */ + private final PopupMenuListener popupStateListener = new PopupMenuListener() { + @Override + public void popupMenuWillBecomeVisible(final PopupMenuEvent ev) { + popupShowing = true; + } + + @Override + public void popupMenuWillBecomeInvisible(final PopupMenuEvent ev) { + popupShowing = false; + } + + @Override + public void popupMenuCanceled(final PopupMenuEvent ev) { + popupShowing = false; + } + }; + + /** Active node picking. Only attached while in Edit Mode */ + private final MouseAdapter hoverHandler = new MouseAdapter() { + @Override + public void mouseMoved(final MouseEvent e) { + onHover(e); + } + }; + + BigViewerEditor(final AbstractBigViewer viewer, final SNT snt) { + this.viewer = viewer; + this.snt = snt; + this.ed = snt.getNodeEditor(); // shared with the tracing canvas and other viewers + } + + boolean isActive() { + return active; + } + + /** + * @return a message explaining why Edit Mode cannot be entered from this viewer at the moment, or null + */ + private String entryBlocker() { + final String blocker = snt.getEditModeBlocker(); + if (blocker != null) return blocker; + if (viewer.annotations() == null || viewer.getViewerCanvas() == null) return "Edit Mode unavailable: no annotation layer"; + if (viewer.isPathInProgress()) return "Edit Mode: Finish or discard the current path first"; + return null; + } + + /** Checks viewer-specific prerequisites, and asks SNT to enable Edit Mode (see {@link #onSntEditModeChanged}) */ + private void requestEnter() { + final String blocker = entryBlocker(); + if (blocker != null) { + viewer.getGuiUtils().error(blocker); + return; + } + final Path single = snt.getSingleSelectedPath(); + if (single != null) snt.enterEditMode(single); + // On success, SNT calls onSntEditModeChanged(true) + } + + /** Called (via the viewer) when SNT enables/disables Edit Mode, regardless of the front-end that triggered it */ + void onSntEditModeChanged(final boolean enabled) { + if (enabled) activate(); + else deactivate(); + } + + void toggle() { + if (snt.isEditModeEnabled()) exit(null); + else requestEnter(); + } + + private void activate() { + if (active) return; + // Tracing must be suspended even if the interaction layer is unavailable (SNT is in Edit Mode regardless). + // It is unlocked when SNT leaves Edit Mode (see SNT#resetStateAfterEditMode) + viewer.setTracingLockedByPause(true); + final Component canvas = viewer.getViewerCanvas(); + if (canvas == null || viewer.annotations() == null) return; + active = true; + canvas.addMouseMotionListener(hoverHandler); + viewer.installEditKeybindings(buildInputMap(), buildActionMap()); + refreshNodes(); + } + + /** + * Exits Edit Mode on all front-ends + * + * @param msg message to be displayed by the viewer once exited, or null + */ + void exit(final String msg) { + exitMessage = msg; + if (snt.isEditModeEnabled()) snt.exitEditMode(); // SNT calls back onSntEditModeChanged(false) + else deactivate(); + } + + /** + * Removes the viewer-side interaction layer. The editing path, active node and the tracing state are managed by + * SNT (see {@code SNT#enableEditMode(boolean)}), so they are not touched here + */ + private void deactivate() { + deactivate(true); + } + + /** @param notify false if the viewer is being disposed: only listeners are released */ + private void deactivate(final boolean notify) { + if (!active) return; + active = false; + final Component canvas = viewer.getViewerCanvas(); + if (canvas != null) { + canvas.removeMouseMotionListener(hoverHandler); + } + if (popup != null) popup.setVisible(false); + if (!notify) return; + viewer.removeEditKeybindings(); + final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); + if (ann != null) { + ann.clear(NodesLayer.class); + ann.clear(ActiveLayer.class); + ann.clear(SecondaryLayer.class); + } + viewer.showViewerMessage((exitMessage == null) ? "Edit Mode off" : exitMessage); + exitMessage = null; + } + + /** + * Called when the viewer is being disposed. In Stream mode (no tracing canvas) the viewers are the only + * front-ends: closing the last one that is editing ends Edit Mode, so that SNT is not left in an unreachable state + */ + void onViewerDisposed() { + final boolean wasActive = active; + deactivate(false); + if (!wasActive || !snt.isStreamMode()) return; + final sc.fiji.snt.SNTUI ui = snt.getUI(); + if (ui != null && ui.isOtherBigViewerEditing(viewer)) return; + if (SwingUtilities.isEventDispatchThread()) snt.exitEditMode(); + else SwingUtilities.invokeLater(snt::exitEditMode); + } + +// picking and markers + + private void onHover(final MouseEvent e) { + if (!active || popupShowing || ed.isActiveNodeLocked()) return; + // Like the classic canvas, the active node persists when the cursor moves away from it + // (so that 'Move' and 'Insert' can target a cursor position away from the node) + final int idx = pick(e.getX(), e.getY()); + if (idx >= 0 && idx != ed.getActiveNode()) { + ed.setActiveNode(idx); + refreshActiveMarker(); + syncActiveNode(); + } + } + + /** Makes the tracing canvas and the other viewer (if any) reflect the active node/lock state */ + private void syncActiveNode() { + snt.repaintAllPanes(); + snt.notifyActiveNodeChanged(viewer); + } + + private int pick(final int screenX, final int screenY) { + final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); + return (ann == null) ? -1 : ann.hitTest(NodesLayer.class, screenX, screenY); + } + + /** Rebuilds the overlay layer of all the nodes of the editing path, and the active marker */ + void refreshNodes() { + final Path p = ed.getPath(); + final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); + if (ann == null) return; + if (p == null) { + ann.clear(NodesLayer.class); + ann.clear(ActiveLayer.class); + ann.clear(SecondaryLayer.class); + return; + } + final float size = viewer.getDefaultMarkerSize() * NODE_SCALE; + final Color nodeColor = nodeColor(); + final List points = new ArrayList<>(p.size()); + final List sizes = new ArrayList<>(p.size()); + final List colors = new ArrayList<>(p.size()); + for (int i = 0; i < p.size(); i++) { + points.add(p.getNode(i)); + sizes.add(size); + colors.add(nodeColor); + } + ann.replaceAll(NodesLayer.class, points, sizes, colors); + refreshActiveMarker(); + refreshSecondaryMarker(); + } + + /** Highlights the active node of the secondary path: the target of {@link #connect()} */ + private void refreshSecondaryMarker() { + final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); + if (ann == null) return; + final Path sec = ed.getSecondaryPath(); + final int idx = (sec == null) ? -1 : sec.getEditableNodeIndex(); + if (sec == null || sec == ed.getPath() || idx < 0 || idx >= sec.size()) { + ann.clear(SecondaryLayer.class); + return; + } + ann.replaceAll(SecondaryLayer.class, List.of(sec.getNode(idx)), + List.of(viewer.getDefaultMarkerSize() * ACTIVE_SCALE), List.of(SECONDARY_COLOR)); + } + + void refreshActiveMarker() { + final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); + if (ann == null) return; + final Path p = ed.getPath(); + final int idx = ed.getActiveNode(); + if (p == null || idx < 0 || idx >= p.size()) { + ann.clear(ActiveLayer.class); + return; + } + ann.replaceAll(ActiveLayer.class, List.of(p.getNode(idx)), List.of(viewer.getDefaultMarkerSize() * ACTIVE_SCALE), + List.of(ed.isActiveNodeLocked() ? lockedColor() : activeColor())); + } + + // commands + + /** Reports the outcome of an operation and refreshes the viewer as needed */ + private void report(final PathNodeEditor.Result r) { + if (r == null) return; + r.dispatch(res -> { + // Editing path no longer valid (e.g., re-root/split/delete). Re-sync paths and leave edit mode + viewer.syncPathManagerList(); + if (!res.ok() && res.message() != null) { + viewer.getGuiUtils().error(res.message()); // failure after the path was released + exit(null); + } else { + exit(res.message()); + } + }, res -> { + viewer.syncPathManagerList(); + viewer.invalidatePathGeometry(); // moved/inserted nodes do not alter the cached fingerprint + snt.repaintAllPanes(); // tracing canvas, if any + refreshNodes(); + snt.notifyEditingPathChanged(); // other viewers (throttled) + showMessage(res); + }, res -> { + refreshActiveMarker(); + showMessage(res); + }); + } + + private void showMessage(final PathNodeEditor.Result r) { + if (r.message() == null) return; + if (r.modal()) viewer.getGuiUtils().error(r.message()); + else viewer.showViewerMessage(r.message()); + } + + private void doDelete() { + report(ed.deleteNodeOrPath(viewer.getGuiUtils())); + } + + private void doInsert(final double[] pos) { + if (pos == null) { + viewer.showViewerMessage("Could not resolve cursor position"); + return; + } + report(ed.insertNode(pos[0], pos[1], pos[2])); + } + + private void doMove(final double[] pos) { + if (pos == null) { + viewer.showViewerMessage("Could not resolve cursor position"); + return; + } + report(ed.moveNode(pos[0], pos[1], pos[2])); + } + + private void doLock() { + final PathNodeEditor.Result r = ed.toggleLock(); + refreshActiveMarker(); // no geometry change: no need to re-sync the scene + syncActiveNode(); + if (r.message() != null) viewer.showViewerMessage(r.message()); + } + + private void doRadius() { + report(ed.promptAndSetRadius(viewer.getGuiUtils())); + } + + private void doTag(final Component anchor, final int x, final int y) { + if (ed.getActiveNode() < 0) { + viewer.showViewerMessage("No node selected"); + return; + } + final int node = ed.getActiveNode(); // snapshot: hovering other nodes while choosing must not retarget the tag + final Path nodePath = ed.getPath(); + final JPopupMenu tagMenu = GuiUtils.MenuItems.colorTagPopup(anchor, + chosen -> report(ed.tagNode(nodePath, node, chosen))); + tagMenu.setLightWeightPopupEnabled(false); + tagMenu.addPopupMenuListener(popupStateListener); + tagMenu.show(anchor, x, y); + } + + private void doTagAtCursor() { + final Component c = viewer.getViewerCanvas(); + if (c == null) return; + Point p = c.getMousePosition(); + if (p == null) p = new Point(c.getWidth() / 2, c.getHeight() / 2); + doTag(c, p.x, p.y); + } + + /** Connects the active node to the active node of the secondary path (the one edited previously) */ + private void connect() { + report(ed.connectToSecondaryPath(viewer.getGuiUtils(), true)); + } + + private void doSetRoot() { + report(ed.setActiveNodeAsRoot(viewer.getGuiUtils())); + } + + private void doSplit() { + report(ed.splitTreeAtActiveNode(viewer.getGuiUtils())); + } + + /** @return whether there are edits to undo (always false if Edit Mode is off) */ + boolean canUndo() { + return active && ed.canUndo(); + } + + /** Undoes the last edit */ + void undo() { + report(ed.undo()); + } + + // key bindings + + private InputMap buildInputMap() { + final InputMap im = NodeEditCommand.inputMap(); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_C, 0), "snt-edit-connect"); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "snt-edit-exit"); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.SHIFT_DOWN_MASK), "snt-edit-exit"); + return im; + } + + private ActionMap buildActionMap() { + final ActionMap am = NodeEditCommand.actionMap( + cmd -> run(cmd, viewer::resolveCursorWorldPosition, this::doTagAtCursor)); + am.put("snt-edit-connect", action("Connect", this::connect)); + am.put("snt-edit-exit", action("Exit", () -> exit(null))); + return am; + } + + /** + * Executes a command. The cursor position is only resolved by the commands that need it + * + * @param cmd the command + * @param pos supplies the cursor position (calibrated coordinates) + * @param tag opens the tag chooser at the appropriate location + */ + private void run(final NodeEditCommand cmd, final Supplier pos, final Runnable tag) { + switch (cmd) { + case DELETE -> doDelete(); + case INSERT -> doInsert(pos.get()); + case MOVE -> doMove(pos.get()); + case LOCK -> doLock(); + case RADIUS -> doRadius(); + case TAG -> tag.run(); + case SET_ROOT -> doSetRoot(); + case SPLIT -> doSplit(); + case UNDO -> undo(); + } + } + + private static AbstractAction action(final String name, final Runnable r) { + return new AbstractAction(name) { + @Override + public void actionPerformed(final java.awt.event.ActionEvent e) { + r.run(); + } + }; + } + + // context menu + + /** The 'Connect' entry: enabled if the editing and secondary paths each have an active node */ + private JMenuItem connectMenuItem() { + final Path sec = ed.getSecondaryPath(); + final boolean enabled = active && ed.getPath() != null && ed.getActiveNode() >= 0 && ed.hasSecondaryNode(); + final String label = (active && sec != null && sec.getEditableNodeIndex() != -1) + ? "Connect to " + NodeEditCommand.shortName(sec) + " (node " + sec.getEditableNodeIndex() + ")" + : "Connect to Secondary Editing Path..."; + final JMenuItem item = new JMenuItem(NodeEditCommand.MENU_INDENT + label); + item.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, 0)); + item.setEnabled(enabled); + item.setToolTipText("To connect two paths: Grab the parent path (G), hover over the node to branch from,
    " + + "grab the child path (G), hover over the node to connect, and press C"); + item.addActionListener(ev -> connect()); + return item; + } + + /** + * Appends the Edit Mode section to the viewer's context menu (see {@link BigViewerMenu}) + * + * @param menu the menu + * @param e the event that opened the menu + * @param clickPos the cursor position (calibrated coordinates) captured when the menu was opened, or null + */ + void addMenuItems(final JPopupMenu menu, final MouseEvent e, final double[] clickPos) { + if (active) { + // Snapshot target node and cursor position at the time of the click: neither may change while the menu is + // open (hovering over other nodes en route to a menu item must not retarget the command) + final int picked = pick(e.getX(), e.getY()); + if (picked >= 0) ed.setActiveNode(picked); // no-op if locked. Otherwise keeps the previous active node + refreshActiveMarker(); + } + final double[] cursor = clickPos; + final boolean hasNode = active && ed.getActiveNode() >= 0; + final Component source = (Component) e.getSource(); + final Path single = snt.getSingleSelectedPath(); + final JCheckBoxMenuItem toggle = new JCheckBoxMenuItem(NodeEditCommand.editToggleLabel((single == null) ? null : NodeEditCommand.shortName(single)), active); + toggle.setIcon(IconFactory.menuIcon(IconFactory.GLYPH.PEN)); + toggle.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.SHIFT_DOWN_MASK)); + final String blocker = (active) ? null : entryBlocker(); + toggle.setEnabled(blocker == null); + if (blocker != null) toggle.setToolTipText(blocker); + toggle.addActionListener(ev -> toggle()); + menu.add(toggle); + menu.addSeparator(); + NodeEditCommand.addMenuItems(menu, + new NodeEditCommand.MenuState(hasNode, active && ed.isActiveNodeLocked(), active && ed.canUndo()), + cmd -> run(cmd, () -> cursor, () -> doTag(source, e.getX(), e.getY()))); + // Canvas-only commands + menu.addSeparator(); + menu.add(BigViewerMenu.cropOnlyItem(NodeEditCommand.MENU_INDENT + "Bring Active Node to Current Z-plane", KeyEvent.VK_B)); + menu.add(connectMenuItem()); + menu.add(BigViewerMenu.cropOnlyItem(NodeEditCommand.MENU_INDENT + "Paint Mode", KeyEvent.VK_P)); + menu.addPopupMenuListener(popupStateListener); + popup = menu; + } +} diff --git a/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java b/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java new file mode 100644 index 000000000..3d616bbef --- /dev/null +++ b/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java @@ -0,0 +1,223 @@ +/*- + * #%L + * Fiji distribution of ImageJ for the life sciences. + * %% + * Copyright (C) 2010 - 2026 Fiji developers. + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +package sc.fiji.snt.viewer; + +import java.awt.Component; +import java.awt.Point; +import java.awt.event.ActionListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.KeyStroke; +import javax.swing.MenuElement; +import javax.swing.SwingUtilities; + +import sc.fiji.snt.Path; +import sc.fiji.snt.SNT; +import sc.fiji.snt.SNTUI; +import sc.fiji.snt.gui.ContextMenuCommand; +import sc.fiji.snt.gui.NodeEditCommand; +import sc.fiji.snt.gui.IconFactory; + +/** + * Context menu of {@link Bdv}/{@link Bvv}: the counterpart of the contextual menu of the tracing canvas, with the same + * entries, order and labels. It opens with a right-click that is not a drag (BDV/BVV use right-drag to pan). Commands + * without a viewer equivalent are disabled and flagged with a 'crop' icon (they only apply to a materialized crop). + * The 'Edit Mode' section is provided by {@link BigViewerEditor}. + * + * @author Tiago Ferreira + */ +final class BigViewerMenu { + + static final String CROP_ONLY_TIP = "Applies only to a materialized crop, not to the live Bvv/Bdv scene"; + + private final AbstractBigViewer viewer; + private final SNT snt; + private final BigViewerEditor editor; + private Component host; // the display the listener is attached to + private Point rightPress; + + private final MouseAdapter listener = new MouseAdapter() { + @Override + public void mousePressed(final MouseEvent e) { + rightPress = SwingUtilities.isRightMouseButton(e) ? e.getPoint() : null; + } + + @Override + public void mouseReleased(final MouseEvent e) { + final Point start = rightPress; + rightPress = null; + if (start == null || !SwingUtilities.isRightMouseButton(e)) return; + if (start.distance(e.getPoint()) > AbstractBigViewer.CLICK_MOVE_TOLERANCE_PX) return; // a drag + show(e); + } + }; + + BigViewerMenu(final AbstractBigViewer viewer, final SNT snt, final BigViewerEditor editor) { + this.viewer = viewer; + this.snt = snt; + this.editor = editor; + } + + /** Attaches the menu to the viewer's display (once per display) */ + void install() { + final Component canvas = viewer.getViewerCanvas(); + if (canvas == null || canvas == host) return; + host = canvas; + viewer.addMouseListenerToDisplay(listener); + } + + private void show(final MouseEvent e) { + final SNTUI ui = snt.getUI(); + final boolean sntPaused = ui != null && ui.getState() == SNTUI.SNT_PAUSED; + final boolean editing = editor.isActive(); + final AbstractBigViewer.AbstractTracer tracer = viewer.menuTracer(); + final boolean tracingPaused = snt.isTracingPaused(); + final boolean tracingAvailable = tracer != null && !editing && !tracingPaused; + final Path single = snt.getSingleSelectedPath(); + final boolean hasPaths = snt.getPathAndFillManager().size() > 0; + + final JPopupMenu menu = new JPopupMenu(); + menu.setLightWeightPopupEnabled(false); // BVV renders on a heavyweight GL canvas + // Snapshot of the click position: the live cursor position is unreliable once the menu is open + final double[] clickPos = viewer.resolveCursorWorldPosition(); + + // Selection + final JMenuItem grab = item(ContextMenuCommand.GRAB, KeyEvent.VK_G, 0, tracer != null && hasPaths, + ev -> tracer.selectNearestPath(clickPos, false)); + grab.setToolTipText("Select nearest path to where the menu was opened"); + menu.add(grab); + final JMenuItem append = item(ContextMenuCommand.APPEND, KeyEvent.VK_G, InputEvent.SHIFT_DOWN_MASK, + tracer != null && hasPaths && !editing, ev -> tracer.selectNearestPath(clickPos, true)); + append.setToolTipText("Add nearest path to current selection"); + menu.add(append); + menu.add(cropOnlyItem(ContextMenuCommand.LASSO.label(), 0)); + menu.addSeparator(); + + // Display + final JMenuItem hide = item(ContextMenuCommand.HIDE, KeyEvent.VK_H, 0, true, + ev -> viewer.showViewerMessage("Press and hold H to temporarily hide paths and annotations")); + menu.add(hide); + menu.add(cropOnlyItem(ContextMenuCommand.ORIENTATION.label(), KeyEvent.VK_O)); + menu.addSeparator(); + + // Bookmarks and analysis + final JMenuItem bookmark = item(ContextMenuCommand.BOOKMARK, KeyEvent.VK_M, 0, true, + ev -> viewer.placeMarker(clickPos)); + menu.add(bookmark); + final JMenuItem sholl = item(ContextMenuCommand.SHOLL, KeyEvent.VK_A, + InputEvent.SHIFT_DOWN_MASK | InputEvent.ALT_DOWN_MASK, hasPaths && !editing, + ev -> viewer.startShollAtNearestNode(clickPos)); + menu.add(sholl); + menu.addSeparator(); + + // Tracing + menu.add(cropOnlyItem(ContextMenuCommand.BRIGHTEST.label(), KeyEvent.VK_V)); + final JMenuItem extend = item(ContextMenuCommand.EXTEND, 0, 0, + tracingAvailable && viewer.isTracingEnabled() && single != null && !viewer.isPathInProgress(), + (tracer == null) ? null : tracer.getExtendSelectedPathAction()); + if (single != null) extend.setText("Continue Extending " + NodeEditCommand.shortName(single)); + menu.add(extend); + final JMenuItem fork = item(ContextMenuCommand.FORK, 0, 0, false, null); + fork.setToolTipText("In Bvv/Bdv: Alt+Click on a path while tracing"); + menu.add(fork); + menu.addSeparator(); + final JMenuItem undo = (editing) + ? item(NodeEditCommand.UNDO.label(), KeyEvent.VK_Z, 0, editor.canUndo(), ev -> editor.undo()) + : item(ContextMenuCommand.UNDO_SEGMENT, KeyEvent.VK_Z, 0, tracingAvailable && tracer.canUndoSegment(), + (tracer == null) ? null : tracer.getUndoSegmentAction()); + ContextMenuCommand.UNDO_SEGMENT.applyIcon(undo); // also if the edit-mode variant was chosen + menu.add(undo); + final JMenuItem finish = item(ContextMenuCommand.FINISH, KeyEvent.VK_ENTER, 0, + tracingAvailable && viewer.isPathInProgress(), + (tracer == null) ? null : tracer.getFinishPathAction()); + menu.add(finish); + menu.addSeparator(); + + // Edit Mode + editor.addMenuItems(menu, e, clickPos); + + // Pause + menu.addSeparator(); + final JCheckBoxMenuItem pauseTracing = new JCheckBoxMenuItem(ContextMenuCommand.PAUSE_TRACING.label(), + tracingPaused); + ContextMenuCommand.PAUSE_TRACING.applyIcon(pauseTracing); + pauseTracing.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_P, InputEvent.SHIFT_DOWN_MASK)); + pauseTracing.setEnabled(ui != null && !editing && !viewer.isPathInProgress()); + pauseTracing.addActionListener(ev -> { + if (ui != null) ui.pauseTracing(pauseTracing.isSelected()); + }); + menu.add(pauseTracing); + final JCheckBoxMenuItem pauseSNT = new JCheckBoxMenuItem(ContextMenuCommand.PAUSE_SNT.label(), sntPaused); + ContextMenuCommand.PAUSE_SNT.applyIcon(pauseSNT); + pauseSNT.setEnabled(ui != null && !editing && !viewer.isPathInProgress()); + pauseSNT.addActionListener(ev -> { + if (ui != null) ui.pause(pauseSNT.isSelected()); + }); + menu.add(pauseSNT); + + // SNT fully paused: disable everything except the toggle itself + if (sntPaused) { + for (final MenuElement me : menu.getSubElements()) { + if (me instanceof JMenuItem mi && mi != pauseSNT) mi.setEnabled(false); + } + pauseSNT.setEnabled(true); + } + menu.show((Component) e.getSource(), e.getX(), e.getY()); + } + + /** As {@link #item(String, int, int, boolean, ActionListener)}, with label and icon of a shared command */ + private static JMenuItem item(final ContextMenuCommand cmd, final int keyCode, final int modifiers, + final boolean enabled, final ActionListener l) { + final JMenuItem item = item(cmd.label(), keyCode, modifiers, enabled, l); + cmd.applyIcon(item); + return item; + } + + private static JMenuItem item(final String label, final int keyCode, final int modifiers, final boolean enabled, + final ActionListener l) { + final JMenuItem item = new JMenuItem(label); + if (keyCode != 0) item.setAccelerator(KeyStroke.getKeyStroke(keyCode, modifiers)); + item.setEnabled(enabled && l != null); + if (l != null) item.addActionListener(l); + return item; + } + + /** + * A disabled item for a canvas command that has no equivalent in the viewers + * + * @param label the menu label + * @param keyCode the shortcut of the canvas command, or 0 + */ + static JMenuItem cropOnlyItem(final String label, final int keyCode) { + final JMenuItem item = item(label, keyCode, 0, false, null); + IconFactory.assignIcon(item, IconFactory.GLYPH.CROP); + item.setToolTipText(CROP_ONLY_TIP); + return item; + } +} diff --git a/src/main/java/sc/fiji/snt/viewer/Bvv.java b/src/main/java/sc/fiji/snt/viewer/Bvv.java index e0031919a..7c5c48b2b 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bvv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bvv.java @@ -100,7 +100,6 @@ public static Bvv getInstance() { private final BvvOptions options; private JToggleButton slabAnnotationsToggle; // Slab Annotations toggle injected into BookmarkManager's toolbar - private PathOverlay pathOverlay; private AnnotationOverlay annotationOverlay; private SceneOverlay sceneOverlay; private BigVolumeViewer currentBvv; @@ -169,6 +168,9 @@ public Bvv(final SNT snt) { } private double[] getClickedPosition(final MouseEvent e) { + // A null event means "live cursor" (e.g., G key, Edit Mode): never cached, as null would otherwise match + // lastPositionEvent (also null) and return a stale position + if (e == null) return getClickedPosition(); if (e == lastPositionEvent) return lastPosition; // same physical click, already computed lastPositionEvent = e; lastPosition = getClickedPosition(); @@ -1426,6 +1428,7 @@ private void attachControlPanel(final BvvSource source) { registerCenterOnDoubleClickListener(currentBvv); if (snt != null) { tracer = new Tracer(snt); + installContextMenu(); // We'll re-assign the viewer to the GUI here because only at this point the viewer frame is // available and this allows snt.getUI().setBvv() to inject the window listener that monitors // window closing. Innocuous if viewer has been already assigned @@ -2497,25 +2500,16 @@ public void showViewerMessage(final String msg) { if (currentBvv != null) currentBvv.getViewer().showMessage(msg); } - /** - * Locks/unlocks this viewer's tracing-mode toolbar buttons to "No tracing" as per - * {@code AbstractTracer#setLockedByPause}. Does nothing if this viewer has no tracer (e.g. a standalone - * viewer with no attached SNT instance, or the toolbar has not been built yet). - * - * @param locked whether a global SNT_PAUSED/TRACING_PAUSED pause is currently in effect - */ - public void setTracingLockedByPause(final boolean locked) { - if (tracer != null) tracer.setLockedByPause(locked); - } - @Override - public void enableTracing(final boolean manual) { - if (tracer != null) tracer.enableTracingExternally(manual); + protected double[] resolveCursorWorldPosition() { + // Same depth resolution as tracing clicks (visible-depth heuristic), instead of the focal plane + return (tracer != null) ? tracer.resolveClickWorldPosition(null) : super.resolveCursorWorldPosition(); } @Override - public boolean isPathInProgress() { - return tracer != null && tracer.isPathInProgress(); + protected org.scijava.ui.behaviour.util.InputActionBindings getKeybindings() { + final VolumeViewerFrame vf = getViewerFrame(); + return (vf == null) ? null : vf.getKeybindings(); } @Override @@ -4179,17 +4173,17 @@ public static class AnnotationOverlay implements AbstractBigViewer.AnnotationOve * Annotations, partitioned by owner. Independent features (bookmarks/markers, detector-derived * seeds, the legacy ownerless API) each get their own list here, so that one feature's * clear()/replaceAll() never wipes another's markers - all layers are still rendered together - * (see {@link #flatten()}/{@link #updateScene()}). LinkedHashMap for deterministic iteration - * order, which {@link #flatten()} relies on to keep {@link #layerRanges} stable between calls. + * (see {@link #flatten(Map)}/{@link #updateScene()}). LinkedHashMap for deterministic iteration + * order, which {@link #flatten(Map)} relies on to keep {@link #layerRanges} stable between calls. */ private final Map> layers = new LinkedHashMap<>(); /** * [start, end) range each owner occupies in the most recently flattened list, i.e. in - * {@code annRenderer.screenData}/{@code annRenderer.selectedIndex} index space. Rebuilt by - * every {@link #flatten()} call; used to translate owner-local indices to/from the global + * {@code annRenderer.frame}/{@code annRenderer.selectedIndex} index space. Replaced by + * every {@link #updateScene()} call; used to translate owner-local indices to/from the global * indices {@link #hitTest(int, int)}/{@link #setSelectedIndex(int)} operate on. */ - private final Map layerRanges = new LinkedHashMap<>(); + private volatile Map layerRanges = Map.of(); // replaced, never mutated private final PathRenderingOptions renderingOptions; AnnotationOverlay(final BigViewerPanel viewerPanel, final Bvv viewer, final PathRenderingOptions renderingOptions) { @@ -4284,18 +4278,17 @@ public void setVisible(final boolean visible) { /** * Flattens every owner's layer into a single list, in stable (LinkedHashMap) owner order, - * and records each owner's [start, end) range in {@link #layerRanges} - the mapping the + * and records each owner's [start, end) range in {@code ranges} - the mapping the * owner-scoped {@link #hitTest(Object, int, int)}/{@link #setSelectedIndex(Object, int)} * need to translate between an owner-local index and the global index space * {@code annRenderer} actually renders/hit-tests against. */ - private List flatten() { + private List flatten(final Map ranges) { final List flat = new ArrayList<>(getCount()); - layerRanges.clear(); for (final Map.Entry> e : layers.entrySet()) { final int start = flat.size(); flat.addAll(e.getValue()); - layerRanges.put(e.getKey(), new int[]{start, flat.size()}); + ranges.put(e.getKey(), new int[]{start, flat.size()}); } return flat; } @@ -4304,7 +4297,11 @@ private List flatten() { * Ensure renderers/nodes reflect current annotation list. */ public void updateScene() { - annRenderer.setAnnotations(flatten()); + final Map ranges = new LinkedHashMap<>(); + final List flat = flatten(ranges); + layerRanges = ranges; + // The renderer keeps its own copy of the ranges, tied to the screen data computed from this very list + annRenderer.setAnnotations(flat, ranges); viewerPanel.requestRepaint(); } @@ -4331,8 +4328,9 @@ public void setSelectedIndex(final Object owner, final int index) { */ @Override public int hitTest(final int screenX, final int screenY) { - final AnnRenderer.AnnotationScreenData[] data = annRenderer.screenData; - if (data == null) return -1; + final AnnRenderer.Frame frame = annRenderer.frame; + if (frame == null) return -1; + final AnnRenderer.AnnotationScreenData[] data = frame.data(); for (int i = 0; i < data.length; i++) { if (data[i] == null || !data[i].visible) continue; final double dx = screenX - data[i].screenX; @@ -4350,10 +4348,13 @@ public int hitTest(final int screenX, final int screenY) { */ @Override public int hitTest(final Object owner, final int screenX, final int screenY) { - final int[] range = layerRanges.get(owner); + // Screen data and layer ranges must come from the same flattened list: both are read from one frame, + // so a concurrent updateScene() cannot make an index from one list address another + final AnnRenderer.Frame frame = annRenderer.frame; + if (frame == null) return -1; + final AnnRenderer.AnnotationScreenData[] data = frame.data(); + final int[] range = frame.source().ranges().get(owner); if (range == null) return -1; - final AnnRenderer.AnnotationScreenData[] data = annRenderer.screenData; - if (data == null) return -1; final int end = Math.min(range[1], data.length); for (int i = range[0]; i < end; i++) { if (data[i] == null || !data[i].visible) continue; @@ -4445,14 +4446,21 @@ class AnnRenderer implements bdv.viewer.OverlayRenderer { private final double[] viewerCoords = new double[3]; boolean hide; int selectedIndex = -1; - // Source annotations - private List annotations = new ArrayList<>(); + /** Flattened annotations together with the owner ranges they were flattened with */ + private record Source(List list, Map ranges) {} + + /** Screen data and the {@link Source} it was computed from */ + private record Frame(AnnotationScreenData[] data, Source source) {} + + // Source annotations: a single volatile reference, so that readers (painter thread, hit tests on the + // EDT) always see a consistent list/ranges pair, and a list that does not change size mid-iteration + private volatile Source source = new Source(List.of(), Map.of()); private volatile boolean cacheValid = false; // Cached screen data. Volatile: computeScreenData() only ever publishes a fully-built // local array here in a single reference store (see below), so a concurrent // invalidateCache() - e.g. triggered from a background-thread SeedOverlay update - // can only ever observe the previous complete array or null, never a partially-filled one - private volatile AnnotationScreenData[] screenData; + private volatile Frame frame; // Canvas dimensions cache private int canvasWidth, canvasHeight; private double centerX, centerY; @@ -4462,19 +4470,20 @@ class AnnRenderer implements bdv.viewer.OverlayRenderer { this.renderingOptions = renderingOptions; } - void setAnnotations(final List list) { - this.annotations = (list != null) ? new ArrayList<>(list) : new ArrayList<>(); + void setAnnotations(final List list, final Map ranges) { + this.source = new Source((list != null) ? new ArrayList<>(list) : new ArrayList<>(), + (ranges != null) ? ranges : Map.of()); invalidateCache(); } void invalidateCache() { cacheValid = false; - screenData = null; + frame = null; } @Override public void drawOverlays(final Graphics g) { - if (annotations.isEmpty() || hide) return; + if (source.list().isEmpty() || hide) return; // Update canvas dimensions canvasWidth = viewerPanel.getDisplayWidth(); @@ -4493,15 +4502,19 @@ public void drawOverlays(final Graphics g) { } // Compute screen data if needed - if (!cacheValid || screenData == null) { + // A frame computed from a list that has since been replaced (updateScene() racing with a + // computation in progress) is stale, whatever cacheValid says + final Frame cached = frame; + if (!cacheValid || cached == null || cached.source() != source) { computeScreenData(); cacheValid = true; } // Snapshot the volatile field once for this frame: guaranteed complete or null, // never partially built, even if another thread invalidates mid-frame - final AnnotationScreenData[] frameData = screenData; - if (frameData == null) return; + final Frame currentFrame = frame; + if (currentFrame == null) return; + final AnnotationScreenData[] frameData = currentFrame.data(); // Get clipping info final boolean doClip = renderingOptions.isClippingEnabled(); @@ -4565,6 +4578,8 @@ public void drawOverlays(final Graphics g) { } private void computeScreenData() { + final Source snapshot = source; // one list for the whole computation + final List annotations = snapshot.list(); final AnnotationScreenData[] local = new AnnotationScreenData[annotations.size()]; final double margin = 100.0; @@ -4618,7 +4633,7 @@ private void computeScreenData() { // Publish the fully-built array in one atomic reference store; a concurrent // invalidateCache() can now only ever null the field or leave the previous // complete array in place, never observe a partially-filled one - screenData = local; + frame = new Frame(local, snapshot); } private double[] getClipPosition() { @@ -5065,8 +5080,18 @@ private TreeScreenData computeScreenData(final Tree tree) { final double minR = renderingOptions.getMinThickness() / 2.0; final double maxR = renderingOptions.getMaxThickness() / 2.0; + // Path.nodes is an unsynchronized list that the EDT may shrink (edit mode) while this + // runs on the painter thread: if that happens skip the path. The edit triggers a + // cache invalidation, so the next frame is rebuilt from consistent data + boolean raced = false; for (int i = 0; i < n; i++) { - final Path.PathNode node = path.getNode(i); + final Path.PathNode node; + try { + node = path.getNode(i); + } catch (final IndexOutOfBoundsException | java.util.ConcurrentModificationException ex) { + raced = true; + break; + } worldCoords[0] = node.x + offX; worldCoords[1] = node.y + offY; @@ -5093,6 +5118,8 @@ private TreeScreenData computeScreenData(final Tree tree) { pathData.visible[i] = isOnScreen(pathData.screenX[i], pathData.screenY[i]); } + if (raced) continue; + // Pre-build SegmentData for each valid segment to avoid per-frame allocation for (int i = 0; i < pathData.size - 1; i++) { if (!pathData.visible[i] && !pathData.visible[i + 1]) continue; @@ -5475,13 +5502,8 @@ Action addMarkerAction() { return new AbstractAction("Add Marker") { @Override public void actionPerformed(final java.awt.event.ActionEvent e) { - if (blockMarkerPlacement()) return; final double[] pos = getClickedPosition(); - if (pos != null) { // warning message if null - getMarkerManager().add(pos[0], pos[1], pos[2]); - bvv.getViewer().showMessage(String.format("Marker placed at (%.1f, %.1f, %.1f)", - pos[0], pos[1], pos[2])); - } + if (pos != null) placeMarker(pos); // warning message if null } }; } From e035b1290ea21698b29e72eb8a4aa7eeea2f465e Mon Sep 17 00:00:00 2001 From: Tiago Ferreira Date: Sun, 11 Oct 2026 16:34:38 -0400 Subject: [PATCH 2/3] Fix PlausibilityMonitor not picking up changes & extend checks to Bdv/Bvv ... while at it: - improve min-max detection - Fix NPE - Improve nearest point detection - Fix selection in Bdv --- .../java/sc/fiji/snt/CurationManager.java | 31 +++- src/main/java/sc/fiji/snt/SNT.java | 9 +- .../sc/fiji/snt/viewer/AbstractBigViewer.java | 172 ++++++++++++++---- src/main/java/sc/fiji/snt/viewer/Bdv.java | 12 ++ src/main/java/sc/fiji/snt/viewer/Bvv.java | 9 +- 5 files changed, 193 insertions(+), 40 deletions(-) diff --git a/src/main/java/sc/fiji/snt/CurationManager.java b/src/main/java/sc/fiji/snt/CurationManager.java index d30b6ba27..c92d2cef6 100644 --- a/src/main/java/sc/fiji/snt/CurationManager.java +++ b/src/main/java/sc/fiji/snt/CurationManager.java @@ -676,7 +676,7 @@ private JPanel buildLiveParamsPanel() { angleCheck != null && angleCheck.isEnabled()); branchAngleMinCheckbox.setToolTipText("Warns when a child path emerges nearly parallel to its parent"); branchAngleMinSpinner = new JSpinner(new SpinnerNumberModel( - angleCheck != null ? angleCheck.getMinAngleDeg() : 10.0, 0.0, 90.0, 5.0)); + angleCheck != null ? angleCheck.getMinAngleDeg() : 10.0, 0.0, 180.0, 5.0)); branchAngleMinCheckbox.addActionListener(e -> { if (angleCheck != null) angleCheck.setEnabled( branchAngleMinCheckbox.isSelected() || branchAngleMaxCheckbox.isSelected()); @@ -684,6 +684,7 @@ private JPanel buildLiveParamsPanel() { }); branchAngleMinSpinner.addChangeListener(e -> { if (angleCheck != null) angleCheck.setMinAngleDeg((Double) branchAngleMinSpinner.getValue()); + coupleBranchAngleBounds(); }); // Histogram button: both BranchAngle spinners share the same OUTSIDE-flagged // distribution. Clicking either button opens the same chart with both markers. @@ -701,7 +702,7 @@ private JPanel buildLiveParamsPanel() { angleCheck != null && angleCheck.isEnabled()); branchAngleMaxCheckbox.setToolTipText("Warns when a child path doubles back, nearly anti-parallel to its parent"); branchAngleMaxSpinner = new JSpinner(new SpinnerNumberModel( - angleCheck != null ? angleCheck.getMaxAngleDeg() : 170.0, 90.0, 180.0, 5.0)); + angleCheck != null ? angleCheck.getMaxAngleDeg() : 170.0, 0.0, 180.0, 5.0)); branchAngleMaxCheckbox.addActionListener(e -> { if (angleCheck != null) angleCheck.setEnabled( branchAngleMinCheckbox.isSelected() || branchAngleMaxCheckbox.isSelected()); @@ -709,6 +710,7 @@ private JPanel buildLiveParamsPanel() { }); branchAngleMaxSpinner.addChangeListener(e -> { if (angleCheck != null) angleCheck.setMaxAngleDeg((Double) branchAngleMaxSpinner.getValue()); + coupleBranchAngleBounds(); }); final JButton branchAngleMaxHist = (angleCheck == null) ? null : CurationHistograms.button("Fork angle", null, @@ -718,6 +720,7 @@ private JPanel buildLiveParamsPanel() { () -> ((Number) branchAngleMinSpinner.getValue()).doubleValue(), CurationHistograms.Side.OUTSIDE_FLAGGED, p); addCheckRow(p, c, branchAngleMaxCheckbox, spinnerWithHistogram(branchAngleMaxSpinner, branchAngleMaxHist)); + coupleBranchAngleBounds(); // Direction continuity final PlausibilityCheck.DirectionContinuity dirCheck = @@ -1899,6 +1902,8 @@ private void addCheckRow(final JPanel panel, final GridBagConstraints c, private JComponent spinnerWithHistogram(final JSpinner spinner, final JButton histButton) { tightenSpinner(spinner); if (histButton == null) return spinner; + // Listeners fire last-added first: commit pending typed text before the plot reads values + histButton.addActionListener(e -> commitSpinnerEdit(spinner)); final JPanel p = new JPanel(new BorderLayout(0, 0)); p.setOpaque(false); p.add(spinner, BorderLayout.CENTER); @@ -1912,11 +1917,33 @@ private JComponent spinnerWithHistogram(final JSpinner spinner, final JButton hi * default of ~9 columns wastes horizontal space and pushes the checkbox * label into truncation. */ + /** Keeps min <= max: each fork angle spinner is bounded by the other's value */ + private void coupleBranchAngleBounds() { + if (branchAngleMinSpinner == null || branchAngleMaxSpinner == null) return; + if (branchAngleMinSpinner.getModel() instanceof SpinnerNumberModel minModel + && branchAngleMaxSpinner.getModel() instanceof SpinnerNumberModel maxModel) { + minModel.setMaximum(((Number) maxModel.getValue()).doubleValue()); + maxModel.setMinimum(((Number) minModel.getValue()).doubleValue()); + } + } + + private static void commitSpinnerEdit(final JSpinner spinner) { + try { + spinner.commitEdit(); + } catch (final java.text.ParseException ignored) { + // invalid text: keep last valid value + } + } + private static void tightenSpinner(final JSpinner spinner) { if (spinner == null) return; final JComponent editor = spinner.getEditor(); if (editor instanceof JSpinner.DefaultEditor de) { de.getTextField().setColumns(4); + // Commit typed values as soon as they are valid: the histogram and + // Scan buttons are non-focusable, so no focus-lost commit happens + if (de.getTextField().getFormatter() instanceof javax.swing.text.DefaultFormatter df) + df.setCommitsOnValidEdit(true); } } diff --git a/src/main/java/sc/fiji/snt/SNT.java b/src/main/java/sc/fiji/snt/SNT.java index 303cb1032..7fddb2b08 100644 --- a/src/main/java/sc/fiji/snt/SNT.java +++ b/src/main/java/sc/fiji/snt/SNT.java @@ -5237,9 +5237,12 @@ protected void updateTracingViewers(final boolean includeLegacy3Dviewer, final b } } if (getUI() != null && getUI().bdvSNT != null) { - // Bdv has no updateSelection()-equivalent yet (unlike Bvv): always do a full - // rebuild, even for selectionOnly changes. Hopefully this is cheap enough - new Thread(() -> getUI().bdvSNT.syncPathManagerList()).start(); + if (selectionOnly) { + // Same rationale as for Bvv above: run on the EDT so the repaint is flushed immediately + SwingUtilities.invokeLater(() -> getUI().bdvSNT.updateSelection()); + } else { + new Thread(() -> getUI().bdvSNT.syncPathManagerList()).start(); + } } if (includeLegacy3Dviewer) update3DViewerContents(); } diff --git a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java index 5ee48293e..7b3916290 100644 --- a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java +++ b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java @@ -3177,13 +3177,8 @@ protected abstract class AbstractTracer extends MouseAdapter { // by the time mouseReleased(MouseEvent) fires, silently dropping a fork request (see #handleClick) private boolean pressAltDown, pressShiftDown; - // Screen-space pick radius for snapping a click to a fork point (see #handleClick). It gets - // converted to a world-space distance via pickRadiusWorld() so it stays perceptually constant - // across zoom levels. Safe to keep generous: the search that consumes this is a single path - // (findNearestRenderedPath()), so a large radius can only ever match somewhere along that one - // path. Manually-placed tracing waypoints can be much sparser than densely auto-traced ones: a - // click needs to land within this radius of *some* node for a fork point to be found at all in - // the first place (see NearPoint#getInsertionIndex()) + // Screen-space pick radius for snapping a click to a fork point (see #findForkHit). Also converted + // to a world-space distance via pickRadiusWorld() for the "Continue Extending" end-point tolerance private static final double FORK_POINT_PICK_RADIUS_SCREEN_PX = 150; // Wall-clock time (System.currentTimeMillis()) until which trace clicks are deferred because a @@ -3311,40 +3306,36 @@ private void handleClick(final MouseEvent e) { // modifier is an explicit request to attach to an existing branch, so if that request // can't be honored, the click should be a clean no-op showViewerMessage("Forking..."); - // Auto-select the rendered path nearest the click, then restrict the fork-point search - // to just that one path, instead of scanning every path in the project - final Path nearestPath = findNearestRenderedPath(cc1); - // Pick radius is defined in screen pixels (see pickRadiusWorld()) rather than a fixed - // world-space distance, so "close enough to fork" stays consistent across zoom levels - final double pickRadius = pickRadiusWorld(FORK_POINT_PICK_RADIUS_SCREEN_PX); - final NearPoint nearPoint = (nearestPath == null) ? null : snt.getPathAndFillManager() - .nearestPointOnAnyPath(Collections.singletonList(nearestPath), cc1[0], cc1[1], cc1[2], pickRadius); - if (nearPoint == null) { - final String detail = (nearestPath == null) ? "No paths are currently rendered to fork from." - : String.format("No point within %.2f%s of the cursor was found on the nearest path ('%s').", - pickRadius, calUnit, nearestPath.getName()); + // Fork point search happens in viewer (screen) space: depth along the viewing axis is ignored, + // since the picked 3D position (slice cursor in BDV, ray-max in BVV) rarely lies exactly on the + // path that is visibly under the cursor. A 3D distance would reject such clicks + final ForkHit hit = findForkHit(cc1, FORK_POINT_PICK_RADIUS_SCREEN_PX); + if (hit == null) { final String mod = (renderingOptions.requireShiftToFork) ? "Alt+Shift" : "Alt"; - getGuiUtils().error("" + detail - + "
    Move closer to the target path, or click without " + mod + " to start an unconnected path.", - "No Fork Point Found"); + getGuiUtils().error(String.format("No rendered path within %.0f screen pixels of the cursor." + + "
    Move closer to the target path, or click without %s to start an unconnected path.", + FORK_POINT_PICK_RADIUS_SCREEN_PX, mod), "No Fork Point Found"); return; } + final Path nearestPath = hit.path(); // Fork point found: only now commit to starting the path snt.selectPath(nearestPath, false); previousNode = new Path.PathNode(cc1); highlightClickedLocation(previousNode, true); - // Prefer the interpolated point on the path's line segments over the nearest raw node: - // manually-placed waypoints can be sparser than pickRadius, so the closest actual node - // may sit well past it even when the click lands right on the path itself. Materialize - // that position as a real node so the branch point is geometrically accurate and the - // parent path visibly gains a node at the fork location - final int insertionIndex = nearPoint.getInsertionIndex(); - if (insertionIndex < 0) { - previousForkPoint = nearPoint.getNode(); // already an existing node + // Materialize the closest position on the path's line segments as a real node, so that the + // branch point is geometrically accurate even when waypoints are sparse + if (hit.t() <= 1e-6) { + previousForkPoint = nearestPath.getNode(hit.segStart()); + } else if (hit.t() >= 1 - 1e-6) { + previousForkPoint = nearestPath.getNode(hit.segStart() + 1); } else { - nearestPath.insertNode(insertionIndex, nearPoint.getClosestIntersectionPoint()); - previousForkPoint = nearestPath.getNode(insertionIndex); + final Path.PathNode n1 = nearestPath.getNode(hit.segStart()); + final Path.PathNode n2 = nearestPath.getNode(hit.segStart() + 1); + final double t = hit.t(); + nearestPath.insertNode(hit.segStart() + 1, new PointInImage(n1.x + t * (n2.x - n1.x), + n1.y + t * (n2.y - n1.y), n1.z + t * (n2.z - n1.z))); + previousForkPoint = nearestPath.getNode(hit.segStart() + 1); syncOverlays(); // so the new node is reflected next repaint, not just on the next full sync } return; @@ -3585,6 +3576,116 @@ protected double pickRadiusWorld(final double screenPx) { return (screenPxPerWorldUnit > 0) ? screenPx / screenPxPerWorldUnit : screenPx; } + // Screen-space distance within which two fork-point candidates are considered equally close, in which + // case the one nearer the click's depth (viewer Z) wins (see #findForkHit) + private static final double FORK_POINT_TIE_TOLERANCE_SCREEN_PX = 3; + + private record ForkHit(Path path, int segStart, double t, double distPx, double absDz) { + boolean isBetterThan(final ForkHit other) { + if (other == null) return true; + if (Math.abs(distPx - other.distPx) <= FORK_POINT_TIE_TOLERANCE_SCREEN_PX) + return absDz < other.absDz; + return distPx < other.distPx; + } + } + + /** + * Finds the point on any rendered path closest to a world position in viewer (screen) space, + * i.e., ignoring depth along the viewing axis. Distances are in screen pixels + * + * @param worldPos calibrated (x,y,z) position + * @param maxPx maximum accepted distance, in screen pixels + * @return the closest hit (segment start index and parametric position along that segment), + * or {@code null} if nothing is within {@code maxPx} + */ + private ForkHit findForkHit(final double[] worldPos, final double maxPx) { + final AffineTransform3D tf = getViewerTransform(); + if (tf == null) return null; + final double[] c = new double[3]; + tf.apply(worldPos, c); + ForkHit best = null; + final double[] a = new double[3]; + final double[] b = new double[3]; + final double[] w = new double[3]; + for (final Tree tree : getRenderedTrees()) { + for (final Path path : tree.list()) { + if (!path.isActiveVersion() || path.size() == 0) continue; + Path.PathNode node = path.getNode(0); + w[0] = node.x; w[1] = node.y; w[2] = node.z; + tf.apply(w, a); + if (path.size() == 1) { + final double d = Math.hypot(a[0] - c[0], a[1] - c[1]); + if (d <= maxPx) { + final ForkHit h = new ForkHit(path, 0, 0, d, Math.abs(a[2] - c[2])); + if (h.isBetterThan(best)) best = h; + } + continue; + } + for (int i = 1; i < path.size(); i++) { + node = path.getNode(i); + w[0] = node.x; w[1] = node.y; w[2] = node.z; + tf.apply(w, b); + final double dx = b[0] - a[0]; + final double dy = b[1] - a[1]; + final double len2 = dx * dx + dy * dy; + double t = (len2 > 0) ? ((c[0] - a[0]) * dx + (c[1] - a[1]) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + final double d = Math.hypot(a[0] + t * dx - c[0], a[1] + t * dy - c[1]); + if (d <= maxPx) { + final double dz = Math.abs(a[2] + t * (b[2] - a[2]) - c[2]); + final ForkHit h = new ForkHit(path, i - 1, t, d, dz); + if (h.isBetterThan(best)) best = h; + } + System.arraycopy(b, 0, a, 0, 3); + } + } + } + return best; + } + + private sc.fiji.snt.analysis.curation.PlausibilityMonitor liveMonitor() { + final sc.fiji.snt.SNTUI ui = snt.getUI(); + if (ui == null) return null; + final sc.fiji.snt.analysis.curation.PlausibilityMonitor m = ui.getPlausibilityMonitor(); + return (m != null && m.isEnabled()) ? m : null; + } + + private void clearLiveCheckContext() { + final sc.fiji.snt.SNTUI ui = snt.getUI(); + if (ui != null) ui.getPlausibilityMonitor().clearForkContext(); + } + + /** + * Mirrors SNT's classic hooks (fork initiated + segment completed) for the first segment of a + * new path: a forked segment already carries its parent path and branch point index + */ + private void runLiveChecksOnFirstSegment(final Path first) { + final sc.fiji.snt.analysis.curation.PlausibilityMonitor m = liveMonitor(); + if (m == null) return; + final Path parent = first.getParentPath(); + if (parent == null) { + m.clearForkContext(); + return; + } + m.onForkInitiated(parent, first.getBranchPointIndex()); + reportLiveWarnings(m.onSegmentCompleted(first)); + } + + /** Mirrors SNT's classic path finalization hook */ + private void runLiveChecksOnFinishedPath(final Path finished) { + final sc.fiji.snt.analysis.curation.PlausibilityMonitor m = liveMonitor(); + if (m == null) return; + if (finished.getParentPath() == null) { + m.clearForkContext(); + return; + } + reportLiveWarnings(m.onPathFinalized(finished)); + } + + private void reportLiveWarnings(final java.util.List w) { + if (w != null && !w.isEmpty()) showViewerMessage("Warning: " + w.getFirst().message()); + } + /** * Commits {@code tempPath} to the Path Manager and resets tracing state. Called directly * from {@code mouseReleased} when no segment is in flight, or deferred from {@code done()} @@ -3619,6 +3720,7 @@ private void finishPath() { snt.getPathAndFillManager().addPath(tempPath); if (renderingOptions.activateFinishedPath) snt.selectPath(tempPath, false); showViewerMessage("Path finished"); + runLiveChecksOnFinishedPath(tempPath); } clearTracingOverlayContents(); syncPathManagerList(); @@ -3708,6 +3810,7 @@ protected void discardCurrentPath() { setToBeCombinedPath(null); // discard a pending "Continue Extending" request too, if any previousNode = null; tempPath = null; + clearLiveCheckContext(); pendingFinish = false; computing = false; confirmedSegmentSizes.clear(); @@ -3775,6 +3878,7 @@ protected void done() { // so the in-progress segment visually pairs with the path it will extend if (toBeCombinedPath != null) tempPath.setSelected(true); pushConfirmedSegmentSize(result.size()); + runLiveChecksOnFirstSegment(result); } else { final int sizeBefore = tempPath.size(); tempPath.add(result); @@ -3930,9 +4034,11 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { } private void highlightClickedLocation(final Path.PathNode node, final boolean highlightAsForkPoint) { + // Bdv returns null here (bookmarks use their own color cycle) + final Color base = (getDefaultMarkerColor() != null) ? getDefaultMarkerColor() : Color.YELLOW; ensureTracingOverlay().addAnnotation(node, (highlightAsForkPoint) ? getDefaultMarkerSize() * 2 : getDefaultMarkerSize(), - (highlightAsForkPoint) ? SNTColor.contrastHueColor(getDefaultMarkerColor(), Color.BLACK) : getDefaultMarkerColor()); + (highlightAsForkPoint) ? SNTColor.contrastHueColor(base, Color.BLACK) : base); } private void drawSegment(final Path segment) { diff --git a/src/main/java/sc/fiji/snt/viewer/Bdv.java b/src/main/java/sc/fiji/snt/viewer/Bdv.java index 692388aa2..402cec91b 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bdv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bdv.java @@ -772,6 +772,18 @@ public void resyncCalibrationFromActiveSource() { if (tracer != null) tracer.syncChannelFromActiveSource(); } + /** + * Lightweight alternative to {@link #syncPathManagerList()} for pure selection changes: patches + * color/thickness in place for already-rendered trees. Mirrors {@link Bvv#updateSelection()} + * + * @throws IllegalArgumentException if this viewer is not tethered to a SNT instance + */ + public void updateSelection() { + if (snt == null) + throw new IllegalArgumentException("This function is only available in snt-aware Bdv instances"); + if (pathOverlay != null) pathOverlay.recolor(); + } + @Override public void syncOverlays() { if (pathOverlay != null) pathOverlay.updatePaths(); diff --git a/src/main/java/sc/fiji/snt/viewer/Bvv.java b/src/main/java/sc/fiji/snt/viewer/Bvv.java index 7c5c48b2b..0db097ae7 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bvv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bvv.java @@ -4218,12 +4218,17 @@ public void addAnnotations(final Collection points, final float radius final List list = layer(DEFAULT_LAYER); for (SNTPoint p : points) { final Color pColor = (p instanceof Path.PathNode pn) ? pn.getColor() : null; - list.add(new Annotation(p, radius, (pColor == null) ? color : pColor)); + list.add(new Annotation(p, radius, nonNull((pColor == null) ? color : pColor))); } } updateScene(); } + /** Annotations must have a color: null falls back to the rendering fallback color */ + private Color nonNull(final Color c) { + return (c != null) ? c : renderingOptions.fallbackColor; + } + /** * Add a single annotation. */ @@ -4233,7 +4238,7 @@ public void addAnnotation(final SNTPoint p, final float radius, final Color colo @Override public void addAnnotation(final Object owner, final SNTPoint p, final float radius, final Color color) { - if (p != null) layer(owner).add(new Annotation(p, radius, color)); + if (p != null) layer(owner).add(new Annotation(p, radius, nonNull(color))); updateScene(); } From 8e50ba4641c5d048b049d75091cd4c5e188d8b1c Mon Sep 17 00:00:00 2001 From: Tiago Ferreira Date: Sun, 11 Oct 2026 21:46:54 -0400 Subject: [PATCH 3/3] Improve consistency of contextual menus between viewers --- .../java/sc/fiji/snt/CurationManager.java | 26 +-- .../sc/fiji/snt/InteractiveTracerCanvas.java | 50 ++---- .../sc/fiji/snt/gui/ContextMenuCommand.java | 4 + src/main/java/sc/fiji/snt/gui/GuiUtils.java | 61 +++++++ .../java/sc/fiji/snt/gui/NodeEditCommand.java | 1 - .../sc/fiji/snt/viewer/AbstractBigViewer.java | 170 ++++++++++++------ src/main/java/sc/fiji/snt/viewer/Bdv.java | 1 + .../sc/fiji/snt/viewer/BigViewerEditor.java | 28 ++- .../sc/fiji/snt/viewer/BigViewerMenu.java | 20 ++- src/main/java/sc/fiji/snt/viewer/Bvv.java | 1 + 10 files changed, 234 insertions(+), 128 deletions(-) diff --git a/src/main/java/sc/fiji/snt/CurationManager.java b/src/main/java/sc/fiji/snt/CurationManager.java index c92d2cef6..19c1f1af9 100644 --- a/src/main/java/sc/fiji/snt/CurationManager.java +++ b/src/main/java/sc/fiji/snt/CurationManager.java @@ -1902,8 +1902,6 @@ private void addCheckRow(final JPanel panel, final GridBagConstraints c, private JComponent spinnerWithHistogram(final JSpinner spinner, final JButton histButton) { tightenSpinner(spinner); if (histButton == null) return spinner; - // Listeners fire last-added first: commit pending typed text before the plot reads values - histButton.addActionListener(e -> commitSpinnerEdit(spinner)); final JPanel p = new JPanel(new BorderLayout(0, 0)); p.setOpaque(false); p.add(spinner, BorderLayout.CENTER); @@ -1911,12 +1909,6 @@ private JComponent spinnerWithHistogram(final JSpinner spinner, final JButton hi return p; } - /** - * Reduces a spinner's editor to a compact column count. All curation - * spinners hold short numeric values (typically 3-5 characters); the - * default of ~9 columns wastes horizontal space and pushes the checkbox - * label into truncation. - */ /** Keeps min <= max: each fork angle spinner is bounded by the other's value */ private void coupleBranchAngleBounds() { if (branchAngleMinSpinner == null || branchAngleMaxSpinner == null) return; @@ -1927,23 +1919,17 @@ private void coupleBranchAngleBounds() { } } - private static void commitSpinnerEdit(final JSpinner spinner) { - try { - spinner.commitEdit(); - } catch (final java.text.ParseException ignored) { - // invalid text: keep last valid value - } - } - + /** + * Reduces a spinner's editor to a compact column count. All curation + * spinners hold short numeric values (typically 3-5 characters); the + * default of ~9 columns wastes horizontal space and pushes the checkbox + * label into truncation. + */ private static void tightenSpinner(final JSpinner spinner) { if (spinner == null) return; final JComponent editor = spinner.getEditor(); if (editor instanceof JSpinner.DefaultEditor de) { de.getTextField().setColumns(4); - // Commit typed values as soon as they are valid: the histogram and - // Scan buttons are non-focusable, so no focus-lost commit happens - if (de.getTextField().getFormatter() instanceof javax.swing.text.DefaultFormatter df) - df.setCommitsOnValidEdit(true); } } diff --git a/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java b/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java index 0a837736d..662e870df 100644 --- a/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java +++ b/src/main/java/sc/fiji/snt/InteractiveTracerCanvas.java @@ -385,35 +385,13 @@ private void updateConnectToSecondaryEditingPathMenuItem() { } private JMenuItem helpOnConnectingMenuItem() { - final String msg = "To connect two paths in Edit Mode:
      " + - "
    1. Press Shift+E to enter Edit Mode
    2. " + - "
    3. Select the parent path using G (Group paths around cursor)
    4. " + - "
    5. Hover over the node where the child should branch from
    6. " + - "
    7. Press G to switch to the child path
    8. " + - "
    9. Select the child's connection node (typically a start or end node)
    10. " + - "
    11. Press C to Connect
    12. " + - "
    Notes:
      " + - "
    • A confirmation dialog shows the parent/child assignment with option to swap roles
    • " + - "
    • Child paths are automatically re-oriented to maintain proper tree structure
    • " + - "
    • If connection points are distant, a bridging segment is created automatically
    • " + - "
    • If child's selected node is mid-path, a T-junction is created
    • " + - "
    • Loop-forming connections are not allowed
    • " + - "
    • To concatenate or combine paths end-to-end, use Path Manager's Edit menu
    • " + - "
    "; - final JMenuItem helpItem = new JMenuItem(AListener.NODE_CONNECT_HELP); - helpItem.addActionListener(e -> { - final boolean canvasActivationState = tracerPlugin.getPrefs().isCanvasAutoActivationEnabled(); - tracerPlugin.getPrefs().setCanvasAutoActivation(false); // this will not update the checkbox state in SNTUI, but - // ensures the help dialog will maintain its frontmost state - getGuiUtils().showHTMLDialog(msg, AListener.NODE_CONNECT_HELP, false) - .addWindowListener(new WindowAdapter() { - @Override - public void windowClosing(final WindowEvent e) { - tracerPlugin.getPrefs().setCanvasAutoActivation(canvasActivationState); - } - }); - }); - return helpItem; + final boolean[] canvasActivationState = new boolean[1]; + return GuiUtils.MenuItems.connectHelp(this::getGuiUtils, () -> { + canvasActivationState[0] = tracerPlugin.getPrefs().isCanvasAutoActivationEnabled(); + // this will not update the checkbox state in SNTUI, but ensures the help dialog will maintain its + // frontmost state + tracerPlugin.getPrefs().setCanvasAutoActivation(false); + }, () -> tracerPlugin.getPrefs().setCanvasAutoActivation(canvasActivationState[0])); } /** Menu item for a command shared with the Bdv/Bvv context menu: label and icon come from the enum */ @@ -1454,7 +1432,6 @@ protected void toggleEditMode() { toggleEditModeMenuItem.doClick(); } - private void toggleKeyWarning(final String key, final String msg) { final Timer timer = new Timer(500, ae -> { // Restore state after delay @@ -1473,12 +1450,7 @@ private void toggleKeyWarning(final String key, final String msg) { PathNodeCanvas.setShowDirectionArrows(true); tracerPlugin.repaintAllPanes(); } - // Show tip if not suppressed - if (!tracerPlugin.getPrefs().getTemp("key-skipnag" + key, false)) { - final Boolean skipNag = getGuiUtils().getPersistentWarning( - "Tip: Hold \"" + key + "\" to " + msg + ".", "Keyboard Operation"); - if (skipNag != null) tracerPlugin.getPrefs().setTemp("key-skipnag" + key, skipNag); - } + getGuiUtils().showKeyTip(tracerPlugin.getPrefs(), key, msg); timer.start(); } @@ -1522,7 +1494,7 @@ private class AListener implements ActionListener, ItemListener { private final static String INDENT = NodeEditCommand.MENU_INDENT; private final static String NODE_MOVE_Z = INDENT + "Bring Active Node to Current Z-plane"; private final static String NODE_PAINT = INDENT + "Paint Mode"; - private final static String NODE_CONNECT_HELP = INDENT + "Connect to Help..."; + private final static String NODE_CONNECT_HELP = GuiUtils.MenuItems.CONNECT_HELP_LABEL; private final static String NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX = INDENT + "Connect to "; private final static String NODE_CONNECT_TO_PREV_EDITING_PATH_PLACEHOLDER = NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX + "..."; @@ -1604,10 +1576,10 @@ private void handleExtendPath() { private boolean handleGeneralCommands(final String command, final ActionEvent e) { switch (command) { case SHOW_ARROWS: - toggleKeyWarning("O", "show path orientation (start → end)"); + toggleKeyWarning("O", ContextMenuCommand.ORIENTATION_KEY_TIP); return true; case HIDE_ALL: - toggleKeyWarning("H", "temporarily hide paths"); + toggleKeyWarning("H", ContextMenuCommand.HIDE_KEY_TIP); return true; case BOOKMARK_CURSOR: bookmarkCursorLocation(); diff --git a/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java b/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java index fdf80e4c5..ec5e72d01 100644 --- a/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java +++ b/src/main/java/sc/fiji/snt/gui/ContextMenuCommand.java @@ -51,6 +51,10 @@ public enum ContextMenuCommand { PAUSE_TRACING(Labels.PAUSE_TRACING, '\uf04c', true, false), PAUSE_SNT(Labels.PAUSE_SNT, IconFactory.GLYPH.PAUSE2); + /** Hold-key descriptions used by {@link GuiUtils#showKeyTip(sc.fiji.snt.SNTPrefs, String, String)} */ + public static final String HIDE_KEY_TIP = "temporarily hide paths"; + public static final String ORIENTATION_KEY_TIP = "show path orientation (start \u2192 end)"; + /** The labels as compile-time constants, for front-ends that dispatch on them (e.g., in a {@code switch}) */ public static final class Labels { public static final String GRAB = "Grab Nearest Path"; diff --git a/src/main/java/sc/fiji/snt/gui/GuiUtils.java b/src/main/java/sc/fiji/snt/gui/GuiUtils.java index 59c038e69..0b0c29949 100644 --- a/src/main/java/sc/fiji/snt/gui/GuiUtils.java +++ b/src/main/java/sc/fiji/snt/gui/GuiUtils.java @@ -872,6 +872,21 @@ public boolean[] getConfirmationAndOption(final String msg, final String title, } + /** + * Displays the "Hold [key] to..." keyboard tip (shared by the InteractiveTracerCanvas and Bdv/Bvv context + * menus), unless the user opted out of it. The opt-out is remembered for the session + * + * @param prefs the preferences holding the opt-out flag + * @param key the key to be held, e.g., "H" + * @param msg what the key does, e.g., "temporarily hide paths" + */ + public void showKeyTip(final SNTPrefs prefs, final String key, final String msg) { + final String pref = "key-skipnag" + key; + if (prefs.getTemp(pref, false)) return; + final Boolean skipNag = getPersistentWarning("Tip: Hold \"" + key + "\" to " + msg + ".", "Keyboard Operation"); + if (skipNag != null) prefs.setTemp(pref, skipNag); + } + /** * Displays a dialog with radio button choices, optional info text, and an optional checkbox. * Useful for operations that require a selection with additional context and options. @@ -3576,6 +3591,52 @@ public void windowDeactivated(final WindowEvent e) { /** Utility methods for building and configuring {@link JMenuItem}/{@link JMenu} entries. */ public static class MenuItems { + /** Label of the item returned by {@link #connectHelp(Supplier, Runnable, Runnable)} */ + public static final String CONNECT_HELP_LABEL = NodeEditCommand.MENU_INDENT + "Connect to Help..."; + + private static final String CONNECT_HELP_HTML = "To connect two paths in Edit Mode:
      " + + "
    1. Press Shift+E to enter Edit Mode
    2. " + + "
    3. Select the parent path using G (Grab path around cursor)
    4. " + + "
    5. Hover over the node where the child should branch from
    6. " + + "
    7. Press G to switch to the child path
    8. " + + "
    9. Select the child's connection node (typically a start or end node)
    10. " + + "
    11. Press C to Connect
    12. " + + "
    Notes:
      " + + "
    • A confirmation dialog shows the parent/child assignment with option to swap roles
    • " + + "
    • Child paths are automatically re-oriented to maintain proper tree structure
    • " + + "
    • If connection points are distant, a bridging segment is created automatically
    • " + + "
    • If child's selected node is mid-path, a T-junction is created
    • " + + "
    • Loop-forming connections are not allowed
    • " + + "
    • To concatenate or combine paths end-to-end, use Path Manager's Edit menu
    • " + + "
    "; + + /** + * Menu item that opens the "how to connect two paths in Edit Mode" help dialog (shared by the + * InteractiveTracerCanvas and Bdv/Bvv context menus) + * + * @param gui supplies the GuiUtils used to display the dialog (evaluated when the item is activated) + * @param onOpen optional task run right before the dialog is displayed (may be null) + * @param onClose optional task run when the dialog is closed (may be null) + * @return the menu item + */ + public static JMenuItem connectHelp(final Supplier gui, final Runnable onOpen, + final Runnable onClose) { + final JMenuItem helpItem = new JMenuItem(CONNECT_HELP_LABEL); + helpItem.addActionListener(e -> { + if (onOpen != null) onOpen.run(); + final JDialog dialog = gui.get().showHTMLDialog(CONNECT_HELP_HTML, CONNECT_HELP_LABEL, false); + if (onClose != null) { + dialog.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(final WindowEvent we) { + onClose.run(); + } + }); + } + }); + return helpItem; + } + public static void addSeparator(final JPopupMenu menu, final String header) { final JLabel label = GuiUtils.leftAlignedLabel(header, false); if (menu.getComponentCount() > 0) menu.addSeparator(); diff --git a/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java b/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java index ef16ba8d4..89ebcbdba 100644 --- a/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java +++ b/src/main/java/sc/fiji/snt/gui/NodeEditCommand.java @@ -178,7 +178,6 @@ public static void addMenuItems(final JPopupMenu menu, final MenuState state, final Consumer handler) { for (final NodeEditCommand cmd : values()) { if (cmd == UNDO) continue; // shared with the tracing 'Undo Last Segment' entry (mode-aware) - if (cmd == RADIUS || cmd == SET_ROOT) menu.addSeparator(); final JMenuItem item = cmd.createMenuItem(e -> handler.accept(cmd)); item.setText(MENU_INDENT + cmd.label(state.nodeLocked())); item.setEnabled(state.isEnabled(cmd)); diff --git a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java index 7b3916290..1dff5295e 100644 --- a/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java +++ b/src/main/java/sc/fiji/snt/viewer/AbstractBigViewer.java @@ -142,6 +142,11 @@ public void onSntEditModeChanged(final boolean enabled) { editor.onSntEditModeChanged(enabled); } + /** Re-evaluates the Edit Mode markers after a selection change (they are only shown for a selected path) */ + protected void refreshEditNodesOnSelectionChange() { + if (editor != null && editor.isActive()) editor.refreshNodes(); + } + /** Called by SNT when nodes of the path being edited were modified in place by another front-end */ public void onSntEditedPathModified() { if (!SwingUtilities.isEventDispatchThread()) { @@ -3263,21 +3268,7 @@ private void handleClick(final MouseEvent e) { } if (previousNode == null) { - // If a background A* batch re-trace is running (PathManagerUI's "Re-trace with A*..."), - // its workers are reading this SNT instance's shared image-data fields concurrently. - // Refuse to start a new path on a *different* channel/frame than the batch is using. - // That would trigger the resync below and race with it - assert snt != null; - final int[] batchLock = snt.getBatchRetraceChannelFrame(); - if (batchLock != null) { - final int[] candidate = peekActiveChannelFrame(); - if (candidate == null || candidate[0] != batchLock[0] || candidate[1] != batchLock[1]) { - getGuiUtils().error("An A* batch re-trace is running on channel " - + batchLock[0] + " / frame " + batchLock[1] + ". Starting a new path on a " - + "different channel/frame is disabled until it finishes."); - return; - } - } + if (startNewPathBlocked()) return; confirmedSegmentSizes.clear(); // just in case of abnormal prior state updateUndoButtonState(); @@ -3302,42 +3293,7 @@ private void handleClick(final MouseEvent e) { return; } - // Fork requested: resolve it *before* committing to starting a path at all. The fork - // modifier is an explicit request to attach to an existing branch, so if that request - // can't be honored, the click should be a clean no-op - showViewerMessage("Forking..."); - // Fork point search happens in viewer (screen) space: depth along the viewing axis is ignored, - // since the picked 3D position (slice cursor in BDV, ray-max in BVV) rarely lies exactly on the - // path that is visibly under the cursor. A 3D distance would reject such clicks - final ForkHit hit = findForkHit(cc1, FORK_POINT_PICK_RADIUS_SCREEN_PX); - if (hit == null) { - final String mod = (renderingOptions.requireShiftToFork) ? "Alt+Shift" : "Alt"; - getGuiUtils().error(String.format("No rendered path within %.0f screen pixels of the cursor." - + "
    Move closer to the target path, or click without %s to start an unconnected path.", - FORK_POINT_PICK_RADIUS_SCREEN_PX, mod), "No Fork Point Found"); - return; - } - final Path nearestPath = hit.path(); - - // Fork point found: only now commit to starting the path - snt.selectPath(nearestPath, false); - previousNode = new Path.PathNode(cc1); - highlightClickedLocation(previousNode, true); - // Materialize the closest position on the path's line segments as a real node, so that the - // branch point is geometrically accurate even when waypoints are sparse - if (hit.t() <= 1e-6) { - previousForkPoint = nearestPath.getNode(hit.segStart()); - } else if (hit.t() >= 1 - 1e-6) { - previousForkPoint = nearestPath.getNode(hit.segStart() + 1); - } else { - final Path.PathNode n1 = nearestPath.getNode(hit.segStart()); - final Path.PathNode n2 = nearestPath.getNode(hit.segStart() + 1); - final double t = hit.t(); - nearestPath.insertNode(hit.segStart() + 1, new PointInImage(n1.x + t * (n2.x - n1.x), - n1.y + t * (n2.y - n1.y), n1.z + t * (n2.z - n1.z))); - previousForkPoint = nearestPath.getNode(hit.segStart() + 1); - syncOverlays(); // so the new node is reflected next repaint, not just on the next full sync - } + beginFork(cc1); return; } @@ -3604,6 +3560,7 @@ private ForkHit findForkHit(final double[] worldPos, final double maxPx) { final double[] c = new double[3]; tf.apply(worldPos, c); ForkHit best = null; + final double maxPxSq = maxPx * maxPx; // compare squared distances: sqrt only for actual candidates final double[] a = new double[3]; final double[] b = new double[3]; final double[] w = new double[3]; @@ -3614,9 +3571,9 @@ private ForkHit findForkHit(final double[] worldPos, final double maxPx) { w[0] = node.x; w[1] = node.y; w[2] = node.z; tf.apply(w, a); if (path.size() == 1) { - final double d = Math.hypot(a[0] - c[0], a[1] - c[1]); - if (d <= maxPx) { - final ForkHit h = new ForkHit(path, 0, 0, d, Math.abs(a[2] - c[2])); + final double d2 = (a[0] - c[0]) * (a[0] - c[0]) + (a[1] - c[1]) * (a[1] - c[1]); + if (d2 <= maxPxSq) { + final ForkHit h = new ForkHit(path, 0, 0, Math.sqrt(d2), Math.abs(a[2] - c[2])); if (h.isBetterThan(best)) best = h; } continue; @@ -3630,10 +3587,12 @@ private ForkHit findForkHit(final double[] worldPos, final double maxPx) { final double len2 = dx * dx + dy * dy; double t = (len2 > 0) ? ((c[0] - a[0]) * dx + (c[1] - a[1]) * dy) / len2 : 0; t = Math.max(0, Math.min(1, t)); - final double d = Math.hypot(a[0] + t * dx - c[0], a[1] + t * dy - c[1]); - if (d <= maxPx) { + final double ex = a[0] + t * dx - c[0]; + final double ey = a[1] + t * dy - c[1]; + final double d2 = ex * ex + ey * ey; + if (d2 <= maxPxSq) { final double dz = Math.abs(a[2] + t * (b[2] - a[2]) - c[2]); - final ForkHit h = new ForkHit(path, i - 1, t, d, dz); + final ForkHit h = new ForkHit(path, i - 1, t, Math.sqrt(d2), dz); if (h.isBetterThan(best)) best = h; } System.arraycopy(b, 0, a, 0, 3); @@ -4033,6 +3992,103 @@ public void actionPerformed(final java.awt.event.ActionEvent e) { }; } + /** + * Whether starting a new path is currently disallowed: a background A* batch re-trace is reading + * this SNT instance's shared image-data fields, and the active channel/frame differs from the one + * it uses. An error message is displayed in that case + */ + private boolean startNewPathBlocked() { + assert snt != null; + // If a background A* batch re-trace is running (PathManagerUI's "Re-trace with A*..."), + // its workers are reading this SNT instance's shared image-data fields concurrently. + // Refuse to start a new path on a *different* channel/frame than the batch is using. + // That would trigger the resync below and race with it + final int[] batchLock = snt.getBatchRetraceChannelFrame(); + if (batchLock != null) { + final int[] candidate = peekActiveChannelFrame(); + if (candidate == null || candidate[0] != batchLock[0] || candidate[1] != batchLock[1]) { + getGuiUtils().error("An A* batch re-trace is running on channel " + + batchLock[0] + " / frame " + batchLock[1] + ". Starting a new path on a " + + "different channel/frame is disabled until it finishes."); + return true; + } + } + return false; + } + + /** + * Starts a new path forked off the rendered path closest to a (calibrated) position, or displays + * an error if there is none + */ + private void beginFork(final double[] cc1) { + // Fork requested: resolve it *before* committing to starting a path at all. The fork modifier is an + // explicit request to attach to an existing branch, so if that request can't be honored, the click should + // be a clean no-op + showViewerMessage("Forking..."); + // Fork point search happens in viewer (screen) space: depth along the viewing axis is ignored, + // since the picked 3D position (slice cursor in BDV, ray-max in BVV) rarely lies exactly on the + // path that is visibly under the cursor. A 3D distance would reject such clicks + final ForkHit hit = findForkHit(cc1, FORK_POINT_PICK_RADIUS_SCREEN_PX); + if (hit == null) { + getGuiUtils().error(String.format("No rendered path within %.0f pixels of the cursor." + + "
    Move closer to the path you want to branch off.", + FORK_POINT_PICK_RADIUS_SCREEN_PX), "No Fork Point Found"); + return; + } + final Path nearestPath = hit.path(); + + // Fork point found: only now commit to starting the path + snt.selectPath(nearestPath, false); + previousNode = new Path.PathNode(cc1); + highlightClickedLocation(previousNode, true); + // Materialize the closest position on the path's line segments as a real node, so that the + // branch point is geometrically accurate even when waypoints are sparse + if (hit.t() <= 1e-6) { + previousForkPoint = nearestPath.getNode(hit.segStart()); + } else if (hit.t() >= 1 - 1e-6) { + previousForkPoint = nearestPath.getNode(hit.segStart() + 1); + } else { + final Path.PathNode n1 = nearestPath.getNode(hit.segStart()); + final Path.PathNode n2 = nearestPath.getNode(hit.segStart() + 1); + final double t = hit.t(); + nearestPath.insertNode(hit.segStart() + 1, new PointInImage(n1.x + t * (n2.x - n1.x), + n1.y + t * (n2.y - n1.y), n1.z + t * (n2.z - n1.z))); + previousForkPoint = nearestPath.getNode(hit.segStart() + 1); + syncOverlays(); // so the new node is reflected next repaint, not just on the next full sync + } + } + + /** + * Builds the "Fork at nearest point" action for a position captured earlier than the action's + * invocation (e.g., where the context menu was opened). Same outcome as an Alt+Click while tracing + * + * @param worldPos the calibrated position, or null if it could not be resolved + */ + protected AbstractAction getForkAction(final double[] worldPos) { + return new AbstractAction(ContextMenuCommand.FORK.label()) { + @Override + public void actionPerformed(final java.awt.event.ActionEvent e) { + if (snt == null || !snt.isTracingActive() || !tracingEnabled) { + getGuiUtils().error("Tracing functions currently disabled."); + return; + } + if (previousNode != null || computing) { + getGuiUtils().error("Please finish or discard the current path first."); + return; + } + if (worldPos == null) { + showViewerMessage("Could not resolve cursor position"); + return; + } + if (startNewPathBlocked()) return; + confirmedSegmentSizes.clear(); + updateUndoButtonState(); + syncChannelFromActiveSource(); + beginFork(worldPos); + } + }; + } + private void highlightClickedLocation(final Path.PathNode node, final boolean highlightAsForkPoint) { // Bdv returns null here (bookmarks use their own color cycle) final Color base = (getDefaultMarkerColor() != null) ? getDefaultMarkerColor() : Color.YELLOW; diff --git a/src/main/java/sc/fiji/snt/viewer/Bdv.java b/src/main/java/sc/fiji/snt/viewer/Bdv.java index 402cec91b..d9e881418 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bdv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bdv.java @@ -782,6 +782,7 @@ public void updateSelection() { if (snt == null) throw new IllegalArgumentException("This function is only available in snt-aware Bdv instances"); if (pathOverlay != null) pathOverlay.recolor(); + refreshEditNodesOnSelectionChange(); } @Override diff --git a/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java b/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java index d3e4e7292..f15667b1a 100644 --- a/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java +++ b/src/main/java/sc/fiji/snt/viewer/BigViewerEditor.java @@ -278,7 +278,9 @@ void refreshNodes() { final Path p = ed.getPath(); final AbstractBigViewer.AnnotationOverlay ann = viewer.annotations(); if (ann == null) return; - if (p == null) { + // As in the tracing canvas, a deselected editing path is not shown as being edited: the node markers + // use the color of selected paths and would otherwise make it look selected + if (p == null || !p.isSelected()) { ann.clear(NodesLayer.class); ann.clear(ActiveLayer.class); ann.clear(SecondaryLayer.class); @@ -318,7 +320,7 @@ void refreshActiveMarker() { if (ann == null) return; final Path p = ed.getPath(); final int idx = ed.getActiveNode(); - if (p == null || idx < 0 || idx >= p.size()) { + if (p == null || !p.isSelected() || idx < 0 || idx >= p.size()) { ann.clear(ActiveLayer.class); return; } @@ -414,6 +416,7 @@ private void doTagAtCursor() { /** Connects the active node to the active node of the secondary path (the one edited previously) */ private void connect() { + if (editingPathDeselected()) return; report(ed.connectToSecondaryPath(viewer.getGuiUtils(), true)); } @@ -461,6 +464,7 @@ private ActionMap buildActionMap() { * @param tag opens the tag chooser at the appropriate location */ private void run(final NodeEditCommand cmd, final Supplier pos, final Runnable tag) { + if (editingPathDeselected()) return; switch (cmd) { case DELETE -> doDelete(); case INSERT -> doInsert(pos.get()); @@ -474,6 +478,18 @@ private void run(final NodeEditCommand cmd, final Supplier pos, final } } + /** + * Whether the editing path is not selected (e.g., deselected in Path Manager): its node markers are hidden (see + * {@link #refreshNodes()}), so node commands are no-ops rather than acting on a path that is not shown as edited. + * Edit Mode is kept so that the path can be re-selected, or another one grabbed (e.g., when connecting paths) + */ + private boolean editingPathDeselected() { + final Path p = ed.getPath(); + if (p == null || p.isSelected()) return false; + viewer.showViewerMessage("The path being edited is not selected"); + return true; + } + private static AbstractAction action(final String name, final Runnable r) { return new AbstractAction(name) { @Override @@ -516,26 +532,24 @@ void addMenuItems(final JPopupMenu menu, final MouseEvent e, final double[] clic if (picked >= 0) ed.setActiveNode(picked); // no-op if locked. Otherwise keeps the previous active node refreshActiveMarker(); } - final double[] cursor = clickPos; final boolean hasNode = active && ed.getActiveNode() >= 0; final Component source = (Component) e.getSource(); final Path single = snt.getSingleSelectedPath(); final JCheckBoxMenuItem toggle = new JCheckBoxMenuItem(NodeEditCommand.editToggleLabel((single == null) ? null : NodeEditCommand.shortName(single)), active); - toggle.setIcon(IconFactory.menuIcon(IconFactory.GLYPH.PEN)); + IconFactory.assignIcon(toggle, IconFactory.GLYPH.PEN); toggle.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.SHIFT_DOWN_MASK)); final String blocker = (active) ? null : entryBlocker(); toggle.setEnabled(blocker == null); if (blocker != null) toggle.setToolTipText(blocker); toggle.addActionListener(ev -> toggle()); menu.add(toggle); - menu.addSeparator(); NodeEditCommand.addMenuItems(menu, new NodeEditCommand.MenuState(hasNode, active && ed.isActiveNodeLocked(), active && ed.canUndo()), - cmd -> run(cmd, () -> cursor, () -> doTag(source, e.getX(), e.getY()))); + cmd -> run(cmd, () -> clickPos, () -> doTag(source, e.getX(), e.getY()))); // Canvas-only commands - menu.addSeparator(); menu.add(BigViewerMenu.cropOnlyItem(NodeEditCommand.MENU_INDENT + "Bring Active Node to Current Z-plane", KeyEvent.VK_B)); menu.add(connectMenuItem()); + menu.add(GuiUtils.MenuItems.connectHelp(viewer::getGuiUtils, null, null)); menu.add(BigViewerMenu.cropOnlyItem(NodeEditCommand.MENU_INDENT + "Paint Mode", KeyEvent.VK_P)); menu.addPopupMenuListener(popupStateListener); popup = menu; diff --git a/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java b/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java index 3d616bbef..8ad78bc97 100644 --- a/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java +++ b/src/main/java/sc/fiji/snt/viewer/BigViewerMenu.java @@ -121,7 +121,7 @@ private void show(final MouseEvent e) { // Display final JMenuItem hide = item(ContextMenuCommand.HIDE, KeyEvent.VK_H, 0, true, - ev -> viewer.showViewerMessage("Press and hold H to temporarily hide paths and annotations")); + ev -> viewer.getGuiUtils().showKeyTip(snt.getPrefs(), "H", ContextMenuCommand.HIDE_KEY_TIP)); menu.add(hide); menu.add(cropOnlyItem(ContextMenuCommand.ORIENTATION.label(), KeyEvent.VK_O)); menu.addSeparator(); @@ -143,8 +143,15 @@ private void show(final MouseEvent e) { (tracer == null) ? null : tracer.getExtendSelectedPathAction()); if (single != null) extend.setText("Continue Extending " + NodeEditCommand.shortName(single)); menu.add(extend); - final JMenuItem fork = item(ContextMenuCommand.FORK, 0, 0, false, null); - fork.setToolTipText("In Bvv/Bdv: Alt+Click on a path while tracing"); + final JMenuItem fork = item(ContextMenuCommand.FORK, 0, 0, + tracingAvailable && viewer.isTracingEnabled() && hasPaths && !viewer.isPathInProgress(), + (tracer == null) ? null : tracer.getForkAction(clickPos)); + // NB: mouse gestures cannot be Swing accelerators, so the shortcut is documented in the label only + // (same as InteractiveTracerCanvas) + fork.setText(ContextMenuCommand.FORK.label() + (viewer.getRenderingOptions().requireShiftToFork + ? " (or Alt+Shift+Click)" : " (or Alt+Click)")); + fork.setToolTipText("In Bvv/Bdv: " + ((viewer.getRenderingOptions().requireShiftToFork) + ? "Alt+Shift+Click" : "Alt+Click") + " on a path while tracing"); menu.add(fork); menu.addSeparator(); final JMenuItem undo = (editing) @@ -216,7 +223,12 @@ private static JMenuItem item(final String label, final int keyCode, final int m */ static JMenuItem cropOnlyItem(final String label, final int keyCode) { final JMenuItem item = item(label, keyCode, 0, false, null); - IconFactory.assignIcon(item, IconFactory.GLYPH.CROP); + if (label.startsWith(NodeEditCommand.MENU_INDENT)) { + // indented icon needed + IconFactory.assignDoubleIcon(item, null, null, IconFactory.GLYPH.CROP); + } else { + IconFactory.assignIcon(item, IconFactory.GLYPH.CROP); + } item.setToolTipText(CROP_ONLY_TIP); return item; } diff --git a/src/main/java/sc/fiji/snt/viewer/Bvv.java b/src/main/java/sc/fiji/snt/viewer/Bvv.java index 0db097ae7..4d6b1094d 100644 --- a/src/main/java/sc/fiji/snt/viewer/Bvv.java +++ b/src/main/java/sc/fiji/snt/viewer/Bvv.java @@ -2896,6 +2896,7 @@ public void updateSelection() { if (snt == null) throw new IllegalArgumentException("This function is only available in snt-aware Bvv instances"); if (pathOverlay != null) pathOverlay.recolor(); + refreshEditNodesOnSelectionChange(); } /**