From a16b519bdce7bce4d60e1cbc6e2391ef2a1ec254 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 18 Sep 2026 11:53:43 -0400 Subject: [PATCH 1/2] Map a drawn line to the other camera only once both ends are placed Mapping the first end mid-draw interrupted the draw and flashed the stereo dialog. The dialog now also waits 400 ms and never shows for a single point. --- client/dive-common/use/useModeManager.spec.ts | 33 +++++++++++++++++-- client/dive-common/use/useModeManager.ts | 6 ++-- .../frontend/components/ViewerLoader.vue | 21 +++++++----- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index 93374e519..a5e6bd918 100644 --- a/client/dive-common/use/useModeManager.spec.ts +++ b/client/dive-common/use/useModeManager.spec.ts @@ -15,7 +15,8 @@ import type { AnnotationId } from 'vue-media-annotator/BaseAnnotation'; import type { MarkChangesPending } from 'vue-media-annotator/BaseAnnotationStore'; import Track from 'vue-media-annotator/track'; import { ROTATION_ATTRIBUTE_NAME } from 'vue-media-annotator/utils'; -import useModeManager from './useModeManager'; +import { clientSettings } from 'dive-common/store/settings'; +import useModeManager, { type StereoAnnotationCompleteParams } from './useModeManager'; import HeadTail from '../recipes/headtail'; import { headTailFeatures } from '../../src/headTail'; import type Recipe from '../../src/recipe'; @@ -24,7 +25,11 @@ function translation(tx: number, ty: number): Matrix3 { return [[1, 0, tx], [0, 1, ty], [0, 0, 1]]; } -function makeHarness(markChangesPending: MarkChangesPending = () => undefined, recipes: Recipe[] = []) { +function makeHarness( + markChangesPending: MarkChangesPending = () => undefined, + recipes: Recipe[] = [], + onStereoAnnotationComplete: ((params: StereoAnnotationCompleteParams) => void) | undefined = undefined, +) { const cameraStore = new CameraStore({ markChangesPending }); cameraStore.removeCamera('singleCam'); cameraStore.addCamera('left'); @@ -77,6 +82,7 @@ function makeHarness(markChangesPending: MarkChangesPending = () => undefined, r readonlyState: ref(false), recipes, alignedView, + onStereoAnnotationComplete, }); modeManager.selectedCamera.value = 'left'; return { @@ -456,3 +462,26 @@ describe('centerline editing continuity', () => { expect(track.features[0].bounds).toEqual([0, 0, 100, 100]); }); }); + +describe('stereo mapping of a line being drawn', () => { + it('waits for both ends instead of mapping the first one alone', () => { + const wasAutoCompute = clientSettings.stereoSettings.autoComputeOtherCamera; + clientSettings.stereoSettings.autoComputeOtherCamera = true; + try { + const recipe = new HeadTail(); + const events: StereoAnnotationCompleteParams[] = []; + const { modeManager: manager } = makeHarness(undefined, [recipe], (params) => events.push(params)); + manager.handler.trackAdd(); + recipe.activate(); + const draw = (coordinates: number[][]) => manager.handler.updateGeoJSON('in-progress', 0, 0, { + type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates }, + }, 'HeadTails'); + draw([[10, 10]]); + expect(events).toEqual([]); + draw([[10, 10], [90, 10]]); + expect(events.map((e) => e.type)).toEqual(['line']); + } finally { + clientSettings.stereoSettings.autoComputeOtherCamera = wasAutoCompute; + } + }); +}); diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index f4eedce16..9222ce8de 100644 --- a/client/dive-common/use/useModeManager.ts +++ b/client/dive-common/use/useModeManager.ts @@ -972,9 +972,11 @@ export default function useModeManager({ mirrorFeatureToAlignedCameras(track.id, frameNum); - // Emit persisted named points, including a head placed before its tail. - // Completed lines use their existing whole-line transfer event instead. + // Emit persisted named points. Completed lines use their existing + // whole-line transfer event instead, and the first end of a line still + // being drawn waits for it: mapping it mid-draw interrupts the draw. if (onStereoAnnotationComplete && stereoInteractiveActive() + && update.done.every((v) => v !== false) && !(data.geometry.type === 'LineString' && data.geometry.coordinates.length >= 2)) { Object.entries(update.geoJsonFeatureRecord).forEach(([pointKey, geoms]) => { geoms.forEach((geom) => { diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 4f41204fb..99027931b 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -1164,6 +1164,7 @@ export default defineComponent({ // line at this frame. Once set, interactive stereo never overwrites that // side's geometry again — only the user can. Kept off the Attributes panel. const STEREO_USER_LINE_ATTR = 'stereo_user_line'; + const STEREO_LOADING_DIALOG_DELAY_MS = 400; // How the length was set: 'stereo' = auto-computed from the warped lines, // 'user_set' = locked by the user (auto-update leaves the length alone). const STEREO_LENGTH_METHOD_ATTR = 'length_method'; @@ -1602,10 +1603,15 @@ export default defineComponent({ // Show loading indicator while waiting for stereo transfer (interactive // single-transfer path only; bulk import owns the dialog itself). - if (!quiet) { - stereoLoadingMessage.value = 'Computing stereo correspondence...'; - stereoLoadingError.value = ''; - stereoLoadingDialog.value = true; + // Only once the wait is noticeable, so a fast transfer doesn't flash it, + // and never for a single point. + let loadingTimer: number | undefined; + if (!quiet && params.type !== 'point') { + loadingTimer = window.setTimeout(() => { + stereoLoadingMessage.value = 'Computing stereo correspondence...'; + stereoLoadingError.value = ''; + stereoLoadingDialog.value = true; + }, STEREO_LOADING_DIALOG_DELAY_MS); } const pointTargetBefore = params.type === 'point' @@ -1882,10 +1888,6 @@ export default defineComponent({ } } } - // Success — hide loading dialog (interactive path only) - if (!quiet) { - stereoLoadingDialog.value = false; - } return 'transferred'; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -1902,6 +1904,9 @@ export default defineComponent({ stereoLoadingError.value = `Failed to transfer annotation to the other camera. ${message}`; stereoLoadingDialog.value = true; return 'failed'; + } finally { + window.clearTimeout(loadingTimer); + if (!quiet && !stereoLoadingError.value) stereoLoadingDialog.value = false; } } From dfccfb8fd60c77c44f22a4a618574ce449c774cf Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Fri, 18 Sep 2026 11:55:23 -0400 Subject: [PATCH 2/2] Show the stereo dialog after 300 ms --- client/platform/desktop/frontend/components/ViewerLoader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 99027931b..7535f4a9b 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -1164,7 +1164,7 @@ export default defineComponent({ // line at this frame. Once set, interactive stereo never overwrites that // side's geometry again — only the user can. Kept off the Attributes panel. const STEREO_USER_LINE_ATTR = 'stereo_user_line'; - const STEREO_LOADING_DIALOG_DELAY_MS = 400; + const STEREO_LOADING_DIALOG_DELAY_MS = 300; // How the length was set: 'stereo' = auto-computed from the warped lines, // 'user_set' = locked by the user (auto-update leaves the length alone). const STEREO_LENGTH_METHOD_ATTR = 'length_method';