From 5d6ff5465638453d293c2d614fe26e5ad6d28037 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 22 May 2025 10:25:47 +0200 Subject: [PATCH 01/92] wip: ignore user state updates; update to skeleton and volume changes --- frontend/javascripts/viewer/api/wk_dev.ts | 2 +- .../model/actions/skeletontracing_actions.tsx | 20 ++- .../model/actions/volumetracing_actions.ts | 18 +- .../viewer/model/helpers/nml_helpers.ts | 2 + .../model/helpers/position_converter.ts | 16 ++ .../model/reducers/skeletontracing_reducer.ts | 102 +++++++++++ .../model/reducers/volumetracing_reducer.ts | 50 +++++- .../viewer/model/sagas/save_saga.ts | 163 +++++++++++++++++- .../viewer/model/sagas/save_saga_constants.ts | 3 +- .../viewer/model/sagas/update_actions.ts | 8 + .../dataset_info_tab_view.tsx | 10 ++ 11 files changed, 377 insertions(+), 17 deletions(-) diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index 9a28e7f959b..30de8fa0ab5 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -11,7 +11,7 @@ import type ApiLoader from "./api_loader"; // Can be accessed via window.webknossos.DEV.flags. Only use this // for debugging or one off scripts. export const WkDevFlags = { - logActions: false, + logActions: true, sam: { useLocalMask: true, }, diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index 2ac352cf29c..d0819ac8ede 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -16,6 +16,7 @@ import type { MutableTreeMap, Tree, TreeGroup } from "viewer/model/types/tree_ty import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/store"; import RemoveTreeModal from "viewer/view/remove_tree_modal"; +import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; export type InitializeSkeletonTracingAction = ReturnType; export type CreateNodeAction = ReturnType; @@ -65,6 +66,9 @@ type SetTreeGroupAction = ReturnType; type SetShowSkeletonsAction = ReturnType; type SetMergerModeEnabledAction = ReturnType; type UpdateNavigationListAction = ReturnType; +type ApplySkeletonUpdateActionsFromServerAction = ReturnType< + typeof applySkeletonUpdateActionsFromServerAction +>; export type LoadAgglomerateSkeletonAction = ReturnType; type NoAction = ReturnType; @@ -131,7 +135,8 @@ export type SkeletonTracingAction = | SetShowSkeletonsAction | SetMergerModeEnabledAction | UpdateNavigationListAction - | LoadAgglomerateSkeletonAction; + | LoadAgglomerateSkeletonAction + | ApplySkeletonUpdateActionsFromServerAction; export const SkeletonTracingSaveRelevantActions = [ "INITIALIZE_SKELETONTRACING", @@ -173,6 +178,7 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_TREE_COLOR", "BATCH_UPDATE_GROUPS_AND_TREES", // Composited actions, only dispatched using `batchActions` ...AllUserBoundingBoxActions, + "APPLY_UPDATE_ACTIONS_FROM_SERVER", ]; const noAction = () => @@ -639,6 +645,18 @@ export const updateNavigationListAction = (list: Array, activeIndex: num activeIndex, }) as const; +export const applySkeletonUpdateActionsFromServerAction = ( + actions: Array, + // version: number, + // author: string, +) => + ({ + type: "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER", + actions, + // version, + // author, + }) as const; + export const loadAgglomerateSkeletonAction = ( layerName: string, mappingName: string, diff --git a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts index 5e86dc2615b..0ef7655303a 100644 --- a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts @@ -8,6 +8,7 @@ import type { QuickSelectGeometry } from "viewer/geometries/helper_geometries"; import { AllUserBoundingBoxActions } from "viewer/model/actions/annotation_actions"; import type { NumberLike, Segment, SegmentGroup, SegmentMap } from "viewer/store"; import type BucketSnapshot from "../bucket_data_handling/bucket_snapshot"; +import type { ApplicableVolumeUpdateAction } from "../sagas/update_actions"; export type InitializeVolumeTracingAction = ReturnType; export type InitializeEditableMappingAction = ReturnType; @@ -53,6 +54,9 @@ export type SetMappingIsLockedAction = ReturnType; +export type ApplyVolumeUpdateActionsFromServerAction = ReturnType< + typeof applyVolumeUpdateActionsFromServerAction +>; export type ComputeQuickSelectForRectAction = ReturnType; export type ComputeQuickSelectForPointAction = ReturnType; @@ -111,7 +115,8 @@ export type VolumeTracingAction = | CancelQuickSelectAction | ConfirmQuickSelectAction | SetVolumeBucketDataHasChangedAction - | BatchUpdateGroupsAndSegmentsAction; + | BatchUpdateGroupsAndSegmentsAction + | ApplyVolumeUpdateActionsFromServerAction; export const VolumeTracingSaveRelevantActions = [ "CREATE_CELL", @@ -133,6 +138,7 @@ export const VolumeTracingSaveRelevantActions = [ "TOGGLE_SEGMENT_GROUP", "TOGGLE_ALL_SEGMENTS", "SET_HIDE_UNREGISTERED_SEGMENTS", + "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER", ]; export const VolumeTracingUndoRelevantActions = ["START_EDITING", "COPY_SEGMENTATION_LAYER"]; @@ -471,3 +477,13 @@ export const setVolumeBucketDataHasChangedAction = (tracingId: string) => type: "SET_VOLUME_BUCKET_DATA_HAS_CHANGED", tracingId, }) as const; + +export const applyVolumeUpdateActionsFromServerAction = ( + actions: Array, +) => + ({ + type: "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER", + actions, + // version, + // author, + }) as const; diff --git a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts index 1e08cdf6f38..580712caaba 100644 --- a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts @@ -1120,6 +1120,7 @@ export function parseNml(nmlString: string): Promise<{ case "volume": { isParsingVolumeTag = true; containedVolumes = true; + break; } default: @@ -1172,6 +1173,7 @@ export function parseNml(nmlString: string): Promise<{ case "volume": { isParsingVolumeTag = false; + break; } default: diff --git a/frontend/javascripts/viewer/model/helpers/position_converter.ts b/frontend/javascripts/viewer/model/helpers/position_converter.ts index 2162345de6d..c1fd8123872 100644 --- a/frontend/javascripts/viewer/model/helpers/position_converter.ts +++ b/frontend/javascripts/viewer/model/helpers/position_converter.ts @@ -18,6 +18,22 @@ export function globalPositionToBucketPosition( additionalCoordinates || [], ]; } + +export function globalPositionToBucketPositionWithMag( + [x, y, z]: Vector3, + mag: Vector3, + additionalCoordinates: AdditionalCoordinate[] | null | undefined, +): BucketAddress { + const magIndex = Math.log2(Math.max(...mag)); + return [ + Math.floor(x / (constants.BUCKET_WIDTH * mag[0])), + Math.floor(y / (constants.BUCKET_WIDTH * mag[1])), + Math.floor(z / (constants.BUCKET_WIDTH * mag[2])), + magIndex, + additionalCoordinates || [], + ]; +} + export function scaleGlobalPositionWithMagnification( [x, y, z]: Vector3, mag: Vector3, diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 00226b96480..197ab6bec0b 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -629,6 +629,106 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos }); } + case "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER": { + const { actions } = action; + let newState = state; + for (const ua of actions) { + switch (ua.name) { + // case "createTree": { + // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = + // ua.value; + // newState = update(newState, { + // tracing: { + // skeleton: { + // trees: { + // [id]: { + // $set: { + // name, + // treeId: id, + // nodes: new DiffableMap(), + // timestamp, + // color, + // branchPoints, + // edges: new EdgeCollection(), + // comments, + // isVisible, + // groupId, + // }, + // }, + // }, + // }, + // }, + // }); + // break; + // } + case "createNode": { + if (state.annotation.skeleton == null) { + continue; + } + + const { treeId, ...serverNode } = ua.value; + // eslint-disable-next-line no-loop-func + + const { position: untransformedPosition, resolution: mag, ...node } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const tree = getTree(state.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + cachedMaxNodeId: { $set: node.id }, + }, + }, + }); + break; + } + case "createEdge": { + const { treeId, source, target } = ua.value; + // eslint-disable-next-line no-loop-func + if (state.annotation.skeleton == null) { + continue; + } + + const tree = getTree(state.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const newEdge = { + source, + target, + }; + const edges = tree.edges.addEdge(newEdge); + const newTree = update(tree, { edges: { $set: edges } }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + }, + }, + }); + break; + } + default: + } + } + return newState; + } + default: // pass } @@ -645,6 +745,8 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos // Don't create nodes if the skeleton layer is rendered with transforms. return state; } + + // use this code as template const { position, rotation, viewport, mag, treeId, timestamp, additionalCoordinates } = action; const tree = getOrCreateTree(state, skeletonTracing, treeId, timestamp, TreeTypeEnum.DEFAULT); diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index c04efa56632..64fd44d4659 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -22,12 +22,14 @@ import type { SetMappingEnabledAction, SetMappingNameAction, } from "viewer/model/actions/settings_actions"; -import type { - ClickSegmentAction, - RemoveSegmentAction, - SetSegmentsAction, - UpdateSegmentAction, - VolumeTracingAction, +import { + removeSegmentAction, + updateSegmentAction, + type ClickSegmentAction, + type RemoveSegmentAction, + type SetSegmentsAction, + type UpdateSegmentAction, + type VolumeTracingAction, } from "viewer/model/actions/volumetracing_actions"; import { updateKey2 } from "viewer/model/helpers/deep_update"; import { @@ -676,6 +678,42 @@ function VolumeTracingReducer( }); } + case "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER": { + const { actions } = action; + let newState = state; + for (const ua of actions) { + switch (ua.name) { + case "updateLargestSegmentId": { + const volumeTracing = getVolumeTracingById(state.annotation, ua.value.actionTracingId); + newState = setLargestSegmentIdReducer( + newState, + volumeTracing, + // todop: can this really be null? if so, what should we do? + ua.value.largestSegmentId ?? 0, + ); + break; + } + case "createSegment": + case "updateSegment": { + const { actionTracingId, ...segment } = ua.value; + return VolumeTracingReducer( + state, + updateSegmentAction(segment.id, segment, actionTracingId), + ); + } + case "deleteSegment": { + return VolumeTracingReducer( + state, + removeSegmentAction(ua.value.id, ua.value.actionTracingId), + ); + } + default: { + ua satisfies never; + } + } + } + } + default: return state; } diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index d8148a415c8..4bc72c1fc4a 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -1,4 +1,8 @@ -import { getNewestVersionForAnnotation, sendSaveRequestWithToken } from "admin/rest_api"; +import { + getNewestVersionForAnnotation, + sendSaveRequestWithToken, + getUpdateActionLog, +} from "admin/rest_api"; import Date from "libs/date"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; @@ -9,6 +13,7 @@ import memoizeOne from "memoize-one"; import messages from "messages"; import { buffers } from "redux-saga"; import { actionChannel, call, delay, fork, put, race, take, takeEvery } from "typed-redux-saga"; +import type { APIUpdateActionBatch } from "types/api_types"; import { ControlModeEnum } from "viewer/constants"; import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; import { selectTracing } from "viewer/model/accessors/tracing_accessor"; @@ -22,15 +27,22 @@ import { shiftSaveQueueAction, } from "viewer/model/actions/save_actions"; import type { InitializeSkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; -import { SkeletonTracingSaveRelevantActions } from "viewer/model/actions/skeletontracing_actions"; +import { + SkeletonTracingSaveRelevantActions, + applySkeletonUpdateActionsFromServerAction, +} from "viewer/model/actions/skeletontracing_actions"; import { ViewModeSaveRelevantActions } from "viewer/model/actions/view_mode_actions"; import { + applyVolumeUpdateActionsFromServerAction, type InitializeVolumeTracingAction, VolumeTracingSaveRelevantActions, } from "viewer/model/actions/volumetracing_actions"; import compactSaveQueue from "viewer/model/helpers/compaction/compact_save_queue"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; -import { globalPositionToBucketPosition } from "viewer/model/helpers/position_converter"; +import { + globalPositionToBucketPosition, + globalPositionToBucketPositionWithMag, +} from "viewer/model/helpers/position_converter"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { ensureWkReady } from "viewer/model/sagas/ready_sagas"; @@ -499,9 +511,10 @@ export function* setupSavingForTracingType( } } -const VERSION_POLL_INTERVAL_COLLAB = 10 * 1000; -const VERSION_POLL_INTERVAL_READ_ONLY = 60 * 1000; -const VERSION_POLL_INTERVAL_SINGLE_EDITOR = 30 * 1000; +// todop: restore to 10, 60, 30 ? +const VERSION_POLL_INTERVAL_COLLAB = 1 * 1000; +const VERSION_POLL_INTERVAL_READ_ONLY = 1 * 1000; +const VERSION_POLL_INTERVAL_SINGLE_EDITOR = 1 * 1000; function* watchForSaveConflicts(): Saga { function* checkForNewVersion() { @@ -558,10 +571,33 @@ function* watchForSaveConflicts(): Saga { }); const toastKey = "save_conflicts_warning"; - if (versionOnServer > versionOnClient) { + const newerVersionCount = versionOnServer - versionOnClient; + if (newerVersionCount > 0) { // The latest version on the server is greater than the most-recently // stored version. + const { url: tracingStoreUrl } = yield* select((state) => state.annotation.tracingStore); + + const newerActions = yield* call( + getUpdateActionLog, + tracingStoreUrl, + annotationId, + versionOnClient + 1, + ); + + if (newerActions.length !== newerVersionCount) { + // todop: maybe default to showing the "please reload" toast + // as it's not critical here? + throw new Error("unexpected error"); + } + + console.log("newerActions", newerActions); + + if (yield* canActionsBeIncorporated(newerActions)) { + yield* put(setVersionNumberAction(versionOnServer)); + return; + } + const saveQueue = yield* select((state) => state.save.queue); let msg = ""; @@ -617,8 +653,121 @@ function* watchForSaveConflicts(): Saga { console.warn(exception); // @ts-ignore ErrorHandling.notify(exception); + // todop: remove again? + Toast.error(exception); + } + } +} + +function* canActionsBeIncorporated(newerActions: APIUpdateActionBatch[]): Saga { + for (const actionBatch of newerActions) { + for (const action of actionBatch.value) { + switch (action.name) { + // Updates to user-specific state can be ignored: + // Camera + case "updateCamera": + case "updateTdCamera": + // Active items + case "updateActiveNode": + case "updateActiveSegmentId": + // Visibilities + case "updateTreeVisibility": + case "updateTreeGroupVisibility": + case "updateSegmentVisibility": + case "updateSegmentGroupVisibility": + case "updateUserBoundingBoxVisibilityInSkeletonTracing": + case "updateUserBoundingBoxVisibilityInVolumeTracing": + // Group expansion + case "updateTreeGroupsExpandedState": + case "updateSegmentGroupsExpandedState": { + break; + } + case "createNode": + case "createEdge": { + yield* put(applySkeletonUpdateActionsFromServerAction([action])); + break; + } + case "updateBucket": { + const { value } = action; + const cube = Model.getCubeByLayerName(value.actionTracingId); + + const dataLayer = Model.getLayerByName(value.actionTracingId); + const bucketAddress = globalPositionToBucketPositionWithMag( + value.position, + value.mag, + value.additionalCoordinates, + ); + + const bucket = cube.getBucket(bucketAddress); + if (bucket != null && bucket.type !== "null") { + cube.collectBucket(bucket); + dataLayer.layerRenderingManager.refresh(); + } + break; + } + case "deleteSegmentData": { + const { value } = action; + const { actionTracingId, id } = value; + const cube = Model.getCubeByLayerName(actionTracingId); + const dataLayer = Model.getLayerByName(actionTracingId); + + cube.collectBucketsIf((bucket) => bucket.containsValue(id)); + dataLayer.layerRenderingManager.refresh(); + break; + } + case "updateLargestSegmentId": + case "createSegment": + case "deleteSegment": + case "updateSegment": { + yield* put(applyVolumeUpdateActionsFromServerAction([action])); + break; + } + + // High-level annotation specific + case "addLayerToAnnotation": + case "addSegmentIndex": + case "createTracing": + case "deleteLayerFromAnnotation": + case "importVolumeTracing": + case "revertToVersion": + case "updateLayerMetadata": + case "updateMappingName": + case "updateMetadataOfAnnotation": + + // Volume + case "removeFallbackLayer": + case "updateSegmentGroups": + case "updateUserBoundingBoxesInVolumeTracing": + + // Proofreading + case "mergeAgglomerate": + case "splitAgglomerate": + + // Skeleton + case "createTree": + case "deleteEdge": + case "deleteNode": + case "deleteTree": + case "mergeTree": // todop: is this really skeleton? + case "updateSkeletonTracing": + case "updateTree": + case "updateTreeEdgesVisibility": + case "updateTreeGroups": + case "moveTreeComponent": + case "updateNode": + case "updateUserBoundingBoxesInSkeletonTracing": + + case "updateVolumeTracing": { + console.log("cannot apply action", action.name); + return false; + } + default: { + action satisfies never; + } + } } } + return true; } export default [saveTracingAsync, watchForSaveConflicts]; diff --git a/frontend/javascripts/viewer/model/sagas/save_saga_constants.ts b/frontend/javascripts/viewer/model/sagas/save_saga_constants.ts index dbc7dba6729..0f9a230704e 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga_constants.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga_constants.ts @@ -1,6 +1,7 @@ // The save saga uses a retry mechanism which is based // on exponential back-off. -export const PUSH_THROTTLE_TIME = 30000; // 30s +// todop: restore to 30s +export const PUSH_THROTTLE_TIME = 5000; // 30s export const SAVE_RETRY_WAITING_TIME = 2000; export const MAX_SAVE_RETRY_WAITING_TIME = 300000; // 5m diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/update_actions.ts index a68b389b5cf..4903dce4403 100644 --- a/frontend/javascripts/viewer/model/sagas/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/update_actions.ts @@ -105,6 +105,14 @@ export type UpdateAction = | UpdateActionWithoutIsolationRequirement | UpdateActionWithIsolationRequirement; +export type ApplicableSkeletonUpdateAction = CreateNodeUpdateAction | CreateEdgeUpdateAction; + +export type ApplicableVolumeUpdateAction = + | UpdateLargestSegmentIdVolumeAction + | UpdateSegmentUpdateAction + | CreateSegmentUpdateAction + | DeleteSegmentUpdateAction; + export type UpdateActionWithIsolationRequirement = | RevertToVersionUpdateAction | AddLayerToAnnotationUpdateAction; diff --git a/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx b/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx index 390efb86d3a..2ca4d1e5ad4 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx @@ -31,6 +31,7 @@ import type { StoreAnnotation, Task, WebknossosState } from "viewer/store"; import { getOrganization } from "admin/rest_api"; import FastTooltip from "components/fast_tooltip"; +import { useWkSelector } from "libs/react_hooks"; import { mayUserEditDataset, pluralize, safeNumberToStr } from "libs/utils"; import messages from "messages"; import type { EmptyObject } from "types/globals"; @@ -624,6 +625,7 @@ export class DatasetInfoTabView extends React.PureComponent { return (
+ {this.getAnnotationName()} {this.getAnnotationDescription()} {this.getDatasetName()} @@ -653,6 +655,14 @@ export class DatasetInfoTabView extends React.PureComponent { } } +// todop: remove again +function DebugInfo() { + const versionOnClient = useWkSelector((state) => { + return state.annotation.version; + }); + return <>Version: {versionOnClient}; +} + const mapStateToProps = (state: WebknossosState): StateProps => ({ annotation: state.annotation, dataset: state.dataset, From ce266b013b1b4dfe50a396c6d29365833bf27443 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 22 May 2025 11:07:23 +0200 Subject: [PATCH 02/92] stop polling aggressively when an unapplicable update was encountered --- .../viewer/model/sagas/save_saga.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 4bc72c1fc4a..ef1706ca1b2 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -517,7 +517,14 @@ const VERSION_POLL_INTERVAL_READ_ONLY = 1 * 1000; const VERSION_POLL_INTERVAL_SINGLE_EDITOR = 1 * 1000; function* watchForSaveConflicts(): Saga { - function* checkForNewVersion() { + function* checkForNewVersion(): boolean { + /* + * Checks whether there is a newer version on the server. If so, + * the saga tries to also update the current annotation to the newest + * state. + * If the update is not possible, the user will be notified that a newer + * version exists on the server. In that case, true will be returned (`didAskUserToRefreshPage`). + */ const allowSave = yield* select( (state) => state.annotation.restrictions.allowSave && state.annotation.restrictions.allowUpdate, @@ -540,7 +547,7 @@ function* watchForSaveConflicts(): Saga { // b) checking for newer versions when the active user may update the annotation introduces // a race condition between this saga and the actual save saga. Synchronizing these sagas // would be possible, but would add further complexity to the mission critical save saga. - return; + return false; } const maybeSkeletonTracing = yield* select((state) => state.annotation.skeleton); @@ -554,7 +561,7 @@ function* watchForSaveConflicts(): Saga { ]); if (tracings.length === 0) { - return; + return false; } const versionOnServer = yield* call( @@ -595,7 +602,7 @@ function* watchForSaveConflicts(): Saga { if (yield* canActionsBeIncorporated(newerActions)) { yield* put(setVersionNumberAction(versionOnServer)); - return; + return false; } const saveQueue = yield* select((state) => state.save.queue); @@ -615,9 +622,11 @@ function* watchForSaveConflicts(): Saga { sticky: true, key: toastKey, }); + return true; } else { Toast.close(toastKey); } + return false; } function* getPollInterval(): Saga { @@ -646,7 +655,12 @@ function* watchForSaveConflicts(): Saga { continue; } try { - yield* call(checkForNewVersion); + const didAskUserToRefreshPage = yield* call(checkForNewVersion); + if (didAskUserToRefreshPage) { + // The user was already notified about the current annotation being outdated. + // There is not much else we can do now. Sleep for 5 minutes. + yield* call(sleep, 5 * 60 * 1000); + } } catch (exception) { // If the version check fails for some reason, we don't want to crash the entire // saga. @@ -687,6 +701,8 @@ function* canActionsBeIncorporated(newerActions: APIUpdateActionBatch[]): Saga Date: Thu, 22 May 2025 14:02:53 +0200 Subject: [PATCH 03/92] live update to proofreading actions --- .../model/reducers/volumetracing_reducer.ts | 5 +- .../viewer/model/sagas/mapping_saga.ts | 13 ++- .../viewer/model/sagas/proofread_saga.ts | 88 ++++++++++++++---- .../viewer/model/sagas/save_saga.ts | 90 ++++++++++++++++--- 4 files changed, 155 insertions(+), 41 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 64fd44d4659..76e9c0be737 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -23,13 +23,13 @@ import type { SetMappingNameAction, } from "viewer/model/actions/settings_actions"; import { - removeSegmentAction, - updateSegmentAction, type ClickSegmentAction, type RemoveSegmentAction, type SetSegmentsAction, type UpdateSegmentAction, type VolumeTracingAction, + removeSegmentAction, + updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import { updateKey2 } from "viewer/model/helpers/deep_update"; import { @@ -712,6 +712,7 @@ function VolumeTracingReducer( } } } + break; } default: diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts index 6d4a0a20ba6..c8c1d59a15d 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts @@ -257,7 +257,7 @@ function* watchChangedBucketsForLayer(layerName: string): Saga { const mappingInfo = yield* select((state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, layerName), ); - const { mappingName, mappingType, mappingStatus } = mappingInfo; + const { mappingName, mappingStatus } = mappingInfo; if (mappingName == null || mappingStatus !== MappingStatusEnum.ENABLED) { return; @@ -271,7 +271,7 @@ function* watchChangedBucketsForLayer(layerName: string): Saga { let isBusy = yield* select((state) => state.uiInformation.busyBlockingInfo.isBusy); if (!isBusy) { const { cancel } = yield* race({ - updateHdf5: call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName, mappingType), + updateHdf5: call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName), cancel: take( ((action: Action) => action.type === "SET_BUSY_BLOCKING_INFO_ACTION" && @@ -397,7 +397,6 @@ function* handleSetMapping( layerName, layerInfo, mappingName, - mappingType, action, oldActiveMappingByLayer, ); @@ -408,23 +407,21 @@ function* handleSetHdf5Mapping( layerName: string, layerInfo: APIDataLayer, mappingName: string, - mappingType: MappingType, action: SetMappingAction, oldActiveMappingByLayer: Container>, ): Saga { if (yield* select((state) => getNeedsLocalHdf5Mapping(state, layerName))) { - yield* call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName, mappingType); + yield* call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName); } else { // An HDF5 mapping was set that is applied remotely. A reload is necessary. yield* call(reloadData, oldActiveMappingByLayer, action); } } -function* updateLocalHdf5Mapping( +export function* updateLocalHdf5Mapping( layerName: string, layerInfo: APIDataLayer, mappingName: string, - mappingType: MappingType, ): Saga { const dataset = yield* select((state) => state.dataset); const annotation = yield* select((state) => state.annotation); @@ -490,7 +487,7 @@ function* updateLocalHdf5Mapping( onlyB: newSegmentIds, }); - yield* put(setMappingAction(layerName, mappingName, mappingType, { mapping })); + yield* put(setMappingAction(layerName, mappingName, "HDF5", { mapping })); } function* handleSetJsonMapping( diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index edd25a2780b..58c8592553d 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -396,17 +396,13 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { volumeTracingId, ), ); - const mergedMapping = yield* call( - mergeAgglomeratesInMapping, + yield* call( + updateMappingWithMerge, + volumeTracingId, activeMapping, targetAgglomerateId, sourceAgglomerateId, ); - yield* put( - setMappingAction(volumeTracingId, activeMapping.mappingName, activeMapping.mappingType, { - mapping: mergedMapping, - }), - ); } else if (action.type === "DELETE_EDGE") { if (sourceAgglomerateId !== targetAgglomerateId) { Toast.error("Segments that should be split need to be in the same agglomerate."); @@ -741,18 +737,13 @@ function* handleProofreadMergeOrMinCut(action: Action) { sourceInfo.unmappedId, targetInfo.unmappedId, ); - const mergedMapping = yield* call( - mergeAgglomeratesInMapping, + yield* call( + updateMappingWithMerge, + volumeTracingId, activeMapping, targetAgglomerateId, sourceAgglomerateId, ); - - yield* put( - setMappingAction(volumeTracingId, activeMapping.mappingName, activeMapping.mappingType, { - mapping: mergedMapping, - }), - ); } else if (action.type === "MIN_CUT_AGGLOMERATE") { if (sourceInfo.unmappedId === targetInfo.unmappedId) { Toast.error( @@ -1249,10 +1240,9 @@ function* getPositionForSegmentId(volumeTracing: VolumeTracing, segmentId: numbe return position; } -function* splitAgglomerateInMapping( +function getSegmentIdsThatMapToAgglomerate( activeMapping: ActiveMappingInfo, sourceAgglomerateId: number, - volumeTracingId: string, ) { // Obtain all segment ids that map to sourceAgglomerateId const mappingEntries = Array.from(activeMapping.mapping as NumberLikeMap); @@ -1264,10 +1254,17 @@ function* splitAgglomerateInMapping( // If the mapping contains BigInts, we need a BigInt for the filtering const comparableSourceAgglomerateId = adaptToType(sourceAgglomerateId); - const splitSegmentIds = mappingEntries + return mappingEntries .filter(([_segmentId, agglomerateId]) => agglomerateId === comparableSourceAgglomerateId) .map(([segmentId, _agglomerateId]) => segmentId); +} +function* splitAgglomerateInMapping( + activeMapping: ActiveMappingInfo, + sourceAgglomerateId: number, + volumeTracingId: string, +) { + const splitSegmentIds = getSegmentIdsThatMapToAgglomerate(activeMapping, sourceAgglomerateId); const annotationId = yield* select((state) => state.annotation.annotationId); const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); // Ask the server to map the (split) segment ids. This creates a partial mapping @@ -1314,6 +1311,61 @@ function mergeAgglomeratesInMapping( ) as Mapping; } +export function* updateMappingWithMerge( + volumeTracingId: string, + activeMapping: ActiveMappingInfo, + targetAgglomerateId: number, + sourceAgglomerateId: number, +) { + const mergedMapping = yield* call( + mergeAgglomeratesInMapping, + activeMapping, + targetAgglomerateId, + sourceAgglomerateId, + ); + yield* put( + setMappingAction(volumeTracingId, activeMapping.mappingName, activeMapping.mappingType, { + mapping: mergedMapping, + }), + ); +} + +export function* updateMappingWithOmittedSplitPartners( + volumeTracingId: string, + activeMapping: ActiveMappingInfo, + sourceAgglomerateId: number, +) { + /* + * sourceAgglomerateId was split. All segment ids that were mapped to sourceAgglomerateId, + * are removed from the activeMapping by this function. + * The return value of this function is the list of segment ids that were removed. + */ + + const mappingEntries = Array.from(activeMapping.mapping as NumberLikeMap); + + const adaptToType = + mappingEntries.length > 0 && isBigInt(mappingEntries[0][0]) + ? (el: number) => BigInt(el) + : (el: number) => el; + // If the mapping contains BigInts, we need a BigInt for the filtering + const comparableSourceAgglomerateId = adaptToType(sourceAgglomerateId); + + const newMapping = new Map(); + + for (const entry of mappingEntries) { + const [key, value] = entry; + if (value !== comparableSourceAgglomerateId) { + newMapping.set(key, value); + } + } + + yield* put( + setMappingAction(volumeTracingId, activeMapping.mappingName, activeMapping.mappingType, { + mapping: newMapping, + }), + ); +} + function* gatherInfoForOperation( action: ProofreadMergeAction | MinCutAgglomerateWithPositionAction, preparation: Preparation, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index ef1706ca1b2..ae95932d51a 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -15,7 +15,11 @@ import { buffers } from "redux-saga"; import { actionChannel, call, delay, fork, put, race, take, takeEvery } from "typed-redux-saga"; import type { APIUpdateActionBatch } from "types/api_types"; import { ControlModeEnum } from "viewer/constants"; -import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; +import { + getLayerByName, + getMagInfo, + getMappingInfo, +} from "viewer/model/accessors/dataset_accessor"; import { selectTracing } from "viewer/model/accessors/tracing_accessor"; import { FlycamActions } from "viewer/model/actions/flycam_actions"; import { @@ -33,9 +37,9 @@ import { } from "viewer/model/actions/skeletontracing_actions"; import { ViewModeSaveRelevantActions } from "viewer/model/actions/view_mode_actions"; import { - applyVolumeUpdateActionsFromServerAction, type InitializeVolumeTracingAction, VolumeTracingSaveRelevantActions, + applyVolumeUpdateActionsFromServerAction, } from "viewer/model/actions/volumetracing_actions"; import compactSaveQueue from "viewer/model/helpers/compaction/compact_save_queue"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; @@ -70,6 +74,8 @@ import type { import { getFlooredPosition, getRotation } from "../accessors/flycam_accessor"; import type { Action } from "../actions/actions"; import type { BatchedAnnotationInitializationAction } from "../actions/annotation_actions"; +import { updateLocalHdf5Mapping } from "./mapping_saga"; +import { updateMappingWithMerge, updateMappingWithOmittedSplitPartners } from "./proofread_saga"; import { takeEveryWithBatchActionSupport } from "./saga_helpers"; const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; @@ -517,7 +523,7 @@ const VERSION_POLL_INTERVAL_READ_ONLY = 1 * 1000; const VERSION_POLL_INTERVAL_SINGLE_EDITOR = 1 * 1000; function* watchForSaveConflicts(): Saga { - function* checkForNewVersion(): boolean { + function* checkForNewVersion(): Saga { /* * Checks whether there is a newer version on the server. If so, * the saga tries to also update the current annotation to the newest @@ -600,8 +606,7 @@ function* watchForSaveConflicts(): Saga { console.log("newerActions", newerActions); - if (yield* canActionsBeIncorporated(newerActions)) { - yield* put(setVersionNumberAction(versionOnServer)); + if (yield* tryToIncorporateActions(newerActions)) { return false; } @@ -668,12 +673,18 @@ function* watchForSaveConflicts(): Saga { // @ts-ignore ErrorHandling.notify(exception); // todop: remove again? - Toast.error(exception); + Toast.error(`${exception}`); } } } -function* canActionsBeIncorporated(newerActions: APIUpdateActionBatch[]): Saga { +function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga { + const refreshFunctionByTracing: Record Saga> = {}; + function* finalize() { + for (const fn of Object.values(refreshFunctionByTracing)) { + yield* call(fn); + } + } for (const actionBatch of newerActions) { for (const action of actionBatch.value) { switch (action.name) { @@ -739,6 +750,60 @@ function* canActionsBeIncorporated(newerActions: APIUpdateActionBatch[]): Saga + store.temporaryConfiguration.activeMappingByLayer[action.value.actionTracingId], + ); + yield* call( + updateMappingWithMerge, + action.value.actionTracingId, + activeMapping, + action.value.agglomerateId2, + action.value.agglomerateId1, + ); + break; + } + case "splitAgglomerate": { + const activeMapping = yield* select( + (store) => + store.temporaryConfiguration.activeMappingByLayer[action.value.actionTracingId], + ); + yield* call( + updateMappingWithOmittedSplitPartners, + action.value.actionTracingId, + activeMapping, + action.value.agglomerateId, + ); + + const layerName = action.value.actionTracingId; + + const mappingInfo = yield* select((state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, layerName), + ); + const { mappingName } = mappingInfo; + + if (mappingName == null) { + throw new Error( + "Could not apply splitAgglomerate because no active mapping was found.", + ); + } + + const dataset = yield* select((state) => state.dataset); + const layerInfo = getLayerByName(dataset, layerName); + + refreshFunctionByTracing[layerName] = function* (): Saga { + yield* call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName); + }; + + break; + } + + /* + * Currently not supported: + */ + // High-level annotation specific case "addLayerToAnnotation": case "addSegmentIndex": @@ -752,13 +817,9 @@ function* canActionsBeIncorporated(newerActions: APIUpdateActionBatch[]): Saga Date: Thu, 22 May 2025 14:19:55 +0200 Subject: [PATCH 04/92] update comment --- frontend/javascripts/viewer/model/sagas/proofread_saga.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index 58c8592553d..30338792e80 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -1336,9 +1336,8 @@ export function* updateMappingWithOmittedSplitPartners( sourceAgglomerateId: number, ) { /* - * sourceAgglomerateId was split. All segment ids that were mapped to sourceAgglomerateId, + * When sourceAgglomerateId was split, all segment ids that were mapped to sourceAgglomerateId, * are removed from the activeMapping by this function. - * The return value of this function is the list of segment ids that were removed. */ const mappingEntries = Array.from(activeMapping.mapping as NumberLikeMap); From 19cffd858f4b1fabef09876e3db80e3e44050e18 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 13:51:12 +0200 Subject: [PATCH 05/92] integrate live bbox updates for skeleton tracing --- .../model/actions/annotation_actions.ts | 2 +- .../model/reducers/annotation_reducer.ts | 46 ++- .../model/reducers/skeletontracing_reducer.ts | 281 ++++++++++++------ .../model/reducers/volumetracing_reducer.ts | 6 + .../viewer/model/sagas/save_saga.ts | 18 +- .../viewer/model/sagas/update_actions.ts | 55 ++-- frontend/javascripts/viewer/store.ts | 4 +- 7 files changed, 283 insertions(+), 129 deletions(-) diff --git a/frontend/javascripts/viewer/model/actions/annotation_actions.ts b/frontend/javascripts/viewer/model/actions/annotation_actions.ts index 32dbf01b6b8..5182bf2bc71 100644 --- a/frontend/javascripts/viewer/model/actions/annotation_actions.ts +++ b/frontend/javascripts/viewer/model/actions/annotation_actions.ts @@ -58,7 +58,7 @@ type FinishedResizingUserBoundingBoxAction = ReturnType< >; type AddUserBoundingBoxesAction = ReturnType; type AddNewUserBoundingBox = ReturnType; -type ChangeUserBoundingBoxAction = ReturnType; +export type ChangeUserBoundingBoxAction = ReturnType; type DeleteUserBoundingBox = ReturnType; export type UpdateMeshVisibilityAction = ReturnType; export type UpdateMeshOpacityAction = ReturnType; diff --git a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts index 40ae372612e..b4c49953ed3 100644 --- a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts @@ -7,7 +7,14 @@ import { maybeGetSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { getDisplayedDataExtentInPlaneMode } from "viewer/model/accessors/view_mode_accessor"; import type { Action } from "viewer/model/actions/actions"; import { updateKey, updateKey2 } from "viewer/model/helpers/deep_update"; -import type { MeshInformation, UserBoundingBox, WebknossosState } from "viewer/store"; +import type { + MeshInformation, + SkeletonTracing, + UserBoundingBox, + UserBoundingBoxWithoutIdMaybe, + VolumeTracing, + WebknossosState, +} from "viewer/store"; import { getDatasetBoundingBox } from "../accessors/dataset_accessor"; import { getAdditionalCoordinatesAsString } from "../accessors/flycam_accessor"; import { getMeshesForAdditionalCoordinates } from "../accessors/volumetracing_accessor"; @@ -56,6 +63,41 @@ const updateUserBoundingBoxes = ( }); }; +export function handleUserBoundingBoxUpdateInTracing( + state: WebknossosState, + tracing: SkeletonTracing | VolumeTracing, + updatedUserBoundingBoxes: UserBoundingBox[], +) { + if (tracing.type === "skeleton") { + return update(state, { + annotation: { + skeleton: { + userBoundingBoxes: { + $set: updatedUserBoundingBoxes, + }, + }, + }, + }); + } + + const newVolumes = state.annotation.volumes.map((volumeTracing) => + tracing.tracingId === volumeTracing.tracingId + ? { + ...volumeTracing, + updatedUserBoundingBoxes, + } + : volumeTracing, + ); + + return update(state, { + annotation: { + volumes: { + $set: newVolumes, + }, + }, + }); +} + const maybeAddAdditionalCoordinatesToMeshState = ( state: WebknossosState, additionalCoordinates: AdditionalCoordinate[] | null | undefined, @@ -151,8 +193,6 @@ function AnnotationReducer(state: WebknossosState, action: Action): WebknossosSt const updatedUserBoundingBoxes = tracing.userBoundingBoxes.map((bbox) => bbox.id === action.id ? { - // @ts-expect-error ts-migrate(2783) FIXME: 'id' is specified more than once, so this usage wi... Remove this comment to see the full error message - id: bbox.id, ...bbox, ...action.newProps, } diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 197ab6bec0b..8c945a9f66b 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -46,7 +46,7 @@ import { toggleTreeGroupReducer, } from "viewer/model/reducers/skeletontracing_reducer_helpers"; import { type TreeGroup, TreeMap } from "viewer/model/types/tree_types"; -import type { SkeletonTracing, WebknossosState } from "viewer/store"; +import type { SkeletonTracing, UserBoundingBox, WebknossosState } from "viewer/store"; import { GroupTypeEnum, additionallyExpandGroup, @@ -54,6 +54,8 @@ import { } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { getUserStateForTracing } from "../accessors/annotation_accessor"; import { max, maxBy } from "../helpers/iterator_utils"; +import { handleUserBoundingBoxUpdateInTracing } from "./annotation_reducer"; +import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; function SkeletonTracingReducer(state: WebknossosState, action: Action): WebknossosState { if (action.type === "INITIALIZE_SKELETONTRACING") { @@ -631,102 +633,7 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos case "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - let newState = state; - for (const ua of actions) { - switch (ua.name) { - // case "createTree": { - // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = - // ua.value; - // newState = update(newState, { - // tracing: { - // skeleton: { - // trees: { - // [id]: { - // $set: { - // name, - // treeId: id, - // nodes: new DiffableMap(), - // timestamp, - // color, - // branchPoints, - // edges: new EdgeCollection(), - // comments, - // isVisible, - // groupId, - // }, - // }, - // }, - // }, - // }, - // }); - // break; - // } - case "createNode": { - if (state.annotation.skeleton == null) { - continue; - } - - const { treeId, ...serverNode } = ua.value; - // eslint-disable-next-line no-loop-func - - const { position: untransformedPosition, resolution: mag, ...node } = serverNode; - const clientNode = { untransformedPosition, mag, ...node }; - - const tree = getTree(state.annotation.skeleton, treeId); - if (tree == null) { - // todop: escalate error? - continue; - } - const diffableNodeMap = tree.nodes; - const newDiffableMap = diffableNodeMap.set(node.id, clientNode); - const newTree = update(tree, { - nodes: { $set: newDiffableMap }, - }); - newState = update(newState, { - annotation: { - skeleton: { - trees: { - [tree.treeId]: { $set: newTree }, - }, - cachedMaxNodeId: { $set: node.id }, - }, - }, - }); - break; - } - case "createEdge": { - const { treeId, source, target } = ua.value; - // eslint-disable-next-line no-loop-func - if (state.annotation.skeleton == null) { - continue; - } - - const tree = getTree(state.annotation.skeleton, treeId); - if (tree == null) { - // todop: escalate error? - continue; - } - const newEdge = { - source, - target, - }; - const edges = tree.edges.addEdge(newEdge); - const newTree = update(tree, { edges: { $set: edges } }); - newState = update(newState, { - annotation: { - skeleton: { - trees: { - [tree.treeId]: { $set: newTree }, - }, - }, - }, - }); - break; - } - default: - } - } - return newState; + return applySkeletonUpdateActionsFromServer(actions, state).value; } default: // pass @@ -1366,6 +1273,186 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos } } +function applySkeletonUpdateActionsFromServer( + actions: ApplicableSkeletonUpdateAction[], + newState: WebknossosState, +) { + for (const ua of actions) { + switch (ua.name) { + // case "createTree": { + // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = + // ua.value; + // newState = update(newState, { + // tracing: { + // skeleton: { + // trees: { + // [id]: { + // $set: { + // name, + // treeId: id, + // nodes: new DiffableMap(), + // timestamp, + // color, + // branchPoints, + // edges: new EdgeCollection(), + // comments, + // isVisible, + // groupId, + // }, + // }, + // }, + // }, + // }, + // }); + // break; + // } + case "createNode": { + if (newState.annotation.skeleton == null) { + continue; + } + + const { treeId, ...serverNode } = ua.value; + // eslint-disable-next-line no-loop-func + const { position: untransformedPosition, resolution: mag, ...node } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const tree = getTree(newState.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + cachedMaxNodeId: { $set: node.id }, + }, + }, + }); + break; + } + case "createEdge": { + const { treeId, source, target } = ua.value; + // eslint-disable-next-line no-loop-func + if (newState.annotation.skeleton == null) { + continue; + } + + const tree = getTree(newState.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const newEdge = { + source, + target, + }; + const edges = tree.edges.addEdge(newEdge); + const newTree = update(tree, { edges: { $set: edges } }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + }, + }, + }); + break; + } + case "updateUserBoundingBoxInSkeletonTracing": { + // todop: dont pass state and instead do the update here? + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const { boundingBox, ...valueWithoutBoundingBox } = ua.value; + const maybeBoundingBoxValue = + boundingBox != null + ? { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox) } + : {}; + + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.map((bbox) => + bbox.id === ua.value.boundingBoxId + ? { + ...bbox, + ...valueWithoutBoundingBox, + ...maybeBoundingBoxValue, + } + : bbox, + ); + + newState = handleUserBoundingBoxUpdateInTracing( + newState, + skeleton, + updatedUserBoundingBoxes, + ); + break; + } + case "addUserBoundingBoxInSkeletonTracing": { + // todop: dont pass state and instead do the update here? + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const { boundingBox, ...valueWithoutBoundingBox } = ua.value.boundingBox; + const maybeBoundingBoxValue = { + boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox), + }; + const newUserBBox: UserBoundingBox = { + // The visibility is stored per user. Therefore, we default to true here. + isVisible: true, + ...valueWithoutBoundingBox, + ...maybeBoundingBoxValue, + }; + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.concat([newUserBBox]); + + newState = handleUserBoundingBoxUpdateInTracing( + newState, + skeleton, + updatedUserBoundingBoxes, + ); + break; + } + case "deleteUserBoundingBoxInSkeletonTracing": { + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.filter( + (bbox) => bbox.id !== ua.value.boundingBoxId, + ); + + newState = handleUserBoundingBoxUpdateInTracing( + newState, + skeleton, + updatedUserBoundingBoxes, + ); + + break; + } + default: { + ua satisfies never; + } + } + } + + // The state is wrapped in this container object to prevent the above switch-cases from + // accidentally returning newState (this is the usual way in reducers but would ignore + // remaining update actions). + return { value: newState }; +} + export function sanitizeMetadata(metadata: MetadataEntryProto[]) { // Workaround for stringList values that are [], even though they // should be null. This workaround is necessary because protobuf cannot diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 76e9c0be737..51d9163c911 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -707,6 +707,12 @@ function VolumeTracingReducer( removeSegmentAction(ua.value.id, ua.value.actionTracingId), ); } + case "updateUserBoundingBoxInVolumeTracing": + case "addUserBoundingBoxInVolumeTracing": + case "deleteUserBoundingBoxInVolumeTracing": { + // todop + return state; + } default: { ua satisfies never; } diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index ae95932d51a..8033ed4514b 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -688,6 +688,7 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga); return { - name: actionName, - value: { - boundingBoxId, - actionTracingId, - ...updatedPropsForServer, - }, - } as const; + boundingBoxId, + actionTracingId, + ...updatedPropsForServer, + }; } export function updateUserBoundingBoxInVolumeTracing( @@ -596,12 +607,10 @@ export function updateUserBoundingBoxInVolumeTracing( updatedProps: PartialBoundingBoxWithoutVisibility, actionTracingId: string, ) { - return _updateUserBoundingBoxHelper( - "updateUserBoundingBoxInVolumeTracing", - boundingBoxId, - updatedProps, - actionTracingId, - ); + return { + name: "updateUserBoundingBoxInVolumeTracing", + value: _updateUserBoundingBoxHelper(boundingBoxId, updatedProps, actionTracingId), + } as const; } export function updateUserBoundingBoxInSkeletonTracing( @@ -609,12 +618,10 @@ export function updateUserBoundingBoxInSkeletonTracing( updatedProps: PartialBoundingBoxWithoutVisibility, actionTracingId: string, ) { - return _updateUserBoundingBoxHelper( - "updateUserBoundingBoxInSkeletonTracing", - boundingBoxId, - updatedProps, - actionTracingId, - ); + return { + name: "updateUserBoundingBoxInSkeletonTracing", + value: _updateUserBoundingBoxHelper(boundingBoxId, updatedProps, actionTracingId), + } as const; } export function updateUserBoundingBoxVisibilityInSkeletonTracing( diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 0edcc7ebdc8..ab59f6c2dfd 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -91,8 +91,8 @@ export type BoundingBoxObject = { export type UserBoundingBoxToServer = { boundingBox: BoundingBoxObject; id: number; - name?: string; - color?: Vector3; + name: string; + color: Vector3; isVisible?: boolean; }; export type UserBoundingBoxWithoutIdMaybe = { From e4aeb61c07338039adceca99266a41329da8d7eb Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 13:55:25 +0200 Subject: [PATCH 06/92] rename BoundingBoxType to BoundingBoxMinMaxType --- frontend/javascripts/libs/utils.ts | 24 ++++++++++++------- frontend/javascripts/libs/vector_input.tsx | 8 +++---- frontend/javascripts/types/api_types.ts | 2 +- frontend/javascripts/viewer/api/api_latest.ts | 10 ++++---- frontend/javascripts/viewer/constants.ts | 2 +- .../combinations/bounding_box_handlers.ts | 4 ++-- .../viewer/controller/scene_controller.ts | 4 ++-- .../bucket_data_handling/bounding_box.ts | 6 ++--- .../model/bucket_data_handling/bucket.ts | 4 ++-- .../model/bucket_data_handling/data_cube.ts | 6 ++--- .../prefetch_strategy_arbitrary.ts | 8 +++---- .../viewer/model/helpers/nml_helpers.ts | 4 ++-- .../model/reducers/annotation_reducer.ts | 3 +-- .../viewer/model/reducers/reducer_helpers.ts | 8 +++---- .../model/reducers/skeletontracing_reducer.ts | 2 +- .../viewer/model/sagas/min_cut_saga.ts | 7 ++++-- .../model/sagas/volume/floodfill_saga.tsx | 6 ++--- frontend/javascripts/viewer/store.ts | 8 +++---- .../view/action-bar/download_modal_view.tsx | 6 ++--- .../right-border-tabs/bounding_box_tab.tsx | 4 ++-- 20 files changed, 67 insertions(+), 59 deletions(-) diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 66b229b60c3..0fc5ee9c0a6 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -5,7 +5,7 @@ import _ from "lodash"; import type { APIDataset, APIUser, MapEntries } from "types/api_types"; import type { ArbitraryObject, Comparator } from "types/globals"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, ColorObject, Point3, TypedArray, @@ -276,7 +276,7 @@ export function getRandomColor(): Vector3 { return randomColor as any as Vector3; } -export function computeBoundingBoxFromArray(bb: Vector6): BoundingBoxType { +export function computeBoundingBoxFromArray(bb: Vector6): BoundingBoxMinMaxType { const [x, y, z, width, height, depth] = bb; return { min: [x, y, z], @@ -284,11 +284,15 @@ export function computeBoundingBoxFromArray(bb: Vector6): BoundingBoxType { }; } -export function computeBoundingBoxFromBoundingBoxObject(bb: BoundingBoxObject): BoundingBoxType { +export function computeBoundingBoxFromBoundingBoxObject( + bb: BoundingBoxObject, +): BoundingBoxMinMaxType { return computeBoundingBoxFromArray([...bb.topLeft, bb.width, bb.height, bb.depth]); } -export function computeBoundingBoxObjectFromBoundingBox(bb: BoundingBoxType): BoundingBoxObject { +export function computeBoundingBoxObjectFromBoundingBox( + bb: BoundingBoxMinMaxType, +): BoundingBoxObject { const boundingBoxArray = computeArrayFromBoundingBox(bb); return { topLeft: [boundingBoxArray[0], boundingBoxArray[1], boundingBoxArray[2]], @@ -298,7 +302,7 @@ export function computeBoundingBoxObjectFromBoundingBox(bb: BoundingBoxType): Bo }; } -export function computeArrayFromBoundingBox(bb: BoundingBoxType): Vector6 { +export function computeArrayFromBoundingBox(bb: BoundingBoxMinMaxType): Vector6 { return [ bb.min[0], bb.min[1], @@ -309,11 +313,13 @@ export function computeArrayFromBoundingBox(bb: BoundingBoxType): Vector6 { ]; } -export function computeShapeFromBoundingBox(bb: BoundingBoxType): Vector3 { +export function computeShapeFromBoundingBox(bb: BoundingBoxMinMaxType): Vector3 { return [bb.max[0] - bb.min[0], bb.max[1] - bb.min[1], bb.max[2] - bb.min[2]]; } -export function aggregateBoundingBox(boundingBoxes: Array): BoundingBoxType { +export function aggregateBoundingBox( + boundingBoxes: Array, +): BoundingBoxMinMaxType { if (boundingBoxes.length === 0) { return { min: [0, 0, 0], @@ -344,8 +350,8 @@ export function aggregateBoundingBox(boundingBoxes: Array): B } export function areBoundingBoxesOverlappingOrTouching( - firstBB: BoundingBoxType, - secondBB: BoundingBoxType, + firstBB: BoundingBoxMinMaxType, + secondBB: BoundingBoxMinMaxType, ) { let areOverlapping = true; diff --git a/frontend/javascripts/libs/vector_input.tsx b/frontend/javascripts/libs/vector_input.tsx index e687e05f615..052ad7ac301 100644 --- a/frontend/javascripts/libs/vector_input.tsx +++ b/frontend/javascripts/libs/vector_input.tsx @@ -2,7 +2,7 @@ import type { InputProps } from "antd"; import * as Utils from "libs/utils"; import _ from "lodash"; import * as React from "react"; -import type { ServerBoundingBoxTypeTuple } from "types/api_types"; +import type { ServerBoundingBoxMinMaxTypeTuple } from "types/api_types"; import type { Vector3, Vector6 } from "viewer/constants"; import InputComponent from "viewer/view/components/input_component"; @@ -206,11 +206,11 @@ export class ArbitraryVectorInput extends BaseVector { } type BoundingBoxInputProps = Omit & { - value: ServerBoundingBoxTypeTuple; - onChange: (arg0: ServerBoundingBoxTypeTuple) => void; + value: ServerBoundingBoxMinMaxTypeTuple; + onChange: (arg0: ServerBoundingBoxMinMaxTypeTuple) => void; }; -function boundingBoxToVector6(value: ServerBoundingBoxTypeTuple): Vector6 { +function boundingBoxToVector6(value: ServerBoundingBoxMinMaxTypeTuple): Vector6 { const { topLeft, width, height, depth } = value; const [x, y, z] = topLeft; return [x, y, z, width, height, depth]; diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index ff11d8ee95f..d85815887cd 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -848,7 +848,7 @@ export type UserBoundingBoxFromServer = { color?: ColorObject; isVisible?: boolean; }; -export type ServerBoundingBoxTypeTuple = { +export type ServerBoundingBoxMinMaxTypeTuple = { topLeft: Vector3; width: number; height: number; diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 509db1f1507..38070b2a460 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -22,7 +22,7 @@ import { type APICompoundType, APICompoundTypeEnum, type ElementClass } from "ty import type { AdditionalCoordinate } from "types/api_types"; import type { Writeable } from "types/globals"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, BucketAddress, ControlMode, LabeledVoxelsMap, @@ -1887,7 +1887,7 @@ class DataApi { */ async getDataFor2DBoundingBox( layerName: string, - bbox: BoundingBoxType, + bbox: BoundingBoxMinMaxType, _zoomStep: number | null | undefined = null, ) { return this.getDataForBoundingBox(layerName, bbox, _zoomStep); @@ -1900,7 +1900,7 @@ class DataApi { */ async getDataForBoundingBox( layerName: string, - mag1Bbox: BoundingBoxType, + mag1Bbox: BoundingBoxMinMaxType, _zoomStep: number | null | undefined = null, additionalCoordinates: AdditionalCoordinate[] | null = null, ) { @@ -1989,7 +1989,7 @@ class DataApi { } getBucketAddressesInCuboid( - bbox: BoundingBoxType, + bbox: BoundingBoxMinMaxType, magnifications: Array, zoomStep: number, additionalCoordinates: AdditionalCoordinate[] | null, @@ -2036,7 +2036,7 @@ class DataApi { cutOutCuboid( buckets: Array, - bbox: BoundingBoxType, + bbox: BoundingBoxMinMaxType, elementClass: ElementClass, magnifications: Array, zoomStep: number, diff --git a/frontend/javascripts/viewer/constants.ts b/frontend/javascripts/viewer/constants.ts index 1744a7a9745..a501f6390f9 100644 --- a/frontend/javascripts/viewer/constants.ts +++ b/frontend/javascripts/viewer/constants.ts @@ -38,7 +38,7 @@ export type ColorObject = { b: number; a: number; }; -export type BoundingBoxType = { +export type BoundingBoxMinMaxType = { min: Vector3; max: Vector3; }; diff --git a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts index b84d77284a6..6a6f11e2812 100644 --- a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts @@ -1,7 +1,7 @@ import { V3 } from "libs/mjs"; import { document } from "libs/window"; import _ from "lodash"; -import type { BoundingBoxType, OrthoView, Point2, Vector2, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, OrthoView, Point2, Vector2, Vector3 } from "viewer/constants"; import getSceneController from "viewer/controller/scene_controller_provider"; import { getSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { @@ -115,7 +115,7 @@ export type SelectedEdge = { type DistanceArray = [number, number, number, number]; function computeDistanceArray( - boundingBoxBounds: BoundingBoxType, + boundingBoxBounds: BoundingBoxMinMaxType, globalPosition: Vector3, indices: DimensionMap, planeRatio: Vector3, diff --git a/frontend/javascripts/viewer/controller/scene_controller.ts b/frontend/javascripts/viewer/controller/scene_controller.ts index 2bbad2759be..7d4436670c9 100644 --- a/frontend/javascripts/viewer/controller/scene_controller.ts +++ b/frontend/javascripts/viewer/controller/scene_controller.ts @@ -8,7 +8,7 @@ import _ from "lodash"; import * as THREE from "three"; import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from "three-mesh-bvh"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, OrthoView, OrthoViewMap, OrthoViewWithoutTDMap, @@ -325,7 +325,7 @@ class SceneController { } updateTaskBoundingBoxes( - taskCubeByTracingId: Record, + taskCubeByTracingId: Record, ): void { /* Ensures that a green task bounding box is rendered in the scene for diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts index 32de78b782b..2404122190e 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts @@ -1,7 +1,7 @@ import { V3 } from "libs/mjs"; import { map3, mod } from "libs/utils"; import _ from "lodash"; -import type { BoundingBoxType, OrthoView, Vector2, Vector3, Vector4 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, OrthoView, Vector2, Vector3, Vector4 } from "viewer/constants"; import constants from "viewer/constants"; import type { BoundingBoxObject } from "viewer/store"; import Dimensions from "../dimensions"; @@ -11,7 +11,7 @@ class BoundingBox { min: Vector3; max: Vector3; - constructor(boundingBox: BoundingBoxType | null | undefined) { + constructor(boundingBox: BoundingBoxMinMaxType | null | undefined) { if (boundingBox == null) { this.min = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]; this.max = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; @@ -249,7 +249,7 @@ class BoundingBox { return { topLeft: this.min, width: size[0], height: size[1], depth: size[2] }; } - toBoundingBoxType(): BoundingBoxType { + toBoundingBoxMinMaxType(): BoundingBoxMinMaxType { return { min: this.min, max: this.max, diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts index 9fd8bb45cd5..45dac7e4fbc 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts @@ -6,7 +6,7 @@ import { type Emitter, createNanoEvents } from "nanoevents"; import * as THREE from "three"; import type { BucketDataArray, ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxType, BucketAddress, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, BucketAddress, Vector3 } from "viewer/constants"; import Constants from "viewer/constants"; import type { MaybeUnmergedBucketLoadedPromise } from "viewer/model/actions/volumetracing_actions"; import { addBucketToUndoAction } from "viewer/model/actions/volumetracing_actions"; @@ -165,7 +165,7 @@ export class DataBucket { this.emitter.emit(event, ...args); } - getBoundingBox(): BoundingBoxType { + getBoundingBox(): BoundingBoxMinMaxType { const min = bucketPositionToGlobalAddress(this.zoomedAddress, this.cube.magInfo); const bucketMag = this.cube.magInfo.getMagByIndexOrThrow(this.zoomedAddress[3]); const max: Vector3 = [ diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index 47b833dce9e..3c52f7037cb 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -15,7 +15,7 @@ import * as THREE from "three"; import type { AdditionalAxis, BucketDataArray, ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, BucketAddress, LabelMasksByBucketAndW, Vector3, @@ -570,7 +570,7 @@ class DataCube { additionalCoordinates: AdditionalCoordinate[] | null, segmentIdNumber: number, dimensionIndices: DimensionMap, - _floodfillBoundingBox: BoundingBoxType, + _floodfillBoundingBox: BoundingBoxMinMaxType, zoomStep: number, progressCallback: ProgressCallback, use3D: boolean, @@ -578,7 +578,7 @@ class DataCube { ): Promise<{ bucketsWithLabeledVoxelsMap: LabelMasksByBucketAndW; wasBoundingBoxExceeded: boolean; - coveredBoundingBox: BoundingBoxType; + coveredBoundingBox: BoundingBoxMinMaxType; }> { // This flood-fill algorithm works in two nested levels and uses a list of buckets to flood fill. // On the inner level a bucket is flood-filled and if the iteration of the buckets data diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts b/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts index e59ae18c365..9b0ca391444 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts @@ -1,7 +1,7 @@ import type { Matrix4x4 } from "libs/mjs"; import { M4x4, V3 } from "libs/mjs"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxType, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, Vector3 } from "viewer/constants"; import PolyhedronRasterizer from "viewer/model/bucket_data_handling/polyhedron_rasterizer"; import { AbstractPrefetchStrategy } from "viewer/model/bucket_data_handling/prefetch_strategy_plane"; import type { PullQueueItem } from "viewer/model/bucket_data_handling/pullqueue"; @@ -25,11 +25,11 @@ export class PrefetchStrategyArbitrary extends AbstractPrefetchStrategy { ); getExtentObject( - poly0: BoundingBoxType, - poly1: BoundingBoxType, + poly0: BoundingBoxMinMaxType, + poly1: BoundingBoxMinMaxType, zoom0: number, zoom1: number, - ): BoundingBoxType { + ): BoundingBoxMinMaxType { return { min: [ Math.min(poly0.min[0] << zoom0, poly1.min[0] << zoom1), diff --git a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts index 580712caaba..5b8fdb18c7c 100644 --- a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts @@ -9,7 +9,7 @@ import Saxophone from "saxophone"; import type { APIBuildInfo, MetadataEntryProto } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; import { - type BoundingBoxType, + type BoundingBoxMinMaxType, IdentityTransform, type TreeType, TreeTypeEnum, @@ -201,7 +201,7 @@ function serializeMetaInformation( } function serializeTaskBoundingBox( - boundingBox: BoundingBoxType | null | undefined, + boundingBox: BoundingBoxMinMaxType | null | undefined, tagName: string, ): string { if (boundingBox) { diff --git a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts index b4c49953ed3..bfc3d9e0bc1 100644 --- a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts @@ -11,7 +11,6 @@ import type { MeshInformation, SkeletonTracing, UserBoundingBox, - UserBoundingBoxWithoutIdMaybe, VolumeTracing, WebknossosState, } from "viewer/store"; @@ -250,7 +249,7 @@ function AnnotationReducer(state: WebknossosState, action: Action): WebknossosSt // Only update the bounding box if the bounding box overlaps with the dataset bounds. // Else the bounding box is completely outside the dataset bounds -> in that case just keep the bounding box and let the user cook. if (newBoundingBoxWithinDataset.getVolume() > 0) { - newUserBoundingBox.boundingBox = newBoundingBoxWithinDataset.toBoundingBoxType(); + newUserBoundingBox.boundingBox = newBoundingBoxWithinDataset.toBoundingBoxMinMaxType(); } const updatedUserBoundingBoxes = [...userBoundingBoxes, newUserBoundingBox]; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index a54ad44821d..363460d1607 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -8,7 +8,7 @@ import type { UserBoundingBoxFromServer, VolumeUserState, } from "types/api_types"; -import type { BoundingBoxType } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "viewer/constants"; import type { AnnotationTool, AnnotationToolId } from "viewer/model/accessors/tool_accessor"; import { Toolkits } from "viewer/model/accessors/tool_accessor"; import { updateKey } from "viewer/model/helpers/deep_update"; @@ -26,7 +26,7 @@ import type { Tree, TreeGroup } from "../types/tree_types"; export function convertServerBoundingBoxToBoundingBox( boundingBox: ServerBoundingBox, -): BoundingBoxType { +): BoundingBoxMinMaxType { return Utils.computeBoundingBoxFromArray( Utils.concatVector3(Utils.point3ToVector3(boundingBox.topLeft), [ boundingBox.width, @@ -38,7 +38,7 @@ export function convertServerBoundingBoxToBoundingBox( export function convertServerBoundingBoxToFrontend( boundingBox: ServerBoundingBox | null | undefined, -): BoundingBoxType | null | undefined { +): BoundingBoxMinMaxType | null | undefined { if (!boundingBox) return null; return convertServerBoundingBoxToBoundingBox(boundingBox); } @@ -70,7 +70,7 @@ export function convertUserBoundingBoxFromFrontendToServer( } export function convertFrontendBoundingBoxToServer( - boundingBox: BoundingBoxType, + boundingBox: BoundingBoxMinMaxType, ): BoundingBoxObject { return { topLeft: boundingBox.min, diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 8c945a9f66b..00847b90ae0 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -54,8 +54,8 @@ import { } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { getUserStateForTracing } from "../accessors/annotation_accessor"; import { max, maxBy } from "../helpers/iterator_utils"; -import { handleUserBoundingBoxUpdateInTracing } from "./annotation_reducer"; import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; +import { handleUserBoundingBoxUpdateInTracing } from "./annotation_reducer"; function SkeletonTracingReducer(state: WebknossosState, action: Action): WebknossosState { if (action.type === "INITIALIZE_SKELETONTRACING") { diff --git a/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts b/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts index 5cf4d437262..1bef3045c21 100644 --- a/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts @@ -7,7 +7,7 @@ import _ from "lodash"; import { call, put } from "typed-redux-saga"; import type { APISegmentationLayer } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxType, TypedArray, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, TypedArray, Vector3 } from "viewer/constants"; import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; import { enforceActiveVolumeTracing, @@ -170,7 +170,10 @@ function removeOutgoingEdge(edgeBuffer: Uint16Array, idx: number, neighborIdx: n edgeBuffer[idx] &= ~(2 ** neighborIdx); } -export function isBoundingBoxUsableForMinCut(boundingBoxObj: BoundingBoxType, nodes: Array) { +export function isBoundingBoxUsableForMinCut( + boundingBoxObj: BoundingBoxMinMaxType, + nodes: Array, +) { const bbox = new BoundingBox(boundingBoxObj); return ( bbox.containsPoint(nodes[0].untransformedPosition) && diff --git a/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx b/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx index c97cb0c431f..6dc5ceada19 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx @@ -5,7 +5,7 @@ import * as Utils from "libs/utils"; import _ from "lodash"; import { call, put, takeEvery } from "typed-redux-saga"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, FillMode, LabeledVoxelsMap, OrthoView, @@ -132,7 +132,7 @@ function* getBoundingBoxForFloodFill( position: Vector3, currentViewport: OrthoView, finestSegmentationLayerMag: Vector3, -): Saga { +): Saga { const isRestrictedToBoundingBox = yield* select( (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox, ); @@ -346,7 +346,7 @@ function* notifyUserAboutResult( startTimeOfFloodfill: number, progressCallback: ProgressCallback, fillMode: FillMode, - coveredBoundingBox: BoundingBoxType, + coveredBoundingBox: BoundingBoxMinMaxType, oldSegmentIdAtSeed: number, activeCellId: number, seedPosition: Vector3, diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index ab59f6c2dfd..1e8bbf35161 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -33,7 +33,7 @@ import type { TracingType, } from "types/api_types"; import type { - BoundingBoxType, + BoundingBoxMinMaxType, ContourMode, ControlMode, FillMode, @@ -96,13 +96,13 @@ export type UserBoundingBoxToServer = { isVisible?: boolean; }; export type UserBoundingBoxWithoutIdMaybe = { - boundingBox?: BoundingBoxType; + boundingBox?: BoundingBoxMinMaxType; name?: string; color?: Vector3; isVisible?: boolean; }; export type UserBoundingBoxWithoutId = { - boundingBox: BoundingBoxType; + boundingBox: BoundingBoxMinMaxType; name: string; color: Vector3; isVisible: boolean; @@ -154,7 +154,7 @@ export type Annotation = { type TracingBase = { readonly createdTimestamp: number; readonly tracingId: string; - readonly boundingBox: BoundingBoxType | null | undefined; + readonly boundingBox: BoundingBoxMinMaxType | null | undefined; readonly userBoundingBoxes: Array; readonly additionalAxes: AdditionalAxis[]; }; diff --git a/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx b/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx index 8d9b0dd18fb..ac3cc42254f 100644 --- a/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx +++ b/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx @@ -42,7 +42,7 @@ import { type AdditionalAxis, type VoxelSize, } from "types/api_types"; -import type { BoundingBoxType, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType, Vector3 } from "viewer/constants"; import { getByteCountFromLayer, getDataLayers, @@ -123,7 +123,7 @@ function getExportLayerInfos( }; } -export function isBoundingBoxExportable(boundingBox: BoundingBoxType, mag: Vector3) { +export function isBoundingBoxExportable(boundingBox: BoundingBoxMinMaxType, mag: Vector3) { const shape = computeShapeFromBoundingBox(boundingBox); const volume = Math.ceil(shape[0] / mag[0]) * Math.ceil(shape[1] / mag[1]) * Math.ceil(shape[2] / mag[2]); @@ -164,7 +164,7 @@ export function isBoundingBoxExportable(boundingBox: BoundingBoxType, mag: Vecto function estimateFileSize( selectedLayer: APIDataLayer, mag: Vector3, - boundingBox: BoundingBoxType, + boundingBox: BoundingBoxMinMaxType, exportFormat: ExportFormat, ) { const shape = computeShapeFromBoundingBox(boundingBox); diff --git a/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx b/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx index 3f6f4659274..445834bbeb2 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx @@ -9,7 +9,7 @@ import { useDispatch } from "react-redux"; import AutoSizer from "react-virtualized-auto-sizer"; import { APIJobType } from "types/api_types"; import { - type BoundingBoxType, + type BoundingBoxMinMaxType, ControlModeEnum, type Vector3, type Vector6, @@ -47,7 +47,7 @@ export default function BoundingBoxTab() { const [menu, setMenu] = useState(null); const dispatch = useDispatch(); - const setChangeBoundingBoxBounds = (id: number, boundingBox: BoundingBoxType) => + const setChangeBoundingBoxBounds = (id: number, boundingBox: BoundingBoxMinMaxType) => dispatch( changeUserBoundingBoxAction(id, { boundingBox, From 7d74402b007a0e8b3db47aded8939d809640e41e Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 13:57:24 +0200 Subject: [PATCH 07/92] rename UserBoundingBoxToServer to UserBoundingBoxForServer --- .../viewer/model/reducers/reducer_helpers.ts | 10 +++++----- frontend/javascripts/viewer/store.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 363460d1607..791988d5dd5 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -17,14 +17,14 @@ import type { BoundingBoxObject, SegmentGroup, UserBoundingBox, - UserBoundingBoxToServer, + UserBoundingBoxForServer, UserBoundingBoxWithOptIsVisible, WebknossosState, } from "viewer/store"; import { type DisabledInfo, getDisabledInfoForTools } from "../accessors/disabled_tool_accessor"; import type { Tree, TreeGroup } from "../types/tree_types"; -export function convertServerBoundingBoxToBoundingBox( +function convertServerBoundingBoxToBoundingBoxMinMaxType( boundingBox: ServerBoundingBox, ): BoundingBoxMinMaxType { return Utils.computeBoundingBoxFromArray( @@ -40,7 +40,7 @@ export function convertServerBoundingBoxToFrontend( boundingBox: ServerBoundingBox | null | undefined, ): BoundingBoxMinMaxType | null | undefined { if (!boundingBox) return null; - return convertServerBoundingBoxToBoundingBox(boundingBox); + return convertServerBoundingBoxToBoundingBoxMinMaxType(boundingBox); } export function convertUserBoundingBoxesFromServerToFrontend( @@ -51,7 +51,7 @@ export function convertUserBoundingBoxesFromServerToFrontend( return boundingBoxes.map((bb) => { const { color, id, name, isVisible, boundingBox } = bb; - const convertedBoundingBox = convertServerBoundingBoxToBoundingBox(boundingBox); + const convertedBoundingBox = convertServerBoundingBoxToBoundingBoxMinMaxType(boundingBox); return { boundingBox: convertedBoundingBox, color: color ? Utils.colorObjectToRGBArray(color) : Utils.getRandomColor(), @@ -64,7 +64,7 @@ export function convertUserBoundingBoxesFromServerToFrontend( export function convertUserBoundingBoxFromFrontendToServer( boundingBox: UserBoundingBoxWithOptIsVisible, -): UserBoundingBoxToServer { +): UserBoundingBoxForServer { const { boundingBox: bb, ...rest } = boundingBox; return { ...rest, boundingBox: Utils.computeBoundingBoxObjectFromBoundingBox(bb) }; } diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 1e8bbf35161..3ab870b3ad3 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -88,7 +88,7 @@ export type BoundingBoxObject = { readonly height: number; readonly depth: number; }; -export type UserBoundingBoxToServer = { +export type UserBoundingBoxForServer = { boundingBox: BoundingBoxObject; id: number; name: string; From f16688e05f5ed1f1ef53fbeb0be0bb8b2a0f3eca Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 15:19:25 +0200 Subject: [PATCH 08/92] refactor bbox types a bit (mainly move them to common module) --- frontend/javascripts/libs/utils.ts | 11 ++------- frontend/javascripts/types/api_types.ts | 12 ++++------ frontend/javascripts/types/bounding_box.ts | 23 +++++++++++++++++++ frontend/javascripts/viewer/api/api_latest.ts | 2 +- frontend/javascripts/viewer/constants.ts | 6 +---- .../combinations/bounding_box_handlers.ts | 3 ++- .../viewer/controller/scene_controller.ts | 9 ++------ .../bucket_data_handling/bounding_box.ts | 3 ++- .../model/bucket_data_handling/bucket.ts | 3 ++- .../model/bucket_data_handling/data_cube.ts | 9 ++------ .../prefetch_strategy_arbitrary.ts | 3 ++- .../viewer/model/helpers/nml_helpers.ts | 9 ++------ .../viewer/model/reducers/reducer_helpers.ts | 2 +- .../viewer/model/sagas/min_cut_saga.ts | 3 ++- .../model/sagas/volume/floodfill_saga.tsx | 10 ++------ frontend/javascripts/viewer/store.ts | 10 +++----- .../view/action-bar/download_modal_view.tsx | 3 ++- .../right-border-tabs/bounding_box_tab.tsx | 8 ++----- 18 files changed, 58 insertions(+), 71 deletions(-) create mode 100644 frontend/javascripts/types/bounding_box.ts diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 0fc5ee9c0a6..88bd2392d7a 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -3,16 +3,9 @@ import naturalSort from "javascript-natural-sort"; import window, { document, location } from "libs/window"; import _ from "lodash"; import type { APIDataset, APIUser, MapEntries } from "types/api_types"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; import type { ArbitraryObject, Comparator } from "types/globals"; -import type { - BoundingBoxMinMaxType, - ColorObject, - Point3, - TypedArray, - Vector3, - Vector4, - Vector6, -} from "viewer/constants"; +import type { ColorObject, Point3, TypedArray, Vector3, Vector4, Vector6 } from "viewer/constants"; import type { TreeGroup } from "viewer/model/types/tree_types"; import type { BoundingBoxObject, NumberLike, SegmentGroup } from "viewer/store"; diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index d85815887cd..f30676a9c8f 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -1,6 +1,8 @@ import type { PricingPlanEnum } from "admin/organization/pricing_plan_utils"; import _ from "lodash"; +import type { ServerBoundingBox } from "types/bounding_box"; import type { + AdditionalCoordinate, ColorObject, LOG_LEVELS, NestedMatrix4, @@ -25,7 +27,9 @@ import type { } from "viewer/store"; import type { EmptyObject } from "./globals"; -export type AdditionalCoordinate = { name: string; value: number }; +// Re-export +export type { ServerBoundingBox } from "types/bounding_box"; +export type { AdditionalCoordinate } from "viewer/constants"; export type APIMessage = { [key in "info" | "warning" | "error"]?: string }; export type ElementClass = @@ -835,12 +839,6 @@ export type ServerBranchPoint = { createdTimestamp: number; nodeId: number; }; -export type ServerBoundingBox = { - topLeft: Point3; - width: number; - height: number; - depth: number; -}; export type UserBoundingBoxFromServer = { boundingBox: ServerBoundingBox; id: number; diff --git a/frontend/javascripts/types/bounding_box.ts b/frontend/javascripts/types/bounding_box.ts new file mode 100644 index 00000000000..683e9989d5c --- /dev/null +++ b/frontend/javascripts/types/bounding_box.ts @@ -0,0 +1,23 @@ +import type { Point3, Vector3 } from "viewer/constants"; + +// 51 matches +export type BoundingBoxMinMaxType = { + min: Vector3; + max: Vector3; +}; + +// 10 matches +export type ServerBoundingBox = { + topLeft: Point3; + width: number; + height: number; + depth: number; +}; + +// 39 matches +export type BoundingBoxObject = { + readonly topLeft: Vector3; + readonly width: number; + readonly height: number; + readonly depth: number; +}; diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 38070b2a460..6c15d89a80a 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -20,9 +20,9 @@ import messages from "messages"; import TWEEN from "tween.js"; import { type APICompoundType, APICompoundTypeEnum, type ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; import type { Writeable } from "types/globals"; import type { - BoundingBoxMinMaxType, BucketAddress, ControlMode, LabeledVoxelsMap, diff --git a/frontend/javascripts/viewer/constants.ts b/frontend/javascripts/viewer/constants.ts index a501f6390f9..733aa966509 100644 --- a/frontend/javascripts/viewer/constants.ts +++ b/frontend/javascripts/viewer/constants.ts @@ -1,4 +1,4 @@ -import type { AdditionalCoordinate } from "types/api_types"; +export type AdditionalCoordinate = { name: string; value: number }; export const ViewModeValues = ["orthogonal", "flight", "oblique"] as ViewMode[]; @@ -38,10 +38,6 @@ export type ColorObject = { b: number; a: number; }; -export type BoundingBoxMinMaxType = { - min: Vector3; - max: Vector3; -}; export type Rect = { top: number; left: number; diff --git a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts index 6a6f11e2812..3c19a064892 100644 --- a/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts +++ b/frontend/javascripts/viewer/controller/combinations/bounding_box_handlers.ts @@ -1,7 +1,8 @@ import { V3 } from "libs/mjs"; import { document } from "libs/window"; import _ from "lodash"; -import type { BoundingBoxMinMaxType, OrthoView, Point2, Vector2, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { OrthoView, Point2, Vector2, Vector3 } from "viewer/constants"; import getSceneController from "viewer/controller/scene_controller_provider"; import { getSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { diff --git a/frontend/javascripts/viewer/controller/scene_controller.ts b/frontend/javascripts/viewer/controller/scene_controller.ts index 7d4436670c9..08e1f390439 100644 --- a/frontend/javascripts/viewer/controller/scene_controller.ts +++ b/frontend/javascripts/viewer/controller/scene_controller.ts @@ -7,13 +7,8 @@ import _ from "lodash"; import * as THREE from "three"; import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from "three-mesh-bvh"; -import type { - BoundingBoxMinMaxType, - OrthoView, - OrthoViewMap, - OrthoViewWithoutTDMap, - Vector3, -} from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { OrthoView, OrthoViewMap, OrthoViewWithoutTDMap, Vector3 } from "viewer/constants"; import constants, { OrthoViews, OrthoViewValuesWithoutTDView, diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts index 2404122190e..24c4c01ed53 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts @@ -1,7 +1,8 @@ import { V3 } from "libs/mjs"; import { map3, mod } from "libs/utils"; import _ from "lodash"; -import type { BoundingBoxMinMaxType, OrthoView, Vector2, Vector3, Vector4 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { OrthoView, Vector2, Vector3, Vector4 } from "viewer/constants"; import constants from "viewer/constants"; import type { BoundingBoxObject } from "viewer/store"; import Dimensions from "../dimensions"; diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts index 45dac7e4fbc..39282fca965 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts @@ -6,7 +6,8 @@ import { type Emitter, createNanoEvents } from "nanoevents"; import * as THREE from "three"; import type { BucketDataArray, ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxMinMaxType, BucketAddress, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { BucketAddress, Vector3 } from "viewer/constants"; import Constants from "viewer/constants"; import type { MaybeUnmergedBucketLoadedPromise } from "viewer/model/actions/volumetracing_actions"; import { addBucketToUndoAction } from "viewer/model/actions/volumetracing_actions"; diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index 3c52f7037cb..95a149d77c4 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -14,13 +14,8 @@ import { type Emitter, createNanoEvents } from "nanoevents"; import * as THREE from "three"; import type { AdditionalAxis, BucketDataArray, ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import type { - BoundingBoxMinMaxType, - BucketAddress, - LabelMasksByBucketAndW, - Vector3, - Vector4, -} from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { BucketAddress, LabelMasksByBucketAndW, Vector3, Vector4 } from "viewer/constants"; import constants, { MappingStatusEnum } from "viewer/constants"; import Constants from "viewer/constants"; import { getMappingInfo } from "viewer/model/accessors/dataset_accessor"; diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts b/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts index 9b0ca391444..3c1d91fe08f 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/prefetch_strategy_arbitrary.ts @@ -1,7 +1,8 @@ import type { Matrix4x4 } from "libs/mjs"; import { M4x4, V3 } from "libs/mjs"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxMinMaxType, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { Vector3 } from "viewer/constants"; import PolyhedronRasterizer from "viewer/model/bucket_data_handling/polyhedron_rasterizer"; import { AbstractPrefetchStrategy } from "viewer/model/bucket_data_handling/prefetch_strategy_plane"; import type { PullQueueItem } from "viewer/model/bucket_data_handling/pullqueue"; diff --git a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts index 5b8fdb18c7c..a3ee7f1cc36 100644 --- a/frontend/javascripts/viewer/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/viewer/model/helpers/nml_helpers.ts @@ -8,13 +8,8 @@ import messages from "messages"; import Saxophone from "saxophone"; import type { APIBuildInfo, MetadataEntryProto } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import { - type BoundingBoxMinMaxType, - IdentityTransform, - type TreeType, - TreeTypeEnum, - type Vector3, -} from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import { IdentityTransform, type TreeType, TreeTypeEnum, type Vector3 } from "viewer/constants"; import Constants from "viewer/constants"; import { getPosition, getRotation } from "viewer/model/accessors/flycam_accessor"; import EdgeCollection from "viewer/model/edge_collection"; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 791988d5dd5..f01580e40db 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -8,7 +8,7 @@ import type { UserBoundingBoxFromServer, VolumeUserState, } from "types/api_types"; -import type { BoundingBoxMinMaxType } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; import type { AnnotationTool, AnnotationToolId } from "viewer/model/accessors/tool_accessor"; import { Toolkits } from "viewer/model/accessors/tool_accessor"; import { updateKey } from "viewer/model/helpers/deep_update"; diff --git a/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts b/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts index 1bef3045c21..d767b7cc318 100644 --- a/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts @@ -7,7 +7,8 @@ import _ from "lodash"; import { call, put } from "typed-redux-saga"; import type { APISegmentationLayer } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; -import type { BoundingBoxMinMaxType, TypedArray, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { TypedArray, Vector3 } from "viewer/constants"; import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; import { enforceActiveVolumeTracing, diff --git a/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx b/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx index 6dc5ceada19..64c69d3380c 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volume/floodfill_saga.tsx @@ -4,14 +4,8 @@ import Toast from "libs/toast"; import * as Utils from "libs/utils"; import _ from "lodash"; import { call, put, takeEvery } from "typed-redux-saga"; -import type { - BoundingBoxMinMaxType, - FillMode, - LabeledVoxelsMap, - OrthoView, - Vector2, - Vector3, -} from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { FillMode, LabeledVoxelsMap, OrthoView, Vector2, Vector3 } from "viewer/constants"; import Constants, { FillModeEnum, Unicode } from "viewer/constants"; import getSceneController from "viewer/controller/scene_controller_provider"; import { getDatasetBoundingBox, getMagInfo } from "viewer/model/accessors/dataset_accessor"; diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 3ab870b3ad3..df9e0f9c3da 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -33,7 +33,6 @@ import type { TracingType, } from "types/api_types"; import type { - BoundingBoxMinMaxType, ContourMode, ControlMode, FillMode, @@ -61,6 +60,7 @@ import type { TreeMap, } from "./model/types/tree_types"; +import type { BoundingBoxMinMaxType, BoundingBoxObject } from "types/bounding_box"; // Value imports import defaultState from "viewer/default_state"; import actionLoggerMiddleware from "viewer/model/helpers/action_logger_middleware"; @@ -82,12 +82,8 @@ import FlycamInfoCacheReducer from "./model/reducers/flycam_info_cache_reducer"; import OrganizationReducer from "./model/reducers/organization_reducer"; import type { StartAIJobModalState } from "./view/action-bar/starting_job_modals"; -export type BoundingBoxObject = { - readonly topLeft: Vector3; - readonly width: number; - readonly height: number; - readonly depth: number; -}; +export type { BoundingBoxObject } from "types/bounding_box"; + export type UserBoundingBoxForServer = { boundingBox: BoundingBoxObject; id: number; diff --git a/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx b/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx index ac3cc42254f..b7b83d788dd 100644 --- a/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx +++ b/frontend/javascripts/viewer/view/action-bar/download_modal_view.tsx @@ -42,7 +42,8 @@ import { type AdditionalAxis, type VoxelSize, } from "types/api_types"; -import type { BoundingBoxMinMaxType, Vector3 } from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { Vector3 } from "viewer/constants"; import { getByteCountFromLayer, getDataLayers, diff --git a/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx b/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx index 445834bbeb2..a7927dbaa84 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/bounding_box_tab.tsx @@ -8,12 +8,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useDispatch } from "react-redux"; import AutoSizer from "react-virtualized-auto-sizer"; import { APIJobType } from "types/api_types"; -import { - type BoundingBoxMinMaxType, - ControlModeEnum, - type Vector3, - type Vector6, -} from "viewer/constants"; +import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import { ControlModeEnum, type Vector3, type Vector6 } from "viewer/constants"; import { isAnnotationOwner } from "viewer/model/accessors/annotation_accessor"; import { getSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { From 1694d894437471e976a42ca7013d876e612ac55e Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:04:15 +0200 Subject: [PATCH 09/92] refactor update action application code into own folder and new files; integrate bbox code for volume tracing, too --- frontend/javascripts/libs/utils.ts | 4 - .../model/reducers/annotation_reducer.ts | 43 +--- .../viewer/model/reducers/reducer_helpers.ts | 33 +++- .../model/reducers/skeletontracing_reducer.ts | 185 +----------------- .../update_action_application/bounding_box.ts | 113 +++++++++++ .../update_action_application/skeleton.ts | 127 ++++++++++++ .../update_action_application/volume.ts | 70 +++++++ .../model/reducers/volumetracing_reducer.ts | 55 +----- 8 files changed, 346 insertions(+), 284 deletions(-) create mode 100644 frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts create mode 100644 frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts create mode 100644 frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 88bd2392d7a..90527b787e1 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -424,10 +424,6 @@ export function stringToNumberArray(s: string): Array { return result; } -export function concatVector3(a: Vector3, b: Vector3): Vector6 { - return [a[0], a[1], a[2], b[0], b[1], b[2]]; -} - export function numberArrayToVector3(array: Array): Vector3 { const output: Vector3 = [0, 0, 0]; diff --git a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts index bfc3d9e0bc1..3d21abb8d9a 100644 --- a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts @@ -7,13 +7,7 @@ import { maybeGetSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { getDisplayedDataExtentInPlaneMode } from "viewer/model/accessors/view_mode_accessor"; import type { Action } from "viewer/model/actions/actions"; import { updateKey, updateKey2 } from "viewer/model/helpers/deep_update"; -import type { - MeshInformation, - SkeletonTracing, - UserBoundingBox, - VolumeTracing, - WebknossosState, -} from "viewer/store"; +import type { MeshInformation, UserBoundingBox, WebknossosState } from "viewer/store"; import { getDatasetBoundingBox } from "../accessors/dataset_accessor"; import { getAdditionalCoordinatesAsString } from "../accessors/flycam_accessor"; import { getMeshesForAdditionalCoordinates } from "../accessors/volumetracing_accessor"; @@ -62,41 +56,6 @@ const updateUserBoundingBoxes = ( }); }; -export function handleUserBoundingBoxUpdateInTracing( - state: WebknossosState, - tracing: SkeletonTracing | VolumeTracing, - updatedUserBoundingBoxes: UserBoundingBox[], -) { - if (tracing.type === "skeleton") { - return update(state, { - annotation: { - skeleton: { - userBoundingBoxes: { - $set: updatedUserBoundingBoxes, - }, - }, - }, - }); - } - - const newVolumes = state.annotation.volumes.map((volumeTracing) => - tracing.tracingId === volumeTracing.tracingId - ? { - ...volumeTracing, - updatedUserBoundingBoxes, - } - : volumeTracing, - ); - - return update(state, { - annotation: { - volumes: { - $set: newVolumes, - }, - }, - }); -} - const maybeAddAdditionalCoordinatesToMeshState = ( state: WebknossosState, additionalCoordinates: AdditionalCoordinate[] | null | undefined, diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index f01580e40db..0a696eeedc5 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -9,6 +9,7 @@ import type { VolumeUserState, } from "types/api_types"; import type { BoundingBoxMinMaxType } from "types/bounding_box"; +import type { Vector3 } from "viewer/constants"; import type { AnnotationTool, AnnotationToolId } from "viewer/model/accessors/tool_accessor"; import { Toolkits } from "viewer/model/accessors/tool_accessor"; import { updateKey } from "viewer/model/helpers/deep_update"; @@ -22,27 +23,43 @@ import type { WebknossosState, } from "viewer/store"; import { type DisabledInfo, getDisabledInfoForTools } from "../accessors/disabled_tool_accessor"; +import type { UpdateUserBoundingBoxInSkeletonTracingAction } from "../sagas/update_actions"; import type { Tree, TreeGroup } from "../types/tree_types"; function convertServerBoundingBoxToBoundingBoxMinMaxType( boundingBox: ServerBoundingBox, ): BoundingBoxMinMaxType { - return Utils.computeBoundingBoxFromArray( - Utils.concatVector3(Utils.point3ToVector3(boundingBox.topLeft), [ - boundingBox.width, - boundingBox.height, - boundingBox.depth, - ]), - ); + const min = Utils.point3ToVector3(boundingBox.topLeft); + const max: Vector3 = [ + min[0] + boundingBox.width, + min[1] + boundingBox.height, + min[2] + boundingBox.depth, + ]; + return { min, max }; } export function convertServerBoundingBoxToFrontend( boundingBox: ServerBoundingBox | null | undefined, ): BoundingBoxMinMaxType | null | undefined { - if (!boundingBox) return null; + if (!boundingBox) return boundingBox; return convertServerBoundingBoxToBoundingBoxMinMaxType(boundingBox); } +export function convertUserBoundingBoxFromUpdateActionToFrontend( + bboxValue: UpdateUserBoundingBoxInSkeletonTracingAction["value"], +): Partial { + const { boundingBox, actionTracingId: _actionTracingId, ...valueWithoutBoundingBox } = bboxValue; + const maybeBoundingBoxValue = + boundingBox != null + ? { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox) } + : {}; + + return { + ...valueWithoutBoundingBox, + ...maybeBoundingBoxValue, + }; +} + export function convertUserBoundingBoxesFromServerToFrontend( boundingBoxes: Array, userState: SkeletonUserState | VolumeUserState | undefined, diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 00847b90ae0..6978a7c31e5 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -46,7 +46,7 @@ import { toggleTreeGroupReducer, } from "viewer/model/reducers/skeletontracing_reducer_helpers"; import { type TreeGroup, TreeMap } from "viewer/model/types/tree_types"; -import type { SkeletonTracing, UserBoundingBox, WebknossosState } from "viewer/store"; +import type { SkeletonTracing, WebknossosState } from "viewer/store"; import { GroupTypeEnum, additionallyExpandGroup, @@ -54,8 +54,7 @@ import { } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { getUserStateForTracing } from "../accessors/annotation_accessor"; import { max, maxBy } from "../helpers/iterator_utils"; -import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; -import { handleUserBoundingBoxUpdateInTracing } from "./annotation_reducer"; +import { applySkeletonUpdateActionsFromServer } from "./update_action_application/skeleton"; function SkeletonTracingReducer(state: WebknossosState, action: Action): WebknossosState { if (action.type === "INITIALIZE_SKELETONTRACING") { @@ -1273,186 +1272,6 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos } } -function applySkeletonUpdateActionsFromServer( - actions: ApplicableSkeletonUpdateAction[], - newState: WebknossosState, -) { - for (const ua of actions) { - switch (ua.name) { - // case "createTree": { - // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = - // ua.value; - // newState = update(newState, { - // tracing: { - // skeleton: { - // trees: { - // [id]: { - // $set: { - // name, - // treeId: id, - // nodes: new DiffableMap(), - // timestamp, - // color, - // branchPoints, - // edges: new EdgeCollection(), - // comments, - // isVisible, - // groupId, - // }, - // }, - // }, - // }, - // }, - // }); - // break; - // } - case "createNode": { - if (newState.annotation.skeleton == null) { - continue; - } - - const { treeId, ...serverNode } = ua.value; - // eslint-disable-next-line no-loop-func - const { position: untransformedPosition, resolution: mag, ...node } = serverNode; - const clientNode = { untransformedPosition, mag, ...node }; - - const tree = getTree(newState.annotation.skeleton, treeId); - if (tree == null) { - // todop: escalate error? - continue; - } - const diffableNodeMap = tree.nodes; - const newDiffableMap = diffableNodeMap.set(node.id, clientNode); - const newTree = update(tree, { - nodes: { $set: newDiffableMap }, - }); - newState = update(newState, { - annotation: { - skeleton: { - trees: { - [tree.treeId]: { $set: newTree }, - }, - cachedMaxNodeId: { $set: node.id }, - }, - }, - }); - break; - } - case "createEdge": { - const { treeId, source, target } = ua.value; - // eslint-disable-next-line no-loop-func - if (newState.annotation.skeleton == null) { - continue; - } - - const tree = getTree(newState.annotation.skeleton, treeId); - if (tree == null) { - // todop: escalate error? - continue; - } - const newEdge = { - source, - target, - }; - const edges = tree.edges.addEdge(newEdge); - const newTree = update(tree, { edges: { $set: edges } }); - newState = update(newState, { - annotation: { - skeleton: { - trees: { - [tree.treeId]: { $set: newTree }, - }, - }, - }, - }); - break; - } - case "updateUserBoundingBoxInSkeletonTracing": { - // todop: dont pass state and instead do the update here? - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - - const { boundingBox, ...valueWithoutBoundingBox } = ua.value; - const maybeBoundingBoxValue = - boundingBox != null - ? { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox) } - : {}; - - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.map((bbox) => - bbox.id === ua.value.boundingBoxId - ? { - ...bbox, - ...valueWithoutBoundingBox, - ...maybeBoundingBoxValue, - } - : bbox, - ); - - newState = handleUserBoundingBoxUpdateInTracing( - newState, - skeleton, - updatedUserBoundingBoxes, - ); - break; - } - case "addUserBoundingBoxInSkeletonTracing": { - // todop: dont pass state and instead do the update here? - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - - const { boundingBox, ...valueWithoutBoundingBox } = ua.value.boundingBox; - const maybeBoundingBoxValue = { - boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox), - }; - const newUserBBox: UserBoundingBox = { - // The visibility is stored per user. Therefore, we default to true here. - isVisible: true, - ...valueWithoutBoundingBox, - ...maybeBoundingBoxValue, - }; - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.concat([newUserBBox]); - - newState = handleUserBoundingBoxUpdateInTracing( - newState, - skeleton, - updatedUserBoundingBoxes, - ); - break; - } - case "deleteUserBoundingBoxInSkeletonTracing": { - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.filter( - (bbox) => bbox.id !== ua.value.boundingBoxId, - ); - - newState = handleUserBoundingBoxUpdateInTracing( - newState, - skeleton, - updatedUserBoundingBoxes, - ); - - break; - } - default: { - ua satisfies never; - } - } - } - - // The state is wrapped in this container object to prevent the above switch-cases from - // accidentally returning newState (this is the usual way in reducers but would ignore - // remaining update actions). - return { value: newState }; -} - export function sanitizeMetadata(metadata: MetadataEntryProto[]) { // Workaround for stringList values that are [], even though they // should be null. This workaround is necessary because protobuf cannot diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts new file mode 100644 index 00000000000..f0376eb1ea4 --- /dev/null +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -0,0 +1,113 @@ +import update from "immutability-helper"; +import * as Utils from "libs/utils"; +import type { + AddUserBoundingBoxInSkeletonTracingAction, + AddUserBoundingBoxInVolumeTracingAction, + DeleteUserBoundingBoxInSkeletonTracingAction, + DeleteUserBoundingBoxInVolumeTracingAction, + UpdateUserBoundingBoxInSkeletonTracingAction, + UpdateUserBoundingBoxInVolumeTracingAction, +} from "viewer/model/sagas/update_actions"; +import type { + SkeletonTracing, + UserBoundingBox, + VolumeTracing, + WebknossosState, +} from "viewer/store"; +import { convertUserBoundingBoxFromUpdateActionToFrontend } from "../reducer_helpers"; + +export function applyUpdateUserBoundingBox( + newState: WebknossosState, + ua: UpdateUserBoundingBoxInSkeletonTracingAction | UpdateUserBoundingBoxInVolumeTracingAction, +) { + // todop: dont pass state and instead do the update here? + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.map( + (bbox): UserBoundingBox => + bbox.id === ua.value.boundingBoxId + ? { ...bbox, ...convertUserBoundingBoxFromUpdateActionToFrontend(ua.value) } + : bbox, + ); + + return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); +} + +export function applyAddUserBoundingBox( + newState: WebknossosState, + ua: AddUserBoundingBoxInSkeletonTracingAction | AddUserBoundingBoxInVolumeTracingAction, +) { + // todop: dont pass state and instead do the update here? + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const { boundingBox, ...valueWithoutBoundingBox } = ua.value.boundingBox; + const maybeBoundingBoxValue = { + boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox), + }; + const newUserBBox: UserBoundingBox = { + // The visibility is stored per user. Therefore, we default to true here. + isVisible: true, + ...valueWithoutBoundingBox, + ...maybeBoundingBoxValue, + }; + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.concat([newUserBBox]); + + return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); +} + +export function applyDeleteUserBoundingBox( + newState: WebknossosState, + ua: DeleteUserBoundingBoxInSkeletonTracingAction | DeleteUserBoundingBoxInVolumeTracingAction, +) { + const { skeleton } = newState.annotation; + if (skeleton == null) { + throw new Error("No skeleton found to apply update to."); + } + + const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.filter( + (bbox) => bbox.id !== ua.value.boundingBoxId, + ); + + return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); +} + +function handleUserBoundingBoxUpdateInTracing( + state: WebknossosState, + tracing: SkeletonTracing | VolumeTracing, + updatedUserBoundingBoxes: UserBoundingBox[], +) { + if (tracing.type === "skeleton") { + return update(state, { + annotation: { + skeleton: { + userBoundingBoxes: { + $set: updatedUserBoundingBoxes, + }, + }, + }, + }); + } + + const newVolumes = state.annotation.volumes.map((volumeTracing) => + tracing.tracingId === volumeTracing.tracingId + ? { + ...volumeTracing, + updatedUserBoundingBoxes, + } + : volumeTracing, + ); + + return update(state, { + annotation: { + volumes: { + $set: newVolumes, + }, + }, + }); +} diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts new file mode 100644 index 00000000000..76026c63dc6 --- /dev/null +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -0,0 +1,127 @@ +import update from "immutability-helper"; +import { getTree } from "viewer/model/accessors/skeletontracing_accessor"; +import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; +import type { WebknossosState } from "viewer/store"; +import { + applyAddUserBoundingBox, + applyDeleteUserBoundingBox, + applyUpdateUserBoundingBox, +} from "./bounding_box"; + +export function applySkeletonUpdateActionsFromServer( + actions: ApplicableSkeletonUpdateAction[], + newState: WebknossosState, +): { value: WebknossosState } { + for (const ua of actions) { + switch (ua.name) { + // case "createTree": { + // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = + // ua.value; + // newState = update(newState, { + // tracing: { + // skeleton: { + // trees: { + // [id]: { + // $set: { + // name, + // treeId: id, + // nodes: new DiffableMap(), + // timestamp, + // color, + // branchPoints, + // edges: new EdgeCollection(), + // comments, + // isVisible, + // groupId, + // }, + // }, + // }, + // }, + // }, + // }); + // break; + // } + case "createNode": { + if (newState.annotation.skeleton == null) { + continue; + } + + const { treeId, ...serverNode } = ua.value; + // eslint-disable-next-line no-loop-func + const { position: untransformedPosition, resolution: mag, ...node } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const tree = getTree(newState.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + cachedMaxNodeId: { $set: node.id }, + }, + }, + }); + break; + } + case "createEdge": { + const { treeId, source, target } = ua.value; + // eslint-disable-next-line no-loop-func + if (newState.annotation.skeleton == null) { + continue; + } + + const tree = getTree(newState.annotation.skeleton, treeId); + if (tree == null) { + // todop: escalate error? + continue; + } + const newEdge = { + source, + target, + }; + const edges = tree.edges.addEdge(newEdge); + const newTree = update(tree, { edges: { $set: edges } }); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + [tree.treeId]: { $set: newTree }, + }, + }, + }, + }); + break; + } + case "updateUserBoundingBoxInSkeletonTracing": { + newState = applyUpdateUserBoundingBox(newState, ua); + break; + } + case "addUserBoundingBoxInSkeletonTracing": { + newState = applyAddUserBoundingBox(newState, ua); + break; + } + case "deleteUserBoundingBoxInSkeletonTracing": { + newState = applyDeleteUserBoundingBox(newState, ua); + break; + } + default: { + ua satisfies never; + } + } + } + + // The state is wrapped in this container object to prevent the above switch-cases from + // accidentally returning newState (which is common in reducers but would ignore + // remaining update actions here). + return { value: newState }; +} diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts new file mode 100644 index 00000000000..df36af34be6 --- /dev/null +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -0,0 +1,70 @@ +import { getVolumeTracingById } from "viewer/model/accessors/volumetracing_accessor"; +import { + removeSegmentAction, + updateSegmentAction, +} from "viewer/model/actions/volumetracing_actions"; +import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; +import type { WebknossosState } from "viewer/store"; +import VolumeTracingReducer from "../volumetracing_reducer"; +import { setLargestSegmentIdReducer } from "../volumetracing_reducer_helpers"; +import { + applyAddUserBoundingBox, + applyDeleteUserBoundingBox, + applyUpdateUserBoundingBox, +} from "./bounding_box"; + +export function applyVolumeUpdateActionsFromServer( + actions: ApplicableVolumeUpdateAction[], + newState: WebknossosState, +): { value: WebknossosState } { + for (const ua of actions) { + switch (ua.name) { + case "updateLargestSegmentId": { + const volumeTracing = getVolumeTracingById(newState.annotation, ua.value.actionTracingId); + newState = setLargestSegmentIdReducer( + newState, + volumeTracing, + // todop: can this really be null? if so, what should we do? + ua.value.largestSegmentId ?? 0, + ); + break; + } + case "createSegment": + case "updateSegment": { + const { actionTracingId, ...segment } = ua.value; + newState = VolumeTracingReducer( + newState, + updateSegmentAction(segment.id, segment, actionTracingId), + ); + break; + } + case "deleteSegment": { + newState = VolumeTracingReducer( + newState, + removeSegmentAction(ua.value.id, ua.value.actionTracingId), + ); + break; + } + case "updateUserBoundingBoxInVolumeTracing": { + newState = applyUpdateUserBoundingBox(newState, ua); + break; + } + case "addUserBoundingBoxInVolumeTracing": { + newState = applyAddUserBoundingBox(newState, ua); + break; + } + case "deleteUserBoundingBoxInVolumeTracing": { + newState = applyDeleteUserBoundingBox(newState, ua); + break; + } + default: { + ua satisfies never; + } + } + } + + // The state is wrapped in this container object to prevent the above switch-cases from + // accidentally returning newState (which is common in reducers but would ignore + // remaining update actions here). + return { value: newState }; +} diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 51d9163c911..601b0003bcc 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -22,14 +22,12 @@ import type { SetMappingEnabledAction, SetMappingNameAction, } from "viewer/model/actions/settings_actions"; -import { - type ClickSegmentAction, - type RemoveSegmentAction, - type SetSegmentsAction, - type UpdateSegmentAction, - type VolumeTracingAction, - removeSegmentAction, - updateSegmentAction, +import type { + ClickSegmentAction, + RemoveSegmentAction, + SetSegmentsAction, + UpdateSegmentAction, + VolumeTracingAction, } from "viewer/model/actions/volumetracing_actions"; import { updateKey2 } from "viewer/model/helpers/deep_update"; import { @@ -68,6 +66,7 @@ import { mapGroups, mapGroupsToGenerator } from "../accessors/skeletontracing_ac import type { TreeGroup } from "../types/tree_types"; import { sanitizeMetadata } from "./skeletontracing_reducer"; import { forEachGroups } from "./skeletontracing_reducer_helpers"; +import { applyVolumeUpdateActionsFromServer } from "./update_action_application/volume"; type SegmentUpdateInfo = | { @@ -680,45 +679,7 @@ function VolumeTracingReducer( case "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - let newState = state; - for (const ua of actions) { - switch (ua.name) { - case "updateLargestSegmentId": { - const volumeTracing = getVolumeTracingById(state.annotation, ua.value.actionTracingId); - newState = setLargestSegmentIdReducer( - newState, - volumeTracing, - // todop: can this really be null? if so, what should we do? - ua.value.largestSegmentId ?? 0, - ); - break; - } - case "createSegment": - case "updateSegment": { - const { actionTracingId, ...segment } = ua.value; - return VolumeTracingReducer( - state, - updateSegmentAction(segment.id, segment, actionTracingId), - ); - } - case "deleteSegment": { - return VolumeTracingReducer( - state, - removeSegmentAction(ua.value.id, ua.value.actionTracingId), - ); - } - case "updateUserBoundingBoxInVolumeTracing": - case "addUserBoundingBoxInVolumeTracing": - case "deleteUserBoundingBoxInVolumeTracing": { - // todop - return state; - } - default: { - ua satisfies never; - } - } - } - break; + return applyVolumeUpdateActionsFromServer(actions, state).value; } default: From e9605c489ec5e7834d986b92454aa238ab0ba0e7 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:06:43 +0200 Subject: [PATCH 10/92] fix cyclic dep --- .../model/reducers/update_action_application/bounding_box.ts | 1 + .../viewer/model/reducers/update_action_application/volume.ts | 2 +- .../javascripts/viewer/model/reducers/volumetracing_reducer.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts index f0376eb1ea4..d651a2ba61e 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -20,6 +20,7 @@ export function applyUpdateUserBoundingBox( newState: WebknossosState, ua: UpdateUserBoundingBoxInSkeletonTracingAction | UpdateUserBoundingBoxInVolumeTracingAction, ) { + // todop: this won't work for volume actions // todop: dont pass state and instead do the update here? const { skeleton } = newState.annotation; if (skeleton == null) { diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index df36af34be6..19a6150ba16 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -5,7 +5,6 @@ import { } from "viewer/model/actions/volumetracing_actions"; import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; import type { WebknossosState } from "viewer/store"; -import VolumeTracingReducer from "../volumetracing_reducer"; import { setLargestSegmentIdReducer } from "../volumetracing_reducer_helpers"; import { applyAddUserBoundingBox, @@ -16,6 +15,7 @@ import { export function applyVolumeUpdateActionsFromServer( actions: ApplicableVolumeUpdateAction[], newState: WebknossosState, + VolumeTracingReducer: (state: WebknossosState) => WebknossosState, ): { value: WebknossosState } { for (const ua of actions) { switch (ua.name) { diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 601b0003bcc..e3649904fee 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -679,7 +679,7 @@ function VolumeTracingReducer( case "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - return applyVolumeUpdateActionsFromServer(actions, state).value; + return applyVolumeUpdateActionsFromServer(actions, state, VolumeTracingReducer).value; } default: From e65f24a306e222b5bb8c29d2dffe3f3fa3943d65 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:08:59 +0200 Subject: [PATCH 11/92] fix type --- .../model/reducers/update_action_application/volume.ts | 6 +++++- .../viewer/model/reducers/volumetracing_reducer.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 19a6150ba16..d025fc7398c 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -5,6 +5,7 @@ import { } from "viewer/model/actions/volumetracing_actions"; import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; import type { WebknossosState } from "viewer/store"; +import type { VolumeTracingReducerAction } from "../volumetracing_reducer"; import { setLargestSegmentIdReducer } from "../volumetracing_reducer_helpers"; import { applyAddUserBoundingBox, @@ -15,7 +16,10 @@ import { export function applyVolumeUpdateActionsFromServer( actions: ApplicableVolumeUpdateAction[], newState: WebknossosState, - VolumeTracingReducer: (state: WebknossosState) => WebknossosState, + VolumeTracingReducer: ( + state: WebknossosState, + action: VolumeTracingReducerAction, + ) => WebknossosState, ): { value: WebknossosState } { for (const ua of actions) { switch (ua.name) { diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index e3649904fee..53cdc19a2ba 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -323,7 +323,7 @@ export function serverVolumeToClientVolumeTracing( return volumeTracing; } -type VolumeTracingReducerAction = +export type VolumeTracingReducerAction = | VolumeTracingAction | SetMappingAction | FinishMappingInitializationAction From 1072cdac32a75570ca7137424087e3745ec7aa76 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:15:08 +0200 Subject: [PATCH 12/92] fix bbox updates for volume by accessing correct tracing --- .../update_action_application/bounding_box.ts | 33 +++++-------------- .../update_action_application/skeleton.ts | 20 ++++++++--- .../update_action_application/volume.ts | 18 ++++++++-- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts index d651a2ba61e..1454cb75c45 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -18,35 +18,24 @@ import { convertUserBoundingBoxFromUpdateActionToFrontend } from "../reducer_hel export function applyUpdateUserBoundingBox( newState: WebknossosState, + tracing: SkeletonTracing | VolumeTracing, ua: UpdateUserBoundingBoxInSkeletonTracingAction | UpdateUserBoundingBoxInVolumeTracingAction, ) { - // todop: this won't work for volume actions - // todop: dont pass state and instead do the update here? - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.map( + const updatedUserBoundingBoxes = tracing.userBoundingBoxes.map( (bbox): UserBoundingBox => bbox.id === ua.value.boundingBoxId ? { ...bbox, ...convertUserBoundingBoxFromUpdateActionToFrontend(ua.value) } : bbox, ); - return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); + return handleUserBoundingBoxUpdateInTracing(newState, tracing, updatedUserBoundingBoxes); } export function applyAddUserBoundingBox( newState: WebknossosState, + tracing: SkeletonTracing | VolumeTracing, ua: AddUserBoundingBoxInSkeletonTracingAction | AddUserBoundingBoxInVolumeTracingAction, ) { - // todop: dont pass state and instead do the update here? - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - const { boundingBox, ...valueWithoutBoundingBox } = ua.value.boundingBox; const maybeBoundingBoxValue = { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox), @@ -57,25 +46,21 @@ export function applyAddUserBoundingBox( ...valueWithoutBoundingBox, ...maybeBoundingBoxValue, }; - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.concat([newUserBBox]); + const updatedUserBoundingBoxes = tracing.userBoundingBoxes.concat([newUserBBox]); - return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); + return handleUserBoundingBoxUpdateInTracing(newState, tracing, updatedUserBoundingBoxes); } export function applyDeleteUserBoundingBox( newState: WebknossosState, + tracing: SkeletonTracing | VolumeTracing, ua: DeleteUserBoundingBoxInSkeletonTracingAction | DeleteUserBoundingBoxInVolumeTracingAction, ) { - const { skeleton } = newState.annotation; - if (skeleton == null) { - throw new Error("No skeleton found to apply update to."); - } - - const updatedUserBoundingBoxes = skeleton.userBoundingBoxes.filter( + const updatedUserBoundingBoxes = tracing.userBoundingBoxes.filter( (bbox) => bbox.id !== ua.value.boundingBoxId, ); - return handleUserBoundingBoxUpdateInTracing(newState, skeleton, updatedUserBoundingBoxes); + return handleUserBoundingBoxUpdateInTracing(newState, tracing, updatedUserBoundingBoxes); } function handleUserBoundingBoxUpdateInTracing( diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 76026c63dc6..459643004e0 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -1,5 +1,5 @@ import update from "immutability-helper"; -import { getTree } from "viewer/model/accessors/skeletontracing_accessor"; +import { enforceSkeletonTracing, getTree } from "viewer/model/accessors/skeletontracing_accessor"; import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; import type { WebknossosState } from "viewer/store"; import { @@ -103,15 +103,27 @@ export function applySkeletonUpdateActionsFromServer( break; } case "updateUserBoundingBoxInSkeletonTracing": { - newState = applyUpdateUserBoundingBox(newState, ua); + newState = applyUpdateUserBoundingBox( + newState, + enforceSkeletonTracing(newState.annotation), + ua, + ); break; } case "addUserBoundingBoxInSkeletonTracing": { - newState = applyAddUserBoundingBox(newState, ua); + newState = applyAddUserBoundingBox( + newState, + enforceSkeletonTracing(newState.annotation), + ua, + ); break; } case "deleteUserBoundingBoxInSkeletonTracing": { - newState = applyDeleteUserBoundingBox(newState, ua); + newState = applyDeleteUserBoundingBox( + newState, + enforceSkeletonTracing(newState.annotation), + ua, + ); break; } default: { diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index d025fc7398c..4875843a2f1 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -50,15 +50,27 @@ export function applyVolumeUpdateActionsFromServer( break; } case "updateUserBoundingBoxInVolumeTracing": { - newState = applyUpdateUserBoundingBox(newState, ua); + newState = applyUpdateUserBoundingBox( + newState, + getVolumeTracingById(newState.annotation, ua.value.actionTracingId), + ua, + ); break; } case "addUserBoundingBoxInVolumeTracing": { - newState = applyAddUserBoundingBox(newState, ua); + newState = applyAddUserBoundingBox( + newState, + getVolumeTracingById(newState.annotation, ua.value.actionTracingId), + ua, + ); break; } case "deleteUserBoundingBoxInVolumeTracing": { - newState = applyDeleteUserBoundingBox(newState, ua); + newState = applyDeleteUserBoundingBox( + newState, + getVolumeTracingById(newState.annotation, ua.value.actionTracingId), + ua, + ); break; } default: { From 03c8998cb41cffbbfdd2cedd55dd4197f608fad1 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:25:03 +0200 Subject: [PATCH 13/92] rename to BoundingBoxProto where applicable --- frontend/javascripts/types/api_types.ts | 12 ++++++------ frontend/javascripts/types/bounding_box.ts | 2 +- .../viewer/model/reducers/reducer_helpers.ts | 8 ++++---- frontend/javascripts/viewer/model_initialization.ts | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index f30676a9c8f..0153f27cb5e 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -1,6 +1,6 @@ import type { PricingPlanEnum } from "admin/organization/pricing_plan_utils"; import _ from "lodash"; -import type { ServerBoundingBox } from "types/bounding_box"; +import type { BoundingBoxProto } from "types/bounding_box"; import type { AdditionalCoordinate, ColorObject, @@ -28,7 +28,7 @@ import type { import type { EmptyObject } from "./globals"; // Re-export -export type { ServerBoundingBox } from "types/bounding_box"; +export type { BoundingBoxProto } from "types/bounding_box"; export type { AdditionalCoordinate } from "viewer/constants"; export type APIMessage = { [key in "info" | "warning" | "error"]?: string }; @@ -840,7 +840,7 @@ export type ServerBranchPoint = { nodeId: number; }; export type UserBoundingBoxFromServer = { - boundingBox: ServerBoundingBox; + boundingBox: BoundingBoxProto; id: number; name?: string; color?: ColorObject; @@ -895,7 +895,7 @@ type ServerSegment = { export type ServerTracingBase = { id: string; userBoundingBoxes: Array; - userBoundingBox?: ServerBoundingBox; + userBoundingBox?: BoundingBoxProto; createdTimestamp: number; error?: string; additionalAxes: ServerAdditionalAxis[]; @@ -929,7 +929,7 @@ export type ServerSkeletonTracing = ServerTracingBase & { // This is done to simplify the selection for the type. typ: "Skeleton"; activeNodeId?: number; // only use as a fallback if userStates is empty - boundingBox?: ServerBoundingBox; + boundingBox?: BoundingBoxProto; trees: Array; treeGroups: Array | null | undefined; storedWithExternalTreeBodies?: boolean; // unused in frontend @@ -952,7 +952,7 @@ export type ServerVolumeTracing = ServerTracingBase & { // This is done to simplify the selection for the type. typ: "Volume"; activeSegmentId?: number; // only use as a fallback if userStates is empty - boundingBox: ServerBoundingBox; + boundingBox: BoundingBoxProto; elementClass: ElementClass; fallbackLayer?: string; segments: Array; diff --git a/frontend/javascripts/types/bounding_box.ts b/frontend/javascripts/types/bounding_box.ts index 683e9989d5c..1ba8de36cd3 100644 --- a/frontend/javascripts/types/bounding_box.ts +++ b/frontend/javascripts/types/bounding_box.ts @@ -7,7 +7,7 @@ export type BoundingBoxMinMaxType = { }; // 10 matches -export type ServerBoundingBox = { +export type BoundingBoxProto = { topLeft: Point3; width: number; height: number; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 0a696eeedc5..ce36b90228a 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -3,7 +3,7 @@ import type { APIAnnotation, AdditionalAxis, ServerAdditionalAxis, - ServerBoundingBox, + BoundingBoxProto, SkeletonUserState, UserBoundingBoxFromServer, VolumeUserState, @@ -27,7 +27,7 @@ import type { UpdateUserBoundingBoxInSkeletonTracingAction } from "../sagas/upda import type { Tree, TreeGroup } from "../types/tree_types"; function convertServerBoundingBoxToBoundingBoxMinMaxType( - boundingBox: ServerBoundingBox, + boundingBox: BoundingBoxProto, ): BoundingBoxMinMaxType { const min = Utils.point3ToVector3(boundingBox.topLeft); const max: Vector3 = [ @@ -39,7 +39,7 @@ function convertServerBoundingBoxToBoundingBoxMinMaxType( } export function convertServerBoundingBoxToFrontend( - boundingBox: ServerBoundingBox | null | undefined, + boundingBox: BoundingBoxProto | null | undefined, ): BoundingBoxMinMaxType | null | undefined { if (!boundingBox) return boundingBox; return convertServerBoundingBoxToBoundingBoxMinMaxType(boundingBox); @@ -97,7 +97,7 @@ export function convertFrontendBoundingBoxToServer( }; } -export function convertPointToVecInBoundingBox(boundingBox: ServerBoundingBox): BoundingBoxObject { +export function convertBoundingBoxProtoToObject(boundingBox: BoundingBoxProto): BoundingBoxObject { return { width: boundingBox.width, height: boundingBox.height, diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 0692c708e91..66583fa92d5 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -118,7 +118,7 @@ import { getUserStateForTracing } from "./model/accessors/annotation_accessor"; import { doAllLayersHaveTheSameRotation } from "./model/accessors/dataset_layer_transformation_accessor"; import { setVersionNumberAction } from "./model/actions/save_actions"; import { - convertPointToVecInBoundingBox, + convertBoundingBoxProtoToObject, convertServerAdditionalAxesToFrontEnd, convertServerAnnotationToFrontendAnnotation, } from "./model/reducers/reducer_helpers"; @@ -612,7 +612,7 @@ function getMergedDataLayersFromDatasetAndVolumeTracings( elementClass: tracing.elementClass, category: "segmentation", largestSegmentId: tracing.largestSegmentId, - boundingBox: convertPointToVecInBoundingBox(tracing.boundingBox), + boundingBox: convertBoundingBoxProtoToObject(tracing.boundingBox), resolutions: tracingMags, mappings: fallbackLayer != null && "mappings" in fallbackLayer ? fallbackLayer.mappings : undefined, From e76f0a111cf3b7a9ed9212ec50bcc89af087061f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:25:36 +0200 Subject: [PATCH 14/92] also rename to UserBoundingBoxProto --- frontend/javascripts/types/api_types.ts | 4 ++-- frontend/javascripts/viewer/model/reducers/reducer_helpers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index 0153f27cb5e..72f0956ca12 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -839,7 +839,7 @@ export type ServerBranchPoint = { createdTimestamp: number; nodeId: number; }; -export type UserBoundingBoxFromServer = { +export type UserBoundingBoxProto = { boundingBox: BoundingBoxProto; id: number; name?: string; @@ -894,7 +894,7 @@ type ServerSegment = { }; export type ServerTracingBase = { id: string; - userBoundingBoxes: Array; + userBoundingBoxes: Array; userBoundingBox?: BoundingBoxProto; createdTimestamp: number; error?: string; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index ce36b90228a..e73b3bdf347 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -5,7 +5,7 @@ import type { ServerAdditionalAxis, BoundingBoxProto, SkeletonUserState, - UserBoundingBoxFromServer, + UserBoundingBoxProto, VolumeUserState, } from "types/api_types"; import type { BoundingBoxMinMaxType } from "types/bounding_box"; @@ -61,7 +61,7 @@ export function convertUserBoundingBoxFromUpdateActionToFrontend( } export function convertUserBoundingBoxesFromServerToFrontend( - boundingBoxes: Array, + boundingBoxes: Array, userState: SkeletonUserState | VolumeUserState | undefined, ): Array { const idToVisible = userState ? Utils.mapEntriesToMap(userState.boundingBoxVisibilities) : {}; From 7c02290c1e6a62e05ae82f91aaf1bb4683888dc9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:26:31 +0200 Subject: [PATCH 15/92] also rename AdditionalAxisProto --- frontend/javascripts/types/api_types.ts | 4 ++-- frontend/javascripts/viewer/model/reducers/reducer_helpers.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index 72f0956ca12..b246676099f 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -71,7 +71,7 @@ export type AdditionalAxis = { name: string; }; -export type ServerAdditionalAxis = { +export type AdditionalAxisProto = { bounds: { x: number; y: number }; index: number; name: string; @@ -898,7 +898,7 @@ export type ServerTracingBase = { userBoundingBox?: BoundingBoxProto; createdTimestamp: number; error?: string; - additionalAxes: ServerAdditionalAxis[]; + additionalAxes: AdditionalAxisProto[]; // The backend sends the version property, but the front-end should // not care about it. To ensure this, parseProtoTracing will remove // the property. diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index e73b3bdf347..05591ac4c0e 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -2,7 +2,7 @@ import * as Utils from "libs/utils"; import type { APIAnnotation, AdditionalAxis, - ServerAdditionalAxis, + AdditionalAxisProto, BoundingBoxProto, SkeletonUserState, UserBoundingBoxProto, @@ -155,7 +155,7 @@ export function convertServerAnnotationToFrontendAnnotation( } export function convertServerAdditionalAxesToFrontEnd( - additionalAxes: ServerAdditionalAxis[], + additionalAxes: AdditionalAxisProto[], ): AdditionalAxis[] { return additionalAxes.map((coords) => ({ ...coords, From 0481beddc98210946435afc6897cd89df32f0d4c Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 May 2025 16:35:48 +0200 Subject: [PATCH 16/92] misc --- frontend/javascripts/test/helpers/saveHelpers.ts | 6 +----- frontend/javascripts/viewer/model/sagas/save_saga.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/test/helpers/saveHelpers.ts b/frontend/javascripts/test/helpers/saveHelpers.ts index 68be879397d..15aae1669b6 100644 --- a/frontend/javascripts/test/helpers/saveHelpers.ts +++ b/frontend/javascripts/test/helpers/saveHelpers.ts @@ -25,11 +25,7 @@ export function withoutUpdateTracing( items: UpdateActionWithoutIsolationRequirement[], ): UpdateActionWithoutIsolationRequirement[] { return items.filter( - (item) => - item.name !== "updateSkeletonTracing" && - item.name !== "updateVolumeTracing" && - item.name !== "updateActiveNode" && - item.name !== "updateActiveSegmentId", + (item) => item.name !== "updateActiveNode" && item.name !== "updateActiveSegmentId", ); } diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 8033ed4514b..649f484c20b 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -839,16 +839,18 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga Date: Wed, 28 May 2025 16:52:46 +0200 Subject: [PATCH 17/92] support createTree update action --- .../update_action_application/skeleton.ts | 69 +++++++++---------- .../viewer/model/sagas/save_saga.ts | 4 +- .../viewer/model/sagas/update_actions.ts | 1 + 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 459643004e0..d805b75ccc9 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -1,6 +1,9 @@ import update from "immutability-helper"; +import DiffableMap from "libs/diffable_map"; import { enforceSkeletonTracing, getTree } from "viewer/model/accessors/skeletontracing_accessor"; +import EdgeCollection from "viewer/model/edge_collection"; import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; +import type { Tree } from "viewer/model/types/tree_types"; import type { WebknossosState } from "viewer/store"; import { applyAddUserBoundingBox, @@ -14,58 +17,54 @@ export function applySkeletonUpdateActionsFromServer( ): { value: WebknossosState } { for (const ua of actions) { switch (ua.name) { - // case "createTree": { - // const { id, color, name, comments, timestamp, branchPoints, isVisible, groupId } = - // ua.value; - // newState = update(newState, { - // tracing: { - // skeleton: { - // trees: { - // [id]: { - // $set: { - // name, - // treeId: id, - // nodes: new DiffableMap(), - // timestamp, - // color, - // branchPoints, - // edges: new EdgeCollection(), - // comments, - // isVisible, - // groupId, - // }, - // }, - // }, - // }, - // }, - // }); - // break; - // } - case "createNode": { - if (newState.annotation.skeleton == null) { - continue; - } + case "createTree": { + const { id, ...rest } = ua.value; + const newTree: Tree = { + treeId: id, + ...rest, + nodes: new DiffableMap(), + edges: new EdgeCollection(), + }; + const newTrees = enforceSkeletonTracing(newState.annotation).trees.set(id, newTree); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + $set: newTrees, + }, + }, + }, + }); + break; + } + case "updateTree": { + // todop + break; + } + case "createNode": { const { treeId, ...serverNode } = ua.value; // eslint-disable-next-line no-loop-func const { position: untransformedPosition, resolution: mag, ...node } = serverNode; const clientNode = { untransformedPosition, mag, ...node }; - const tree = getTree(newState.annotation.skeleton, treeId); + const skeleton = enforceSkeletonTracing(newState.annotation); + const tree = getTree(skeleton, treeId); if (tree == null) { - // todop: escalate error? - continue; + throw new Error("Could not create node because tree was not found."); } const diffableNodeMap = tree.nodes; const newDiffableMap = diffableNodeMap.set(node.id, clientNode); const newTree = update(tree, { nodes: { $set: newDiffableMap }, }); + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + newState = update(newState, { annotation: { skeleton: { trees: { - [tree.treeId]: { $set: newTree }, + $set: newTrees, }, cachedMaxNodeId: { $set: node.id }, }, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 649f484c20b..e404ca87396 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -711,6 +711,8 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga Date: Wed, 28 May 2025 16:59:29 +0200 Subject: [PATCH 18/92] also implement updateTree --- .../update_action_application/skeleton.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index d805b75ccc9..cf8dd1b564d 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -39,7 +39,23 @@ export function applySkeletonUpdateActionsFromServer( break; } case "updateTree": { - // todop + const { id: treeId, actionTracingId: _actionTracingId, ...treeRest } = ua.value; + const skeleton = enforceSkeletonTracing(newState.annotation); + const tree = getTree(skeleton, treeId); + if (tree == null) { + throw new Error("Could not create node because tree was not found."); + } + const newTree = { ...tree, ...treeRest }; + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + newState = update(newState, { + annotation: { + skeleton: { + trees: { + $set: newTrees, + }, + }, + }, + }); break; } case "createNode": { From 1cd1935af3561d1d5d93121ca0e46b6e5d778157 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 30 May 2025 12:38:22 +0200 Subject: [PATCH 19/92] misc --- frontend/javascripts/viewer/model/sagas/save_saga.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index e404ca87396..4b003460952 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -715,6 +715,7 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga Date: Mon, 2 Jun 2025 21:35:15 +0200 Subject: [PATCH 20/92] implement more live updates of skeleton actions; add some tests --- frontend/javascripts/admin/rest_api.ts | 10 +- frontend/javascripts/test/libs/nml.spec.ts | 2 +- .../reducers/skeletontracing_reducer.spec.ts | 8 +- .../skeleton.spec.ts | 136 ++++++++++++ .../javascripts/viewer/geometries/skeleton.ts | 1 + .../accessors/skeletontracing_accessor.ts | 4 +- .../model/actions/skeletontracing_actions.tsx | 2 +- .../viewer/model/edge_collection.ts | 4 +- .../model/reducers/skeletontracing_reducer.ts | 28 ++- .../skeletontracing_reducer_helpers.ts | 2 +- .../update_action_application/skeleton.ts | 202 +++++++++++++++++- .../viewer/model/sagas/save_saga.ts | 14 +- .../viewer/model/sagas/update_actions.ts | 35 +-- 13 files changed, 408 insertions(+), 40 deletions(-) create mode 100644 frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 534af595f00..b713112cefc 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -765,8 +765,9 @@ export function getUpdateActionLog( annotationId: string, oldestVersion?: number, newestVersion?: number, + sortAscending: boolean = false, ): Promise> { - return doWithToken((token) => { + return doWithToken(async (token) => { const params = new URLSearchParams(); params.set("token", token); if (oldestVersion != null) { @@ -775,9 +776,14 @@ export function getUpdateActionLog( if (newestVersion != null) { params.set("newestVersion", newestVersion.toString()); } - return Request.receiveJSON( + const log: APIUpdateActionBatch[] = await Request.receiveJSON( `${tracingStoreUrl}/tracings/annotation/${annotationId}/updateActionLog?${params}`, ); + + if (sortAscending) { + log.reverse(); + } + return log; }); } diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index bedd8113a09..58e31cc7695 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -769,7 +769,7 @@ describe("NML", () => { expect(newSkeletonTracing.trees.getOrThrow(4).nodes.size()).toBe(3); expect(newSkeletonTracing.trees.getOrThrow(4).nodes.getOrThrow(12).id).toBe(12); - const getSortedEdges = (edges: EdgeCollection) => _.sortBy(edges.asArray(), "source"); + const getSortedEdges = (edges: EdgeCollection) => _.sortBy(edges.toArray(), "source"); // And node ids in edges, branchpoints and comments should have been replaced expect(getSortedEdges(newSkeletonTracing.trees.getOrThrow(3).edges)).toEqual([ diff --git a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts index 6d95071500e..c8920a5444a 100644 --- a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts @@ -112,7 +112,7 @@ describe("SkeletonTracing", () => { expect(newSkeletonTracing.activeNodeId).toBe(3); expect(newSkeletonTracing.trees.getOrThrow(1).nodes.size()).toEqual(3); expect(newSkeletonTracing.trees.getOrThrow(1).edges.size()).toEqual(2); - expect(newSkeletonTracing.trees.getOrThrow(1).edges.asArray()).toEqual([ + expect(newSkeletonTracing.trees.getOrThrow(1).edges.toArray()).toEqual([ { source: 1, target: 2, @@ -152,7 +152,7 @@ describe("SkeletonTracing", () => { expect(newSkeletonTracing.trees.getOrThrow(2).nodes.size()).toEqual(0); expect(newSkeletonTracing.trees.getOrThrow(3).nodes.size()).toEqual(2); expect(newSkeletonTracing.trees.getOrThrow(2).edges.size()).toEqual(0); - expect(newSkeletonTracing.trees.getOrThrow(3).edges.asArray()).toEqual([ + expect(newSkeletonTracing.trees.getOrThrow(3).edges.toArray()).toEqual([ { source: 2, target: 3, @@ -1023,7 +1023,7 @@ describe("SkeletonTracing", () => { const newSkeletonTracing = enforceSkeletonTracing(newState.annotation); expect(newSkeletonTracing.trees.size()).toBe(2); expect(newSkeletonTracing.trees.getOrThrow(3).nodes.size()).toBe(4); - expect(newSkeletonTracing.trees.getOrThrow(3).edges.asArray()).toEqual([ + expect(newSkeletonTracing.trees.getOrThrow(3).edges.toArray()).toEqual([ { source: 2, target: 3, @@ -1089,7 +1089,7 @@ describe("SkeletonTracing", () => { const newSkeletonTracing = enforceSkeletonTracing(newState.annotation); expect(newSkeletonTracing.trees.size()).toBe(2); expect(newSkeletonTracing.trees.getOrThrow(3).nodes.size()).toBe(4); - expect(newSkeletonTracing.trees.getOrThrow(3).edges.asArray()).toEqual([ + expect(newSkeletonTracing.trees.getOrThrow(3).edges.toArray()).toEqual([ { source: 2, target: 3, diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts new file mode 100644 index 00000000000..2684b01a399 --- /dev/null +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -0,0 +1,136 @@ +import update from "immutability-helper"; +import { initialState as defaultState } from "test/fixtures/hybridtracing_object"; +import { chainReduce } from "test/helpers/chainReducer"; +import type { Vector3 } from "viewer/constants"; +import { + enforceSkeletonTracing, + getActiveNode, + getActiveTree, +} from "viewer/model/accessors/skeletontracing_accessor"; +import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; +import SkeletonTracingReducer from "viewer/model/reducers/skeletontracing_reducer"; +import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; +import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; +import type { WebknossosState } from "viewer/store"; +import { describe, expect, it } from "vitest"; + +const initialState: WebknossosState = update(defaultState, { + annotation: { + restrictions: { + allowUpdate: { + $set: true, + }, + branchPointsAllowed: { + $set: true, + }, + }, + annotationType: { $set: "Explorational" }, + }, +}); + +const position = [10, 10, 10] as Vector3; +const rotation = [0.5, 0.5, 0.5] as Vector3; +const viewport = 0; +const mag = 0; + +const applyActions = chainReduce(SkeletonTracingReducer); + +describe("Update Action Application for SkeletonTracing", () => { + it("should add a new node", () => { + const createNode = SkeletonTracingActions.createNodeAction( + position, + null, + rotation, + viewport, + mag, + ); + const newState = applyActions(initialState, [ + createNode, // 1 + createNode, // 2 + createNode, // 3 + createNode, // 4 + createNode, // 5 + SkeletonTracingActions.deleteNodeAction(3), + SkeletonTracingActions.createTreeAction(), + createNode, // 6 + createNode, // 7 + createNode, // 8 + SkeletonTracingActions.setTreeNameAction("Special Name", 1), + SkeletonTracingActions.mergeTreesAction(5, 7), + SkeletonTracingActions.setActiveNodeAction(null), + ]); + const newSkeletonTracing = enforceSkeletonTracing(newState.annotation); + + expect(newSkeletonTracing.trees.size()).toBe(3); + + const updateActions = Array.from( + diffSkeletonTracing(initialState.annotation.skeleton!, newSkeletonTracing), + ) as ApplicableSkeletonUpdateAction[]; + + const reappliedNewState = applyActions(initialState, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + SkeletonTracingActions.setActiveNodeAction(null), + ]); + + console.log("cachedMaxNodeId", reappliedNewState.annotation.skeleton!.cachedMaxNodeId); + + expect(reappliedNewState).toEqual(newState); + }); + + it("should clear the active node if it was deleted", () => { + const createNode = SkeletonTracingActions.createNodeAction( + position, + null, + rotation, + viewport, + mag, + ); + const newState = applyActions(initialState, [ + createNode, // 1 + createNode, // 2 + SkeletonTracingActions.setActiveNodeAction(2), + ]); + expect(getActiveNode(enforceSkeletonTracing(newState.annotation))?.id).toBe(2); + + const newState2 = applyActions(newState, [SkeletonTracingActions.deleteNodeAction(2)]); + + const updateActions = Array.from( + diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + ) as ApplicableSkeletonUpdateAction[]; + + const newState3 = applyActions(newState, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + ]); + + expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); + }); + + it("should clear the active node and active tree if the active tree was deleted", () => { + const createNode = SkeletonTracingActions.createNodeAction( + position, + null, + rotation, + viewport, + mag, + ); + const newState = applyActions(initialState, [ + createNode, // 1 + createNode, // 2 + SkeletonTracingActions.setActiveTreeAction(2), + ]); + expect(getActiveTree(enforceSkeletonTracing(newState.annotation))?.treeId).toBe(2); + + const newState2 = applyActions(newState, [SkeletonTracingActions.deleteTreeAction(2)]); + + const updateActions = Array.from( + diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + ) as ApplicableSkeletonUpdateAction[]; + + const newState3 = applyActions(newState, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + ]); + + expect(getActiveTree(enforceSkeletonTracing(newState3.annotation))).toBeNull(); + expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); + }); +}); diff --git a/frontend/javascripts/viewer/geometries/skeleton.ts b/frontend/javascripts/viewer/geometries/skeleton.ts index eb76513426a..9dfada759a4 100644 --- a/frontend/javascripts/viewer/geometries/skeleton.ts +++ b/frontend/javascripts/viewer/geometries/skeleton.ts @@ -347,6 +347,7 @@ class Skeleton { ); for (const update of diff) { + console.log("Skeleton.ts: Applying", update.name); switch (update.name) { case "createNode": { const { treeId, id: nodeId } = update.value; diff --git a/frontend/javascripts/viewer/model/accessors/skeletontracing_accessor.ts b/frontend/javascripts/viewer/model/accessors/skeletontracing_accessor.ts index 02937b11776..d4f24bc5406 100644 --- a/frontend/javascripts/viewer/model/accessors/skeletontracing_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/skeletontracing_accessor.ts @@ -73,7 +73,7 @@ export function getActiveNode(skeletonTracing: SkeletonTracing): Node | null { const { activeTreeId, activeNodeId } = skeletonTracing; if (activeTreeId != null && activeNodeId != null) { - return skeletonTracing.trees.getOrThrow(activeTreeId).nodes.getOrThrow(activeNodeId); + return skeletonTracing.trees.getNullable(activeTreeId)?.nodes.getNullable(activeNodeId) ?? null; } return null; @@ -86,7 +86,7 @@ export function getActiveTree(skeletonTracing: SkeletonTracing | null | undefine const { activeTreeId } = skeletonTracing; if (activeTreeId != null) { - return skeletonTracing.trees.getNullable(activeTreeId) || null; + return skeletonTracing.trees.getNullable(activeTreeId) ?? null; } return null; diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index d0819ac8ede..94d9795b2c6 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -245,7 +245,7 @@ export const deleteEdgeAction = ( }) as const; export const setActiveNodeAction = ( - nodeId: number, + nodeId: number | null, suppressAnimation: boolean = false, suppressCentering: boolean = false, ) => diff --git a/frontend/javascripts/viewer/model/edge_collection.ts b/frontend/javascripts/viewer/model/edge_collection.ts index af757261397..becabef729e 100644 --- a/frontend/javascripts/viewer/model/edge_collection.ts +++ b/frontend/javascripts/viewer/model/edge_collection.ts @@ -89,7 +89,7 @@ export default class EdgeCollection implements NotEnumerableByObject { } map(fn: (value: Edge) => T): Array { - return this.asArray().map(fn); + return this.toArray().map(fn); } *all(): Generator { @@ -100,7 +100,7 @@ export default class EdgeCollection implements NotEnumerableByObject { } } - asArray(): Edge[] { + toArray(): Edge[] { return Array.from(this.all()); } diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 6978a7c31e5..a23b7c410e9 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -6,7 +6,7 @@ import * as Utils from "libs/utils"; import _ from "lodash"; import type { MetadataEntryProto } from "types/api_types"; import { userSettings } from "types/schemas/user_settings.schema"; -import Constants, { TreeTypeEnum } from "viewer/constants"; +import { TreeTypeEnum } from "viewer/constants"; import { areGeometriesTransformed, findTreeByNodeId, @@ -36,6 +36,7 @@ import { deleteNode, deleteTrees, ensureTreeNames, + getMaximumNodeId, getOrCreateTree, mergeTrees, removeMissingGroupsFromTrees, @@ -69,9 +70,7 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos let activeNodeId = userState?.activeNodeId ?? action.tracing.activeNodeId; const treeGroups = applyUserStateToGroups(action.tracing.treeGroups || [], userState); - let cachedMaxNodeId = max(trees.values().flatMap((__) => __.nodes.map((node) => node.id))); - - cachedMaxNodeId = cachedMaxNodeId != null ? cachedMaxNodeId : Constants.MIN_NODE_ID - 1; + const cachedMaxNodeId = getMaximumNodeId(trees); let activeTreeId = null; @@ -151,6 +150,27 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos switch (action.type) { case "SET_ACTIVE_NODE": { const { nodeId } = action; + + console.log("SET_ACTIVE_NODE", nodeId); + + if (nodeId == null) { + return update(state, { + annotation: { + skeleton: { + activeNodeId: { + $set: null, + }, + activeTreeId: { + $set: null, + }, + activeGroupId: { + $set: null, + }, + }, + }, + }); + } + const tree = findTreeByNodeId(skeletonTracing.trees, nodeId); if (tree) { return update(state, { diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer_helpers.ts index e93ede8f04b..f0ed93ea6c3 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer_helpers.ts @@ -707,7 +707,7 @@ export function mergeTrees( const updatedSourceTree: Tree = { ...sourceTree, nodes: newNodes, - edges: sourceTree.edges.addEdges(targetTree.edges.asArray().concat(newEdge)), + edges: sourceTree.edges.addEdges(targetTree.edges.toArray().concat(newEdge)), comments: sourceTree.comments.concat(targetTree.comments), branchPoints: sourceTree.branchPoints.concat(targetTree.branchPoints), }; diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index cf8dd1b564d..58ffa3e153b 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -5,6 +5,7 @@ import EdgeCollection from "viewer/model/edge_collection"; import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; import type { Tree } from "viewer/model/types/tree_types"; import type { WebknossosState } from "viewer/store"; +import { getMaximumNodeId } from "../skeletontracing_reducer_helpers"; import { applyAddUserBoundingBox, applyDeleteUserBoundingBox, @@ -18,7 +19,7 @@ export function applySkeletonUpdateActionsFromServer( for (const ua of actions) { switch (ua.name) { case "createTree": { - const { id, ...rest } = ua.value; + const { id, updatedId: _updatedId, actionTracingId: _actionTracingId, ...rest } = ua.value; const newTree: Tree = { treeId: id, ...rest, @@ -39,7 +40,12 @@ export function applySkeletonUpdateActionsFromServer( break; } case "updateTree": { - const { id: treeId, actionTracingId: _actionTracingId, ...treeRest } = ua.value; + const { + id: treeId, + actionTracingId: _actionTracingId, + updatedId: _updatedId, + ...treeRest + } = ua.value; const skeleton = enforceSkeletonTracing(newState.annotation); const tree = getTree(skeleton, treeId); if (tree == null) { @@ -60,8 +66,12 @@ export function applySkeletonUpdateActionsFromServer( } case "createNode": { const { treeId, ...serverNode } = ua.value; - // eslint-disable-next-line no-loop-func - const { position: untransformedPosition, resolution: mag, ...node } = serverNode; + const { + position: untransformedPosition, + resolution: mag, + actionTracingId: _actionTracingId, + ...node + } = serverNode; const clientNode = { untransformedPosition, mag, ...node }; const skeleton = enforceSkeletonTracing(newState.annotation); @@ -76,6 +86,42 @@ export function applySkeletonUpdateActionsFromServer( }); const newTrees = skeleton.trees.set(newTree.treeId, newTree); + console.log("setting cachedMaxNodeId to", node.id); + + newState = update(newState, { + annotation: { + skeleton: { + trees: { + $set: newTrees, + }, + cachedMaxNodeId: { $set: Math.max(skeleton.cachedMaxNodeId, node.id) }, + }, + }, + }); + break; + } + case "updateNode": { + const { treeId, ...serverNode } = ua.value; + const { + position: untransformedPosition, + actionTracingId: _actionTracingId, + mag, + ...node + } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const skeleton = enforceSkeletonTracing(newState.annotation); + const tree = getTree(skeleton, treeId); + if (tree == null) { + throw new Error("Could not update node because tree was not found."); + } + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + newState = update(newState, { annotation: { skeleton: { @@ -106,17 +152,163 @@ export function applySkeletonUpdateActionsFromServer( }; const edges = tree.edges.addEdge(newEdge); const newTree = update(tree, { edges: { $set: edges } }); + const newTrees = newState.annotation.skeleton.trees.set(tree.treeId, newTree); + newState = update(newState, { annotation: { skeleton: { trees: { - [tree.treeId]: { $set: newTree }, + $set: newTrees, }, }, }, }); break; } + case "deleteTree": { + const { id } = ua.value; + const skeleton = enforceSkeletonTracing(newState.annotation); + const updatedTrees = skeleton.trees.delete(id); + + newState = update(newState, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + }, + }, + }); + + break; + } + case "moveTreeComponent": { + // Use the _ prefix to ensure that the following code rather + // uses the nodeIdSet. + const { nodeIds: _nodeIds, sourceId, targetId } = ua.value; + const nodeIdSet = new Set(_nodeIds); + + const skeleton = enforceSkeletonTracing(newState.annotation); + const sourceTree = getTree(skeleton, sourceId); + const targetTree = getTree(skeleton, targetId); + + if (!sourceTree || !targetTree) { + throw new Error("Source or target tree not found."); + } + + // Separate moved and remaining nodes + const movedNodeEntries = sourceTree.nodes + .entries() + .filter(([id]) => nodeIdSet.has(id)) + .toArray(); + const remainingNodeEntries = sourceTree.nodes + .entries() + .filter(([id]) => !nodeIdSet.has(id)) + .toArray(); + + // Separate moved and remaining edges + const movedEdges = sourceTree.edges + .toArray() + .filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target)); + const remainingEdges = sourceTree.edges + .toArray() + .filter((e) => !(nodeIdSet.has(e.source) && nodeIdSet.has(e.target))); + + // Create updated source tree + const updatedSourceTree = { + ...sourceTree, + nodes: new DiffableMap(remainingNodeEntries), + edges: new EdgeCollection().addEdges(remainingEdges), + }; + + // Create updated target tree + const updatedTargetNodes = targetTree.nodes.clone(); + for (const [id, node] of movedNodeEntries) { + updatedTargetNodes.mutableSet(id, node); + } + + // todop: check why tests did not fail when .addEdges(movedEdges) was missing + const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges); + + const updatedTargetTree = { + ...targetTree, + nodes: updatedTargetNodes, + edges: updatedTargetEdges, + }; + + const updatedTrees = skeleton.trees + .set(sourceId, updatedSourceTree) + .set(targetId, updatedTargetTree); + + newState = update(newState, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + }, + }, + }); + + break; + } + + case "deleteEdge": { + const { treeId, source, target } = ua.value; + + const skeleton = enforceSkeletonTracing(newState.annotation); + const tree = getTree(skeleton, treeId); + + if (!tree) { + throw new Error("Source or target tree not found."); + } + + const updatedTree = { + ...tree, + edges: tree.edges.removeEdge({ source, target }), + }; + + const updatedTrees = skeleton.trees.set(treeId, updatedTree); + + newState = update(newState, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + }, + }, + }); + + break; + } + + case "deleteNode": { + const { treeId, nodeId } = ua.value; + + const skeleton = enforceSkeletonTracing(newState.annotation); + const tree = getTree(skeleton, treeId); + + if (!tree) { + throw new Error("Source or target tree not found."); + } + + const updatedTree = { + ...tree, + nodes: tree.nodes.delete(nodeId), + }; + + const updatedTrees = skeleton.trees.set(treeId, updatedTree); + + const newActiveNodeId = skeleton.activeNodeId === nodeId ? null : nodeId; + + newState = update(newState, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, + activeNodeId: { $set: newActiveNodeId }, + }, + }, + }); + + break; + } + case "updateUserBoundingBoxInSkeletonTracing": { newState = applyUpdateUserBoundingBox( newState, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 4b003460952..40e8d04cfdf 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -591,11 +591,14 @@ function* watchForSaveConflicts(): Saga { const { url: tracingStoreUrl } = yield* select((state) => state.annotation.tracingStore); + // The order is ascending in the version number ([v_n, v_(n+1), ...]). const newerActions = yield* call( getUpdateActionLog, tracingStoreUrl, annotationId, versionOnClient + 1, + undefined, + true, ); if (newerActions.length !== newerVersionCount) { @@ -687,6 +690,7 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga & { position: Node["untransformedPosition"]; treeId: number; resolution: number; + actionTracingId: string; }; export type UpdateActionNode = Omit & { position: Node["untransformedPosition"]; treeId: number; + actionTracingId: string; }; export function createNode(treeId: number, node: Node, actionTracingId: string) { const { untransformedPosition, mag, ...restNode } = node; + const value: CreateActionNode = { + actionTracingId, + ...restNode, + position: untransformedPosition, + treeId, + resolution: mag, + }; return { name: "createNode", - value: { - actionTracingId, - ...restNode, - position: untransformedPosition, - treeId, - resolution: mag, - } as CreateActionNode, + value, } as const; } export function updateNode(treeId: number, node: Node, actionTracingId: string) { const { untransformedPosition, ...restNode } = node; + const value: UpdateActionNode = { + actionTracingId, + ...restNode, + position: untransformedPosition, + treeId, + }; return { name: "updateNode", - value: { - actionTracingId, - ...restNode, - position: untransformedPosition, - treeId, - } as UpdateActionNode, + value, } as const; } export function deleteNode(treeId: number, nodeId: number, actionTracingId: string) { From 4ea23ba77d090063d98b9df1c2ea25619ecd4ea9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Jun 2025 17:09:31 +0200 Subject: [PATCH 21/92] clean up chain reduce --- frontend/javascripts/test/helpers/chainReducer.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/test/helpers/chainReducer.ts b/frontend/javascripts/test/helpers/chainReducer.ts index 3269fbe5f31..892a70392ba 100644 --- a/frontend/javascripts/test/helpers/chainReducer.ts +++ b/frontend/javascripts/test/helpers/chainReducer.ts @@ -1,9 +1,16 @@ -export function chainReduce(reducer: (arg0: S, arg1: A) => S) { - return (state: S, actionGetters: Array A)>) => { +type ReducerFn = (s: State, arg1: Action) => State; + +export function chainReduce(reducer: ReducerFn) { + /* + * Given a reducer, chainReduce returns a function which accepts a state and + * an array of actions (or action getters). When invoked, that function will + * use the reducer to apply all actions on the initial state. + */ + return (state: State, actionGetters: Array Action)>) => { return actionGetters.reduce((currentState, actionGetter) => { - const action: A = + const action: Action = typeof actionGetter === "function" - ? (actionGetter as (state: S) => A)(currentState) + ? (actionGetter as (state: State) => Action)(currentState) : actionGetter; return reducer(currentState, action); }, state); From c03e04df3551d41e477c2c247167f576cd727921 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Jun 2025 17:09:53 +0200 Subject: [PATCH 22/92] wip: skeleton update action tests --- .../skeleton.spec.ts | 238 +++++++++++++++--- 1 file changed, 200 insertions(+), 38 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 2684b01a399..60e1b7e3ef1 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -1,4 +1,5 @@ import update from "immutability-helper"; +import _ from "lodash"; import { initialState as defaultState } from "test/fixtures/hybridtracing_object"; import { chainReduce } from "test/helpers/chainReducer"; import type { Vector3 } from "viewer/constants"; @@ -7,12 +8,18 @@ import { getActiveNode, getActiveTree, } from "viewer/model/accessors/skeletontracing_accessor"; +import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; +import { SkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; +import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; import SkeletonTracingReducer from "viewer/model/reducers/skeletontracing_reducer"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; -import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; +import type { + ApplicableSkeletonUpdateAction, + UpdateActionWithoutIsolationRequirement, +} from "viewer/model/sagas/update_actions"; import type { WebknossosState } from "viewer/store"; -import { describe, expect, it } from "vitest"; +import { describe, expect, test, it, afterAll } from "vitest"; const initialState: WebknossosState = update(defaultState, { annotation: { @@ -35,49 +42,185 @@ const mag = 0; const applyActions = chainReduce(SkeletonTracingReducer); +const actionNamesList: Record = { + updateTree: true, + createTree: true, + updateNode: true, + createNode: true, + createEdge: true, + deleteTree: true, + deleteEdge: true, + deleteNode: true, + moveTreeComponent: true, + addUserBoundingBoxInSkeletonTracing: true, + updateUserBoundingBoxInSkeletonTracing: true, + deleteUserBoundingBoxInSkeletonTracing: true, +}; + describe("Update Action Application for SkeletonTracing", () => { - it("should add a new node", () => { - const createNode = SkeletonTracingActions.createNodeAction( - position, - null, - rotation, - viewport, - mag, - ); - const newState = applyActions(initialState, [ - createNode, // 1 - createNode, // 2 - createNode, // 3 - createNode, // 4 - createNode, // 5 - SkeletonTracingActions.deleteNodeAction(3), - SkeletonTracingActions.createTreeAction(), - createNode, // 6 - createNode, // 7 - createNode, // 8 - SkeletonTracingActions.setTreeNameAction("Special Name", 1), - SkeletonTracingActions.mergeTreesAction(5, 7), - SkeletonTracingActions.setActiveNodeAction(null), - ]); - const newSkeletonTracing = enforceSkeletonTracing(newState.annotation); + const seenActionTypes = new Set(); - expect(newSkeletonTracing.trees.size()).toBe(3); + let idx = 0; + const createNode = () => + SkeletonTracingActions.createNodeAction([10, 10, idx++], null, rotation, viewport, mag); - const updateActions = Array.from( - diffSkeletonTracing(initialState.annotation.skeleton!, newSkeletonTracing), - ) as ApplicableSkeletonUpdateAction[]; + /* + * Hardcode these values if you want to focus on a specific test. + */ + const compactionModes = [false]; + const hardcodedBeforeVersionIndex: number | null = null; // 14; + const hardcodedAfterVersionIndex: number | null = null; // 26; + // const compactionModes = [true]; + // const hardcodedBeforeVersionIndex: number | null = 9; // 14; + // const hardcodedAfterVersionIndex: number | null = 26; // 26; - const reappliedNewState = applyActions(initialState, [ - SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), - SkeletonTracingActions.setActiveNodeAction(null), - ]); + const userActions: SkeletonTracingAction[] = [ + SkeletonTracingActions.deleteTreeAction(2), // delete second tree. one tree remains. + createNode(), // nodeId=1 + createNode(), // nodeId=2 + createNode(), // nodeId=3 + createNode(), // nodeId=4 + createNode(), // nodeId=5 + SkeletonTracingActions.deleteNodeAction(3), // tree components == {1,2} {4,5} + SkeletonTracingActions.createTreeAction(), + createNode(), // nodeId=6 + createNode(), // nodeId=7 + createNode(), // nodeId=8, tree components == {1,2} {4,5} {6,7,8} + SkeletonTracingActions.setTreeNameAction("Special Name", 1), + SkeletonTracingActions.setActiveNodeAction(null), + SkeletonTracingActions.mergeTreesAction(5, 7), // tree components {1,2} {4,5,6,7,8} + SkeletonTracingActions.setActiveNodeAction(2), + createNode(), // nodeId=9, tree components {1,2,9} {4,5,6,7,8} + SkeletonTracingActions.setActiveNodeAction(2), + createNode(), // nodeId=10, tree components {1,2,9,10} {4,5,6,7,8} + SkeletonTracingActions.setActiveNodeAction(1), + createNode(), // nodeId=11, tree components {11,1,2,9,10} {4,5,6,7,8} + SkeletonTracingActions.deleteEdgeAction(1, 2), // tree components {11,1} {2,9,10} {4,5,6,7,8} + SkeletonTracingActions.createTreeAction(), + createNode(), // nodeId=12 + createNode(), // nodeId=13 + createNode(), // nodeId=14, tree components == {1,2} {4,5} {6,7,8} {12,13,14} + SkeletonTracingActions.deleteTreeAction(3), + SkeletonTracingActions.setNodePositionAction([1, 2, 3], 6), + addUserBoundingBoxAction({ + boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, + name: "UserBBox", + color: [1, 2, 3], + isVisible: true, + }), + ]; - console.log("cachedMaxNodeId", reappliedNewState.annotation.skeleton!.cachedMaxNodeId); + test.skip("User actions for test should not contain no-ops", () => { + let state = initialState; + for (const action of userActions) { + const newState = SkeletonTracingReducer(state, action); + expect(newState !== state).toBeTruthy(); - expect(reappliedNewState).toEqual(newState); + state = newState; + console.log("state.activeTreeId", state.annotation.skeleton!.activeTreeId); + } }); - it("should clear the active node if it was deleted", () => { + const beforeVersionIndices = + hardcodedBeforeVersionIndex != null + ? [hardcodedBeforeVersionIndex] + : _.range(0, userActions.length); + + // it.only("should re-apply update actions from complex diff and get same state", () => { + describe.each(compactionModes)( + "[Compaction=%s]: should re-apply update actions from complex diff and get same state", + (withCompaction) => { + describe.each(beforeVersionIndices)("From v=%i", (beforeVersionIndex: number) => { + const afterVersionIndices = + hardcodedAfterVersionIndex != null + ? [hardcodedAfterVersionIndex] + : _.range(beforeVersionIndex + 1, userActions.length + 1); + + test.each(afterVersionIndices)("To v=%i", (afterVersionIndex: number) => { + // console.log(".slice(0, beforeVersionIndex)", beforeVersionIndex); + // console.log("actions", userActions.slice(0, beforeVersionIndex)); + const state2WithActiveTree = applyActions( + initialState, + userActions.slice(0, beforeVersionIndex), + ); + + const state2WithoutActiveTree = applyActions(state2WithActiveTree, [ + SkeletonTracingActions.setActiveNodeAction(null), + ]); + + // console.log("state2.activeTreeId", state2.annotation.skeleton!.activeTreeId); + + // console.log("actions", userActions.slice(beforeVersionIndex, afterVersionIndex + 1)); + const actionsToApply = userActions.slice(beforeVersionIndex, afterVersionIndex + 1); + // console.log("actionsToApply", actionsToApply); + const state3 = applyActions( + state2WithActiveTree, + actionsToApply.concat([SkeletonTracingActions.setActiveNodeAction(null)]), + ); + // console.log("state3.activeTreeId", state3.annotation.skeleton!.activeTreeId); + expect(state2WithoutActiveTree !== state3).toBeTruthy(); + + // console.log( + // ".slice(beforeVersionIndex, afterVersionIndex + 1)", + // beforeVersionIndex, + // afterVersionIndex + 1, + // ); + // logTrees("state2", state2); + // logTrees("state3", state3); + const skeletonTracing2 = enforceSkeletonTracing(state2WithoutActiveTree.annotation); + const skeletonTracing3 = enforceSkeletonTracing(state3.annotation); + + const updateActionsBeforeCompaction = Array.from( + diffSkeletonTracing(skeletonTracing2, skeletonTracing3), + ); + // console.log("updateActionsBeforeCompaction", updateActionsBeforeCompaction); + const maybeCompact = withCompaction + ? compactUpdateActions + : (updateActions: UpdateActionWithoutIsolationRequirement[]) => updateActions; + const updateActions = maybeCompact( + updateActionsBeforeCompaction, + skeletonTracing3, + ) as ApplicableSkeletonUpdateAction[]; + + console.log( + "updateActions", + updateActions + .filter((ua) => ua.name === "createNode") + .map((ua) => [ua.value.id, ua.value.position]), + ); + + for (const action of updateActions) { + seenActionTypes.add(action.name); + } + + // console.log("updateActions", updateActions); + expect(updateActions.length > 0).toBeTruthy(); + + const reappliedNewState = applyActions(state2WithoutActiveTree, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + SkeletonTracingActions.setActiveNodeAction(null), + ]); + + // console.log( + // "state2WithoutActiveTree.cachedMaxNodeId", + // state2WithoutActiveTree.annotation.skeleton!.cachedMaxNodeId, + // ); + // console.log("state3.cachedMaxNodeId", state3.annotation.skeleton!.cachedMaxNodeId); + + // console.log( + // "reappliedNewState.cachedMaxNodeId", + // reappliedNewState.annotation.skeleton!.cachedMaxNodeId, + // ); + logTrees("state3", state3); + logTrees("reappliedNewState", reappliedNewState); + + expect(reappliedNewState).toEqual(state3); + }); + }); + }, + ); + + it.skip("should clear the active node if it was deleted", () => { const createNode = SkeletonTracingActions.createNodeAction( position, null, @@ -105,7 +248,7 @@ describe("Update Action Application for SkeletonTracing", () => { expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); }); - it("should clear the active node and active tree if the active tree was deleted", () => { + it.skip("should clear the active node and active tree if the active tree was deleted", () => { const createNode = SkeletonTracingActions.createNodeAction( position, null, @@ -133,4 +276,23 @@ describe("Update Action Application for SkeletonTracing", () => { expect(getActiveTree(enforceSkeletonTracing(newState3.annotation))).toBeNull(); expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); }); + + afterAll(() => { + console.log("Seen action types:", [...seenActionTypes]); + expect(seenActionTypes).toEqual(new Set(Object.keys(actionNamesList))); + }); }); + +function logTrees(prefix: string, state: WebknossosState) { + const size = state.annotation.skeleton!.trees.getOrThrow(1).nodes.size(); + console.log("logTrees. size", size); + for (const tree of state.annotation.skeleton!.trees.values()) { + console.log( + `${prefix}. tree.id=${tree.treeId}.`, + "edges: ", + // Array.from(tree.edges.values().map((edge) => `${edge.source}-${edge.target}`)).join(", "), + "nodes: ", + Array.from(tree.nodes.values().map((n) => n.id)).join(", "), + ); + } +} From 0a507ae0cf694ac8fc6c618f92f40a3bab29442b Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Jun 2025 17:10:13 +0200 Subject: [PATCH 23/92] add values method for EdgeCollection --- frontend/javascripts/viewer/model/edge_collection.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/javascripts/viewer/model/edge_collection.ts b/frontend/javascripts/viewer/model/edge_collection.ts index becabef729e..7fef5216f59 100644 --- a/frontend/javascripts/viewer/model/edge_collection.ts +++ b/frontend/javascripts/viewer/model/edge_collection.ts @@ -93,6 +93,10 @@ export default class EdgeCollection implements NotEnumerableByObject { } *all(): Generator { + yield* this.values(); + } + + *values(): MapIterator { for (const edgeArray of this.outMap.values()) { for (const edge of edgeArray) { yield edge; From a0c18cbcae079cec3f995c57dc5347e947c1473d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Jun 2025 17:11:05 +0200 Subject: [PATCH 24/92] fix cachedMaxNodeId --- .../viewer/model/reducers/skeletontracing_reducer.ts | 2 -- .../reducers/update_action_application/skeleton.ts | 10 +++++----- frontend/javascripts/viewer/model/sagas/save_saga.ts | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index a23b7c410e9..1f8c62be2ba 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -151,8 +151,6 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos case "SET_ACTIVE_NODE": { const { nodeId } = action; - console.log("SET_ACTIVE_NODE", nodeId); - if (nodeId == null) { return update(state, { annotation: { diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 58ffa3e153b..5b4fb06742f 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -86,15 +86,13 @@ export function applySkeletonUpdateActionsFromServer( }); const newTrees = skeleton.trees.set(newTree.treeId, newTree); - console.log("setting cachedMaxNodeId to", node.id); - newState = update(newState, { annotation: { skeleton: { trees: { $set: newTrees, }, - cachedMaxNodeId: { $set: Math.max(skeleton.cachedMaxNodeId, node.id) }, + cachedMaxNodeId: { $set: getMaximumNodeId(newTrees) }, }, }, }); @@ -128,7 +126,6 @@ export function applySkeletonUpdateActionsFromServer( trees: { $set: newTrees, }, - cachedMaxNodeId: { $set: node.id }, }, }, }); @@ -174,6 +171,7 @@ export function applySkeletonUpdateActionsFromServer( annotation: { skeleton: { trees: { $set: updatedTrees }, + cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, }, }, }); @@ -226,7 +224,9 @@ export function applySkeletonUpdateActionsFromServer( } // todop: check why tests did not fail when .addEdges(movedEdges) was missing - const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges); + const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges, true); + + console.log("movedEdges.values()", Array.from(movedEdges.values())); const updatedTargetTree = { ...targetTree, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 40e8d04cfdf..e6c4ac386e0 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -848,7 +848,7 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga Date: Tue, 3 Jun 2025 17:15:16 +0200 Subject: [PATCH 25/92] typing --- .../viewer/model/actions/annotation_actions.ts | 2 +- .../viewer/model/actions/skeletontracing_actions.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/javascripts/viewer/model/actions/annotation_actions.ts b/frontend/javascripts/viewer/model/actions/annotation_actions.ts index 5182bf2bc71..31219fee24b 100644 --- a/frontend/javascripts/viewer/model/actions/annotation_actions.ts +++ b/frontend/javascripts/viewer/model/actions/annotation_actions.ts @@ -57,7 +57,7 @@ type FinishedResizingUserBoundingBoxAction = ReturnType< typeof finishedResizingUserBoundingBoxAction >; type AddUserBoundingBoxesAction = ReturnType; -type AddNewUserBoundingBox = ReturnType; +export type AddNewUserBoundingBox = ReturnType; export type ChangeUserBoundingBoxAction = ReturnType; type DeleteUserBoundingBox = ReturnType; export type UpdateMeshVisibilityAction = ReturnType; diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index 94d9795b2c6..fed31a741f2 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -11,7 +11,10 @@ import { getTree, getTreeAndNode, } from "viewer/model/accessors/skeletontracing_accessor"; -import { AllUserBoundingBoxActions } from "viewer/model/actions/annotation_actions"; +import { + type AddNewUserBoundingBox, + AllUserBoundingBoxActions, +} from "viewer/model/actions/annotation_actions"; import type { MutableTreeMap, Tree, TreeGroup } from "viewer/model/types/tree_types"; import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/store"; @@ -136,7 +139,8 @@ export type SkeletonTracingAction = | SetMergerModeEnabledAction | UpdateNavigationListAction | LoadAgglomerateSkeletonAction - | ApplySkeletonUpdateActionsFromServerAction; + | ApplySkeletonUpdateActionsFromServerAction + | AddNewUserBoundingBox; export const SkeletonTracingSaveRelevantActions = [ "INITIALIZE_SKELETONTRACING", From a7945357fccde164b09a649a6e57094547960663 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Jun 2025 17:16:23 +0200 Subject: [PATCH 26/92] add todo comment --- .../test/reducers/update_action_application/skeleton.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 60e1b7e3ef1..89318e44aa7 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -113,6 +113,7 @@ describe("Update Action Application for SkeletonTracing", () => { test.skip("User actions for test should not contain no-ops", () => { let state = initialState; for (const action of userActions) { + // todop: use wk reducer so that addUserBoundingBoxAction does sth const newState = SkeletonTracingReducer(state, action); expect(newState !== state).toBeTruthy(); From 35e64d869c74c0e247aac9d574a1cc10580047fd Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 4 Jun 2025 18:11:54 +0200 Subject: [PATCH 27/92] also pass prev and current tracing to compact function so that moved and modified nodes are detected as such --- .../skeleton.spec.ts | 51 ++++----------- .../test/sagas/skeletontracing_saga.spec.ts | 30 ++++++--- .../compaction/compact_update_actions.ts | 64 +++++++++++++------ .../update_action_application/skeleton.ts | 3 - .../viewer/model/sagas/save_saga.ts | 1 + frontend/javascripts/viewer/store.ts | 2 +- 6 files changed, 78 insertions(+), 73 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 89318e44aa7..af5fd83dbdb 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -102,12 +102,12 @@ describe("Update Action Application for SkeletonTracing", () => { createNode(), // nodeId=14, tree components == {1,2} {4,5} {6,7,8} {12,13,14} SkeletonTracingActions.deleteTreeAction(3), SkeletonTracingActions.setNodePositionAction([1, 2, 3], 6), - addUserBoundingBoxAction({ - boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, - name: "UserBBox", - color: [1, 2, 3], - isVisible: true, - }), + // addUserBoundingBoxAction({ + // boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, + // name: "UserBBox", + // color: [1, 2, 3], + // isVisible: true, + // }), ]; test.skip("User actions for test should not contain no-ops", () => { @@ -118,7 +118,6 @@ describe("Update Action Application for SkeletonTracing", () => { expect(newState !== state).toBeTruthy(); state = newState; - console.log("state.activeTreeId", state.annotation.skeleton!.activeTreeId); } }); @@ -138,8 +137,6 @@ describe("Update Action Application for SkeletonTracing", () => { : _.range(beforeVersionIndex + 1, userActions.length + 1); test.each(afterVersionIndices)("To v=%i", (afterVersionIndex: number) => { - // console.log(".slice(0, beforeVersionIndex)", beforeVersionIndex); - // console.log("actions", userActions.slice(0, beforeVersionIndex)); const state2WithActiveTree = applyActions( initialState, userActions.slice(0, beforeVersionIndex), @@ -149,23 +146,13 @@ describe("Update Action Application for SkeletonTracing", () => { SkeletonTracingActions.setActiveNodeAction(null), ]); - // console.log("state2.activeTreeId", state2.annotation.skeleton!.activeTreeId); - - // console.log("actions", userActions.slice(beforeVersionIndex, afterVersionIndex + 1)); const actionsToApply = userActions.slice(beforeVersionIndex, afterVersionIndex + 1); - // console.log("actionsToApply", actionsToApply); const state3 = applyActions( state2WithActiveTree, actionsToApply.concat([SkeletonTracingActions.setActiveNodeAction(null)]), ); - // console.log("state3.activeTreeId", state3.annotation.skeleton!.activeTreeId); expect(state2WithoutActiveTree !== state3).toBeTruthy(); - // console.log( - // ".slice(beforeVersionIndex, afterVersionIndex + 1)", - // beforeVersionIndex, - // afterVersionIndex + 1, - // ); // logTrees("state2", state2); // logTrees("state3", state3); const skeletonTracing2 = enforceSkeletonTracing(state2WithoutActiveTree.annotation); @@ -180,16 +167,10 @@ describe("Update Action Application for SkeletonTracing", () => { : (updateActions: UpdateActionWithoutIsolationRequirement[]) => updateActions; const updateActions = maybeCompact( updateActionsBeforeCompaction, + skeletonTracing2, skeletonTracing3, ) as ApplicableSkeletonUpdateAction[]; - console.log( - "updateActions", - updateActions - .filter((ua) => ua.name === "createNode") - .map((ua) => [ua.value.id, ua.value.position]), - ); - for (const action of updateActions) { seenActionTypes.add(action.name); } @@ -202,18 +183,8 @@ describe("Update Action Application for SkeletonTracing", () => { SkeletonTracingActions.setActiveNodeAction(null), ]); - // console.log( - // "state2WithoutActiveTree.cachedMaxNodeId", - // state2WithoutActiveTree.annotation.skeleton!.cachedMaxNodeId, - // ); - // console.log("state3.cachedMaxNodeId", state3.annotation.skeleton!.cachedMaxNodeId); - - // console.log( - // "reappliedNewState.cachedMaxNodeId", - // reappliedNewState.annotation.skeleton!.cachedMaxNodeId, - // ); - logTrees("state3", state3); - logTrees("reappliedNewState", reappliedNewState); + // logTrees("state3", state3); + // logTrees("reappliedNewState", reappliedNewState); expect(reappliedNewState).toEqual(state3); }); @@ -280,11 +251,11 @@ describe("Update Action Application for SkeletonTracing", () => { afterAll(() => { console.log("Seen action types:", [...seenActionTypes]); - expect(seenActionTypes).toEqual(new Set(Object.keys(actionNamesList))); + // expect(seenActionTypes).toEqual(new Set(Object.keys(actionNamesList))); }); }); -function logTrees(prefix: string, state: WebknossosState) { +function _logTrees(prefix: string, state: WebknossosState) { const size = state.annotation.skeleton!.trees.getOrThrow(1).nodes.size(); console.log("logTrees. size", size); for (const tree of state.annotation.skeleton!.trees.values()) { diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index ac8124e8afd..444fb770407 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -40,12 +40,13 @@ function testDiffing(prevAnnotation: StoreAnnotation, nextAnnotation: StoreAnnot function createCompactedSaveQueueFromUpdateActions( updateActions: UpdateActionWithoutIsolationRequirement[][], timestamp: number, + prevTracing: SkeletonTracing, tracing: SkeletonTracing, stats: TracingStats | null = null, ) { return compactSaveQueue( createSaveQueueFromUpdateActions( - updateActions.map((batch) => compactUpdateActions(batch, tracing)), + updateActions.map((batch) => compactUpdateActions(batch, prevTracing, tracing)), timestamp, stats, ), @@ -567,7 +568,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); const simplifiedFirstBatch = simplifiedUpdateActions[0].actions; @@ -630,7 +632,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( updateActions, TIMESTAMP, - skeletonTracing, + newState1.annotation.skeleton!, + newState2.annotation.skeleton!, ); // This should result in one created node and its edge (a) @@ -725,7 +728,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( updateActions, TIMESTAMP, - skeletonTracing, + initialState.annotation.skeleton!, + newState.annotation.skeleton!, ); // This should result in a moved treeComponent of size one (a) @@ -814,7 +818,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); // This should result in a new tree @@ -873,7 +878,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); // This should result in two new trees and two moved treeComponents of size three and two @@ -950,7 +956,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( updateActions, TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState2.annotation.skeleton!, ); // This should result in the creation of a new tree (a) @@ -1042,7 +1049,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); // The deleteTree optimization in compactUpdateActions (that is unrelated to this test) // will remove the first deleteNode update action as the first tree is deleted because of the merge, @@ -1068,7 +1076,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); const simplifiedFirstBatch = simplifiedUpdateActions[0].actions; @@ -1097,7 +1106,8 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( [updateActions], TIMESTAMP, - skeletonTracing, + testState.annotation.skeleton!, + newState.annotation.skeleton!, ); const simplifiedFirstBatch = simplifiedUpdateActions[0].actions; diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts index 03f442253e9..26f40002056 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts @@ -1,5 +1,6 @@ import { withoutValues } from "libs/utils"; import _ from "lodash"; +import { CreateNodeAction, DeleteNodeAction } from "viewer/model/actions/skeletontracing_actions"; import compactToggleActions from "viewer/model/helpers/compaction/compact_toggle_actions"; import type { CreateEdgeUpdateAction, @@ -9,7 +10,7 @@ import type { DeleteTreeUpdateAction, UpdateActionWithoutIsolationRequirement, } from "viewer/model/sagas/update_actions"; -import { moveTreeComponent } from "viewer/model/sagas/update_actions"; +import { moveTreeComponent, updateNode } from "viewer/model/sagas/update_actions"; import type { SkeletonTracing, VolumeTracing } from "viewer/store"; // The Cantor pairing function assigns one natural number to each pair of natural numbers @@ -17,7 +18,11 @@ function cantor(a: number, b: number): number { return 0.5 * (a + b) * (a + b + 1) + b; } -function compactMovedNodesAndEdges(updateActions: Array) { +function compactMovedNodesAndEdges( + updateActions: Array, + prevTracing: SkeletonTracing | VolumeTracing, + tracing: SkeletonTracing | VolumeTracing, +) { // This function detects tree merges and splits. // It does so by identifying nodes and edges that were deleted in one tree only to be created // in another tree again afterwards. @@ -28,6 +33,10 @@ function compactMovedNodesAndEdges(updateActions: Array cantor(createUA.value.treeId, deleteUA.value.treeId), - ); + ) as Record< + number, + Array< + | [CreateNodeUpdateAction, DeleteNodeUpdateAction] + | [CreateEdgeUpdateAction, DeleteEdgeUpdateAction] + > + >; // Create a moveTreeComponent update action for each of the groups and insert it at the right spot for (const movedPairings of _.values(groupedMovedNodesAndEdges)) { const actionTracingId = movedPairings[0][1].value.actionTracingId; const oldTreeId = movedPairings[0][1].value.treeId; const newTreeId = movedPairings[0][0].value.treeId; - // This could be done with a .filter(...).map(...), but flow cannot comprehend that - const nodeIds = movedPairings.reduce((agg: number[], [createUA]) => { - if (createUA.name === "createNode") agg.push(createUA.value.id); - return agg; - }, []); + const nodeIds = movedPairings + .filter( + (tuple): tuple is [CreateNodeUpdateAction, DeleteNodeUpdateAction] => + tuple[0].name === "createNode", + ) + .map(([createUA]) => createUA.value.id); + // The moveTreeComponent update action needs to be placed: // BEFORE the possible deleteTree update action of the oldTreeId and // AFTER the possible createTree update action of the newTreeId @@ -96,6 +113,8 @@ function compactMovedNodesAndEdges(updateActions: Array ua.name === "createTree" && ua.value.id === newTreeId, ); + const moveAction = moveTreeComponent(oldTreeId, newTreeId, nodeIds, actionTracingId); + if (deleteTreeUAIndex > -1 && createTreeUAIndex > -1) { // This should not happen, but in case it does, the moveTreeComponent update action // cannot be inserted as the createTreeUA is after the deleteTreeUA @@ -103,29 +122,35 @@ function compactMovedNodesAndEdges(updateActions: Array -1) { // Insert after the createTreeUA - compactedActions.splice( - createTreeUAIndex + 1, - 0, - moveTreeComponent(oldTreeId, newTreeId, nodeIds, actionTracingId), - ); + compactedActions.splice(createTreeUAIndex + 1, 0, moveAction); } else if (deleteTreeUAIndex > -1) { // Insert before the deleteTreeUA - compactedActions.splice( - deleteTreeUAIndex, - 0, - moveTreeComponent(oldTreeId, newTreeId, nodeIds, actionTracingId), - ); + compactedActions.splice(deleteTreeUAIndex, 0, moveAction); } else { // Insert in front compactedActions.unshift(moveTreeComponent(oldTreeId, newTreeId, nodeIds, actionTracingId)); } + // Add updateNode actions if node was changed (by reference) + for (const [createUA, deleteUA] of movedPairings) { + if (createUA.name === "createNode" && deleteUA.name === "deleteNode") { + const nodeId = createUA.value.id; + const newNode = tracing.trees.getNullable(newTreeId)?.nodes.getNullable(nodeId); + const oldNode = prevTracing.trees.getNullable(oldTreeId)?.nodes.getNullable(nodeId); + + if (newNode !== oldNode && newNode != null) { + compactedActions.push(updateNode(newTreeId, newNode, actionTracingId)); + } + } + } + // Remove the original create/delete update actions of the moved nodes and edges. type CreateOrDeleteNodeOrEdge = | CreateNodeUpdateAction | DeleteNodeUpdateAction | CreateEdgeUpdateAction | DeleteEdgeUpdateAction; + compactedActions = withoutValues( compactedActions, // Cast movedPairs type to satisfy _.flatten @@ -157,10 +182,11 @@ function compactDeletedTrees(updateActions: Array, + prevTracing: SkeletonTracing | VolumeTracing, tracing: SkeletonTracing | VolumeTracing, ): Array { return compactToggleActions( - compactDeletedTrees(compactMovedNodesAndEdges(updateActions)), + compactDeletedTrees(compactMovedNodesAndEdges(updateActions, prevTracing, tracing)), tracing, ); } diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 5b4fb06742f..d8de201fadc 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -223,11 +223,8 @@ export function applySkeletonUpdateActionsFromServer( updatedTargetNodes.mutableSet(id, node); } - // todop: check why tests did not fail when .addEdges(movedEdges) was missing const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges, true); - console.log("movedEdges.values()", Array.from(movedEdges.values())); - const updatedTargetTree = { ...targetTree, nodes: updatedTargetNodes, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index e6c4ac386e0..7d65bc9f8b1 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -506,6 +506,7 @@ export function* setupSavingForTracingType( const items = compactUpdateActions( Array.from(yield* call(performDiffTracing, prevTracing, tracing)), + prevTracing, tracing, ); diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index df9e0f9c3da..811411c7eec 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -579,7 +579,7 @@ export type WebknossosState = { }; const sagaMiddleware = createSagaMiddleware(); export type Reducer = (state: WebknossosState, action: Action) => WebknossosState; -const combinedReducers = reduceReducers( +export const combinedReducers = reduceReducers( SettingsReducer, DatasetReducer, SkeletonTracingReducer, From 73f1b792d40b1c4f4ccedcb4cd569644ab4e635d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 4 Jun 2025 18:12:20 +0200 Subject: [PATCH 28/92] prepare testing bbox related actions --- .../test/fixtures/dataset_server_object.ts | 113 ++++++++++-------- .../skeleton.spec.ts | 22 ++-- 2 files changed, 75 insertions(+), 60 deletions(-) diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 64d093cdfc1..1ede0441f5c 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -1,5 +1,63 @@ import { UnitLong } from "viewer/constants"; -import type { APIDataset } from "types/api_types"; +import type { APIColorLayer, APIDataset, APISegmentationLayer } from "types/api_types"; + +const sampleColorLayer: APIColorLayer = { + name: "color", + category: "color", + boundingBox: { + topLeft: [0, 0, 0], + width: 10240, + height: 10240, + depth: 10240, + }, + resolutions: [ + [1, 1, 1], + [2, 2, 2], + [32, 32, 32], + [4, 4, 4], + [8, 8, 8], + [16, 16, 16], + ], + elementClass: "uint8", + additionalAxes: [], +}; + +const sampleSegmentationLayer: APISegmentationLayer = { + name: "segmentation", + category: "segmentation", + boundingBox: { + topLeft: [0, 0, 0], + width: 10240, + height: 10240, + depth: 10240, + }, + resolutions: [ + [1, 1, 1], + [2, 2, 2], + [32, 32, 32], + [4, 4, 4], + [8, 8, 8], + [16, 16, 16], + ], + elementClass: "uint32", + largestSegmentId: 1000000000, + mappings: [ + "larger5um1", + "axons", + "astrocyte-ge-7", + "astrocyte", + "mitochondria", + "astrocyte-full", + ], + tracingId: undefined, + additionalAxes: [], +}; + +export const sampleTracingLayer: APISegmentationLayer = { + ...sampleSegmentationLayer, + name: "tracingId", + tracingId: "tracingId", +}; const apiDataset: APIDataset = { id: "66f3c82966010034942e9740", @@ -9,58 +67,7 @@ const apiDataset: APIDataset = { name: "ROI2017_wkw", team: "Connectomics department", }, - dataLayers: [ - { - name: "color", - category: "color", - boundingBox: { - topLeft: [0, 0, 0], - width: 10240, - height: 10240, - depth: 10240, - }, - resolutions: [ - [1, 1, 1], - [2, 2, 2], - [32, 32, 32], - [4, 4, 4], - [8, 8, 8], - [16, 16, 16], - ], - elementClass: "uint8", - additionalAxes: [], - }, - { - name: "segmentation", - category: "segmentation", - boundingBox: { - topLeft: [0, 0, 0], - width: 10240, - height: 10240, - depth: 10240, - }, - resolutions: [ - [1, 1, 1], - [2, 2, 2], - [32, 32, 32], - [4, 4, 4], - [8, 8, 8], - [16, 16, 16], - ], - elementClass: "uint32", - largestSegmentId: 1000000000, - mappings: [ - "larger5um1", - "axons", - "astrocyte-ge-7", - "astrocyte", - "mitochondria", - "astrocyte-full", - ], - tracingId: undefined, - additionalAxes: [], - }, - ], + dataLayers: [sampleColorLayer, sampleSegmentationLayer], scale: { factor: [11.239999771118164, 11.239999771118164, 28], unit: UnitLong.nm }, }, dataStore: { diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index af5fd83dbdb..d32302345bb 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -1,5 +1,6 @@ import update from "immutability-helper"; import _ from "lodash"; +import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; import { initialState as defaultState } from "test/fixtures/hybridtracing_object"; import { chainReduce } from "test/helpers/chainReducer"; import type { Vector3 } from "viewer/constants"; @@ -18,7 +19,7 @@ import type { ApplicableSkeletonUpdateAction, UpdateActionWithoutIsolationRequirement, } from "viewer/model/sagas/update_actions"; -import type { WebknossosState } from "viewer/store"; +import { combinedReducers, type WebknossosState } from "viewer/store"; import { describe, expect, test, it, afterAll } from "vitest"; const initialState: WebknossosState = update(defaultState, { @@ -33,6 +34,13 @@ const initialState: WebknossosState = update(defaultState, { }, annotationType: { $set: "Explorational" }, }, + dataset: { + dataSource: { + dataLayers: { + $set: [sampleTracingLayer], + }, + }, + }, }); const position = [10, 10, 10] as Vector3; @@ -40,7 +48,7 @@ const rotation = [0.5, 0.5, 0.5] as Vector3; const viewport = 0; const mag = 0; -const applyActions = chainReduce(SkeletonTracingReducer); +const applyActions = chainReduce(combinedReducers); const actionNamesList: Record = { updateTree: true, @@ -67,12 +75,12 @@ describe("Update Action Application for SkeletonTracing", () => { /* * Hardcode these values if you want to focus on a specific test. */ - const compactionModes = [false]; - const hardcodedBeforeVersionIndex: number | null = null; // 14; - const hardcodedAfterVersionIndex: number | null = null; // 26; + const compactionModes = [true, false]; + const hardcodedBeforeVersionIndex: number | null = null; + const hardcodedAfterVersionIndex: number | null = null; // const compactionModes = [true]; - // const hardcodedBeforeVersionIndex: number | null = 9; // 14; - // const hardcodedAfterVersionIndex: number | null = 26; // 26; + // const hardcodedBeforeVersionIndex: number | null = 27; // 14; + // const hardcodedAfterVersionIndex: number | null = 28; // 26; const userActions: SkeletonTracingAction[] = [ SkeletonTracingActions.deleteTreeAction(2), // delete second tree. one tree remains. From 9e234da6229ff40ac7c755f007f6367dfbcaecc1 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 10 Jun 2025 15:07:49 +0200 Subject: [PATCH 29/92] fix skeleton specs (don't concat and then compact because that scenario should not happen in prod etc) --- .../skeleton.spec.ts | 3 +- .../test/sagas/skeletontracing_saga.spec.ts | 50 +++++++++++-------- .../compaction/compact_update_actions.ts | 1 - 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index d32302345bb..2d1a566ea29 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -9,9 +9,8 @@ import { getActiveNode, getActiveTree, } from "viewer/model/accessors/skeletontracing_accessor"; -import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; -import { SkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; +import type { SkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; import SkeletonTracingReducer from "viewer/model/reducers/skeletontracing_reducer"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 444fb770407..eb04829d88c 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -699,11 +699,11 @@ describe("SkeletonTracingSaga", () => { // Create three nodes in the first tree, then create a second tree with one node const testState = applyActions(initialState, [ - createNodeAction, - createNodeAction, - createNodeAction, + createNodeAction, // nodeId=1 + createNodeAction, // nodeId=2 + createNodeAction, // nodeId=3 createTreeAction, - createNodeAction, + createNodeAction, // nodeId=4 ]); // Merge the second tree into the first tree (a) @@ -714,8 +714,8 @@ describe("SkeletonTracingSaga", () => { // Create another tree and two nodes (b) const newState = applyActions(stateAfterFirstMerge, [ createTreeAction, - createNodeAction, - createNodeAction, + createNodeAction, // nodeId=5 + createNodeAction, // nodeId=6 ]); updateActions.push(testDiffing(stateAfterFirstMerge.annotation, newState.annotation)); @@ -728,7 +728,7 @@ describe("SkeletonTracingSaga", () => { const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( updateActions, TIMESTAMP, - initialState.annotation.skeleton!, + testState.annotation.skeleton!, newState.annotation.skeleton!, ); @@ -938,30 +938,35 @@ describe("SkeletonTracingSaga", () => { // Create six nodes const testState = applyActions(initialState, [ - createNodeAction, - createNodeAction, - createNodeAction, - createNodeAction, - createNodeAction, - createNodeAction, + createNodeAction, // nodeId=1 + createNodeAction, // nodeId=2 <-- will be deleted + createNodeAction, // nodeId=3 + createNodeAction, // nodeId=4 <-- will be deleted + createNodeAction, // nodeId=5 + createNodeAction, // nodeId=6 ]); // Delete the second node to split the tree (a) const newState1 = SkeletonTracingReducer(testState, deleteMiddleNodeAction); - const updateActions = []; - updateActions.push(testDiffing(testState.annotation, newState1.annotation)); + const updateActions1 = [testDiffing(testState.annotation, newState1.annotation)]; + const simplifiedUpdateActions1 = createCompactedSaveQueueFromUpdateActions( + updateActions1, + TIMESTAMP, + testState.annotation.skeleton!, + newState1.annotation.skeleton!, + ); // Delete node 4 to split the tree again (b) const newState2 = SkeletonTracingReducer(newState1, deleteOtherMiddleNodeAction); - updateActions.push(testDiffing(newState1.annotation, newState2.annotation)); - const simplifiedUpdateActions = createCompactedSaveQueueFromUpdateActions( - updateActions, + const updateActions2 = [testDiffing(newState1.annotation, newState2.annotation)]; + const simplifiedUpdateActions2 = createCompactedSaveQueueFromUpdateActions( + updateActions2, TIMESTAMP, - testState.annotation.skeleton!, + newState1.annotation.skeleton!, newState2.annotation.skeleton!, ); // This should result in the creation of a new tree (a) - const simplifiedFirstBatch = simplifiedUpdateActions[0].actions; + const simplifiedFirstBatch = simplifiedUpdateActions1[0].actions; expect(simplifiedFirstBatch[0]).toMatchObject({ name: "createTree", value: { @@ -991,9 +996,11 @@ describe("SkeletonTracingSaga", () => { expect(simplifiedFirstBatch[3].name).toBe("deleteEdge"); expect(simplifiedFirstBatch[4].name).toBe("deleteEdge"); expect(simplifiedFirstBatch.length).toBe(5); + expect(simplifiedUpdateActions1.length).toBe(1); // the creation of a new tree (b) - const simplifiedSecondBatch = simplifiedUpdateActions[1].actions; + const simplifiedSecondBatch = simplifiedUpdateActions2[0].actions; + expect(simplifiedUpdateActions2.length).toBe(1); expect(simplifiedSecondBatch[0]).toMatchObject({ name: "createTree", value: { @@ -1023,6 +1030,7 @@ describe("SkeletonTracingSaga", () => { expect(simplifiedSecondBatch[3].name).toBe("deleteEdge"); expect(simplifiedSecondBatch[4].name).toBe("deleteEdge"); expect(simplifiedSecondBatch.length).toBe(5); + expect(simplifiedUpdateActions2.length).toBe(1); }); it("compactUpdateActions should do nothing if it cannot compact", () => { diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts index 26f40002056..f564de9ca43 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts @@ -1,6 +1,5 @@ import { withoutValues } from "libs/utils"; import _ from "lodash"; -import { CreateNodeAction, DeleteNodeAction } from "viewer/model/actions/skeletontracing_actions"; import compactToggleActions from "viewer/model/helpers/compaction/compact_toggle_actions"; import type { CreateEdgeUpdateAction, From 3ff93c7af4e682c77c36bc942f457655855e5109 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 10 Jun 2025 15:57:37 +0200 Subject: [PATCH 30/92] fix bounding box test in skeleton.spec --- .../test/fixtures/hybridtracing_object.ts | 11 ++-- .../test/fixtures/skeletontracing_object.ts | 21 ++++++++ .../test/fixtures/volumetracing_object.ts | 11 ++-- .../skeleton.spec.ts | 53 +++++++++++-------- .../model/reducers/skeletontracing_reducer.ts | 3 ++ .../model/reducers/volumetracing_reducer.ts | 3 ++ frontend/javascripts/viewer/store.ts | 4 +- 7 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 frontend/javascripts/test/fixtures/skeletontracing_object.ts diff --git a/frontend/javascripts/test/fixtures/hybridtracing_object.ts b/frontend/javascripts/test/fixtures/hybridtracing_object.ts index 263e4cc9549..2cb10001e5d 100644 --- a/frontend/javascripts/test/fixtures/hybridtracing_object.ts +++ b/frontend/javascripts/test/fixtures/hybridtracing_object.ts @@ -1,7 +1,7 @@ import update from "immutability-helper"; import { TreeMap, type Tree } from "viewer/model/types/tree_types"; import type { SkeletonTracing } from "viewer/store"; -import { initialState as defaultState } from "test/fixtures/volumetracing_object"; +import { initialState as defaultVolumeState } from "test/fixtures/volumetracing_object"; import DiffableMap from "libs/diffable_map"; import EdgeCollection from "viewer/model/edge_collection"; @@ -9,7 +9,7 @@ import { MISSING_GROUP_ID } from "viewer/view/right-border-tabs/trees_tab/tree_h import { TreeTypeEnum } from "viewer/constants"; import type { APIColorLayer } from "types/api_types"; -const colorLayer: APIColorLayer = { +export const colorLayer: APIColorLayer = { name: "color", category: "color", boundingBox: { @@ -85,16 +85,19 @@ export const initialSkeletonTracing: SkeletonTracing = { additionalAxes: [], }; -export const initialState = update(defaultState, { +export const initialState = update(defaultVolumeState, { annotation: { skeleton: { $set: initialSkeletonTracing, }, + readOnly: { + $set: null, + }, }, dataset: { dataSource: { dataLayers: { - $set: [...defaultState.dataset.dataSource.dataLayers, colorLayer], + $set: [...defaultVolumeState.dataset.dataSource.dataLayers, colorLayer], }, }, }, diff --git a/frontend/javascripts/test/fixtures/skeletontracing_object.ts b/frontend/javascripts/test/fixtures/skeletontracing_object.ts new file mode 100644 index 00000000000..559f08d0fcc --- /dev/null +++ b/frontend/javascripts/test/fixtures/skeletontracing_object.ts @@ -0,0 +1,21 @@ +import update from "immutability-helper"; +import { initialSkeletonTracing, colorLayer } from "./hybridtracing_object"; +import defaultState from "viewer/default_state"; + +export const initialState = update(defaultState, { + annotation: { + skeleton: { + $set: initialSkeletonTracing, + }, + readOnly: { + $set: null, + }, + }, + dataset: { + dataSource: { + dataLayers: { + $set: [colorLayer], + }, + }, + }, +}); diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index b0b515eb170..afe50f81c15 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -31,15 +31,18 @@ export const initialState = update(defaultState, { restrictions: { $set: { branchPointsAllowed: true, + initialAllowUpdate: true, allowUpdate: true, allowFinish: true, allowAccess: true, allowDownload: true, + allowedModes: [], + somaClickingAllowed: true, + volumeInterpolationAllowed: true, + mergerMode: false, magRestrictions: { - // @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'number | un... Remove this comment to see the full error message - min: null, - // @ts-expect-error ts-migrate(2322) FIXME: Type 'null' is not assignable to type 'number | un... Remove this comment to see the full error message - max: null, + min: undefined, + max: undefined, }, }, }, diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 2d1a566ea29..3f3e15f19cb 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -1,7 +1,7 @@ import update from "immutability-helper"; import _ from "lodash"; import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; -import { initialState as defaultState } from "test/fixtures/hybridtracing_object"; +import { initialState as defaultSkeletonState } from "test/fixtures/skeletontracing_object"; import { chainReduce } from "test/helpers/chainReducer"; import type { Vector3 } from "viewer/constants"; import { @@ -9,19 +9,20 @@ import { getActiveNode, getActiveTree, } from "viewer/model/accessors/skeletontracing_accessor"; +import type { Action } from "viewer/model/actions/actions"; +import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; -import type { SkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; +import { setActiveUserBoundingBoxId } from "viewer/model/actions/ui_actions"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; -import SkeletonTracingReducer from "viewer/model/reducers/skeletontracing_reducer"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; import type { ApplicableSkeletonUpdateAction, UpdateActionWithoutIsolationRequirement, } from "viewer/model/sagas/update_actions"; -import { combinedReducers, type WebknossosState } from "viewer/store"; +import { combinedReducer, type WebknossosState } from "viewer/store"; import { describe, expect, test, it, afterAll } from "vitest"; -const initialState: WebknossosState = update(defaultState, { +const initialState: WebknossosState = update(defaultSkeletonState, { annotation: { restrictions: { allowUpdate: { @@ -47,7 +48,7 @@ const rotation = [0.5, 0.5, 0.5] as Vector3; const viewport = 0; const mag = 0; -const applyActions = chainReduce(combinedReducers); +const applyActions = chainReduce(combinedReducer); const actionNamesList: Record = { updateTree: true, @@ -78,10 +79,10 @@ describe("Update Action Application for SkeletonTracing", () => { const hardcodedBeforeVersionIndex: number | null = null; const hardcodedAfterVersionIndex: number | null = null; // const compactionModes = [true]; - // const hardcodedBeforeVersionIndex: number | null = 27; // 14; - // const hardcodedAfterVersionIndex: number | null = 28; // 26; + // const hardcodedBeforeVersionIndex: number | null = 25; + // const hardcodedAfterVersionIndex: number | null = 27; - const userActions: SkeletonTracingAction[] = [ + const userActions: Action[] = [ SkeletonTracingActions.deleteTreeAction(2), // delete second tree. one tree remains. createNode(), // nodeId=1 createNode(), // nodeId=2 @@ -109,19 +110,19 @@ describe("Update Action Application for SkeletonTracing", () => { createNode(), // nodeId=14, tree components == {1,2} {4,5} {6,7,8} {12,13,14} SkeletonTracingActions.deleteTreeAction(3), SkeletonTracingActions.setNodePositionAction([1, 2, 3], 6), - // addUserBoundingBoxAction({ - // boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, - // name: "UserBBox", - // color: [1, 2, 3], - // isVisible: true, - // }), + addUserBoundingBoxAction({ + boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, + name: "UserBBox", + color: [1, 2, 3], + isVisible: true, + }), ]; - test.skip("User actions for test should not contain no-ops", () => { + test("User actions for test should not contain no-ops", () => { let state = initialState; for (const action of userActions) { // todop: use wk reducer so that addUserBoundingBoxAction does sth - const newState = SkeletonTracingReducer(state, action); + const newState = combinedReducer(state, action); expect(newState !== state).toBeTruthy(); state = newState; @@ -133,7 +134,6 @@ describe("Update Action Application for SkeletonTracing", () => { ? [hardcodedBeforeVersionIndex] : _.range(0, userActions.length); - // it.only("should re-apply update actions from complex diff and get same state", () => { describe.each(compactionModes)( "[Compaction=%s]: should re-apply update actions from complex diff and get same state", (withCompaction) => { @@ -151,12 +151,16 @@ describe("Update Action Application for SkeletonTracing", () => { const state2WithoutActiveTree = applyActions(state2WithActiveTree, [ SkeletonTracingActions.setActiveNodeAction(null), + setActiveUserBoundingBoxId(null), ]); const actionsToApply = userActions.slice(beforeVersionIndex, afterVersionIndex + 1); const state3 = applyActions( state2WithActiveTree, - actionsToApply.concat([SkeletonTracingActions.setActiveNodeAction(null)]), + actionsToApply.concat([ + SkeletonTracingActions.setActiveNodeAction(null), + setActiveUserBoundingBoxId(null), + ]), ); expect(state2WithoutActiveTree !== state3).toBeTruthy(); @@ -188,10 +192,13 @@ describe("Update Action Application for SkeletonTracing", () => { const reappliedNewState = applyActions(state2WithoutActiveTree, [ SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), SkeletonTracingActions.setActiveNodeAction(null), + setActiveUserBoundingBoxId(null), ]); + // console.log("state3.annotation", state3.annotation); + // console.log("reappliedNewState.annotation", reappliedNewState.annotation); // logTrees("state3", state3); - // logTrees("reappliedNewState", reappliedNewState); + // logTrees("reappliedNewState.", reappliedNewState); expect(reappliedNewState).toEqual(state3); }); @@ -199,7 +206,7 @@ describe("Update Action Application for SkeletonTracing", () => { }, ); - it.skip("should clear the active node if it was deleted", () => { + it("should clear the active node if it was deleted", () => { const createNode = SkeletonTracingActions.createNodeAction( position, null, @@ -227,7 +234,7 @@ describe("Update Action Application for SkeletonTracing", () => { expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); }); - it.skip("should clear the active node and active tree if the active tree was deleted", () => { + it("should clear the active node and active tree if the active tree was deleted", () => { const createNode = SkeletonTracingActions.createNodeAction( position, null, @@ -262,7 +269,7 @@ describe("Update Action Application for SkeletonTracing", () => { }); }); -function _logTrees(prefix: string, state: WebknossosState) { +function logTrees(prefix: string, state: WebknossosState) { const size = state.annotation.skeleton!.trees.getOrThrow(1).nodes.size(); console.log("logTrees. size", size); for (const tree of state.annotation.skeleton!.trees.values()) { diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 1f8c62be2ba..0bd5a8f3696 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -134,6 +134,9 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos skeleton: { $set: skeletonTracing, }, + readOnly: { + $set: null, + }, }, }); } diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 53cdc19a2ba..8b2bdd51a88 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -437,6 +437,9 @@ function VolumeTracingReducer( volumes: { $set: newVolumes, }, + readOnly: { + $set: null, + }, }, }); diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 811411c7eec..091d9a9e8d3 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -579,7 +579,7 @@ export type WebknossosState = { }; const sagaMiddleware = createSagaMiddleware(); export type Reducer = (state: WebknossosState, action: Action) => WebknossosState; -export const combinedReducers = reduceReducers( +export const combinedReducer = reduceReducers( SettingsReducer, DatasetReducer, SkeletonTracingReducer, @@ -597,7 +597,7 @@ export const combinedReducers = reduceReducers( ); const store = createStore( - enableBatching(combinedReducers), + enableBatching(combinedReducer), defaultState, applyMiddleware(actionLoggerMiddleware, overwriteActionMiddleware, sagaMiddleware as Middleware), ); From d50c72821df79521f40f5739a6be46a21efd62c8 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 10 Jun 2025 16:33:07 +0200 Subject: [PATCH 31/92] finish skeleton spec and fix bbox related test --- .../skeleton.spec.ts | 48 +++++++++---------- .../viewer/model/reducers/reducer_helpers.ts | 7 ++- .../update_action_application/skeleton.ts | 5 ++ .../viewer/model/sagas/update_actions.ts | 1 + 4 files changed, 34 insertions(+), 27 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 3f3e15f19cb..8d0a28f440c 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -10,7 +10,11 @@ import { getActiveTree, } from "viewer/model/accessors/skeletontracing_accessor"; import type { Action } from "viewer/model/actions/actions"; -import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; +import { + addUserBoundingBoxAction, + changeUserBoundingBoxAction, + deleteUserBoundingBoxAction, +} from "viewer/model/actions/annotation_actions"; import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; import { setActiveUserBoundingBoxId } from "viewer/model/actions/ui_actions"; import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; @@ -50,7 +54,11 @@ const mag = 0; const applyActions = chainReduce(combinedReducer); -const actionNamesList: Record = { +// This helper dict exists so that we can ensure via typescript that +// the list contains all members of ApplicableSkeletonUpdateAction. As soon as +// ApplicableSkeletonUpdateAction is extended with another action, TS will complain +// if the following dictionary doesn't contain that action. +const actionNamesHelper: Record = { updateTree: true, createTree: true, updateNode: true, @@ -62,8 +70,10 @@ const actionNamesList: Record = { moveTreeComponent: true, addUserBoundingBoxInSkeletonTracing: true, updateUserBoundingBoxInSkeletonTracing: true, + updateUserBoundingBoxVisibilityInSkeletonTracing: true, deleteUserBoundingBoxInSkeletonTracing: true, }; +const actionNamesList = Object.keys(actionNamesHelper); describe("Update Action Application for SkeletonTracing", () => { const seenActionTypes = new Set(); @@ -78,9 +88,6 @@ describe("Update Action Application for SkeletonTracing", () => { const compactionModes = [true, false]; const hardcodedBeforeVersionIndex: number | null = null; const hardcodedAfterVersionIndex: number | null = null; - // const compactionModes = [true]; - // const hardcodedBeforeVersionIndex: number | null = 25; - // const hardcodedAfterVersionIndex: number | null = 27; const userActions: Action[] = [ SkeletonTracingActions.deleteTreeAction(2), // delete second tree. one tree remains. @@ -116,12 +123,13 @@ describe("Update Action Application for SkeletonTracing", () => { color: [1, 2, 3], isVisible: true, }), + changeUserBoundingBoxAction(1, { name: "Updated Name" }), + deleteUserBoundingBoxAction(1), ]; test("User actions for test should not contain no-ops", () => { let state = initialState; for (const action of userActions) { - // todop: use wk reducer so that addUserBoundingBoxAction does sth const newState = combinedReducer(state, action); expect(newState !== state).toBeTruthy(); @@ -141,7 +149,7 @@ describe("Update Action Application for SkeletonTracing", () => { const afterVersionIndices = hardcodedAfterVersionIndex != null ? [hardcodedAfterVersionIndex] - : _.range(beforeVersionIndex + 1, userActions.length + 1); + : _.range(beforeVersionIndex, userActions.length + 1); test.each(afterVersionIndices)("To v=%i", (afterVersionIndex: number) => { const state2WithActiveTree = applyActions( @@ -149,7 +157,7 @@ describe("Update Action Application for SkeletonTracing", () => { userActions.slice(0, beforeVersionIndex), ); - const state2WithoutActiveTree = applyActions(state2WithActiveTree, [ + const state2WithoutActiveState = applyActions(state2WithActiveTree, [ SkeletonTracingActions.setActiveNodeAction(null), setActiveUserBoundingBoxId(null), ]); @@ -162,17 +170,14 @@ describe("Update Action Application for SkeletonTracing", () => { setActiveUserBoundingBoxId(null), ]), ); - expect(state2WithoutActiveTree !== state3).toBeTruthy(); + expect(state2WithoutActiveState !== state3).toBeTruthy(); - // logTrees("state2", state2); - // logTrees("state3", state3); - const skeletonTracing2 = enforceSkeletonTracing(state2WithoutActiveTree.annotation); + const skeletonTracing2 = enforceSkeletonTracing(state2WithoutActiveState.annotation); const skeletonTracing3 = enforceSkeletonTracing(state3.annotation); const updateActionsBeforeCompaction = Array.from( diffSkeletonTracing(skeletonTracing2, skeletonTracing3), ); - // console.log("updateActionsBeforeCompaction", updateActionsBeforeCompaction); const maybeCompact = withCompaction ? compactUpdateActions : (updateActions: UpdateActionWithoutIsolationRequirement[]) => updateActions; @@ -186,20 +191,12 @@ describe("Update Action Application for SkeletonTracing", () => { seenActionTypes.add(action.name); } - // console.log("updateActions", updateActions); - expect(updateActions.length > 0).toBeTruthy(); - - const reappliedNewState = applyActions(state2WithoutActiveTree, [ + const reappliedNewState = applyActions(state2WithoutActiveState, [ SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), SkeletonTracingActions.setActiveNodeAction(null), setActiveUserBoundingBoxId(null), ]); - // console.log("state3.annotation", state3.annotation); - // console.log("reappliedNewState.annotation", reappliedNewState.annotation); - // logTrees("state3", state3); - // logTrees("reappliedNewState.", reappliedNewState); - expect(reappliedNewState).toEqual(state3); }); }); @@ -264,19 +261,18 @@ describe("Update Action Application for SkeletonTracing", () => { }); afterAll(() => { - console.log("Seen action types:", [...seenActionTypes]); - // expect(seenActionTypes).toEqual(new Set(Object.keys(actionNamesList))); + expect(seenActionTypes).toEqual(new Set(actionNamesList)); }); }); -function logTrees(prefix: string, state: WebknossosState) { +function _debugLogTrees(prefix: string, state: WebknossosState) { const size = state.annotation.skeleton!.trees.getOrThrow(1).nodes.size(); console.log("logTrees. size", size); for (const tree of state.annotation.skeleton!.trees.values()) { console.log( `${prefix}. tree.id=${tree.treeId}.`, "edges: ", - // Array.from(tree.edges.values().map((edge) => `${edge.source}-${edge.target}`)).join(", "), + Array.from(tree.edges.values().map((edge) => `${edge.source}-${edge.target}`)).join(", "), "nodes: ", Array.from(tree.nodes.values().map((n) => n.id)).join(", "), ); diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 05591ac4c0e..772b9c210de 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -48,7 +48,12 @@ export function convertServerBoundingBoxToFrontend( export function convertUserBoundingBoxFromUpdateActionToFrontend( bboxValue: UpdateUserBoundingBoxInSkeletonTracingAction["value"], ): Partial { - const { boundingBox, actionTracingId: _actionTracingId, ...valueWithoutBoundingBox } = bboxValue; + const { + boundingBox, + boundingBoxId: _boundingBoxId, + actionTracingId: _actionTracingId, + ...valueWithoutBoundingBox + } = bboxValue; const maybeBoundingBoxValue = boundingBox != null ? { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox) } diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index d8de201fadc..f11c6fcc859 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -322,6 +322,11 @@ export function applySkeletonUpdateActionsFromServer( ); break; } + case "updateUserBoundingBoxVisibilityInSkeletonTracing": { + // Visibility updates are user-specific and don't need to be + // incorporated for the current user. + break; + } case "deleteUserBoundingBoxInSkeletonTracing": { newState = applyDeleteUserBoundingBox( newState, diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/update_actions.ts index 297d9f44111..e21ab52acc8 100644 --- a/frontend/javascripts/viewer/model/sagas/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/update_actions.ts @@ -117,6 +117,7 @@ export type ApplicableSkeletonUpdateAction = | MoveTreeComponentUpdateAction | AddUserBoundingBoxInSkeletonTracingAction | UpdateUserBoundingBoxInSkeletonTracingAction + | UpdateUserBoundingBoxVisibilityInSkeletonTracingAction | DeleteUserBoundingBoxInSkeletonTracingAction; export type ApplicableVolumeUpdateAction = From dcdce64f60b4c77ca15d2e4a68bd3b32bab5e18d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 10 Jun 2025 17:45:48 +0200 Subject: [PATCH 32/92] support more update actions --- .../skeleton.spec.ts | 10 ++++++++ .../model/actions/skeletontracing_actions.tsx | 4 ---- .../model/actions/volumetracing_actions.ts | 2 -- .../update_action_application/skeleton.ts | 23 +++++++++++++++++++ .../update_action_application/volume.ts | 9 ++++++++ .../viewer/model/sagas/save_saga.ts | 12 +++++----- .../viewer/model/sagas/update_actions.ts | 4 ++++ 7 files changed, 52 insertions(+), 12 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 8d0a28f440c..4ef9f07a7be 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -24,6 +24,7 @@ import type { UpdateActionWithoutIsolationRequirement, } from "viewer/model/sagas/update_actions"; import { combinedReducer, type WebknossosState } from "viewer/store"; +import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { describe, expect, test, it, afterAll } from "vitest"; const initialState: WebknossosState = update(defaultSkeletonState, { @@ -68,6 +69,9 @@ const actionNamesHelper: Record = deleteEdge: true, deleteNode: true, moveTreeComponent: true, + updateTreeGroups: true, + updateTreeGroupsExpandedState: true, + updateTreeEdgesVisibility: true, addUserBoundingBoxInSkeletonTracing: true, updateUserBoundingBoxInSkeletonTracing: true, updateUserBoundingBoxVisibilityInSkeletonTracing: true, @@ -125,6 +129,12 @@ describe("Update Action Application for SkeletonTracing", () => { }), changeUserBoundingBoxAction(1, { name: "Updated Name" }), deleteUserBoundingBoxAction(1), + SkeletonTracingActions.setTreeGroupsAction([ + makeBasicGroupObject(3, "group 3"), + makeBasicGroupObject(7, "group 7"), + ]), + SkeletonTracingActions.setTreeGroupAction(7, 2), + SkeletonTracingActions.setTreeEdgeVisibilityAction(2, false), ]; test("User actions for test should not contain no-ops", () => { diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index fed31a741f2..6dafa324d2a 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -651,14 +651,10 @@ export const updateNavigationListAction = (list: Array, activeIndex: num export const applySkeletonUpdateActionsFromServerAction = ( actions: Array, - // version: number, - // author: string, ) => ({ type: "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER", actions, - // version, - // author, }) as const; export const loadAgglomerateSkeletonAction = ( diff --git a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts index 0ef7655303a..4fc193025af 100644 --- a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts @@ -484,6 +484,4 @@ export const applyVolumeUpdateActionsFromServerAction = ( ({ type: "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER", actions, - // version, - // author, }) as const; diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index f11c6fcc859..0496649e3d0 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -11,6 +11,11 @@ import { applyDeleteUserBoundingBox, applyUpdateUserBoundingBox, } from "./bounding_box"; +import SkeletonTracingReducer from "../skeletontracing_reducer"; +import { + setTreeEdgeVisibilityAction, + setTreeGroupsAction, +} from "viewer/model/actions/skeletontracing_actions"; export function applySkeletonUpdateActionsFromServer( actions: ApplicableSkeletonUpdateAction[], @@ -306,6 +311,24 @@ export function applySkeletonUpdateActionsFromServer( break; } + case "updateTreeGroups": { + newState = SkeletonTracingReducer(newState, setTreeGroupsAction(ua.value.treeGroups)); + break; + } + + case "updateTreeGroupsExpandedState": { + // changes to user specific state does not need to be reacted to + break; + } + + case "updateTreeEdgesVisibility": { + newState = SkeletonTracingReducer( + newState, + setTreeEdgeVisibilityAction(ua.value.treeId, ua.value.edgesAreVisible), + ); + break; + } + case "updateUserBoundingBoxInSkeletonTracing": { newState = applyUpdateUserBoundingBox( newState, diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 4875843a2f1..eb451e17cf4 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -1,6 +1,7 @@ import { getVolumeTracingById } from "viewer/model/accessors/volumetracing_accessor"; import { removeSegmentAction, + setSegmentGroupsAction, updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; @@ -12,6 +13,7 @@ import { applyDeleteUserBoundingBox, applyUpdateUserBoundingBox, } from "./bounding_box"; +import type { TreeGroup } from "viewer/model/types/tree_types"; export function applyVolumeUpdateActionsFromServer( actions: ApplicableVolumeUpdateAction[], @@ -49,6 +51,13 @@ export function applyVolumeUpdateActionsFromServer( ); break; } + case "updateSegmentGroups": { + newState = VolumeTracingReducer( + newState, + setSegmentGroupsAction(ua.value.segmentGroups, ua.value.actionTracingId), + ); + break; + } case "updateUserBoundingBoxInVolumeTracing": { newState = applyUpdateUserBoundingBox( newState, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 7d65bc9f8b1..fe0a6824d86 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -725,6 +725,8 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga Date: Wed, 11 Jun 2025 10:28:07 +0200 Subject: [PATCH 33/92] improve typing --- .../test/schemas/dataset_view_configuration.spec.ts | 3 +-- frontend/javascripts/viewer/store.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/test/schemas/dataset_view_configuration.spec.ts b/frontend/javascripts/test/schemas/dataset_view_configuration.spec.ts index b9a163cfb74..894f79a5b69 100644 --- a/frontend/javascripts/test/schemas/dataset_view_configuration.spec.ts +++ b/frontend/javascripts/test/schemas/dataset_view_configuration.spec.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import _ from "lodash"; import { describe, it, expect } from "vitest"; import { validateObjectWithType } from "types/validation"; @@ -7,7 +6,7 @@ import { enforceValidatedDatasetViewConfiguration } from "types/schemas/dataset_ import DATASET from "test/fixtures/dataset_server_object"; const datasetViewConfigurationType = "types::DatasetViewConfiguration"; -const CORRECT_DATASET_CONFIGURATION = { +const CORRECT_DATASET_CONFIGURATION: Record = { fourBit: false, interpolation: true, renderMissingDataBlack: true, diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 091d9a9e8d3..97f4e5e40bf 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -594,10 +594,10 @@ export const combinedReducer = reduceReducers( UiReducer, ConnectomeReducer, OrganizationReducer, -); +) as Reducer; const store = createStore( - enableBatching(combinedReducer), + enableBatching(combinedReducer as any), defaultState, applyMiddleware(actionLoggerMiddleware, overwriteActionMiddleware, sagaMiddleware as Middleware), ); From c118dd8758e0b1f963de0a754ced8ec69e80a5d8 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 11 Jun 2025 10:55:18 +0200 Subject: [PATCH 34/92] refactor to fix cyclic deps --- .../reducers/skeletontracing_reducer.spec.ts | 3 +- .../viewmodes/arbitrary_controller.tsx | 2 +- .../controller/viewmodes/plane_controller.tsx | 2 +- .../javascripts/viewer/geometries/skeleton.ts | 1 - .../model/actions/skeletontracing_actions.tsx | 86 +----------------- .../skeletontracing_actions_with_effects.tsx | 90 +++++++++++++++++++ .../model/reducers/skeletontracing_reducer.ts | 2 +- .../update_action_application/skeleton.ts | 12 +-- .../update_action_application/volume.ts | 1 - .../javascripts/viewer/view/context_menu.tsx | 2 +- .../trees_tab/skeleton_tab_view.tsx | 2 +- 11 files changed, 107 insertions(+), 96 deletions(-) create mode 100644 frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx diff --git a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts index c8920a5444a..2356c8b0ea1 100644 --- a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts @@ -22,6 +22,7 @@ import { type Tree, MutableTreeMap, } from "viewer/model/types/tree_types"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; const initialState: WebknossosState = update(defaultState, { annotation: { @@ -170,7 +171,7 @@ describe("SkeletonTracing", () => { }); it("should delete the tree if 'delete node as user' is initiated for an empty tree", () => { - const { createTreeAction, deleteNodeAsUserAction } = SkeletonTracingActions; + const { createTreeAction } = SkeletonTracingActions; const newState = applyActions(initialStateWithActiveTreeId2, [ createTreeAction(), (currentState: WebknossosState) => deleteNodeAsUserAction(currentState), diff --git a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx index c712664de8d..c2f6483d9e6 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx @@ -35,7 +35,6 @@ import { createBranchPointAction, createNodeAction, createTreeAction, - deleteNodeAsUserAction, requestDeleteBranchPointAction, setActiveNodeAction, toggleAllTreesAction, @@ -47,6 +46,7 @@ import Store from "viewer/store"; import ArbitraryView from "viewer/view/arbitrary_view"; import { downloadScreenshot } from "viewer/view/rendering_utils"; import { SkeletonToolController } from "../combinations/tool_controls"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; const arbitraryViewportId = "inputcatcher_arbitraryViewport"; type Props = { diff --git a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx index 2fb72a5b7fb..17c3c964ab7 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx @@ -44,7 +44,6 @@ import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; import { createBranchPointAction, createTreeAction, - deleteNodeAsUserAction, requestDeleteBranchPointAction, toggleAllTreesAction, toggleInactiveTreesAction, @@ -70,6 +69,7 @@ import { showToastWarningForLargestSegmentIdMissing } from "viewer/view/largest_ import PlaneView from "viewer/view/plane_view"; import { downloadScreenshot } from "viewer/view/rendering_utils"; import { highlightAndSetCursorOnHoveredBoundingBox } from "../combinations/bounding_box_handlers"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; function ensureNonConflictingHandlers( skeletonControls: Record, diff --git a/frontend/javascripts/viewer/geometries/skeleton.ts b/frontend/javascripts/viewer/geometries/skeleton.ts index 9dfada759a4..eb76513426a 100644 --- a/frontend/javascripts/viewer/geometries/skeleton.ts +++ b/frontend/javascripts/viewer/geometries/skeleton.ts @@ -347,7 +347,6 @@ class Skeleton { ); for (const update of diff) { - console.log("Skeleton.ts: Applying", update.name); switch (update.name) { case "createNode": { const { treeId, id: nodeId } = update.value; diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index 6dafa324d2a..e9b2f4efc37 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -1,24 +1,14 @@ -import { Modal } from "antd"; -import renderIndependently from "libs/render_independently"; -import messages from "messages"; import type { Key } from "react"; import { batchActions } from "redux-batched-actions"; import type { MetadataEntryProto, ServerSkeletonTracing } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; import type { TreeType, Vector3 } from "viewer/constants"; -import { - enforceSkeletonTracing, - getTree, - getTreeAndNode, -} from "viewer/model/accessors/skeletontracing_accessor"; import { type AddNewUserBoundingBox, AllUserBoundingBoxActions, } from "viewer/model/actions/annotation_actions"; import type { MutableTreeMap, Tree, TreeGroup } from "viewer/model/types/tree_types"; -import type { SkeletonTracing, WebknossosState } from "viewer/store"; -import Store from "viewer/store"; -import RemoveTreeModal from "viewer/view/remove_tree_modal"; +import type { SkeletonTracing } from "viewer/store"; import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; export type InitializeSkeletonTracingAction = ReturnType; @@ -44,7 +34,7 @@ type RequestDeleteBranchPointAction = ReturnType; type SetEdgeVisibilityAction = ReturnType; type AddTreesAndGroupsAction = ReturnType; -type DeleteTreeAction = ReturnType; +export type DeleteTreeAction = ReturnType; type DeleteTreesAction = ReturnType; type ResetSkeletonTracingAction = ReturnType; type SetActiveTreeAction = ReturnType; @@ -73,7 +63,7 @@ type ApplySkeletonUpdateActionsFromServerAction = ReturnType< typeof applySkeletonUpdateActionsFromServerAction >; export type LoadAgglomerateSkeletonAction = ReturnType; -type NoAction = ReturnType; +export type NoAction = ReturnType; export type BatchableUpdateTreeAction = | SetTreeGroupAction @@ -185,7 +175,7 @@ export const SkeletonTracingSaveRelevantActions = [ "APPLY_UPDATE_ACTIONS_FROM_SERVER", ]; -const noAction = () => +export const noAction = () => ({ type: "NONE", }) as const; @@ -574,74 +564,6 @@ export const setMergerModeEnabledAction = (active: boolean) => active, }) as const; -// The following actions have the prefix "AsUser" which means that they -// offer some additional logic which is sensible from a user-centered point of view. -// For example, the deleteNodeAsUserAction also initiates the deletion of a tree, -// when the current tree is empty. -export const deleteNodeAsUserAction = ( - state: WebknossosState, - nodeId?: number, - treeId?: number, -): DeleteNodeAction | NoAction | DeleteTreeAction => { - const skeletonTracing = enforceSkeletonTracing(state.annotation); - const treeAndNode = getTreeAndNode(skeletonTracing, nodeId, treeId); - - if (!treeAndNode) { - const tree = getTree(skeletonTracing, treeId); - if (!tree) return noAction(); - - // If the tree is empty, it will be deleted - return tree.nodes.size() === 0 ? deleteTreeAction(tree.treeId) : noAction(); - } - - const [tree, node] = treeAndNode; - - if (state.task != null && node.id === 1) { - // Let the user confirm the deletion of the initial node (node with id 1) of a task - Modal.confirm({ - title: messages["tracing.delete_initial_node"], - onOk: () => { - Store.dispatch(deleteNodeAction(node.id, tree.treeId)); - }, - }); - // As Modal.confirm is async, return noAction() and the modal will dispatch the real action - // if the user confirms - return noAction(); - } - - return deleteNodeAction(node.id, tree.treeId); -}; - -// Let the user confirm the deletion of the initial node (node with id 1) of a task -function confirmDeletingInitialNode(treeId: number) { - Modal.confirm({ - title: messages["tracing.delete_tree_with_initial_node"], - onOk: () => { - Store.dispatch(deleteTreeAction(treeId)); - }, - }); -} - -export const handleDeleteTreeByUser = (treeId?: number) => { - const state = Store.getState(); - const skeletonTracing = enforceSkeletonTracing(state.annotation); - const tree = getTree(skeletonTracing, treeId); - if (!tree) return; - - if (state.task != null && tree.nodes.has(1)) { - confirmDeletingInitialNode(tree.treeId); - } else if (state.userConfiguration.hideTreeRemovalWarning) { - Store.dispatch(deleteTreeAction(tree.treeId)); - } else { - renderIndependently((destroy) => ( - Store.dispatch(deleteTreeAction(tree.treeId))} - destroy={destroy} - /> - )); - } -}; - export const updateNavigationListAction = (list: Array, activeIndex: number) => ({ type: "UPDATE_NAVIGATION_LIST", diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx new file mode 100644 index 00000000000..78e63afba8a --- /dev/null +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx @@ -0,0 +1,90 @@ +import { Modal } from "antd"; +import renderIndependently from "libs/render_independently"; +import messages from "messages"; +import { + enforceSkeletonTracing, + getTree, + getTreeAndNode, +} from "viewer/model/accessors/skeletontracing_accessor"; +import type { WebknossosState } from "viewer/store"; +import Store from "viewer/store"; +import RemoveTreeModal from "viewer/view/remove_tree_modal"; +import { + deleteNodeAction, + type DeleteNodeAction, + deleteTreeAction, + type DeleteTreeAction, + noAction, + type NoAction, +} from "./skeletontracing_actions"; + +// The following functions are used as a direct response to a user action. +// The functions may interact with the Store which is why they are in a separate file +// (this avoids cyclic dependencies). +// The functions offer some additional logic which is sensible from a user-centered point of view. +// For example, the deleteNodeAsUserAction also initiates the deletion of a tree, +// when the current tree is empty. +// Ideally, this module should be refactored away (instead the logic should live in sagas). +export const deleteNodeAsUserAction = ( + state: WebknossosState, + nodeId?: number, + treeId?: number, +): DeleteNodeAction | NoAction | DeleteTreeAction => { + const skeletonTracing = enforceSkeletonTracing(state.annotation); + const treeAndNode = getTreeAndNode(skeletonTracing, nodeId, treeId); + + if (!treeAndNode) { + const tree = getTree(skeletonTracing, treeId); + if (!tree) return noAction(); + + // If the tree is empty, it will be deleted + return tree.nodes.size() === 0 ? deleteTreeAction(tree.treeId) : noAction(); + } + + const [tree, node] = treeAndNode; + + if (state.task != null && node.id === 1) { + // Let the user confirm the deletion of the initial node (node with id 1) of a task + Modal.confirm({ + title: messages["tracing.delete_initial_node"], + onOk: () => { + Store.dispatch(deleteNodeAction(node.id, tree.treeId)); + }, + }); + // As Modal.confirm is async, return noAction() and the modal will dispatch the real action + // if the user confirms + return noAction(); + } + + return deleteNodeAction(node.id, tree.treeId); +}; + +// Let the user confirm the deletion of the initial node (node with id 1) of a task +function confirmDeletingInitialNode(treeId: number) { + Modal.confirm({ + title: messages["tracing.delete_tree_with_initial_node"], + onOk: () => { + Store.dispatch(deleteTreeAction(treeId)); + }, + }); +} + +export const handleDeleteTreeByUser = (treeId?: number) => { + const state = Store.getState(); + const skeletonTracing = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeletonTracing, treeId); + if (!tree) return; + + if (state.task != null && tree.nodes.has(1)) { + confirmDeletingInitialNode(tree.treeId); + } else if (state.userConfiguration.hideTreeRemovalWarning) { + Store.dispatch(deleteTreeAction(tree.treeId)); + } else { + renderIndependently((destroy) => ( + Store.dispatch(deleteTreeAction(tree.treeId))} + destroy={destroy} + /> + )); + } +}; diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 0bd5a8f3696..377a655d2e5 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -653,7 +653,7 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos case "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - return applySkeletonUpdateActionsFromServer(actions, state).value; + return applySkeletonUpdateActionsFromServer(SkeletonTracingReducer, actions, state).value; } default: // pass diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 0496649e3d0..5a2d18be7ef 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -1,23 +1,23 @@ import update from "immutability-helper"; import DiffableMap from "libs/diffable_map"; import { enforceSkeletonTracing, getTree } from "viewer/model/accessors/skeletontracing_accessor"; +import { + setTreeEdgeVisibilityAction, + setTreeGroupsAction, +} from "viewer/model/actions/skeletontracing_actions"; import EdgeCollection from "viewer/model/edge_collection"; import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; import type { Tree } from "viewer/model/types/tree_types"; -import type { WebknossosState } from "viewer/store"; +import type { Reducer, WebknossosState } from "viewer/store"; import { getMaximumNodeId } from "../skeletontracing_reducer_helpers"; import { applyAddUserBoundingBox, applyDeleteUserBoundingBox, applyUpdateUserBoundingBox, } from "./bounding_box"; -import SkeletonTracingReducer from "../skeletontracing_reducer"; -import { - setTreeEdgeVisibilityAction, - setTreeGroupsAction, -} from "viewer/model/actions/skeletontracing_actions"; export function applySkeletonUpdateActionsFromServer( + SkeletonTracingReducer: Reducer, actions: ApplicableSkeletonUpdateAction[], newState: WebknossosState, ): { value: WebknossosState } { diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index eb451e17cf4..89a1d8ae9d3 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -13,7 +13,6 @@ import { applyDeleteUserBoundingBox, applyUpdateUserBoundingBox, } from "./bounding_box"; -import type { TreeGroup } from "viewer/model/types/tree_types"; export function applyVolumeUpdateActionsFromServer( actions: ApplicableVolumeUpdateAction[], diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index 8647780c348..ff607d5e624 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -108,7 +108,6 @@ import { createTreeAction, deleteBranchpointByIdAction, deleteEdgeAction, - deleteNodeAsUserAction, expandParentGroupsOfTreeAction, mergeTreesAction, setActiveNodeAction, @@ -145,6 +144,7 @@ import { withMappingActivationConfirmation, } from "viewer/view/right-border-tabs/segments_tab/segments_view_helper"; import { LoadMeshMenuItemLabel } from "./right-border-tabs/segments_tab/load_mesh_menu_item_label"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; type ContextMenuContextValue = React.MutableRefObject | null; export const ContextMenuContext = createContext(null); diff --git a/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx b/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx index d13cd59ed92..9732e30d21c 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx @@ -43,7 +43,6 @@ import { deleteTreesAction, deselectActiveTreeAction, deselectActiveTreeGroupAction, - handleDeleteTreeByUser, selectNextTreeAction, setActiveTreeAction, setActiveTreeGroupAction, @@ -87,6 +86,7 @@ import { } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import AdvancedSearchPopover from "../advanced_search_popover"; import DeleteGroupModalView from "../delete_group_modal_view"; +import { handleDeleteTreeByUser } from "viewer/model/actions/skeletontracing_actions_with_effects"; const { confirm } = Modal; const treeTabId = "tree-list"; From 49a3b8be91c71dfd3b07099aa7445c02f2f924b4 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 11 Jun 2025 11:21:40 +0200 Subject: [PATCH 35/92] refactor fixtures --- .../test/fixtures/dataset_server_object.ts | 100 ++++++++++-------- .../test/fixtures/volumetracing_object.ts | 36 +++++-- frontend/javascripts/viewer/api/wk_dev.ts | 2 +- .../viewmodes/arbitrary_controller.tsx | 2 +- .../controller/viewmodes/plane_controller.tsx | 2 +- .../skeletontracing_actions_with_effects.tsx | 6 +- .../javascripts/viewer/view/context_menu.tsx | 2 +- .../trees_tab/skeleton_tab_view.tsx | 2 +- 8 files changed, 88 insertions(+), 64 deletions(-) diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 1ede0441f5c..0672a391290 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -59,54 +59,60 @@ export const sampleTracingLayer: APISegmentationLayer = { tracingId: "tracingId", }; -const apiDataset: APIDataset = { - id: "66f3c82966010034942e9740", - name: "ROI2017_wkw", - dataSource: { - id: { - name: "ROI2017_wkw", - team: "Connectomics department", +function createDataset(dataLayers: Array): APIDataset { + return { + id: "66f3c82966010034942e9740", + name: "ROI2017_wkw", + dataSource: { + id: { + name: "ROI2017_wkw", + team: "Connectomics department", + }, + dataLayers, + scale: { factor: [11.239999771118164, 11.239999771118164, 28], unit: UnitLong.nm }, }, - dataLayers: [sampleColorLayer, sampleSegmentationLayer], - scale: { factor: [11.239999771118164, 11.239999771118164, 28], unit: UnitLong.nm }, - }, - dataStore: { - name: "localhost", - url: "http://localhost:9000", - allowsUpload: true, - jobsSupportedByAvailableWorkers: [], - jobsEnabled: false, - }, - owningOrganization: "Connectomics department", - allowedTeams: [ - { - id: "5b1e45f9a00000a000abc2c3", - name: "Connectomics department", - organization: "Connectomics department", - }, - ], - allowedTeamsCumulative: [ - { - id: "5b1e45f9a00000a000abc2c3", - name: "Connectomics department", - organization: "Connectomics department", + dataStore: { + name: "localhost", + url: "http://localhost:9000", + allowsUpload: true, + jobsSupportedByAvailableWorkers: [], + jobsEnabled: false, }, - ], - isActive: true, - isPublic: false, - description: null, - created: 1502288550432, - isEditable: true, - directoryName: "ROI2017_wkw", - isUnreported: false, - tags: [], - folderId: "66f3c82466010002752e972c", - metadata: [], - logoUrl: "/assets/images/logo.svg", - lastUsedByUser: 1727268949322, - sortingKey: 1727252521746, - publication: null, - usedStorageBytes: 0, -}; + owningOrganization: "Connectomics department", + allowedTeams: [ + { + id: "5b1e45f9a00000a000abc2c3", + name: "Connectomics department", + organization: "Connectomics department", + }, + ], + allowedTeamsCumulative: [ + { + id: "5b1e45f9a00000a000abc2c3", + name: "Connectomics department", + organization: "Connectomics department", + }, + ], + isActive: true, + isPublic: false, + description: null, + created: 1502288550432, + isEditable: true, + directoryName: "ROI2017_wkw", + isUnreported: false, + tags: [], + folderId: "66f3c82466010002752e972c", + metadata: [], + logoUrl: "/assets/images/logo.svg", + lastUsedByUser: 1727268949322, + sortingKey: 1727252521746, + publication: null, + usedStorageBytes: 0, + }; +} + +const apiDataset = createDataset([sampleColorLayer, sampleSegmentationLayer]); + +export const apiDatasetForVolumeTracing = createDataset([sampleTracingLayer]); export default apiDataset; diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index afe50f81c15..a516fa25658 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -1,26 +1,40 @@ import update from "immutability-helper"; -import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import Constants from "viewer/constants"; import defaultState from "viewer/default_state"; +import { combinedReducer, type VolumeTracing } from "viewer/store"; +import DiffableMap from "libs/diffable_map"; +import { setDatasetAction } from "viewer/model/actions/dataset_actions"; +import { convertFrontendBoundingBoxToServer } from "viewer/model/reducers/reducer_helpers"; +import { apiDatasetForVolumeTracing } from "./dataset_server_object"; export const VOLUME_TRACING_ID = "volumeTracingId"; -const volumeTracing = { +const volumeTracing: VolumeTracing = { type: "volume", + segments: new DiffableMap(), + segmentGroups: [], + hasSegmentIndex: true, + contourTracingMode: "DRAW", + hideUnregisteredSegments: false, activeCellId: 0, - activeTool: AnnotationTool.MOVE, largestSegmentId: 0, contourList: [], lastLabelActions: [], tracingId: VOLUME_TRACING_ID, -}; + createdTimestamp: 1234, + boundingBox: { min: [0, 1, 2], max: [10, 11, 12] }, + userBoundingBoxes: [], + additionalAxes: [], +} as const; + const notEmptyViewportRect = { top: 0, left: 0, width: Constants.VIEWPORT_WIDTH, height: Constants.VIEWPORT_WIDTH, }; -export const initialState = update(defaultState, { + +const stateWithoutDatasetInitialization = update(defaultState, { annotation: { annotationType: { $set: "Explorational", @@ -47,7 +61,6 @@ export const initialState = update(defaultState, { }, }, volumes: { - // @ts-expect-error ts-migrate(2322) FIXME: Type '{ type: string; activeCellId: number; active... Remove this comment to see the full error message $set: [volumeTracing], }, }, @@ -64,12 +77,12 @@ export const initialState = update(defaultState, { [4, 4, 4], ], category: "segmentation", + largestSegmentId: volumeTracing.largestSegmentId ?? 0, elementClass: "uint32", name: volumeTracing.tracingId, tracingId: volumeTracing.tracingId, - // @ts-expect-error ts-migrate(2322) FIXME: Type '{ resolutions: [number, number, number][]; c... Remove this comment to see the full error message - isDisabled: false, - alpha: 100, + additionalAxes: [], + boundingBox: convertFrontendBoundingBoxToServer(volumeTracing.boundingBox!), }, ], }, @@ -111,3 +124,8 @@ export const initialState = update(defaultState, { }, }, }); + +export const initialState = combinedReducer( + stateWithoutDatasetInitialization, + setDatasetAction(apiDatasetForVolumeTracing), +); diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index 30de8fa0ab5..9a28e7f959b 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -11,7 +11,7 @@ import type ApiLoader from "./api_loader"; // Can be accessed via window.webknossos.DEV.flags. Only use this // for debugging or one off scripts. export const WkDevFlags = { - logActions: true, + logActions: false, sam: { useLocalMask: true, }, diff --git a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx index c2f6483d9e6..f138a25d71f 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx @@ -40,13 +40,13 @@ import { toggleAllTreesAction, toggleInactiveTreesAction, } from "viewer/model/actions/skeletontracing_actions"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; import { api } from "viewer/singletons"; import Store from "viewer/store"; import ArbitraryView from "viewer/view/arbitrary_view"; import { downloadScreenshot } from "viewer/view/rendering_utils"; import { SkeletonToolController } from "../combinations/tool_controls"; -import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; const arbitraryViewportId = "inputcatcher_arbitraryViewport"; type Props = { diff --git a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx index 17c3c964ab7..0ae334327b7 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx @@ -48,6 +48,7 @@ import { toggleAllTreesAction, toggleInactiveTreesAction, } from "viewer/model/actions/skeletontracing_actions"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; import { cycleToolAction, enterAction, @@ -69,7 +70,6 @@ import { showToastWarningForLargestSegmentIdMissing } from "viewer/view/largest_ import PlaneView from "viewer/view/plane_view"; import { downloadScreenshot } from "viewer/view/rendering_utils"; import { highlightAndSetCursorOnHoveredBoundingBox } from "../combinations/bounding_box_handlers"; -import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; function ensureNonConflictingHandlers( skeletonControls: Record, diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx index 78e63afba8a..d6e66ea6e0c 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions_with_effects.tsx @@ -10,12 +10,12 @@ import type { WebknossosState } from "viewer/store"; import Store from "viewer/store"; import RemoveTreeModal from "viewer/view/remove_tree_modal"; import { - deleteNodeAction, type DeleteNodeAction, - deleteTreeAction, type DeleteTreeAction, - noAction, type NoAction, + deleteNodeAction, + deleteTreeAction, + noAction, } from "./skeletontracing_actions"; // The following functions are used as a direct response to a user action. diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index ff607d5e624..b00ee06db95 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -137,6 +137,7 @@ import type { VolumeTracing, } from "viewer/store"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; import { type MutableNode, type Tree, TreeMap } from "viewer/model/types/tree_types"; import Store from "viewer/store"; import { @@ -144,7 +145,6 @@ import { withMappingActivationConfirmation, } from "viewer/view/right-border-tabs/segments_tab/segments_view_helper"; import { LoadMeshMenuItemLabel } from "./right-border-tabs/segments_tab/load_mesh_menu_item_label"; -import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; type ContextMenuContextValue = React.MutableRefObject | null; export const ContextMenuContext = createContext(null); diff --git a/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx b/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx index 9732e30d21c..4d55b1b2515 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/trees_tab/skeleton_tab_view.tsx @@ -54,6 +54,7 @@ import { toggleAllTreesAction, toggleInactiveTreesAction, } from "viewer/model/actions/skeletontracing_actions"; +import { handleDeleteTreeByUser } from "viewer/model/actions/skeletontracing_actions_with_effects"; import { setDropzoneModalVisibilityAction } from "viewer/model/actions/ui_actions"; import { importVolumeTracingAction, @@ -86,7 +87,6 @@ import { } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import AdvancedSearchPopover from "../advanced_search_popover"; import DeleteGroupModalView from "../delete_group_modal_view"; -import { handleDeleteTreeByUser } from "viewer/model/actions/skeletontracing_actions_with_effects"; const { confirm } = Modal; const treeTabId = "tree-list"; From 13ec558f35efad828bf1a729924d32ad6c7d31a6 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 11 Jun 2025 13:41:00 +0200 Subject: [PATCH 36/92] write and fix volume specs for UA application --- .../test/fixtures/volumetracing_object.ts | 1 + .../update_action_application/volume.spec.ts | 201 ++++++++++++++++++ .../update_action_application/bounding_box.ts | 2 +- .../update_action_application/volume.ts | 17 +- .../viewer/model/sagas/update_actions.ts | 4 +- 5 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 frontend/javascripts/test/reducers/update_action_application/volume.spec.ts diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index a516fa25658..2d634bf380c 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -63,6 +63,7 @@ const stateWithoutDatasetInitialization = update(defaultState, { volumes: { $set: [volumeTracing], }, + readOnly: { $set: null }, }, dataset: { dataSource: { diff --git a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts new file mode 100644 index 00000000000..ea345d3ed6b --- /dev/null +++ b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts @@ -0,0 +1,201 @@ +import update from "immutability-helper"; +import _ from "lodash"; +import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; +import { initialState as defaultVolumeState } from "test/fixtures/volumetracing_object"; +import { chainReduce } from "test/helpers/chainReducer"; +import type { Action } from "viewer/model/actions/actions"; +import { + addUserBoundingBoxAction, + changeUserBoundingBoxAction, + deleteUserBoundingBoxAction, +} from "viewer/model/actions/annotation_actions"; +import * as VolumeTracingActions from "viewer/model/actions/volumetracing_actions"; +import { setActiveUserBoundingBoxId } from "viewer/model/actions/ui_actions"; +import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; +import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; +import type { + ApplicableVolumeUpdateAction, + UpdateActionWithoutIsolationRequirement, +} from "viewer/model/sagas/update_actions"; +import { combinedReducer, type WebknossosState } from "viewer/store"; +import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; +import { afterAll, describe, expect, test } from "vitest"; + +const enforceVolumeTracing = (state: WebknossosState) => { + const tracing = state.annotation.volumes[0]; + if (tracing == null || state.annotation.volumes.length !== 1) { + throw new Error("No volume tracing found"); + } + return tracing; +}; + +const initialState: WebknossosState = update(defaultVolumeState, { + annotation: { + restrictions: { + allowUpdate: { + $set: true, + }, + branchPointsAllowed: { + $set: true, + }, + }, + annotationType: { $set: "Explorational" }, + }, + dataset: { + dataSource: { + dataLayers: { + $set: [sampleTracingLayer], + }, + }, + }, +}); + +const { tracingId } = initialState.annotation.volumes[0]; + +const applyActions = chainReduce(combinedReducer); + +// This helper dict exists so that we can ensure via typescript that +// the list contains all members of ApplicableVolumeUpdateAction. As soon as +// ApplicableVolumeUpdateAction is extended with another action, TS will complain +// if the following dictionary doesn't contain that action. +const actionNamesHelper: Record = { + updateLargestSegmentId: true, + updateSegment: true, + createSegment: true, + deleteSegment: true, + updateSegmentGroups: true, + addUserBoundingBoxInVolumeTracing: true, + updateUserBoundingBoxInVolumeTracing: true, + deleteUserBoundingBoxInVolumeTracing: true, + updateSegmentGroupsExpandedState: true, + updateUserBoundingBoxVisibilityInVolumeTracing: true, +}; +const actionNamesList = Object.keys(actionNamesHelper); + +describe("Update Action Application for VolumeTracing", () => { + const seenActionTypes = new Set(); + + /* + * Hardcode these values if you want to focus on a specific test. + */ + const compactionModes = [true, false]; + const hardcodedBeforeVersionIndex: number | null = null; + const hardcodedAfterVersionIndex: number | null = null; + + const userActions: Action[] = [ + VolumeTracingActions.updateSegmentAction(2, { somePosition: [1, 2, 3] }, tracingId), + VolumeTracingActions.updateSegmentAction(3, { somePosition: [3, 4, 5] }, tracingId), + VolumeTracingActions.updateSegmentAction( + 3, + { + name: "name", + groupId: 3, + metadata: [ + { + key: "someKey", + stringValue: "some string value", + }, + ], + }, + tracingId, + ), + addUserBoundingBoxAction({ + boundingBox: { min: [0, 0, 0], max: [10, 10, 10] }, + name: "UserBBox", + color: [1, 2, 3], + isVisible: true, + }), + changeUserBoundingBoxAction(1, { name: "Updated Name" }), + deleteUserBoundingBoxAction(1), + VolumeTracingActions.setSegmentGroupsAction( + [makeBasicGroupObject(3, "group 3"), makeBasicGroupObject(7, "group 7")], + tracingId, + ), + VolumeTracingActions.removeSegmentAction(3, tracingId), + VolumeTracingActions.setLargestSegmentIdAction(10000), + ]; + + test("User actions for test should not contain no-ops", () => { + let state = initialState; + for (const action of userActions) { + const newState = combinedReducer(state, action); + expect(newState !== state).toBeTruthy(); + + state = newState; + } + }); + + const beforeVersionIndices = + hardcodedBeforeVersionIndex != null + ? [hardcodedBeforeVersionIndex] + : _.range(0, userActions.length); + + describe.each(compactionModes)( + "[Compaction=%s]: should re-apply update actions from complex diff and get same state", + (withCompaction) => { + describe.each(beforeVersionIndices)("From v=%i", (beforeVersionIndex: number) => { + const afterVersionIndices = + hardcodedAfterVersionIndex != null + ? [hardcodedAfterVersionIndex] + : _.range(beforeVersionIndex, userActions.length + 1); + + test.each(afterVersionIndices)("To v=%i", (afterVersionIndex: number) => { + const state2WithActiveTree = applyActions( + initialState, + userActions.slice(0, beforeVersionIndex), + ); + + const state2WithoutActiveState = applyActions(state2WithActiveTree, [ + VolumeTracingActions.setActiveCellAction(0), + setActiveUserBoundingBoxId(null), + ]); + + const actionsToApply = userActions.slice(beforeVersionIndex, afterVersionIndex + 1); + const state3 = applyActions( + state2WithActiveTree, + actionsToApply.concat([ + VolumeTracingActions.setActiveCellAction(0), + setActiveUserBoundingBoxId(null), + ]), + ); + expect(state2WithoutActiveState !== state3).toBeTruthy(); + + const volumeTracing2 = enforceVolumeTracing(state2WithoutActiveState); + const volumeTracing3 = enforceVolumeTracing(state3); + + const updateActionsBeforeCompaction = Array.from( + diffVolumeTracing(volumeTracing2, volumeTracing3), + ); + console.log( + "updateActionsBeforeCompaction", + updateActionsBeforeCompaction.map((el) => el.name), + ); + const maybeCompact = withCompaction + ? compactUpdateActions + : (updateActions: UpdateActionWithoutIsolationRequirement[]) => updateActions; + const updateActions = maybeCompact( + updateActionsBeforeCompaction, + volumeTracing2, + volumeTracing3, + ) as ApplicableVolumeUpdateAction[]; + + for (const action of updateActions) { + seenActionTypes.add(action.name); + } + + const reappliedNewState = applyActions(state2WithoutActiveState, [ + VolumeTracingActions.applyVolumeUpdateActionsFromServerAction(updateActions), + VolumeTracingActions.setActiveCellAction(0), + setActiveUserBoundingBoxId(null), + ]); + + expect(reappliedNewState).toEqual(state3); + }); + }); + }, + ); + + afterAll(() => { + expect(seenActionTypes).toEqual(new Set(actionNamesList)); + }); +}); diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts index 1454cb75c45..a4b8288d9f3 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -84,7 +84,7 @@ function handleUserBoundingBoxUpdateInTracing( tracing.tracingId === volumeTracing.tracingId ? { ...volumeTracing, - updatedUserBoundingBoxes, + userBoundingBoxes: updatedUserBoundingBoxes, } : volumeTracing, ); diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 89a1d8ae9d3..58b50121405 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -5,7 +5,7 @@ import { updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; -import type { WebknossosState } from "viewer/store"; +import type { Segment, WebknossosState } from "viewer/store"; import type { VolumeTracingReducerAction } from "../volumetracing_reducer"; import { setLargestSegmentIdReducer } from "../volumetracing_reducer_helpers"; import { @@ -36,10 +36,15 @@ export function applyVolumeUpdateActionsFromServer( } case "createSegment": case "updateSegment": { - const { actionTracingId, ...segment } = ua.value; + const { actionTracingId, ...originalSegment } = ua.value; + const { anchorPosition, ...segmentWithoutAnchor } = originalSegment; + const segment: Partial = { + somePosition: anchorPosition ?? undefined, + ...segmentWithoutAnchor, + }; newState = VolumeTracingReducer( newState, - updateSegmentAction(segment.id, segment, actionTracingId), + updateSegmentAction(originalSegment.id, segment, actionTracingId), ); break; } @@ -81,6 +86,12 @@ export function applyVolumeUpdateActionsFromServer( ); break; } + case "updateSegmentGroupsExpandedState": + case "updateUserBoundingBoxVisibilityInVolumeTracing": { + // These update actions are user specific and don't need to be incorporated here + // because they are from another user. + break; + } default: { ua satisfies never; } diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/update_actions.ts index 087784cc22a..5a300ed51c0 100644 --- a/frontend/javascripts/viewer/model/sagas/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/update_actions.ts @@ -131,7 +131,9 @@ export type ApplicableVolumeUpdateAction = | UpdateSegmentGroupsUpdateAction | AddUserBoundingBoxInVolumeTracingAction | UpdateUserBoundingBoxInVolumeTracingAction - | DeleteUserBoundingBoxInVolumeTracingAction; + | DeleteUserBoundingBoxInVolumeTracingAction + | UpdateSegmentGroupsExpandedStateUpdateAction + | UpdateUserBoundingBoxVisibilityInVolumeTracingAction; export type UpdateActionWithIsolationRequirement = | RevertToVersionUpdateAction From 71cab4591f76b6e9b3b040098381fb4da2326e04 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 11 Jun 2025 15:33:54 +0200 Subject: [PATCH 37/92] misc --- .../javascripts/test/helpers/saveHelpers.ts | 2 +- .../update_action_application/skeleton.spec.ts | 8 ++++---- .../test/sagas/compact_toggle_actions.spec.ts | 6 +++--- .../test/sagas/skeletontracing_saga.spec.ts | 13 +++++-------- .../volumetracing/volumetracing_saga.spec.ts | 7 ------- frontend/javascripts/types/bounding_box.ts | 17 +++++++---------- .../model/reducers/skeletontracing_reducer.ts | 1 - .../javascripts/viewer/model/sagas/save_saga.ts | 4 +--- .../viewer/model/sagas/update_actions.ts | 10 +++++++--- .../javascripts/viewer/view/version_entry.tsx | 4 ++-- 10 files changed, 30 insertions(+), 42 deletions(-) diff --git a/frontend/javascripts/test/helpers/saveHelpers.ts b/frontend/javascripts/test/helpers/saveHelpers.ts index 15aae1669b6..19cd0bc70a4 100644 --- a/frontend/javascripts/test/helpers/saveHelpers.ts +++ b/frontend/javascripts/test/helpers/saveHelpers.ts @@ -21,7 +21,7 @@ export function createSaveQueueFromUpdateActions( })); } -export function withoutUpdateTracing( +export function withoutUpdateActiveItemTracing( items: UpdateActionWithoutIsolationRequirement[], ): UpdateActionWithoutIsolationRequirement[] { return items.filter( diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 4ef9f07a7be..aed3767613d 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -222,8 +222,8 @@ describe("Update Action Application for SkeletonTracing", () => { mag, ); const newState = applyActions(initialState, [ - createNode, // 1 - createNode, // 2 + createNode, // nodeId=1 + createNode, // nodeId=2 SkeletonTracingActions.setActiveNodeAction(2), ]); expect(getActiveNode(enforceSkeletonTracing(newState.annotation))?.id).toBe(2); @@ -250,8 +250,8 @@ describe("Update Action Application for SkeletonTracing", () => { mag, ); const newState = applyActions(initialState, [ - createNode, // 1 - createNode, // 2 + createNode, // nodeId=1 + createNode, // nodeId=2 SkeletonTracingActions.setActiveTreeAction(2), ]); expect(getActiveTree(enforceSkeletonTracing(newState.annotation))?.treeId).toBe(2); diff --git a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts index 514fe6f6c6b..f7dbb5f3eeb 100644 --- a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts +++ b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts @@ -10,7 +10,7 @@ import { } from "viewer/model/sagas/update_actions"; import { withoutUpdateSegment, - withoutUpdateTracing, + withoutUpdateActiveItemTracing, withoutUpdateTree, } from "test/helpers/saveHelpers"; import DiffableMap from "libs/diffable_map"; @@ -162,7 +162,7 @@ function testSkeletonDiffing(prevState: WebknossosState, nextState: WebknossosSt // are creating completely new trees, so that we don't have to go through the // action->reducer pipeline) return withoutUpdateTree( - withoutUpdateTracing( + withoutUpdateActiveItemTracing( Array.from( diffSkeletonTracing( enforceSkeletonTracing(prevState.annotation), @@ -179,7 +179,7 @@ function testVolumeDiffing(prevState: WebknossosState, nextState: WebknossosStat // are creating completely new trees, so that we don't have to go through the // action->reducer pipeline) return withoutUpdateSegment( - withoutUpdateTracing( + withoutUpdateActiveItemTracing( Array.from( diffVolumeTracing(prevState.annotation.volumes[0], nextState.annotation.volumes[0]), ), diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index eb04829d88c..7f267ea954d 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -2,7 +2,6 @@ import { setupWebknossosForTesting, type WebknossosTestContext } from "test/help import type { SkeletonTracing, StoreAnnotation } from "viewer/store"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import Store from "viewer/store"; -import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; import { chainReduce } from "test/helpers/chainReducer"; import DiffableMap from "libs/diffable_map"; @@ -11,7 +10,10 @@ import compactSaveQueue from "viewer/model/helpers/compaction/compact_save_queue import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; import defaultState from "viewer/default_state"; import update from "immutability-helper"; -import { createSaveQueueFromUpdateActions, withoutUpdateTracing } from "../helpers/saveHelpers"; +import { + createSaveQueueFromUpdateActions, + withoutUpdateActiveItemTracing, +} from "../helpers/saveHelpers"; import { MISSING_GROUP_ID } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { TreeTypeEnum } from "viewer/constants"; import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; @@ -27,7 +29,7 @@ import { Model } from "viewer/singletons"; const actionTracingId = "tracingId"; function testDiffing(prevAnnotation: StoreAnnotation, nextAnnotation: StoreAnnotation) { - return withoutUpdateTracing( + return withoutUpdateActiveItemTracing( Array.from( diffSkeletonTracing( enforceSkeletonTracing(prevAnnotation), @@ -135,11 +137,6 @@ describe("SkeletonTracingSaga", () => { context.tearDownPullQueues(); // Saving after each test and checking that the root saga didn't crash, // ensures that each test is cleanly exited. Without it weird output can - // occur (e.g., a promise gets resolved which interferes with the next test). - expect(hasRootSagaCrashed()).toBe(false); - }); - - it("shouldn't do anything if unchanged (saga test)", async (context: WebknossosTestContext) => { await Model.ensureSavedState(); expect(context.receivedDataPerSaveRequest.length).toBe(0); }); diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index 199eba82ad3..42f853906af 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -21,7 +21,6 @@ import { import VolumeLayer from "viewer/model/volumetracing/volumelayer"; import { serverVolumeToClientVolumeTracing } from "viewer/model/reducers/volumetracing_reducer"; import { Model, Store } from "viewer/singletons"; -import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; const serverVolumeTracing: ServerVolumeTracing = { typ: "Volume", @@ -87,12 +86,6 @@ describe("VolumeTracingSaga", () => { context.tearDownPullQueues(); // Saving after each test and checking that the root saga didn't crash, - // ensures that each test is cleanly exited. Without it weird output can - // occur (e.g., a promise gets resolved which interferes with the next test). - expect(hasRootSagaCrashed()).toBe(false); - }); - - it("shouldn't do anything if unchanged (saga test)", async (context: WebknossosTestContext) => { await Model.ensureSavedState(); expect(context.receivedDataPerSaveRequest.length).toBe(0); }); diff --git a/frontend/javascripts/types/bounding_box.ts b/frontend/javascripts/types/bounding_box.ts index 1ba8de36cd3..e608a69642a 100644 --- a/frontend/javascripts/types/bounding_box.ts +++ b/frontend/javascripts/types/bounding_box.ts @@ -1,23 +1,20 @@ import type { Point3, Vector3 } from "viewer/constants"; -// 51 matches export type BoundingBoxMinMaxType = { min: Vector3; max: Vector3; }; -// 10 matches -export type BoundingBoxProto = { - topLeft: Point3; - width: number; - height: number; - depth: number; -}; - -// 39 matches export type BoundingBoxObject = { readonly topLeft: Vector3; readonly width: number; readonly height: number; readonly depth: number; }; + +export type BoundingBoxProto = { + topLeft: Point3; + width: number; + height: number; + depth: number; +}; diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 377a655d2e5..43c6e0d273c 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -673,7 +673,6 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos return state; } - // use this code as template const { position, rotation, viewport, mag, treeId, timestamp, additionalCoordinates } = action; const tree = getOrCreateTree(state, skeletonTracing, treeId, timestamp, TreeTypeEnum.DEFAULT); diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index fe0a6824d86..9bc83f6a6d4 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -847,11 +847,9 @@ function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga | ReturnType; export type DeleteTreeUpdateAction = ReturnType; export type MoveTreeComponentUpdateAction = ReturnType; -export type MergeTreeUpdateAction = ReturnType; +export type LEGACY_MergeTreeUpdateAction = ReturnType; export type CreateNodeUpdateAction = ReturnType; export type UpdateNodeUpdateAction = ReturnType; export type UpdateTreeVisibilityUpdateAction = ReturnType; @@ -141,7 +141,7 @@ export type UpdateActionWithIsolationRequirement = export type UpdateActionWithoutIsolationRequirement = | UpdateTreeUpdateAction | DeleteTreeUpdateAction - | MergeTreeUpdateAction + | LEGACY_MergeTreeUpdateAction | MoveTreeComponentUpdateAction | CreateNodeUpdateAction | UpdateNodeUpdateAction @@ -313,7 +313,11 @@ export function updateTreeGroupVisibility( }, } as const; } -export function mergeTree(sourceTreeId: number, targetTreeId: number, actionTracingId: string) { +export function LEGACY_mergeTree( + sourceTreeId: number, + targetTreeId: number, + actionTracingId: string, +) { return { name: "mergeTree", value: { diff --git a/frontend/javascripts/viewer/view/version_entry.tsx b/frontend/javascripts/viewer/view/version_entry.tsx index 231f110abf6..f995fe01cc3 100644 --- a/frontend/javascripts/viewer/view/version_entry.tsx +++ b/frontend/javascripts/viewer/view/version_entry.tsx @@ -43,7 +43,7 @@ import type { LEGACY_UpdateUserBoundingBoxesInSkeletonTracingUpdateAction, LEGACY_UpdateUserBoundingBoxesInVolumeTracingUpdateAction, MergeAgglomerateUpdateAction, - MergeTreeUpdateAction, + LEGACY_MergeTreeUpdateAction, MoveTreeComponentUpdateAction, RevertToVersionUpdateAction, ServerUpdateAction, @@ -387,7 +387,7 @@ const descriptionFns: Record< icon: , }), // This should never be shown since currently this update action is never dispatched. - mergeTree: (action: AsServerAction): Description => ({ + mergeTree: (action: AsServerAction): Description => ({ description: `Merged the trees with id ${action.value.sourceId} and ${action.value.targetId}.`, icon: , }), From a7ced9fdbc3824deafcff492b0ae35169dfb138d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Jun 2025 15:32:58 +0200 Subject: [PATCH 38/92] fix merge-related problems in specs --- frontend/javascripts/test/fixtures/dataset_server_object.ts | 4 ++-- .../javascripts/test/sagas/skeletontracing_saga.spec.ts | 5 +++++ .../test/sagas/volumetracing/volumetracing_saga.spec.ts | 6 +++++- frontend/javascripts/viewer/model/sagas/save_saga.ts | 2 +- frontend/javascripts/viewer/view/version_entry.tsx | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 0672a391290..08834ff1d06 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -55,8 +55,8 @@ const sampleSegmentationLayer: APISegmentationLayer = { export const sampleTracingLayer: APISegmentationLayer = { ...sampleSegmentationLayer, - name: "tracingId", - tracingId: "tracingId", + name: "volumeTracingId", + tracingId: "volumeTracingId", }; function createDataset(dataLayers: Array): APIDataset { diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 7f267ea954d..354ebbc225e 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -25,6 +25,7 @@ import SkeletonTracingReducer from "viewer/model/reducers/skeletontracing_reduce import { TIMESTAMP } from "test/global_mocks"; import { type Tree, TreeMap } from "viewer/model/types/tree_types"; import { Model } from "viewer/singletons"; +import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; const actionTracingId = "tracingId"; @@ -137,6 +138,10 @@ describe("SkeletonTracingSaga", () => { context.tearDownPullQueues(); // Saving after each test and checking that the root saga didn't crash, // ensures that each test is cleanly exited. Without it weird output can + expect(hasRootSagaCrashed()).toBe(false); + }); + + it("shouldn't do anything if unchanged (saga test)", async (context: WebknossosTestContext) => { await Model.ensureSavedState(); expect(context.receivedDataPerSaveRequest.length).toBe(0); }); diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index 42f853906af..60814e0ddf4 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -21,6 +21,7 @@ import { import VolumeLayer from "viewer/model/volumetracing/volumelayer"; import { serverVolumeToClientVolumeTracing } from "viewer/model/reducers/volumetracing_reducer"; import { Model, Store } from "viewer/singletons"; +import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; const serverVolumeTracing: ServerVolumeTracing = { typ: "Volume", @@ -84,8 +85,11 @@ describe("VolumeTracingSaga", () => { afterEach(async (context) => { context.tearDownPullQueues(); - // Saving after each test and checking that the root saga didn't crash, + expect(hasRootSagaCrashed()).toBe(false); + }); + + it("shouldn't do anything if unchanged (saga test)", async (context: WebknossosTestContext) => { await Model.ensureSavedState(); expect(context.receivedDataPerSaveRequest.length).toBe(0); }); diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 9bc83f6a6d4..55fb950c27d 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -1,7 +1,7 @@ import { getNewestVersionForAnnotation, - sendSaveRequestWithToken, getUpdateActionLog, + sendSaveRequestWithToken, } from "admin/rest_api"; import Date from "libs/date"; import ErrorHandling from "libs/error_handling"; diff --git a/frontend/javascripts/viewer/view/version_entry.tsx b/frontend/javascripts/viewer/view/version_entry.tsx index f995fe01cc3..5bc6b580acc 100644 --- a/frontend/javascripts/viewer/view/version_entry.tsx +++ b/frontend/javascripts/viewer/view/version_entry.tsx @@ -40,10 +40,10 @@ import type { DeleteTreeUpdateAction, DeleteUserBoundingBoxInSkeletonTracingAction, DeleteUserBoundingBoxInVolumeTracingAction, + LEGACY_MergeTreeUpdateAction, LEGACY_UpdateUserBoundingBoxesInSkeletonTracingUpdateAction, LEGACY_UpdateUserBoundingBoxesInVolumeTracingUpdateAction, MergeAgglomerateUpdateAction, - LEGACY_MergeTreeUpdateAction, MoveTreeComponentUpdateAction, RevertToVersionUpdateAction, ServerUpdateAction, From eb0b54c51b4e5bbee0234748f717a6a6d9580ef5 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Jun 2025 14:26:19 +0200 Subject: [PATCH 39/92] prepare hybrid fixtures for proofreading tests and clean up --- .../test/fixtures/dataset_server_object.ts | 3 + .../test/fixtures/hybridtracing_object.ts | 2 +- .../fixtures/hybridtracing_server_objects.ts | 88 +++++++++++++++++++ .../fixtures/volumetracing_server_objects.ts | 46 +++------- frontend/javascripts/test/global_mocks.ts | 1 + .../javascripts/test/helpers/apiHelpers.ts | 32 +++++-- frontend/javascripts/test/libs/nml.spec.ts | 2 +- .../test/sagas/proofreading.spec.ts | 47 ++++++++++ .../test/sagas/skeletontracing_saga.spec.ts | 4 +- .../volumetracing/volumetracing_saga.spec.ts | 4 +- .../model/accessors/volumetracing_accessor.ts | 4 +- frontend/javascripts/viewer/store.ts | 2 +- 12 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts create mode 100644 frontend/javascripts/test/sagas/proofreading.spec.ts diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 08834ff1d06..f04c407cec9 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -22,6 +22,8 @@ const sampleColorLayer: APIColorLayer = { additionalAxes: [], }; +export const sampleHdf5AgglomerateName = "sampleHdf5Mapping"; +// this is a uint32 segmentation layer const sampleSegmentationLayer: APISegmentationLayer = { name: "segmentation", category: "segmentation", @@ -49,6 +51,7 @@ const sampleSegmentationLayer: APISegmentationLayer = { "mitochondria", "astrocyte-full", ], + agglomerates: [sampleHdf5AgglomerateName], tracingId: undefined, additionalAxes: [], }; diff --git a/frontend/javascripts/test/fixtures/hybridtracing_object.ts b/frontend/javascripts/test/fixtures/hybridtracing_object.ts index 2cb10001e5d..df00a6dfcb8 100644 --- a/frontend/javascripts/test/fixtures/hybridtracing_object.ts +++ b/frontend/javascripts/test/fixtures/hybridtracing_object.ts @@ -65,7 +65,7 @@ const initialTreeTwo: Tree = { export const initialSkeletonTracing: SkeletonTracing = { type: "skeleton", createdTimestamp: 0, - tracingId: "tracingId", + tracingId: "skeletonTracingId", trees: new TreeMap([ [1, initialTreeOne], [2, initialTreeTwo], diff --git a/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts b/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts new file mode 100644 index 00000000000..c373d5e4e1f --- /dev/null +++ b/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts @@ -0,0 +1,88 @@ +import { + type APIAnnotation, + AnnotationLayerEnum, + type APITracingStoreAnnotation, +} from "types/api_types"; +import { tracing as skeletonTracing } from "./skeletontracing_server_objects"; +import { tracing as volumeTracing } from "./volumetracing_server_objects"; + +export const tracings = [skeletonTracing, volumeTracing]; + +export const annotation: APIAnnotation = { + description: "", + datasetId: "66f3c82966010034942e9740", + state: "Active", + id: "598b52293c00009906f043e7", + visibility: "Internal", + modified: 1529066010230, + name: "", + teams: [], + typ: "Explorational", + task: null, + restrictions: { + allowAccess: true, + allowUpdate: true, + allowFinish: true, + allowDownload: true, + allowSave: true, + }, + annotationLayers: [ + { + name: AnnotationLayerEnum.Skeleton, + tracingId: skeletonTracing.id, + typ: AnnotationLayerEnum.Skeleton, + stats: {}, + }, + { + name: AnnotationLayerEnum.Volume, + tracingId: volumeTracing.id, + typ: AnnotationLayerEnum.Volume, + stats: {}, + }, + ], + dataSetName: "ROI2017_wkw", + organization: "Connectomics Department", + dataStore: { + name: "localhost", + url: "http://localhost:9000", + allowsUpload: true, + jobsEnabled: false, + jobsSupportedByAvailableWorkers: [], + }, + tracingStore: { + name: "localhost", + url: "http://localhost:9000", + }, + settings: { + allowedModes: ["orthogonal", "oblique", "flight"], + branchPointsAllowed: true, + somaClickingAllowed: true, + volumeInterpolationAllowed: false, + mergerMode: false, + magRestrictions: {}, + }, + tags: ["ROI2017_wkw", "skeleton"], + tracingTime: 0, + contributors: [], + othersMayEdit: false, + isLockedByOwner: false, +}; + +export const annotationProto: APITracingStoreAnnotation = { + description: "hybrid-annotation-description", + version: 1, + earliestAccessibleVersion: 0, + annotationLayers: [ + { + tracingId: skeletonTracing.id, + name: "skeleton layer name", + typ: AnnotationLayerEnum.Skeleton, + }, + { + tracingId: volumeTracing.id, + name: "volume layer name", + typ: AnnotationLayerEnum.Volume, + }, + ], + userStates: [], +}; diff --git a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts index dd38749c75e..254b7c25372 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts @@ -5,7 +5,9 @@ import { type APITracingStoreAnnotation, } from "types/api_types"; -const TRACING_ID = "volumeTracingId-1234"; +const TRACING_ID = "volumeTracingId"; + +// this is a uint16 segmentation layer export const tracing: ServerVolumeTracing = { typ: "Volume", activeSegmentId: 10000, @@ -40,39 +42,16 @@ export const tracing: ServerVolumeTracing = { largestSegmentId: 21890, zoomLevel: 0, mags: [ - { - x: 1, - y: 1, - z: 1, - }, - { - x: 2, - y: 2, - z: 2, - }, - { - x: 4, - y: 4, - z: 4, - }, - { - x: 8, - y: 8, - z: 8, - }, - { - x: 16, - y: 16, - z: 16, - }, - { - x: 32, - y: 32, - z: 32, - }, + { x: 1, y: 1, z: 1 }, + { x: 2, y: 2, z: 2 }, + { x: 4, y: 4, z: 4 }, + { x: 8, y: 8, z: 8 }, + { x: 16, y: 16, z: 16 }, + { x: 32, y: 32, z: 32 }, ], userStates: [], }; + export const annotation: APIAnnotation = { datasetId: "66f3c82966010034942e9740", description: "", @@ -92,7 +71,7 @@ export const annotation: APIAnnotation = { }, annotationLayers: [ { - name: "volume", + name: "some volume name", tracingId: TRACING_ID, typ: AnnotationLayerEnum.Volume, stats: {}, @@ -125,6 +104,7 @@ export const annotation: APIAnnotation = { othersMayEdit: false, isLockedByOwner: false, }; + export const annotationProto: APITracingStoreAnnotation = { description: "volume-annotation-description", version: 1, @@ -132,7 +112,7 @@ export const annotationProto: APITracingStoreAnnotation = { annotationLayers: [ { tracingId: TRACING_ID, - name: "volume", + name: "some volume name", typ: AnnotationLayerEnum.Volume, }, ], diff --git a/frontend/javascripts/test/global_mocks.ts b/frontend/javascripts/test/global_mocks.ts index 46e400848e5..298e94aeeeb 100644 --- a/frontend/javascripts/test/global_mocks.ts +++ b/frontend/javascripts/test/global_mocks.ts @@ -123,6 +123,7 @@ vi.mock("antd", () => { Dropdown: {}, message: { hide: vi.fn(), + destroy: vi.fn(), // These return a "hide function" show: vi.fn(() => () => {}), loading: vi.fn(() => () => {}), diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index e6545b3bd99..c155ef67ce5 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -20,7 +20,7 @@ import { annotation as VOLUME_ANNOTATION, annotationProto as VOLUME_ANNOTATION_PROTO, } from "../fixtures/volumetracing_server_objects"; -import DATASET from "../fixtures/dataset_server_object"; +import DATASET, { apiDatasetForVolumeTracing } from "../fixtures/dataset_server_object"; import type { ApiInterface } from "viewer/api/api_latest"; import type { ModelType } from "viewer/model"; @@ -40,6 +40,12 @@ import app from "app"; import { sendSaveRequestWithToken } from "admin/rest_api"; import { resetStoreAction, restartSagaAction, wkReadyAction } from "viewer/model/actions/actions"; import { setActiveUserAction } from "viewer/model/actions/user_actions"; +import { + tracings as HYBRID_TRACINGS, + annotation as HYBRID_ANNOTATION, + annotationProto as HYBRID_ANNOTATION_PROTO, +} from "test/fixtures/hybridtracing_server_objects"; +import { ServerTracing } from "types/api_types"; const TOKEN = "secure-token"; const ANNOTATION_TYPE = "annotationTypeValue"; @@ -117,6 +123,8 @@ function receiveJSONMockImplementation( } if (url === `/api/datasets/${annotationFixture.datasetId}`) { + // todop + // return Promise.resolve(_.cloneDeep(apiDatasetForVolumeTracing)); return Promise.resolve(_.cloneDeep(DATASET)); } @@ -171,17 +179,22 @@ vi.mock("libs/keyboard", () => ({ const modelData = { skeleton: { - tracing: SKELETON_TRACING, + tracings: [SKELETON_TRACING], annotation: SKELETON_ANNOTATION, annotationProto: SKELETON_ANNOTATION_PROTO, }, volume: { - tracing: VOLUME_TRACING, + tracings: [VOLUME_TRACING], annotation: VOLUME_ANNOTATION, annotationProto: VOLUME_ANNOTATION_PROTO, }, + hybrid: { + tracings: HYBRID_TRACINGS, + annotation: HYBRID_ANNOTATION, + annotationProto: HYBRID_ANNOTATION_PROTO, + }, task: { - tracing: TASK_TRACING, + tracings: [TASK_TRACING], annotation: TASK_ANNOTATION, annotationProto: TASK_ANNOTATION_PROTO, }, @@ -229,7 +242,16 @@ export async function setupWebknossosForTesting( receiveJSONMockImplementation(url, options, annotationFixture), ); - vi.mocked(parseProtoTracing).mockReturnValue(_.cloneDeep(modelData[mode].tracing)); + vi.mocked(parseProtoTracing).mockImplementation( + (_buffer: ArrayBuffer, annotationType: "skeleton" | "volume"): ServerTracing => { + const { tracings } = modelData[mode]; + const tracing = tracings.find((tracing) => tracing.typ.toLowerCase() === annotationType); + if (tracing == null) { + throw new Error(`Could not find tracing for ${annotationType}.`); + } + return tracing; + }, + ); vi.mocked(parseProtoAnnotation).mockReturnValue(_.cloneDeep(modelData[mode].annotationProto)); setSceneController({ diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index 58e31cc7695..391cb4cbaef 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -31,7 +31,7 @@ const createDummyNode = (id: number): Node => ({ const initialSkeletonTracing: SkeletonTracing = { type: "skeleton", createdTimestamp: 0, - tracingId: "tracingId", + tracingId: "skeletonTracingId", cachedMaxNodeId: 7, trees: new DiffableMap([ [ diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts new file mode 100644 index 00000000000..5dc6effcda7 --- /dev/null +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -0,0 +1,47 @@ +import { put, take } from "redux-saga/effects"; +import { sampleHdf5AgglomerateName } from "test/fixtures/dataset_server_object"; +import { setupWebknossosForTesting, type WebknossosTestContext } from "test/helpers/apiHelpers"; +import { proofreadMerge } from "viewer/model/actions/proofread_actions"; +import { setMappingAction } from "viewer/model/actions/settings_actions"; +import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; +import { Store } from "viewer/singletons"; +import { startSaga } from "viewer/store"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe.skip("Proofreading", () => { + beforeEach(async (context) => { + await setupWebknossosForTesting(context, "hybrid"); + }); + + afterEach(async (context) => { + context.tearDownPullQueues(); + // Saving after each test and checking that the root saga didn't crash, + expect(hasRootSagaCrashed()).toBe(false); + }); + + it("shouldn't do anything if unchanged (saga test)", async (_context: WebknossosTestContext) => { + const { annotation } = Store.getState(); + // console.log("annotation.skeleton", annotation.skeleton); + // console.log("annotation.volumes", annotation.volumes); + const { tracingId } = annotation.volumes[0]; + + /* + SET_MAPPING + FINISH_MAPPING_INITIALIZATION + dispatched by + Mappings.updateMappingTextures + MappingSaga.reloadData + triggered by handleSetHdf5Mapping and when the bucket retrieval source changed between REQUESTED-WITH-MAPPING <> REQUESTED-WITHOUT-MAPPING + */ + + const task = startSaga(function* () { + yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield take("FINISH_MAPPING_INITIALIZATION"); + yield put(proofreadMerge([0, 0, 0], 1, 2)); + }); + + await task.toPromise(); + }, 4000); +}); diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 354ebbc225e..0af65e9eba2 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -27,7 +27,7 @@ import { type Tree, TreeMap } from "viewer/model/types/tree_types"; import { Model } from "viewer/singletons"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; -const actionTracingId = "tracingId"; +const actionTracingId = "skeletonTracingId"; function testDiffing(prevAnnotation: StoreAnnotation, nextAnnotation: StoreAnnotation) { return withoutUpdateActiveItemTracing( @@ -75,7 +75,7 @@ const skeletonTreeOne: Tree = { const skeletonTracing: SkeletonTracing = { type: "skeleton", createdTimestamp: 0, - tracingId: "tracingId", + tracingId: "skeletonTracingId", trees: new TreeMap([[1, skeletonTreeOne]]), treeGroups: [], activeGroupId: null, diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index 60814e0ddf4..ff12397368f 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -25,7 +25,7 @@ import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; const serverVolumeTracing: ServerVolumeTracing = { typ: "Volume", - id: "tracingId", + id: "volumeTracingId", elementClass: "uint32", createdTimestamp: 0, boundingBox: { @@ -107,7 +107,7 @@ describe("VolumeTracingSaga", () => { expect(action).toMatchObject({ name: "updateActiveSegmentId", value: { - actionTracingId: "volumeTracingId-1234", + actionTracingId: volumeTracing.tracingId, activeSegmentId: 5, }, }); diff --git a/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts b/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts index d52dbf73443..c43f33dab69 100644 --- a/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts @@ -73,7 +73,9 @@ export function getVolumeTracingById( const volumeTracing = annotation.volumes.find((t) => t.tracingId === tracingId); if (volumeTracing == null) { - throw new Error(`Could not find volume tracing with id ${tracingId}`); + throw new Error( + `Could not find volume tracing with id ${tracingId}. Only found: ${annotation.volumes.map((t) => t.tracingId)}`, + ); } return volumeTracing; diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 97f4e5e40bf..d6a0fe727ea 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -603,7 +603,7 @@ const store = createStore( ); export function startSaga(saga: Saga) { - sagaMiddleware.run(saga); + return sagaMiddleware.run(saga); } export type StoreType = typeof store; From 23835bdbe5016f02ad8fc8b8abeedf85232d1bb9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Jun 2025 14:41:22 +0200 Subject: [PATCH 40/92] replace volumeTracing in spec with existing fixture and fix wrong id --- .../fixtures/volumetracing_server_objects.ts | 2 +- .../volumetracing/volumetracing_saga.spec.ts | 37 +------------------ 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts index 254b7c25372..8cdaf480939 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts @@ -38,7 +38,7 @@ export const tracing: ServerVolumeTracing = { }, additionalAxes: [], elementClass: "uint16", - id: "segmentation", + id: TRACING_ID, largestSegmentId: 21890, zoomLevel: 0, mags: [ diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index ff12397368f..a85a713940e 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -1,7 +1,6 @@ import { it, expect, describe, beforeEach, afterEach } from "vitest"; import { setupWebknossosForTesting, type WebknossosTestContext } from "test/helpers/apiHelpers"; import { take, put, call } from "redux-saga/effects"; -import type { ServerVolumeTracing } from "types/api_types"; import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { OrthoViews, @@ -22,41 +21,7 @@ import VolumeLayer from "viewer/model/volumetracing/volumelayer"; import { serverVolumeToClientVolumeTracing } from "viewer/model/reducers/volumetracing_reducer"; import { Model, Store } from "viewer/singletons"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; - -const serverVolumeTracing: ServerVolumeTracing = { - typ: "Volume", - id: "volumeTracingId", - elementClass: "uint32", - createdTimestamp: 0, - boundingBox: { - topLeft: { - x: 0, - y: 0, - z: 0, - }, - width: 10, - height: 10, - depth: 10, - }, - segments: [], - segmentGroups: [], - additionalAxes: [], - userBoundingBoxes: [], - largestSegmentId: 0, - userStates: [], - zoomLevel: 0, - editPosition: { - x: 0, - y: 0, - z: 0, - }, - editPositionAdditionalCoordinates: null, - editRotation: { - x: 0, - y: 0, - z: 0, - }, -}; +import { tracing as serverVolumeTracing } from "test/fixtures/volumetracing_server_objects"; const volumeTracing = serverVolumeToClientVolumeTracing(serverVolumeTracing, null, null); From 7972857c5193fd3e206a71a0abe5dc7447f3e705 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Jun 2025 14:51:01 +0200 Subject: [PATCH 41/92] DRY more fixtures --- .../test/fixtures/volumetracing_object.ts | 23 ++++--------------- .../reducers/volumetracing_reducer.spec.ts | 9 ++++++-- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index 2d634bf380c..4dccfb1d038 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -1,31 +1,16 @@ import update from "immutability-helper"; import Constants from "viewer/constants"; import defaultState from "viewer/default_state"; -import { combinedReducer, type VolumeTracing } from "viewer/store"; -import DiffableMap from "libs/diffable_map"; +import { combinedReducer } from "viewer/store"; import { setDatasetAction } from "viewer/model/actions/dataset_actions"; import { convertFrontendBoundingBoxToServer } from "viewer/model/reducers/reducer_helpers"; import { apiDatasetForVolumeTracing } from "./dataset_server_object"; +import { tracing } from "./volumetracing_server_objects"; +import { serverVolumeToClientVolumeTracing } from "viewer/model/reducers/volumetracing_reducer"; export const VOLUME_TRACING_ID = "volumeTracingId"; -const volumeTracing: VolumeTracing = { - type: "volume", - segments: new DiffableMap(), - segmentGroups: [], - hasSegmentIndex: true, - contourTracingMode: "DRAW", - hideUnregisteredSegments: false, - activeCellId: 0, - largestSegmentId: 0, - contourList: [], - lastLabelActions: [], - tracingId: VOLUME_TRACING_ID, - createdTimestamp: 1234, - boundingBox: { min: [0, 1, 2], max: [10, 11, 12] }, - userBoundingBoxes: [], - additionalAxes: [], -} as const; +const volumeTracing = serverVolumeToClientVolumeTracing(tracing, null, null); const notEmptyViewportRect = { top: 0, diff --git a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts index b400d47bffb..a2f0386b86c 100644 --- a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts @@ -19,6 +19,8 @@ export function getFirstVolumeTracingOrFail(annotation: StoreAnnotation): Volume throw new Error("Annotation is not of type volume!"); } +const INITIAL_LARGEST_SEGMENT_ID = initialState.annotation.volumes[0].largestSegmentId ?? 0; + describe("VolumeTracing", () => { it("should set a new active cell", () => { const createCellAction = VolumeTracingActions.createCellAction(1000, 1000); @@ -66,7 +68,7 @@ describe("VolumeTracing", () => { // Create cell const newState = VolumeTracingReducer(initialState, createCellAction); const tracing = getFirstVolumeTracingOrFail(newState.annotation); - expect(tracing.activeCellId).toBe(1); + expect(tracing.activeCellId).toBe(INITIAL_LARGEST_SEGMENT_ID + 1); }); it("should create a non-existing cell id and not update the largestSegmentId", () => { @@ -79,7 +81,7 @@ describe("VolumeTracing", () => { const newState = VolumeTracingReducer(initialState, createCellAction); const tracing = getFirstVolumeTracingOrFail(newState.annotation); - expect(tracing.largestSegmentId).toBe(0); + expect(tracing.largestSegmentId).toBe(INITIAL_LARGEST_SEGMENT_ID); }); it("should create an existing cell and not update the largestSegmentId", () => { @@ -121,6 +123,9 @@ describe("VolumeTracing", () => { largestSegmentId: { $set: LARGEST_SEGMENT_ID, }, + activeCellId: { + $set: LARGEST_SEGMENT_ID, + }, }, }, }, From 2eac7acd531ef5e4efa3c344ef14e64723e278b2 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Jun 2025 16:12:50 +0200 Subject: [PATCH 42/92] refactor preprocessing of dataset in model initialization --- .../test/fixtures/dataset_server_object.ts | 6 ++- .../test/fixtures/volumetracing_object.ts | 7 ++-- .../fixtures/volumetracing_server_objects.ts | 1 + .../javascripts/test/helpers/apiHelpers.ts | 34 ++++++++++------- .../viewer/model_initialization.ts | 38 ++++++++++--------- 5 files changed, 51 insertions(+), 35 deletions(-) diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index f04c407cec9..936747e1a12 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -56,6 +56,10 @@ const sampleSegmentationLayer: APISegmentationLayer = { additionalAxes: [], }; +// This is a segmentation layer object that could be directly +// inserted into the store. Do not use this object if it's intended +// to go through the normal model initialization because it does not +// have a fallback property. export const sampleTracingLayer: APISegmentationLayer = { ...sampleSegmentationLayer, name: "volumeTracingId", @@ -116,6 +120,6 @@ function createDataset(dataLayers: Array): const apiDataset = createDataset([sampleColorLayer, sampleSegmentationLayer]); -export const apiDatasetForVolumeTracing = createDataset([sampleTracingLayer]); +export const apiDatasetForVolumeTracing = createDataset([sampleSegmentationLayer]); export default apiDataset; diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index 4dccfb1d038..1709c6432e0 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -5,12 +5,13 @@ import { combinedReducer } from "viewer/store"; import { setDatasetAction } from "viewer/model/actions/dataset_actions"; import { convertFrontendBoundingBoxToServer } from "viewer/model/reducers/reducer_helpers"; import { apiDatasetForVolumeTracing } from "./dataset_server_object"; -import { tracing } from "./volumetracing_server_objects"; +import { tracing as serverVolumeTracing } from "./volumetracing_server_objects"; import { serverVolumeToClientVolumeTracing } from "viewer/model/reducers/volumetracing_reducer"; +import { preprocessDataset } from "viewer/model_initialization"; export const VOLUME_TRACING_ID = "volumeTracingId"; -const volumeTracing = serverVolumeToClientVolumeTracing(tracing, null, null); +const volumeTracing = serverVolumeToClientVolumeTracing(serverVolumeTracing, null, null); const notEmptyViewportRect = { top: 0, @@ -113,5 +114,5 @@ const stateWithoutDatasetInitialization = update(defaultState, { export const initialState = combinedReducer( stateWithoutDatasetInitialization, - setDatasetAction(apiDatasetForVolumeTracing), + setDatasetAction(preprocessDataset(apiDatasetForVolumeTracing, [serverVolumeTracing])), ); diff --git a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts index 8cdaf480939..e8b22c733b0 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_server_objects.ts @@ -50,6 +50,7 @@ export const tracing: ServerVolumeTracing = { { x: 32, y: 32, z: 32 }, ], userStates: [], + fallbackLayer: "segmentation", }; export const annotation: APIAnnotation = { diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index c155ef67ce5..369616a88d0 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -20,7 +20,7 @@ import { annotation as VOLUME_ANNOTATION, annotationProto as VOLUME_ANNOTATION_PROTO, } from "../fixtures/volumetracing_server_objects"; -import DATASET, { apiDatasetForVolumeTracing } from "../fixtures/dataset_server_object"; +import DATASET from "../fixtures/dataset_server_object"; import type { ApiInterface } from "viewer/api/api_latest"; import type { ModelType } from "viewer/model"; @@ -37,7 +37,7 @@ import { setActiveOrganizationAction } from "viewer/model/actions/organization_a import Request, { type RequestOptions } from "libs/request"; import { parseProtoAnnotation, parseProtoTracing } from "viewer/model/helpers/proto_helpers"; import app from "app"; -import { sendSaveRequestWithToken } from "admin/rest_api"; +import { getDataset, sendSaveRequestWithToken } from "admin/rest_api"; import { resetStoreAction, restartSagaAction, wkReadyAction } from "viewer/model/actions/actions"; import { setActiveUserAction } from "viewer/model/actions/user_actions"; import { @@ -45,7 +45,7 @@ import { annotation as HYBRID_ANNOTATION, annotationProto as HYBRID_ANNOTATION_PROTO, } from "test/fixtures/hybridtracing_server_objects"; -import { ServerTracing } from "types/api_types"; +import type { ServerTracing } from "types/api_types"; const TOKEN = "secure-token"; const ANNOTATION_TYPE = "annotationTypeValue"; @@ -88,6 +88,7 @@ vi.mock("admin/rest_api.ts", async () => { (mockedSendRequestWithToken as any).receivedDataPerSaveRequest = receivedDataPerSaveRequest; return { ...actual, + getDataset: vi.fn(), sendSaveRequestWithToken: mockedSendRequestWithToken, }; }); @@ -122,12 +123,6 @@ function receiveJSONMockImplementation( return Promise.resolve({}); } - if (url === `/api/datasets/${annotationFixture.datasetId}`) { - // todop - // return Promise.resolve(_.cloneDeep(apiDatasetForVolumeTracing)); - return Promise.resolve(_.cloneDeep(DATASET)); - } - if (url === "/api/userToken/generate" && options && options.method === "POST") { return Promise.resolve({ token: TOKEN, @@ -179,21 +174,25 @@ vi.mock("libs/keyboard", () => ({ const modelData = { skeleton: { + dataset: DATASET, tracings: [SKELETON_TRACING], annotation: SKELETON_ANNOTATION, annotationProto: SKELETON_ANNOTATION_PROTO, }, volume: { + dataset: DATASET, tracings: [VOLUME_TRACING], annotation: VOLUME_ANNOTATION, annotationProto: VOLUME_ANNOTATION_PROTO, }, hybrid: { + dataset: DATASET, tracings: HYBRID_TRACINGS, annotation: HYBRID_ANNOTATION, annotationProto: HYBRID_ANNOTATION_PROTO, }, task: { + dataset: DATASET, tracings: [TASK_TRACING], annotation: TASK_ANNOTATION, annotationProto: TASK_ANNOTATION_PROTO, @@ -236,15 +235,24 @@ export async function setupWebknossosForTesting( ).receivedDataPerSaveRequest; const webknossos = new WebknossosApi(Model); - const annotationFixture = modelData[mode].annotation; + const { tracings, annotationProto, dataset, annotation } = modelData[mode]; vi.mocked(Request).receiveJSON.mockImplementation((url, options) => - receiveJSONMockImplementation(url, options, annotationFixture), + receiveJSONMockImplementation(url, options, annotation), + ); + + vi.mocked(getDataset).mockImplementation( + async ( + _datasetId: string, + _sharingToken?: string | null | undefined, + _options: RequestOptions = {}, + ) => { + return _.cloneDeep(dataset); + }, ); vi.mocked(parseProtoTracing).mockImplementation( (_buffer: ArrayBuffer, annotationType: "skeleton" | "volume"): ServerTracing => { - const { tracings } = modelData[mode]; const tracing = tracings.find((tracing) => tracing.typ.toLowerCase() === annotationType); if (tracing == null) { throw new Error(`Could not find tracing for ${annotationType}.`); @@ -252,7 +260,7 @@ export async function setupWebknossosForTesting( return tracing; }, ); - vi.mocked(parseProtoAnnotation).mockReturnValue(_.cloneDeep(modelData[mode].annotationProto)); + vi.mocked(parseProtoAnnotation).mockReturnValue(_.cloneDeep(annotationProto)); setSceneController({ name: "This is a dummy scene controller so that getSceneController works in the tests.", diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 66583fa92d5..78f6430cf7e 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -215,7 +215,8 @@ export async function initialize( const serverVolumeTracings = getServerVolumeTracings(serverTracings); const serverVolumeTracingIds = serverVolumeTracings.map((volumeTracing) => volumeTracing.id); - initializeDataset(initialFetch, dataset, serverTracings); + preprocessDataset(dataset, serverTracings); + initializeDataset(initialFetch, dataset); const initialDatasetSettings = await getDatasetViewConfiguration( dataset, serverVolumeTracingIds, @@ -447,11 +448,20 @@ function setInitialTool() { } } -function initializeDataset( - initialFetch: boolean, - dataset: APIDataset, - serverTracings: Array, -): void { +export function preprocessDataset(dataset: APIDataset, serverTracings: Array) { + const mutableDataset = dataset as any as MutableAPIDataset; + const volumeTracings = getServerVolumeTracings(serverTracings); + + if (volumeTracings.length > 0) { + const newDataLayers = getMergedDataLayersFromDatasetAndVolumeTracings(dataset, volumeTracings); + mutableDataset.dataSource.dataLayers = newDataLayers; + validateVolumeLayers(volumeTracings, newDataLayers); + } + + return mutableDataset; +} + +function initializeDataset(initialFetch: boolean, dataset: APIDataset): void { let error; if (!dataset) { @@ -477,21 +487,13 @@ function initializeDataset( datasetName: dataset.name, datasetId: dataset.id, }); - const mutableDataset = dataset as any as MutableAPIDataset; - const volumeTracings = getServerVolumeTracings(serverTracings); - - if (volumeTracings.length > 0) { - const newDataLayers = getMergedDataLayersFromDatasetAndVolumeTracings(dataset, volumeTracings); - mutableDataset.dataSource.dataLayers = newDataLayers; - validateVolumeLayers(volumeTracings, newDataLayers); - } - Store.dispatch(setDatasetAction(mutableDataset as APIDataset)); - initializeAdditionalCoordinates(mutableDataset); + Store.dispatch(setDatasetAction(dataset)); + initializeAdditionalCoordinates(dataset); } -function initializeAdditionalCoordinates(mutableDataset: MutableAPIDataset) { - const unifiedAdditionalCoordinates = getUnifiedAdditionalCoordinates(mutableDataset); +function initializeAdditionalCoordinates(dataset: APIDataset) { + const unifiedAdditionalCoordinates = getUnifiedAdditionalCoordinates(dataset); const initialAdditionalCoordinates = Utils.values(unifiedAdditionalCoordinates).map( ({ name, bounds }) => ({ name, From 035ae5ff5f061446d1bf4fe9096203ad18d13311 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Jun 2025 08:40:46 +0200 Subject: [PATCH 43/92] introduce StoreDataset to add type safety for mandatory preprocessing of ds in model initialization --- .../test/fixtures/dataset_server_object.ts | 4 ++-- .../update_action_application/volume.spec.ts | 4 ---- frontend/javascripts/viewer/default_state.ts | 1 + .../viewer/model/actions/dataset_actions.ts | 3 ++- .../viewer/model_initialization.ts | 22 ++++++++++++------- frontend/javascripts/viewer/store.ts | 11 +++++++++- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/frontend/javascripts/test/fixtures/dataset_server_object.ts b/frontend/javascripts/test/fixtures/dataset_server_object.ts index 936747e1a12..b02b069b1ab 100644 --- a/frontend/javascripts/test/fixtures/dataset_server_object.ts +++ b/frontend/javascripts/test/fixtures/dataset_server_object.ts @@ -13,7 +13,7 @@ const sampleColorLayer: APIColorLayer = { resolutions: [ [1, 1, 1], [2, 2, 2], - [32, 32, 32], + [32, 32, 32], // unsorted on purpose [4, 4, 4], [8, 8, 8], [16, 16, 16], @@ -36,7 +36,7 @@ const sampleSegmentationLayer: APISegmentationLayer = { resolutions: [ [1, 1, 1], [2, 2, 2], - [32, 32, 32], + [32, 32, 32], // unsorted on purpose [4, 4, 4], [8, 8, 8], [16, 16, 16], diff --git a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts index ea345d3ed6b..1e378778002 100644 --- a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts @@ -166,10 +166,6 @@ describe("Update Action Application for VolumeTracing", () => { const updateActionsBeforeCompaction = Array.from( diffVolumeTracing(volumeTracing2, volumeTracing3), ); - console.log( - "updateActionsBeforeCompaction", - updateActionsBeforeCompaction.map((el) => el.name), - ); const maybeCompact = withCompaction ? compactUpdateActions : (updateActions: UpdateActionWithoutIsolationRequirement[]) => updateActions; diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index ba7a5fc5f3d..28886ce4bf8 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -122,6 +122,7 @@ const defaultState: WebknossosState = { }, task: null, dataset: { + areLayersPreprocessed: true, id: "dummy-dataset-id", name: "Loading", folderId: "dummy-folder-id", diff --git a/frontend/javascripts/viewer/model/actions/dataset_actions.ts b/frontend/javascripts/viewer/model/actions/dataset_actions.ts index 7c123afc6f4..084f4ec28d1 100644 --- a/frontend/javascripts/viewer/model/actions/dataset_actions.ts +++ b/frontend/javascripts/viewer/model/actions/dataset_actions.ts @@ -1,4 +1,5 @@ import type { APIDataset, CoordinateTransformation } from "types/api_types"; +import { StoreDataset } from "viewer/store"; type SetDatasetAction = ReturnType; type SetLayerMappingsAction = ReturnType; type SetLayerTransformsAction = ReturnType; @@ -16,7 +17,7 @@ export type DatasetAction = | SetLayerHasSegmentIndexAction | EnsureSegmentIndexIsLoadedAction; -export const setDatasetAction = (dataset: APIDataset) => +export const setDatasetAction = (dataset: StoreDataset) => ({ type: "SET_DATASET", dataset, diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 78f6430cf7e..27d1944c670 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -110,6 +110,7 @@ import DataLayer from "viewer/model/data_layer"; import type { DatasetConfiguration, DatasetLayerConfiguration, + StoreDataset, TraceOrViewCommand, UserConfiguration, } from "viewer/store"; @@ -206,16 +207,16 @@ export async function initialize( datasetId = initialCommandType.datasetId; } - const [dataset, initialUserSettings, serverTracings] = await fetchParallel( + const [apiDataset, initialUserSettings, serverTracings] = await fetchParallel( annotation, datasetId, version, ); - maybeFixDatasetNameInURL(dataset, initialCommandType); + maybeFixDatasetNameInURL(apiDataset, initialCommandType); const serverVolumeTracings = getServerVolumeTracings(serverTracings); const serverVolumeTracingIds = serverVolumeTracings.map((volumeTracing) => volumeTracing.id); - preprocessDataset(dataset, serverTracings); + const dataset = preprocessDataset(apiDataset, serverTracings); initializeDataset(initialFetch, dataset); const initialDatasetSettings = await getDatasetViewConfiguration( dataset, @@ -326,7 +327,7 @@ async function fetchEditableMappings( return Promise.all(promises); } -function validateSpecsForLayers(dataset: APIDataset, requiredBucketCapacity: number): any { +function validateSpecsForLayers(dataset: StoreDataset, requiredBucketCapacity: number): any { const layers = dataset.dataSource.dataLayers; const specs = getSupportedTextureSpecs(); validateMinimumRequirements(specs); @@ -448,7 +449,10 @@ function setInitialTool() { } } -export function preprocessDataset(dataset: APIDataset, serverTracings: Array) { +export function preprocessDataset( + dataset: APIDataset, + serverTracings: Array, +): StoreDataset { const mutableDataset = dataset as any as MutableAPIDataset; const volumeTracings = getServerVolumeTracings(serverTracings); @@ -458,10 +462,12 @@ export function preprocessDataset(dataset: APIDataset, serverTracings: Array Date: Wed, 18 Jun 2025 16:04:43 +0200 Subject: [PATCH 44/92] implement first test for proofreading (merges two agglomerates) --- frontend/javascripts/admin/rest_api.ts | 6 + frontend/javascripts/libs/utils.ts | 30 +++- .../test/fixtures/dummy_organization.ts | 15 ++ frontend/javascripts/test/global_mocks.ts | 5 +- .../javascripts/test/helpers/apiHelpers.ts | 87 +++++++++-- .../test/sagas/proofreading.spec.ts | 136 +++++++++++++++--- .../viewer/model/actions/dataset_actions.ts | 4 +- .../model/bucket_data_handling/bucket.ts | 2 +- .../model/bucket_data_handling/data_cube.ts | 7 + .../viewer/model/reducers/reducer_helpers.ts | 1 + .../viewer/model/sagas/mapping_saga.ts | 16 ++- .../viewer/model/sagas/proofread_saga.ts | 22 ++- 12 files changed, 293 insertions(+), 38 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index b713112cefc..32eeaa15603 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -1957,6 +1957,9 @@ export async function getAgglomeratesForSegmentsFromDatastore, ): Promise { + if (segmentIds.length === 0) { + return new Map(); + } const segmentIdBuffer = serializeProtoListOfLong(segmentIds); const listArrayBuffer: ArrayBuffer = await doWithToken((token) => { const params = new URLSearchParams({ token }); @@ -1987,6 +1990,9 @@ export async function getAgglomeratesForSegmentsFromTracingstore { + if (segmentIds.length === 0) { + return new Map(); + } const params = new URLSearchParams({ annotationId }); if (version != null) { params.set("version", version.toString()); diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 90527b787e1..adec068bdae 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -1,3 +1,4 @@ +import { Chalk } from "chalk"; import dayjs from "dayjs"; import naturalSort from "javascript-natural-sort"; import window, { document, location } from "libs/window"; @@ -1257,7 +1258,11 @@ export function notEmpty(value: TValue | null | undefined): value is TVa export function isNumberMap(x: Map): x is Map { const { value } = x.entries().next(); - return Boolean(value && typeof value[0] === "number"); + if (value === undefined) { + // Let's assume a number map when the map is empty. + return true; + } + return Boolean(typeof value[0] === "number"); } export function isBigInt(x: NumberLike): x is bigint { @@ -1363,3 +1368,26 @@ export function areSetsEqual(setA: Set, setB: Set) { } return true; } + +// ColoredLogger can be used to make certain log outputs easier to find (especially useful +// when automatic logging of redux actions is enabled which makes the overall logging +// very verbose). +const chalk = new Chalk({ level: 3 }); +export const ColoredLogger = { + log: (...args: unknown[]) => { + // Simple wrapper to allow easy switching from colored to non-colored logs + console.log(...args); + }, + logRed: (str: string, ...args: unknown[]) => { + console.log(chalk.bgRed(str), ...args); + }, + logGreen: (str: string, ...args: unknown[]) => { + console.log(chalk.bgGreen(str), ...args); + }, + logYellow: (str: string, ...args: unknown[]) => { + console.log(chalk.bgYellow(str), ...args); + }, + logBlue: (str: string, ...args: unknown[]) => { + console.log(chalk.bgBlue(str), ...args); + }, +}; diff --git a/frontend/javascripts/test/fixtures/dummy_organization.ts b/frontend/javascripts/test/fixtures/dummy_organization.ts index fd71ad4124e..34c84d7b05c 100644 --- a/frontend/javascripts/test/fixtures/dummy_organization.ts +++ b/frontend/javascripts/test/fixtures/dummy_organization.ts @@ -16,4 +16,19 @@ const dummyOrga: APIOrganization = { ownerName: undefined, }; +export const powerOrga: APIOrganization = { + id: "organizationId", + name: "Test Organization", + additionalInformation: "", + pricingPlan: PricingPlanEnum.Power, + enableAutoVerify: true, + newUserMailingList: "", + paidUntil: 0, + includedUsers: 1000, + includedStorageBytes: 10000, + usedStorageBytes: 0, + ownerName: undefined, + creditBalance: undefined, +}; + export default dummyOrga; diff --git a/frontend/javascripts/test/global_mocks.ts b/frontend/javascripts/test/global_mocks.ts index 298e94aeeeb..e41c47d4630 100644 --- a/frontend/javascripts/test/global_mocks.ts +++ b/frontend/javascripts/test/global_mocks.ts @@ -16,8 +16,8 @@ vi.mock("libs/keyboard", () => ({ vi.mock("libs/toast", () => ({ default: { - error: vi.fn(), - warning: vi.fn(), + error: vi.fn((msg) => console.error(msg)), + warning: vi.fn((msg) => console.warn(msg)), close: vi.fn(), success: vi.fn(), info: vi.fn(), @@ -128,6 +128,7 @@ vi.mock("antd", () => { show: vi.fn(() => () => {}), loading: vi.fn(() => () => {}), success: vi.fn(() => () => {}), + error: vi.fn(() => () => {}), }, Modal: { confirm: vi.fn(), diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index 369616a88d0..70585087ef1 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -1,6 +1,6 @@ import { vi, type TestContext as BaseTestContext } from "vitest"; import _ from "lodash"; -import { ControlModeEnum } from "viewer/constants"; +import Constants, { ControlModeEnum, type Vector2 } from "viewer/constants"; import { sleep } from "libs/utils"; import dummyUser from "test/fixtures/dummy_user"; import dummyOrga from "test/fixtures/dummy_organization"; @@ -20,7 +20,7 @@ import { annotation as VOLUME_ANNOTATION, annotationProto as VOLUME_ANNOTATION_PROTO, } from "../fixtures/volumetracing_server_objects"; -import DATASET from "../fixtures/dataset_server_object"; +import DATASET, { sampleHdf5AgglomerateName } from "../fixtures/dataset_server_object"; import type { ApiInterface } from "viewer/api/api_latest"; import type { ModelType } from "viewer/model"; @@ -29,7 +29,7 @@ import Model from "viewer/model"; import UrlManager from "viewer/controller/url_manager"; import WebknossosApi from "viewer/api/api_loader"; -import { type SaveQueueEntry, default as Store, startSaga } from "viewer/store"; +import { type NumberLike, type SaveQueueEntry, default as Store, startSaga } from "viewer/store"; import rootSaga from "viewer/model/sagas/root_saga"; import { setStore, setModel } from "viewer/singletons"; import { setupApi } from "viewer/api/internal_api"; @@ -56,6 +56,7 @@ export interface WebknossosTestContext extends BaseTestContext { model: ModelType; mocks: { Request: typeof Request; + getCurrentMappingEntriesFromServer: typeof getCurrentMappingEntriesFromServer; }; setSlowCompression: (enabled: boolean) => void; api: ApiInterface; @@ -77,6 +78,10 @@ vi.mock("libs/request", () => ({ }, })); +const getCurrentMappingEntriesFromServer = vi.fn((): Array<[number, number]> => { + return []; +}); + vi.mock("admin/rest_api.ts", async () => { const actual = await vi.importActual("admin/rest_api.ts"); @@ -86,10 +91,52 @@ vi.mock("admin/rest_api.ts", async () => { return Promise.resolve(); }); (mockedSendRequestWithToken as any).receivedDataPerSaveRequest = receivedDataPerSaveRequest; + + const getAgglomeratesForSegmentsImpl = async (segmentIds: Array) => { + const segmentIdSet = new Set(segmentIds); + const entries = getCurrentMappingEntriesFromServer().filter(([id]) => + segmentIdSet.has(id), + ) as Vector2[]; + if (entries.length < segmentIdSet.size) { + console.log("entries", entries); + console.log("segmentIdSet", segmentIdSet); + throw new Error( + "Incorrect mock implementation of getAgglomeratesForSegmentsImpl detected. The requested segment ids were not properly served.", + ); + } + return new Map(entries); + }; + const getAgglomeratesForSegmentsFromDatastoreMock = vi.fn( + ( + _dataStoreUrl: string, + _dataSourceId: unknown, + _layerName: string, + _mappingId: string, + segmentIds: Array, + ) => { + return getAgglomeratesForSegmentsImpl(segmentIds); + }, + ); + + const getAgglomeratesForSegmentsFromTracingstoreMock = vi.fn( + ( + _tracingStoreUrl: string, + _tracingId: string, + segmentIds: Array, + _annotationId: string, + _version?: number | null | undefined, + ) => { + return getAgglomeratesForSegmentsImpl(segmentIds); + }, + ); + return { ...actual, getDataset: vi.fn(), sendSaveRequestWithToken: mockedSendRequestWithToken, + getAgglomeratesForDatasetLayer: vi.fn(() => [sampleHdf5AgglomerateName]), + getAgglomeratesForSegmentsFromTracingstore: getAgglomeratesForSegmentsFromTracingstoreMock, + getAgglomeratesForSegmentsFromDatastore: getAgglomeratesForSegmentsFromDatastoreMock, }; }); @@ -150,13 +197,36 @@ vi.mock("viewer/model/bucket_data_handling/data_rendering_logic", async (importO }; }); -export function createBucketResponseFunction(TypedArrayClass: any, fillValue: number, delay = 0) { +type Override = { + position: [number, number, number]; // [x, y, z] + value: number; +}; + +export function createBucketResponseFunction( + TypedArrayClass: any, + fillValue: number, + delay = 0, + overrides: Override[] = [], +) { return async function getBucketData(_url: string, payload: { data: Array }) { - const bucketCount = payload.data.length; await sleep(delay); + const bucketCount = payload.data.length; + const typedArray = new TypedArrayClass(bucketCount * 32 ** 3).fill(fillValue); + + for (let bucketIdx = 0; bucketIdx < bucketCount; bucketIdx++) { + for (const { position, value } of overrides) { + const [x, y, z] = position; + const indexInBucket = + bucketIdx * Constants.BUCKET_WIDTH ** 3 + + z * Constants.BUCKET_WIDTH ** 2 + + y * Constants.BUCKET_WIDTH + + x; + typedArray[indexInBucket] = value; + } + } + return { - buffer: new Uint8Array(new TypedArrayClass(bucketCount * 32 ** 3).fill(fillValue).buffer) - .buffer, + buffer: new Uint8Array(typedArray.buffer).buffer, headers: { "missing-buckets": "[]", }, @@ -223,7 +293,8 @@ export async function setupWebknossosForTesting( testContext.model = Model; testContext.mocks = { - Request, + Request: vi.mocked(Request), + getCurrentMappingEntriesFromServer, }; testContext.setSlowCompression = setSlowCompression; testContext.tearDownPullQueues = () => diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 5dc6effcda7..b474c7d36d0 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -1,14 +1,30 @@ -import { put, take } from "redux-saga/effects"; +import { ColoredLogger } from "libs/utils"; +import { call, put, select, take } from "redux-saga/effects"; import { sampleHdf5AgglomerateName } from "test/fixtures/dataset_server_object"; -import { setupWebknossosForTesting, type WebknossosTestContext } from "test/helpers/apiHelpers"; +import { powerOrga } from "test/fixtures/dummy_organization"; +import { + createBucketResponseFunction, + setupWebknossosForTesting, + type WebknossosTestContext, +} from "test/helpers/apiHelpers"; +import { getMappingInfo } from "viewer/model/accessors/dataset_accessor"; +import { getCurrentMag } from "viewer/model/accessors/flycam_accessor"; +import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; +import { setZoomStepAction } from "viewer/model/actions/flycam_actions"; +import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; import { proofreadMerge } from "viewer/model/actions/proofread_actions"; import { setMappingAction } from "viewer/model/actions/settings_actions"; +import { setToolAction } from "viewer/model/actions/ui_actions"; +import { + setActiveCellAction, + updateSegmentAction, +} from "viewer/model/actions/volumetracing_actions"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; import { Store } from "viewer/singletons"; import { startSaga } from "viewer/store"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -describe.skip("Proofreading", () => { +describe("Proofreading", () => { beforeEach(async (context) => { await setupWebknossosForTesting(context, "hybrid"); }); @@ -19,29 +35,113 @@ describe.skip("Proofreading", () => { expect(hasRootSagaCrashed()).toBe(false); }); - it("shouldn't do anything if unchanged (saga test)", async (_context: WebknossosTestContext) => { + it("should merge two agglomerates and update the mapping accordingly", async (context: WebknossosTestContext) => { + const { api, mocks } = context; + vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( + createBucketResponseFunction(Uint16Array, 1, 5, [ + { position: [0, 0, 0], value: 1337 }, + { position: [1, 1, 1], value: 1 }, + { position: [2, 2, 2], value: 2 }, + { position: [3, 3, 3], value: 3 }, + { position: [4, 4, 4], value: 4 }, + { position: [5, 5, 5], value: 5 }, + { position: [6, 6, 6], value: 6 }, + { position: [7, 7, 7], value: 7 }, + ]), + ); + + mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ + [1, 10], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [8, 13], + [1337, 1337], + ]); + const { annotation } = Store.getState(); - // console.log("annotation.skeleton", annotation.skeleton); - // console.log("annotation.volumes", annotation.volumes); const { tracingId } = annotation.volumes[0]; - /* - SET_MAPPING - FINISH_MAPPING_INITIALIZATION - dispatched by - Mappings.updateMappingTextures - MappingSaga.reloadData - triggered by handleSetHdf5Mapping and when the bucket retrieval source changed between REQUESTED-WITH-MAPPING <> REQUESTED-WITHOUT-MAPPING - */ - const task = startSaga(function* () { + // Set up organization with power plan (necessary for proofreading) + // and zoom in so that buckets in mag 1, 1, 1 are loaded. + yield put(setActiveOrganizationAction(powerOrga)); + yield put(setZoomStepAction(0.3)); + const currentMag = yield select((state) => getCurrentMag(state, tracingId)); + expect(currentMag).toEqual([1, 1, 1]); + + // Activate agglomerate mapping and wait for finished mapping initialization + // (unfortunately, that action is dispatched twice; once for the activation and once + // for the changed BucketRetrievalSource). Ideally, this should be refactored away. yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); + + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); + + // Activate the proofread tool. WK will reload the bucket data and apply the mapping + // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). + yield put(setToolAction(AnnotationTool.PROOFREAD)); yield take("FINISH_MAPPING_INITIALIZATION"); + + // Read data from the 0,0,0 bucket so that it is in memory (important because the mapping + // is only maintained for loaded buckets). + const valueAt444 = yield call(() => api.data.getDataValue(tracingId, [4, 4, 4], 0)); + expect(valueAt444).toBe(4); + // Once again, we wait for FINISH_MAPPING_INITIALIZATION because the mapping is updated + // for the keys that are found in the newly loaded bucket. yield take("FINISH_MAPPING_INITIALIZATION"); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + + // Execute the actual merge and wait for the finished mapping. + yield put(proofreadMerge([4, 4, 4], 1, 4)); yield take("FINISH_MAPPING_INITIALIZATION"); - yield put(proofreadMerge([0, 0, 0], 1, 2)); + + const mapping = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + + expect(mapping).toEqual( + new Map([ + [1, 10], + [2, 10], + [3, 10], + [4, 10], + [5, 10], + [6, 12], + [7, 12], + [1337, 1337] + ]), + ); + + yield call(() => api.tracing.save()); + + const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; + + expect(mergeSaveActionBatch).toEqual([{ + name: 'mergeAgglomerate', + value: { + actionTracingId: 'volumeTracingId', + agglomerateId1: 10, + agglomerateId2: 11, + segmentId1: 1, + segmentId2: 4, + mag: [1, 1, 1] + } + }]) }); await task.toPromise(); - }, 4000); + }, 8000); }); diff --git a/frontend/javascripts/viewer/model/actions/dataset_actions.ts b/frontend/javascripts/viewer/model/actions/dataset_actions.ts index 084f4ec28d1..629d904e3a6 100644 --- a/frontend/javascripts/viewer/model/actions/dataset_actions.ts +++ b/frontend/javascripts/viewer/model/actions/dataset_actions.ts @@ -1,5 +1,5 @@ -import type { APIDataset, CoordinateTransformation } from "types/api_types"; -import { StoreDataset } from "viewer/store"; +import type { CoordinateTransformation } from "types/api_types"; +import type { StoreDataset } from "viewer/store"; type SetDatasetAction = ReturnType; type SetLayerMappingsAction = ReturnType; type SetLayerTransformsAction = ReturnType; diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts index 39282fca965..b38978d9fba 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts @@ -625,7 +625,7 @@ export class DataBucket { expected: channelCount * Constants.BUCKET_SIZE, channelCount, }; - console.warn("bucket.data has unexpected length", debugInfo); + console.warn(`bucket.data for ${this.zoomedAddress} has unexpected length`, debugInfo); ErrorHandling.notify( new Error(`bucket.data has unexpected length. Details: ${JSON.stringify(debugInfo)}`), ); diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index 95a149d77c4..b7b75db1c10 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -3,6 +3,7 @@ import { V3, V4 } from "libs/mjs"; import type { ProgressCallback } from "libs/progress_callback"; import Toast from "libs/toast"; import { + ColoredLogger, areBoundingBoxesOverlappingOrTouching, castForArrayType, isNumberMap, @@ -502,6 +503,12 @@ class DataCube { getValueSetForAllBuckets(): Set | Set { this.lastRequestForValueSet = Date.now(); + const loadedBuckets = this.buckets.filter((bucket) => bucket.state === "LOADED"); + ColoredLogger.logGreen( + "loadedBuckets", + loadedBuckets.map((b) => b.zoomedAddress), + ); + // Theoretically, we could ignore coarser buckets for which we know that // finer buckets are already loaded. However, the current performance // is acceptable which is why this optimization isn't implemented. diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 772b9c210de..30100eb761a 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -232,6 +232,7 @@ export function setToolReducer(state: WebknossosState, tool: AnnotationTool) { const disabledToolInfo = getDisabledInfoForTools(state); if (!isToolAvailable(state, disabledToolInfo, tool)) { + console.log(`Cannot switch to ${tool.readableName} because it's not available.`); return state; } diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts index c8c1d59a15d..1541d254a32 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts @@ -8,7 +8,7 @@ import { import { message } from "antd"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; -import { fastDiffSetAndMap, sleep } from "libs/utils"; +import { ColoredLogger, fastDiffSetAndMap, sleep } from "libs/utils"; import _ from "lodash"; import { buffers, eventChannel } from "redux-saga"; import type { ActionPattern } from "redux-saga/effects"; @@ -229,12 +229,19 @@ function* watchChangedBucketsForLayer(layerName: string): Saga { const dataCube = yield* call([Model, Model.getCubeByLayerName], layerName); const bucketChannel = yield* call(createBucketDataChangedChannel, dataCube); + // todop: remove again? + yield* call(handler); + while (true) { yield take(bucketChannel); // We received a BUCKET_DATA_CHANGED event. `handler` needs to be invoked. // However, let's throttle¹ this by waiting and then discarding all other events // that might have accumulated in between. - yield* call(sleep, 500); + + // todop + const throttleDelay = process.env.IS_TESTING ? 5 : 500; + + yield* call(sleep, throttleDelay); yield flush(bucketChannel); // After flushing and while the handler below is running, // the bucketChannel might fill up again. This means, the @@ -488,6 +495,11 @@ export function* updateLocalHdf5Mapping( }); yield* put(setMappingAction(layerName, mappingName, "HDF5", { mapping })); + if (process.env.IS_TESTING) { + // in test context, the mapping.ts code is not executed (which is usually responsible + // for finishing the initialization). + yield put(finishMappingInitializationAction(layerName)); + } } function* handleSetJsonMapping( diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index 30338792e80..63dbfd2a098 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -8,6 +8,7 @@ import { import { V3 } from "libs/mjs"; import Toast from "libs/toast"; import { SoftError, isBigInt, isNumberMap } from "libs/utils"; +import window from "libs/window"; import _ from "lodash"; import { all, call, put, spawn, takeEvery } from "typed-redux-saga"; import type { AdditionalCoordinate, ServerEditableMapping } from "types/api_types"; @@ -698,6 +699,7 @@ function* handleProofreadMergeOrMinCut(action: Action) { const idInfos = yield* call(gatherInfoForOperation, action, preparation); if (idInfos == null) { + console.warn("[Proofreading] Could not gather id infos."); return; } const [sourceInfo, targetInfo] = idInfos; @@ -1110,7 +1112,7 @@ function* prepareSplitOrMerge(isSkeletonProofreading: boolean): Saga | null> { const { volumeTracing } = preparation; const { tracingId: volumeTracingId, activeCellId, activeUnmappedSegmentId } = volumeTracing; - if (activeCellId === 0) return null; + if (activeCellId === 0) { + console.warn("[Proofreading] Cannot execute operation because active segment id is 0"); + return null; + } const segments = yield* select((store) => getSegmentsForLayer(store, volumeTracingId)); const activeSegment = segments.getNullable(activeCellId); - if (activeSegment == null) return null; + if (activeSegment == null) { + console.warn("[Proofreading] Cannot execute operation because no active segment item exists"); + return null; + } const activeSegmentPositionFloat = activeSegment.somePosition; - if (activeSegmentPositionFloat == null) return null; + if (activeSegmentPositionFloat == null) { + console.warn("[Proofreading] Cannot execute operation because active segment has no position"); + return null; + } const activeSegmentPosition = V3.floor(activeSegmentPositionFloat); @@ -1405,6 +1416,9 @@ function* gatherInfoForOperation( targetPosition, ]); if (idInfos == null) { + console.warn( + "[Proofreading] Cannot execute operation because agglomerate infos couldn't be determined for source and target position.", + ); return null; } const [idInfo1, idInfo2] = idInfos; From 3a67f16d2e32df783856e03868c6e871a67b0569 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 18 Jun 2025 16:51:24 +0200 Subject: [PATCH 45/92] also add test for proofreading min cut --- frontend/javascripts/admin/rest_api.ts | 2 +- .../javascripts/test/helpers/apiHelpers.ts | 26 +++- .../test/sagas/proofreading.spec.ts | 147 +++++++++++++++++- .../controller/combinations/tool_controls.ts | 4 +- .../viewer/model/actions/proofread_actions.ts | 4 +- .../viewer/model/sagas/proofread_saga.ts | 2 +- .../javascripts/viewer/view/context_menu.tsx | 8 +- 7 files changed, 180 insertions(+), 13 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 32eeaa15603..80274d1603f 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -2175,7 +2175,7 @@ export function getSynapseTypes( ); } -type MinCutTargetEdge = { +export type MinCutTargetEdge = { position1: Vector3; position2: Vector3; segmentId1: number; diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index 70585087ef1..f616967bf41 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -1,7 +1,7 @@ import { vi, type TestContext as BaseTestContext } from "vitest"; import _ from "lodash"; import Constants, { ControlModeEnum, type Vector2 } from "viewer/constants"; -import { sleep } from "libs/utils"; +import { ColoredLogger, sleep } from "libs/utils"; import dummyUser from "test/fixtures/dummy_user"; import dummyOrga from "test/fixtures/dummy_organization"; import { setSceneController } from "viewer/controller/scene_controller_provider"; @@ -37,7 +37,12 @@ import { setActiveOrganizationAction } from "viewer/model/actions/organization_a import Request, { type RequestOptions } from "libs/request"; import { parseProtoAnnotation, parseProtoTracing } from "viewer/model/helpers/proto_helpers"; import app from "app"; -import { getDataset, sendSaveRequestWithToken } from "admin/rest_api"; +import { + getDataset, + getEdgesForAgglomerateMinCut, + sendSaveRequestWithToken, + type MinCutTargetEdge, +} from "admin/rest_api"; import { resetStoreAction, restartSagaAction, wkReadyAction } from "viewer/model/actions/actions"; import { setActiveUserAction } from "viewer/model/actions/user_actions"; import { @@ -57,6 +62,7 @@ export interface WebknossosTestContext extends BaseTestContext { mocks: { Request: typeof Request; getCurrentMappingEntriesFromServer: typeof getCurrentMappingEntriesFromServer; + getEdgesForAgglomerateMinCut: typeof getEdgesForAgglomerateMinCut; }; setSlowCompression: (enabled: boolean) => void; api: ApiInterface; @@ -104,6 +110,12 @@ vi.mock("admin/rest_api.ts", async () => { "Incorrect mock implementation of getAgglomeratesForSegmentsImpl detected. The requested segment ids were not properly served.", ); } + ColoredLogger.logGreen( + "getAgglomeratesForSegmentsImpl returns", + entries, + "for requested", + segmentIds, + ); return new Map(entries); }; const getAgglomeratesForSegmentsFromDatastoreMock = vi.fn( @@ -137,6 +149,15 @@ vi.mock("admin/rest_api.ts", async () => { getAgglomeratesForDatasetLayer: vi.fn(() => [sampleHdf5AgglomerateName]), getAgglomeratesForSegmentsFromTracingstore: getAgglomeratesForSegmentsFromTracingstoreMock, getAgglomeratesForSegmentsFromDatastore: getAgglomeratesForSegmentsFromDatastoreMock, + getEdgesForAgglomerateMinCut: vi.fn( + ( + _tracingStoreUrl: string, + _tracingId: string, + _segmentsInfo: unknown, + ): Promise> => { + throw new Error("Not yet mocked."); + }, + ), }; }); @@ -295,6 +316,7 @@ export async function setupWebknossosForTesting( testContext.mocks = { Request: vi.mocked(Request), getCurrentMappingEntriesFromServer, + getEdgesForAgglomerateMinCut, }; testContext.setSlowCompression = setSlowCompression; testContext.tearDownPullQueues = () => diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index b474c7d36d0..83c0bc38304 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -12,7 +12,7 @@ import { getCurrentMag } from "viewer/model/accessors/flycam_accessor"; import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { setZoomStepAction } from "viewer/model/actions/flycam_actions"; import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; -import { proofreadMerge } from "viewer/model/actions/proofread_actions"; +import { proofreadMergeAction, minCutAgglomerateWithPositionAction } from "viewer/model/actions/proofread_actions"; import { setMappingAction } from "viewer/model/actions/settings_actions"; import { setToolAction } from "viewer/model/actions/ui_actions"; import { @@ -104,7 +104,7 @@ describe("Proofreading", () => { yield put(setActiveCellAction(1)); // Execute the actual merge and wait for the finished mapping. - yield put(proofreadMerge([4, 4, 4], 1, 4)); + yield put(proofreadMergeAction([4, 4, 4], 1, 4)); yield take("FINISH_MAPPING_INITIALIZATION"); const mapping = yield select( @@ -144,4 +144,147 @@ describe("Proofreading", () => { await task.toPromise(); }, 8000); + + it("should split two agglomerates and update the mapping accordingly", async (context: WebknossosTestContext) => { + const { api, mocks } = context; + vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( + createBucketResponseFunction(Uint16Array, 1, 5, [ + { position: [0, 0, 0], value: 1337 }, + { position: [1, 1, 1], value: 1 }, + { position: [2, 2, 2], value: 2 }, + { position: [3, 3, 3], value: 3 }, + { position: [4, 4, 4], value: 4 }, + { position: [5, 5, 5], value: 5 }, + { position: [6, 6, 6], value: 6 }, + { position: [7, 7, 7], value: 7 }, + ]), + ); + + mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ + [1, 10], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [8, 13], + [1337, 1337], + ]); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + // Set up organization with power plan (necessary for proofreading) + // and zoom in so that buckets in mag 1, 1, 1 are loaded. + yield put(setActiveOrganizationAction(powerOrga)); + yield put(setZoomStepAction(0.3)); + const currentMag = yield select((state) => getCurrentMag(state, tracingId)); + expect(currentMag).toEqual([1, 1, 1]); + + // Activate agglomerate mapping and wait for finished mapping initialization + // (unfortunately, that action is dispatched twice; once for the activation and once + // for the changed BucketRetrievalSource). Ideally, this should be refactored away. + yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); + + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); + + // Activate the proofread tool. WK will reload the bucket data and apply the mapping + // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). + yield put(setToolAction(AnnotationTool.PROOFREAD)); + yield take("FINISH_MAPPING_INITIALIZATION"); + + // Read data from the 0,0,0 bucket so that it is in memory (important because the mapping + // is only maintained for loaded buckets). + const valueAt444 = yield call(() => api.data.getDataValue(tracingId, [4, 4, 4], 0)); + expect(valueAt444).toBe(4); + // Once again, we wait for FINISH_MAPPING_INITIALIZATION because the mapping is updated + // for the keys that are found in the newly loaded bucket. + yield take("FINISH_MAPPING_INITIALIZATION"); + + const mapping0 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + expect(mapping0).toEqual( + new Map([ + [1, 10], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [1337, 1337] + ]), + ); + + // Set up the merge-related segment partners. Normally, this would happen + // due to the user's interactions. + yield put(updateSegmentAction(1, { somePosition: [1, 1, 1] }, tracingId)); + yield put(setActiveCellAction(1)); + + // Prepare the server's reply for the upcoming split. + vi.mocked(mocks.getEdgesForAgglomerateMinCut).mockReturnValue(Promise.resolve([ + { + position1: [1, 1, 1], + position2: [2, 2, 2], + segmentId1: 1, + segmentId2: 2, + }, + ])) + // Already prepare the server's reply for mapping requests that will be sent + // after the split. + mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ + [1, 9], + [2, 10], + [3, 10], + ]); + + // Execute the split and wait for the finished mapping. + yield put(minCutAgglomerateWithPositionAction([2, 2, 2], 2, 10)); + yield take("FINISH_MAPPING_INITIALIZATION"); + + const mapping1 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + + expect(mapping1).toEqual( + new Map([ + [1, 9], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [1337, 1337] + ]), + ); + + yield call(() => api.tracing.save()); + + const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; + + expect(mergeSaveActionBatch).toEqual([{ + name: 'splitAgglomerate', + value: { + actionTracingId: 'volumeTracingId', + agglomerateId: 10, + segmentId1: 1, + segmentId2: 2, + mag: [1, 1, 1] + } + }]) + }); + + await task.toPromise(); + }, 8000); }); diff --git a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts index 23915beaf21..7fc6832bb44 100644 --- a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts +++ b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts @@ -43,7 +43,7 @@ import { finishedResizingUserBoundingBoxAction } from "viewer/model/actions/anno import { minCutAgglomerateWithPositionAction, proofreadAtPosition, - proofreadMerge, + proofreadMergeAction, } from "viewer/model/actions/proofread_actions"; import { hideMeasurementTooltipAction, @@ -1082,7 +1082,7 @@ export class ProofreadToolController { const globalPosition = calculateGlobalPos(state, pos); if (event.shiftKey) { - Store.dispatch(proofreadMerge(globalPosition)); + Store.dispatch(proofreadMergeAction(globalPosition)); } else if (event.ctrlKey || event.metaKey) { Store.dispatch(minCutAgglomerateWithPositionAction(globalPosition)); } else { diff --git a/frontend/javascripts/viewer/model/actions/proofread_actions.ts b/frontend/javascripts/viewer/model/actions/proofread_actions.ts index 31141279d10..411cfb1c2d2 100644 --- a/frontend/javascripts/viewer/model/actions/proofread_actions.ts +++ b/frontend/javascripts/viewer/model/actions/proofread_actions.ts @@ -4,7 +4,7 @@ import type { Tree } from "viewer/model/types/tree_types"; export type ProofreadAtPositionAction = ReturnType; export type ClearProofreadingByProductsAction = ReturnType; -export type ProofreadMergeAction = ReturnType; +export type ProofreadMergeAction = ReturnType; export type MinCutAgglomerateAction = ReturnType; export type MinCutAgglomerateWithPositionAction = ReturnType< typeof minCutAgglomerateWithPositionAction @@ -36,7 +36,7 @@ export const clearProofreadingByProducts = () => type: "CLEAR_PROOFREADING_BY_PRODUCTS", }) as const; -export const proofreadMerge = ( +export const proofreadMergeAction = ( position: Vector3 | null, segmentId?: number | null, agglomerateId?: number | null, diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index 63dbfd2a098..93387274b19 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -7,7 +7,7 @@ import { } from "admin/rest_api"; import { V3 } from "libs/mjs"; import Toast from "libs/toast"; -import { SoftError, isBigInt, isNumberMap } from "libs/utils"; +import { ColoredLogger, SoftError, isBigInt, isNumberMap } from "libs/utils"; import window from "libs/window"; import _ from "lodash"; import { all, call, put, spawn, takeEvery } from "typed-redux-saga"; diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index b00ee06db95..3b9dfae2792 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -96,7 +96,7 @@ import { cutAgglomerateFromNeighborsAction, minCutAgglomerateAction, minCutAgglomerateWithPositionAction, - proofreadMerge, + proofreadMergeAction, } from "viewer/model/actions/proofread_actions"; import { loadAdHocMeshAction, @@ -449,7 +449,9 @@ function getMeshItems( // Should not happen due to the disabled property. return; } - return Store.dispatch(proofreadMerge(null, maybeUnmappedSegmentId, clickedMeshId)); + return Store.dispatch( + proofreadMergeAction(null, maybeUnmappedSegmentId, clickedMeshId), + ); }, label: ( Merge with active segment @@ -1136,7 +1138,7 @@ function getNoNodeContextMenuOptions(props: NoNodeContextMenuProps): ItemType[] ? { key: "merge-agglomerate-skeleton", disabled: !isProofreadingActive, - onClick: () => Store.dispatch(proofreadMerge(globalPosition)), + onClick: () => Store.dispatch(proofreadMergeAction(globalPosition)), label: ( Date: Wed, 18 Jun 2025 16:58:12 +0200 Subject: [PATCH 46/92] refactor spec --- .../test/sagas/proofreading.spec.ts | 100 +++++++----------- 1 file changed, 38 insertions(+), 62 deletions(-) diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 83c0bc38304..f23e7cbf10b 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -1,4 +1,5 @@ import { ColoredLogger } from "libs/utils"; +import { Saga } from "redux-saga"; import { call, put, select, take } from "redux-saga/effects"; import { sampleHdf5AgglomerateName } from "test/fixtures/dataset_server_object"; import { powerOrga } from "test/fixtures/dummy_organization"; @@ -24,6 +25,41 @@ import { Store } from "viewer/singletons"; import { startSaga } from "viewer/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +function* initializeMappingAndTool(context: WebknossosTestContext, tracingId: string): Saga { + const { api } = context; + // Set up organization with power plan (necessary for proofreading) + // and zoom in so that buckets in mag 1, 1, 1 are loaded. + yield put(setActiveOrganizationAction(powerOrga)); + yield put(setZoomStepAction(0.3)); + const currentMag = yield select((state) => getCurrentMag(state, tracingId)); + expect(currentMag).toEqual([1, 1, 1]); + + // Activate agglomerate mapping and wait for finished mapping initialization + // (unfortunately, that action is dispatched twice; once for the activation and once + // for the changed BucketRetrievalSource). Ideally, this should be refactored away. + yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); + + ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); + yield take("FINISH_MAPPING_INITIALIZATION"); + ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); + + // Activate the proofread tool. WK will reload the bucket data and apply the mapping + // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). + yield put(setToolAction(AnnotationTool.PROOFREAD)); + yield take("FINISH_MAPPING_INITIALIZATION"); + + // Read data from the 0,0,0 bucket so that it is in memory (important because the mapping + // is only maintained for loaded buckets). + const valueAt444 = yield call(() => api.data.getDataValue(tracingId, [4, 4, 4], 0)); + expect(valueAt444).toBe(4); + // Once again, we wait for FINISH_MAPPING_INITIALIZATION because the mapping is updated + // for the keys that are found in the newly loaded bucket. + yield take("FINISH_MAPPING_INITIALIZATION"); +} + describe("Proofreading", () => { beforeEach(async (context) => { await setupWebknossosForTesting(context, "hybrid"); @@ -66,37 +102,7 @@ describe("Proofreading", () => { const { tracingId } = annotation.volumes[0]; const task = startSaga(function* () { - // Set up organization with power plan (necessary for proofreading) - // and zoom in so that buckets in mag 1, 1, 1 are loaded. - yield put(setActiveOrganizationAction(powerOrga)); - yield put(setZoomStepAction(0.3)); - const currentMag = yield select((state) => getCurrentMag(state, tracingId)); - expect(currentMag).toEqual([1, 1, 1]); - - // Activate agglomerate mapping and wait for finished mapping initialization - // (unfortunately, that action is dispatched twice; once for the activation and once - // for the changed BucketRetrievalSource). Ideally, this should be refactored away. - yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); - yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); - - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); - yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); - - // Activate the proofread tool. WK will reload the bucket data and apply the mapping - // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). - yield put(setToolAction(AnnotationTool.PROOFREAD)); - yield take("FINISH_MAPPING_INITIALIZATION"); - - // Read data from the 0,0,0 bucket so that it is in memory (important because the mapping - // is only maintained for loaded buckets). - const valueAt444 = yield call(() => api.data.getDataValue(tracingId, [4, 4, 4], 0)); - expect(valueAt444).toBe(4); - // Once again, we wait for FINISH_MAPPING_INITIALIZATION because the mapping is updated - // for the keys that are found in the newly loaded bucket. - yield take("FINISH_MAPPING_INITIALIZATION"); + yield call(initializeMappingAndTool, context, tracingId); // Set up the merge-related segment partners. Normally, this would happen // due to the user's interactions. @@ -176,37 +182,7 @@ describe("Proofreading", () => { const { tracingId } = annotation.volumes[0]; const task = startSaga(function* () { - // Set up organization with power plan (necessary for proofreading) - // and zoom in so that buckets in mag 1, 1, 1 are loaded. - yield put(setActiveOrganizationAction(powerOrga)); - yield put(setZoomStepAction(0.3)); - const currentMag = yield select((state) => getCurrentMag(state, tracingId)); - expect(currentMag).toEqual([1, 1, 1]); - - // Activate agglomerate mapping and wait for finished mapping initialization - // (unfortunately, that action is dispatched twice; once for the activation and once - // for the changed BucketRetrievalSource). Ideally, this should be refactored away. - yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); - yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); - - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); - yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); - - // Activate the proofread tool. WK will reload the bucket data and apply the mapping - // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). - yield put(setToolAction(AnnotationTool.PROOFREAD)); - yield take("FINISH_MAPPING_INITIALIZATION"); - - // Read data from the 0,0,0 bucket so that it is in memory (important because the mapping - // is only maintained for loaded buckets). - const valueAt444 = yield call(() => api.data.getDataValue(tracingId, [4, 4, 4], 0)); - expect(valueAt444).toBe(4); - // Once again, we wait for FINISH_MAPPING_INITIALIZATION because the mapping is updated - // for the keys that are found in the newly loaded bucket. - yield take("FINISH_MAPPING_INITIALIZATION"); + yield call(initializeMappingAndTool, context, tracingId); const mapping0 = yield select( (state) => From 2963c7cf5f603e5d67f86133ee36574c11a50e1b Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 18 Jun 2025 17:00:52 +0200 Subject: [PATCH 47/92] further refac --- .../test/sagas/proofreading.spec.ts | 77 +++++++------------ 1 file changed, 29 insertions(+), 48 deletions(-) diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index f23e7cbf10b..4aaabb61cc9 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -60,6 +60,33 @@ function* initializeMappingAndTool(context: WebknossosTestContext, tracingId: st yield take("FINISH_MAPPING_INITIALIZATION"); } +function mockInitialBucketAndAgglomerateData(context: WebknossosTestContext) { + const { mocks } = context; + vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( + createBucketResponseFunction(Uint16Array, 1, 5, [ + { position: [0, 0, 0], value: 1337 }, + { position: [1, 1, 1], value: 1 }, + { position: [2, 2, 2], value: 2 }, + { position: [3, 3, 3], value: 3 }, + { position: [4, 4, 4], value: 4 }, + { position: [5, 5, 5], value: 5 }, + { position: [6, 6, 6], value: 6 }, + { position: [7, 7, 7], value: 7 }, + ]), + ); + mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ + [1, 10], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [8, 13], + [1337, 1337], + ]); +} + describe("Proofreading", () => { beforeEach(async (context) => { await setupWebknossosForTesting(context, "hybrid"); @@ -73,30 +100,7 @@ describe("Proofreading", () => { it("should merge two agglomerates and update the mapping accordingly", async (context: WebknossosTestContext) => { const { api, mocks } = context; - vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( - createBucketResponseFunction(Uint16Array, 1, 5, [ - { position: [0, 0, 0], value: 1337 }, - { position: [1, 1, 1], value: 1 }, - { position: [2, 2, 2], value: 2 }, - { position: [3, 3, 3], value: 3 }, - { position: [4, 4, 4], value: 4 }, - { position: [5, 5, 5], value: 5 }, - { position: [6, 6, 6], value: 6 }, - { position: [7, 7, 7], value: 7 }, - ]), - ); - - mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ - [1, 10], - [2, 10], - [3, 10], - [4, 11], - [5, 11], - [6, 12], - [7, 12], - [8, 13], - [1337, 1337], - ]); + mockInitialBucketAndAgglomerateData(context); const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; @@ -153,30 +157,7 @@ describe("Proofreading", () => { it("should split two agglomerates and update the mapping accordingly", async (context: WebknossosTestContext) => { const { api, mocks } = context; - vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( - createBucketResponseFunction(Uint16Array, 1, 5, [ - { position: [0, 0, 0], value: 1337 }, - { position: [1, 1, 1], value: 1 }, - { position: [2, 2, 2], value: 2 }, - { position: [3, 3, 3], value: 3 }, - { position: [4, 4, 4], value: 4 }, - { position: [5, 5, 5], value: 5 }, - { position: [6, 6, 6], value: 6 }, - { position: [7, 7, 7], value: 7 }, - ]), - ); - - mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ - [1, 10], - [2, 10], - [3, 10], - [4, 11], - [5, 11], - [6, 12], - [7, 12], - [8, 13], - [1337, 1337], - ]); + mockInitialBucketAndAgglomerateData(context); const { annotation } = Store.getState(); const { tracingId } = annotation.volumes[0]; From 70fb5b96b53d9bdf01c8ec0537fa86d412724fbc Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 13:38:05 +0200 Subject: [PATCH 48/92] fix wrong import --- frontend/javascripts/test/sagas/proofreading.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 4aaabb61cc9..9fed7790f7f 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -1,5 +1,5 @@ import { ColoredLogger } from "libs/utils"; -import { Saga } from "redux-saga"; +import type { Saga } from "viewer/model/sagas/effect-generators"; import { call, put, select, take } from "redux-saga/effects"; import { sampleHdf5AgglomerateName } from "test/fixtures/dataset_server_object"; import { powerOrga } from "test/fixtures/dummy_organization"; From 13b91a3537ebe2a1c9c125be92ede83b96751e26 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 13:38:39 +0200 Subject: [PATCH 49/92] reconfigure tsconfig to be compatible with tsgo (no more baseUrl) --- tsconfig.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index ac62082774f..1991b0630e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,16 +12,8 @@ "target": "esnext", "skipLibCheck": true, //reconsider this later "allowSyntheticDefaultImports": true, - "baseUrl": "./frontend/javascripts", "paths": { - "viewer/*": ["./viewer/*"], - "libs/*": ["./libs/*"], - "dashboard/*": ["./dashboard/*"], - "components/*": ["./components/*"], - "admin/*": ["./admin/*"], - "types/*": ["./types/*"], - "test/*": ["./test/*"], - "features": ["./features.ts"] + "*": ["./frontend/javascripts/*"] } }, "include": [ From 2a3b7c18aa2ae9f119b933a770c47b8066632f36 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 16:12:09 +0200 Subject: [PATCH 50/92] also add proofreading specs for incorporating update actions from server --- .../javascripts/test/helpers/apiHelpers.ts | 10 +- .../test/sagas/proofreading.spec.ts | 194 ++++++++++++++---- .../model/bucket_data_handling/data_cube.ts | 7 - .../viewer/model/sagas/mapping_saga.ts | 2 +- .../viewer/model/sagas/proofread_saga.ts | 3 +- .../viewer/model/sagas/save_saga.ts | 2 +- 6 files changed, 163 insertions(+), 55 deletions(-) diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index f616967bf41..a041b914400 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -1,7 +1,7 @@ import { vi, type TestContext as BaseTestContext } from "vitest"; import _ from "lodash"; import Constants, { ControlModeEnum, type Vector2 } from "viewer/constants"; -import { ColoredLogger, sleep } from "libs/utils"; +import { sleep } from "libs/utils"; import dummyUser from "test/fixtures/dummy_user"; import dummyOrga from "test/fixtures/dummy_organization"; import { setSceneController } from "viewer/controller/scene_controller_provider"; @@ -104,18 +104,10 @@ vi.mock("admin/rest_api.ts", async () => { segmentIdSet.has(id), ) as Vector2[]; if (entries.length < segmentIdSet.size) { - console.log("entries", entries); - console.log("segmentIdSet", segmentIdSet); throw new Error( "Incorrect mock implementation of getAgglomeratesForSegmentsImpl detected. The requested segment ids were not properly served.", ); } - ColoredLogger.logGreen( - "getAgglomeratesForSegmentsImpl returns", - entries, - "for requested", - segmentIds, - ); return new Map(entries); }; const getAgglomeratesForSegmentsFromDatastoreMock = vi.fn( diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 9fed7790f7f..318e3241c9f 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -1,4 +1,3 @@ -import { ColoredLogger } from "libs/utils"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { call, put, select, take } from "redux-saga/effects"; import { sampleHdf5AgglomerateName } from "test/fixtures/dataset_server_object"; @@ -24,6 +23,7 @@ import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; import { Store } from "viewer/singletons"; import { startSaga } from "viewer/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { tryToIncorporateActions } from "viewer/model/sagas/save_saga"; function* initializeMappingAndTool(context: WebknossosTestContext, tracingId: string): Saga { const { api } = context; @@ -38,13 +38,9 @@ function* initializeMappingAndTool(context: WebknossosTestContext, tracingId: st // (unfortunately, that action is dispatched twice; once for the activation and once // for the changed BucketRetrievalSource). Ideally, this should be refactored away. yield put(setMappingAction(tracingId, sampleHdf5AgglomerateName, "HDF5")); - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (1)"); yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (1)"); - ColoredLogger.logYellow("wait for FINISH_MAPPING_INITIALIZATION (2)"); yield take("FINISH_MAPPING_INITIALIZATION"); - ColoredLogger.logYellow("received FINISH_MAPPING_INITIALIZATION (2)"); // Activate the proofread tool. WK will reload the bucket data and apply the mapping // locally (acknowledged by FINISH_MAPPING_INITIALIZATION). @@ -87,6 +83,39 @@ function mockInitialBucketAndAgglomerateData(context: WebknossosTestContext) { ]); } +const initialMapping = new Map([ + [1, 10], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [1337, 1337] +]); + +const expectedMappingAfterMerge = new Map([ + [1, 10], + [2, 10], + [3, 10], + [4, 10], + [5, 10], + [6, 12], + [7, 12], + [1337, 1337] +]) + +const expectedMappingAfterSplit = new Map([ + [1, 9], + [2, 10], + [3, 10], + [4, 11], + [5, 11], + [6, 12], + [7, 12], + [1337, 1337] +]) + describe("Proofreading", () => { beforeEach(async (context) => { await setupWebknossosForTesting(context, "hybrid"); @@ -99,7 +128,7 @@ describe("Proofreading", () => { }); it("should merge two agglomerates and update the mapping accordingly", async (context: WebknossosTestContext) => { - const { api, mocks } = context; + const { api } = context; mockInitialBucketAndAgglomerateData(context); const { annotation } = Store.getState(); @@ -107,6 +136,13 @@ describe("Proofreading", () => { const task = startSaga(function* () { yield call(initializeMappingAndTool, context, tracingId); + const mapping0 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + expect(mapping0).toEqual( + initialMapping, + ); // Set up the merge-related segment partners. Normally, this would happen // due to the user's interactions. @@ -123,16 +159,7 @@ describe("Proofreading", () => { ); expect(mapping).toEqual( - new Map([ - [1, 10], - [2, 10], - [3, 10], - [4, 10], - [5, 10], - [6, 12], - [7, 12], - [1337, 1337] - ]), + expectedMappingAfterMerge, ); yield call(() => api.tracing.save()); @@ -170,16 +197,7 @@ describe("Proofreading", () => { getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); expect(mapping0).toEqual( - new Map([ - [1, 10], - [2, 10], - [3, 10], - [4, 11], - [5, 11], - [6, 12], - [7, 12], - [1337, 1337] - ]), + initialMapping, ); // Set up the merge-related segment partners. Normally, this would happen @@ -214,16 +232,7 @@ describe("Proofreading", () => { ); expect(mapping1).toEqual( - new Map([ - [1, 9], - [2, 10], - [3, 10], - [4, 11], - [5, 11], - [6, 12], - [7, 12], - [1337, 1337] - ]), + expectedMappingAfterSplit, ); yield call(() => api.tracing.save()); @@ -244,4 +253,117 @@ describe("Proofreading", () => { await task.toPromise(); }, 8000); + + it("should update the mapping when a the server has a new update action with a merge operation", async (context: WebknossosTestContext) => { + const { api } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + const mapping0 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + expect(mapping0).toEqual( + initialMapping, + ); + yield call(() => api.tracing.save()); + context.receivedDataPerSaveRequest = []; + + yield call(tryToIncorporateActions, [{ + version: 1, value: [{ + name: 'mergeAgglomerate', + value: { + actionTracingId: 'volumeTracingId', + actionTimestamp: 0, + agglomerateId1: 10, + agglomerateId2: 11, + segmentId1: 1, + segmentId2: 4, + mag: [1, 1, 1] + } + }] + }]) + + yield take("FINISH_MAPPING_INITIALIZATION"); + + const mapping1 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + + expect(mapping1).toEqual( + expectedMappingAfterMerge, + ); + + yield call(() => api.tracing.save()); + + expect(context.receivedDataPerSaveRequest).toEqual([]); + }); + + await task.toPromise(); + }, 8000); + + it("should update the mapping when a the server has a new update action with a split operation", async (context: WebknossosTestContext) => { + const { api, mocks } = context; + mockInitialBucketAndAgglomerateData(context); + + const { annotation } = Store.getState(); + const { tracingId } = annotation.volumes[0]; + + const task = startSaga(function* () { + yield call(initializeMappingAndTool, context, tracingId); + + const mapping0 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + expect(mapping0).toEqual( + initialMapping, + ); + yield call(() => api.tracing.save()); + context.receivedDataPerSaveRequest = []; + + // Already prepare the server's reply for mapping requests that will be sent + // after the split. + mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ + [1, 9], + [2, 10], + [3, 10], + ]); + + yield call(tryToIncorporateActions, [{ + version: 1, value: [{ + name: 'splitAgglomerate', + value: { + actionTracingId: 'volumeTracingId', + actionTimestamp: 0, + agglomerateId: 10, + segmentId1: 1, + segmentId2: 2, + mag: [1, 1, 1] + } + }] + }]) + + const mapping1 = yield select( + (state) => + getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, + ); + + expect(mapping1).toEqual( + expectedMappingAfterSplit, + ); + + yield call(() => api.tracing.save()); + + expect(context.receivedDataPerSaveRequest).toEqual([]); + }); + + await task.toPromise(); + }, 8000); }); diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index b7b75db1c10..95a149d77c4 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -3,7 +3,6 @@ import { V3, V4 } from "libs/mjs"; import type { ProgressCallback } from "libs/progress_callback"; import Toast from "libs/toast"; import { - ColoredLogger, areBoundingBoxesOverlappingOrTouching, castForArrayType, isNumberMap, @@ -503,12 +502,6 @@ class DataCube { getValueSetForAllBuckets(): Set | Set { this.lastRequestForValueSet = Date.now(); - const loadedBuckets = this.buckets.filter((bucket) => bucket.state === "LOADED"); - ColoredLogger.logGreen( - "loadedBuckets", - loadedBuckets.map((b) => b.zoomedAddress), - ); - // Theoretically, we could ignore coarser buckets for which we know that // finer buckets are already loaded. However, the current performance // is acceptable which is why this optimization isn't implemented. diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts index 1541d254a32..c33f77e61b0 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts @@ -8,7 +8,7 @@ import { import { message } from "antd"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; -import { ColoredLogger, fastDiffSetAndMap, sleep } from "libs/utils"; +import { fastDiffSetAndMap, sleep } from "libs/utils"; import _ from "lodash"; import { buffers, eventChannel } from "redux-saga"; import type { ActionPattern } from "redux-saga/effects"; diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index 93387274b19..f7d0aff2b1b 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -7,7 +7,7 @@ import { } from "admin/rest_api"; import { V3 } from "libs/mjs"; import Toast from "libs/toast"; -import { ColoredLogger, SoftError, isBigInt, isNumberMap } from "libs/utils"; +import { SoftError, isBigInt, isNumberMap } from "libs/utils"; import window from "libs/window"; import _ from "lodash"; import { all, call, put, spawn, takeEvery } from "typed-redux-saga"; @@ -1291,6 +1291,7 @@ function* splitAgglomerateInMapping( return [segmentId, agglomerateId]; }), ) as Mapping; + return splitMapping; } diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 55fb950c27d..d44a8b45d52 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -682,7 +682,7 @@ function* watchForSaveConflicts(): Saga { } } -function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga { +export function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga { const refreshFunctionByTracing: Record Saga> = {}; function* finalize() { for (const fn of Object.values(refreshFunctionByTracing)) { From 2f02cb1d66e6ffa935e3c918b98dca99cad04b63 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 16:12:35 +0200 Subject: [PATCH 51/92] format --- .../test/sagas/proofreading.spec.ts | 175 +++++++++--------- 1 file changed, 89 insertions(+), 86 deletions(-) diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 318e3241c9f..8c52091dd9b 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -12,7 +12,10 @@ import { getCurrentMag } from "viewer/model/accessors/flycam_accessor"; import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { setZoomStepAction } from "viewer/model/actions/flycam_actions"; import { setActiveOrganizationAction } from "viewer/model/actions/organization_actions"; -import { proofreadMergeAction, minCutAgglomerateWithPositionAction } from "viewer/model/actions/proofread_actions"; +import { + proofreadMergeAction, + minCutAgglomerateWithPositionAction, +} from "viewer/model/actions/proofread_actions"; import { setMappingAction } from "viewer/model/actions/settings_actions"; import { setToolAction } from "viewer/model/actions/ui_actions"; import { @@ -91,7 +94,7 @@ const initialMapping = new Map([ [5, 11], [6, 12], [7, 12], - [1337, 1337] + [1337, 1337], ]); const expectedMappingAfterMerge = new Map([ @@ -102,8 +105,8 @@ const expectedMappingAfterMerge = new Map([ [5, 10], [6, 12], [7, 12], - [1337, 1337] -]) + [1337, 1337], +]); const expectedMappingAfterSplit = new Map([ [1, 9], @@ -113,8 +116,8 @@ const expectedMappingAfterSplit = new Map([ [5, 11], [6, 12], [7, 12], - [1337, 1337] -]) + [1337, 1337], +]); describe("Proofreading", () => { beforeEach(async (context) => { @@ -140,9 +143,7 @@ describe("Proofreading", () => { (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping0).toEqual( - initialMapping, - ); + expect(mapping0).toEqual(initialMapping); // Set up the merge-related segment partners. Normally, this would happen // due to the user's interactions. @@ -158,25 +159,25 @@ describe("Proofreading", () => { getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping).toEqual( - expectedMappingAfterMerge, - ); + expect(mapping).toEqual(expectedMappingAfterMerge); yield call(() => api.tracing.save()); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; - expect(mergeSaveActionBatch).toEqual([{ - name: 'mergeAgglomerate', - value: { - actionTracingId: 'volumeTracingId', - agglomerateId1: 10, - agglomerateId2: 11, - segmentId1: 1, - segmentId2: 4, - mag: [1, 1, 1] - } - }]) + expect(mergeSaveActionBatch).toEqual([ + { + name: "mergeAgglomerate", + value: { + actionTracingId: "volumeTracingId", + agglomerateId1: 10, + agglomerateId2: 11, + segmentId1: 1, + segmentId2: 4, + mag: [1, 1, 1], + }, + }, + ]); }); await task.toPromise(); @@ -196,9 +197,7 @@ describe("Proofreading", () => { (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping0).toEqual( - initialMapping, - ); + expect(mapping0).toEqual(initialMapping); // Set up the merge-related segment partners. Normally, this would happen // due to the user's interactions. @@ -206,14 +205,16 @@ describe("Proofreading", () => { yield put(setActiveCellAction(1)); // Prepare the server's reply for the upcoming split. - vi.mocked(mocks.getEdgesForAgglomerateMinCut).mockReturnValue(Promise.resolve([ - { - position1: [1, 1, 1], - position2: [2, 2, 2], - segmentId1: 1, - segmentId2: 2, - }, - ])) + vi.mocked(mocks.getEdgesForAgglomerateMinCut).mockReturnValue( + Promise.resolve([ + { + position1: [1, 1, 1], + position2: [2, 2, 2], + segmentId1: 1, + segmentId2: 2, + }, + ]), + ); // Already prepare the server's reply for mapping requests that will be sent // after the split. mocks.getCurrentMappingEntriesFromServer.mockReturnValue([ @@ -231,24 +232,24 @@ describe("Proofreading", () => { getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping1).toEqual( - expectedMappingAfterSplit, - ); + expect(mapping1).toEqual(expectedMappingAfterSplit); yield call(() => api.tracing.save()); const mergeSaveActionBatch = context.receivedDataPerSaveRequest.at(-1)![0]?.actions; - expect(mergeSaveActionBatch).toEqual([{ - name: 'splitAgglomerate', - value: { - actionTracingId: 'volumeTracingId', - agglomerateId: 10, - segmentId1: 1, - segmentId2: 2, - mag: [1, 1, 1] - } - }]) + expect(mergeSaveActionBatch).toEqual([ + { + name: "splitAgglomerate", + value: { + actionTracingId: "volumeTracingId", + agglomerateId: 10, + segmentId1: 1, + segmentId2: 2, + mag: [1, 1, 1], + }, + }, + ]); }); await task.toPromise(); @@ -268,26 +269,29 @@ describe("Proofreading", () => { (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping0).toEqual( - initialMapping, - ); + expect(mapping0).toEqual(initialMapping); yield call(() => api.tracing.save()); context.receivedDataPerSaveRequest = []; - yield call(tryToIncorporateActions, [{ - version: 1, value: [{ - name: 'mergeAgglomerate', - value: { - actionTracingId: 'volumeTracingId', - actionTimestamp: 0, - agglomerateId1: 10, - agglomerateId2: 11, - segmentId1: 1, - segmentId2: 4, - mag: [1, 1, 1] - } - }] - }]) + yield call(tryToIncorporateActions, [ + { + version: 1, + value: [ + { + name: "mergeAgglomerate", + value: { + actionTracingId: "volumeTracingId", + actionTimestamp: 0, + agglomerateId1: 10, + agglomerateId2: 11, + segmentId1: 1, + segmentId2: 4, + mag: [1, 1, 1], + }, + }, + ], + }, + ]); yield take("FINISH_MAPPING_INITIALIZATION"); @@ -296,9 +300,7 @@ describe("Proofreading", () => { getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping1).toEqual( - expectedMappingAfterMerge, - ); + expect(mapping1).toEqual(expectedMappingAfterMerge); yield call(() => api.tracing.save()); @@ -322,9 +324,7 @@ describe("Proofreading", () => { (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping0).toEqual( - initialMapping, - ); + expect(mapping0).toEqual(initialMapping); yield call(() => api.tracing.save()); context.receivedDataPerSaveRequest = []; @@ -336,28 +336,31 @@ describe("Proofreading", () => { [3, 10], ]); - yield call(tryToIncorporateActions, [{ - version: 1, value: [{ - name: 'splitAgglomerate', - value: { - actionTracingId: 'volumeTracingId', - actionTimestamp: 0, - agglomerateId: 10, - segmentId1: 1, - segmentId2: 2, - mag: [1, 1, 1] - } - }] - }]) + yield call(tryToIncorporateActions, [ + { + version: 1, + value: [ + { + name: "splitAgglomerate", + value: { + actionTracingId: "volumeTracingId", + actionTimestamp: 0, + agglomerateId: 10, + segmentId1: 1, + segmentId2: 2, + mag: [1, 1, 1], + }, + }, + ], + }, + ]); const mapping1 = yield select( (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId).mapping, ); - expect(mapping1).toEqual( - expectedMappingAfterSplit, - ); + expect(mapping1).toEqual(expectedMappingAfterSplit); yield call(() => api.tracing.save()); From dd73fc9916df660fbadb281c430b78e99bf37b78 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 16:21:05 +0200 Subject: [PATCH 52/92] clean up --- frontend/javascripts/viewer/api/wk_dev.ts | 3 ++ .../update_action_application/skeleton.ts | 7 +-- .../update_action_application/volume.ts | 7 +-- .../reducers/volumetracing_reducer_helpers.ts | 2 +- .../viewer/model/sagas/mapping_saga.ts | 9 ++-- .../viewer/model/sagas/save_saga.ts | 48 ++++++++++--------- .../dataset_info_tab_view.tsx | 4 +- 7 files changed, 41 insertions(+), 39 deletions(-) diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index 9a28e7f959b..dd5f4559f6c 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -27,6 +27,9 @@ export const WkDevFlags = { // it needs to be set to true before the rendering is initialized. disableLayerNameSanitization: false, }, + debugging: { + showCurrentVersionInInfoTab: false, + }, meshing: { marchingCubeSizeInTargetMag: [64, 64, 64] as Vector3, }, diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 5a2d18be7ef..628375a07f4 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -140,13 +140,14 @@ export function applySkeletonUpdateActionsFromServer( const { treeId, source, target } = ua.value; // eslint-disable-next-line no-loop-func if (newState.annotation.skeleton == null) { - continue; + throw new Error("Could not apply update action because no skeleton exists."); } const tree = getTree(newState.annotation.skeleton, treeId); if (tree == null) { - // todop: escalate error? - continue; + throw new Error( + `Could not apply update action because tree with id=${treeId} was not found.`, + ); } const newEdge = { source, diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 58b50121405..92e53937968 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -26,12 +26,7 @@ export function applyVolumeUpdateActionsFromServer( switch (ua.name) { case "updateLargestSegmentId": { const volumeTracing = getVolumeTracingById(newState.annotation, ua.value.actionTracingId); - newState = setLargestSegmentIdReducer( - newState, - volumeTracing, - // todop: can this really be null? if so, what should we do? - ua.value.largestSegmentId ?? 0, - ); + newState = setLargestSegmentIdReducer(newState, volumeTracing, ua.value.largestSegmentId); break; } case "createSegment": diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer_helpers.ts index f79911522e6..1a8bce01e68 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer_helpers.ts @@ -151,7 +151,7 @@ export function setContourTracingModeReducer( export function setLargestSegmentIdReducer( state: WebknossosState, volumeTracing: VolumeTracing, - id: number, + id: number | null, ) { return updateVolumeTracing(state, volumeTracing.tracingId, { largestSegmentId: id, diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts index c33f77e61b0..05f8a972b51 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts @@ -78,6 +78,8 @@ import { ensureWkReady } from "./ready_sagas"; type APIMappings = Record; type Container = { value: T }; +const BUCKET_WATCHING_THROTTLE_DELAY = process.env.IS_TESTING ? 5 : 500; + const takeLatestMappingChange = ( oldActiveMappingByLayer: Container>, layerName: string, @@ -229,7 +231,7 @@ function* watchChangedBucketsForLayer(layerName: string): Saga { const dataCube = yield* call([Model, Model.getCubeByLayerName], layerName); const bucketChannel = yield* call(createBucketDataChangedChannel, dataCube); - // todop: remove again? + // todop: remove again? currently only exists for the tests yield* call(handler); while (true) { @@ -238,10 +240,7 @@ function* watchChangedBucketsForLayer(layerName: string): Saga { // However, let's throttle¹ this by waiting and then discarding all other events // that might have accumulated in between. - // todop - const throttleDelay = process.env.IS_TESTING ? 5 : 500; - - yield* call(sleep, throttleDelay); + yield* call(sleep, BUCKET_WATCHING_THROTTLE_DELAY); yield flush(bucketChannel); // After flushing and while the handler below is running, // the bucketChannel might fill up again. This means, the diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index d44a8b45d52..6e4473ff7de 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -592,26 +592,28 @@ function* watchForSaveConflicts(): Saga { const { url: tracingStoreUrl } = yield* select((state) => state.annotation.tracingStore); - // The order is ascending in the version number ([v_n, v_(n+1), ...]). - const newerActions = yield* call( - getUpdateActionLog, - tracingStoreUrl, - annotationId, - versionOnClient + 1, - undefined, - true, - ); - - if (newerActions.length !== newerVersionCount) { - // todop: maybe default to showing the "please reload" toast - // as it's not critical here? - throw new Error("unexpected error"); - } + try { + // The order is ascending in the version number ([v_n, v_(n+1), ...]). + const newerActions = yield* call( + getUpdateActionLog, + tracingStoreUrl, + annotationId, + versionOnClient + 1, + undefined, + true, + ); - console.log("newerActions", newerActions); + if (newerActions.length !== newerVersionCount) { + throw new Error("Unexpected size of newer versions."); + } - if (yield* tryToIncorporateActions(newerActions)) { - return false; + console.log("Trying to incorporate newerActions", newerActions); + if ((yield* tryToIncorporateActions(newerActions)).success) { + return false; + } + } catch (exc) { + // Afterwards, the user will be asked to reload the page. + console.error("Error during application of update actions", exc); } const saveQueue = yield* select((state) => state.save.queue); @@ -682,7 +684,9 @@ function* watchForSaveConflicts(): Saga { } } -export function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): Saga { +export function* tryToIncorporateActions( + newerActions: APIUpdateActionBatch[], +): Saga<{ success: boolean }> { const refreshFunctionByTracing: Record Saga> = {}; function* finalize() { for (const fn of Object.values(refreshFunctionByTracing)) { @@ -854,9 +858,9 @@ export function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): case "updateVolumeTracing": case "updateUserBoundingBoxesInSkeletonTracing": case "updateUserBoundingBoxesInVolumeTracing": { - console.log("cannot apply action", action.name); + console.log("Cannot apply action", action.name); yield* call(finalize); - return false; + return { success: false }; } default: { action satisfies never; @@ -866,7 +870,7 @@ export function* tryToIncorporateActions(newerActions: APIUpdateActionBatch[]): yield* put(setVersionNumberAction(actionBatch.version)); } yield* call(finalize); - return true; + return { success: true }; } export default [saveTracingAsync, watchForSaveConflicts]; diff --git a/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx b/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx index 2ca4d1e5ad4..1ddbdbbdfbb 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/dataset_info_tab_view.tsx @@ -35,6 +35,7 @@ import { useWkSelector } from "libs/react_hooks"; import { mayUserEditDataset, pluralize, safeNumberToStr } from "libs/utils"; import messages from "messages"; import type { EmptyObject } from "types/globals"; +import { WkDevFlags } from "viewer/api/wk_dev"; import { mayEditAnnotationProperties } from "viewer/model/accessors/annotation_accessor"; import { formatUserName } from "viewer/model/accessors/user_accessor"; import { getReadableNameForLayerName } from "viewer/model/accessors/volumetracing_accessor"; @@ -625,7 +626,7 @@ export class DatasetInfoTabView extends React.PureComponent { return (
- + {WkDevFlags.debugging.showCurrentVersionInInfoTab && } {this.getAnnotationName()} {this.getAnnotationDescription()} {this.getDatasetName()} @@ -655,7 +656,6 @@ export class DatasetInfoTabView extends React.PureComponent { } } -// todop: remove again function DebugInfo() { const versionOnClient = useWkSelector((state) => { return state.annotation.version; From 6d515db2820212c75ca9e8ce1aa7c0ebbd804893 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 19 Jun 2025 16:23:32 +0200 Subject: [PATCH 53/92] update changelog --- unreleased_changes/8648.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 unreleased_changes/8648.md diff --git a/unreleased_changes/8648.md b/unreleased_changes/8648.md new file mode 100644 index 00000000000..dc1d665f4dc --- /dev/null +++ b/unreleased_changes/8648.md @@ -0,0 +1,2 @@ +### Added +- When you are viewing an annotation and another user changes that annotation, these changes will be automatically shown. For some changes (e.g., when adding a new annotation layer), you will still need to reload the page, but most of the time WEBKNOSSOS will update the annotation automatically. From 302d078a851df4b6738ee3a54bb1b70fdacbcb55 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 20 Jun 2025 10:24:16 +0200 Subject: [PATCH 54/92] speed up tests by reducing sleep in ensureSavedState --- frontend/javascripts/viewer/model.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model.ts b/frontend/javascripts/viewer/model.ts index 9e7946fd959..01297e60732 100644 --- a/frontend/javascripts/viewer/model.ts +++ b/frontend/javascripts/viewer/model.ts @@ -26,7 +26,9 @@ import Deferred from "libs/async/deferred"; import { globalToLayerTransformedPosition } from "./model/accessors/dataset_layer_transformation_accessor"; import { initialize } from "./model_initialization"; -// TODO: Non-reactive +const WAIT_AFTER_SAVE_TRIGGER = process.env.IS_TESTING ? 5 : 500; + +// TODO: This class should be moved into the store and sagas. export class WebKnossosModel { // @ts-expect-error ts-migrate(2564) FIXME: Property 'dataLayers' has no initializer and is no... Remove this comment to see the full error message dataLayers: Record; @@ -385,7 +387,7 @@ export class WebKnossosModel { Store.dispatch(saveNowAction()); } - await Utils.sleep(500); + await Utils.sleep(WAIT_AFTER_SAVE_TRIGGER); } }; From cb645d0a66d5ba42448aebcaa3d6719448bda8cd Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 20 Jun 2025 11:46:48 +0200 Subject: [PATCH 55/92] bump timeouts a bit --- frontend/javascripts/test/model/cuckoo_table.spec.ts | 2 +- frontend/javascripts/test/model/cuckoo_table_uint32.spec.ts | 2 +- frontend/javascripts/test/model/cuckoo_table_uint64.spec.ts | 2 +- frontend/javascripts/test/model/cuckoo_table_vec5.spec.ts | 2 +- frontend/javascripts/viewer/model/edge_collection.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/test/model/cuckoo_table.spec.ts b/frontend/javascripts/test/model/cuckoo_table.spec.ts index 8d1f6a1feb8..bd1e9521cb2 100644 --- a/frontend/javascripts/test/model/cuckoo_table.spec.ts +++ b/frontend/javascripts/test/model/cuckoo_table.spec.ts @@ -106,7 +106,7 @@ describe("CuckooTableVec3", () => { }).toThrow(); }); - it("Maxing out capacity", { timeout: 20000 }, () => { + it("Maxing out capacity", { timeout: 25000 }, () => { const textureWidth = 128; const attemptCount = 10; for (let attempt = 0; attempt < attemptCount; attempt++) { diff --git a/frontend/javascripts/test/model/cuckoo_table_uint32.spec.ts b/frontend/javascripts/test/model/cuckoo_table_uint32.spec.ts index c74a2e9992e..5997bf30cd5 100644 --- a/frontend/javascripts/test/model/cuckoo_table_uint32.spec.ts +++ b/frontend/javascripts/test/model/cuckoo_table_uint32.spec.ts @@ -27,7 +27,7 @@ function isValueEqual(val1: Value, val2: Value | null) { } describe("CuckooTableUint32", () => { - it("Maxing out capacity", { timeout: 20000 }, () => { + it("Maxing out capacity", { timeout: 25000 }, () => { const textureWidth = 128; const attemptCount = 10; diff --git a/frontend/javascripts/test/model/cuckoo_table_uint64.spec.ts b/frontend/javascripts/test/model/cuckoo_table_uint64.spec.ts index 1a53df53593..5510609a5ae 100644 --- a/frontend/javascripts/test/model/cuckoo_table_uint64.spec.ts +++ b/frontend/javascripts/test/model/cuckoo_table_uint64.spec.ts @@ -29,7 +29,7 @@ function isValueEqual(val1: Value, val2: Value | null) { } describe("CuckooTableUint64", () => { - it("Maxing out capacity", { timeout: 20000 }, () => { + it("Maxing out capacity", { timeout: 25000 }, () => { const textureWidth = 128; const attemptCount = 10; for (let attempt = 0; attempt < attemptCount; attempt++) { diff --git a/frontend/javascripts/test/model/cuckoo_table_vec5.spec.ts b/frontend/javascripts/test/model/cuckoo_table_vec5.spec.ts index 76d17df5665..7fa9885e27a 100644 --- a/frontend/javascripts/test/model/cuckoo_table_vec5.spec.ts +++ b/frontend/javascripts/test/model/cuckoo_table_vec5.spec.ts @@ -116,7 +116,7 @@ describe("CuckooTableVec5", () => { }).toThrow(); }); - it("Maxing out capacity", { timeout: 20000 }, () => { + it("Maxing out capacity", { timeout: 25000 }, () => { const textureWidth = 128; const attemptCount = 10; for (let attempt = 0; attempt < attemptCount; attempt++) { diff --git a/frontend/javascripts/viewer/model/edge_collection.ts b/frontend/javascripts/viewer/model/edge_collection.ts index 7fef5216f59..6066bd9383e 100644 --- a/frontend/javascripts/viewer/model/edge_collection.ts +++ b/frontend/javascripts/viewer/model/edge_collection.ts @@ -96,7 +96,7 @@ export default class EdgeCollection implements NotEnumerableByObject { yield* this.values(); } - *values(): MapIterator { + *values(): Generator { for (const edgeArray of this.outMap.values()) { for (const edge of edgeArray) { yield edge; From e66e72f06ce49a542b823970c3c9bdcf02c8dbb6 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 20 Jun 2025 14:06:17 +0200 Subject: [PATCH 56/92] add test for reloading bucket if it changed on the server --- ...olumetracing_remote_bucket_updates.spec.ts | 84 +++++++++++++++++++ .../viewer/model/sagas/update_actions.ts | 9 +- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts new file mode 100644 index 00000000000..31146bae0a3 --- /dev/null +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts @@ -0,0 +1,84 @@ +import { + createBucketResponseFunction, + setupWebknossosForTesting, + type WebknossosTestContext, +} from "test/helpers/apiHelpers"; +import { call } from "typed-redux-saga"; +import type { Vector3 } from "viewer/constants"; +import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; +import { tryToIncorporateActions } from "viewer/model/sagas/save_saga"; +import { startSaga } from "viewer/store"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Volume Tracing with remote updates", () => { + beforeEach(async (context) => { + await setupWebknossosForTesting(context, "volume"); + }); + + afterEach(async (context) => { + expect(hasRootSagaCrashed()).toBe(false); + // Saving after each test and checking that the root saga didn't crash, + // ensures that each test is cleanly exited. Without it weird output can + // occur (e.g., a promise gets resolved which interferes with the next test). + await context.api.tracing.save(); + expect(hasRootSagaCrashed()).toBe(false); + context.tearDownPullQueues(); + }); + + it("A bucket should automatically be reloaded if newer data exists on the server", async ({ + api, + mocks, + }) => { + const oldCellId = 11; + + vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( + createBucketResponseFunction(Uint16Array, oldCellId, 5), + ); + + // Reload buckets which might have already been loaded before swapping the sendJSONReceiveArraybufferWithHeaders + // function. + await api.data.reloadAllBuckets(); + + const task = startSaga(function* () { + const position = [0, 0, 0] as Vector3; + const newCellId = 2; + const volumeTracingLayerName = api.data.getVolumeTracingLayerIds()[0]; + + expect( + yield call(() => api.data.getDataValue(volumeTracingLayerName, position)), + "Initially, there should be oldCellId", + ).toBe(oldCellId); + + // Already prepare the updated backend response. + vi.mocked(mocks.Request).sendJSONReceiveArraybufferWithHeaders.mockImplementation( + createBucketResponseFunction(Uint16Array, newCellId, 5), + ); + + yield tryToIncorporateActions([ + { + version: 1, + value: [ + { + name: "updateBucket", + value: { + actionTracingId: "volumeTracingId", + actionTimestamp: 0, + position, + additionalCoordinates: undefined, + mag: [1, 1, 1], + cubeSize: 1024, + base64Data: undefined, // The server will not send this, either. + }, + }, + ], + }, + ]); + + expect(yield call(() => api.data.getDataValue(volumeTracingLayerName, position))).toBe( + newCellId, + ); + }); + + await task.toPromise(); + }); +}); diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/update_actions.ts index 957c2b7c1f7..6688319377d 100644 --- a/frontend/javascripts/viewer/model/sagas/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/update_actions.ts @@ -750,12 +750,19 @@ export function updateBucket( base64Data: string, actionTracingId: string, ) { + if (base64Data == null) { + throw new Error("Invalid updateBucket action."); + } return { name: "updateBucket", value: { actionTracingId, ...bucketInfo, - base64Data, + // The frontend should always send base64Data. However, + // the return type of this function is also used for the + // update actions that can be retrieved from the server. + // In that case, the value will always be undefined. + base64Data: base64Data as string | undefined, }, } as const; } From 78833e5b12e1f0c2a100c555772fdb68f5efe013 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 20 Jun 2025 14:16:14 +0200 Subject: [PATCH 57/92] bump save delay a bit to fix flaky test --- frontend/javascripts/viewer/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/viewer/model.ts b/frontend/javascripts/viewer/model.ts index 01297e60732..ad624437481 100644 --- a/frontend/javascripts/viewer/model.ts +++ b/frontend/javascripts/viewer/model.ts @@ -26,7 +26,7 @@ import Deferred from "libs/async/deferred"; import { globalToLayerTransformedPosition } from "./model/accessors/dataset_layer_transformation_accessor"; import { initialize } from "./model_initialization"; -const WAIT_AFTER_SAVE_TRIGGER = process.env.IS_TESTING ? 5 : 500; +const WAIT_AFTER_SAVE_TRIGGER = process.env.IS_TESTING ? 50 : 500; // TODO: This class should be moved into the store and sagas. export class WebKnossosModel { From 42c3ebb703b2bfd410da81e97a3741c362b8b66d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 14:21:07 +0200 Subject: [PATCH 58/92] use ViewModeValues constant where possible --- .../test/fixtures/hybridtracing_server_objects.ts | 3 ++- .../test/fixtures/skeletontracing_server_objects.ts | 3 ++- .../javascripts/test/fixtures/tasktracing_server_objects.ts | 5 +++-- frontend/javascripts/viewer/default_state.ts | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts b/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts index c373d5e4e1f..bba15e9ba9c 100644 --- a/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/hybridtracing_server_objects.ts @@ -5,6 +5,7 @@ import { } from "types/api_types"; import { tracing as skeletonTracing } from "./skeletontracing_server_objects"; import { tracing as volumeTracing } from "./volumetracing_server_objects"; +import { ViewModeValues } from "viewer/constants"; export const tracings = [skeletonTracing, volumeTracing]; @@ -54,7 +55,7 @@ export const annotation: APIAnnotation = { url: "http://localhost:9000", }, settings: { - allowedModes: ["orthogonal", "oblique", "flight"], + allowedModes: ViewModeValues, branchPointsAllowed: true, somaClickingAllowed: true, volumeInterpolationAllowed: false, diff --git a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts index 30ba7d81d3c..ac22411b542 100644 --- a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts @@ -4,6 +4,7 @@ import { AnnotationLayerEnum, type APITracingStoreAnnotation, } from "types/api_types"; +import { ViewModeValues } from "viewer/constants"; const TRACING_ID = "skeletonTracingId-47e37793-d0be-4240-a371-87ce68561a13"; @@ -205,7 +206,7 @@ export const annotation: APIAnnotation = { url: "http://localhost:9000", }, settings: { - allowedModes: ["orthogonal", "oblique", "flight"], + allowedModes: ViewModeValues, branchPointsAllowed: true, somaClickingAllowed: true, volumeInterpolationAllowed: false, diff --git a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts index 3cb558c379a..9d00df3ac9e 100644 --- a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts @@ -4,6 +4,7 @@ import { AnnotationLayerEnum, type APITracingStoreAnnotation, } from "types/api_types"; +import { ViewModeValues } from "viewer/constants"; const TRACING_ID = "skeletonTracingId-e90133de-b2db-4912-8261-8b6f84f7edab"; export const tracing: ServerSkeletonTracing = { @@ -88,7 +89,7 @@ export const annotation: APIAnnotation = { teamId: "teamId-5b1e45f9a00000a000abc2c3", teamName: "Connectomics department", settings: { - allowedModes: ["orthogonal", "oblique", "flight"], + allowedModes: ViewModeValues, branchPointsAllowed: true, somaClickingAllowed: true, volumeInterpolationAllowed: false, @@ -146,7 +147,7 @@ export const annotation: APIAnnotation = { }, visibility: "Internal", settings: { - allowedModes: ["orthogonal", "oblique", "flight"], + allowedModes: ViewModeValues, branchPointsAllowed: true, somaClickingAllowed: true, volumeInterpolationAllowed: false, diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index 28886ce4bf8..10290a2d29c 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -9,6 +9,7 @@ import Constants, { TDViewDisplayModeEnum, InterpolationModeEnum, UnitLong, + ViewModeValues, } from "viewer/constants"; import constants from "viewer/constants"; import { AnnotationTool, Toolkit } from "viewer/model/accessors/tool_accessor"; @@ -33,7 +34,7 @@ const initialAnnotationInfo = { somaClickingAllowed: false, mergerMode: false, volumeInterpolationAllowed: false, - allowedModes: ["orthogonal", "oblique", "flight"] as APIAllowedMode[], + allowedModes: ViewModeValues, magRestrictions: {}, }, visibility: "Internal" as APIAnnotationVisibility, From 1662292c0fcb4efb1a9ca6966d9ca244263df4b5 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 14:46:28 +0200 Subject: [PATCH 59/92] incorporate some feedback --- .../javascripts/test/helpers/apiHelpers.ts | 5 +- .../skeleton.spec.ts | 13 +- frontend/javascripts/viewer/default_state.ts | 2 +- .../model/reducers/skeletontracing_reducer.ts | 2 +- .../update_action_application/skeleton.ts | 597 +++++++++--------- .../update_action_application/volume.ts | 153 ++--- .../model/reducers/volumetracing_reducer.ts | 2 +- 7 files changed, 385 insertions(+), 389 deletions(-) diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index a041b914400..c7eec41556c 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -147,7 +147,10 @@ vi.mock("admin/rest_api.ts", async () => { _tracingId: string, _segmentsInfo: unknown, ): Promise> => { - throw new Error("Not yet mocked."); + // This simply serves as a preparation so that specs can mock the function + // when needed. Without this stub, it's harder to mock this specific function + // later. + throw new Error("No test has mocked the return value yet here."); }, ), }; diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index aed3767613d..db652ab7fdc 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -3,6 +3,7 @@ import _ from "lodash"; import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; import { initialState as defaultSkeletonState } from "test/fixtures/skeletontracing_object"; import { chainReduce } from "test/helpers/chainReducer"; +import { withoutUpdateActiveItemTracing } from "test/helpers/saveHelpers"; import type { Vector3 } from "viewer/constants"; import { enforceSkeletonTracing, @@ -230,8 +231,10 @@ describe("Update Action Application for SkeletonTracing", () => { const newState2 = applyActions(newState, [SkeletonTracingActions.deleteNodeAction(2)]); - const updateActions = Array.from( - diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + const updateActions = withoutUpdateActiveItemTracing( + Array.from( + diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + ), ) as ApplicableSkeletonUpdateAction[]; const newState3 = applyActions(newState, [ @@ -258,8 +261,10 @@ describe("Update Action Application for SkeletonTracing", () => { const newState2 = applyActions(newState, [SkeletonTracingActions.deleteTreeAction(2)]); - const updateActions = Array.from( - diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + const updateActions = withoutUpdateActiveItemTracing( + Array.from( + diffSkeletonTracing(newState.annotation.skeleton!, newState2.annotation.skeleton!), + ), ) as ApplicableSkeletonUpdateAction[]; const newState3 = applyActions(newState, [ diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index 10290a2d29c..bb173defcef 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -1,5 +1,5 @@ import { getSystemColorTheme } from "theme"; -import type { APIAllowedMode, APIAnnotationType, APIAnnotationVisibility } from "types/api_types"; +import type { APIAnnotationType, APIAnnotationVisibility } from "types/api_types"; import { defaultDatasetViewConfiguration } from "types/schemas/dataset_view_configuration.schema"; import Constants, { ControlModeEnum, diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 43c6e0d273c..93e7fee2297 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -653,7 +653,7 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos case "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - return applySkeletonUpdateActionsFromServer(SkeletonTracingReducer, actions, state).value; + return applySkeletonUpdateActionsFromServer(SkeletonTracingReducer, actions, state); } default: // pass diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 628375a07f4..8f6b8b14ca7 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -19,354 +19,335 @@ import { export function applySkeletonUpdateActionsFromServer( SkeletonTracingReducer: Reducer, actions: ApplicableSkeletonUpdateAction[], - newState: WebknossosState, -): { value: WebknossosState } { + state: WebknossosState, +): WebknossosState { + let newState = state; for (const ua of actions) { - switch (ua.name) { - case "createTree": { - const { id, updatedId: _updatedId, actionTracingId: _actionTracingId, ...rest } = ua.value; - const newTree: Tree = { - treeId: id, - ...rest, - nodes: new DiffableMap(), - edges: new EdgeCollection(), - }; - const newTrees = enforceSkeletonTracing(newState.annotation).trees.set(id, newTree); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, - }, - }, - }); - break; - } - case "updateTree": { - const { - id: treeId, - actionTracingId: _actionTracingId, - updatedId: _updatedId, - ...treeRest - } = ua.value; - const skeleton = enforceSkeletonTracing(newState.annotation); - const tree = getTree(skeleton, treeId); - if (tree == null) { - throw new Error("Could not create node because tree was not found."); - } - const newTree = { ...tree, ...treeRest }; - const newTrees = skeleton.trees.set(newTree.treeId, newTree); - newState = update(newState, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, + newState = applySingleAction(SkeletonTracingReducer, ua, newState); + } + + return newState; +} + +function applySingleAction( + SkeletonTracingReducer: Reducer, + ua: ApplicableSkeletonUpdateAction, + state: WebknossosState, +): WebknossosState { + switch (ua.name) { + case "createTree": { + const { id, updatedId: _updatedId, actionTracingId: _actionTracingId, ...rest } = ua.value; + const newTree: Tree = { + treeId: id, + ...rest, + nodes: new DiffableMap(), + edges: new EdgeCollection(), + }; + const newTrees = enforceSkeletonTracing(state.annotation).trees.set(id, newTree); + + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, }, }, - }); - break; + }, + }); + } + case "updateTree": { + const { + id: treeId, + actionTracingId: _actionTracingId, + updatedId: _updatedId, + ...treeRest + } = ua.value; + const skeleton = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeleton, treeId); + if (tree == null) { + throw new Error("Could not create node because tree was not found."); } - case "createNode": { - const { treeId, ...serverNode } = ua.value; - const { - position: untransformedPosition, - resolution: mag, - actionTracingId: _actionTracingId, - ...node - } = serverNode; - const clientNode = { untransformedPosition, mag, ...node }; - - const skeleton = enforceSkeletonTracing(newState.annotation); - const tree = getTree(skeleton, treeId); - if (tree == null) { - throw new Error("Could not create node because tree was not found."); - } - const diffableNodeMap = tree.nodes; - const newDiffableMap = diffableNodeMap.set(node.id, clientNode); - const newTree = update(tree, { - nodes: { $set: newDiffableMap }, - }); - const newTrees = skeleton.trees.set(newTree.treeId, newTree); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, - cachedMaxNodeId: { $set: getMaximumNodeId(newTrees) }, + const newTree = { ...tree, ...treeRest }; + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, }, }, - }); - break; + }, + }); + } + case "createNode": { + const { treeId, ...serverNode } = ua.value; + const { + position: untransformedPosition, + resolution: mag, + actionTracingId: _actionTracingId, + ...node + } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const skeleton = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeleton, treeId); + if (tree == null) { + throw new Error("Could not create node because tree was not found."); } - case "updateNode": { - const { treeId, ...serverNode } = ua.value; - const { - position: untransformedPosition, - actionTracingId: _actionTracingId, - mag, - ...node - } = serverNode; - const clientNode = { untransformedPosition, mag, ...node }; - - const skeleton = enforceSkeletonTracing(newState.annotation); - const tree = getTree(skeleton, treeId); - if (tree == null) { - throw new Error("Could not update node because tree was not found."); - } - const diffableNodeMap = tree.nodes; - const newDiffableMap = diffableNodeMap.set(node.id, clientNode); - const newTree = update(tree, { - nodes: { $set: newDiffableMap }, - }); - const newTrees = skeleton.trees.set(newTree.treeId, newTree); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, }, + cachedMaxNodeId: { $set: getMaximumNodeId(newTrees) }, }, - }); - break; + }, + }); + } + case "updateNode": { + const { treeId, ...serverNode } = ua.value; + const { + position: untransformedPosition, + actionTracingId: _actionTracingId, + mag, + ...node + } = serverNode; + const clientNode = { untransformedPosition, mag, ...node }; + + const skeleton = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeleton, treeId); + if (tree == null) { + throw new Error("Could not update node because tree was not found."); } - case "createEdge": { - const { treeId, source, target } = ua.value; - // eslint-disable-next-line no-loop-func - if (newState.annotation.skeleton == null) { - throw new Error("Could not apply update action because no skeleton exists."); - } - - const tree = getTree(newState.annotation.skeleton, treeId); - if (tree == null) { - throw new Error( - `Could not apply update action because tree with id=${treeId} was not found.`, - ); - } - const newEdge = { - source, - target, - }; - const edges = tree.edges.addEdge(newEdge); - const newTree = update(tree, { edges: { $set: edges } }); - const newTrees = newState.annotation.skeleton.trees.set(tree.treeId, newTree); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, + const diffableNodeMap = tree.nodes; + const newDiffableMap = diffableNodeMap.set(node.id, clientNode); + const newTree = update(tree, { + nodes: { $set: newDiffableMap }, + }); + const newTrees = skeleton.trees.set(newTree.treeId, newTree); + + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, }, }, - }); - break; + }, + }); + } + case "createEdge": { + const { treeId, source, target } = ua.value; + // eslint-disable-next-line no-loop-func + if (state.annotation.skeleton == null) { + throw new Error("Could not apply update action because no skeleton exists."); } - case "deleteTree": { - const { id } = ua.value; - const skeleton = enforceSkeletonTracing(newState.annotation); - const updatedTrees = skeleton.trees.delete(id); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { $set: updatedTrees }, - cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, - }, - }, - }); - break; + const tree = getTree(state.annotation.skeleton, treeId); + if (tree == null) { + throw new Error( + `Could not apply update action because tree with id=${treeId} was not found.`, + ); } - case "moveTreeComponent": { - // Use the _ prefix to ensure that the following code rather - // uses the nodeIdSet. - const { nodeIds: _nodeIds, sourceId, targetId } = ua.value; - const nodeIdSet = new Set(_nodeIds); - - const skeleton = enforceSkeletonTracing(newState.annotation); - const sourceTree = getTree(skeleton, sourceId); - const targetTree = getTree(skeleton, targetId); - - if (!sourceTree || !targetTree) { - throw new Error("Source or target tree not found."); - } - - // Separate moved and remaining nodes - const movedNodeEntries = sourceTree.nodes - .entries() - .filter(([id]) => nodeIdSet.has(id)) - .toArray(); - const remainingNodeEntries = sourceTree.nodes - .entries() - .filter(([id]) => !nodeIdSet.has(id)) - .toArray(); - - // Separate moved and remaining edges - const movedEdges = sourceTree.edges - .toArray() - .filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target)); - const remainingEdges = sourceTree.edges - .toArray() - .filter((e) => !(nodeIdSet.has(e.source) && nodeIdSet.has(e.target))); - - // Create updated source tree - const updatedSourceTree = { - ...sourceTree, - nodes: new DiffableMap(remainingNodeEntries), - edges: new EdgeCollection().addEdges(remainingEdges), - }; - - // Create updated target tree - const updatedTargetNodes = targetTree.nodes.clone(); - for (const [id, node] of movedNodeEntries) { - updatedTargetNodes.mutableSet(id, node); - } - - const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges, true); - - const updatedTargetTree = { - ...targetTree, - nodes: updatedTargetNodes, - edges: updatedTargetEdges, - }; - - const updatedTrees = skeleton.trees - .set(sourceId, updatedSourceTree) - .set(targetId, updatedTargetTree); - - newState = update(newState, { - annotation: { - skeleton: { - trees: { $set: updatedTrees }, + const newEdge = { + source, + target, + }; + const edges = tree.edges.addEdge(newEdge); + const newTree = update(tree, { edges: { $set: edges } }); + const newTrees = state.annotation.skeleton.trees.set(tree.treeId, newTree); + + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, }, }, - }); + }, + }); + } + case "deleteTree": { + const { id } = ua.value; + const skeleton = enforceSkeletonTracing(state.annotation); + const updatedTrees = skeleton.trees.delete(id); + + return update(state, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, + }, + }, + }); + } + case "moveTreeComponent": { + // Use the _ prefix to ensure that the following code rather + // uses the nodeIdSet. + const { nodeIds: _nodeIds, sourceId, targetId } = ua.value; + const nodeIdSet = new Set(_nodeIds); + + const skeleton = enforceSkeletonTracing(state.annotation); + const sourceTree = getTree(skeleton, sourceId); + const targetTree = getTree(skeleton, targetId); + + if (!sourceTree || !targetTree) { + throw new Error("Source or target tree not found."); + } - break; + // Separate moved and remaining nodes + const movedNodeEntries = sourceTree.nodes + .entries() + .filter(([id]) => nodeIdSet.has(id)) + .toArray(); + const remainingNodeEntries = sourceTree.nodes + .entries() + .filter(([id]) => !nodeIdSet.has(id)) + .toArray(); + + // Separate moved and remaining edges + const movedEdges = sourceTree.edges + .toArray() + .filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target)); + const remainingEdges = sourceTree.edges + .toArray() + .filter((e) => !(nodeIdSet.has(e.source) && nodeIdSet.has(e.target))); + + // Create updated source tree + const updatedSourceTree = { + ...sourceTree, + nodes: new DiffableMap(remainingNodeEntries), + edges: new EdgeCollection().addEdges(remainingEdges), + }; + + // Create updated target tree + const updatedTargetNodes = targetTree.nodes.clone(); + for (const [id, node] of movedNodeEntries) { + updatedTargetNodes.mutableSet(id, node); } - case "deleteEdge": { - const { treeId, source, target } = ua.value; + const updatedTargetEdges = targetTree.edges.clone().addEdges(movedEdges, true); - const skeleton = enforceSkeletonTracing(newState.annotation); - const tree = getTree(skeleton, treeId); + const updatedTargetTree = { + ...targetTree, + nodes: updatedTargetNodes, + edges: updatedTargetEdges, + }; - if (!tree) { - throw new Error("Source or target tree not found."); - } + const updatedTrees = skeleton.trees + .set(sourceId, updatedSourceTree) + .set(targetId, updatedTargetTree); - const updatedTree = { - ...tree, - edges: tree.edges.removeEdge({ source, target }), - }; + return update(state, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + }, + }, + }); + } - const updatedTrees = skeleton.trees.set(treeId, updatedTree); + case "deleteEdge": { + const { treeId, source, target } = ua.value; - newState = update(newState, { - annotation: { - skeleton: { - trees: { $set: updatedTrees }, - }, - }, - }); + const skeleton = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeleton, treeId); - break; + if (!tree) { + throw new Error("Source or target tree not found."); } - case "deleteNode": { - const { treeId, nodeId } = ua.value; + const updatedTree = { + ...tree, + edges: tree.edges.removeEdge({ source, target }), + }; + + const updatedTrees = skeleton.trees.set(treeId, updatedTree); + + return update(state, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + }, + }, + }); + } + + case "deleteNode": { + const { treeId, nodeId } = ua.value; - const skeleton = enforceSkeletonTracing(newState.annotation); - const tree = getTree(skeleton, treeId); + const skeleton = enforceSkeletonTracing(state.annotation); + const tree = getTree(skeleton, treeId); - if (!tree) { - throw new Error("Source or target tree not found."); - } + if (!tree) { + throw new Error("Source or target tree not found."); + } - const updatedTree = { - ...tree, - nodes: tree.nodes.delete(nodeId), - }; + const updatedTree = { + ...tree, + nodes: tree.nodes.delete(nodeId), + }; - const updatedTrees = skeleton.trees.set(treeId, updatedTree); + const updatedTrees = skeleton.trees.set(treeId, updatedTree); - const newActiveNodeId = skeleton.activeNodeId === nodeId ? null : nodeId; + const newActiveNodeId = skeleton.activeNodeId === nodeId ? null : nodeId; - newState = update(newState, { - annotation: { - skeleton: { - trees: { $set: updatedTrees }, - cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, - activeNodeId: { $set: newActiveNodeId }, - }, + return update(state, { + annotation: { + skeleton: { + trees: { $set: updatedTrees }, + cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, + activeNodeId: { $set: newActiveNodeId }, }, - }); - - break; - } + }, + }); + } - case "updateTreeGroups": { - newState = SkeletonTracingReducer(newState, setTreeGroupsAction(ua.value.treeGroups)); - break; - } + case "updateTreeGroups": { + return SkeletonTracingReducer(state, setTreeGroupsAction(ua.value.treeGroups)); + } - case "updateTreeGroupsExpandedState": { - // changes to user specific state does not need to be reacted to - break; - } + case "updateTreeGroupsExpandedState": { + // changes to user specific state does not need to be reacted to + return state; + } - case "updateTreeEdgesVisibility": { - newState = SkeletonTracingReducer( - newState, - setTreeEdgeVisibilityAction(ua.value.treeId, ua.value.edgesAreVisible), - ); - break; - } + case "updateTreeEdgesVisibility": { + return SkeletonTracingReducer( + state, + setTreeEdgeVisibilityAction(ua.value.treeId, ua.value.edgesAreVisible), + ); + } - case "updateUserBoundingBoxInSkeletonTracing": { - newState = applyUpdateUserBoundingBox( - newState, - enforceSkeletonTracing(newState.annotation), - ua, - ); - break; - } - case "addUserBoundingBoxInSkeletonTracing": { - newState = applyAddUserBoundingBox( - newState, - enforceSkeletonTracing(newState.annotation), - ua, - ); - break; - } - case "updateUserBoundingBoxVisibilityInSkeletonTracing": { - // Visibility updates are user-specific and don't need to be - // incorporated for the current user. - break; - } - case "deleteUserBoundingBoxInSkeletonTracing": { - newState = applyDeleteUserBoundingBox( - newState, - enforceSkeletonTracing(newState.annotation), - ua, - ); - break; - } - default: { - ua satisfies never; - } + case "updateUserBoundingBoxInSkeletonTracing": { + return applyUpdateUserBoundingBox(state, enforceSkeletonTracing(state.annotation), ua); + } + case "addUserBoundingBoxInSkeletonTracing": { + return applyAddUserBoundingBox(state, enforceSkeletonTracing(state.annotation), ua); + } + case "updateUserBoundingBoxVisibilityInSkeletonTracing": { + // Visibility updates are user-specific and don't need to be + // incorporated for the current user. + return state; + } + case "deleteUserBoundingBoxInSkeletonTracing": { + return applyDeleteUserBoundingBox(state, enforceSkeletonTracing(state.annotation), ua); + } + default: { + ua satisfies never; } } + ua satisfies never; - // The state is wrapped in this container object to prevent the above switch-cases from - // accidentally returning newState (which is common in reducers but would ignore - // remaining update actions here). - return { value: newState }; + console.log("update action name", ua.name); + // Satisfy TS. + throw new Error("Reached unexpected part of function."); } diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 92e53937968..1e9aa6ea348 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -16,85 +16,92 @@ import { export function applyVolumeUpdateActionsFromServer( actions: ApplicableVolumeUpdateAction[], - newState: WebknossosState, + state: WebknossosState, VolumeTracingReducer: ( state: WebknossosState, action: VolumeTracingReducerAction, ) => WebknossosState, -): { value: WebknossosState } { +): WebknossosState { + let newState = state; for (const ua of actions) { - switch (ua.name) { - case "updateLargestSegmentId": { - const volumeTracing = getVolumeTracingById(newState.annotation, ua.value.actionTracingId); - newState = setLargestSegmentIdReducer(newState, volumeTracing, ua.value.largestSegmentId); - break; - } - case "createSegment": - case "updateSegment": { - const { actionTracingId, ...originalSegment } = ua.value; - const { anchorPosition, ...segmentWithoutAnchor } = originalSegment; - const segment: Partial = { - somePosition: anchorPosition ?? undefined, - ...segmentWithoutAnchor, - }; - newState = VolumeTracingReducer( - newState, - updateSegmentAction(originalSegment.id, segment, actionTracingId), - ); - break; - } - case "deleteSegment": { - newState = VolumeTracingReducer( - newState, - removeSegmentAction(ua.value.id, ua.value.actionTracingId), - ); - break; - } - case "updateSegmentGroups": { - newState = VolumeTracingReducer( - newState, - setSegmentGroupsAction(ua.value.segmentGroups, ua.value.actionTracingId), - ); - break; - } - case "updateUserBoundingBoxInVolumeTracing": { - newState = applyUpdateUserBoundingBox( - newState, - getVolumeTracingById(newState.annotation, ua.value.actionTracingId), - ua, - ); - break; - } - case "addUserBoundingBoxInVolumeTracing": { - newState = applyAddUserBoundingBox( - newState, - getVolumeTracingById(newState.annotation, ua.value.actionTracingId), - ua, - ); - break; - } - case "deleteUserBoundingBoxInVolumeTracing": { - newState = applyDeleteUserBoundingBox( - newState, - getVolumeTracingById(newState.annotation, ua.value.actionTracingId), - ua, - ); - break; - } - case "updateSegmentGroupsExpandedState": - case "updateUserBoundingBoxVisibilityInVolumeTracing": { - // These update actions are user specific and don't need to be incorporated here - // because they are from another user. - break; - } - default: { - ua satisfies never; - } + newState = applySingleAction(ua, newState, VolumeTracingReducer); + } + + return newState; +} + +function applySingleAction( + ua: ApplicableVolumeUpdateAction, + state: WebknossosState, + VolumeTracingReducer: ( + state: WebknossosState, + action: VolumeTracingReducerAction, + ) => WebknossosState, +): WebknossosState { + switch (ua.name) { + case "updateLargestSegmentId": { + const volumeTracing = getVolumeTracingById(state.annotation, ua.value.actionTracingId); + return setLargestSegmentIdReducer(state, volumeTracing, ua.value.largestSegmentId); + } + case "createSegment": + case "updateSegment": { + const { actionTracingId, ...originalSegment } = ua.value; + const { anchorPosition, ...segmentWithoutAnchor } = originalSegment; + const segment: Partial = { + somePosition: anchorPosition ?? undefined, + ...segmentWithoutAnchor, + }; + return VolumeTracingReducer( + state, + updateSegmentAction(originalSegment.id, segment, actionTracingId), + ); + } + case "deleteSegment": { + return VolumeTracingReducer( + state, + removeSegmentAction(ua.value.id, ua.value.actionTracingId), + ); + } + case "updateSegmentGroups": { + return VolumeTracingReducer( + state, + setSegmentGroupsAction(ua.value.segmentGroups, ua.value.actionTracingId), + ); + } + case "updateUserBoundingBoxInVolumeTracing": { + return applyUpdateUserBoundingBox( + state, + getVolumeTracingById(state.annotation, ua.value.actionTracingId), + ua, + ); + } + case "addUserBoundingBoxInVolumeTracing": { + return applyAddUserBoundingBox( + state, + getVolumeTracingById(state.annotation, ua.value.actionTracingId), + ua, + ); + } + case "deleteUserBoundingBoxInVolumeTracing": { + return applyDeleteUserBoundingBox( + state, + getVolumeTracingById(state.annotation, ua.value.actionTracingId), + ua, + ); + } + case "updateSegmentGroupsExpandedState": + case "updateUserBoundingBoxVisibilityInVolumeTracing": { + // These update actions are user specific and don't need to be incorporated here + // because they are from another user. + return state; + } + default: { + ua satisfies never; } } - // The state is wrapped in this container object to prevent the above switch-cases from - // accidentally returning newState (which is common in reducers but would ignore - // remaining update actions here). - return { value: newState }; + ua satisfies never; + + // Satisfy TS. + throw new Error("Reached unexpected part of function."); } diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index 8b2bdd51a88..28ff50809ca 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -682,7 +682,7 @@ function VolumeTracingReducer( case "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - return applyVolumeUpdateActionsFromServer(actions, state, VolumeTracingReducer).value; + return applyVolumeUpdateActionsFromServer(actions, state, VolumeTracingReducer); } default: From 7b3adab3a7f2313835c2605ac98fa38964d9e138 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 15:11:04 +0200 Subject: [PATCH 60/92] read activeTreeId and activeNodeId directly from store instead of using accessor; fix nulling activeTreeId when it the active tree deleted --- .../reducers/update_action_application/skeleton.spec.ts | 9 ++++++--- .../model/reducers/update_action_application/skeleton.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index db652ab7fdc..0679ff40c8c 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -241,7 +241,8 @@ describe("Update Action Application for SkeletonTracing", () => { SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), ]); - expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); + const { activeNodeId } = enforceSkeletonTracing(newState3.annotation); + expect(activeNodeId).toBeNull(); }); it("should clear the active node and active tree if the active tree was deleted", () => { @@ -271,8 +272,10 @@ describe("Update Action Application for SkeletonTracing", () => { SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), ]); - expect(getActiveTree(enforceSkeletonTracing(newState3.annotation))).toBeNull(); - expect(getActiveNode(enforceSkeletonTracing(newState3.annotation))).toBeNull(); + const { activeTreeId, activeNodeId } = enforceSkeletonTracing(newState3.annotation); + + expect(activeNodeId).toBeNull(); + expect(activeTreeId).toBeNull(); }); afterAll(() => { diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 8f6b8b14ca7..bc5eac67457 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -180,11 +180,14 @@ function applySingleAction( const skeleton = enforceSkeletonTracing(state.annotation); const updatedTrees = skeleton.trees.delete(id); + const newActiveTreeId = skeleton.activeTreeId === id ? null : id; + return update(state, { annotation: { skeleton: { trees: { $set: updatedTrees }, cachedMaxNodeId: { $set: getMaximumNodeId(updatedTrees) }, + activeTreeId: { $set: newActiveTreeId }, }, }, }); @@ -347,7 +350,6 @@ function applySingleAction( } ua satisfies never; - console.log("update action name", ua.name); // Satisfy TS. throw new Error("Reached unexpected part of function."); } From 0928f6cb794f04b2ae146ea027d96cb291149f9d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:05:32 +0200 Subject: [PATCH 61/92] more feedback --- .../test/reducers/update_action_application/skeleton.spec.ts | 1 + frontend/javascripts/test/sagas/proofreading.spec.ts | 5 ++--- .../viewer/model/actions/skeletontracing_actions.tsx | 1 - .../viewer/model/actions/volumetracing_actions.ts | 1 - .../model/reducers/update_action_application/bounding_box.ts | 4 ++-- .../model/reducers/update_action_application/skeleton.ts | 4 +++- frontend/javascripts/viewer/model/sagas/update_actions.ts | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 0679ff40c8c..33e49b31a1c 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -279,6 +279,7 @@ describe("Update Action Application for SkeletonTracing", () => { }); afterAll(() => { + // Ensure that each possible action is included in the testing at least once expect(seenActionTypes).toEqual(new Set(actionNamesList)); }); }); diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index 8c52091dd9b..b3033c96d11 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -81,7 +81,6 @@ function mockInitialBucketAndAgglomerateData(context: WebknossosTestContext) { [5, 11], [6, 12], [7, 12], - [8, 13], [1337, 1337], ]); } @@ -255,7 +254,7 @@ describe("Proofreading", () => { await task.toPromise(); }, 8000); - it("should update the mapping when a the server has a new update action with a merge operation", async (context: WebknossosTestContext) => { + it("should update the mapping when the server has a new update action with a merge operation", async (context: WebknossosTestContext) => { const { api } = context; mockInitialBucketAndAgglomerateData(context); @@ -310,7 +309,7 @@ describe("Proofreading", () => { await task.toPromise(); }, 8000); - it("should update the mapping when a the server has a new update action with a split operation", async (context: WebknossosTestContext) => { + it("should update the mapping when the server has a new update action with a split operation", async (context: WebknossosTestContext) => { const { api, mocks } = context; mockInitialBucketAndAgglomerateData(context); diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index e9b2f4efc37..e517cda8f1d 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -172,7 +172,6 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_TREE_COLOR", "BATCH_UPDATE_GROUPS_AND_TREES", // Composited actions, only dispatched using `batchActions` ...AllUserBoundingBoxActions, - "APPLY_UPDATE_ACTIONS_FROM_SERVER", ]; export const noAction = () => diff --git a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts index 4fc193025af..7a3418ebc3e 100644 --- a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts @@ -138,7 +138,6 @@ export const VolumeTracingSaveRelevantActions = [ "TOGGLE_SEGMENT_GROUP", "TOGGLE_ALL_SEGMENTS", "SET_HIDE_UNREGISTERED_SEGMENTS", - "APPLY_VOLUME_UPDATE_ACTIONS_FROM_SERVER", ]; export const VolumeTracingUndoRelevantActions = ["START_EDITING", "COPY_SEGMENTATION_LAYER"]; diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts index a4b8288d9f3..b42d2bcfb6a 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -37,14 +37,14 @@ export function applyAddUserBoundingBox( ua: AddUserBoundingBoxInSkeletonTracingAction | AddUserBoundingBoxInVolumeTracingAction, ) { const { boundingBox, ...valueWithoutBoundingBox } = ua.value.boundingBox; - const maybeBoundingBoxValue = { + const boundingBoxValue = { boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBox), }; const newUserBBox: UserBoundingBox = { // The visibility is stored per user. Therefore, we default to true here. isVisible: true, ...valueWithoutBoundingBox, - ...maybeBoundingBoxValue, + ...boundingBoxValue, }; const updatedUserBoundingBoxes = tracing.userBoundingBoxes.concat([newUserBBox]); diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index bc5eac67457..1f992d96336 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -36,6 +36,7 @@ function applySingleAction( ): WebknossosState { switch (ua.name) { case "createTree": { + // updatedId is part of the updateAction format but was never really used. const { id, updatedId: _updatedId, actionTracingId: _actionTracingId, ...rest } = ua.value; const newTree: Tree = { treeId: id, @@ -59,6 +60,7 @@ function applySingleAction( const { id: treeId, actionTracingId: _actionTracingId, + // updatedId is part of the updateAction format but was never really used. updatedId: _updatedId, ...treeRest } = ua.value; @@ -180,7 +182,7 @@ function applySingleAction( const skeleton = enforceSkeletonTracing(state.annotation); const updatedTrees = skeleton.trees.delete(id); - const newActiveTreeId = skeleton.activeTreeId === id ? null : id; + const newActiveTreeId = skeleton.activeTreeId === id ? null : skeleton.activeTreeId; return update(state, { annotation: { diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/update_actions.ts index 6688319377d..90db0ec02e7 100644 --- a/frontend/javascripts/viewer/model/sagas/update_actions.ts +++ b/frontend/javascripts/viewer/model/sagas/update_actions.ts @@ -234,7 +234,7 @@ export function createTree(tree: Tree, actionTracingId: string) { value: { actionTracingId, id: tree.treeId, - updatedId: undefined, + updatedId: undefined, // was never really used, but is kept to keep the type information precise color: tree.color, name: tree.name, timestamp: tree.timestamp, From 81b5a60b049ddca1d31f4bf77bf61a0029af3380 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:20:26 +0200 Subject: [PATCH 62/92] more feedback --- .../volumetracing_saga_integration_1.spec.ts | 2 +- frontend/javascripts/viewer/api/api_latest.ts | 4 +- .../model/bucket_data_handling/data_cube.ts | 12 ++-- .../update_action_application/skeleton.ts | 61 +++++++++---------- .../viewer/model/sagas/proofread_saga.ts | 10 +-- .../viewer/model/sagas/save_saga.ts | 10 ++- .../javascripts/viewer/view/version_list.tsx | 2 +- 7 files changed, 49 insertions(+), 52 deletions(-) diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts index fd698777bb5..cdc05727bab 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts @@ -89,7 +89,7 @@ describe("Volume Tracing", () => { ); const cube = api.data.model.getCubeByLayerName(volumeTracingLayerName); - cube.collectAllBuckets(); + cube.removeAllBuckets(); await dispatchUndoAsync(Store.dispatch); diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 6c15d89a80a..786d95e5393 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -1638,7 +1638,7 @@ class DataApi { await Model.ensureSavedState(); } - dataLayer.cube.collectBucketsIf(predicateFn || truePredicate); + dataLayer.cube.removeBucketsIf(predicateFn || truePredicate); dataLayer.layerRenderingManager.refresh(); } }), @@ -1654,7 +1654,7 @@ class DataApi { } Utils.values(this.model.dataLayers).forEach((dataLayer: DataLayer) => { - dataLayer.cube.collectAllBuckets(); + dataLayer.cube.removeAllBuckets(); dataLayer.layerRenderingManager.refresh(); }); } diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index 95a149d77c4..343e610aaf7 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -409,7 +409,7 @@ class DataCube { } if (foundCollectibleBucket) { - this.collectBucket(this.buckets[this.bucketIterator]); + this.removeBucket(this.buckets[this.bucketIterator]); } else { const warnMessage = `More than ${this.buckets.length} buckets needed to be allocated.`; @@ -442,11 +442,11 @@ class DataCube { this.bucketIterator = (this.bucketIterator + 1) % (this.buckets.length + 1); } - collectAllBuckets(): void { - this.collectBucketsIf(() => true); + removeAllBuckets(): void { + this.removeBucketsIf(() => true); } - collectBucketsIf(predicateFn: (bucket: DataBucket) => boolean): void { + removeBucketsIf(predicateFn: (bucket: DataBucket) => boolean): void { // This method is always called in the context of reloading data. // All callers should ensure a saved state. This is encapsulated in the // api's reloadBuckets function that is used for most refresh-related @@ -479,7 +479,7 @@ class DataCube { false, ) ) { - this.collectBucket(bucket); + this.removeBucket(bucket); } else { notCollectedBuckets.push(bucket); } @@ -513,7 +513,7 @@ class DataCube { return valueSet; } - collectBucket(bucket: DataBucket): void { + removeBucket(bucket: DataBucket): void { const address = bucket.zoomedAddress; const [bucketIndex, cube] = this.getBucketIndexAndCube(address); diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 1f992d96336..43cd4309098 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -146,37 +146,6 @@ function applySingleAction( }, }); } - case "createEdge": { - const { treeId, source, target } = ua.value; - // eslint-disable-next-line no-loop-func - if (state.annotation.skeleton == null) { - throw new Error("Could not apply update action because no skeleton exists."); - } - - const tree = getTree(state.annotation.skeleton, treeId); - if (tree == null) { - throw new Error( - `Could not apply update action because tree with id=${treeId} was not found.`, - ); - } - const newEdge = { - source, - target, - }; - const edges = tree.edges.addEdge(newEdge); - const newTree = update(tree, { edges: { $set: edges } }); - const newTrees = state.annotation.skeleton.trees.set(tree.treeId, newTree); - - return update(state, { - annotation: { - skeleton: { - trees: { - $set: newTrees, - }, - }, - }, - }); - } case "deleteTree": { const { id } = ua.value; const skeleton = enforceSkeletonTracing(state.annotation); @@ -259,7 +228,37 @@ function applySingleAction( }, }); } + case "createEdge": { + const { treeId, source, target } = ua.value; + // eslint-disable-next-line no-loop-func + if (state.annotation.skeleton == null) { + throw new Error("Could not apply update action because no skeleton exists."); + } + + const tree = getTree(state.annotation.skeleton, treeId); + if (tree == null) { + throw new Error( + `Could not apply update action because tree with id=${treeId} was not found.`, + ); + } + const newEdge = { + source, + target, + }; + const edges = tree.edges.addEdge(newEdge); + const newTree = update(tree, { edges: { $set: edges } }); + const newTrees = state.annotation.skeleton.trees.set(tree.treeId, newTree); + return update(state, { + annotation: { + skeleton: { + trees: { + $set: newTrees, + }, + }, + }, + }); + } case "deleteEdge": { const { treeId, source, target } = ua.value; diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index f7d0aff2b1b..c776e470922 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -1333,14 +1333,14 @@ export function* updateMappingWithMerge( ); } -export function* updateMappingWithOmittedSplitPartners( +export function* removeAgglomerateFromActiveMapping( volumeTracingId: string, activeMapping: ActiveMappingInfo, - sourceAgglomerateId: number, + agglomerateId: number, ) { /* - * When sourceAgglomerateId was split, all segment ids that were mapped to sourceAgglomerateId, - * are removed from the activeMapping by this function. + * This function removes all super-voxels segments from the active mapping + * that map to the specified agglomerateId. */ const mappingEntries = Array.from(activeMapping.mapping as NumberLikeMap); @@ -1350,7 +1350,7 @@ export function* updateMappingWithOmittedSplitPartners( ? (el: number) => BigInt(el) : (el: number) => el; // If the mapping contains BigInts, we need a BigInt for the filtering - const comparableSourceAgglomerateId = adaptToType(sourceAgglomerateId); + const comparableSourceAgglomerateId = adaptToType(agglomerateId); const newMapping = new Map(); diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 6e4473ff7de..fb5eed584a6 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -75,7 +75,7 @@ import { getFlooredPosition, getRotation } from "../accessors/flycam_accessor"; import type { Action } from "../actions/actions"; import type { BatchedAnnotationInitializationAction } from "../actions/annotation_actions"; import { updateLocalHdf5Mapping } from "./mapping_saga"; -import { updateMappingWithMerge, updateMappingWithOmittedSplitPartners } from "./proofread_saga"; +import { updateMappingWithMerge, removeAgglomerateFromActiveMapping } from "./proofread_saga"; import { takeEveryWithBatchActionSupport } from "./saga_helpers"; const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; @@ -607,7 +607,6 @@ function* watchForSaveConflicts(): Saga { throw new Error("Unexpected size of newer versions."); } - console.log("Trying to incorporate newerActions", newerActions); if ((yield* tryToIncorporateActions(newerActions)).success) { return false; } @@ -695,7 +694,6 @@ export function* tryToIncorporateActions( } for (const actionBatch of newerActions) { for (const action of actionBatch.value) { - console.log("incorporating", action.name); switch (action.name) { ///////////// // Updates to user-specific state can be ignored: @@ -755,7 +753,7 @@ export function* tryToIncorporateActions( const bucket = cube.getBucket(bucketAddress); if (bucket != null && bucket.type !== "null") { - cube.collectBucket(bucket); + cube.removeBucket(bucket); dataLayer.layerRenderingManager.refresh(); } break; @@ -766,7 +764,7 @@ export function* tryToIncorporateActions( const cube = Model.getCubeByLayerName(actionTracingId); const dataLayer = Model.getLayerByName(actionTracingId); - cube.collectBucketsIf((bucket) => bucket.containsValue(id)); + cube.removeBucketsIf((bucket) => bucket.containsValue(id)); dataLayer.layerRenderingManager.refresh(); break; } @@ -804,7 +802,7 @@ export function* tryToIncorporateActions( store.temporaryConfiguration.activeMappingByLayer[action.value.actionTracingId], ); yield* call( - updateMappingWithOmittedSplitPartners, + removeAgglomerateFromActiveMapping, action.value.actionTracingId, activeMapping, action.value.agglomerateId, diff --git a/frontend/javascripts/viewer/view/version_list.tsx b/frontend/javascripts/viewer/view/version_list.tsx index 21f319e8c30..8a80937aaf9 100644 --- a/frontend/javascripts/viewer/view/version_list.tsx +++ b/frontend/javascripts/viewer/view/version_list.tsx @@ -80,7 +80,7 @@ export async function previewVersion(version?: number) { segmentationLayersToReload.push(...Model.getSegmentationTracingLayers()); for (const segmentationLayer of segmentationLayersToReload) { - segmentationLayer.cube.collectAllBuckets(); + segmentationLayer.cube.removeAllBuckets(); segmentationLayer.layerRenderingManager.refresh(); } } From 2ea03416aae611e81b10f15a030aaf40a08cc099 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:29:50 +0200 Subject: [PATCH 63/92] refresh layer in finalization step too --- .../viewer/model/sagas/save_saga.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index fb5eed584a6..b69e36a6fe2 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -686,9 +686,17 @@ function* watchForSaveConflicts(): Saga { export function* tryToIncorporateActions( newerActions: APIUpdateActionBatch[], ): Saga<{ success: boolean }> { - const refreshFunctionByTracing: Record Saga> = {}; + // After all actions were incorporated, volume buckets and hdf5 mappings + // are reloaded (if they exist and necessary). This is done as a + // "finalization step", because it requires that the newest version is set + // in the store annotation. Also, it only needs to happen once (instead of + // per action). + const updateLocalHdf5FunctionByTracing: Record void> = {}; + const refreshLayerFunctionByTracing: Record void> = {}; function* finalize() { - for (const fn of Object.values(refreshFunctionByTracing)) { + for (const fn of Object.values(updateLocalHdf5FunctionByTracing).concat( + Object.values(refreshLayerFunctionByTracing), + )) { yield* call(fn); } } @@ -754,7 +762,9 @@ export function* tryToIncorporateActions( const bucket = cube.getBucket(bucketAddress); if (bucket != null && bucket.type !== "null") { cube.removeBucket(bucket); - dataLayer.layerRenderingManager.refresh(); + refreshLayerFunctionByTracing[value.actionTracingId] = () => { + dataLayer.layerRenderingManager.refresh(); + }; } break; } @@ -765,7 +775,9 @@ export function* tryToIncorporateActions( const dataLayer = Model.getLayerByName(actionTracingId); cube.removeBucketsIf((bucket) => bucket.containsValue(id)); - dataLayer.layerRenderingManager.refresh(); + refreshLayerFunctionByTracing[value.actionTracingId] = () => { + dataLayer.layerRenderingManager.refresh(); + }; break; } case "updateLargestSegmentId": @@ -824,8 +836,8 @@ export function* tryToIncorporateActions( const dataset = yield* select((state) => state.dataset); const layerInfo = getLayerByName(dataset, layerName); - refreshFunctionByTracing[layerName] = function* (): Saga { - yield* call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName); + updateLocalHdf5FunctionByTracing[layerName] = () => { + updateLocalHdf5Mapping(layerName, layerInfo, mappingName); }; break; From 82e1682ba888c740427137a3e006f0cc77d173dd Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:30:29 +0200 Subject: [PATCH 64/92] swap param order for updateMappingWithMerge --- frontend/javascripts/viewer/model/sagas/proofread_saga.ts | 6 +++--- frontend/javascripts/viewer/model/sagas/save_saga.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index c776e470922..a5c0d3938ad 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -401,8 +401,8 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { updateMappingWithMerge, volumeTracingId, activeMapping, - targetAgglomerateId, sourceAgglomerateId, + targetAgglomerateId, ); } else if (action.type === "DELETE_EDGE") { if (sourceAgglomerateId !== targetAgglomerateId) { @@ -743,8 +743,8 @@ function* handleProofreadMergeOrMinCut(action: Action) { updateMappingWithMerge, volumeTracingId, activeMapping, - targetAgglomerateId, sourceAgglomerateId, + targetAgglomerateId, ); } else if (action.type === "MIN_CUT_AGGLOMERATE") { if (sourceInfo.unmappedId === targetInfo.unmappedId) { @@ -1317,8 +1317,8 @@ function mergeAgglomeratesInMapping( export function* updateMappingWithMerge( volumeTracingId: string, activeMapping: ActiveMappingInfo, - targetAgglomerateId: number, sourceAgglomerateId: number, + targetAgglomerateId: number, ) { const mergedMapping = yield* call( mergeAgglomeratesInMapping, diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index b69e36a6fe2..1af530972ef 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -803,8 +803,8 @@ export function* tryToIncorporateActions( updateMappingWithMerge, action.value.actionTracingId, activeMapping, - action.value.agglomerateId2, action.value.agglomerateId1, + action.value.agglomerateId2, ); break; } From f22bc56d2d4b7d4db06fde506d9a876f7f3b603a Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:30:57 +0200 Subject: [PATCH 65/92] also swap order for updateMappingWithMerge --- frontend/javascripts/viewer/model/sagas/proofread_saga.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts index a5c0d3938ad..09498c29ec7 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/proofread_saga.ts @@ -1297,8 +1297,8 @@ function* splitAgglomerateInMapping( function mergeAgglomeratesInMapping( activeMapping: ActiveMappingInfo, - targetAgglomerateId: number, sourceAgglomerateId: number, + targetAgglomerateId: number, ): Mapping { const adaptToType = activeMapping.mapping && isNumberMap(activeMapping.mapping) @@ -1323,8 +1323,8 @@ export function* updateMappingWithMerge( const mergedMapping = yield* call( mergeAgglomeratesInMapping, activeMapping, - targetAgglomerateId, sourceAgglomerateId, + targetAgglomerateId, ); yield* put( setMappingAction(volumeTracingId, activeMapping.mappingName, activeMapping.mappingType, { From d4353fa77f70ed23930b69e8ae21ca3a12a2e34c Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 16:48:14 +0200 Subject: [PATCH 66/92] more feedback --- frontend/javascripts/viewer/model/sagas/save_saga.ts | 2 +- frontend/javascripts/viewer/model_initialization.ts | 2 +- frontend/javascripts/viewer/store.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 1af530972ef..9242a8e3ad4 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -75,7 +75,7 @@ import { getFlooredPosition, getRotation } from "../accessors/flycam_accessor"; import type { Action } from "../actions/actions"; import type { BatchedAnnotationInitializationAction } from "../actions/annotation_actions"; import { updateLocalHdf5Mapping } from "./mapping_saga"; -import { updateMappingWithMerge, removeAgglomerateFromActiveMapping } from "./proofread_saga"; +import { removeAgglomerateFromActiveMapping, updateMappingWithMerge } from "./proofread_saga"; import { takeEveryWithBatchActionSupport } from "./saga_helpers"; const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 27d1944c670..14bb62291c6 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -498,7 +498,7 @@ function initializeDataset(initialFetch: boolean, dataset: StoreDataset): void { initializeAdditionalCoordinates(dataset); } -function initializeAdditionalCoordinates(dataset: APIDataset) { +function initializeAdditionalCoordinates(dataset: StoreDataset) { const unifiedAdditionalCoordinates = getUnifiedAdditionalCoordinates(dataset); const initialAdditionalCoordinates = Utils.values(unifiedAdditionalCoordinates).map( ({ name, bounds }) => ({ diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index d0b99cef4aa..f6d5587f60a 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -557,7 +557,7 @@ export type LocalSegmentationData = { }; export type StoreDataset = APIDataset & { - // The backend servers an APIDataset object. The frontend + // The backend serves an APIDataset object. The frontend // adds/merges volume tracing objects into that dataset. The // StoreDataset reflects this on a type level. For example, // one cannot accidentally use the APIDataset during store From a94a7a62b184a3ec6980110ebd1b100ebee32590 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 24 Jun 2025 17:00:44 +0200 Subject: [PATCH 67/92] fix incorrect invocation of saga --- frontend/javascripts/viewer/model/sagas/save_saga.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 9242a8e3ad4..f016f216241 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -691,8 +691,8 @@ export function* tryToIncorporateActions( // "finalization step", because it requires that the newest version is set // in the store annotation. Also, it only needs to happen once (instead of // per action). - const updateLocalHdf5FunctionByTracing: Record void> = {}; - const refreshLayerFunctionByTracing: Record void> = {}; + const updateLocalHdf5FunctionByTracing: Record unknown> = {}; + const refreshLayerFunctionByTracing: Record unknown> = {}; function* finalize() { for (const fn of Object.values(updateLocalHdf5FunctionByTracing).concat( Object.values(refreshLayerFunctionByTracing), @@ -836,8 +836,8 @@ export function* tryToIncorporateActions( const dataset = yield* select((state) => state.dataset); const layerInfo = getLayerByName(dataset, layerName); - updateLocalHdf5FunctionByTracing[layerName] = () => { - updateLocalHdf5Mapping(layerName, layerInfo, mappingName); + updateLocalHdf5FunctionByTracing[layerName] = function* () { + yield* call(updateLocalHdf5Mapping, layerName, layerInfo, mappingName); }; break; From bd46a5abd7f61ca1ae6a123330795ce64ddeb5d0 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 08:37:01 +0200 Subject: [PATCH 68/92] fix incorrect newActiveNodeId --- .../viewer/model/reducers/update_action_application/skeleton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index 43cd4309098..e978b734f73 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -302,7 +302,7 @@ function applySingleAction( const updatedTrees = skeleton.trees.set(treeId, updatedTree); - const newActiveNodeId = skeleton.activeNodeId === nodeId ? null : nodeId; + const newActiveNodeId = skeleton.activeNodeId === nodeId ? null : skeleton.activeNodeId; return update(state, { annotation: { From d8dbc6994961d65de33db336f8e8f2688c4a22d1 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 16:32:28 +0200 Subject: [PATCH 69/92] remove one todo and make watchChangedBucketsForLayer a bit clearer --- .../viewer/model/sagas/mapping_saga.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts index 4b06fed3688..8f9d0689879 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/mapping_saga.ts @@ -228,36 +228,43 @@ function createBucketRetrievalSourceChannel(layerName: string) { } function* watchChangedBucketsForLayer(layerName: string): Saga { + /* + * This saga listens for changed bucket data and then triggers the updateLocalHdf5Mapping + * saga in an interruptible manner. See comments below for some rationale. + */ const dataCube = yield* call([Model, Model.getCubeByLayerName], layerName); const bucketChannel = yield* call(createBucketDataChangedChannel, dataCube); - // todop: remove again? currently only exists for the tests - yield* call(handler); + // Also update the local hdf5 mapping by inspecting all already existing + // buckets (likely, there are none yet because all buckets were reloaded, but + // it's still safer to do this here). + yield* call(startInterruptibleUpdateMapping); while (true) { yield take(bucketChannel); - // We received a BUCKET_DATA_CHANGED event. `handler` needs to be invoked. + // We received a BUCKET_DATA_CHANGED event. `startInterruptibleUpdateMapping` needs + // to be invoked. // However, let's throttle¹ this by waiting and then discarding all other events // that might have accumulated in between. yield* call(sleep, BUCKET_WATCHING_THROTTLE_DELAY); yield flush(bucketChannel); - // After flushing and while the handler below is running, + // After flushing and while the startInterruptibleUpdateMapping below is running, // the bucketChannel might fill up again. This means, the // next loop will immediately take from the channel which // is what we need. - yield* call(handler); + yield* call(startInterruptibleUpdateMapping); // Addendum: // ¹ We don't use redux-saga's throttle, because that would - // call `handler` in parallel if enough events are + // call `startInterruptibleUpdateMapping` in parallel if enough events are // consumed over the throttling duration. - // However, running `handler` in parallel would be a waste - // of computation. Therefore, we invoke `handler` strictly + // However, running `startInterruptibleUpdateMapping` in parallel would be a waste + // of computation. Therefore, we invoke `startInterruptibleUpdateMapping` strictly // sequentially. } - function* handler() { + function* startInterruptibleUpdateMapping() { const dataset = yield* select((state) => state.dataset); const layerInfo = getLayerByName(dataset, layerName); const mappingInfo = yield* select((state) => From bd536d0f34d87af03c37e8575a2c115c4281079c Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 16:42:36 +0200 Subject: [PATCH 70/92] show error and terminate synchronizing saga in error case --- frontend/javascripts/viewer/model/sagas/save_saga.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index f016f216241..cb133a6768b 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -677,8 +677,11 @@ function* watchForSaveConflicts(): Saga { console.warn(exception); // @ts-ignore ErrorHandling.notify(exception); - // todop: remove again? - Toast.error(`${exception}`); + Toast.error( + "An unrecoverable error occurred while synchronizing this annotation. Please refresh the page.", + ); + // A hard error was thrown. Terminate this saga. + break; } } } From a9fa06c711ad70f4a6a65519035593bfadae0eee Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 17:03:34 +0200 Subject: [PATCH 71/92] fix race condition between getNewestVersionForAnnotation and getUpdateActionLog; remove the former and rely on the latter --- .../viewer/model/sagas/save_saga.ts | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index cb133a6768b..1468bab36be 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -523,7 +523,7 @@ const VERSION_POLL_INTERVAL_COLLAB = 1 * 1000; const VERSION_POLL_INTERVAL_READ_ONLY = 1 * 1000; const VERSION_POLL_INTERVAL_SINGLE_EDITOR = 1 * 1000; -function* watchForSaveConflicts(): Saga { +function* watchForSaveConflicts(): Saga { function* checkForNewVersion(): Saga { /* * Checks whether there is a newer version on the server. If so, @@ -571,42 +571,25 @@ function* watchForSaveConflicts(): Saga { return false; } - const versionOnServer = yield* call( - getNewestVersionForAnnotation, - tracingStoreUrl, - annotationId, - ); - - // Read the tracing version again from the store, since the - // old reference to tracing might be outdated now due to the - // immutability. const versionOnClient = yield* select((state) => { return state.annotation.version; }); - const toastKey = "save_conflicts_warning"; - const newerVersionCount = versionOnServer - versionOnClient; - if (newerVersionCount > 0) { - // The latest version on the server is greater than the most-recently - // stored version. - - const { url: tracingStoreUrl } = yield* select((state) => state.annotation.tracingStore); + // Fetch all update actions that belong to a version that is newer than + // versionOnClient. If there are none, the array will be empty. + // The order is ascending in the version number ([v_n, v_(n+1), ...]). + const newerActions = yield* call( + getUpdateActionLog, + tracingStoreUrl, + annotationId, + versionOnClient + 1, + undefined, + true, + ); + const toastKey = "save_conflicts_warning"; + if (newerActions.length > 0) { try { - // The order is ascending in the version number ([v_n, v_(n+1), ...]). - const newerActions = yield* call( - getUpdateActionLog, - tracingStoreUrl, - annotationId, - versionOnClient + 1, - undefined, - true, - ); - - if (newerActions.length !== newerVersionCount) { - throw new Error("Unexpected size of newer versions."); - } - if ((yield* tryToIncorporateActions(newerActions)).success) { return false; } From 51e08f1286cb8eddf7bd1985cd6e89892ad7888e Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 17:06:57 +0200 Subject: [PATCH 72/92] format --- frontend/javascripts/viewer/model/sagas/save_saga.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/save_saga.ts index 1468bab36be..fd5b21ba401 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/save_saga.ts @@ -1,8 +1,4 @@ -import { - getNewestVersionForAnnotation, - getUpdateActionLog, - sendSaveRequestWithToken, -} from "admin/rest_api"; +import { getUpdateActionLog, sendSaveRequestWithToken } from "admin/rest_api"; import Date from "libs/date"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; From 505911667b40739bba53bc6d2c3265dd41c03f6d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 11:16:56 +0200 Subject: [PATCH 73/92] fix that some actions were not applied because of allowUpdate==false --- frontend/javascripts/test/helpers/utils.ts | 29 +++++++++++++++++++ .../skeleton.spec.ts | 19 +++++++----- .../update_action_application/volume.spec.ts | 15 ++++++---- .../model/reducers/skeletontracing_reducer.ts | 18 ++++++++++-- 4 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 frontend/javascripts/test/helpers/utils.ts diff --git a/frontend/javascripts/test/helpers/utils.ts b/frontend/javascripts/test/helpers/utils.ts new file mode 100644 index 00000000000..888c5c47174 --- /dev/null +++ b/frontend/javascripts/test/helpers/utils.ts @@ -0,0 +1,29 @@ +import update from "immutability-helper"; +import type { Action } from "viewer/model/actions/actions"; +import type { WebknossosState } from "viewer/store"; + +export const applyActionsOnReadOnlyVersion = ( + applyActions: ( + state: WebknossosState, + actionGetters: Array Action)>, + ) => WebknossosState, + state: WebknossosState, + actionGetters: Array Action)>, +) => { + const readOnlyState = overrideAllowUpdateInState(state, false); + const reappliedNewState = applyActions(readOnlyState, actionGetters); + + return overrideAllowUpdateInState(reappliedNewState, true); +}; + +function overrideAllowUpdateInState(state: WebknossosState, value: boolean) { + return update(state, { + annotation: { + restrictions: { + allowUpdate: { + $set: value, + }, + }, + }, + }); +} diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 33e49b31a1c..e97528a4a66 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -4,6 +4,7 @@ import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; import { initialState as defaultSkeletonState } from "test/fixtures/skeletontracing_object"; import { chainReduce } from "test/helpers/chainReducer"; import { withoutUpdateActiveItemTracing } from "test/helpers/saveHelpers"; +import { applyActionsOnReadOnlyVersion } from "test/helpers/utils"; import type { Vector3 } from "viewer/constants"; import { enforceSkeletonTracing, @@ -202,11 +203,15 @@ describe("Update Action Application for SkeletonTracing", () => { seenActionTypes.add(action.name); } - const reappliedNewState = applyActions(state2WithoutActiveState, [ - SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), - SkeletonTracingActions.setActiveNodeAction(null), - setActiveUserBoundingBoxId(null), - ]); + const reappliedNewState = applyActionsOnReadOnlyVersion( + applyActions, + state2WithoutActiveState, + [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + SkeletonTracingActions.setActiveNodeAction(null), + setActiveUserBoundingBoxId(null), + ], + ); expect(reappliedNewState).toEqual(state3); }); @@ -237,7 +242,7 @@ describe("Update Action Application for SkeletonTracing", () => { ), ) as ApplicableSkeletonUpdateAction[]; - const newState3 = applyActions(newState, [ + const newState3 = applyActionsOnReadOnlyVersion(applyActions, newState, [ SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), ]); @@ -268,7 +273,7 @@ describe("Update Action Application for SkeletonTracing", () => { ), ) as ApplicableSkeletonUpdateAction[]; - const newState3 = applyActions(newState, [ + const newState3 = applyActionsOnReadOnlyVersion(applyActions, newState, [ SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), ]); diff --git a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts index 1e378778002..fad036b38c4 100644 --- a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts @@ -20,6 +20,7 @@ import type { import { combinedReducer, type WebknossosState } from "viewer/store"; import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { afterAll, describe, expect, test } from "vitest"; +import { applyActionsOnReadOnlyVersion } from "test/helpers/utils"; const enforceVolumeTracing = (state: WebknossosState) => { const tracing = state.annotation.volumes[0]; @@ -179,11 +180,15 @@ describe("Update Action Application for VolumeTracing", () => { seenActionTypes.add(action.name); } - const reappliedNewState = applyActions(state2WithoutActiveState, [ - VolumeTracingActions.applyVolumeUpdateActionsFromServerAction(updateActions), - VolumeTracingActions.setActiveCellAction(0), - setActiveUserBoundingBoxId(null), - ]); + const reappliedNewState = applyActionsOnReadOnlyVersion( + applyActions, + state2WithoutActiveState, + [ + VolumeTracingActions.applyVolumeUpdateActionsFromServerAction(updateActions), + VolumeTracingActions.setActiveCellAction(0), + setActiveUserBoundingBoxId(null), + ], + ); expect(reappliedNewState).toEqual(state3); }); diff --git a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts index 93e7fee2297..dfca78bd3b1 100644 --- a/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/skeletontracing_reducer.ts @@ -57,7 +57,11 @@ import { getUserStateForTracing } from "../accessors/annotation_accessor"; import { max, maxBy } from "../helpers/iterator_utils"; import { applySkeletonUpdateActionsFromServer } from "./update_action_application/skeleton"; -function SkeletonTracingReducer(state: WebknossosState, action: Action): WebknossosState { +function SkeletonTracingReducer( + state: WebknossosState, + action: Action, + ignoreAllowUpdate: boolean = false, +): WebknossosState { if (action.type === "INITIALIZE_SKELETONTRACING") { const userState = getUserStateForTracing( action.tracing, @@ -653,7 +657,13 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos case "APPLY_SKELETON_UPDATE_ACTIONS_FROM_SERVER": { const { actions } = action; - return applySkeletonUpdateActionsFromServer(SkeletonTracingReducer, actions, state); + return applySkeletonUpdateActionsFromServer( + // Pass a SkeletonTracingReducer that ignores allowUpdate because + // we want to be able to apply updates even in read-only views. + (state: WebknossosState, action: Action) => SkeletonTracingReducer(state, action, true), + actions, + state, + ); } default: // pass @@ -664,7 +674,9 @@ function SkeletonTracingReducer(state: WebknossosState, action: Action): Webknos */ const { restrictions } = state.annotation; const { allowUpdate } = restrictions; - if (!allowUpdate) return state; + if (!(allowUpdate || ignoreAllowUpdate)) { + return state; + } switch (action.type) { case "CREATE_NODE": { From 33bb355df73d89ebd6b9266598410a91cbfc2fe2 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 11:22:49 +0200 Subject: [PATCH 74/92] refactor transformStateAsReadOnly --- frontend/javascripts/test/helpers/utils.ts | 18 ++++++------- .../skeleton.spec.ts | 26 ++++++++++--------- .../update_action_application/volume.spec.ts | 10 +++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/frontend/javascripts/test/helpers/utils.ts b/frontend/javascripts/test/helpers/utils.ts index 888c5c47174..636831a910b 100644 --- a/frontend/javascripts/test/helpers/utils.ts +++ b/frontend/javascripts/test/helpers/utils.ts @@ -1,19 +1,19 @@ import update from "immutability-helper"; -import type { Action } from "viewer/model/actions/actions"; import type { WebknossosState } from "viewer/store"; -export const applyActionsOnReadOnlyVersion = ( - applyActions: ( - state: WebknossosState, - actionGetters: Array Action)>, - ) => WebknossosState, +export const transformStateAsReadOnly = ( state: WebknossosState, - actionGetters: Array Action)>, + transformFn: (state: WebknossosState) => WebknossosState, ) => { + /* + * This function can be used to make a state read only before + * transforming it somehow (e.g., with a reducer). The result of + * the transformation is then made not-read-only again. + */ const readOnlyState = overrideAllowUpdateInState(state, false); - const reappliedNewState = applyActions(readOnlyState, actionGetters); + const transformedState = transformFn(readOnlyState); - return overrideAllowUpdateInState(reappliedNewState, true); + return overrideAllowUpdateInState(transformedState, true); }; function overrideAllowUpdateInState(state: WebknossosState, value: boolean) { diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index e97528a4a66..635d6eeaafd 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -4,7 +4,7 @@ import { sampleTracingLayer } from "test/fixtures/dataset_server_object"; import { initialState as defaultSkeletonState } from "test/fixtures/skeletontracing_object"; import { chainReduce } from "test/helpers/chainReducer"; import { withoutUpdateActiveItemTracing } from "test/helpers/saveHelpers"; -import { applyActionsOnReadOnlyVersion } from "test/helpers/utils"; +import { transformStateAsReadOnly } from "test/helpers/utils"; import type { Vector3 } from "viewer/constants"; import { enforceSkeletonTracing, @@ -203,14 +203,12 @@ describe("Update Action Application for SkeletonTracing", () => { seenActionTypes.add(action.name); } - const reappliedNewState = applyActionsOnReadOnlyVersion( - applyActions, - state2WithoutActiveState, - [ + const reappliedNewState = transformStateAsReadOnly(state2WithoutActiveState, (state) => + applyActions(state, [ SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), SkeletonTracingActions.setActiveNodeAction(null), setActiveUserBoundingBoxId(null), - ], + ]), ); expect(reappliedNewState).toEqual(state3); @@ -242,9 +240,11 @@ describe("Update Action Application for SkeletonTracing", () => { ), ) as ApplicableSkeletonUpdateAction[]; - const newState3 = applyActionsOnReadOnlyVersion(applyActions, newState, [ - SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), - ]); + const newState3 = transformStateAsReadOnly(newState, (state) => + applyActions(state, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + ]), + ); const { activeNodeId } = enforceSkeletonTracing(newState3.annotation); expect(activeNodeId).toBeNull(); @@ -273,9 +273,11 @@ describe("Update Action Application for SkeletonTracing", () => { ), ) as ApplicableSkeletonUpdateAction[]; - const newState3 = applyActionsOnReadOnlyVersion(applyActions, newState, [ - SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), - ]); + const newState3 = transformStateAsReadOnly(newState, (state) => + applyActions(state, [ + SkeletonTracingActions.applySkeletonUpdateActionsFromServerAction(updateActions), + ]), + ); const { activeTreeId, activeNodeId } = enforceSkeletonTracing(newState3.annotation); diff --git a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts index fad036b38c4..a78303c9a9e 100644 --- a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts @@ -20,7 +20,7 @@ import type { import { combinedReducer, type WebknossosState } from "viewer/store"; import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { afterAll, describe, expect, test } from "vitest"; -import { applyActionsOnReadOnlyVersion } from "test/helpers/utils"; +import { transformStateAsReadOnly } from "test/helpers/utils"; const enforceVolumeTracing = (state: WebknossosState) => { const tracing = state.annotation.volumes[0]; @@ -180,14 +180,12 @@ describe("Update Action Application for VolumeTracing", () => { seenActionTypes.add(action.name); } - const reappliedNewState = applyActionsOnReadOnlyVersion( - applyActions, - state2WithoutActiveState, - [ + const reappliedNewState = transformStateAsReadOnly(state2WithoutActiveState, (state) => + applyActions(state, [ VolumeTracingActions.applyVolumeUpdateActionsFromServerAction(updateActions), VolumeTracingActions.setActiveCellAction(0), setActiveUserBoundingBoxId(null), - ], + ]), ); expect(reappliedNewState).toEqual(state3); From 9efae18102c6ef9fbfb225065103c01fe776ea34 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 09:08:43 +0200 Subject: [PATCH 75/92] move some sagas to new subfolders --- .../backend-snapshot-tests/annotations.e2e.ts | 4 ++-- frontend/javascripts/test/helpers/saveHelpers.ts | 2 +- .../test/reducers/save_reducer.spec.ts | 4 ++-- .../update_action_application/skeleton.spec.ts | 2 +- .../update_action_application/volume.spec.ts | 2 +- .../test/sagas/bounding_box_saving.spec.ts | 2 +- .../test/sagas/compact_toggle_actions.spec.ts | 2 +- .../javascripts/test/sagas/proofreading.spec.ts | 2 +- .../test/sagas/saga_integration.spec.ts | 4 ++-- .../javascripts/test/sagas/save_saga.spec.ts | 4 ++-- .../test/sagas/skeletontracing_saga.spec.ts | 2 +- .../volumetracing_remote_bucket_updates.spec.ts | 2 +- frontend/javascripts/types/api_types.ts | 2 +- .../javascripts/viewer/geometries/skeleton.ts | 2 +- frontend/javascripts/viewer/merger_mode.ts | 2 +- .../viewer/model/actions/save_actions.ts | 2 +- .../model/actions/skeletontracing_actions.tsx | 2 +- .../model/actions/volumetracing_actions.ts | 2 +- .../model/bucket_data_handling/pushqueue.ts | 2 +- .../bucket_data_handling/wkstore_adapter.ts | 4 ++-- .../helpers/compaction/compact_save_queue.ts | 2 +- .../helpers/compaction/compact_toggle_actions.ts | 4 ++-- .../helpers/compaction/compact_update_actions.ts | 4 ++-- .../viewer/model/helpers/diff_helpers.ts | 2 +- .../viewer/model/reducers/reducer_helpers.ts | 2 +- .../viewer/model/reducers/save_reducer.ts | 2 +- .../update_action_application/bounding_box.ts | 2 +- .../update_action_application/skeleton.ts | 2 +- .../reducers/update_action_application/volume.ts | 2 +- .../viewer/model/sagas/annotation_saga.tsx | 4 ++-- .../javascripts/viewer/model/sagas/root_saga.ts | 6 +++--- .../viewer/model/sagas/{ => saving}/save_saga.ts | 16 ++++++++-------- .../sagas/{ => saving}/save_saga_constants.ts | 0 .../viewer/model/sagas/settings_saga.ts | 2 +- .../viewer/model/sagas/skeletontracing_saga.ts | 4 ++-- .../javascripts/viewer/model/sagas/undo_saga.ts | 2 +- .../model/sagas/{ => volume}/mapping_saga.ts | 10 +++++----- .../model/sagas/{ => volume}/min_cut_saga.ts | 0 .../model/sagas/{ => volume}/proofread_saga.ts | 12 ++++++------ .../quick_select}/quick_select_heuristic_saga.ts | 0 .../quick_select}/quick_select_ml_saga.ts | 6 +++--- .../quick_select}/quick_select_saga.ts | 6 +++--- .../model/sagas/{ => volume}/update_actions.ts | 0 .../viewer/model/sagas/volumetracing_saga.tsx | 4 ++-- frontend/javascripts/viewer/store.ts | 2 +- .../view/left-border-tabs/layer_settings_tab.tsx | 2 +- .../modals/add_volume_layer_modal.tsx | 2 +- .../javascripts/viewer/view/version_entry.tsx | 2 +- .../javascripts/viewer/view/version_list.tsx | 2 +- 49 files changed, 77 insertions(+), 77 deletions(-) rename frontend/javascripts/viewer/model/sagas/{ => saving}/save_saga.ts (98%) rename frontend/javascripts/viewer/model/sagas/{ => saving}/save_saga_constants.ts (100%) rename frontend/javascripts/viewer/model/sagas/{ => volume}/mapping_saga.ts (98%) rename frontend/javascripts/viewer/model/sagas/{ => volume}/min_cut_saga.ts (100%) rename frontend/javascripts/viewer/model/sagas/{ => volume}/proofread_saga.ts (99%) rename frontend/javascripts/viewer/model/sagas/{ => volume/quick_select}/quick_select_heuristic_saga.ts (100%) rename frontend/javascripts/viewer/model/sagas/{ => volume/quick_select}/quick_select_ml_saga.ts (98%) rename frontend/javascripts/viewer/model/sagas/{ => volume/quick_select}/quick_select_saga.ts (94%) rename frontend/javascripts/viewer/model/sagas/{ => volume}/update_actions.ts (100%) diff --git a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts index 31b4532a504..0d043a0b2bc 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts @@ -11,8 +11,8 @@ import { createTreeMapFromTreeArray } from "viewer/model/reducers/skeletontracin import { diffTrees } from "viewer/model/sagas/skeletontracing_saga"; import { getNullableSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; import { getServerVolumeTracings } from "viewer/model/accessors/volumetracing_accessor"; -import { addVersionNumbers } from "viewer/model/sagas/save_saga"; -import * as UpdateActions from "viewer/model/sagas/update_actions"; +import { addVersionNumbers } from "viewer/model/sagas/saving/save_saga"; +import * as UpdateActions from "viewer/model/sagas/volume/update_actions"; import * as api from "admin/rest_api"; import generateDummyTrees from "viewer/model/helpers/generate_dummy_trees"; import { describe, it, beforeAll, expect } from "vitest"; diff --git a/frontend/javascripts/test/helpers/saveHelpers.ts b/frontend/javascripts/test/helpers/saveHelpers.ts index 73da125f139..f234f070e84 100644 --- a/frontend/javascripts/test/helpers/saveHelpers.ts +++ b/frontend/javascripts/test/helpers/saveHelpers.ts @@ -1,5 +1,5 @@ import type { TracingStats } from "viewer/model/accessors/annotation_accessor"; -import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/volume/update_actions"; import type { SaveQueueEntry } from "viewer/store"; import { idUserA } from "test/e2e-setup"; import dummyUser from "test/fixtures/dummy_user"; diff --git a/frontend/javascripts/test/reducers/save_reducer.spec.ts b/frontend/javascripts/test/reducers/save_reducer.spec.ts index de9a5cb22d6..f5d1a2f2226 100644 --- a/frontend/javascripts/test/reducers/save_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/save_reducer.spec.ts @@ -2,11 +2,11 @@ import { vi, describe, it, expect } from "vitest"; import dummyUser from "test/fixtures/dummy_user"; import type { WebknossosState } from "viewer/store"; import { createSaveQueueFromUpdateActions } from "../helpers/saveHelpers"; -import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/volume/update_actions"; import * as SaveActions from "viewer/model/actions/save_actions"; import SaveReducer from "viewer/model/reducers/save_reducer"; -import { createEdge } from "viewer/model/sagas/update_actions"; +import { createEdge } from "viewer/model/sagas/volume/update_actions"; import { TIMESTAMP } from "test/global_mocks"; vi.mock("viewer/model/accessors/annotation_accessor", () => ({ diff --git a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts index 635d6eeaafd..45c75fcd9b0 100644 --- a/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/skeleton.spec.ts @@ -24,7 +24,7 @@ import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; import type { ApplicableSkeletonUpdateAction, UpdateActionWithoutIsolationRequirement, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { combinedReducer, type WebknossosState } from "viewer/store"; import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { describe, expect, test, it, afterAll } from "vitest"; diff --git a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts index a78303c9a9e..4a2ebcda193 100644 --- a/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts +++ b/frontend/javascripts/test/reducers/update_action_application/volume.spec.ts @@ -16,7 +16,7 @@ import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; import type { ApplicableVolumeUpdateAction, UpdateActionWithoutIsolationRequirement, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { combinedReducer, type WebknossosState } from "viewer/store"; import { makeBasicGroupObject } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { afterAll, describe, expect, test } from "vitest"; diff --git a/frontend/javascripts/test/sagas/bounding_box_saving.spec.ts b/frontend/javascripts/test/sagas/bounding_box_saving.spec.ts index 7225562dfb2..c9f640eb038 100644 --- a/frontend/javascripts/test/sagas/bounding_box_saving.spec.ts +++ b/frontend/javascripts/test/sagas/bounding_box_saving.spec.ts @@ -6,7 +6,7 @@ import { type UpdateUserBoundingBoxInSkeletonTracingAction, updateUserBoundingBoxInVolumeTracing, type UpdateUserBoundingBoxInVolumeTracingAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { SaveQueueEntry, UserBoundingBox } from "viewer/store"; import { describe, expect, it } from "vitest"; diff --git a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts index f7dbb5f3eeb..9ee1e408fed 100644 --- a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts +++ b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts @@ -7,7 +7,7 @@ import { updateSegmentVisibilityVolumeAction, updateTreeGroupVisibility, updateTreeVisibility, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { withoutUpdateSegment, withoutUpdateActiveItemTracing, diff --git a/frontend/javascripts/test/sagas/proofreading.spec.ts b/frontend/javascripts/test/sagas/proofreading.spec.ts index b3033c96d11..93ed28f1740 100644 --- a/frontend/javascripts/test/sagas/proofreading.spec.ts +++ b/frontend/javascripts/test/sagas/proofreading.spec.ts @@ -26,7 +26,7 @@ import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; import { Store } from "viewer/singletons"; import { startSaga } from "viewer/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { tryToIncorporateActions } from "viewer/model/sagas/save_saga"; +import { tryToIncorporateActions } from "viewer/model/sagas/saving/save_saga"; function* initializeMappingAndTool(context: WebknossosTestContext, tracingId: string): Saga { const { api } = context; diff --git a/frontend/javascripts/test/sagas/saga_integration.spec.ts b/frontend/javascripts/test/sagas/saga_integration.spec.ts index 10c0378ae22..2d7d714bfff 100644 --- a/frontend/javascripts/test/sagas/saga_integration.spec.ts +++ b/frontend/javascripts/test/sagas/saga_integration.spec.ts @@ -3,7 +3,7 @@ import { setupWebknossosForTesting, type WebknossosTestContext } from "test/help import { createSaveQueueFromUpdateActions } from "test/helpers/saveHelpers"; import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; import { getStats } from "viewer/model/accessors/annotation_accessor"; -import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "viewer/model/sagas/save_saga_constants"; +import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "viewer/model/sagas/saving/save_saga_constants"; import Store from "viewer/store"; import generateDummyTrees from "viewer/model/helpers/generate_dummy_trees"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; @@ -19,7 +19,7 @@ import { deleteNodeAction, } from "viewer/model/actions/skeletontracing_actions"; import { discardSaveQueuesAction } from "viewer/model/actions/save_actions"; -import * as UpdateActions from "viewer/model/sagas/update_actions"; +import * as UpdateActions from "viewer/model/sagas/volume/update_actions"; import { TIMESTAMP } from "test/global_mocks"; describe("Saga Integration Tests", () => { diff --git a/frontend/javascripts/test/sagas/save_saga.spec.ts b/frontend/javascripts/test/sagas/save_saga.spec.ts index baab497d417..e6a0f56afd5 100644 --- a/frontend/javascripts/test/sagas/save_saga.spec.ts +++ b/frontend/javascripts/test/sagas/save_saga.spec.ts @@ -10,13 +10,13 @@ import { UnitLong } from "viewer/constants"; import { put, take, call } from "redux-saga/effects"; import * as SaveActions from "viewer/model/actions/save_actions"; -import * as UpdateActions from "viewer/model/sagas/update_actions"; +import * as UpdateActions from "viewer/model/sagas/volume/update_actions"; import { pushSaveQueueAsync, sendSaveRequestToServer, toggleErrorHighlighting, addVersionNumbers, -} from "viewer/model/sagas/save_saga"; +} from "viewer/model/sagas/saving/save_saga"; import { TIMESTAMP } from "test/global_mocks"; import { sendSaveRequestWithToken } from "admin/rest_api"; diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 0af65e9eba2..3e983bb3cd8 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -17,7 +17,7 @@ import { import { MISSING_GROUP_ID } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; import { TreeTypeEnum } from "viewer/constants"; import { enforceSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; -import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/volume/update_actions"; import type { TracingStats } from "viewer/model/accessors/annotation_accessor"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; import * as SkeletonTracingActions from "viewer/model/actions/skeletontracing_actions"; diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts index 31146bae0a3..88eeb6d2968 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_remote_bucket_updates.spec.ts @@ -6,7 +6,7 @@ import { import { call } from "typed-redux-saga"; import type { Vector3 } from "viewer/constants"; import { hasRootSagaCrashed } from "viewer/model/sagas/root_saga"; -import { tryToIncorporateActions } from "viewer/model/sagas/save_saga"; +import { tryToIncorporateActions } from "viewer/model/sagas/saving/save_saga"; import { startSaga } from "viewer/store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/frontend/javascripts/types/api_types.ts b/frontend/javascripts/types/api_types.ts index de0b2b02191..d7e7b1be3b8 100644 --- a/frontend/javascripts/types/api_types.ts +++ b/frontend/javascripts/types/api_types.ts @@ -17,7 +17,7 @@ import type { TracingStats, VolumeTracingStats, } from "viewer/model/accessors/annotation_accessor"; -import type { ServerUpdateAction } from "viewer/model/sagas/update_actions"; +import type { ServerUpdateAction } from "viewer/model/sagas/volume/update_actions"; import type { CommentType, Edge, TreeGroup } from "viewer/model/types/tree_types"; import type { BoundingBoxObject, diff --git a/frontend/javascripts/viewer/geometries/skeleton.ts b/frontend/javascripts/viewer/geometries/skeleton.ts index eb76513426a..d98497be1c1 100644 --- a/frontend/javascripts/viewer/geometries/skeleton.ts +++ b/frontend/javascripts/viewer/geometries/skeleton.ts @@ -11,7 +11,7 @@ import NodeShader, { import { getZoomValue } from "viewer/model/accessors/flycam_accessor"; import { sum } from "viewer/model/helpers/iterator_utils"; import { cachedDiffTrees } from "viewer/model/sagas/skeletontracing_saga"; -import type { CreateActionNode, UpdateActionNode } from "viewer/model/sagas/update_actions"; +import type { CreateActionNode, UpdateActionNode } from "viewer/model/sagas/volume/update_actions"; import type { Edge, Node, Tree } from "viewer/model/types/tree_types"; import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/throttled_store"; diff --git a/frontend/javascripts/viewer/merger_mode.ts b/frontend/javascripts/viewer/merger_mode.ts index 4bcfbe5b1da..090476b64a6 100644 --- a/frontend/javascripts/viewer/merger_mode.ts +++ b/frontend/javascripts/viewer/merger_mode.ts @@ -17,7 +17,7 @@ import type { DeleteNodeUpdateAction, NodeWithTreeId, UpdateActionNode, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { api } from "viewer/singletons"; import type { SkeletonTracing, StoreType, WebknossosState } from "viewer/store"; import Store from "viewer/throttled_store"; diff --git a/frontend/javascripts/viewer/model/actions/save_actions.ts b/frontend/javascripts/viewer/model/actions/save_actions.ts index 2335b626c59..0e26118534a 100644 --- a/frontend/javascripts/viewer/model/actions/save_actions.ts +++ b/frontend/javascripts/viewer/model/actions/save_actions.ts @@ -6,7 +6,7 @@ import type { UpdateAction, UpdateActionWithIsolationRequirement, UpdateActionWithoutIsolationRequirement, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; export type SaveQueueType = "skeleton" | "volume" | "mapping"; export type PushSaveQueueTransaction = { diff --git a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx index e517cda8f1d..dac947792ff 100644 --- a/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/viewer/model/actions/skeletontracing_actions.tsx @@ -9,7 +9,7 @@ import { } from "viewer/model/actions/annotation_actions"; import type { MutableTreeMap, Tree, TreeGroup } from "viewer/model/types/tree_types"; import type { SkeletonTracing } from "viewer/store"; -import type { ApplicableSkeletonUpdateAction } from "../sagas/update_actions"; +import type { ApplicableSkeletonUpdateAction } from "../sagas/volume/update_actions"; export type InitializeSkeletonTracingAction = ReturnType; export type CreateNodeAction = ReturnType; diff --git a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts index 7a3418ebc3e..4d047e08ccc 100644 --- a/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/viewer/model/actions/volumetracing_actions.ts @@ -8,7 +8,7 @@ import type { QuickSelectGeometry } from "viewer/geometries/helper_geometries"; import { AllUserBoundingBoxActions } from "viewer/model/actions/annotation_actions"; import type { NumberLike, Segment, SegmentGroup, SegmentMap } from "viewer/store"; import type BucketSnapshot from "../bucket_data_handling/bucket_snapshot"; -import type { ApplicableVolumeUpdateAction } from "../sagas/update_actions"; +import type { ApplicableVolumeUpdateAction } from "../sagas/volume/update_actions"; export type InitializeVolumeTracingAction = ReturnType; export type InitializeEditableMappingAction = ReturnType; diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/pushqueue.ts b/frontend/javascripts/viewer/model/bucket_data_handling/pushqueue.ts index 301b5310d39..d7eda22e56e 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/pushqueue.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/pushqueue.ts @@ -7,7 +7,7 @@ import { createCompressedUpdateBucketActions } from "viewer/model/bucket_data_ha import Store from "viewer/store"; import { escalateErrorAction } from "../actions/actions"; import { pushSaveQueueTransaction } from "../actions/save_actions"; -import type { UpdateActionWithoutIsolationRequirement } from "../sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "../sagas/volume/update_actions"; // Only process the PushQueue after there was no user interaction (or bucket modification due to // downsampling) for PUSH_DEBOUNCE_TIME milliseconds. diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/wkstore_adapter.ts b/frontend/javascripts/viewer/model/bucket_data_handling/wkstore_adapter.ts index d484c9b2e9b..84ee056fa4d 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/wkstore_adapter.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/wkstore_adapter.ts @@ -20,8 +20,8 @@ import { } from "viewer/model/accessors/volumetracing_accessor"; import type { DataBucket } from "viewer/model/bucket_data_handling/bucket"; import { bucketPositionToGlobalAddress } from "viewer/model/helpers/position_converter"; -import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/update_actions"; -import { updateBucket } from "viewer/model/sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/volume/update_actions"; +import { updateBucket } from "viewer/model/sagas/volume/update_actions"; import type { DataLayerType, VolumeTracing } from "viewer/store"; import Store from "viewer/store"; import ByteArraysToLz4Base64Worker from "viewer/workers/byte_arrays_to_lz4_base64.worker"; diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_save_queue.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_save_queue.ts index 2c7dc893150..55713c42a34 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_save_queue.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_save_queue.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import type { UpdateUserBoundingBoxInSkeletonTracingAction, UpdateUserBoundingBoxInVolumeTracingAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { SaveQueueEntry } from "viewer/store"; function removeAllButLastUpdateActiveItemAndCameraAction( diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_toggle_actions.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_toggle_actions.ts index c7084ddaae2..9f012f54df8 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_toggle_actions.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_toggle_actions.ts @@ -8,13 +8,13 @@ import type { UpdateActionWithoutIsolationRequirement, UpdateSegmentVisibilityVolumeAction, UpdateTreeVisibilityUpdateAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { updateSegmentGroupVisibilityVolumeAction, updateSegmentVisibilityVolumeAction, updateTreeGroupVisibility, updateTreeVisibility, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { Tree, TreeGroup, TreeMap } from "viewer/model/types/tree_types"; import type { Segment, SegmentMap, SkeletonTracing, VolumeTracing } from "viewer/store"; import { diff --git a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts index f564de9ca43..fc136d22245 100644 --- a/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts +++ b/frontend/javascripts/viewer/model/helpers/compaction/compact_update_actions.ts @@ -8,8 +8,8 @@ import type { DeleteNodeUpdateAction, DeleteTreeUpdateAction, UpdateActionWithoutIsolationRequirement, -} from "viewer/model/sagas/update_actions"; -import { moveTreeComponent, updateNode } from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; +import { moveTreeComponent, updateNode } from "viewer/model/sagas/volume/update_actions"; import type { SkeletonTracing, VolumeTracing } from "viewer/store"; // The Cantor pairing function assigns one natural number to each pair of natural numbers diff --git a/frontend/javascripts/viewer/model/helpers/diff_helpers.ts b/frontend/javascripts/viewer/model/helpers/diff_helpers.ts index 8293ccf977a..e3edd2da2d9 100644 --- a/frontend/javascripts/viewer/model/helpers/diff_helpers.ts +++ b/frontend/javascripts/viewer/model/helpers/diff_helpers.ts @@ -10,7 +10,7 @@ import { updateUserBoundingBoxInVolumeTracing, updateUserBoundingBoxVisibilityInSkeletonTracing, updateUserBoundingBoxVisibilityInVolumeTracing, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { UserBoundingBox } from "viewer/store"; import type { TreeGroup } from "../types/tree_types"; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 30100eb761a..8b31fc7966b 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -23,7 +23,7 @@ import type { WebknossosState, } from "viewer/store"; import { type DisabledInfo, getDisabledInfoForTools } from "../accessors/disabled_tool_accessor"; -import type { UpdateUserBoundingBoxInSkeletonTracingAction } from "../sagas/update_actions"; +import type { UpdateUserBoundingBoxInSkeletonTracingAction } from "../sagas/volume/update_actions"; import type { Tree, TreeGroup } from "../types/tree_types"; function convertServerBoundingBoxToBoundingBoxMinMaxType( diff --git a/frontend/javascripts/viewer/model/reducers/save_reducer.ts b/frontend/javascripts/viewer/model/reducers/save_reducer.ts index 29c3e097c83..0c9a07de4d6 100644 --- a/frontend/javascripts/viewer/model/reducers/save_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/save_reducer.ts @@ -5,7 +5,7 @@ import { type TracingStats, getStats } from "viewer/model/accessors/annotation_a import type { Action } from "viewer/model/actions/actions"; import { getActionLog } from "viewer/model/helpers/action_logger_middleware"; import { updateKey, updateKey2 } from "viewer/model/helpers/deep_update"; -import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "viewer/model/sagas/save_saga_constants"; +import { MAXIMUM_ACTION_COUNT_PER_BATCH } from "viewer/model/sagas/saving/save_saga_constants"; import type { SaveState, WebknossosState } from "viewer/store"; // These update actions are not idempotent. Having them diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts index b42d2bcfb6a..ee920f7a245 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/bounding_box.ts @@ -7,7 +7,7 @@ import type { DeleteUserBoundingBoxInVolumeTracingAction, UpdateUserBoundingBoxInSkeletonTracingAction, UpdateUserBoundingBoxInVolumeTracingAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { SkeletonTracing, UserBoundingBox, diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts index e978b734f73..039224a3137 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/skeleton.ts @@ -6,7 +6,7 @@ import { setTreeGroupsAction, } from "viewer/model/actions/skeletontracing_actions"; import EdgeCollection from "viewer/model/edge_collection"; -import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/update_actions"; +import type { ApplicableSkeletonUpdateAction } from "viewer/model/sagas/volume/update_actions"; import type { Tree } from "viewer/model/types/tree_types"; import type { Reducer, WebknossosState } from "viewer/store"; import { getMaximumNodeId } from "../skeletontracing_reducer_helpers"; diff --git a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts index 1e9aa6ea348..a9a86df1e64 100644 --- a/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts +++ b/frontend/javascripts/viewer/model/reducers/update_action_application/volume.ts @@ -4,7 +4,7 @@ import { setSegmentGroupsAction, updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; -import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/update_actions"; +import type { ApplicableVolumeUpdateAction } from "viewer/model/sagas/volume/update_actions"; import type { Segment, WebknossosState } from "viewer/store"; import type { VolumeTracingReducerAction } from "../volumetracing_reducer"; import { setLargestSegmentIdReducer } from "../volumetracing_reducer_helpers"; diff --git a/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx b/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx index bf0be52f494..f8b73cbef1e 100644 --- a/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx @@ -38,7 +38,7 @@ import { select } from "viewer/model/sagas/effect-generators"; import { SETTINGS_MAX_RETRY_COUNT, SETTINGS_RETRY_DELAY, -} from "viewer/model/sagas/save_saga_constants"; +} from "viewer/model/sagas/saving/save_saga_constants"; import { Model } from "viewer/singletons"; import Store from "viewer/store"; import { determineLayout } from "viewer/view/layouting/default_layout_configs"; @@ -48,7 +48,7 @@ import { mayEditAnnotationProperties } from "../accessors/annotation_accessor"; import { needsLocalHdf5Mapping } from "../accessors/volumetracing_accessor"; import { pushSaveQueueTransaction } from "../actions/save_actions"; import { ensureWkReady } from "./ready_sagas"; -import { updateAnnotationLayerName, updateMetadataOfAnnotation } from "./update_actions"; +import { updateAnnotationLayerName, updateMetadataOfAnnotation } from "./volume/update_actions"; /* Note that this must stay in sync with the back-end constant MaxMagForAgglomerateMapping compare https://github.com/scalableminds/webknossos/issues/5223. diff --git a/frontend/javascripts/viewer/model/sagas/root_saga.ts b/frontend/javascripts/viewer/model/sagas/root_saga.ts index ca29fc70788..8115cf830d0 100644 --- a/frontend/javascripts/viewer/model/sagas/root_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/root_saga.ts @@ -8,11 +8,11 @@ import listenToClipHistogramSaga from "viewer/model/sagas/clip_histogram_saga"; import DatasetSagas from "viewer/model/sagas/dataset_saga"; import type { Saga } from "viewer/model/sagas/effect-generators"; import loadHistogramDataSaga from "viewer/model/sagas/load_histogram_data_saga"; -import MappingSaga from "viewer/model/sagas/mapping_saga"; +import MappingSaga from "viewer/model/sagas/volume/mapping_saga"; import { watchDataRelevantChanges } from "viewer/model/sagas/prefetch_saga"; -import ProofreadSaga from "viewer/model/sagas/proofread_saga"; +import ProofreadSaga from "viewer/model/sagas/volume/proofread_saga"; import ReadySagas from "viewer/model/sagas/ready_sagas"; -import SaveSagas, { toggleErrorHighlighting } from "viewer/model/sagas/save_saga"; +import SaveSagas, { toggleErrorHighlighting } from "viewer/model/sagas/saving/save_saga"; import SettingsSaga from "viewer/model/sagas/settings_saga"; import SkeletontracingSagas from "viewer/model/sagas/skeletontracing_saga"; import watchTasksAsync, { warnAboutMagRestriction } from "viewer/model/sagas/task_saga"; diff --git a/frontend/javascripts/viewer/model/sagas/save_saga.ts b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts similarity index 98% rename from frontend/javascripts/viewer/model/sagas/save_saga.ts rename to frontend/javascripts/viewer/model/sagas/saving/save_saga.ts index fd5b21ba401..4b709efb80e 100644 --- a/frontend/javascripts/viewer/model/sagas/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts @@ -51,13 +51,13 @@ import { MAX_SAVE_RETRY_WAITING_TIME, PUSH_THROTTLE_TIME, SAVE_RETRY_WAITING_TIME, -} from "viewer/model/sagas/save_saga_constants"; +} from "viewer/model/sagas/saving/save_saga_constants"; import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; import { type UpdateActionWithoutIsolationRequirement, updateCameraAnnotation, updateTdCamera, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; import { Model } from "viewer/singletons"; import type { @@ -67,12 +67,12 @@ import type { SkeletonTracing, VolumeTracing, } from "viewer/store"; -import { getFlooredPosition, getRotation } from "../accessors/flycam_accessor"; -import type { Action } from "../actions/actions"; -import type { BatchedAnnotationInitializationAction } from "../actions/annotation_actions"; -import { updateLocalHdf5Mapping } from "./mapping_saga"; -import { removeAgglomerateFromActiveMapping, updateMappingWithMerge } from "./proofread_saga"; -import { takeEveryWithBatchActionSupport } from "./saga_helpers"; +import { getFlooredPosition, getRotation } from "../../accessors/flycam_accessor"; +import type { Action } from "../../actions/actions"; +import type { BatchedAnnotationInitializationAction } from "../../actions/annotation_actions"; +import { updateLocalHdf5Mapping } from "../volume/mapping_saga"; +import { removeAgglomerateFromActiveMapping, updateMappingWithMerge } from "../volume/proofread_saga"; +import { takeEveryWithBatchActionSupport } from "../saga_helpers"; const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; diff --git a/frontend/javascripts/viewer/model/sagas/save_saga_constants.ts b/frontend/javascripts/viewer/model/sagas/saving/save_saga_constants.ts similarity index 100% rename from frontend/javascripts/viewer/model/sagas/save_saga_constants.ts rename to frontend/javascripts/viewer/model/sagas/saving/save_saga_constants.ts diff --git a/frontend/javascripts/viewer/model/sagas/settings_saga.ts b/frontend/javascripts/viewer/model/sagas/settings_saga.ts index 78517a6558d..d1ad1e9702e 100644 --- a/frontend/javascripts/viewer/model/sagas/settings_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/settings_saga.ts @@ -13,7 +13,7 @@ import { type Saga, select, take } from "viewer/model/sagas/effect-generators"; import { SETTINGS_MAX_RETRY_COUNT, SETTINGS_RETRY_DELAY, -} from "viewer/model/sagas/save_saga_constants"; +} from "viewer/model/sagas/saving/save_saga_constants"; import type { DatasetConfiguration, DatasetLayerConfiguration } from "viewer/store"; import { Toolkit } from "../accessors/tool_accessor"; import { ensureWkReady } from "./ready_sagas"; diff --git a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts index 8c17a138cad..a297aaa87a8 100644 --- a/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/skeletontracing_saga.ts @@ -55,7 +55,7 @@ import { } from "viewer/model/reducers/skeletontracing_reducer_helpers"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; -import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/update_actions"; +import type { UpdateActionWithoutIsolationRequirement } from "viewer/model/sagas/volume/update_actions"; import { createEdge, createNode, @@ -70,7 +70,7 @@ import { updateTreeGroups, updateTreeGroupsExpandedState, updateTreeVisibility, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { api } from "viewer/singletons"; import type { SkeletonTracing, WebknossosState } from "viewer/store"; import Store from "viewer/store"; diff --git a/frontend/javascripts/viewer/model/sagas/undo_saga.ts b/frontend/javascripts/viewer/model/sagas/undo_saga.ts index f50666c591a..7264d24532f 100644 --- a/frontend/javascripts/viewer/model/sagas/undo_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/undo_saga.ts @@ -38,7 +38,7 @@ import { } from "viewer/model/actions/volumetracing_actions"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; -import { UNDO_HISTORY_SIZE } from "viewer/model/sagas/save_saga_constants"; +import { UNDO_HISTORY_SIZE } from "viewer/model/sagas/saving/save_saga_constants"; import { Model } from "viewer/singletons"; import type { SegmentGroup, SegmentMap, SkeletonTracing, UserBoundingBox } from "viewer/store"; import type BucketSnapshot from "../bucket_data_handling/bucket_snapshot"; diff --git a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts similarity index 98% rename from frontend/javascripts/viewer/model/sagas/mapping_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts index 8f9d0689879..98ebe36b1cd 100644 --- a/frontend/javascripts/viewer/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts @@ -69,11 +69,11 @@ import type { NumberLike, NumberLikeMap, } from "viewer/store"; -import type { Action } from "../actions/actions"; -import { updateSegmentAction } from "../actions/volumetracing_actions"; -import type DataCube from "../bucket_data_handling/data_cube"; -import { listenToStoreProperty } from "../helpers/listener_helpers"; -import { ensureWkReady } from "./ready_sagas"; +import type { Action } from "../../actions/actions"; +import { updateSegmentAction } from "../../actions/volumetracing_actions"; +import type DataCube from "../../bucket_data_handling/data_cube"; +import { listenToStoreProperty } from "../../helpers/listener_helpers"; +import { ensureWkReady } from "../ready_sagas"; type APIMappings = Record; type Container = { value: T }; diff --git a/frontend/javascripts/viewer/model/sagas/min_cut_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts similarity index 100% rename from frontend/javascripts/viewer/model/sagas/min_cut_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts diff --git a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts similarity index 99% rename from frontend/javascripts/viewer/model/sagas/proofread_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts index 09498c29ec7..15bfe6ff5ba 100644 --- a/frontend/javascripts/viewer/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts @@ -69,14 +69,14 @@ import { type UpdateActionWithoutIsolationRequirement, mergeAgglomerate, splitAgglomerate, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { Model, Store, api } from "viewer/singletons"; import type { ActiveMappingInfo, Mapping, NumberLikeMap, VolumeTracing } from "viewer/store"; -import { getCurrentMag } from "../accessors/flycam_accessor"; -import type { Action } from "../actions/actions"; -import type { Tree } from "../types/tree_types"; -import { ensureWkReady } from "./ready_sagas"; -import { takeEveryUnlessBusy, takeWithBatchActionSupport } from "./saga_helpers"; +import { getCurrentMag } from "../../accessors/flycam_accessor"; +import type { Action } from "../../actions/actions"; +import type { Tree } from "../../types/tree_types"; +import { ensureWkReady } from "../ready_sagas"; +import { takeEveryUnlessBusy, takeWithBatchActionSupport } from "../saga_helpers"; function runSagaAndCatchSoftError(saga: (...args: any[]) => Saga) { return function* (...args: any[]) { diff --git a/frontend/javascripts/viewer/model/sagas/quick_select_heuristic_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts similarity index 100% rename from frontend/javascripts/viewer/model/sagas/quick_select_heuristic_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts diff --git a/frontend/javascripts/viewer/model/sagas/quick_select_ml_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_ml_saga.ts similarity index 98% rename from frontend/javascripts/viewer/model/sagas/quick_select_ml_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_ml_saga.ts index c9afb6ec3f0..49d8389c228 100644 --- a/frontend/javascripts/viewer/model/sagas/quick_select_ml_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_ml_saga.ts @@ -17,9 +17,9 @@ import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import type { WebknossosState } from "viewer/store"; -import { getPlaneExtentInVoxelFromStore } from "../accessors/view_mode_accessor"; -import { setGlobalProgressAction } from "../actions/ui_actions"; -import Dimensions from "../dimensions"; +import { getPlaneExtentInVoxelFromStore } from "../../../accessors/view_mode_accessor"; +import { setGlobalProgressAction } from "../../../actions/ui_actions"; +import Dimensions from "../../../dimensions"; import { finalizeQuickSelectForSlice, prepareQuickSelect } from "./quick_select_heuristic_saga"; const MAXIMUM_MASK_BASE = 1024; diff --git a/frontend/javascripts/viewer/model/sagas/quick_select_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts similarity index 94% rename from frontend/javascripts/viewer/model/sagas/quick_select_saga.ts rename to frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts index a1ca2b6c11e..b10a92b1472 100644 --- a/frontend/javascripts/viewer/model/sagas/quick_select_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts @@ -11,11 +11,11 @@ import { type Saga, select } from "viewer/model/sagas/effect-generators"; import getSceneController from "viewer/controller/scene_controller_provider"; import type { VolumeTracing } from "viewer/store"; -import { getActiveSegmentationTracing } from "../accessors/volumetracing_accessor"; -import { setBusyBlockingInfoAction, setQuickSelectStateAction } from "../actions/ui_actions"; +import { getActiveSegmentationTracing } from "../../../accessors/volumetracing_accessor"; +import { setBusyBlockingInfoAction, setQuickSelectStateAction } from "../../../actions/ui_actions"; import performQuickSelectHeuristic from "./quick_select_heuristic_saga"; import performQuickSelectML from "./quick_select_ml_saga"; -import { requestBucketModificationInVolumeTracing } from "./saga_helpers"; +import { requestBucketModificationInVolumeTracing } from "../../saga_helpers"; function* shouldUseHeuristic() { const useHeuristic = yield* select((state) => state.userConfiguration.quickSelect.useHeuristic); diff --git a/frontend/javascripts/viewer/model/sagas/update_actions.ts b/frontend/javascripts/viewer/model/sagas/volume/update_actions.ts similarity index 100% rename from frontend/javascripts/viewer/model/sagas/update_actions.ts rename to frontend/javascripts/viewer/model/sagas/volume/update_actions.ts diff --git a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx index f78e6a0947e..54eea621f31 100644 --- a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx @@ -58,7 +58,7 @@ import { markVolumeTransactionEnd } from "viewer/model/bucket_data_handling/buck import type { Saga } from "viewer/model/sagas/effect-generators"; import { select, take } from "viewer/model/sagas/effect-generators"; import listenToMinCut from "viewer/model/sagas/min_cut_saga"; -import listenToQuickSelect from "viewer/model/sagas/quick_select_saga"; +import listenToQuickSelect from "viewer/model/sagas/volume/quick_select/quick_select_saga"; import { requestBucketModificationInVolumeTracing, takeEveryUnlessBusy, @@ -77,7 +77,7 @@ import { updateSegmentGroupsExpandedState, updateSegmentVisibilityVolumeAction, updateSegmentVolumeAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type VolumeLayer from "viewer/model/volumetracing/volumelayer"; import { Model, api } from "viewer/singletons"; import type { SegmentMap, VolumeTracing } from "viewer/store"; diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index f6d5587f60a..a16a7906473 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -51,7 +51,7 @@ import type { BLEND_MODES, ControlModeEnum } from "viewer/constants"; import type { TracingStats } from "viewer/model/accessors/annotation_accessor"; import type { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import type { Action } from "viewer/model/actions/actions"; -import type { UpdateAction } from "viewer/model/sagas/update_actions"; +import type { UpdateAction } from "viewer/model/sagas/volume/update_actions"; import type { Toolkit } from "./model/accessors/tool_accessor"; import type { MutableTreeGroup, diff --git a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx index b64f9bc13bd..3bc2280c212 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx @@ -111,7 +111,7 @@ import { setNodeRadiusAction, setShowSkeletonsAction, } from "viewer/model/actions/skeletontracing_actions"; -import { addLayerToAnnotation, deleteAnnotationLayer } from "viewer/model/sagas/update_actions"; +import { addLayerToAnnotation, deleteAnnotationLayer } from "viewer/model/sagas/volume/update_actions"; import { Model, api } from "viewer/singletons"; import type { DatasetConfiguration, diff --git a/frontend/javascripts/viewer/view/left-border-tabs/modals/add_volume_layer_modal.tsx b/frontend/javascripts/viewer/view/left-border-tabs/modals/add_volume_layer_modal.tsx index c374cab2ad0..a02405c456c 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/modals/add_volume_layer_modal.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/modals/add_volume_layer_modal.tsx @@ -25,7 +25,7 @@ import { getVolumeTracingLayers, } from "viewer/model/accessors/volumetracing_accessor"; import { pushSaveQueueTransactionIsolated } from "viewer/model/actions/save_actions"; -import { addLayerToAnnotation } from "viewer/model/sagas/update_actions"; +import { addLayerToAnnotation } from "viewer/model/sagas/volume/update_actions"; import { Model, api } from "viewer/singletons"; import Store, { type StoreAnnotation } from "viewer/store"; import InputComponent from "viewer/view/components/input_component"; diff --git a/frontend/javascripts/viewer/view/version_entry.tsx b/frontend/javascripts/viewer/view/version_entry.tsx index 5bc6b580acc..ad2f6b69298 100644 --- a/frontend/javascripts/viewer/view/version_entry.tsx +++ b/frontend/javascripts/viewer/view/version_entry.tsx @@ -71,7 +71,7 @@ import type { UpdateUserBoundingBoxInVolumeTracingAction, UpdateUserBoundingBoxVisibilityInSkeletonTracingAction, UpdateUserBoundingBoxVisibilityInVolumeTracingAction, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import type { StoreAnnotation } from "viewer/store"; import { MISSING_GROUP_ID } from "viewer/view/right-border-tabs/trees_tab/tree_hierarchy_view_helpers"; type Description = { diff --git a/frontend/javascripts/viewer/view/version_list.tsx b/frontend/javascripts/viewer/view/version_list.tsx index 8a80937aaf9..3c022d4a133 100644 --- a/frontend/javascripts/viewer/view/version_list.tsx +++ b/frontend/javascripts/viewer/view/version_list.tsx @@ -26,7 +26,7 @@ import { type ServerUpdateAction, revertToVersion, serverCreateTracing, -} from "viewer/model/sagas/update_actions"; +} from "viewer/model/sagas/volume/update_actions"; import { Model } from "viewer/singletons"; import { api } from "viewer/singletons"; import type { StoreAnnotation } from "viewer/store"; From d27ad2e00b53ea76f4c17f7dbe25df4e467bffbe Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 09:19:28 +0200 Subject: [PATCH 76/92] fix imports --- .../viewer/model/sagas/volume/min_cut_saga.ts | 2 +- .../quick_select/quick_select_heuristic_saga.ts | 16 ++++++++-------- .../viewer/model/sagas/volumetracing_saga.tsx | 2 +- .../javascripts/viewer/view/context_menu.tsx | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts index d767b7cc318..9cf7a8ecb0f 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/min_cut_saga.ts @@ -18,12 +18,12 @@ import type { Action } from "viewer/model/actions/actions"; import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; import { finishAnnotationStrokeAction } from "viewer/model/actions/volumetracing_actions"; import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; +import type { MagInfo } from "viewer/model/helpers/mag_info"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { takeEveryUnlessBusy } from "viewer/model/sagas/saga_helpers"; import type { MutableNode, Node } from "viewer/model/types/tree_types"; import { api } from "viewer/singletons"; -import type { MagInfo } from "../helpers/mag_info"; // By default, a new bounding box is created around // the seed nodes with a padding. Within the bounding box diff --git a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts index 6c775580077..cd826d62e3c 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts @@ -50,18 +50,18 @@ import { getEnabledColorLayers, getLayerBoundingBox, getMagInfo, -} from "../accessors/dataset_accessor"; -import { getTransformsForLayer } from "../accessors/dataset_layer_transformation_accessor"; -import { getActiveMagIndexForLayer } from "../accessors/flycam_accessor"; -import { updateUserSettingAction } from "../actions/settings_actions"; +} from "viewer/model/accessors/dataset_accessor"; +import { getTransformsForLayer } from "viewer/model/accessors/dataset_layer_transformation_accessor"; +import { getActiveMagIndexForLayer } from "viewer/model/accessors/flycam_accessor"; +import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; import { type EnterAction, type EscapeAction, showQuickSelectSettingsAction, -} from "../actions/ui_actions"; -import Dimensions, { type DimensionIndices } from "../dimensions"; -import { createVolumeLayer, labelWithVoxelBuffer2D } from "./volume/helpers"; -import { copyNdArray } from "./volume/volume_interpolation_saga"; +} from "viewer/model/actions/ui_actions"; +import Dimensions, { type DimensionIndices } from "viewer/model/dimensions"; +import { createVolumeLayer, labelWithVoxelBuffer2D } from "../helpers"; +import { copyNdArray } from "../volume_interpolation_saga"; const TOAST_KEY = "QUICKSELECT_PREVIEW_MESSAGE"; diff --git a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx index 54eea621f31..a7df7d1e78d 100644 --- a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx @@ -57,7 +57,7 @@ import { import { markVolumeTransactionEnd } from "viewer/model/bucket_data_handling/bucket"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select, take } from "viewer/model/sagas/effect-generators"; -import listenToMinCut from "viewer/model/sagas/min_cut_saga"; +import listenToMinCut from "viewer/model/sagas/volume/min_cut_saga"; import listenToQuickSelect from "viewer/model/sagas/volume/quick_select/quick_select_saga"; import { requestBucketModificationInVolumeTracing, diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index 3b9dfae2792..85c2af28950 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -124,7 +124,7 @@ import { updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import { extractPathAsNewTree } from "viewer/model/reducers/skeletontracing_reducer_helpers"; -import { isBoundingBoxUsableForMinCut } from "viewer/model/sagas/min_cut_saga"; +import { isBoundingBoxUsableForMinCut } from "viewer/model/sagas/volume/min_cut_saga"; import { getBoundingBoxInMag1 } from "viewer/model/sagas/volume/helpers"; import { voxelToVolumeInUnit } from "viewer/model/scaleinfo"; import { api } from "viewer/singletons"; From c771e9b96bd0208556850646355c60331c9679ff Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 09:30:33 +0200 Subject: [PATCH 77/92] split save sagas into filling and draining modules --- .../backend-snapshot-tests/annotations.e2e.ts | 2 +- .../javascripts/test/sagas/save_saga.spec.ts | 2 +- .../viewer/model/sagas/root_saga.ts | 7 +- .../viewer/model/sagas/saving/save_saga.ts | 521 +----------------- .../quick_select_heuristic_saga.ts | 30 +- .../volume/quick_select/quick_select_saga.ts | 2 +- .../viewer/model/sagas/volumetracing_saga.tsx | 4 +- .../javascripts/viewer/view/context_menu.tsx | 2 +- .../left-border-tabs/layer_settings_tab.tsx | 5 +- 9 files changed, 49 insertions(+), 526 deletions(-) diff --git a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts index 0d043a0b2bc..6ae2c424434 100644 --- a/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts +++ b/frontend/javascripts/test/backend-snapshot-tests/annotations.e2e.ts @@ -11,7 +11,6 @@ import { createTreeMapFromTreeArray } from "viewer/model/reducers/skeletontracin import { diffTrees } from "viewer/model/sagas/skeletontracing_saga"; import { getNullableSkeletonTracing } from "viewer/model/accessors/skeletontracing_accessor"; import { getServerVolumeTracings } from "viewer/model/accessors/volumetracing_accessor"; -import { addVersionNumbers } from "viewer/model/sagas/saving/save_saga"; import * as UpdateActions from "viewer/model/sagas/volume/update_actions"; import * as api from "admin/rest_api"; import generateDummyTrees from "viewer/model/helpers/generate_dummy_trees"; @@ -19,6 +18,7 @@ import { describe, it, beforeAll, expect } from "vitest"; import { createSaveQueueFromUpdateActions } from "../helpers/saveHelpers"; import type { SaveQueueEntry } from "viewer/store"; import DiffableMap from "libs/diffable_map"; +import { addVersionNumbers } from "viewer/model/sagas/saving/save_queue_draining"; const datasetId = "59e9cfbdba632ac2ab8b23b3"; diff --git a/frontend/javascripts/test/sagas/save_saga.spec.ts b/frontend/javascripts/test/sagas/save_saga.spec.ts index e6a0f56afd5..b3460f89180 100644 --- a/frontend/javascripts/test/sagas/save_saga.spec.ts +++ b/frontend/javascripts/test/sagas/save_saga.spec.ts @@ -16,7 +16,7 @@ import { sendSaveRequestToServer, toggleErrorHighlighting, addVersionNumbers, -} from "viewer/model/sagas/saving/save_saga"; +} from "viewer/model/sagas/saving/save_queue_draining"; import { TIMESTAMP } from "test/global_mocks"; import { sendSaveRequestWithToken } from "admin/rest_api"; diff --git a/frontend/javascripts/viewer/model/sagas/root_saga.ts b/frontend/javascripts/viewer/model/sagas/root_saga.ts index 8115cf830d0..ab5b1e169e6 100644 --- a/frontend/javascripts/viewer/model/sagas/root_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/root_saga.ts @@ -8,15 +8,15 @@ import listenToClipHistogramSaga from "viewer/model/sagas/clip_histogram_saga"; import DatasetSagas from "viewer/model/sagas/dataset_saga"; import type { Saga } from "viewer/model/sagas/effect-generators"; import loadHistogramDataSaga from "viewer/model/sagas/load_histogram_data_saga"; -import MappingSaga from "viewer/model/sagas/volume/mapping_saga"; import { watchDataRelevantChanges } from "viewer/model/sagas/prefetch_saga"; -import ProofreadSaga from "viewer/model/sagas/volume/proofread_saga"; import ReadySagas from "viewer/model/sagas/ready_sagas"; -import SaveSagas, { toggleErrorHighlighting } from "viewer/model/sagas/saving/save_saga"; +import SaveSagas from "viewer/model/sagas/saving/save_saga"; import SettingsSaga from "viewer/model/sagas/settings_saga"; import SkeletontracingSagas from "viewer/model/sagas/skeletontracing_saga"; import watchTasksAsync, { warnAboutMagRestriction } from "viewer/model/sagas/task_saga"; import UndoSaga from "viewer/model/sagas/undo_saga"; +import MappingSaga from "viewer/model/sagas/volume/mapping_saga"; +import ProofreadSaga from "viewer/model/sagas/volume/proofread_saga"; import VolumetracingSagas from "viewer/model/sagas/volumetracing_saga"; import type { EscalateErrorAction } from "../actions/actions"; import { setIsWkReadyAction } from "../actions/ui_actions"; @@ -24,6 +24,7 @@ import maintainMaximumZoomForAllMagsSaga from "./flycam_info_cache_saga"; import adHocMeshSaga from "./meshes/ad_hoc_mesh_saga"; import commonMeshSaga, { handleAdditionalCoordinateUpdate } from "./meshes/common_mesh_saga"; import precomputedMeshSaga from "./meshes/precomputed_mesh_saga"; +import { toggleErrorHighlighting } from "./saving/save_queue_draining"; import splitBoundaryMeshSaga from "./split_boundary_mesh_saga"; import { warnIfEmailIsUnverified } from "./user_saga"; diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts index 4b709efb80e..3df8bd2cf32 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts @@ -1,519 +1,38 @@ -import { getUpdateActionLog, sendSaveRequestWithToken } from "admin/rest_api"; -import Date from "libs/date"; +import { getUpdateActionLog } from "admin/rest_api"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; import { sleep } from "libs/utils"; -import window, { alert, document, location } from "libs/window"; import _ from "lodash"; -import memoizeOne from "memoize-one"; -import messages from "messages"; -import { buffers } from "redux-saga"; -import { actionChannel, call, delay, fork, put, race, take, takeEvery } from "typed-redux-saga"; +import { call, fork, put, takeEvery } from "typed-redux-saga"; import type { APIUpdateActionBatch } from "types/api_types"; -import { ControlModeEnum } from "viewer/constants"; -import { - getLayerByName, - getMagInfo, - getMappingInfo, -} from "viewer/model/accessors/dataset_accessor"; -import { selectTracing } from "viewer/model/accessors/tracing_accessor"; -import { FlycamActions } from "viewer/model/actions/flycam_actions"; -import { - type EnsureTracingsWereDiffedToSaveQueueAction, - pushSaveQueueTransaction, - setLastSaveTimestampAction, - setSaveBusyAction, - setVersionNumberAction, - shiftSaveQueueAction, -} from "viewer/model/actions/save_actions"; -import type { InitializeSkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; -import { - SkeletonTracingSaveRelevantActions, - applySkeletonUpdateActionsFromServerAction, -} from "viewer/model/actions/skeletontracing_actions"; -import { ViewModeSaveRelevantActions } from "viewer/model/actions/view_mode_actions"; -import { - type InitializeVolumeTracingAction, - VolumeTracingSaveRelevantActions, - applyVolumeUpdateActionsFromServerAction, -} from "viewer/model/actions/volumetracing_actions"; -import compactSaveQueue from "viewer/model/helpers/compaction/compact_save_queue"; -import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; -import { - globalPositionToBucketPosition, - globalPositionToBucketPositionWithMag, -} from "viewer/model/helpers/position_converter"; +import { getLayerByName, getMappingInfo } from "viewer/model/accessors/dataset_accessor"; +import { setVersionNumberAction } from "viewer/model/actions/save_actions"; +import { applySkeletonUpdateActionsFromServerAction } from "viewer/model/actions/skeletontracing_actions"; +import { applyVolumeUpdateActionsFromServerAction } from "viewer/model/actions/volumetracing_actions"; +import { globalPositionToBucketPositionWithMag } from "viewer/model/helpers/position_converter"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { ensureWkReady } from "viewer/model/sagas/ready_sagas"; -import { - MAXIMUM_ACTION_COUNT_PER_SAVE, - MAX_SAVE_RETRY_WAITING_TIME, - PUSH_THROTTLE_TIME, - SAVE_RETRY_WAITING_TIME, -} from "viewer/model/sagas/saving/save_saga_constants"; -import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; -import { - type UpdateActionWithoutIsolationRequirement, - updateCameraAnnotation, - updateTdCamera, -} from "viewer/model/sagas/volume/update_actions"; -import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; import { Model } from "viewer/singletons"; -import type { - CameraData, - Flycam, - SaveQueueEntry, - SkeletonTracing, - VolumeTracing, -} from "viewer/store"; -import { getFlooredPosition, getRotation } from "../../accessors/flycam_accessor"; -import type { Action } from "../../actions/actions"; -import type { BatchedAnnotationInitializationAction } from "../../actions/annotation_actions"; -import { updateLocalHdf5Mapping } from "../volume/mapping_saga"; -import { removeAgglomerateFromActiveMapping, updateMappingWithMerge } from "../volume/proofread_saga"; +import type { SkeletonTracing, VolumeTracing } from "viewer/store"; import { takeEveryWithBatchActionSupport } from "../saga_helpers"; - -const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; - -export function* pushSaveQueueAsync(): Saga { - yield* call(ensureWkReady); - - yield* put(setLastSaveTimestampAction()); - let loopCounter = 0; - - while (true) { - loopCounter++; - let saveQueue; - // Check whether the save queue is actually empty, the PUSH_SAVE_QUEUE_TRANSACTION action - // could have been triggered during the call to sendSaveRequestToServer - saveQueue = yield* select((state) => state.save.queue); - - if (saveQueue.length === 0) { - if (loopCounter % 100 === 0) { - // See https://github.com/scalableminds/webknossos/pull/6076 (or 82e16e1) for an explanation - // of this delay call. - yield* delay(0); - } - - // Save queue is empty, wait for push event - yield* take("PUSH_SAVE_QUEUE_TRANSACTION"); - } - - const { forcePush } = yield* race({ - timeout: delay(PUSH_THROTTLE_TIME), - forcePush: take("SAVE_NOW"), - }); - yield* put(setSaveBusyAction(true)); - - // Send (parts of) the save queue to the server. - // There are two main cases: - // 1) forcePush is true - // The user explicitly requested to save an annotation. - // In this case, batches are sent to the server until the save - // queue is empty. Note that the save queue might be added to - // while saving is in progress. Still, the save queue will be - // drained until it is empty. If the user hits save and continuously - // annotates further, a high number of save-requests might be sent. - // 2) forcePush is false - // The auto-save interval was reached at time T. The following code - // will determine how many items are in the save queue at this time T. - // Exactly that many items will be sent to the server. - // New items that might be added to the save queue during saving, will be - // ignored (they will be picked up in the next iteration of this loop). - // Otherwise, the risk of a high number of save-requests (see case 1) - // would be present here, too (note the risk would be greater, because the - // user didn't use the save button which is usually accompanied by a small pause). - const itemCountToSave = forcePush - ? Number.POSITIVE_INFINITY - : yield* select((state) => state.save.queue.length); - let savedItemCount = 0; - while (savedItemCount < itemCountToSave) { - saveQueue = yield* select((state) => state.save.queue); - - if (saveQueue.length > 0) { - savedItemCount += yield* call(sendSaveRequestToServer); - } else { - break; - } - } - yield* put(setSaveBusyAction(false)); - } -} - -// This function returns the first n batches of the provided array, so that the count of -// all actions in these n batches does not exceed MAXIMUM_ACTION_COUNT_PER_SAVE -function sliceAppropriateBatchCount(batches: Array): Array { - const slicedBatches = []; - let actionCount = 0; - - for (const batch of batches) { - const newActionCount = actionCount + batch.actions.length; - - if (newActionCount <= MAXIMUM_ACTION_COUNT_PER_SAVE) { - actionCount = newActionCount; - slicedBatches.push(batch); - } else { - break; - } - } - - return slicedBatches; -} - -function getRetryWaitTime(retryCount: number) { - // Exponential backoff up until MAX_SAVE_RETRY_WAITING_TIME - return Math.min(2 ** retryCount * SAVE_RETRY_WAITING_TIME, MAX_SAVE_RETRY_WAITING_TIME); -} - -// The value for this boolean does not need to be restored to false -// at any time, because the browser page is reloaded after the message is shown, anyway. -let didShowFailedSimultaneousTracingError = false; - -export function* sendSaveRequestToServer(): Saga { - /* - * Saves a reasonably-sized part of the save queue to the server (plus retry-mechanism). - * The saga returns the number of save queue items that were saved. - */ - - const fullSaveQueue = yield* select((state) => state.save.queue); - const saveQueue = sliceAppropriateBatchCount(fullSaveQueue); - let compactedSaveQueue = compactSaveQueue(saveQueue); - const version = yield* select((state) => state.annotation.version); - const annotationId = yield* select((state) => state.annotation.annotationId); - const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); - let versionIncrement; - [compactedSaveQueue, versionIncrement] = addVersionNumbers(compactedSaveQueue, version); - let retryCount = 0; - - // This while-loop only exists for the purpose of a retry-mechanism - while (true) { - let exceptionDuringMarkBucketsAsNotDirty = false; - - try { - const startTime = Date.now(); - yield* call( - sendSaveRequestWithToken, - `${tracingStoreUrl}/tracings/annotation/${annotationId}/update?token=`, - { - method: "POST", - data: compactedSaveQueue, - compress: process.env.NODE_ENV === "production", - // Suppressing error toast, as the doWithToken retry with personal token functionality should not show an error. - // Instead the error is logged and toggleErrorHighlighting should take care of showing an error to the user. - showErrorToast: false, - }, - ); - const endTime = Date.now(); - - if (endTime - startTime > PUSH_THROTTLE_TIME) { - yield* call( - [ErrorHandling, ErrorHandling.notify], - new Error( - `Warning: Save request took more than ${Math.ceil(PUSH_THROTTLE_TIME / 1000)} seconds.`, - ), - ); - } - - yield* put(setVersionNumberAction(version + versionIncrement)); - yield* put(setLastSaveTimestampAction()); - yield* put(shiftSaveQueueAction(saveQueue.length)); - - try { - yield* call(markBucketsAsNotDirty, compactedSaveQueue); - } catch (error) { - // If markBucketsAsNotDirty fails some reason, wk cannot recover from this error. - console.warn("Error when marking buckets as clean. No retry possible. Error:", error); - exceptionDuringMarkBucketsAsNotDirty = true; - throw error; - } - - yield* call(toggleErrorHighlighting, false); - return saveQueue.length; - } catch (error) { - if (exceptionDuringMarkBucketsAsNotDirty) { - throw error; - } - - console.warn("Error during saving. Will retry. Error:", error); - const controlMode = yield* select((state) => state.temporaryConfiguration.controlMode); - const isViewOrSandboxMode = - controlMode === ControlModeEnum.VIEW || controlMode === ControlModeEnum.SANDBOX; - - if (!isViewOrSandboxMode) { - // Notify user about error unless, view or sandbox mode is active. In that case, - // we do not need to show the error as it is not so important and distracts the user. - yield* call(toggleErrorHighlighting, true); - } - - // Log the error to airbrake. Also compactedSaveQueue needs to be within an object - // as otherwise the entries would be spread by the notify function. - // @ts-ignore - yield* call({ context: ErrorHandling, fn: ErrorHandling.notify }, error, { - compactedSaveQueue, - retryCount, - }); - - // @ts-ignore - if (error.status === 409) { - // HTTP Code 409 'conflict' for dirty state - // @ts-ignore - window.onbeforeunload = null; - yield* call( - [ErrorHandling, ErrorHandling.notify], - new Error("Saving failed due to '409' status code"), - ); - if (!didShowFailedSimultaneousTracingError) { - // If the saving fails for one tracing (e.g., skeleton), it can also - // fail for another tracing (e.g., volume). The message simply tells the - // user that the saving in general failed. So, there is no sense in showing - // the message multiple times. - yield* call(alert, messages["save.failed_simultaneous_tracing"]); - location.reload(); - didShowFailedSimultaneousTracingError = true; - } - - // Wait "forever" to avoid that the caller initiates other save calls afterwards (e.g., - // can happen if the caller tries to force-flush the save queue). - // The reason we don't throw an error immediately is that this would immediately - // crash all sagas (including saving other tracings). - yield* call(sleep, ONE_YEAR_MS); - throw new Error("Saving failed due to conflict."); - } - - yield* race({ - timeout: delay(getRetryWaitTime(retryCount)), - forcePush: take("SAVE_NOW"), - }); - retryCount++; - } - } -} - -function* markBucketsAsNotDirty(saveQueue: Array) { - const getLayerAndMagInfoForTracingId = memoizeOne((tracingId: string) => { - const segmentationLayer = Model.getSegmentationTracingLayer(tracingId); - const segmentationMagInfo = getMagInfo(segmentationLayer.mags); - return [segmentationLayer, segmentationMagInfo] as const; - }); - for (const saveEntry of saveQueue) { - for (const updateAction of saveEntry.actions) { - if (updateAction.name === "updateBucket") { - const { actionTracingId: tracingId } = updateAction.value; - const [segmentationLayer, segmentationMagInfo] = getLayerAndMagInfoForTracingId(tracingId); - - const { position, mag, additionalCoordinates } = updateAction.value; - const magIndex = segmentationMagInfo.getIndexByMag(mag); - const zoomedBucketAddress = globalPositionToBucketPosition( - position, - segmentationMagInfo.getDenseMags(), - magIndex, - additionalCoordinates, - ); - const bucket = segmentationLayer.cube.getOrCreateBucket(zoomedBucketAddress); - - if (bucket.type === "null") { - continue; - } - - bucket.dirtyCount--; - - if (bucket.dirtyCount === 0) { - bucket.markAsPushed(); - } - } - } - } -} - -export function toggleErrorHighlighting(state: boolean, permanentError: boolean = false): void { - if (document.body != null) { - document.body.classList.toggle("save-error", state); - } - - const message = permanentError ? messages["save.failed.permanent"] : messages["save.failed"]; - - if (state) { - Toast.error(message, { - sticky: true, - }); - } else { - Toast.close(message); - } -} -export function addVersionNumbers( - updateActionsBatches: Array, - lastVersion: number, -): [Array, number] { - let versionIncrement = 0; - const batchesWithVersions = updateActionsBatches.map((batch) => { - if (batch.transactionGroupIndex === 0) { - versionIncrement++; - } - return { ...batch, version: lastVersion + versionIncrement }; - }); - return [batchesWithVersions, versionIncrement]; -} -export function performDiffTracing( - prevTracing: SkeletonTracing | VolumeTracing, - tracing: SkeletonTracing | VolumeTracing, -): Array { - let actions: Array = []; - - if (prevTracing.type === "skeleton" && tracing.type === "skeleton") { - actions = actions.concat(Array.from(diffSkeletonTracing(prevTracing, tracing))); - } - - if (prevTracing.type === "volume" && tracing.type === "volume") { - actions = actions.concat(Array.from(diffVolumeTracing(prevTracing, tracing))); - } - - return actions; -} - -export function performDiffAnnotation( - prevFlycam: Flycam, - flycam: Flycam, - prevTdCamera: CameraData, - tdCamera: CameraData, -): Array { - let actions: Array = []; - - if (prevFlycam !== flycam) { - actions = actions.concat( - updateCameraAnnotation( - getFlooredPosition(flycam), - flycam.additionalCoordinates, - getRotation(flycam), - flycam.zoomStep, - ), - ); - } - - if (prevTdCamera !== tdCamera) { - actions = actions.concat(updateTdCamera()); - } - - return actions; -} - -export function* saveTracingAsync(): Saga { +import { updateLocalHdf5Mapping } from "../volume/mapping_saga"; +import { + removeAgglomerateFromActiveMapping, + updateMappingWithMerge, +} from "../volume/proofread_saga"; +import { pushSaveQueueAsync } from "./save_queue_draining"; +import { setupSavingForAnnotation, setupSavingForTracingType } from "./save_queue_filling"; + +export function* setupSavingToServer(): Saga { + // This saga continously drains the save queue by sending its content to the server. yield* fork(pushSaveQueueAsync); + // The following sagas are responsible for filling the save queue with the update actions. yield* takeEvery("INITIALIZE_ANNOTATION_WITH_TRACINGS", setupSavingForAnnotation); yield* takeEveryWithBatchActionSupport("INITIALIZE_SKELETONTRACING", setupSavingForTracingType); yield* takeEveryWithBatchActionSupport("INITIALIZE_VOLUMETRACING", setupSavingForTracingType); } -function* setupSavingForAnnotation(_action: BatchedAnnotationInitializationAction): Saga { - yield* call(ensureWkReady); - - while (true) { - let prevFlycam = yield* select((state) => state.flycam); - let prevTdCamera = yield* select((state) => state.viewModeData.plane.tdCamera); - yield* take([ - ...FlycamActions, - ...ViewModeSaveRelevantActions, - ...SkeletonTracingSaveRelevantActions, - ]); - // The allowUpdate setting could have changed in the meantime - const allowUpdate = yield* select( - (state) => - state.annotation.restrictions.allowUpdate && state.annotation.restrictions.allowSave, - ); - if (!allowUpdate) continue; - const flycam = yield* select((state) => state.flycam); - const tdCamera = yield* select((state) => state.viewModeData.plane.tdCamera); - - const items = Array.from( - yield* call(performDiffAnnotation, prevFlycam, flycam, prevTdCamera, tdCamera), - ); - - if (items.length > 0) { - yield* put(pushSaveQueueTransaction(items)); - } - - prevFlycam = flycam; - prevTdCamera = tdCamera; - } -} - -export function* setupSavingForTracingType( - initializeAction: InitializeSkeletonTracingAction | InitializeVolumeTracingAction, -): Saga { - /* - Listen to changes to the annotation and derive UpdateActions from the - old and new state. - The actual push to the server is done by the forked pushSaveQueueAsync saga. - */ - const tracingType = - initializeAction.type === "INITIALIZE_SKELETONTRACING" ? "skeleton" : "volume"; - const tracingId = initializeAction.tracing.id; - let prevTracing = (yield* select((state) => selectTracing(state, tracingType, tracingId))) as - | VolumeTracing - | SkeletonTracing; - - yield* call(ensureWkReady); - - const actionBuffer = buffers.expanding(); - const tracingActionChannel = yield* actionChannel( - tracingType === "skeleton" - ? [ - ...SkeletonTracingSaveRelevantActions, - // SET_SKELETON_TRACING is not included in SkeletonTracingSaveRelevantActions, because it is used by Undo/Redo and - // should not create its own Undo/Redo stack entry - "SET_SKELETON_TRACING", - ] - : VolumeTracingSaveRelevantActions, - actionBuffer, - ); - - // See Model.ensureSavedState for an explanation of this action channel. - const ensureDiffedChannel = yield* actionChannel( - "ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE", - ); - - while (true) { - // Prioritize consumption of tracingActionChannel since we don't want to - // reply to the ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE action if there - // are unprocessed user actions. - if (!actionBuffer.isEmpty()) { - yield* take(tracingActionChannel); - } else { - // Wait for either a user action or the "ensureAction". - const { ensureAction } = yield* race({ - _tracingAction: take(tracingActionChannel), - ensureAction: take(ensureDiffedChannel), - }); - if (ensureAction != null) { - ensureAction.callback(tracingId); - continue; - } - } - - // The allowUpdate setting could have changed in the meantime - const allowUpdate = yield* select( - (state) => - state.annotation.restrictions.allowUpdate && state.annotation.restrictions.allowSave, - ); - if (!allowUpdate) continue; - const tracing = (yield* select((state) => selectTracing(state, tracingType, tracingId))) as - | VolumeTracing - | SkeletonTracing; - - const items = compactUpdateActions( - Array.from(yield* call(performDiffTracing, prevTracing, tracing)), - prevTracing, - tracing, - ); - - if (items.length > 0) { - yield* put(pushSaveQueueTransaction(items)); - } - - prevTracing = tracing; - } -} - // todop: restore to 10, 60, 30 ? const VERSION_POLL_INTERVAL_COLLAB = 1 * 1000; const VERSION_POLL_INTERVAL_READ_ONLY = 1 * 1000; @@ -865,4 +384,4 @@ export function* tryToIncorporateActions( return { success: true }; } -export default [saveTracingAsync, watchForSaveConflicts]; +export default [setupSavingToServer, watchForSaveConflicts]; diff --git a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts index cd826d62e3c..66af30f6c3d 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_heuristic_saga.ts @@ -21,10 +21,24 @@ import ndarray from "ndarray"; import { call, put, race, take } from "typed-redux-saga"; import type { APIDataLayer, APIDataset } from "types/api_types"; import type { QuickSelectGeometry } from "viewer/geometries/helper_geometries"; +import { + getDefaultValueRangeOfLayer, + getEnabledColorLayers, + getLayerBoundingBox, + getMagInfo, +} from "viewer/model/accessors/dataset_accessor"; +import { getTransformsForLayer } from "viewer/model/accessors/dataset_layer_transformation_accessor"; +import { getActiveMagIndexForLayer } from "viewer/model/accessors/flycam_accessor"; import { getActiveSegmentationTracing, getSegmentationLayerForTracing, } from "viewer/model/accessors/volumetracing_accessor"; +import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; +import { + type EnterAction, + type EscapeAction, + showQuickSelectSettingsAction, +} from "viewer/model/actions/ui_actions"; import { type CancelQuickSelectAction, type ComputeQuickSelectForPointAction, @@ -36,6 +50,7 @@ import { updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; +import Dimensions, { type DimensionIndices } from "viewer/model/dimensions"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { api } from "viewer/singletons"; @@ -45,21 +60,6 @@ import type { VolumeTracing, WebknossosState, } from "viewer/store"; -import { - getDefaultValueRangeOfLayer, - getEnabledColorLayers, - getLayerBoundingBox, - getMagInfo, -} from "viewer/model/accessors/dataset_accessor"; -import { getTransformsForLayer } from "viewer/model/accessors/dataset_layer_transformation_accessor"; -import { getActiveMagIndexForLayer } from "viewer/model/accessors/flycam_accessor"; -import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; -import { - type EnterAction, - type EscapeAction, - showQuickSelectSettingsAction, -} from "viewer/model/actions/ui_actions"; -import Dimensions, { type DimensionIndices } from "viewer/model/dimensions"; import { createVolumeLayer, labelWithVoxelBuffer2D } from "../helpers"; import { copyNdArray } from "../volume_interpolation_saga"; diff --git a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts index b10a92b1472..324165db311 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/quick_select/quick_select_saga.ts @@ -13,9 +13,9 @@ import getSceneController from "viewer/controller/scene_controller_provider"; import type { VolumeTracing } from "viewer/store"; import { getActiveSegmentationTracing } from "../../../accessors/volumetracing_accessor"; import { setBusyBlockingInfoAction, setQuickSelectStateAction } from "../../../actions/ui_actions"; +import { requestBucketModificationInVolumeTracing } from "../../saga_helpers"; import performQuickSelectHeuristic from "./quick_select_heuristic_saga"; import performQuickSelectML from "./quick_select_ml_saga"; -import { requestBucketModificationInVolumeTracing } from "../../saga_helpers"; function* shouldUseHeuristic() { const useHeuristic = yield* select((state) => state.userConfiguration.quickSelect.useHeuristic); diff --git a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx index a7df7d1e78d..1eb7a78be93 100644 --- a/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/volumetracing_saga.tsx @@ -57,13 +57,13 @@ import { import { markVolumeTransactionEnd } from "viewer/model/bucket_data_handling/bucket"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select, take } from "viewer/model/sagas/effect-generators"; -import listenToMinCut from "viewer/model/sagas/volume/min_cut_saga"; -import listenToQuickSelect from "viewer/model/sagas/volume/quick_select/quick_select_saga"; import { requestBucketModificationInVolumeTracing, takeEveryUnlessBusy, takeWithBatchActionSupport, } from "viewer/model/sagas/saga_helpers"; +import listenToMinCut from "viewer/model/sagas/volume/min_cut_saga"; +import listenToQuickSelect from "viewer/model/sagas/volume/quick_select/quick_select_saga"; import { type UpdateActionWithoutIsolationRequirement, createSegmentVolumeAction, diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index 85c2af28950..f2b2b385b66 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -124,8 +124,8 @@ import { updateSegmentAction, } from "viewer/model/actions/volumetracing_actions"; import { extractPathAsNewTree } from "viewer/model/reducers/skeletontracing_reducer_helpers"; -import { isBoundingBoxUsableForMinCut } from "viewer/model/sagas/volume/min_cut_saga"; import { getBoundingBoxInMag1 } from "viewer/model/sagas/volume/helpers"; +import { isBoundingBoxUsableForMinCut } from "viewer/model/sagas/volume/min_cut_saga"; import { voxelToVolumeInUnit } from "viewer/model/scaleinfo"; import { api } from "viewer/singletons"; import type { diff --git a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx index 3bc2280c212..7cbcd1c222a 100644 --- a/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx +++ b/frontend/javascripts/viewer/view/left-border-tabs/layer_settings_tab.tsx @@ -111,7 +111,10 @@ import { setNodeRadiusAction, setShowSkeletonsAction, } from "viewer/model/actions/skeletontracing_actions"; -import { addLayerToAnnotation, deleteAnnotationLayer } from "viewer/model/sagas/volume/update_actions"; +import { + addLayerToAnnotation, + deleteAnnotationLayer, +} from "viewer/model/sagas/volume/update_actions"; import { Model, api } from "viewer/singletons"; import type { DatasetConfiguration, From 3abc0a9cd7c2583410d2c8464238aa0164140f7d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 25 Jun 2025 09:31:12 +0200 Subject: [PATCH 78/92] add missing files --- .../model/sagas/saving/save_queue_draining.ts | 317 ++++++++++++++++++ .../model/sagas/saving/save_queue_filling.ts | 191 +++++++++++ 2 files changed, 508 insertions(+) create mode 100644 frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts create mode 100644 frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts new file mode 100644 index 00000000000..8c86d09cae8 --- /dev/null +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -0,0 +1,317 @@ +// /* +// * This module contains the sagas responsible for sending the contents of the save queue +// * to the back-end (thus, draining the queue). +// */ + +import { sendSaveRequestWithToken } from "admin/rest_api"; +import Date from "libs/date"; +import ErrorHandling from "libs/error_handling"; +import Toast from "libs/toast"; +import { sleep } from "libs/utils"; +import window, { alert, document, location } from "libs/window"; +import memoizeOne from "memoize-one"; +import messages from "messages"; +import { call, delay, put, race, take } from "typed-redux-saga"; +import { ControlModeEnum } from "viewer/constants"; +import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; +import { + setLastSaveTimestampAction, + setSaveBusyAction, + setVersionNumberAction, + shiftSaveQueueAction, +} from "viewer/model/actions/save_actions"; +import compactSaveQueue from "viewer/model/helpers/compaction/compact_save_queue"; +import { globalPositionToBucketPosition } from "viewer/model/helpers/position_converter"; +import type { Saga } from "viewer/model/sagas/effect-generators"; +import { select } from "viewer/model/sagas/effect-generators"; +import { ensureWkReady } from "viewer/model/sagas/ready_sagas"; +import { + MAXIMUM_ACTION_COUNT_PER_SAVE, + MAX_SAVE_RETRY_WAITING_TIME, + PUSH_THROTTLE_TIME, + SAVE_RETRY_WAITING_TIME, +} from "viewer/model/sagas/saving/save_saga_constants"; +import { Model } from "viewer/singletons"; +import type { SaveQueueEntry } from "viewer/store"; + +const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; + +export function* pushSaveQueueAsync(): Saga { + yield* call(ensureWkReady); + + yield* put(setLastSaveTimestampAction()); + let loopCounter = 0; + + while (true) { + loopCounter++; + let saveQueue; + // Check whether the save queue is actually empty, the PUSH_SAVE_QUEUE_TRANSACTION action + // could have been triggered during the call to sendSaveRequestToServer + saveQueue = yield* select((state) => state.save.queue); + + if (saveQueue.length === 0) { + if (loopCounter % 100 === 0) { + // See https://github.com/scalableminds/webknossos/pull/6076 (or 82e16e1) for an explanation + // of this delay call. + yield* delay(0); + } + + // Save queue is empty, wait for push event + yield* take("PUSH_SAVE_QUEUE_TRANSACTION"); + } + + const { forcePush } = yield* race({ + timeout: delay(PUSH_THROTTLE_TIME), + forcePush: take("SAVE_NOW"), + }); + yield* put(setSaveBusyAction(true)); + + // Send (parts of) the save queue to the server. + // There are two main cases: + // 1) forcePush is true + // The user explicitly requested to save an annotation. + // In this case, batches are sent to the server until the save + // queue is empty. Note that the save queue might be added to + // while saving is in progress. Still, the save queue will be + // drained until it is empty. If the user hits save and continuously + // annotates further, a high number of save-requests might be sent. + // 2) forcePush is false + // The auto-save interval was reached at time T. The following code + // will determine how many items are in the save queue at this time T. + // Exactly that many items will be sent to the server. + // New items that might be added to the save queue during saving, will be + // ignored (they will be picked up in the next iteration of this loop). + // Otherwise, the risk of a high number of save-requests (see case 1) + // would be present here, too (note the risk would be greater, because the + // user didn't use the save button which is usually accompanied by a small pause). + const itemCountToSave = forcePush + ? Number.POSITIVE_INFINITY + : yield* select((state) => state.save.queue.length); + let savedItemCount = 0; + while (savedItemCount < itemCountToSave) { + saveQueue = yield* select((state) => state.save.queue); + + if (saveQueue.length > 0) { + savedItemCount += yield* call(sendSaveRequestToServer); + } else { + break; + } + } + yield* put(setSaveBusyAction(false)); + } +} + +function getRetryWaitTime(retryCount: number) { + // Exponential backoff up until MAX_SAVE_RETRY_WAITING_TIME + return Math.min(2 ** retryCount * SAVE_RETRY_WAITING_TIME, MAX_SAVE_RETRY_WAITING_TIME); +} + +// The value for this boolean does not need to be restored to false +// at any time, because the browser page is reloaded after the message is shown, anyway. +let didShowFailedSimultaneousTracingError = false; + +export function* sendSaveRequestToServer(): Saga { + /* + * Saves a reasonably-sized part of the save queue to the server (plus retry-mechanism). + * The saga returns the number of save queue items that were saved. + */ + + const fullSaveQueue = yield* select((state) => state.save.queue); + const saveQueue = sliceAppropriateBatchCount(fullSaveQueue); + let compactedSaveQueue = compactSaveQueue(saveQueue); + const version = yield* select((state) => state.annotation.version); + const annotationId = yield* select((state) => state.annotation.annotationId); + const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); + let versionIncrement; + [compactedSaveQueue, versionIncrement] = addVersionNumbers(compactedSaveQueue, version); + let retryCount = 0; + + // This while-loop only exists for the purpose of a retry-mechanism + while (true) { + let exceptionDuringMarkBucketsAsNotDirty = false; + + try { + const startTime = Date.now(); + yield* call( + sendSaveRequestWithToken, + `${tracingStoreUrl}/tracings/annotation/${annotationId}/update?token=`, + { + method: "POST", + data: compactedSaveQueue, + compress: process.env.NODE_ENV === "production", + // Suppressing error toast, as the doWithToken retry with personal token functionality should not show an error. + // Instead the error is logged and toggleErrorHighlighting should take care of showing an error to the user. + showErrorToast: false, + }, + ); + const endTime = Date.now(); + + if (endTime - startTime > PUSH_THROTTLE_TIME) { + yield* call( + [ErrorHandling, ErrorHandling.notify], + new Error( + `Warning: Save request took more than ${Math.ceil(PUSH_THROTTLE_TIME / 1000)} seconds.`, + ), + ); + } + + yield* put(setVersionNumberAction(version + versionIncrement)); + yield* put(setLastSaveTimestampAction()); + yield* put(shiftSaveQueueAction(saveQueue.length)); + + try { + yield* call(markBucketsAsNotDirty, compactedSaveQueue); + } catch (error) { + // If markBucketsAsNotDirty fails some reason, wk cannot recover from this error. + console.warn("Error when marking buckets as clean. No retry possible. Error:", error); + exceptionDuringMarkBucketsAsNotDirty = true; + throw error; + } + + yield* call(toggleErrorHighlighting, false); + return saveQueue.length; + } catch (error) { + if (exceptionDuringMarkBucketsAsNotDirty) { + throw error; + } + + console.warn("Error during saving. Will retry. Error:", error); + const controlMode = yield* select((state) => state.temporaryConfiguration.controlMode); + const isViewOrSandboxMode = + controlMode === ControlModeEnum.VIEW || controlMode === ControlModeEnum.SANDBOX; + + if (!isViewOrSandboxMode) { + // Notify user about error unless, view or sandbox mode is active. In that case, + // we do not need to show the error as it is not so important and distracts the user. + yield* call(toggleErrorHighlighting, true); + } + + // Log the error to airbrake. Also compactedSaveQueue needs to be within an object + // as otherwise the entries would be spread by the notify function. + // @ts-ignore + yield* call({ context: ErrorHandling, fn: ErrorHandling.notify }, error, { + compactedSaveQueue, + retryCount, + }); + + // @ts-ignore + if (error.status === 409) { + // HTTP Code 409 'conflict' for dirty state + // @ts-ignore + window.onbeforeunload = null; + yield* call( + [ErrorHandling, ErrorHandling.notify], + new Error("Saving failed due to '409' status code"), + ); + if (!didShowFailedSimultaneousTracingError) { + // If the saving fails for one tracing (e.g., skeleton), it can also + // fail for another tracing (e.g., volume). The message simply tells the + // user that the saving in general failed. So, there is no sense in showing + // the message multiple times. + yield* call(alert, messages["save.failed_simultaneous_tracing"]); + location.reload(); + didShowFailedSimultaneousTracingError = true; + } + + // Wait "forever" to avoid that the caller initiates other save calls afterwards (e.g., + // can happen if the caller tries to force-flush the save queue). + // The reason we don't throw an error immediately is that this would immediately + // crash all sagas (including saving other tracings). + yield* call(sleep, ONE_YEAR_MS); + throw new Error("Saving failed due to conflict."); + } + + yield* race({ + timeout: delay(getRetryWaitTime(retryCount)), + forcePush: take("SAVE_NOW"), + }); + retryCount++; + } + } +} + +function* markBucketsAsNotDirty(saveQueue: Array) { + const getLayerAndMagInfoForTracingId = memoizeOne((tracingId: string) => { + const segmentationLayer = Model.getSegmentationTracingLayer(tracingId); + const segmentationMagInfo = getMagInfo(segmentationLayer.mags); + return [segmentationLayer, segmentationMagInfo] as const; + }); + for (const saveEntry of saveQueue) { + for (const updateAction of saveEntry.actions) { + if (updateAction.name === "updateBucket") { + const { actionTracingId: tracingId } = updateAction.value; + const [segmentationLayer, segmentationMagInfo] = getLayerAndMagInfoForTracingId(tracingId); + + const { position, mag, additionalCoordinates } = updateAction.value; + const magIndex = segmentationMagInfo.getIndexByMag(mag); + const zoomedBucketAddress = globalPositionToBucketPosition( + position, + segmentationMagInfo.getDenseMags(), + magIndex, + additionalCoordinates, + ); + const bucket = segmentationLayer.cube.getOrCreateBucket(zoomedBucketAddress); + + if (bucket.type === "null") { + continue; + } + + bucket.dirtyCount--; + + if (bucket.dirtyCount === 0) { + bucket.markAsPushed(); + } + } + } + } +} + +export function toggleErrorHighlighting(state: boolean, permanentError: boolean = false): void { + if (document.body != null) { + document.body.classList.toggle("save-error", state); + } + + const message = permanentError ? messages["save.failed.permanent"] : messages["save.failed"]; + + if (state) { + Toast.error(message, { + sticky: true, + }); + } else { + Toast.close(message); + } +} + +// This function returns the first n batches of the provided array, so that the count of +// all actions in these n batches does not exceed MAXIMUM_ACTION_COUNT_PER_SAVE +function sliceAppropriateBatchCount(batches: Array): Array { + const slicedBatches = []; + let actionCount = 0; + + for (const batch of batches) { + const newActionCount = actionCount + batch.actions.length; + + if (newActionCount <= MAXIMUM_ACTION_COUNT_PER_SAVE) { + actionCount = newActionCount; + slicedBatches.push(batch); + } else { + break; + } + } + + return slicedBatches; +} + +export function addVersionNumbers( + updateActionsBatches: Array, + lastVersion: number, +): [Array, number] { + let versionIncrement = 0; + const batchesWithVersions = updateActionsBatches.map((batch) => { + if (batch.transactionGroupIndex === 0) { + versionIncrement++; + } + return { ...batch, version: lastVersion + versionIncrement }; + }); + return [batchesWithVersions, versionIncrement]; +} diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts new file mode 100644 index 00000000000..f25c68947a3 --- /dev/null +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_filling.ts @@ -0,0 +1,191 @@ +/* + * This module contains the sagas responsible for populating the save queue + * with update actions that need to be saved to the server. Note that for proofreading, + * the proofreading saga is directly responsible for filling the queue. + */ + +import { buffers } from "redux-saga"; +import { actionChannel, call, put, race, take } from "typed-redux-saga"; +import { selectTracing } from "viewer/model/accessors/tracing_accessor"; +import { FlycamActions } from "viewer/model/actions/flycam_actions"; +import { + type EnsureTracingsWereDiffedToSaveQueueAction, + pushSaveQueueTransaction, +} from "viewer/model/actions/save_actions"; +import type { InitializeSkeletonTracingAction } from "viewer/model/actions/skeletontracing_actions"; +import { SkeletonTracingSaveRelevantActions } from "viewer/model/actions/skeletontracing_actions"; +import { ViewModeSaveRelevantActions } from "viewer/model/actions/view_mode_actions"; +import { + type InitializeVolumeTracingAction, + VolumeTracingSaveRelevantActions, +} from "viewer/model/actions/volumetracing_actions"; +import compactUpdateActions from "viewer/model/helpers/compaction/compact_update_actions"; +import type { Saga } from "viewer/model/sagas/effect-generators"; +import { select } from "viewer/model/sagas/effect-generators"; +import { ensureWkReady } from "viewer/model/sagas/ready_sagas"; +import { diffSkeletonTracing } from "viewer/model/sagas/skeletontracing_saga"; +import { + type UpdateActionWithoutIsolationRequirement, + updateCameraAnnotation, + updateTdCamera, +} from "viewer/model/sagas/volume/update_actions"; +import { diffVolumeTracing } from "viewer/model/sagas/volumetracing_saga"; +import type { CameraData, Flycam, SkeletonTracing, VolumeTracing } from "viewer/store"; +import { getFlooredPosition, getRotation } from "../../accessors/flycam_accessor"; +import type { Action } from "../../actions/actions"; +import type { BatchedAnnotationInitializationAction } from "../../actions/annotation_actions"; + +export function* setupSavingForAnnotation( + _action: BatchedAnnotationInitializationAction, +): Saga { + yield* call(ensureWkReady); + + while (true) { + let prevFlycam = yield* select((state) => state.flycam); + let prevTdCamera = yield* select((state) => state.viewModeData.plane.tdCamera); + yield* take([ + ...FlycamActions, + ...ViewModeSaveRelevantActions, + ...SkeletonTracingSaveRelevantActions, + ]); + // The allowUpdate setting could have changed in the meantime + const allowUpdate = yield* select( + (state) => + state.annotation.restrictions.allowUpdate && state.annotation.restrictions.allowSave, + ); + if (!allowUpdate) continue; + const flycam = yield* select((state) => state.flycam); + const tdCamera = yield* select((state) => state.viewModeData.plane.tdCamera); + + const items = Array.from( + yield* call(performDiffAnnotation, prevFlycam, flycam, prevTdCamera, tdCamera), + ); + + if (items.length > 0) { + yield* put(pushSaveQueueTransaction(items)); + } + + prevFlycam = flycam; + prevTdCamera = tdCamera; + } +} + +export function* setupSavingForTracingType( + initializeAction: InitializeSkeletonTracingAction | InitializeVolumeTracingAction, +): Saga { + /* + Listen to changes to the annotation and derive UpdateActions from the + old and new state. + The actual push to the server is done by the forked pushSaveQueueAsync saga. + */ + const tracingType = + initializeAction.type === "INITIALIZE_SKELETONTRACING" ? "skeleton" : "volume"; + const tracingId = initializeAction.tracing.id; + let prevTracing = (yield* select((state) => selectTracing(state, tracingType, tracingId))) as + | VolumeTracing + | SkeletonTracing; + + yield* call(ensureWkReady); + + const actionBuffer = buffers.expanding(); + const tracingActionChannel = yield* actionChannel( + tracingType === "skeleton" + ? [ + ...SkeletonTracingSaveRelevantActions, + // SET_SKELETON_TRACING is not included in SkeletonTracingSaveRelevantActions, because it is used by Undo/Redo and + // should not create its own Undo/Redo stack entry + "SET_SKELETON_TRACING", + ] + : VolumeTracingSaveRelevantActions, + actionBuffer, + ); + + // See Model.ensureSavedState for an explanation of this action channel. + const ensureDiffedChannel = yield* actionChannel( + "ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE", + ); + + while (true) { + // Prioritize consumption of tracingActionChannel since we don't want to + // reply to the ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE action if there + // are unprocessed user actions. + if (!actionBuffer.isEmpty()) { + yield* take(tracingActionChannel); + } else { + // Wait for either a user action or the "ensureAction". + const { ensureAction } = yield* race({ + _tracingAction: take(tracingActionChannel), + ensureAction: take(ensureDiffedChannel), + }); + if (ensureAction != null) { + ensureAction.callback(tracingId); + continue; + } + } + + // The allowUpdate setting could have changed in the meantime + const allowUpdate = yield* select( + (state) => + state.annotation.restrictions.allowUpdate && state.annotation.restrictions.allowSave, + ); + if (!allowUpdate) continue; + const tracing = (yield* select((state) => selectTracing(state, tracingType, tracingId))) as + | VolumeTracing + | SkeletonTracing; + + const items = compactUpdateActions( + Array.from(yield* call(performDiffTracing, prevTracing, tracing)), + prevTracing, + tracing, + ); + + if (items.length > 0) { + yield* put(pushSaveQueueTransaction(items)); + } + + prevTracing = tracing; + } +} + +export function performDiffTracing( + prevTracing: SkeletonTracing | VolumeTracing, + tracing: SkeletonTracing | VolumeTracing, +): Array { + let actions: Array = []; + + if (prevTracing.type === "skeleton" && tracing.type === "skeleton") { + actions = actions.concat(Array.from(diffSkeletonTracing(prevTracing, tracing))); + } + + if (prevTracing.type === "volume" && tracing.type === "volume") { + actions = actions.concat(Array.from(diffVolumeTracing(prevTracing, tracing))); + } + + return actions; +} + +export function performDiffAnnotation( + prevFlycam: Flycam, + flycam: Flycam, + prevTdCamera: CameraData, + tdCamera: CameraData, +): Array { + let actions: Array = []; + + if (prevFlycam !== flycam) { + actions = actions.concat( + updateCameraAnnotation( + getFlooredPosition(flycam), + flycam.additionalCoordinates, + getRotation(flycam), + flycam.zoomStep, + ), + ); + } + + if (prevTdCamera !== tdCamera) { + actions = actions.concat(updateTdCamera()); + } + + return actions; +} From 762621eb31eb4da9b18d88b2ca7e296f647ef53f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 14:32:27 +0200 Subject: [PATCH 79/92] add explicit release mutex functionality --- app/controllers/AnnotationController.scala | 8 ++++++++ app/models/annotation/AnnotationMutexService.scala | 13 +++++++++++++ conf/webknossos.latest.routes | 1 + frontend/javascripts/admin/rest_api.ts | 6 ++++++ 4 files changed, 28 insertions(+) diff --git a/app/controllers/AnnotationController.scala b/app/controllers/AnnotationController.scala index 806ceeba709..02a80e0f0ae 100755 --- a/app/controllers/AnnotationController.scala +++ b/app/controllers/AnnotationController.scala @@ -451,4 +451,12 @@ class AnnotationController @Inject()( } } + def releaseMutex(id: ObjectId): Action[AnyContent] = sil.SecuredAction.async { implicit request => + logTime(slackNotificationService.noticeSlowRequest, durationThreshold = 1 second) { + for { + _ <- annotationMutexService.release(id, request.identity._id) ?~> "annotation.mutex.failed" + } yield Ok + } + } + } diff --git a/app/models/annotation/AnnotationMutexService.scala b/app/models/annotation/AnnotationMutexService.scala index 98e4dd1beef..543fda0db66 100644 --- a/app/models/annotation/AnnotationMutexService.scala +++ b/app/models/annotation/AnnotationMutexService.scala @@ -68,6 +68,17 @@ class AnnotationMutexService @Inject()(val lifecycle: ApplicationLifecycle, _ <- annotationMutexDAO.upsertOne(mutex.copy(expiry = Instant.in(defaultExpiryTime))) } yield MutexResult(canEdit = true, None) + def release(annotationId: ObjectId, userId: ObjectId): Fox[Unit] = + for { + mutex <- annotationMutexDAO.findOne(annotationId).shiftBox + _ <- mutex match { + case Full(mutex) if mutex.userId == userId => + annotationMutexDAO.deleteOne(annotationId).map(_ => ()) + case _ => + Fox.successful(()) + } + } yield () + def publicWrites(mutexResult: MutexResult): Fox[JsObject] = for { userOpt <- Fox.runOptional(mutexResult.blockedByUser)(user => userDAO.findOne(user)(GlobalAccessContext)) @@ -114,4 +125,6 @@ class AnnotationMutexDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionC def deleteExpired(): Fox[Int] = run(q"DELETE FROM webknossos.annotation_mutexes WHERE expiry < NOW()".asUpdate) + def deleteOne(annotationId: ObjectId): Fox[Int] = + run(q"DELETE FROM webknossos.annotation_mutexes WHERE _annotation = $annotationId".asUpdate) } diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index b095dfe6775..7e57987a38d 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -155,6 +155,7 @@ DELETE /annotations/:id POST /annotations/:id/merge/:mergedTyp/:mergedId controllers.AnnotationController.mergeWithoutType(id: ObjectId, mergedTyp: String, mergedId: ObjectId) GET /annotations/:id/download controllers.AnnotationIOController.downloadWithoutType(id: ObjectId, version: Option[Long], skipVolumeData: Option[Boolean], volumeDataZipFormat: Option[String]) POST /annotations/:id/acquireMutex controllers.AnnotationController.tryAcquiringAnnotationMutex(id: ObjectId) +DELETE /annotations/:id/mutex controllers.AnnotationController.releaseMutex(id: ObjectId) GET /annotations/:typ/:id/info controllers.AnnotationController.info(typ: String, id: ObjectId, timestamp: Option[Long]) DELETE /annotations/:typ/:id controllers.AnnotationController.cancel(typ: String, id: ObjectId) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 2bb539c6db5..59ecc087af2 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -719,6 +719,12 @@ export async function acquireAnnotationMutex( return { canEdit, blockedByUser }; } +export async function releaseAnnotationMutex(annotationId: string): Promise { + await Request.receiveJSON(`/api/annotations/${annotationId}/mutex`, { + method: "DELETE", + }); +} + export async function getTracingForAnnotationType( annotation: APIAnnotation, annotationLayerDescriptor: AnnotationLayerDescriptor, From 922087946e5d0948ae572411c9a1e2646c97f2bb Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:29:09 +0200 Subject: [PATCH 80/92] remove superfluous didShowFailedSimultaneousTracingError logic (unnecessary because of unified annotation versioning) --- .../model/sagas/saving/save_queue_draining.ts | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts index 8c86d09cae8..8f30bdad043 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -106,10 +106,6 @@ function getRetryWaitTime(retryCount: number) { return Math.min(2 ** retryCount * SAVE_RETRY_WAITING_TIME, MAX_SAVE_RETRY_WAITING_TIME); } -// The value for this boolean does not need to be restored to false -// at any time, because the browser page is reloaded after the message is shown, anyway. -let didShowFailedSimultaneousTracingError = false; - export function* sendSaveRequestToServer(): Saga { /* * Saves a reasonably-sized part of the save queue to the server (plus retry-mechanism). @@ -203,21 +199,10 @@ export function* sendSaveRequestToServer(): Saga { [ErrorHandling, ErrorHandling.notify], new Error("Saving failed due to '409' status code"), ); - if (!didShowFailedSimultaneousTracingError) { - // If the saving fails for one tracing (e.g., skeleton), it can also - // fail for another tracing (e.g., volume). The message simply tells the - // user that the saving in general failed. So, there is no sense in showing - // the message multiple times. - yield* call(alert, messages["save.failed_simultaneous_tracing"]); - location.reload(); - didShowFailedSimultaneousTracingError = true; - } - // Wait "forever" to avoid that the caller initiates other save calls afterwards (e.g., - // can happen if the caller tries to force-flush the save queue). - // The reason we don't throw an error immediately is that this would immediately - // crash all sagas (including saving other tracings). - yield* call(sleep, ONE_YEAR_MS); + yield* call(alert, messages["save.failed_simultaneous_tracing"]); + location.reload(); + throw new Error("Saving failed due to conflict."); } From afe2a9466bdcb322765802728af9e526e109cdd8 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:29:45 +0200 Subject: [PATCH 81/92] acquire mutex in proofreading and release after saving --- .../viewer/model/sagas/volume/proofread_saga.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts index 15bfe6ff5ba..c4738d0f31e 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts @@ -1,9 +1,11 @@ import { + acquireAnnotationMutex, type NeighborInfo, getAgglomeratesForSegmentsFromTracingstore, getEdgesForAgglomerateMinCut, getNeighborsForAgglomerateNode, getPositionForSegmentInAgglomerate, + releaseAnnotationMutex, } from "admin/rest_api"; import { V3 } from "libs/mjs"; import Toast from "libs/toast"; @@ -358,6 +360,7 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { const { agglomerateFileMag, getDataValue, activeMapping, volumeTracing } = preparation; const { tracingId: volumeTracingId } = volumeTracing; + const annotationId = yield* select((state) => state.annotation.annotationId); // Use untransformedPosition because agglomerate trees should not have // any transforms, anyway. if (yield* select((state) => areGeometriesTransformed(state))) { @@ -441,6 +444,7 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); + yield* call(releaseAnnotationMutex, annotationId); if (action.type === "MIN_CUT_AGGLOMERATE_WITH_NODE_IDS" || action.type === "DELETE_EDGE") { if (sourceAgglomerateId !== targetAgglomerateId) { @@ -690,6 +694,7 @@ function* handleProofreadMergeOrMinCut(action: Action) { const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); if (!allowUpdate) return; + const annotationId = yield* select((state) => state.annotation.annotationId); const preparation = yield* call(prepareSplitOrMerge, false); if (!preparation) { return; @@ -775,6 +780,7 @@ function* handleProofreadMergeOrMinCut(action: Action) { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); + yield* call(releaseAnnotationMutex, annotationId); if (action.type === "MIN_CUT_AGGLOMERATE") { console.log("start updating the mapping after a min-cut"); @@ -879,6 +885,7 @@ function* handleProofreadCutFromNeighbors(action: Action) { const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); if (!allowUpdate) return; + const annotationId = yield* select((state) => state.annotation.annotationId); const preparation = yield* call(prepareSplitOrMerge, false); if (!preparation) { return; @@ -933,6 +940,7 @@ function* handleProofreadCutFromNeighbors(action: Action) { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); + yield* call(releaseAnnotationMutex, annotationId); // Now that the changes are saved, we can split the mapping locally (because it requires // communication with the back-end). @@ -1116,6 +1124,13 @@ function* prepareSplitOrMerge(isSkeletonProofreading: boolean): Saga state.annotation.annotationId); + const { canEdit } = yield* call(acquireAnnotationMutex, annotationId); + if (!canEdit) { + Toast.error("Could not acquire mutex. Somebody else is proofreading at the moment."); + return null; + } + return { agglomerateFileMag, getDataValue, From e5b1c9514a0ce0a798316309498589c74adeb003 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:30:30 +0200 Subject: [PATCH 82/92] retry even 409 errors --- .../model/sagas/saving/save_queue_draining.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts index 8f30bdad043..2942cb6bda8 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -113,17 +113,19 @@ export function* sendSaveRequestToServer(): Saga { */ const fullSaveQueue = yield* select((state) => state.save.queue); - const saveQueue = sliceAppropriateBatchCount(fullSaveQueue); - let compactedSaveQueue = compactSaveQueue(saveQueue); - const version = yield* select((state) => state.annotation.version); + const storeSaveQueue = sliceAppropriateBatchCount(fullSaveQueue); + const compactedSaveQueueWithoutVersions = compactSaveQueue(storeSaveQueue); const annotationId = yield* select((state) => state.annotation.annotationId); const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); - let versionIncrement; - [compactedSaveQueue, versionIncrement] = addVersionNumbers(compactedSaveQueue, version); let retryCount = 0; // This while-loop only exists for the purpose of a retry-mechanism while (true) { + const version = yield* select((state) => state.annotation.version); + const [compactedSaveQueue, versionIncrement] = addVersionNumbers( + compactedSaveQueueWithoutVersions, + version, + ); let exceptionDuringMarkBucketsAsNotDirty = false; try { @@ -153,7 +155,7 @@ export function* sendSaveRequestToServer(): Saga { yield* put(setVersionNumberAction(version + versionIncrement)); yield* put(setLastSaveTimestampAction()); - yield* put(shiftSaveQueueAction(saveQueue.length)); + yield* put(shiftSaveQueueAction(storeSaveQueue.length)); try { yield* call(markBucketsAsNotDirty, compactedSaveQueue); @@ -165,7 +167,7 @@ export function* sendSaveRequestToServer(): Saga { } yield* call(toggleErrorHighlighting, false); - return saveQueue.length; + return storeSaveQueue.length; } catch (error) { if (exceptionDuringMarkBucketsAsNotDirty) { throw error; @@ -190,8 +192,9 @@ export function* sendSaveRequestToServer(): Saga { retryCount, }); + // todop: detect actual 409 errors again // @ts-ignore - if (error.status === 409) { + if (false && error.status === 409) { // HTTP Code 409 'conflict' for dirty state // @ts-ignore window.onbeforeunload = null; From b9f14b5b5c860db0aa82a958a75e2b5f14e23a3a Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:30:46 +0200 Subject: [PATCH 83/92] Revert "retry even 409 errors" because saving should only be done when a mutex was acquired This reverts commit e5b1c9514a0ce0a798316309498589c74adeb003. --- .../model/sagas/saving/save_queue_draining.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts index 2942cb6bda8..8f30bdad043 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -113,19 +113,17 @@ export function* sendSaveRequestToServer(): Saga { */ const fullSaveQueue = yield* select((state) => state.save.queue); - const storeSaveQueue = sliceAppropriateBatchCount(fullSaveQueue); - const compactedSaveQueueWithoutVersions = compactSaveQueue(storeSaveQueue); + const saveQueue = sliceAppropriateBatchCount(fullSaveQueue); + let compactedSaveQueue = compactSaveQueue(saveQueue); + const version = yield* select((state) => state.annotation.version); const annotationId = yield* select((state) => state.annotation.annotationId); const tracingStoreUrl = yield* select((state) => state.annotation.tracingStore.url); + let versionIncrement; + [compactedSaveQueue, versionIncrement] = addVersionNumbers(compactedSaveQueue, version); let retryCount = 0; // This while-loop only exists for the purpose of a retry-mechanism while (true) { - const version = yield* select((state) => state.annotation.version); - const [compactedSaveQueue, versionIncrement] = addVersionNumbers( - compactedSaveQueueWithoutVersions, - version, - ); let exceptionDuringMarkBucketsAsNotDirty = false; try { @@ -155,7 +153,7 @@ export function* sendSaveRequestToServer(): Saga { yield* put(setVersionNumberAction(version + versionIncrement)); yield* put(setLastSaveTimestampAction()); - yield* put(shiftSaveQueueAction(storeSaveQueue.length)); + yield* put(shiftSaveQueueAction(saveQueue.length)); try { yield* call(markBucketsAsNotDirty, compactedSaveQueue); @@ -167,7 +165,7 @@ export function* sendSaveRequestToServer(): Saga { } yield* call(toggleErrorHighlighting, false); - return storeSaveQueue.length; + return saveQueue.length; } catch (error) { if (exceptionDuringMarkBucketsAsNotDirty) { throw error; @@ -192,9 +190,8 @@ export function* sendSaveRequestToServer(): Saga { retryCount, }); - // todop: detect actual 409 errors again // @ts-ignore - if (false && error.status === 409) { + if (error.status === 409) { // HTTP Code 409 'conflict' for dirty state // @ts-ignore window.onbeforeunload = null; From 0142486001d3c55916e0dc19d970ce150c2b101d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:40:40 +0200 Subject: [PATCH 84/92] extract save mutex saga into own module --- .../test/sagas/annotation_saga.spec.ts | 2 +- .../viewer/model/sagas/annotation_saga.tsx | 143 +----------------- .../model/sagas/saving/save_mutex_saga.tsx | 131 ++++++++++++++++ .../model/sagas/saving/save_queue_draining.ts | 1 - .../model/sagas/volume/proofread_saga.ts | 2 +- 5 files changed, 139 insertions(+), 140 deletions(-) create mode 100644 frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx diff --git a/frontend/javascripts/test/sagas/annotation_saga.spec.ts b/frontend/javascripts/test/sagas/annotation_saga.spec.ts index 142a9f32eb5..a90f3f0894e 100644 --- a/frontend/javascripts/test/sagas/annotation_saga.spec.ts +++ b/frontend/javascripts/test/sagas/annotation_saga.spec.ts @@ -12,7 +12,7 @@ import { } from "viewer/model/actions/annotation_actions"; import { ensureWkReady } from "viewer/model/sagas/ready_sagas"; import { wkReadyAction } from "viewer/model/actions/actions"; -import { acquireAnnotationMutexMaybe } from "viewer/model/sagas/annotation_saga"; +import { acquireAnnotationMutexMaybe } from "viewer/model/sagas/saving/save_mutex_saga"; const createInitialState = ( othersMayEdit: boolean, diff --git a/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx b/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx index f8b73cbef1e..7964c164548 100644 --- a/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/annotation_saga.tsx @@ -1,36 +1,19 @@ import type { EditableAnnotation } from "admin/rest_api"; -import { acquireAnnotationMutex, editAnnotation } from "admin/rest_api"; -import { Button } from "antd"; +import { editAnnotation } from "admin/rest_api"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import _ from "lodash"; import messages from "messages"; -import React from "react"; import type { ActionPattern } from "redux-saga/effects"; -import { - call, - cancel, - cancelled, - delay, - fork, - put, - retry, - take, - takeEvery, - takeLatest, -} from "typed-redux-saga"; -import type { APIUserCompact } from "types/api_types"; +import { call, delay, put, retry, take, takeLatest } from "typed-redux-saga"; import constants, { MappingStatusEnum } from "viewer/constants"; import { getMappingInfo, is2dDataset } from "viewer/model/accessors/dataset_accessor"; import { getActiveMagIndexForLayer } from "viewer/model/accessors/flycam_accessor"; import type { Action } from "viewer/model/actions/actions"; -import { - type EditAnnotationLayerAction, - type SetAnnotationDescriptionAction, - type SetOthersMayEditForAnnotationAction, - setAnnotationAllowUpdateAction, - setBlockedByUserAction, +import type { + EditAnnotationLayerAction, + SetAnnotationDescriptionAction, } from "viewer/model/actions/annotation_actions"; import { setVersionRestoreVisibilityAction } from "viewer/model/actions/ui_actions"; import type { Saga } from "viewer/model/sagas/effect-generators"; @@ -48,6 +31,7 @@ import { mayEditAnnotationProperties } from "../accessors/annotation_accessor"; import { needsLocalHdf5Mapping } from "../accessors/volumetracing_accessor"; import { pushSaveQueueTransaction } from "../actions/save_actions"; import { ensureWkReady } from "./ready_sagas"; +import { acquireAnnotationMutexMaybe } from "./saving/save_mutex_saga"; import { updateAnnotationLayerName, updateMetadataOfAnnotation } from "./volume/update_actions"; /* Note that this must stay in sync with the back-end constant MaxMagForAgglomerateMapping @@ -237,121 +221,6 @@ export function* watchAnnotationAsync(): Saga { yield* takeLatest("EDIT_ANNOTATION_LAYER", pushAnnotationLayerUpdateAsync); } -export function* acquireAnnotationMutexMaybe(): Saga { - yield* call(ensureWkReady); - const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); - const annotationId = yield* select((storeState) => storeState.annotation.annotationId); - if (!allowUpdate) { - return; - } - const othersMayEdit = yield* select((state) => state.annotation.othersMayEdit); - const activeUser = yield* select((state) => state.activeUser); - const acquireMutexInterval = 1000 * 60; - const RETRY_COUNT = 12; - const MUTEX_NOT_ACQUIRED_KEY = "MutexCouldNotBeAcquired"; - const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; - let isInitialRequest = true; - let doesHaveMutexFromBeginning = false; - let doesHaveMutex = false; - let shallTryAcquireMutex = othersMayEdit; - - function onMutexStateChanged(canEdit: boolean, blockedByUser: APIUserCompact | null | undefined) { - if (canEdit) { - Toast.close("MutexCouldNotBeAcquired"); - if (!isInitialRequest) { - const message = ( - - {messages["annotation.acquiringMutexSucceeded"]}{" "} - - - ); - Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); - } - } else { - Toast.close(MUTEX_ACQUIRED_KEY); - const message = - blockedByUser != null - ? messages["annotation.acquiringMutexFailed"]({ - userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, - }) - : messages["annotation.acquiringMutexFailed.noUser"]; - Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); - } - } - - function* tryAcquireMutexContinuously(): Saga { - while (shallTryAcquireMutex) { - if (isInitialRequest) { - yield* put(setAnnotationAllowUpdateAction(false)); - } - try { - const { canEdit, blockedByUser } = yield* retry( - RETRY_COUNT, - acquireMutexInterval / RETRY_COUNT, - acquireAnnotationMutex, - annotationId, - ); - if (isInitialRequest && canEdit) { - doesHaveMutexFromBeginning = true; - // Only set allow update to true in case the first try to get the mutex succeeded. - yield* put(setAnnotationAllowUpdateAction(true)); - } - if (!canEdit || !doesHaveMutexFromBeginning) { - // If the mutex could not be acquired anymore or the user does not have it from the beginning, set allow update to false. - doesHaveMutexFromBeginning = false; - yield* put(setAnnotationAllowUpdateAction(false)); - } - if (canEdit) { - yield* put(setBlockedByUserAction(activeUser)); - } else { - yield* put(setBlockedByUserAction(blockedByUser)); - } - if (canEdit !== doesHaveMutex || isInitialRequest) { - doesHaveMutex = canEdit; - onMutexStateChanged(canEdit, blockedByUser); - } - } catch (error) { - if (process.env.IS_TESTING) { - // In unit tests, that explicitly control this generator function, - // the console.error after the next yield won't be printed, because - // test assertions on the yield will already throw. - // Therefore, we also print the error in the test context. - console.error("Error while trying to acquire mutex:", error); - } - const wasCanceled = yield* cancelled(); - if (!wasCanceled) { - console.error("Error while trying to acquire mutex.", error); - yield* put(setBlockedByUserAction(undefined)); - yield* put(setAnnotationAllowUpdateAction(false)); - doesHaveMutexFromBeginning = false; - if (doesHaveMutex || isInitialRequest) { - onMutexStateChanged(false, null); - doesHaveMutex = false; - } - } - } - isInitialRequest = false; - yield* call(delay, acquireMutexInterval); - } - } - let runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); - function* reactToOthersMayEditChanges({ - othersMayEdit, - }: SetOthersMayEditForAnnotationAction): Saga { - shallTryAcquireMutex = othersMayEdit; - if (shallTryAcquireMutex) { - if (runningTryAcquireMutexContinuouslySaga != null) { - yield* cancel(runningTryAcquireMutexContinuouslySaga); - } - isInitialRequest = true; - runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); - } else { - // othersMayEdit was turned off. The user editing it should be able to edit the annotation. - yield* put(setAnnotationAllowUpdateAction(true)); - } - } - yield* takeEvery("SET_OTHERS_MAY_EDIT_FOR_ANNOTATION", reactToOthersMayEditChanges); -} export default [ warnAboutSegmentationZoom, watchAnnotationAsync, diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx new file mode 100644 index 00000000000..5e3e6758b92 --- /dev/null +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -0,0 +1,131 @@ +import { acquireAnnotationMutex } from "admin/rest_api"; +import { Button } from "antd"; +import Toast from "libs/toast"; +import messages from "messages"; +import React from "react"; +import { call, cancel, cancelled, delay, fork, put, retry, takeEvery } from "typed-redux-saga"; +import type { APIUserCompact } from "types/api_types"; +import { + type SetOthersMayEditForAnnotationAction, + setAnnotationAllowUpdateAction, + setBlockedByUserAction, +} from "viewer/model/actions/annotation_actions"; +import type { Saga } from "viewer/model/sagas/effect-generators"; +import { select } from "viewer/model/sagas/effect-generators"; +import { ensureWkReady } from "../ready_sagas"; + +export function* acquireAnnotationMutexMaybe(): Saga { + yield* call(ensureWkReady); + const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); + const annotationId = yield* select((storeState) => storeState.annotation.annotationId); + if (!allowUpdate) { + return; + } + const othersMayEdit = yield* select((state) => state.annotation.othersMayEdit); + const activeUser = yield* select((state) => state.activeUser); + const acquireMutexInterval = 1000 * 60; + const RETRY_COUNT = 12; + const MUTEX_NOT_ACQUIRED_KEY = "MutexCouldNotBeAcquired"; + const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; + let isInitialRequest = true; + let doesHaveMutexFromBeginning = false; + let doesHaveMutex = false; + let shallTryAcquireMutex = othersMayEdit; + + function onMutexStateChanged(canEdit: boolean, blockedByUser: APIUserCompact | null | undefined) { + if (canEdit) { + Toast.close("MutexCouldNotBeAcquired"); + if (!isInitialRequest) { + const message = ( + + {messages["annotation.acquiringMutexSucceeded"]}" " + + + ); + Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); + } + } else { + Toast.close(MUTEX_ACQUIRED_KEY); + const message = + blockedByUser != null + ? messages["annotation.acquiringMutexFailed"]({ + userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, + }) + : messages["annotation.acquiringMutexFailed.noUser"]; + Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); + } + } + + function* tryAcquireMutexContinuously(): Saga { + while (shallTryAcquireMutex) { + if (isInitialRequest) { + yield* put(setAnnotationAllowUpdateAction(false)); + } + try { + const { canEdit, blockedByUser } = yield* retry( + RETRY_COUNT, + acquireMutexInterval / RETRY_COUNT, + acquireAnnotationMutex, + annotationId, + ); + if (isInitialRequest && canEdit) { + doesHaveMutexFromBeginning = true; + // Only set allow update to true in case the first try to get the mutex succeeded. + yield* put(setAnnotationAllowUpdateAction(true)); + } + if (!canEdit || !doesHaveMutexFromBeginning) { + // If the mutex could not be acquired anymore or the user does not have it from the beginning, set allow update to false. + doesHaveMutexFromBeginning = false; + yield* put(setAnnotationAllowUpdateAction(false)); + } + if (canEdit) { + yield* put(setBlockedByUserAction(activeUser)); + } else { + yield* put(setBlockedByUserAction(blockedByUser)); + } + if (canEdit !== doesHaveMutex || isInitialRequest) { + doesHaveMutex = canEdit; + onMutexStateChanged(canEdit, blockedByUser); + } + } catch (error) { + if (process.env.IS_TESTING) { + // In unit tests, that explicitly control this generator function, + // the console.error after the next yield won't be printed, because + // test assertions on the yield will already throw. + // Therefore, we also print the error in the test context. + console.error("Error while trying to acquire mutex:", error); + } + const wasCanceled = yield* cancelled(); + if (!wasCanceled) { + console.error("Error while trying to acquire mutex.", error); + yield* put(setBlockedByUserAction(undefined)); + yield* put(setAnnotationAllowUpdateAction(false)); + doesHaveMutexFromBeginning = false; + if (doesHaveMutex || isInitialRequest) { + onMutexStateChanged(false, null); + doesHaveMutex = false; + } + } + } + isInitialRequest = false; + yield* call(delay, acquireMutexInterval); + } + } + let runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); + function* reactToOthersMayEditChanges({ + othersMayEdit, + }: SetOthersMayEditForAnnotationAction): Saga { + shallTryAcquireMutex = othersMayEdit; + if (shallTryAcquireMutex) { + if (runningTryAcquireMutexContinuouslySaga != null) { + yield* cancel(runningTryAcquireMutexContinuouslySaga); + } + isInitialRequest = true; + runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); + } else { + // othersMayEdit was turned off. The user editing it should be able to edit the annotation. + yield* put(setAnnotationAllowUpdateAction(true)); + } + } + yield* takeEvery("SET_OTHERS_MAY_EDIT_FOR_ANNOTATION", reactToOthersMayEditChanges); +} diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts index 8f30bdad043..52e24d0b122 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -7,7 +7,6 @@ import { sendSaveRequestWithToken } from "admin/rest_api"; import Date from "libs/date"; import ErrorHandling from "libs/error_handling"; import Toast from "libs/toast"; -import { sleep } from "libs/utils"; import window, { alert, document, location } from "libs/window"; import memoizeOne from "memoize-one"; import messages from "messages"; diff --git a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts index c4738d0f31e..359f3b52c73 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts @@ -1,6 +1,6 @@ import { - acquireAnnotationMutex, type NeighborInfo, + acquireAnnotationMutex, getAgglomeratesForSegmentsFromTracingstore, getEdgesForAgglomerateMinCut, getNeighborsForAgglomerateNode, From ac6b2ab6b7348261ef214790ca8b5e1e6242f396 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:47:54 +0200 Subject: [PATCH 85/92] refactor further --- .../model/sagas/saving/save_mutex_saga.tsx | 209 ++++++++++-------- 1 file changed, 116 insertions(+), 93 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index 5e3e6758b92..b148e27a583 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -14,114 +14,50 @@ import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { ensureWkReady } from "../ready_sagas"; +const MUTEX_NOT_ACQUIRED_KEY = "MutexCouldNotBeAcquired"; +const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; +const RETRY_COUNT = 12; +const ACQUIRE_MUTEX_INTERVAL = 1000 * 60; + +type MutexLogicState = { + isInitialRequest: boolean; + doesHaveMutexFromBeginning: boolean; + doesHaveMutex: boolean; + shallTryAcquireMutex: boolean; +}; + export function* acquireAnnotationMutexMaybe(): Saga { yield* call(ensureWkReady); const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); - const annotationId = yield* select((storeState) => storeState.annotation.annotationId); if (!allowUpdate) { return; } const othersMayEdit = yield* select((state) => state.annotation.othersMayEdit); - const activeUser = yield* select((state) => state.activeUser); - const acquireMutexInterval = 1000 * 60; - const RETRY_COUNT = 12; - const MUTEX_NOT_ACQUIRED_KEY = "MutexCouldNotBeAcquired"; - const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; - let isInitialRequest = true; - let doesHaveMutexFromBeginning = false; - let doesHaveMutex = false; - let shallTryAcquireMutex = othersMayEdit; - function onMutexStateChanged(canEdit: boolean, blockedByUser: APIUserCompact | null | undefined) { - if (canEdit) { - Toast.close("MutexCouldNotBeAcquired"); - if (!isInitialRequest) { - const message = ( - - {messages["annotation.acquiringMutexSucceeded"]}" " - - - ); - Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); - } - } else { - Toast.close(MUTEX_ACQUIRED_KEY); - const message = - blockedByUser != null - ? messages["annotation.acquiringMutexFailed"]({ - userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, - }) - : messages["annotation.acquiringMutexFailed.noUser"]; - Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); - } - } + const mutexLogicState: MutexLogicState = { + isInitialRequest: true, + doesHaveMutexFromBeginning: false, + doesHaveMutex: false, + shallTryAcquireMutex: othersMayEdit, + }; - function* tryAcquireMutexContinuously(): Saga { - while (shallTryAcquireMutex) { - if (isInitialRequest) { - yield* put(setAnnotationAllowUpdateAction(false)); - } - try { - const { canEdit, blockedByUser } = yield* retry( - RETRY_COUNT, - acquireMutexInterval / RETRY_COUNT, - acquireAnnotationMutex, - annotationId, - ); - if (isInitialRequest && canEdit) { - doesHaveMutexFromBeginning = true; - // Only set allow update to true in case the first try to get the mutex succeeded. - yield* put(setAnnotationAllowUpdateAction(true)); - } - if (!canEdit || !doesHaveMutexFromBeginning) { - // If the mutex could not be acquired anymore or the user does not have it from the beginning, set allow update to false. - doesHaveMutexFromBeginning = false; - yield* put(setAnnotationAllowUpdateAction(false)); - } - if (canEdit) { - yield* put(setBlockedByUserAction(activeUser)); - } else { - yield* put(setBlockedByUserAction(blockedByUser)); - } - if (canEdit !== doesHaveMutex || isInitialRequest) { - doesHaveMutex = canEdit; - onMutexStateChanged(canEdit, blockedByUser); - } - } catch (error) { - if (process.env.IS_TESTING) { - // In unit tests, that explicitly control this generator function, - // the console.error after the next yield won't be printed, because - // test assertions on the yield will already throw. - // Therefore, we also print the error in the test context. - console.error("Error while trying to acquire mutex:", error); - } - const wasCanceled = yield* cancelled(); - if (!wasCanceled) { - console.error("Error while trying to acquire mutex.", error); - yield* put(setBlockedByUserAction(undefined)); - yield* put(setAnnotationAllowUpdateAction(false)); - doesHaveMutexFromBeginning = false; - if (doesHaveMutex || isInitialRequest) { - onMutexStateChanged(false, null); - doesHaveMutex = false; - } - } - } - isInitialRequest = false; - yield* call(delay, acquireMutexInterval); - } - } - let runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); + let runningTryAcquireMutexContinuouslySaga = yield* fork( + tryAcquireMutexContinuously, + mutexLogicState, + ); function* reactToOthersMayEditChanges({ othersMayEdit, }: SetOthersMayEditForAnnotationAction): Saga { - shallTryAcquireMutex = othersMayEdit; - if (shallTryAcquireMutex) { + mutexLogicState.shallTryAcquireMutex = othersMayEdit; + if (mutexLogicState.shallTryAcquireMutex) { if (runningTryAcquireMutexContinuouslySaga != null) { yield* cancel(runningTryAcquireMutexContinuouslySaga); } - isInitialRequest = true; - runningTryAcquireMutexContinuouslySaga = yield* fork(tryAcquireMutexContinuously); + mutexLogicState.isInitialRequest = true; + runningTryAcquireMutexContinuouslySaga = yield* fork( + tryAcquireMutexContinuously, + mutexLogicState, + ); } else { // othersMayEdit was turned off. The user editing it should be able to edit the annotation. yield* put(setAnnotationAllowUpdateAction(true)); @@ -129,3 +65,90 @@ export function* acquireAnnotationMutexMaybe(): Saga { } yield* takeEvery("SET_OTHERS_MAY_EDIT_FOR_ANNOTATION", reactToOthersMayEditChanges); } + +function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga { + const annotationId = yield* select((storeState) => storeState.annotation.annotationId); + const activeUser = yield* select((state) => state.activeUser); + + while (mutexLogicState.shallTryAcquireMutex) { + if (mutexLogicState.isInitialRequest) { + yield* put(setAnnotationAllowUpdateAction(false)); + } + try { + const { canEdit, blockedByUser } = yield* retry( + RETRY_COUNT, + ACQUIRE_MUTEX_INTERVAL / RETRY_COUNT, + acquireAnnotationMutex, + annotationId, + ); + if (mutexLogicState.isInitialRequest && canEdit) { + mutexLogicState.doesHaveMutexFromBeginning = true; + // Only set allow update to true in case the first try to get the mutex succeeded. + yield* put(setAnnotationAllowUpdateAction(true)); + } + if (!canEdit || !mutexLogicState.doesHaveMutexFromBeginning) { + // If the mutex could not be acquired anymore or the user does not have it from the beginning, set allow update to false. + mutexLogicState.doesHaveMutexFromBeginning = false; + yield* put(setAnnotationAllowUpdateAction(false)); + } + if (canEdit) { + yield* put(setBlockedByUserAction(activeUser)); + } else { + yield* put(setBlockedByUserAction(blockedByUser)); + } + if (canEdit !== mutexLogicState.doesHaveMutex || mutexLogicState.isInitialRequest) { + mutexLogicState.doesHaveMutex = canEdit; + onMutexStateChanged(mutexLogicState.isInitialRequest, canEdit, blockedByUser); + } + } catch (error) { + if (process.env.IS_TESTING) { + // In unit tests, that explicitly control this generator function, + // the console.error after the next yield won't be printed, because + // test assertions on the yield will already throw. + // Therefore, we also print the error in the test context. + console.error("Error while trying to acquire mutex:", error); + } + const wasCanceled = yield* cancelled(); + if (!wasCanceled) { + console.error("Error while trying to acquire mutex.", error); + yield* put(setBlockedByUserAction(undefined)); + yield* put(setAnnotationAllowUpdateAction(false)); + mutexLogicState.doesHaveMutexFromBeginning = false; + if (mutexLogicState.doesHaveMutex || mutexLogicState.isInitialRequest) { + onMutexStateChanged(mutexLogicState.isInitialRequest, false, null); + mutexLogicState.doesHaveMutex = false; + } + } + } + mutexLogicState.isInitialRequest = false; + yield* call(delay, ACQUIRE_MUTEX_INTERVAL); + } +} + +function onMutexStateChanged( + isInitialRequest: boolean, + canEdit: boolean, + blockedByUser: APIUserCompact | null | undefined, +) { + if (canEdit) { + Toast.close("MutexCouldNotBeAcquired"); + if (!isInitialRequest) { + const message = ( + + {messages["annotation.acquiringMutexSucceeded"]}" " + + + ); + Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); + } + } else { + Toast.close(MUTEX_ACQUIRED_KEY); + const message = + blockedByUser != null + ? messages["annotation.acquiringMutexFailed"]({ + userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, + }) + : messages["annotation.acquiringMutexFailed.noUser"]; + Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); + } +} From fa9963057c0c241c82110e57d5846b744e89c013 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:50:41 +0200 Subject: [PATCH 86/92] disable eager mutex acquisition and also poll for updates when allow update is true --- .../javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx | 2 ++ frontend/javascripts/viewer/model/sagas/saving/save_saga.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index b148e27a583..fc1a210d26f 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -27,6 +27,8 @@ type MutexLogicState = { }; export function* acquireAnnotationMutexMaybe(): Saga { + // todop + return; yield* call(ensureWkReady); const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); if (!allowUpdate) { diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts index 3df8bd2cf32..e92be39f1e6 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_saga.ts @@ -51,7 +51,9 @@ function* watchForSaveConflicts(): Saga { (state) => state.annotation.restrictions.allowSave && state.annotation.restrictions.allowUpdate, ); - if (allowSave) { + + // todop + if (false && allowSave) { // The active user is currently the only one that is allowed to mutate the annotation. // Since we only acquire the mutex upon page load, there shouldn't be any unseen updates // between the page load and this check here. From 87d39a6ced4dc7cf3838f603d6818bf99dfbac1c Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 17:55:04 +0200 Subject: [PATCH 87/92] add DISABLE_EAGER_MUTEX_ACQUISITION bool --- .../viewer/model/sagas/saving/save_mutex_saga.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index fc1a210d26f..7beb0a69b23 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -19,6 +19,9 @@ const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; const RETRY_COUNT = 12; const ACQUIRE_MUTEX_INTERVAL = 1000 * 60; +// todop +const DISABLE_EAGER_MUTEX_ACQUISITION = true; + type MutexLogicState = { isInitialRequest: boolean; doesHaveMutexFromBeginning: boolean; @@ -27,9 +30,10 @@ type MutexLogicState = { }; export function* acquireAnnotationMutexMaybe(): Saga { - // todop - return; yield* call(ensureWkReady); + if (DISABLE_EAGER_MUTEX_ACQUISITION) { + return; + } const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); if (!allowUpdate) { return; @@ -133,7 +137,7 @@ function onMutexStateChanged( blockedByUser: APIUserCompact | null | undefined, ) { if (canEdit) { - Toast.close("MutexCouldNotBeAcquired"); + Toast.close(MUTEX_NOT_ACQUIRED_KEY); if (!isInitialRequest) { const message = ( From 06f2e3fbd0f3cf293c2f373d2996e47301253964 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 26 Jun 2025 18:11:50 +0200 Subject: [PATCH 88/92] add todo comment --- .../javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index 7beb0a69b23..c2b8d9b2a49 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -131,6 +131,7 @@ function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga Date: Fri, 27 Jun 2025 15:25:20 +0200 Subject: [PATCH 89/92] move isMutexAcquired to store --- frontend/javascripts/viewer/default_state.ts | 1 + .../model/actions/annotation_actions.ts | 8 ++ .../model/reducers/annotation_reducer.ts | 7 ++ .../viewer/model/reducers/reducer_helpers.ts | 1 + .../model/sagas/saving/save_mutex_saga.tsx | 86 ++++++++++--------- frontend/javascripts/viewer/store.ts | 1 + 6 files changed, 63 insertions(+), 41 deletions(-) diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index bb173defcef..58f5162f6f7 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -178,6 +178,7 @@ const defaultState: WebknossosState = { contributors: [], othersMayEdit: false, blockedByUser: null, + isMutexAcquired: false, annotationLayers: [], version: 0, earliestAccessibleVersion: 0, diff --git a/frontend/javascripts/viewer/model/actions/annotation_actions.ts b/frontend/javascripts/viewer/model/actions/annotation_actions.ts index 31219fee24b..97fc207c1e0 100644 --- a/frontend/javascripts/viewer/model/actions/annotation_actions.ts +++ b/frontend/javascripts/viewer/model/actions/annotation_actions.ts @@ -52,6 +52,7 @@ export type EditAnnotationLayerAction = ReturnType; type SetAnnotationAllowUpdateAction = ReturnType; type SetBlockedByUserAction = ReturnType; +export type SetIsMutexAcquiredAction = ReturnType; type SetUserBoundingBoxesAction = ReturnType; type FinishedResizingUserBoundingBoxAction = ReturnType< typeof finishedResizingUserBoundingBoxAction @@ -87,6 +88,7 @@ export type AnnotationActionTypes = | SetAnnotationDescriptionAction | SetAnnotationAllowUpdateAction | SetBlockedByUserAction + | SetIsMutexAcquiredAction | SetUserBoundingBoxesAction | ChangeUserBoundingBoxAction | FinishedResizingUserBoundingBoxAction @@ -176,6 +178,12 @@ export const setBlockedByUserAction = (blockedByUser: APIUserCompact | null | un blockedByUser, }) as const; +export const setIsMutexAcquiredAction = (isMutexAcquired: boolean) => + ({ + type: "SET_IS_MUTEX_ACQUIRED", + isMutexAcquired, + }) as const; + // Strictly speaking this is no annotation action but a tracing action, as the boundingBox is saved with // the tracing, hence no ANNOTATION in the action type. export const setUserBoundingBoxesAction = (userBoundingBoxes: Array) => diff --git a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts index 3d21abb8d9a..d23f5a4aae5 100644 --- a/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/annotation_reducer.ts @@ -137,6 +137,13 @@ function AnnotationReducer(state: WebknossosState, action: Action): WebknossosSt }); } + case "SET_IS_MUTEX_ACQUIRED": { + const { isMutexAcquired } = action; + return updateKey(state, "annotation", { + isMutexAcquired, + }); + } + case "SET_USER_BOUNDING_BOXES": { return updateUserBoundingBoxes(state, action.userBoundingBoxes); } diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 8b31fc7966b..c75e588aeb7 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -156,6 +156,7 @@ export function convertServerAnnotationToFrontendAnnotation( othersMayEdit, annotationLayers, blockedByUser: null, + isMutexAcquired: false, }; } diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index c2b8d9b2a49..6f3b70d45a8 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -4,11 +4,12 @@ import Toast from "libs/toast"; import messages from "messages"; import React from "react"; import { call, cancel, cancelled, delay, fork, put, retry, takeEvery } from "typed-redux-saga"; -import type { APIUserCompact } from "types/api_types"; import { + type SetIsMutexAcquiredAction, type SetOthersMayEditForAnnotationAction, setAnnotationAllowUpdateAction, setBlockedByUserAction, + setIsMutexAcquiredAction, } from "viewer/model/actions/annotation_actions"; import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; @@ -25,12 +26,16 @@ const DISABLE_EAGER_MUTEX_ACQUISITION = true; type MutexLogicState = { isInitialRequest: boolean; doesHaveMutexFromBeginning: boolean; - doesHaveMutex: boolean; shallTryAcquireMutex: boolean; }; +function* getDoesHaveMutex(): Saga { + return yield* select((state) => state.annotation.isMutexAcquired); +} + export function* acquireAnnotationMutexMaybe(): Saga { yield* call(ensureWkReady); + yield* fork(watchMutexStateChanges); if (DISABLE_EAGER_MUTEX_ACQUISITION) { return; } @@ -43,7 +48,6 @@ export function* acquireAnnotationMutexMaybe(): Saga { const mutexLogicState: MutexLogicState = { isInitialRequest: true, doesHaveMutexFromBeginning: false, - doesHaveMutex: false, shallTryAcquireMutex: othersMayEdit, }; @@ -97,14 +101,11 @@ function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga - {messages["annotation.acquiringMutexSucceeded"]}" " - - - ); - Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); - } - } else { - Toast.close(MUTEX_ACQUIRED_KEY); - const message = - blockedByUser != null - ? messages["annotation.acquiringMutexFailed"]({ - userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, - }) - : messages["annotation.acquiringMutexFailed.noUser"]; - Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); - } +function* watchMutexStateChanges(): Saga { + // todop: wrong? + let isInitialRequest = true; + yield* takeEvery( + "SET_IS_MUTEX_ACQUIRED", + function* ({ isMutexAcquired }: SetIsMutexAcquiredAction) { + if (isMutexAcquired) { + Toast.close(MUTEX_NOT_ACQUIRED_KEY); + if (!isInitialRequest) { + const message = ( + + {messages["annotation.acquiringMutexSucceeded"]}" " + + + ); + Toast.success(message, { sticky: true, key: MUTEX_ACQUIRED_KEY }); + } + } else { + Toast.close(MUTEX_ACQUIRED_KEY); + const blockedByUser = yield* select((state) => state.annotation.blockedByUser); + const message = + blockedByUser != null + ? messages["annotation.acquiringMutexFailed"]({ + userName: `${blockedByUser.firstName} ${blockedByUser.lastName}`, + }) + : messages["annotation.acquiringMutexFailed.noUser"]; + Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); + } + isInitialRequest = false; + }, + ); } diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index a16a7906473..db159e9fec3 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -146,6 +146,7 @@ export type Annotation = { readonly othersMayEdit: boolean; readonly blockedByUser: APIUserCompact | null | undefined; readonly isLockedByOwner: boolean; + readonly isMutexAcquired: boolean; }; type TracingBase = { readonly createdTimestamp: number; From 2de18a76f021da86a1ac36bcbc6d6c1707c05b48 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 27 Jun 2025 16:24:27 +0200 Subject: [PATCH 90/92] refactor mutex acquisition to use less state --- .../model/sagas/saving/save_mutex_saga.tsx | 66 +++++++++---------- 1 file changed, 31 insertions(+), 35 deletions(-) diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index 6f3b70d45a8..50892b5c3e2 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -25,8 +25,6 @@ const DISABLE_EAGER_MUTEX_ACQUISITION = true; type MutexLogicState = { isInitialRequest: boolean; - doesHaveMutexFromBeginning: boolean; - shallTryAcquireMutex: boolean; }; function* getDoesHaveMutex(): Saga { @@ -34,23 +32,23 @@ function* getDoesHaveMutex(): Saga { } export function* acquireAnnotationMutexMaybe(): Saga { - yield* call(ensureWkReady); - yield* fork(watchMutexStateChanges); if (DISABLE_EAGER_MUTEX_ACQUISITION) { return; } - const allowUpdate = yield* select((state) => state.annotation.restrictions.allowUpdate); - if (!allowUpdate) { + const initialAllowUpdate = yield* select( + (state) => state.annotation.restrictions.initialAllowUpdate, + ); + if (!initialAllowUpdate) { + // We are in an read-only annotation. return; } - const othersMayEdit = yield* select((state) => state.annotation.othersMayEdit); - const mutexLogicState: MutexLogicState = { isInitialRequest: true, - doesHaveMutexFromBeginning: false, - shallTryAcquireMutex: othersMayEdit, }; + yield* call(ensureWkReady); + yield* fork(watchMutexStateChangesForNotification, mutexLogicState); + let runningTryAcquireMutexContinuouslySaga = yield* fork( tryAcquireMutexContinuously, mutexLogicState, @@ -58,19 +56,22 @@ export function* acquireAnnotationMutexMaybe(): Saga { function* reactToOthersMayEditChanges({ othersMayEdit, }: SetOthersMayEditForAnnotationAction): Saga { - mutexLogicState.shallTryAcquireMutex = othersMayEdit; - if (mutexLogicState.shallTryAcquireMutex) { + if (othersMayEdit) { if (runningTryAcquireMutexContinuouslySaga != null) { yield* cancel(runningTryAcquireMutexContinuouslySaga); } - mutexLogicState.isInitialRequest = true; runningTryAcquireMutexContinuouslySaga = yield* fork( tryAcquireMutexContinuously, mutexLogicState, ); } else { // othersMayEdit was turned off. The user editing it should be able to edit the annotation. - yield* put(setAnnotationAllowUpdateAction(true)); + // let's check that owner === activeUser, anyway. + const owner = yield* select((storeState) => storeState.annotation.owner); + const activeUser = yield* select((state) => state.activeUser); + if (activeUser && owner?.id === activeUser?.id) { + yield* put(setAnnotationAllowUpdateAction(true)); + } } } yield* takeEvery("SET_OTHERS_MAY_EDIT_FOR_ANNOTATION", reactToOthersMayEditChanges); @@ -79,9 +80,15 @@ export function* acquireAnnotationMutexMaybe(): Saga { function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga { const annotationId = yield* select((storeState) => storeState.annotation.annotationId); const activeUser = yield* select((state) => state.activeUser); + mutexLogicState.isInitialRequest = true; - while (mutexLogicState.shallTryAcquireMutex) { - if (mutexLogicState.isInitialRequest) { + // We can simply use an infinite loop here, because the saga will be cancelled by + // reactToOthersMayEditChanges when othersMayEdit is set to false. + while (true) { + const blockedByUser = yield* select((state) => state.annotation.blockedByUser); + if (blockedByUser == null || blockedByUser.id !== activeUser?.id) { + // If the annotation is currently not blocked by the active user, + // we immediately disallow updating the annotation. yield* put(setAnnotationAllowUpdateAction(false)); } try { @@ -91,20 +98,12 @@ function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga { - // todop: wrong? - let isInitialRequest = true; +function* watchMutexStateChangesForNotification(mutexLogicState: MutexLogicState): Saga { yield* takeEvery( "SET_IS_MUTEX_ACQUIRED", function* ({ isMutexAcquired }: SetIsMutexAcquiredAction) { if (isMutexAcquired) { Toast.close(MUTEX_NOT_ACQUIRED_KEY); - if (!isInitialRequest) { + if (!mutexLogicState.isInitialRequest) { const message = ( {messages["annotation.acquiringMutexSucceeded"]}" " @@ -159,7 +155,7 @@ function* watchMutexStateChanges(): Saga { : messages["annotation.acquiringMutexFailed.noUser"]; Toast.warning(message, { sticky: true, key: MUTEX_NOT_ACQUIRED_KEY }); } - isInitialRequest = false; + mutexLogicState.isInitialRequest = false; }, ); } From aaeebf01a5722c950aee5829e276bf1dd11c4846 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 27 Jun 2025 18:21:42 +0200 Subject: [PATCH 91/92] make mutex-acquisition ad-hoc when trying to save --- frontend/javascripts/viewer/api/wk_dev.ts | 4 +- .../viewer/model/actions/save_actions.ts | 24 ++++- .../model/sagas/saving/save_mutex_saga.tsx | 94 ++++++++++++++++--- .../model/sagas/saving/save_queue_draining.ts | 10 +- .../viewer/model/sagas/volume/mapping_saga.ts | 1 + .../model/sagas/volume/proofread_saga.ts | 24 ++--- 6 files changed, 126 insertions(+), 31 deletions(-) diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index dd5f4559f6c..5b5330225ec 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -11,7 +11,7 @@ import type ApiLoader from "./api_loader"; // Can be accessed via window.webknossos.DEV.flags. Only use this // for debugging or one off scripts. export const WkDevFlags = { - logActions: false, + logActions: true, sam: { useLocalMask: true, }, @@ -28,7 +28,7 @@ export const WkDevFlags = { disableLayerNameSanitization: false, }, debugging: { - showCurrentVersionInInfoTab: false, + showCurrentVersionInInfoTab: true, }, meshing: { marchingCubeSizeInTargetMag: [64, 64, 64] as Vector3, diff --git a/frontend/javascripts/viewer/model/actions/save_actions.ts b/frontend/javascripts/viewer/model/actions/save_actions.ts index 0e26118534a..d1dccb0d393 100644 --- a/frontend/javascripts/viewer/model/actions/save_actions.ts +++ b/frontend/javascripts/viewer/model/actions/save_actions.ts @@ -26,6 +26,8 @@ type DisableSavingAction = ReturnType; export type EnsureTracingsWereDiffedToSaveQueueAction = ReturnType< typeof ensureTracingsWereDiffedToSaveQueueAction >; +export type EnsureMaySaveNowAction = ReturnType; +export type DoneSavingAction = ReturnType; export type SaveAction = | PushSaveQueueTransaction @@ -38,7 +40,9 @@ export type SaveAction = | UndoAction | RedoAction | DisableSavingAction - | EnsureTracingsWereDiffedToSaveQueueAction; + | EnsureTracingsWereDiffedToSaveQueueAction + | EnsureMaySaveNowAction + | DoneSavingAction; // The action creators pushSaveQueueTransaction and pushSaveQueueTransactionIsolated // are typed so that update actions that need isolation are isolated in a group each. @@ -132,3 +136,21 @@ export const ensureTracingsWereDiffedToSaveQueueAction = (callback: (tracingId: type: "ENSURE_TRACINGS_WERE_DIFFED_TO_SAVE_QUEUE", callback, }) as const; + +export const ensureMaySaveNowAction = (callback: () => void) => + ({ + type: "ENSURE_MAY_SAVE_NOW", + callback, + }) as const; + +export const dispatchEnsureMaySaveNowAsync = async (dispatch: Dispatch): Promise => { + const readyDeferred = new Deferred(); + const action = ensureMaySaveNowAction(() => readyDeferred.resolve(null)); + dispatch(action); + await readyDeferred.promise(); +}; + +export const doneSavingAction = () => + ({ + type: "DONE_SAVING", + }) as const; diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx index 50892b5c3e2..334cf2e1d60 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx +++ b/frontend/javascripts/viewer/model/sagas/saving/save_mutex_saga.tsx @@ -3,7 +3,19 @@ import { Button } from "antd"; import Toast from "libs/toast"; import messages from "messages"; import React from "react"; -import { call, cancel, cancelled, delay, fork, put, retry, takeEvery } from "typed-redux-saga"; +import { + call, + cancel, + cancelled, + delay, + type FixedTask, + fork, + put, + race, + retry, + take, + takeEvery, +} from "typed-redux-saga"; import { type SetIsMutexAcquiredAction, type SetOthersMayEditForAnnotationAction, @@ -14,11 +26,15 @@ import { import type { Saga } from "viewer/model/sagas/effect-generators"; import { select } from "viewer/model/sagas/effect-generators"; import { ensureWkReady } from "../ready_sagas"; +import { EnsureMaySaveNowAction } from "viewer/model/actions/save_actions"; + +// Also refer to application.conf where annotation.mutex.expiryTime is defined +// (typically, 2 minutes). const MUTEX_NOT_ACQUIRED_KEY = "MutexCouldNotBeAcquired"; const MUTEX_ACQUIRED_KEY = "AnnotationMutexAcquired"; -const RETRY_COUNT = 12; const ACQUIRE_MUTEX_INTERVAL = 1000 * 60; +const RETRY_COUNT = 12; // 12 retries with 60/12=5 seconds backup delay // todop const DISABLE_EAGER_MUTEX_ACQUISITION = true; @@ -32,27 +48,23 @@ function* getDoesHaveMutex(): Saga { } export function* acquireAnnotationMutexMaybe(): Saga { - if (DISABLE_EAGER_MUTEX_ACQUISITION) { - return; - } + yield* call(ensureWkReady); const initialAllowUpdate = yield* select( (state) => state.annotation.restrictions.initialAllowUpdate, ); if (!initialAllowUpdate) { - // We are in an read-only annotation. + // We are in an read-only annotation. There's no point in acquiring mutexes. + console.log("exit mutex saga"); return; } const mutexLogicState: MutexLogicState = { isInitialRequest: true, }; - yield* call(ensureWkReady); yield* fork(watchMutexStateChangesForNotification, mutexLogicState); - let runningTryAcquireMutexContinuouslySaga = yield* fork( - tryAcquireMutexContinuously, - mutexLogicState, - ); + let runningTryAcquireMutexContinuouslySaga: FixedTask | null; + function* reactToOthersMayEditChanges({ othersMayEdit, }: SetOthersMayEditForAnnotationAction): Saga { @@ -65,8 +77,9 @@ export function* acquireAnnotationMutexMaybe(): Saga { mutexLogicState, ); } else { - // othersMayEdit was turned off. The user editing it should be able to edit the annotation. - // let's check that owner === activeUser, anyway. + // othersMayEdit was turned off by the activeUser. Since this is only + // allowed by the owner, they should be able to edit the annotation, too. + // Still, let's check that owner === activeUser to be extra safe. const owner = yield* select((storeState) => storeState.annotation.owner); const activeUser = yield* select((state) => state.activeUser); if (activeUser && owner?.id === activeUser?.id) { @@ -75,9 +88,47 @@ export function* acquireAnnotationMutexMaybe(): Saga { } } yield* takeEvery("SET_OTHERS_MAY_EDIT_FOR_ANNOTATION", reactToOthersMayEditChanges); + + if (DISABLE_EAGER_MUTEX_ACQUISITION) { + console.log("listening to all ENSURE_MAY_SAVE_NOW"); + yield* takeEvery("ENSURE_MAY_SAVE_NOW", resolveEnsureMaySaveNowActions); + while (true) { + console.log("taking ENSURE_MAY_SAVE_NOW"); + yield* take("ENSURE_MAY_SAVE_NOW"); + console.log("took ENSURE_MAY_SAVE_NOW"); + const { doneSaving } = yield race({ + tryAcquireMutexContinuously: fork(tryAcquireMutexContinuously, mutexLogicState), + doneSaving: take("DONE_SAVING"), + }); + if (doneSaving) { + yield call(releaseMutex); + } + } + } else { + runningTryAcquireMutexContinuouslySaga = yield* fork( + tryAcquireMutexContinuously, + mutexLogicState, + ); + } } -function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga { +function* resolveEnsureMaySaveNowActions(action: EnsureMaySaveNowAction) { + /* + * For each EnsureMaySaveNowAction wait until, we have the mutex. Then call + * the callback. + */ + while (true) { + const doesHaveMutex = yield* select(getDoesHaveMutex); + if (doesHaveMutex) { + action.callback(); + return; + } + yield* take("SET_BLOCKED_BY_USER"); + } +} + +function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga { + console.log("started tryAcquireMutexContinuously"); const annotationId = yield* select((storeState) => storeState.annotation.annotationId); const activeUser = yield* select((state) => state.activeUser); mutexLogicState.isInitialRequest = true; @@ -85,6 +136,7 @@ function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga state.annotation.blockedByUser); if (blockedByUser == null || blockedByUser.id !== activeUser?.id) { // If the annotation is currently not blocked by the active user, @@ -114,7 +166,9 @@ function* tryAcquireMutexContinuously(mutexLogicState: MutexLogicState): Saga storeState.annotation.annotationId); + yield* retry( + RETRY_COUNT, + ACQUIRE_MUTEX_INTERVAL / RETRY_COUNT, + acquireAnnotationMutex, + annotationId, + ); + yield* put(setAnnotationAllowUpdateAction(true)); + yield* put(setBlockedByUserAction(null)); +} diff --git a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts index 52e24d0b122..84fb7756e81 100644 --- a/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts +++ b/frontend/javascripts/viewer/model/sagas/saving/save_queue_draining.ts @@ -14,6 +14,8 @@ import { call, delay, put, race, take } from "typed-redux-saga"; import { ControlModeEnum } from "viewer/constants"; import { getMagInfo } from "viewer/model/accessors/dataset_accessor"; import { + dispatchEnsureMaySaveNowAsync, + doneSavingAction, setLastSaveTimestampAction, setSaveBusyAction, setVersionNumberAction, @@ -30,11 +32,9 @@ import { PUSH_THROTTLE_TIME, SAVE_RETRY_WAITING_TIME, } from "viewer/model/sagas/saving/save_saga_constants"; -import { Model } from "viewer/singletons"; +import { Model, Store } from "viewer/singletons"; import type { SaveQueueEntry } from "viewer/store"; -const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; - export function* pushSaveQueueAsync(): Saga { yield* call(ensureWkReady); @@ -65,6 +65,9 @@ export function* pushSaveQueueAsync(): Saga { }); yield* put(setSaveBusyAction(true)); + // Wait until we may save + yield* call(dispatchEnsureMaySaveNowAsync, Store.dispatch); + // Send (parts of) the save queue to the server. // There are two main cases: // 1) forcePush is true @@ -96,6 +99,7 @@ export function* pushSaveQueueAsync(): Saga { break; } } + yield* put(doneSavingAction()); yield* put(setSaveBusyAction(false)); } } diff --git a/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts index 98ebe36b1cd..f3169ad72d2 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/mapping_saga.ts @@ -477,6 +477,7 @@ export function* updateLocalHdf5Mapping( intersection: mutableRemainingEntries, } = fastDiffSetAndMap(segmentIds as Set, previousMapping); + // todop: does this crash wk if the request fails? const newEntries = editableMapping != null ? yield* call( diff --git a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts index 359f3b52c73..62eb8e7b7fb 100644 --- a/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts +++ b/frontend/javascripts/viewer/model/sagas/volume/proofread_saga.ts @@ -1,11 +1,9 @@ import { type NeighborInfo, - acquireAnnotationMutex, getAgglomeratesForSegmentsFromTracingstore, getEdgesForAgglomerateMinCut, getNeighborsForAgglomerateNode, getPositionForSegmentInAgglomerate, - releaseAnnotationMutex, } from "admin/rest_api"; import { V3 } from "libs/mjs"; import Toast from "libs/toast"; @@ -444,7 +442,8 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); - yield* call(releaseAnnotationMutex, annotationId); + // todop + // yield* call(releaseAnnotationMutex, annotationId); if (action.type === "MIN_CUT_AGGLOMERATE_WITH_NODE_IDS" || action.type === "DELETE_EDGE") { if (sourceAgglomerateId !== targetAgglomerateId) { @@ -780,7 +779,8 @@ function* handleProofreadMergeOrMinCut(action: Action) { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); - yield* call(releaseAnnotationMutex, annotationId); + // todop + // yield* call(releaseAnnotationMutex, annotationId); if (action.type === "MIN_CUT_AGGLOMERATE") { console.log("start updating the mapping after a min-cut"); @@ -940,7 +940,8 @@ function* handleProofreadCutFromNeighbors(action: Action) { yield* put(pushSaveQueueTransaction(items)); yield* call([Model, Model.ensureSavedState]); - yield* call(releaseAnnotationMutex, annotationId); + // todop + // yield* call(releaseAnnotationMutex, annotationId); // Now that the changes are saved, we can split the mapping locally (because it requires // communication with the back-end). @@ -1124,12 +1125,13 @@ function* prepareSplitOrMerge(isSkeletonProofreading: boolean): Saga state.annotation.annotationId); - const { canEdit } = yield* call(acquireAnnotationMutex, annotationId); - if (!canEdit) { - Toast.error("Could not acquire mutex. Somebody else is proofreading at the moment."); - return null; - } + // todop + // const annotationId = yield* select((state) => state.annotation.annotationId); + // const { canEdit } = yield* call(acquireAnnotationMutex, annotationId); + // if (!canEdit) { + // Toast.error("Could not acquire mutex. Somebody else is proofreading at the moment."); + // return null; + // } return { agglomerateFileMag, From 1cec124e7723a58ca3f1dd102b70d31b73f0f28e Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 30 Jun 2025 11:28:44 +0200 Subject: [PATCH 92/92] in backend, make agglomerateIds and mag optional in proofreading update actions --- .../editablemapping/EditableMappingService.scala | 7 ++++--- .../editablemapping/EditableMappingUpdateActions.scala | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala index 4991972d234..4f30ce81929 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala @@ -185,17 +185,18 @@ class EditableMappingService @Inject()( def findSegmentIdAtPositionIfNeeded(remoteFallbackLayer: RemoteFallbackLayer, positionOpt: Option[Vec3Int], segmentIdOpt: Option[Long], - mag: Vec3Int)(implicit tc: TokenContext): Fox[Long] = + magOpt: Option[Vec3Int])(implicit tc: TokenContext): Fox[Long] = segmentIdOpt match { case Some(segmentId) => Fox.successful(segmentId) - case None => findSegmentIdAtPosition(remoteFallbackLayer, positionOpt, mag) + case None => findSegmentIdAtPosition(remoteFallbackLayer, positionOpt, magOpt) } private def findSegmentIdAtPosition(remoteFallbackLayer: RemoteFallbackLayer, positionOpt: Option[Vec3Int], - mag: Vec3Int)(implicit tc: TokenContext): Fox[Long] = + magOpt: Option[Vec3Int])(implicit tc: TokenContext): Fox[Long] = for { pos <- positionOpt.toFox ?~> "segment id or position is required in editable mapping action" + mag <- magOpt.toFox ?~> "segment id or mag is required in editable mapping action" voxelAsBytes: Array[Byte] <- remoteDatastoreClient.getVoxelAtPosition(remoteFallbackLayer, pos, mag) voxelAsLongArray: Array[Long] <- bytesToLongs(voxelAsBytes, remoteFallbackLayer.elementClass) _ <- Fox.fromBool(voxelAsLongArray.length == 1) ?~> s"Expected one, got ${voxelAsLongArray.length} segment id values for voxel." diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingUpdateActions.scala index 472de842ad5..61ad9841df0 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingUpdateActions.scala @@ -11,12 +11,12 @@ trait EditableMappingUpdateAction extends LayerUpdateAction { // we switched from positions to segment ids in https://github.com/scalableminds/webknossos/pull/7742. // Both are now optional to support applying old update actions stored in the db. -case class SplitAgglomerateUpdateAction(agglomerateId: Long, // Unused, we now look this up by position/segment +case class SplitAgglomerateUpdateAction(agglomerateId: Option[Long], // Unused, we now look this up by position/segment segmentPosition1: Option[Vec3Int], segmentPosition2: Option[Vec3Int], segmentId1: Option[Long], segmentId2: Option[Long], - mag: Vec3Int, + mag: Option[Vec3Int], actionTracingId: String, actionTimestamp: Option[Long] = None, actionAuthorId: Option[ObjectId] = None, @@ -36,13 +36,13 @@ object SplitAgglomerateUpdateAction { // we switched from positions to segment ids in https://github.com/scalableminds/webknossos/pull/7742. // Both are now optional to support applying old update actions stored in the db. -case class MergeAgglomerateUpdateAction(agglomerateId1: Long, // Unused, we now look this up by position/segment - agglomerateId2: Long, // Unused, we now look this up by position/segment +case class MergeAgglomerateUpdateAction(agglomerateId1: Option[Long], // Unused, we now look this up by position/segment + agglomerateId2: Option[Long], // Unused, we now look this up by position/segment segmentPosition1: Option[Vec3Int], segmentPosition2: Option[Vec3Int], segmentId1: Option[Long], segmentId2: Option[Long], - mag: Vec3Int, + mag: Option[Vec3Int], actionTracingId: String, actionTimestamp: Option[Long] = None, actionAuthorId: Option[ObjectId] = None,