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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions client/dive-common/use/useModeManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,29 @@ describe('entering polygon editing', () => {
});
});

describe('confirming a point segmentation', () => {
it('leaves edit mode with the detection still selected, and removes one with nothing drawn', () => {
const recipe = new SegmentationPointClick();
const { modeManager: manager, cameraStore } = makeHarness(undefined, [recipe]);
recipe.activate();
const drawn = manager.handler.trackAdd();
manager.handler.updateRectBounds(0, 0, [0, 0, 10, 10]);
manager.handler.trackEdit(drawn);
recipe.resetPoints();
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBe(drawn);
expect(manager.editingTrack.value).toBe(false);
expect(recipe.active.value).toBe(true);

const empty = manager.handler.trackAdd();
recipe.resetPoints();
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBeNull();
expect(cameraStore.getPossibleTrack(empty, 'left')).toBeUndefined();
expect(cameraStore.getPossibleTrack(drawn, 'left')).toBeDefined();
});
});

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;
Expand Down Expand Up @@ -667,19 +690,22 @@ describe('a right-click that enters point segmentation editing', () => {
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.
// A later right-click with no points placed leaves edit mode with the
// detection still selected, as the other annotation types do.
press();
manager.handler.confirmRecipe();
expect(manager.selectedTrackId.value).toBeNull();
expect(manager.selectedTrackId.value).toBe(first);
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();
expect(manager.selectedTrackId.value).toBe(second);
expect(manager.editingTrack.value).toBe(false);
// With nothing selected a right-click changes nothing.
manager.handler.trackSelect(null, false);
press();
manager.handler.confirmRecipe();
expect(recipe.active.value).toBe(true);
Expand Down
13 changes: 10 additions & 3 deletions client/dive-common/use/useModeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1393,10 +1393,17 @@ export default function useModeManager({
// Clear saved state - the confirmed polygons are now permanent
preSegmentationFeatures.clear();
onStereoSegmentationFinalize?.();
// Exit editing mode and deselect to unhighlight the track
selectTrack(null, false);
// Re-activate segmentation recipe so it's ready for the next detection
// Re-arm the recipe for the next detection first: activating it
// re-selects the current track in edit mode (handleSetAnnotationState).
activeSegRecipes.forEach((r) => r.activate());
// Then leave edit mode with the detection still selected, as the other
// annotation types do; one with nothing drawn is removed instead.
const confirmedId = selectedTrackId.value;
if (confirmedId !== null && _removeIfEmpty(confirmedId)) {
selectTrack(null, false);
} else {
selectTrack(confirmedId, false);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,77 @@ describe('right-clicking the edited detection on another camera', () => {
expect(h.handler.selectCamera).not.toHaveBeenCalled();
});
});

describe('a point-mode right-click that lands on another camera', () => {
function pointHarness() {
const layer = () => ({ bus: new Vue() });
const camera = ref('left');
const edit = { ...layer(), type: 'Point', getMode: () => 'creation' };
const handler = {
confirmRecipe: vi.fn(),
segmentationFinalizePending: vi.fn(),
registerFinalizeCreation: vi.fn(),
};
useAnnotationClickHandling({
camera: 'left',
selectedCamera: camera,
selectedTrackIdRef: ref(1),
selectedKeyRef: ref(''),
frameNumberRef: ref(0),
flickNumberRef: ref(0),
editingModeRef: ref('Point'),
editAnnotationLayer: edit,
polyAnnotationLayer: layer(),
rectAnnotationLayer: layer(),
lineLayer: layer(),
handler,
refreshLayers: vi.fn(),
} as never).wireHandlers();
return { camera, edit, handler };
}

beforeEach(() => vi.useFakeTimers());
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); });

it('locks the mask without deselecting when the other camera takes the edit', () => {
const h = pointHarness();
h.edit.bus.$emit('confirm-annotation-elsewhere', true);
expect(h.handler.segmentationFinalizePending).toHaveBeenCalledOnce();
vi.runAllTimers();
expect(h.handler.confirmRecipe).not.toHaveBeenCalled();
h.camera.value = 'right';
document.dispatchEvent(new MouseEvent('mouseup'));
vi.runAllTimers();
expect(h.handler.confirmRecipe).not.toHaveBeenCalled();
});

it.each([true, false])('confirms on the selected camera once the button is released (held: %s)', (held) => {
const h = pointHarness();
h.edit.bus.$emit('confirm-annotation', held);
vi.runAllTimers();
if (held) {
expect(h.handler.confirmRecipe).not.toHaveBeenCalled();
document.dispatchEvent(new MouseEvent('mouseup'));
vi.runAllTimers();
}
expect(h.handler.confirmRecipe).toHaveBeenCalledOnce();
});

it('ignores a confirm from a camera that is not selected', () => {
const h = pointHarness();
h.camera.value = 'right';
h.edit.bus.$emit('confirm-annotation');
h.edit.bus.$emit('confirm-annotation-elsewhere', false);
vi.runAllTimers();
expect(h.handler.confirmRecipe).not.toHaveBeenCalled();
expect(h.handler.segmentationFinalizePending).not.toHaveBeenCalled();
});

it.each([true, false])('finishes the edit as before when nothing takes it over (button held: %s)', (held) => {
const h = pointHarness();
h.edit.bus.$emit('confirm-annotation-elsewhere', held);
if (held) document.dispatchEvent(new MouseEvent('mouseup'));
vi.runAllTimers();
expect(h.handler.confirmRecipe).toHaveBeenCalledOnce();
});
});
28 changes: 26 additions & 2 deletions client/src/components/layerManager/useAnnotationClickHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,32 @@ export default function useAnnotationClickHandling(options: {
finishPolygonClick(trackId, hit?.polygonKey, switching && hit != null);
});

editAnnotationLayer.bus.$on('confirm-annotation', () => {
handler.confirmRecipe();
// On Linux the contextmenu arrives before the button is released, and
// GeoJS reports the click on release: confirming (leaving edit mode)
// before then makes that click read as a right-click on an unedited
// detection, which re-enters editing. So confirm once the button is up.
const afterRelease = (buttonHeld: boolean, action: () => void) => {
const run = () => window.setTimeout(action, 0);
if (buttonHeld) document.addEventListener('mouseup', run, { once: true });
else run();
};

// Only the selected camera's editor may confirm: the other cameras keep a
// live creation editor too, and a right-click on one of them is a move of
// the edit, not a confirmation.
editAnnotationLayer.bus.$on('confirm-annotation', (buttonHeld: boolean) => {
if (selectedCamera.value !== camera) return;
afterRelease(buttonHeld, () => handler.confirmRecipe());
});

// Lock this camera's mask without deselecting, then finish the edit as a
// plain right-click would unless the other camera took it over.
editAnnotationLayer.bus.$on('confirm-annotation-elsewhere', (buttonHeld: boolean) => {
if (selectedCamera.value !== camera) return;
handler.segmentationFinalizePending();
afterRelease(buttonHeld, () => {
if (selectedCamera.value === camera) handler.confirmRecipe();
});
});
handler.registerFinalizeCreation(() => {
editAnnotationLayer.finalizeInProgress();
Expand Down
26 changes: 26 additions & 0 deletions client/src/layers/EditAnnotationLayer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,29 @@ it('persists line endpoints after point-click annotation on the same editor', as
await h.reopen();
expect(h.featureLayer.annotations()[0].geojson().geometry.coordinates).toEqual([[5, 15], [95, 20]]);
});

it('hands a point-mode right-click on another camera to the move instead of confirming', () => {
const h = harness();
h.layer.setType('Point'); h.layer.setMode('Point');
const own = document.createElement('div');
const other = document.createElement('div');
own.appendChild(document.createElement('canvas'));
document.body.append(own, other);
(h.annotator.geoViewerRef.value as any).node = () => [own];
const confirm = vi.fn();
const elsewhere = vi.fn();
h.layer.bus.$on('confirm-annotation', confirm);
h.layer.bus.$on('confirm-annotation-elsewhere', elsewhere);

own.firstChild!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
expect(confirm).toHaveBeenCalledTimes(1);
expect(elsewhere).not.toHaveBeenCalled();

const held = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, buttons: 2 });
other.dispatchEvent(held);
other.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
expect(confirm).toHaveBeenCalledTimes(1);
expect(elsewhere.mock.calls).toEqual([[true], [false]]);
expect(held.defaultPrevented).toBe(true);
h.layer.destroy(); own.remove(); other.remove();
});
9 changes: 8 additions & 1 deletion client/src/layers/EditAnnotationLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,14 @@ export default class EditAnnotationLayer extends BaseLayer<GeoJSON.Feature> {

// Native contextmenu is used because GeoJS mouseclick may not fire for
// right-button on Windows/Electron.
this.bus.$emit('confirm-annotation');
const [mapNode] = this.annotator.geoViewerRef.value?.node?.() ?? [];
if (mapNode && e.target instanceof Node && !mapNode.contains(e.target)) {
// A right-click on another camera may move this edit there; deselecting
// here first would flash the detection unselected.
this.bus.$emit('confirm-annotation-elsewhere', e.buttons !== 0);
return;
}
this.bus.$emit('confirm-annotation', e.buttons !== 0);
}
}

Expand Down
2 changes: 2 additions & 0 deletions server/tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ envlist =
type
testunit
isolated_build = True
# uv run cannot use the .venv redirect file tox 4.64+ writes.
venv_redirect = false
requires =
tox-uv>=1.0.0

Expand Down
Loading