diff --git a/client/dive-common/use/useModeManager.spec.ts b/client/dive-common/use/useModeManager.spec.ts index 9dd0e5cf1..d992430e3 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 { @@ -457,6 +463,29 @@ describe('centerline editing continuity', () => { }); }); +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; + } + }); +}); + describe('entering polygon editing', () => { const polygon = (key: string): GeoJSON.Feature => ({ type: 'Feature', diff --git a/client/dive-common/use/useModeManager.ts b/client/dive-common/use/useModeManager.ts index 4568fbbd8..472ee80ec 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 88a81f97b..a77e12e4a 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 = 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'; @@ -1632,10 +1633,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' @@ -1916,10 +1922,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); @@ -1936,6 +1938,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; } }