diff --git a/client/dive-common/recipes/segmentationpointclick.ts b/client/dive-common/recipes/segmentationpointclick.ts index 30322b20e..b157ad10a 100644 --- a/client/dive-common/recipes/segmentationpointclick.ts +++ b/client/dive-common/recipes/segmentationpointclick.ts @@ -735,15 +735,19 @@ export default class SegmentationPointClick implements Recipe { /** * Public method to reset (clear) all accumulated points and pending prediction. * Called from UI Reset button. Clears all frames. + * + * @param byUser false when a selection change clears the points instead of + * the user, so a right-click that only entered edit mode cannot count as + * a reset to finalize. */ - resetPoints(): void { + resetPoints(byUser = true): void { // Emit reset event for all frames with data const framesToReset = [this.currentFrame, ...this.frameData.keys()]; framesToReset.forEach((frameNum) => { this.bus.$emit('prediction-reset', { frameNum }); }); this.reset(); - this._wasReset = true; + this._wasReset = byUser; this.icon.value = 'mdi-auto-fix'; } diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index 7947bfda8..fd3deb87d 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * Functional tests for the Align View cross-camera mirror: drawing/editing a * track on one camera while the aligned view is active re-projects the @@ -618,3 +619,69 @@ describe('entering polygon editing', () => { expect(manager.selectedKey.value).toBe('2'); }); }); + +describe('stereo copy of a point-segmented mask', () => { + it('runs once per click and not again when the mask is confirmed', () => { + const wasAutoCompute = clientSettings.stereoSettings.autoComputeOtherCamera; + clientSettings.stereoSettings.autoComputeOtherCamera = true; + try { + const recipe = new SegmentationPointClick(); + const events: StereoAnnotationCompleteParams[] = []; + const { modeManager: manager } = makeHarness(undefined, [recipe], (params) => events.push(params)); + manager.handler.trackAdd(); + const result = { + polygon: [[0, 0], [10, 0], [10, 10]] as [number, number][], + bounds: null, + frameNum: 0, + controlPoints: { points: [[5, 5]] as [number, number][], labels: [1] }, + }; + recipe.bus.$emit('prediction-ready', result); + expect(events.map((e) => e.type)).toEqual(['segmentation']); + recipe.bus.$emit('prediction-confirmed', result); + recipe.bus.$emit('prediction-confirmed-multi', { frames: new Map([[0, result]]) }); + expect(events.map((e) => e.type)).toEqual(['segmentation']); + } finally { + clientSettings.stereoSettings.autoComputeOtherCamera = wasAutoCompute; + } + }); +}); + +describe('a right-click that enters point segmentation editing', () => { + const press = () => document.dispatchEvent(new MouseEvent('mousedown', { button: 2 })); + it('is not finalized by the contextmenu that follows it, unlike a later right-click or a user reset', () => { + const recipe = new SegmentationPointClick(); + const { modeManager: manager } = makeHarness(undefined, [recipe]); + recipe.activate(); + const first = manager.handler.trackAdd(); + manager.handler.updateRectBounds(0, 0, [0, 0, 10, 10]); + const second = manager.handler.trackAdd(); + manager.handler.updateRectBounds(0, 0, [20, 20, 30, 30]); + expect(manager.selectedTrackId.value).toBe(second); + // Selecting another detection clears the recipe, but not as a user reset. + press(); + manager.handler.trackEdit(first); + expect(manager.selectedTrackId.value).toBe(first); + expect(manager.editingTrack.value).toBe(true); + expect(recipe.wasReset).toBe(false); + // On Windows the contextmenu of that right-click arrives after edit mode began. + manager.handler.confirmRecipe(); + expect(manager.selectedTrackId.value).toBe(first); + expect(manager.editingTrack.value).toBe(true); + // A later right-click with no points placed finalizes the detection. + press(); + manager.handler.confirmRecipe(); + expect(manager.selectedTrackId.value).toBeNull(); + expect(manager.editingTrack.value).toBe(false); + // So does one after a reset by the user, even within the same press. + press(); + manager.handler.trackEdit(second); + recipe.resetPoints(); + expect(recipe.wasReset).toBe(true); + manager.handler.confirmRecipe(); + expect(manager.selectedTrackId.value).toBeNull(); + // With nothing selected a right-click changes nothing. + press(); + manager.handler.confirmRecipe(); + expect(recipe.active.value).toBe(true); + }); +}); diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 2096f722c..0630c4352 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -256,16 +256,30 @@ export default function useModeManager({ return false; } + // The right mousedown that selects a detection for point segmentation is + // followed on Windows by its contextmenu only after mouseup, once edit mode + // has begun. That contextmenu must not finalize the fresh edit, while a + // later right-click with no points placed still does, so remember which + // press entered edit mode. + let mouseDownCount = 0; + let editEnteredOnMouseDown = -1; + const countMouseDown = () => { mouseDownCount += 1; }; + if (typeof document !== 'undefined') document.addEventListener('mousedown', countMouseDown, true); + function selectTrack(trackId: AnnotationId | null, edit = false) { // Reset segmentation recipe state when switching to a different track - // so stale points/mask from the previous detection don't interfere + // so stale points/mask from the previous detection don't interfere. This + // is not a user reset (see handleConfirmRecipe). if (trackId !== selectedTrackId.value) { recipes.forEach((r) => { if (r instanceof SegmentationPointClick && r.active.value) { - r.resetPoints(); + r.resetPoints(false); } }); } + if (trackId !== null && edit && (trackId !== selectedTrackId.value || !editingTrack.value)) { + editEnteredOnMouseDown = mouseDownCount; + } // Clean up empty tracks when leaving edit mode (e.g., created a detection // but never drew an annotation, then clicked away or right-clicked to deselect) if ( @@ -1349,12 +1363,10 @@ export default function useModeManager({ * Called when right-click is used in Point mode to lock the annotation. */ function handleConfirmRecipe() { - // First check if any active segmentation recipe has a pending prediction - // or was explicitly reset by the user (Escape key). - // If neither, there's nothing to confirm - this happens when the contextmenu - // event from a right-click that entered Point edit mode triggers - // confirm-annotation before any points are placed. In that case, don't - // confirm/deactivate recipes or deselect - let the edit mode continue. + // A pending prediction is committed; with nothing placed (or after an + // Escape reset) the right-click just finalizes the detection, leaving + // edit mode. Neither applies to the contextmenu of the very press that + // entered edit mode, which Windows delivers after mouseup. let hadPendingPredictionOrReset = false; recipes.forEach((r) => { if (r.active.value && r.confirm && r instanceof SegmentationPointClick) { @@ -1364,7 +1376,10 @@ export default function useModeManager({ } }); if (!hadPendingPredictionOrReset) { - return; + if (selectedTrackId.value === null || !editingTrack.value + || mouseDownCount === editEnteredOnMouseDown) { + return; + } } const activeSegRecipes: SegmentationPointClick[] = []; recipes.forEach((r) => { @@ -1699,7 +1714,9 @@ export default function useModeManager({ * This is called when the user confirms the segmentation (right-click or Enter). */ function handleSegmentationPredictionConfirmed(result: SegmentationPredictionResult) { - handleSegmentationPredictionReady(result); + // Each click already ran the stereo copy and auto-populate for this mask; + // confirming only commits it. + handleSegmentationPredictionReady({ ...result, controlPoints: undefined }); } /** Click-path variant: a fresh point click that should honor continuous mode. */ @@ -1884,6 +1901,7 @@ export default function useModeManager({ /* Unsubscribe before unmount */ onBeforeUnmount(() => { + if (typeof document !== 'undefined') document.removeEventListener('mousedown', countMouseDown, true); recipes.forEach((r) => r.bus.$off('activate', handleSetAnnotationState)); recipes.forEach((r) => { if (r instanceof SegmentationPointClick) {