From 56fddfb220b9de592ec1dfc397d512453b136fd3 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 17 Sep 2026 23:14:30 -0400 Subject: [PATCH 1/2] Navigate a detection's polygons from either stereo camera Right-clicking a polygon of the selected detection on a camera that is not selected now selects that camera and switches polygons there, the same way box and vertex drags already do. --- .../layerManager/useAnnotationClickHandling.spec.ts | 12 ++++++++++-- .../layerManager/useAnnotationClickHandling.ts | 13 +++++++++---- .../layerManager/usePolygonEditNavigation.spec.ts | 12 +++++++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/client/src/components/layerManager/useAnnotationClickHandling.spec.ts b/client/src/components/layerManager/useAnnotationClickHandling.spec.ts index 67086452f..0e236c7c1 100644 --- a/client/src/components/layerManager/useAnnotationClickHandling.spec.ts +++ b/client/src/components/layerManager/useAnnotationClickHandling.spec.ts @@ -1,7 +1,7 @@ import Vue, { ref } from 'vue'; import useAnnotationClickHandling from './useAnnotationClickHandling'; -function harness(type = 'LineString') { +function harness(type = 'LineString', selectedCamera = 'left') { const layer = () => ({ bus: new Vue() }); const polygon = layer(); const selectedKey = ref(type === 'LineString' ? 'HeadTails' : ''); @@ -11,7 +11,7 @@ function harness(type = 'LineString') { const refresh = vi.fn(); useAnnotationClickHandling({ camera: 'left', - selectedCamera: ref('left'), + selectedCamera: ref(selectedCamera), selectedTrackIdRef: ref(1), selectedKeyRef: selectedKey, frameNumberRef: ref(0), @@ -44,6 +44,14 @@ it('defers polygon key selection to the edit layer while polygon editing', () => expect(h.selectFeatureHandle).not.toHaveBeenCalled(); }); +it('defers to the edit layer on a camera that is not selected while polygon editing', () => { + const h = harness('Polygon', 'right'); + h.polygon.bus.$emit('polygon-right-clicked', 1, 'segmentation'); + h.polygon.bus.$emit('polygon-right-clicked-outside'); + expect(h.selectFeatureHandle).not.toHaveBeenCalled(); + expect(h.cancelCreation).not.toHaveBeenCalled(); +}); + it('does not cancel in-progress line creation when a mask is right-clicked', () => { const h = harness(); h.edit.getMode = () => 'creation'; h.polygon.bus.$emit('polygon-right-clicked', 1, 'segmentation'); diff --git a/client/src/components/layerManager/useAnnotationClickHandling.ts b/client/src/components/layerManager/useAnnotationClickHandling.ts index f05b29177..234b541af 100644 --- a/client/src/components/layerManager/useAnnotationClickHandling.ts +++ b/client/src/components/layerManager/useAnnotationClickHandling.ts @@ -143,7 +143,11 @@ export default function useAnnotationClickHandling(options: { }); editAnnotationLayer.bus.$on('polygon-edit-right-click', (geo: { x: number; y: number }) => { const trackId = selectedTrackIdRef.value; - if (selectedCamera.value !== camera || trackId === null || editingModeRef.value !== 'Polygon') return; + if (trackId === null || editingModeRef.value !== 'Polygon') return; + // The editor that took the click is live on every camera holding the + // detection, so navigate its polygons here after selecting this camera. + if (selectedCamera.value !== camera) handler.selectCamera(camera, false); + if (selectedCamera.value !== camera) return; const point = alignedView.mapNativePoint(geo.x, geo.y); const hit = pickPolygon(polyAnnotationLayer.formattedData, trackId as number, point); finishPolygonClick(trackId, hit?.polygonKey); @@ -170,10 +174,11 @@ export default function useAnnotationClickHandling(options: { if (editingModeRef.value === 'LineString' || (editAnnotationLayer.type === 'LineString' && editAnnotationLayer.getMode() !== 'disabled')) return; if (polygonNavigationPending) return; - if (selectedCamera.value === camera && trackId === selectedTrackIdRef.value + if (trackId === selectedTrackIdRef.value && editingModeRef.value === 'Polygon' && editAnnotationLayer.getMode() !== 'creation') { // The edit-layer click resolves the actual polygon hit (including - // holes) and applies the switch after GeoJS finishes this mouse event. + // holes) and applies the switch after GeoJS finishes this mouse event, + // on whichever camera's editor holds the detection. return; } if (editAnnotationLayer.getMode() === 'creation') { @@ -196,7 +201,7 @@ export default function useAnnotationClickHandling(options: { polyAnnotationLayer.bus.$on('polygon-right-clicked-outside', () => { if (editingModeRef.value === 'LineString' || (editAnnotationLayer.type === 'LineString' && editAnnotationLayer.getMode() !== 'disabled')) return; - if (selectedCamera.value === camera && selectedTrackIdRef.value !== null + if (selectedTrackIdRef.value !== null && editingModeRef.value === 'Polygon' && editAnnotationLayer.getMode() !== 'creation') { // The edit layer also receives clicks in gaps between polygons. return; diff --git a/client/src/components/layerManager/usePolygonEditNavigation.spec.ts b/client/src/components/layerManager/usePolygonEditNavigation.spec.ts index f8ce0ef84..a824baf16 100644 --- a/client/src/components/layerManager/usePolygonEditNavigation.spec.ts +++ b/client/src/components/layerManager/usePolygonEditNavigation.spec.ts @@ -38,6 +38,7 @@ function harness() { selectFeatureHandle: vi.fn((_index, selectedKey) => { key.value = selectedKey; }), registerFinalizeCreation: vi.fn(), cancelCreation: vi.fn(), + selectCamera: vi.fn(), }; const rectangle = layer(); const refresh = vi.fn(); @@ -122,7 +123,16 @@ it('allows switching to a polygon with the default empty key', () => { expect(h.mode.value).toBe('Polygon'); }); -it('ignores another camera and non-polygon editing modes', () => { +it('selects the clicked camera before navigating its polygons', () => { + const h = harness(); h.camera.value = 'right'; + h.handler.selectCamera.mockImplementation((next: string) => { h.camera.value = next; }); + h.click(21, 5); vi.runAllTimers(); + expect(h.handler.selectCamera).toHaveBeenCalledExactlyOnceWith('left', false); + expect(h.key.value).toBe('second'); + expect(h.mode.value).toBe('Polygon'); +}); + +it('ignores a camera it cannot select and non-polygon editing modes', () => { const h = harness(); h.camera.value = 'right'; h.click(21, 5); h.camera.value = 'left'; h.mode.value = 'LineString'; h.click(21, 5); vi.runAllTimers(); From 64beebfbd493466eba923e6bc630475a7222b4c8 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 18 Sep 2026 12:21:21 -0400 Subject: [PATCH 2/2] Make an existing mask editable as soon as polygon editing is entered Entering polygon mode selected the empty key, so a keyed mask (SegmentationPolygon) was not found and the editor started a new polygon. The stereo-segmented copy now shares the source polygon's key. --- client/dive-common/use/useModeManager.spec.ts | 27 +++++++++++++++++++ client/dive-common/use/useModeManager.ts | 15 ++++++++++- .../frontend/components/ViewerLoader.vue | 6 ++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index 93374e519..9dd0e5cf1 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -456,3 +456,30 @@ describe('centerline editing continuity', () => { expect(track.features[0].bounds).toEqual([0, 0, 100, 100]); }); }); + +describe('entering polygon editing', () => { + const polygon = (key: string): GeoJSON.Feature => ({ + type: 'Feature', + properties: { key }, + geometry: { type: 'Polygon', coordinates: [[[0, 0], [10, 0], [10, 10], [0, 0]]] }, + }); + + it('lands on the keyed mask a detection already has', () => { + const { cameraStore, modeManager: manager } = makeHarness(); + const id = manager.handler.trackAdd(); + cameraStore.getTrack(id, 'left').setFeature({ frame: 0, keyframe: true, bounds: [0, 0, 10, 10] }, [polygon('SegmentationPolygon')]); + manager.handler.setAnnotationState({ editing: 'LineString', key: 'HeadTails' }); + manager.handler.setAnnotationState({ editing: 'Polygon', key: '' }); + expect(manager.selectedKey.value).toBe('SegmentationPolygon'); + }); + + it('keeps the default polygon and an explicitly requested new key', () => { + const { cameraStore, modeManager: manager } = makeHarness(); + const id = manager.handler.trackAdd(); + cameraStore.getTrack(id, 'left').setFeature({ frame: 0, keyframe: true, bounds: [0, 0, 10, 10] }, [polygon(''), polygon('1')]); + manager.handler.setAnnotationState({ editing: 'Polygon', key: '' }); + expect(manager.selectedKey.value).toBe(''); + manager.handler.setAnnotationState({ editing: 'Polygon', key: '2' }); + expect(manager.selectedKey.value).toBe('2'); + }); +}); diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index f4eedce16..4568fbbd8 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -1270,6 +1270,19 @@ export default function useModeManager({ } } + /** + * Entering polygon editing without naming a polygon: land on one the + * detection already has (masks are often keyed, e.g. SegmentationPolygon) + * so its vertices are editable at once rather than starting a new polygon. + */ + function existingPolygonKey(): string { + if (selectedTrackId.value === null) return ''; + const track = cameraStore.getPossibleTrack(selectedTrackId.value, selectedCamera.value); + const keys = track?.getPolygonFeatures(selectedCameraFrame()).map((p) => p.key) ?? []; + if (!keys.length || keys.includes('')) return ''; + return keys.includes(selectedKey.value) ? selectedKey.value : keys[0]; + } + function handleSetAnnotationState({ visible, editing, key, recipeName, }: SetAnnotationStateArgs) { @@ -1278,7 +1291,7 @@ export default function useModeManager({ } if (editing) { annotationModes.editing = editing; - _selectKey(key); + _selectKey(editing === 'Polygon' && !key ? existingPolygonKey() : key); handleSelectTrack(selectedTrackId.value, true); recipes.forEach((r) => { if (recipeName !== r.name) { diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 4f41204fb..37e1ce81c 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -1848,10 +1848,14 @@ export default defineComponent({ if (first[0] !== last[0] || first[1] !== last[1]) { closedPolygon.push([...first] as [number, number]); } + // Same key as the source polygon, so polygon editing reaches both. + const [sourceFeature] = sourceTrack?.getFeature(params.frameNum) ?? [null]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sourceKey = sourceFeature?.geometry?.features.find((f: any) => f.geometry.type === 'Polygon')?.properties?.key ?? ''; const segGeometry: GeoJSON.Feature[] = [{ type: 'Feature', geometry: { type: 'Polygon', coordinates: [closedPolygon] }, - properties: { key: '' }, + properties: { key: sourceKey }, }]; const segBounds = response.bounds || [ Math.min(...response.polygon.map((p: [number, number]) => p[0])),