diff --git a/client/dive-common/components/CameraRegistration/RegistrationTools.vue b/client/dive-common/components/CameraRegistration/RegistrationTools.vue index 23481ccf7..c72caebf6 100644 --- a/client/dive-common/components/CameraRegistration/RegistrationTools.vue +++ b/client/dive-common/components/CameraRegistration/RegistrationTools.vue @@ -29,7 +29,7 @@ export default defineComponent({ const { saveConfig } = useApi(); const { prompt } = usePrompt(); - const cameras = computed(() => [...cameraStore.camMap.value.keys()]); + const cameras = computed(() => cameraStore.orderedCameraNames()); /** * Per-camera alignment status for the whole rig, driving the status block: * the first camera (display order) is the reference (identity); every other diff --git a/client/dive-common/components/ImportAnnotations.vue b/client/dive-common/components/ImportAnnotations.vue index ddd518562..6cf9ef65f 100644 --- a/client/dive-common/components/ImportAnnotations.vue +++ b/client/dive-common/components/ImportAnnotations.vue @@ -138,7 +138,7 @@ export default defineComponent({ ...Object.keys(cameraRegistration.homographies.value), ...Object.keys(cameraRegistration.correspondences.value), ]; - const cams = [...cameraStore.camMap.value.keys()]; + const cams = cameraStore.orderedCameraNames(); const reference = alignedView.reference.value ?? cams[0]; return cams.filter((camera) => camera !== reference).map((camera) => ({ camera, diff --git a/client/dive-common/components/MultiCamToolbar.vue b/client/dive-common/components/MultiCamToolbar.vue index 34659cb1b..700ef9687 100644 --- a/client/dive-common/components/MultiCamToolbar.vue +++ b/client/dive-common/components/MultiCamToolbar.vue @@ -45,7 +45,7 @@ export default defineComponent({ const enabledTracksRef = useTrackFilters().enabledAnnotations; const inEditingMode = useEditingMode(); - const cameras = computed(() => [...cameraStore.camMap.value.keys()]); + const cameras = computed(() => cameraStore.orderedCameraNames()); const canary = ref(false); const STORAGE_KEY = 'multiCamToolbar.expanded'; diff --git a/client/dive-common/components/MultiCamTools.vue b/client/dive-common/components/MultiCamTools.vue index 62af20d9c..dec6e513c 100644 --- a/client/dive-common/components/MultiCamTools.vue +++ b/client/dive-common/components/MultiCamTools.vue @@ -28,7 +28,7 @@ export default defineComponent({ const { frame } = useTime(); const selectedTrackId = useSelectedTrackId(); const cameraStore = useCameraStore(); - const cameras = computed(() => [...cameraStore.camMap.value.keys()]); + const cameras = computed(() => cameraStore.orderedCameraNames()); const canary = ref(false); function _depend(): boolean { return canary.value; diff --git a/client/dive-common/components/Viewer.vue b/client/dive-common/components/Viewer.vue index 3c0eb9855..3520ba765 100644 --- a/client/dive-common/components/Viewer.vue +++ b/client/dive-common/components/Viewer.vue @@ -1534,6 +1534,9 @@ export default defineComponent({ if (meta.multiCamMedia) { /* We're loading a multicamera dataset */ multiCamList.value = orderedMultiCamCameraNames(meta.multiCamMedia); + // Publish the persisted rig order for consumers that need to know + // which camera is first/last (see CameraStore.displayOrder). + cameraStore.displayOrder.value = multiCamList.value; defaultCamera.value = meta.multiCamMedia.defaultDisplay; changeCamera(defaultCamera.value); baseMulticamDatasetId.value = datasetId.value; @@ -1542,6 +1545,9 @@ export default defineComponent({ } } else { multiCamList.value = ['singleCam']; + // Clear any order carried over from a previously loaded multicam + // dataset, so orderedCameraNames falls back to camMap. + cameraStore.displayOrder.value = []; resetMulticamAlignment(); } cameraStore.setCameraOrder(multiCamList.value); diff --git a/client/src/CameraStore.ts b/client/src/CameraStore.ts index ca1e1dd8f..aa856b9e0 100644 --- a/client/src/CameraStore.ts +++ b/client/src/CameraStore.ts @@ -1,5 +1,5 @@ import { - ComputedRef, Ref, computed, shallowRef, triggerRef, + ComputedRef, Ref, computed, ref, shallowRef, triggerRef, } from 'vue'; import { cloneDeep, uniq } from 'lodash'; import { @@ -68,11 +68,25 @@ export default class CameraStore { private projectionCache: Map>; + /** + * The dataset's persisted camera display order (multiCamMedia.cameraOrder, + * via orderedMultiCamCameraNames), set by the viewer at load. + * + * camMap's own key order is insertion order: cameras are added one at a + * time inside an awaited per-camera load loop, and entries can survive a + * dataset switch, so it is not a dependable statement of rig order. Anything + * where "which camera is first/last" carries meaning -- the registration + * reference camera, the direction a loop-closure residual is measured in -- + * must read this instead. + */ + displayOrder: Ref; + constructor({ markChangesPending }: { markChangesPending: MarkChangesPending }) { this.markChangesPending = markChangesPending; const cameraName = 'singleCam'; this.defaultGroup = ['no-group', 1.0]; this.projectionCache = new Map(); + this.displayOrder = ref([]); this.camMap = shallowRef(new Map([[cameraName, { trackStore: new TrackStore({ markChangesPending, cameraName }), groupStore: new GroupStore({ markChangesPending, cameraName }), @@ -224,6 +238,17 @@ export default class CameraStore { }; } + /** + * Camera names in persisted display order, restricted to cameras actually + * present. Falls back to camMap order when no order has been set (single + * camera datasets, or before the viewer has loaded one). + */ + orderedCameraNames(): string[] { + const present = this.camMap.value; + const ordered = this.displayOrder.value.filter((name) => present.has(name)); + return ordered.length === present.size ? ordered : [...present.keys()]; + } + addCamera(cameraName: string) { if (this.camMap.value.get(cameraName) === undefined) { this.camMap.value.set(cameraName, { diff --git a/client/src/cameraStoreOrder.spec.ts b/client/src/cameraStoreOrder.spec.ts new file mode 100644 index 000000000..ebdfc7555 --- /dev/null +++ b/client/src/cameraStoreOrder.spec.ts @@ -0,0 +1,60 @@ +/** + * Camera display order. camMap's key order is insertion order from an awaited + * per-camera load loop, and entries survive a dataset switch, so anything that + * reads "first" or "last" camera as rig geometry -- the registration reference + * camera, the direction a loop-closure residual is measured in -- has to go + * through the persisted order instead. + */ +import { describe, expect, it } from 'vitest'; +import CameraStore from './CameraStore'; + +/** + * A loaded multicam rig: cameras added (in whatever order the awaited load + * loop produced) and the constructor's 'singleCam' placeholder pruned, as + * Viewer.vue does once loading completes. + */ +function loadedStore(names: string[]): CameraStore { + const s = new CameraStore({ markChangesPending: () => {} }); + names.forEach((n) => s.addCamera(n)); + s.removeCamera('singleCam'); + return s; +} + +describe('CameraStore.orderedCameraNames', () => { + it('falls back to camMap order when no order is published', () => { + const s = loadedStore(['rgb', 'uv']); + expect(s.orderedCameraNames()).toEqual(['rgb', 'uv']); + }); + + it('returns the persisted order regardless of insertion order', () => { + // Arrive out of order, as an awaited load loop can produce. + const s = loadedStore(['uv', 'rgb', 'ir']); + expect([...s.camMap.value.keys()]).toEqual(['uv', 'rgb', 'ir']); + s.displayOrder.value = ['rgb', 'ir', 'uv']; + expect(s.orderedCameraNames()).toEqual(['rgb', 'ir', 'uv']); + }); + + it('ignores a stale order left by a previous dataset', () => { + // Order naming cameras this dataset does not have must not win. + const s = loadedStore(['left', 'right']); + s.displayOrder.value = ['rgb', 'ir', 'uv']; + expect(s.orderedCameraNames()).toEqual(['left', 'right']); + }); + + it('ignores a partial order that omits a present camera', () => { + const s = loadedStore(['rgb', 'ir', 'uv']); + s.displayOrder.value = ['rgb', 'ir']; + expect(s.orderedCameraNames()).toEqual(['rgb', 'ir', 'uv']); + }); + + it('falls back mid-load, while the singleCam placeholder is still present', () => { + // The transient that produced a spurious loop-closure warning: order is + // published but the rig is not fully assembled yet. + const s = new CameraStore({ markChangesPending: () => {} }); + s.addCamera('rgb'); + s.addCamera('ir'); + s.displayOrder.value = ['rgb', 'ir', 'uv']; + expect(s.orderedCameraNames()).toEqual([...s.camMap.value.keys()]); + expect(s.orderedCameraNames()).toContain('singleCam'); + }); +});