Repository navigation
Expand file tree
/
Copy pathInteractiveTracerCanvas.java
More file actions
2181 lines (1956 loc) · 97.3 KB
/
Copy pathInteractiveTracerCanvas.java
File metadata and controls
2181 lines (1956 loc) · 97.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*-
* #%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
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
package sc.fiji.snt;
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.gui.GuiUtils;
import sc.fiji.snt.gui.IconFactory;
import sc.fiji.snt.hyperpanes.MultiDThreePanes;
import sc.fiji.snt.tracing.artist.FillerThreadArtist;
import sc.fiji.snt.tracing.artist.SearchArtist;
import sc.fiji.snt.util.*;
import javax.swing.*;
import javax.swing.Timer;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.Ellipse2D;
import java.util.*;
import java.util.List;
/**
* A canvas that allows interactive tracing of paths.
*/
class InteractiveTracerCanvas extends TracerCanvas implements MouseWheelListener {
static { net.imagej.patcher.LegacyInjector.preinit(); } // required for _every_ class that imports ij. classes
private static final long serialVersionUID = 1L;
private final SNT tracerPlugin;
private JPopupMenu pMenu;
private JCheckBoxMenuItem toggleEditModeMenuItem;
private JMenuItem extendPathMenuItem;
private JMenuItem finishPathMenuItem;
private JMenuItem undoSegmentMenuItem;
private JCheckBoxMenuItem togglePauseTracingMenuItem;
private JCheckBoxMenuItem togglePauseSNTMenuItem;
private JMenuItem connectToSecondaryEditingPath;
private double last_x_in_pane_precise = Double.MIN_VALUE;
private double last_y_in_pane_precise = Double.MIN_VALUE;
private boolean fillTransparent = false;
private Path unconfirmedSegment;
private Path currentPath;
private boolean lastPathUnfinished;
private boolean editMode; // convenience flag to monitor SNT's edit mode
private Color temporaryColor;
private Color unconfirmedColor;
private Color fillColor;
private GuiUtils guiUtils;
protected static String EDIT_MODE_LABEL = "Edit Mode";
protected static String SNT_PAUSED_LABEL = "SNT Paused";
protected static String TRACING_PAUSED_LABEL = "Tracing Paused";
// Preview path for connection visualization
private Path connectionPreview;
/* fixes for 'failed' clicks, see https://forum.image.sc/t/snt-mouse-clicks-often-fail-when-tracing/115544 */
private int pressedX, pressedY;
private boolean clickHandledByRelease;
private boolean popupTriggered;
private boolean altDraggingNode;
private final Deque<Path> 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
// Squared pixel tolerance for click detection (5 pixels). This accommodates
// slight mouse/trackpad movement during click, especially on macOS trackpads.
private static final int CLICK_TOLERANCE_SQ = 25;
private List<PointInImage> warningLocations = new ArrayList<>();
private List<Path> warningAffectedPaths = new ArrayList<>();
private PlausibilityCheck.Severity warningMaxSeverity = null;
private boolean warningViewportTint = false;
private long warningFlashStart = 0;
private static final long FLASH_DURATION_MS = 3000;
private static final long TINT_DURATION_MS = 400;
private static final Color WARNING_RING_COLOR = new Color(255, 165, 0); // orange
private static final Color ERROR_RING_COLOR = new Color(255, 80, 80); // red
private static final Color VIEWPORT_TINT_COLOR = new Color(255, 60, 60, 50); // faint red wash
protected InteractiveTracerCanvas(final ImagePlus imp,
final SNT plugin, final int plane,
final PathAndFillManager pathAndFillManager)
{
super(imp, plugin, plane, pathAndFillManager);
tracerPlugin = plugin;
buildPopupMenu();
super.disablePopupMenu(true); // so that handlePopupMenu is not triggered
addMouseWheelListener(this); // explicit registration. ImageCanvas does not implement WheelListener
}
private void updateForkPointMenuItem(final JMenuItem forkNearestMenuItem) {
// NB: mouse button gestures cannot be set as Swing accelerators (KeyStroke only supports keyboard keys),
// so the shortcut is documented in the label only
final String accelerator = (tracerPlugin.requireShiftToFork) ? " (or Alt+Shift+Click)" : " (or Alt+Click)";
forkNearestMenuItem.setText(AListener.FORK_NEAREST + accelerator);
}
private void buildPopupMenu() {
pMenu = new JPopupMenu();
// Required because we are mixing lightweight and heavyweight components?
pMenu.setLightWeightPopupEnabled(false);
final AListener listener = new AListener();
pMenu.add(menuItem(AListener.SELECT_NEAREST, listener, KeyStroke.getKeyStroke(KeyEvent.VK_G, 0),
'\uf076', true, IconFactory.defaultColor()));
pMenu.add(menuItem(AListener.APPEND_NEAREST, listener,
KeyStroke.getKeyStroke(KeyEvent.VK_G, KeyEvent.SHIFT_DOWN_MASK),
'\uf076', true, IconFactory.selectedColor()));
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.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.addSeparator();
JMenuItem mi = menuItem(AListener.CLICK_AT_MAX, listener, KeyEvent.VK_V, IconFactory.GLYPH.SUN);
pMenu.add(mi);
extendPathMenuItem = menuItem(AListener.EXTEND_SELECTED, listener, null, IconFactory.GLYPH.TAPE);
pMenu.add(extendPathMenuItem);
mi = menuItem(AListener.FORK_NEAREST, listener, null,
'\ue13b', true, IconFactory.defaultColor());
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);
pMenu.add(undoSegmentMenuItem);
finishPathMenuItem = menuItem(AListener.FINISH_PATH, listener,
KeyStroke.getKeyStroke(KeyEvent.VK_F, 0),
'\uf11e', true, IconFactory.defaultColor());
pMenu.add(finishPathMenuItem);
pMenu.addSeparator();
toggleEditModeMenuItem = new JCheckBoxMenuItem(AListener.EDIT_TOGGLE_FORMATTER,
IconFactory.menuIcon(IconFactory.GLYPH.PEN));
toggleEditModeMenuItem.addItemListener(listener);
toggleEditModeMenuItem.setAccelerator(KeyStroke.getKeyStroke("shift E"));
toggleEditModeMenuItem.setMnemonic(KeyEvent.VK_E);
pMenu.add(toggleEditModeMenuItem);
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);
connectToSecondaryEditingPath.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, 0));
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));
final JMenuItem jmi = menuItem(AListener.NODE_COLOR, listener, KeyStroke.getKeyStroke(KeyEvent.VK_T, 0), null);
jmi.setToolTipText("<HTML>Assigns a unique color to active node.<br>"
+ "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.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('\uf04d', true));
togglePauseSNTMenuItem.addItemListener(listener);
pMenu.add(togglePauseSNTMenuItem);
}
private JMenuItem getSelectRoiMenuItem() {
final JMenuItem selectByRoi = new JMenuItem(AListener.SELECT_BY_ROI);
IconFactory.assignIcon(selectByRoi, '\uf248', false, IconFactory.defaultColor());
selectByRoi.addActionListener( e -> {
if (pathAndFillManager.size() == 0) {
getGuiUtils().error("There are no traced paths.", "Nothing to Select");
return;
}
if (getImage().getRoi() != null && selectPathsByRoi()) {
return; // a ROI existed, and we successfully used it to select paths
} else {
// User still has to create ROI
waitingForRoiDrawing = true;
if (unsuitableToolForRoiSelection()) IJ.setTool("freehand");
getGuiUtils().tempMsg("Draw ROI around paths to be selected. Current tool: " + IJ.getToolName());
}
});
return selectByRoi;
}
private void showPopupMenu(final int x, final int y) {
final Path activePath = tracerPlugin.getSingleSelectedPath();
final boolean be = uiReadyForModeChange(SNTUI.EDITING);
final boolean bp = uiReadyForModeChange(SNTUI.SNT_PAUSED);
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)));
toggleEditModeMenuItem.setState(be && editMode);
togglePauseSNTMenuItem.setSelected(bp && tracerPlugin.getUIState() == SNTUI.SNT_PAUSED);
togglePauseTracingMenuItem.setSelected(tracerPlugin.tracingHalted);
for (final MenuElement me : pMenu.getSubElements()) {
if (!(me instanceof JMenuItem mItem)) continue;
final String cmd = mItem.getActionCommand();
if (cmd.startsWith(AListener.FORK_NEAREST))
updateForkPointMenuItem(mItem);
// SNT fully paused: disable everything except the toggle itself
if (togglePauseSNTMenuItem.isSelected() && !cmd.equals(AListener.PAUSE_SNT_TOGGLE)) {
mItem.setEnabled(false);
}
// Edit mode toggle
else if (mItem == toggleEditModeMenuItem) {
mItem.setEnabled(be);
}
// Pause tracing toggle
else if (mItem == togglePauseTracingMenuItem) {
mItem.setEnabled(bp);
}
// Tracing-only commands: disabled in edit mode and when tracing halted
else if (extendPathMenuItem == mItem) {
mItem.setEnabled(tracingActive);
}
else if (cmd.equals(AListener.FINISH_PATH)) {
mItem.setEnabled(tracingActive && tracerPlugin.currentPath != null);
}
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
);
}
else if (cmd.startsWith(AListener.FORK_NEAREST)) {
mItem.setEnabled(!editMode && !tracerPlugin.tracingHalted
&& tracerPlugin.getUIState() == SNTUI.WAITING_TO_START_PATH
&& pathAndFillManager.size() > 0);
}
else if (cmd.equals(AListener.CLICK_AT_MAX)) {
mItem.setEnabled(tracingActive && !tracerPlugin.is2D());
}
// Edit mode node commands
else if (cmd.equals(AListener.APPEND_NEAREST) || cmd.equals(AListener.SELECT_BY_ROI)) {
mItem.setEnabled(!editMode); // In edit mode, only one path selected at any given time
}
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)) {
mItem.setEnabled(be && editMode);
}
// Everything else always enabled (SELECT_NEAREST, APPEND_NEAREST,
// HIDE_ALL, SHOW_ARROWS, BOOKMARK_CURSOR, START_SHOLL, pan, etc.)
else {
mItem.setEnabled(true);
}
}
updateConnectToSecondaryEditingPathMenuItem();
pMenu.show(this, x, y);
}
protected void connectEditingPathToPreviousEditingPath(final boolean showDialog) {
// Validate preconditions
if (!editMode || //
tracerPlugin.getEditingPath() == null || //
tracerPlugin.getEditingPath().getEditableNodeIndex() == -1 || //
tracerPlugin.getPreviousEditingPath() == null || //
tracerPlugin.getPreviousEditingPath().getEditableNodeIndex() == -1) //
{
getGuiUtils().error(AListener.NODE_CONNECT_TO_PREV_EDITING_PATH_PREFIX + ": No connectable node exist.",
"No Connectable Nodes");
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 boolean previousArrowState = PathNodeCanvas.isShowDirectionArrows();
PathNodeCanvas.setShowDirectionArrows(true);
tracerPlugin.updateTracingViewers(false);
try {
// Auto-swap heuristics
boolean shouldSwap = false;
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("<HTML>");
msgBuilder.append("<b>Connection Summary:</b><br>");
msgBuilder.append("<b>Parent:</b> ").append(StringUtils.abbreviate(parentPath.getName(), 30));
msgBuilder.append(" (branch at node #").append(parentNodeIdx).append(")<br>");
msgBuilder.append("<b>Child:</b> ").append(StringUtils.abbreviate(childPath.getName(), 30));
msgBuilder.append(" (connect from node #").append(childNodeIdx).append(")<br>");
msgBuilder.append("<b>Note:</b><br>");
if (!childAtEndpoint && childPath.size() > 2) {
msgBuilder.append(" - Child connects from a mid-path node<br>");
}
msgBuilder.append(" - This operation cannot be undone<br><br>");
// 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, null, false);
} finally {
// Always restore state and clear preview (in case of early return)
connectionPreview = null;
PathNodeCanvas.setShowDirectionArrows(previousArrowState);
tracerPlugin.updateAllViewers();
}
}
/**
* 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 || //
tracerPlugin.getPreviousEditingPath() == null || //
tracerPlugin.getPreviousEditingPath().getEditableNodeIndex() == -1) //
{
connectToSecondaryEditingPath.setText(AListener.NODE_CONNECT_TO_PREV_EDITING_PATH_PLACEHOLDER);
connectToSecondaryEditingPath.setEnabled(false);
return;
}
connectToSecondaryEditingPath.setEnabled(true);
final String label = getShortName(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.
* <p>
* 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(
"<HTML>Cannot connect these paths:<br><br>" +
"<i>" + e.getMessage() + "</i>",
"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<Path> grandchildren = new ArrayList<>(child.getChildren());
final Map<Path, Integer> 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 = "<HTML>To connect two paths in <i>Edit Mode</i>:<ol>" +
" <li>Press <tt>Shift+E</tt> to enter Edit Mode</li>" +
" <li>Select the <b>parent</b> path using <tt>G</tt> (<u>G</u>roup paths around cursor)</li>" +
" <li>Hover over the node where the child should branch from</li>" +
" <li>Press <tt>G</tt> to switch to the <b>child</b> path</li>" +
" <li>Select the child's connection node (typically a start or end node)</li>" +
" <li>Press <tt>C</tt> to <u>C</u>onnect</li>" +
"</ol>Notes:<ul>" +
" <li>A confirmation dialog shows the parent/child assignment with option to swap roles</li>" +
" <li>Child paths are automatically re-oriented to maintain proper tree structure</li>" +
" <li>If connection points are distant, a bridging segment is created automatically</li>" +
" <li>If child's selected node is mid-path, a T-junction is created</li>" +
" <li>Loop-forming connections are not allowed</li>" +
" <li>To concatenate or combine paths end-to-end, use Path Manager's Edit menu</li>" +
"</ul>";
final JMenuItem helpItem = new JMenuItem(AListener.NODE_CONNECT_HELP);
helpItem.addActionListener(e -> {
final boolean canvasActivationState = tracerPlugin.autoCanvasActivation;
tracerPlugin.enableAutoActivation(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.enableAutoActivation(canvasActivationState);
}
});
});
return helpItem;
}
private JMenuItem initMenuItem(final String cmdName, final ActionListener lstnr, final KeyStroke keystroke) {
final JMenuItem mi = GuiUtils.MenuItems.itemWithoutAccelerator(cmdName);
mi.addActionListener(lstnr);
if (keystroke != null) {
mi.setAccelerator(keystroke);
mi.setMnemonic(keystroke.getKeyCode());
}
return mi;
}
private JMenuItem menuItem(final String cmdName, final ActionListener lstnr, final int keycode,
final IconFactory.GLYPH glyph) {
return menuItem(cmdName, lstnr, KeyStroke.getKeyStroke(keycode, 0), glyph);
}
private JMenuItem menuItem(final String cmdName, final ActionListener lstnr, final KeyStroke keystroke,
final IconFactory.GLYPH glyph) {
final JMenuItem mi = initMenuItem(cmdName, lstnr, keystroke);
if (glyph != null) IconFactory.assignIcon(mi, glyph);
return mi;
}
private JMenuItem menuItem(final String cmdName, final ActionListener lstnr, final KeyStroke keystroke,
final char symbol, final boolean solid, final Color color) {
final JMenuItem mi = initMenuItem(cmdName, lstnr, keystroke);
if (symbol != '\u0000') IconFactory.assignIcon(mi, symbol, solid, color);
return mi;
}
public void setFillTransparent(final boolean transparent) {
this.fillTransparent = transparent;
if (transparent && fillColor != null) setFillColor(SNTColor.alphaColor(
fillColor, 50));
}
public void setPathUnfinished(final boolean unfinished) {
this.lastPathUnfinished = unfinished;
}
public void setTemporaryPath(final Path path) {
this.unconfirmedSegment = path;
}
public void setCurrentPath(final Path path) {
this.currentPath = path;
}
private boolean uiReadyForModeChange(final int mode) {
if (!tracerPlugin.isUIready()) return false;
return tracerPlugin.tracingHalted || tracerPlugin
.getUIState() == SNTUI.WAITING_TO_START_PATH || tracerPlugin
.getUIState() == mode;
}
protected void fakeMouseMoved(final boolean shift_pressed,
final boolean join_modifier_pressed)
{
tracerPlugin.mouseMovedTo(last_x_in_pane_precise, last_y_in_pane_precise,
plane, shift_pressed, join_modifier_pressed);
}
private void triggerRubberBandSearch() {
if (tracerPlugin.currentSearchThread != null
|| tracerPlugin.getUIState() != SNTUI.PARTIAL_PATH
|| !tracerPlugin.isAstarEnabled())
return;
tracerPlugin.startRubberBandSearch(last_x_in_pane_precise, last_y_in_pane_precise, plane);
}
protected void clickAtMaxPoint(final boolean join_modifier_pressed) {
if (!tracerPlugin.accessToValidImageData()) {
tempMsg("This option requires valid image data to be loaded.");
return;
}
final int x = (int) Math.round(last_x_in_pane_precise);
final int y = (int) Math.round(last_y_in_pane_precise);
final int[] p = new int[3];
tracerPlugin.findPointInStack(x, y, plane, p);
tracerPlugin.clickAtMaxPoint(x, y, plane, join_modifier_pressed);
}
/**
* Sets the warning overlay state for the canvas.
*
* @param locations spatial locations of warnings (may be null/empty)
* @param affectedPaths paths involved in warnings, highlighted with a thick overlay
* @param maxSeverity the highest severity among the warnings (drives visual tier)
* @param viewportTint whether to flash a brief viewport tint (for ERRORs when panel not visible)
*/
public void setWarningOverlay(final List<PointInImage> locations,
final List<Path> affectedPaths,
final PlausibilityCheck.Severity maxSeverity,
final boolean viewportTint) {
this.warningLocations = (locations != null) ? new ArrayList<>(locations) : new ArrayList<>();
this.warningAffectedPaths = (affectedPaths != null) ? new ArrayList<>(affectedPaths) : new ArrayList<>();
this.warningMaxSeverity = maxSeverity;
this.warningViewportTint = viewportTint;
this.warningFlashStart = System.currentTimeMillis();
repaint();
}
public void clearWarningOverlay() {
warningLocations.clear();
warningAffectedPaths.clear();
warningMaxSeverity = null;
warningViewportTint = false;
repaint();
}
@Override
public void repaint() {
super.repaint();
// Schedule repaints for pulsing animation while flash is active
if (!warningLocations.isEmpty() || !warningAffectedPaths.isEmpty()) {
final long elapsed = System.currentTimeMillis() - warningFlashStart;
if (elapsed < FLASH_DURATION_MS) {
super.repaint(50);
}
}
}
protected void startShollAnalysis() {
PointInImage centerScaled = null;
if (pathAndFillManager.anySelected()) {
final double[] p = new double[3];
tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise,
last_y_in_pane_precise, plane, p);
centerScaled = pathAndFillManager.nearestJoinPointOnSelectedPaths(p[0],
p[1], p[2]);
}
else {
final NearPoint np = getNearPointToMousePointer();
if (np != null) {
centerScaled = np.getNode();
}
}
if (centerScaled == null) {
getGuiUtils().tempMsg("No selectable nodes in view");
return;
}
tracerPlugin.startSholl(centerScaled);
}
/**
* Selects the path nearest to the current mouse pointer position.
*
* @param addToExistingSelection if true, adds to current selection; if false, replaces it
* @return the selected path, or null if no path could be selected
*/
public Path selectNearestPathToMousePointer(final boolean addToExistingSelection) {
if (pathAndFillManager.size() == 0) {
getGuiUtils().tempMsg("Nothing to select: There are no traced paths");
return null;
}
final List<PointInCanvas> nodes = new ArrayList<>();
if (pathAndFillManager.size() == 1) {
// There is only one path, presumably from a just-finished tracing operation.
// NB: Selection status of such path depends on the "Finishing a path selects
// it" checkbox in the GUI. We'll force select it.
final Path onlyPath = pathAndFillManager.getPath(0);
tracerPlugin.selectPath(onlyPath, addToExistingSelection);
getGuiUtils().tempMsg(onlyPath + " selected");
return onlyPath;
} else for (final Path path : pathAndFillManager.getPaths()) {
if (!path.isSelected()) {
nodes.addAll(path.getUnscaledNodesInViewPort(this));
}
}
if (nodes.isEmpty()) {
if (pathAndFillManager.getSelectedPaths().isEmpty())
getGuiUtils().tempMsg("Nothing to select. No paths in view");
// else the closest path to the pointer is an already pre-selected path in view
return null;
}
final double[] p = new double[3];
tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise,
last_y_in_pane_precise, plane, p);
final PointInCanvas cursor = new PointInCanvas(p[0], p[1], 0);
final NearPointInCanvas<PointInCanvas> nearPoint = NearPointInCanvas.nearestPointInCanvas(nodes, cursor);
if (nearPoint == null) {
getGuiUtils().tempMsg("No selectable paths in view");
return null;
}
else {
final Path selectedPath = nearPoint.getPath();
tracerPlugin.selectPath(selectedPath, addToExistingSelection);
getGuiUtils().tempMsg(getShortName(selectedPath) + " selected");
return selectedPath;
}
}
private NearPoint getNearPointToMousePointer() {
if (pathAndFillManager.size() == 0) {
return null;
}
// System.out.println(last_x_in_pane_precise + ", " + last_y_in_pane_precise);
final double[] p = new double[3];
tracerPlugin.findPointInStackPrecise(last_x_in_pane_precise,
last_y_in_pane_precise, plane, p);
// FIXME: We are going to ignore Z coordinates. Not sure why I
// decided this was a good idea. Perhaps needs to be revised?
final Rectangle rect = super.getSrcRect();
final PointInImage rectMin = new PointInImage(rect.getMinX(), rect
.getMinY(), 0);
final PointInImage rectMax = new PointInImage(rect.getMaxX(), rect
.getMaxY(), 0);
final PointInImage cursor = new PointInImage(p[0], p[1], 0);
final double maxSquaredLength = Math.max(cursor.distanceSquaredTo(rectMin),
cursor.distanceSquaredTo(rectMax));
// System.out.println(SNTUtils.formatDouble(last_x_in_pane_precise, 2) + ", " +
// SNTUtils.formatDouble(last_y_in_pane_precise, 2) + " | dx:" + maxSquaredLength);
// Find the nearest point on unselected Paths currently displayed in
// viewPort
final List<Path> paths = pathAndFillManager
.getUnSelectedPathsRenderedInViewPort(this);
if (paths.isEmpty()) {
return null;
}
cursor.z = Double.NaN; // ignore Z-positioning of path nodes
return pathAndFillManager.nearestPointOnAnyPath(paths, cursor,
maxSquaredLength, true);
}
@Override
public void setCursor(final int sx, final int sy, final int ox,
final int oy)
{
if (isEventsDisabled() || !tracerPlugin.isUIready() || !cursorLocked)
super.setCursor(sx, sy, ox, oy);
}
protected void onAltKeyDown() {
if (editMode && !impossibleEdit(false))
setCursor(handCursor);
}
protected void onAltKeyUp() {
if (!altDraggingNode)
setCursor(editMode ? defaultCursor : crosshairCursor);
}
@Override
public void mouseMoved(final MouseEvent e) {
super.mouseMoved(e);
if (isEventsDisabled() || !tracerPlugin.isUIready()) return;
last_x_in_pane_precise = myOffScreenXD(e.getX());
last_y_in_pane_precise = myOffScreenYD(e.getY());
boolean shift_key_down = e.isShiftDown();
final boolean joiner_modifier_down = !editMode && (
(tracerPlugin.requireShiftToFork) ? e.isShiftDown() && e.isAltDown() : e.isAltDown()
);
if (!editMode && tracerPlugin.snapCursor &&
plane == MultiDThreePanes.XY_PLANE && !joiner_modifier_down &&
!shift_key_down && tracerPlugin.accessToValidImageData())
{
final double[] p = new double[3];
tracerPlugin.findSnappingPointInXView(last_x_in_pane_precise,
last_y_in_pane_precise, p);
last_x_in_pane_precise = p[0];
last_y_in_pane_precise = p[1];
// Always sync panes if Z-snapping is enabled
shift_key_down = tracerPlugin.cursorSnapWindowZ > 0;
}
tracerPlugin.mouseMovedTo(last_x_in_pane_precise, last_y_in_pane_precise,
plane, shift_key_down, joiner_modifier_down);
if (editMode) {
setCursor((tracerPlugin.getEditingNode() == -1) ? defaultCursor : handCursor);
} else {
setCursor(crosshairCursor);
}
if (tracerPlugin.rubberBandTracing && !editMode) {
triggerRubberBandSearch(); // has its only validators
}
}
@Override
public void mouseEntered(final MouseEvent e) {
if (super.isEventsDisabled() || !tracerPlugin.isUIready()) {
super.mouseEntered(e);
return;
}
if (tracerPlugin.autoCanvasActivation) imp.getWindow().toFront();
}
/* See ImageCanvas#handlePopupMenu(me); */
private boolean isPopupTrigger(final MouseEvent me) {
return (me.isPopupTrigger() || (!PlatformUtils.isMac() && (me
.getModifiersEx() & MouseEvent.META_DOWN_MASK) != 0));
}
@Override
public void mousePressed(final MouseEvent me) {
pressedX = me.getX();
pressedY = me.getY();
clickHandledByRelease = false;
if (me.getButton() == MouseEvent.BUTTON2) {
IJ.setKeyDown(KeyEvent.VK_SPACE);
super.mousePressed(me);
return;
}
if (isPopupTrigger(me)) {
popupTriggered = true;