diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 21b4beba4d..6eda69e352 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,6 +12,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - Added rendering precomputed meshes with level of detail depending on the zoom of the 3D viewport. This feature only works with version 3 mesh files. [#6909](https://github.com/scalableminds/webknossos/pull/6909) +- Segments can now be removed from the segment list via the context menu. [#6944](https://github.com/scalableminds/webknossos/pull/6944) +- Editing the meta data of segments (e.g., the name) is now undoable. [#6944](https://github.com/scalableminds/webknossos/pull/6944) ### Changed - Moved the view mode selection in the toolbar next to the position field. [#6949](https://github.com/scalableminds/webknossos/pull/6949) diff --git a/frontend/javascripts/components/color_picker.tsx b/frontend/javascripts/components/color_picker.tsx index 2d73e1af8d..ea988b01bf 100644 --- a/frontend/javascripts/components/color_picker.tsx +++ b/frontend/javascripts/components/color_picker.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { Popover } from "antd"; import * as Utils from "libs/utils"; import { HexColorInput, HexColorPicker } from "react-colorful"; @@ -47,10 +47,11 @@ export function ChangeColorMenuItemContent({ }: { title: string; isDisabled: boolean; - onSetColor: (rgb: Vector3) => void; + onSetColor: (rgb: Vector3, createsNewUndoState: boolean) => void; rgb: Vector3; hidePickerIcon?: boolean; }) { + const isFirstColorChange = useRef(true); const color = Utils.rgbToHex(Utils.map3((value) => value * 255, rgb)); const onChangeColor = (colorStr: string) => { if (isDisabled) { @@ -58,8 +59,14 @@ export function ChangeColorMenuItemContent({ } const colorRgb = Utils.hexToRgb(colorStr); const newColor = Utils.map3((component) => component / 255, colorRgb); - onSetColor(newColor); + + // Only create a new undo state on the first color change event. + // All following color change events should mutate the most recent undo + // state so that the undo stack is not filled on each mouse movement. + onSetColor(newColor, isFirstColorChange.current); + isFirstColorChange.current = false; }; + const content = isDisabled ? null : ( ); diff --git a/frontend/javascripts/libs/diffable_map.ts b/frontend/javascripts/libs/diffable_map.ts index 51479a0bea..d80e0ccbf7 100644 --- a/frontend/javascripts/libs/diffable_map.ts +++ b/frontend/javascripts/libs/diffable_map.ts @@ -1,12 +1,15 @@ const defaultItemsPerBatch = 1000; let idCounter = 0; -const idSymbol = Symbol("id"); // DiffableMap is an immutable key-value data structure which supports fast diffing. +const idSymbol = Symbol("id"); + +// DiffableMap is an immutable key-value data structure which supports fast diffing. // Updating a DiffableMap returns a new DiffableMap, in which case the Maps are // derived from each other ("dependent"). // Diffing is very fast when the given Maps are dependent, since the separate chunks // can be compared shallowly. // The insertion order of the DiffableMap is not guaranteed. -// Stored values may be null. However, `undefined` is equal to "does not exist". +// Stored values may be null. However, be careful when dealing with `undefined`, as +// it's interpreted as "does not exist", but can still be listed during enumeration. class DiffableMap { chunks: Array>; diff --git a/frontend/javascripts/oxalis/api/api_latest.ts b/frontend/javascripts/oxalis/api/api_latest.ts index 13a77e54f7..49766d408e 100644 --- a/frontend/javascripts/oxalis/api/api_latest.ts +++ b/frontend/javascripts/oxalis/api/api_latest.ts @@ -2066,6 +2066,8 @@ class DataApi { color: rgbColor, }, effectiveLayerName, + undefined, + true, ), ); } diff --git a/frontend/javascripts/oxalis/controller/combinations/segmentation_handlers.ts b/frontend/javascripts/oxalis/controller/combinations/segmentation_handlers.ts index 77dba205c7..ee380d4237 100644 --- a/frontend/javascripts/oxalis/controller/combinations/segmentation_handlers.ts +++ b/frontend/javascripts/oxalis/controller/combinations/segmentation_handlers.ts @@ -144,8 +144,8 @@ export async function loadSynapsesOfAgglomerateAtPosition(position: Vector3) { const isConnectomeEnabled = hasConnectomeFile(state); if (mappingName && isConnectomeEnabled.value) { - const cellId = await getSegmentIdForPositionAsync(position); - Store.dispatch(setActiveConnectomeAgglomerateIdsAction(segmentation.name, [cellId])); + const segmentId = await getSegmentIdForPositionAsync(position); + Store.dispatch(setActiveConnectomeAgglomerateIdsAction(segmentation.name, [segmentId])); } else { Toast.error(isConnectomeEnabled.reason); } @@ -153,9 +153,9 @@ export async function loadSynapsesOfAgglomerateAtPosition(position: Vector3) { export function handleClickSegment(clickPosition: Point2) { const state = Store.getState(); const globalPosition = calculateGlobalPos(state, clickPosition); - const cellId = getSegmentIdForPosition(globalPosition); + const segmentId = getSegmentIdForPosition(globalPosition); - if (cellId > 0) { - Store.dispatch(clickSegmentAction(cellId, globalPosition)); + if (segmentId > 0) { + Store.dispatch(clickSegmentAction(segmentId, globalPosition)); } } diff --git a/frontend/javascripts/oxalis/model/actions/annotation_actions.ts b/frontend/javascripts/oxalis/model/actions/annotation_actions.ts index 7eced8a171..d1c04e1ae6 100644 --- a/frontend/javascripts/oxalis/model/actions/annotation_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/annotation_actions.ts @@ -260,11 +260,11 @@ export const maybeFetchMeshFilesAction = ( callback, } as const); -export const triggerIsosurfaceDownloadAction = (cellName: string, cellId: number) => +export const triggerIsosurfaceDownloadAction = (cellName: string, segmentId: number) => ({ type: "TRIGGER_ISOSURFACE_DOWNLOAD", cellName, - cellId, + segmentId, } as const); export const refreshIsosurfacesAction = () => @@ -272,25 +272,25 @@ export const refreshIsosurfacesAction = () => type: "REFRESH_ISOSURFACES", } as const); -export const refreshIsosurfaceAction = (layerName: string, cellId: number) => +export const refreshIsosurfaceAction = (layerName: string, segmentId: number) => ({ type: "REFRESH_ISOSURFACE", layerName, - cellId, + segmentId, } as const); -export const startedLoadingIsosurfaceAction = (layerName: string, cellId: number) => +export const startedLoadingIsosurfaceAction = (layerName: string, segmentId: number) => ({ type: "STARTED_LOADING_ISOSURFACE", layerName, - cellId, + segmentId, } as const); -export const finishedLoadingIsosurfaceAction = (layerName: string, cellId: number) => +export const finishedLoadingIsosurfaceAction = (layerName: string, segmentId: number) => ({ type: "FINISHED_LOADING_ISOSURFACE", layerName, - cellId, + segmentId, } as const); export const updateMeshFileListAction = (layerName: string, meshFiles: Array) => @@ -317,16 +317,16 @@ export const importIsosurfaceFromStlAction = (layerName: string, buffer: ArrayBu buffer, } as const); -export const removeIsosurfaceAction = (layerName: string, cellId: number) => +export const removeIsosurfaceAction = (layerName: string, segmentId: number) => ({ type: "REMOVE_ISOSURFACE", layerName, - cellId, + segmentId, } as const); export const addAdHocIsosurfaceAction = ( layerName: string, - cellId: number, + segmentId: number, seedPosition: Vector3, mappingName: string | null | undefined, mappingType: MappingType | null | undefined, @@ -334,7 +334,7 @@ export const addAdHocIsosurfaceAction = ( ({ type: "ADD_AD_HOC_ISOSURFACE", layerName, - cellId, + segmentId, seedPosition, mappingName, mappingType, @@ -342,14 +342,14 @@ export const addAdHocIsosurfaceAction = ( export const addPrecomputedIsosurfaceAction = ( layerName: string, - cellId: number, + segmentId: number, seedPosition: Vector3, meshFileName: string, ) => ({ type: "ADD_PRECOMPUTED_ISOSURFACE", layerName, - cellId, + segmentId, seedPosition, meshFileName, } as const); diff --git a/frontend/javascripts/oxalis/model/actions/segmentation_actions.ts b/frontend/javascripts/oxalis/model/actions/segmentation_actions.ts index f00bed3e31..f4b8984e60 100644 --- a/frontend/javascripts/oxalis/model/actions/segmentation_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/segmentation_actions.ts @@ -12,28 +12,28 @@ export type LoadPrecomputedMeshAction = ReturnType ({ type: "LOAD_AD_HOC_MESH_ACTION", - cellId, + segmentId, seedPosition, extraInfo, layerName, } as const); export const loadPrecomputedMeshAction = ( - cellId: number, + segmentId: number, seedPosition: Vector3, meshFileName: string, layerName?: string, ) => ({ type: "LOAD_PRECOMPUTED_MESH_ACTION", - cellId, + segmentId, seedPosition, meshFileName, layerName, diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index f9e5651f33..3622ed45df 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -35,6 +35,7 @@ export type ImportVolumeTracingAction = ReturnType; export type SetSegmentsAction = ReturnType; export type UpdateSegmentAction = ReturnType; +export type RemoveSegmentAction = ReturnType; export type SetMappingIsEditableAction = ReturnType; export type ComputeQuickSelectForRectAction = ReturnType; @@ -61,6 +62,7 @@ export type VolumeTracingAction = | SetContourTracingModeAction | SetSegmentsAction | UpdateSegmentAction + | RemoveSegmentAction | AddBucketToUndoAction | ImportVolumeTracingAction | SetLargestSegmentIdAction @@ -76,6 +78,7 @@ export const VolumeTracingSaveRelevantActions = [ "SET_ACTIVE_CELL", "FINISH_ANNOTATION_STROKE", "UPDATE_SEGMENT", + "REMOVE_SEGMENT", "SET_SEGMENTS", ...AllUserBoundingBoxActions, // Note that the following two actions are defined in settings_actions.ts @@ -141,17 +144,17 @@ export const finishEditingAction = () => type: "FINISH_EDITING", } as const); -export const setActiveCellAction = (cellId: number, somePosition?: Vector3) => +export const setActiveCellAction = (segmentId: number, somePosition?: Vector3) => ({ type: "SET_ACTIVE_CELL", - cellId, + segmentId, somePosition, } as const); -export const clickSegmentAction = (cellId: number, somePosition: Vector3) => +export const clickSegmentAction = (segmentId: number, somePosition: Vector3) => ({ type: "CLICK_SEGMENT", - cellId, + segmentId, somePosition, } as const); @@ -167,6 +170,7 @@ export const updateSegmentAction = ( segment: Partial, layerName: string, timestamp: number = Date.now(), + createsNewUndoState: boolean = false, ) => ({ type: "UPDATE_SEGMENT", @@ -174,6 +178,19 @@ export const updateSegmentAction = ( segment, layerName, timestamp, + createsNewUndoState, + } as const); + +export const removeSegmentAction = ( + segmentId: number, + layerName: string, + timestamp: number = Date.now(), +) => + ({ + type: "REMOVE_SEGMENT", + segmentId, + layerName, + timestamp, } as const); export const interpolateSegmentationLayerAction = () => @@ -236,10 +253,10 @@ export const importVolumeTracingAction = () => type: "IMPORT_VOLUMETRACING", } as const); -export const setLargestSegmentIdAction = (cellId: number) => +export const setLargestSegmentIdAction = (segmentId: number) => ({ type: "SET_LARGEST_SEGMENT_ID", - cellId, + segmentId, } as const); export const dispatchFloodfillAsync = async ( diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts index 2d9df7f88e..f24e32f033 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts @@ -351,7 +351,7 @@ class DataCube { async _labelVoxelInAllResolutions_DEPRECATED( voxel: Vector3, label: number, - activeCellId?: number | null | undefined, + activeSegmentId?: number | null | undefined, ): Promise { // This function is only provided for the wK front-end api and should not be used internally, // since it only operates on one voxel and therefore is not performance-optimized. @@ -360,7 +360,7 @@ class DataCube { for (const [resolutionIndex] of this.resolutionInfo.getResolutionsWithIndices()) { promises.push( - this._labelVoxelInResolution_DEPRECATED(voxel, label, resolutionIndex, activeCellId), + this._labelVoxelInResolution_DEPRECATED(voxel, label, resolutionIndex, activeSegmentId), ); } @@ -372,7 +372,7 @@ class DataCube { voxel: Vector3, label: number, zoomStep: number, - activeCellId: number | null | undefined, + activeSegmentId: number | null | undefined, ): Promise { let voxelInCube = true; @@ -388,9 +388,9 @@ class DataCube { const voxelIndex = this.getVoxelIndex(voxel, zoomStep); let shouldUpdateVoxel = true; - if (activeCellId != null) { + if (activeSegmentId != null) { const voxelValue = this.getMappedDataValue(voxel, zoomStep); - shouldUpdateVoxel = activeCellId === voxelValue; + shouldUpdateVoxel = activeSegmentId === voxelValue; } if (shouldUpdateVoxel) { @@ -406,7 +406,7 @@ class DataCube { async floodFill( globalSeedVoxel: Vector3, - cellIdNumber: number, + segmentIdNumber: number, dimensionIndices: DimensionMap, floodfillBoundingBox: BoundingBoxType, zoomStep: number, @@ -460,11 +460,11 @@ class DataCube { const seedVoxelIndex = this.getVoxelIndex(globalSeedVoxel, zoomStep); const seedBucketData = seedBucket.getOrCreateData(); - const sourceCellId = seedBucketData[seedVoxelIndex]; + const sourceSegmentId = seedBucketData[seedVoxelIndex]; - const cellId = castForArrayType(cellIdNumber, seedBucketData); + const segmentId = castForArrayType(segmentIdNumber, seedBucketData); - if (sourceCellId === cellId) { + if (sourceSegmentId === segmentId) { return { bucketsWithLabeledVoxelsMap, wasBoundingBoxExceeded: false, @@ -534,15 +534,15 @@ class DataCube { const bucketData = await currentBucket.getDataForMutation(); const initialVoxelIndex = this.getVoxelIndexByVoxelOffset(initialXyzVoxelInBucket); - if (bucketData[initialVoxelIndex] !== sourceCellId) { - // Ignoring neighbour buckets whose cellId at the initial voxel does not match the source cell id. + if (bucketData[initialVoxelIndex] !== sourceSegmentId) { + // Ignoring neighbour buckets whose segmentId at the initial voxel does not match the source cell id. continue; } // Add the bucket to the current volume undo batch, if it isn't already part of it. currentBucket.startDataMutation(); // Mark the initial voxel. - bucketData[initialVoxelIndex] = cellId; + bucketData[initialVoxelIndex] = segmentId; // Create an array saving the labeled voxel of the current slice for the current bucket, if there isn't already one. const currentLabeledVoxelMap = bucketsWithLabeledVoxelsMap.get(currentBucket.zoomedAddress) || new Map(); @@ -609,8 +609,8 @@ class DataCube { // Label the current neighbour and add it to the neighbourVoxelStackUvw to iterate over its neighbours. const neighbourVoxelIndex = this.getVoxelIndexByVoxelOffset(neighbourVoxelXyz); - if (bucketData[neighbourVoxelIndex] === sourceCellId) { - bucketData[neighbourVoxelIndex] = cellId; + if (bucketData[neighbourVoxelIndex] === sourceSegmentId) { + bucketData[neighbourVoxelIndex] = segmentId; markUvwInSliceAsLabeled(neighbourVoxelUvw); neighbourVoxelStackUvw.pushVoxel(neighbourVoxelUvw); labeledVoxelCount++; diff --git a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts index 8ff9ec5a41..22a221b2cc 100644 --- a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts @@ -249,8 +249,8 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { } case "REMOVE_ISOSURFACE": { - const { layerName, cellId } = action; - const { [cellId]: _, ...remainingIsosurfaces } = + const { layerName, segmentId } = action; + const { [segmentId]: _, ...remainingIsosurfaces } = state.localSegmentationData[layerName].isosurfaces; return updateKey2(state, "localSegmentationData", layerName, { isosurfaces: remainingIsosurfaces, @@ -258,9 +258,9 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { } case "ADD_AD_HOC_ISOSURFACE": { - const { layerName, cellId, seedPosition, mappingName, mappingType } = action; + const { layerName, segmentId, seedPosition, mappingName, mappingType } = action; const isosurfaceInfo: IsosurfaceInformation = { - segmentId: cellId, + segmentId: segmentId, seedPosition, isLoading: false, isVisible: true, @@ -273,15 +273,15 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { "localSegmentationData", layerName, "isosurfaces", - cellId, + segmentId, isosurfaceInfo, ); } case "ADD_PRECOMPUTED_ISOSURFACE": { - const { layerName, cellId, seedPosition, meshFileName } = action; + const { layerName, segmentId, seedPosition, meshFileName } = action; const isosurfaceInfo: IsosurfaceInformation = { - segmentId: cellId, + segmentId: segmentId, seedPosition, isLoading: false, isVisible: true, @@ -293,13 +293,13 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { "localSegmentationData", layerName, "isosurfaces", - cellId, + segmentId, isosurfaceInfo, ); } case "STARTED_LOADING_ISOSURFACE": { - const { layerName, cellId } = action; + const { layerName, segmentId } = action; const isosurfaceInfo: Partial = { isLoading: true, }; @@ -308,13 +308,13 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { "localSegmentationData", layerName, "isosurfaces", - cellId, + segmentId, isosurfaceInfo, ); } case "FINISHED_LOADING_ISOSURFACE": { - const { layerName, cellId } = action; + const { layerName, segmentId } = action; const isosurfaceInfo: Partial = { isLoading: false, }; @@ -323,7 +323,7 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { "localSegmentationData", layerName, "isosurfaces", - cellId, + segmentId, isosurfaceInfo, ); } diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index b7cf2e1d5c..4cf5fbb0f5 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -1,10 +1,11 @@ import update from "immutability-helper"; import { ContourModeEnum } from "oxalis/constants"; -import type { EditableMapping, OxalisState, VolumeTracing } from "oxalis/store"; +import type { EditableMapping, OxalisState, SegmentMap, VolumeTracing } from "oxalis/store"; import type { VolumeTracingAction, UpdateSegmentAction, SetSegmentsAction, + RemoveSegmentAction, } from "oxalis/model/actions/volumetracing_actions"; import { convertServerBoundingBoxToFrontend, @@ -53,9 +54,7 @@ function getSegmentUpdateInfo( state: OxalisState, layerName: string | null | undefined, ): SegmentUpdateInfo { - // If the the action is referring to a volume tracing, only update - // the given state if handleVolumeTracing is true. - // Returns [shouldHandleUpdate, layerName] + // Returns an object describing how to update a segment in the specified layer. const layer = getRequestedOrVisibleSegmentationLayer(state, layerName); if (!layer) { @@ -78,65 +77,23 @@ function getSegmentUpdateInfo( } } -function handleSetSegments(state: OxalisState, action: SetSegmentsAction) { - const { segments, layerName } = action; +function updateSegments( + state: OxalisState, + layerName: string, + mapFn: (segments: SegmentMap) => SegmentMap, +) { const updateInfo = getSegmentUpdateInfo(state, layerName); if (updateInfo.type === "NOOP") { return state; } - if (updateInfo.type === "UPDATE_VOLUME_TRACING") { - return updateVolumeTracing(state, updateInfo.volumeTracing.tracingId, { - segments, - }); - } - - // Update localSegmentationData - return updateKey2(state, "localSegmentationData", updateInfo.layerName, { - segments, - }); -} - -function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { - const { segmentId, segment, layerName: _layerName } = action; - const updateInfo = getSegmentUpdateInfo(state, _layerName); - - if (updateInfo.type === "NOOP") { - return state; - } - const { segments } = updateInfo.type === "UPDATE_VOLUME_TRACING" ? updateInfo.volumeTracing : state.localSegmentationData[updateInfo.layerName]; - const oldSegment = segments.getNullable(segmentId); - let somePosition; - - if (segment.somePosition) { - somePosition = Utils.floor3(segment.somePosition); - } else if (oldSegment != null) { - somePosition = oldSegment.somePosition; - } else { - // UPDATE_SEGMENT was called for a non-existing segment without providing - // a position. This is necessary to define custom colors for segments - // which are listed in a JSON mapping. The action will store the segment - // without a position. - } - const newSegment = { - // If oldSegment exists, its creationTime will be - // used by ...oldSegment - creationTime: action.timestamp, - name: null, - color: null, - ...oldSegment, - ...segment, - somePosition, - id: segmentId, - }; - - const newSegmentMap = segments.set(segmentId, newSegment); + const newSegmentMap = mapFn(segments); if (updateInfo.type === "UPDATE_VOLUME_TRACING") { return updateVolumeTracing(state, updateInfo.volumeTracing.tracingId, { @@ -150,6 +107,49 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { }); } +function handleSetSegments(state: OxalisState, action: SetSegmentsAction) { + const { segments, layerName } = action; + return updateSegments(state, layerName, (_oldSegments) => segments); +} + +function handleRemoveSegment(state: OxalisState, action: RemoveSegmentAction) { + return updateSegments(state, action.layerName, (segments) => segments.delete(action.segmentId)); +} + +function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { + return updateSegments(state, action.layerName, (segments) => { + const { segmentId, segment } = action; + const oldSegment = segments.getNullable(segmentId); + + let somePosition; + if (segment.somePosition) { + somePosition = Utils.floor3(segment.somePosition); + } else if (oldSegment != null) { + somePosition = oldSegment.somePosition; + } else { + // UPDATE_SEGMENT was called for a non-existing segment without providing + // a position. This is necessary to define custom colors for segments + // which are listed in a JSON mapping. The action will store the segment + // without a position. + } + + const newSegment = { + // If oldSegment exists, its creationTime will be + // used by ...oldSegment + creationTime: action.timestamp, + name: null, + color: null, + ...oldSegment, + ...segment, + somePosition, + id: segmentId, + }; + + const newSegmentMap = segments.set(segmentId, newSegment); + return newSegmentMap; + }); +} + export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): VolumeTracing { // As the frontend doesn't know all cells, we have to keep track of the highest id // and cannot compute it @@ -241,6 +241,10 @@ function VolumeTracingReducer( return handleUpdateSegment(state, action); } + case "REMOVE_SEGMENT": { + return handleRemoveSegment(state, action); + } + default: // pass } @@ -260,7 +264,7 @@ function VolumeTracingReducer( switch (action.type) { case "SET_ACTIVE_CELL": { - return setActiveCellReducer(state, volumeTracing, action.cellId); + return setActiveCellReducer(state, volumeTracing, action.segmentId); } case "CREATE_CELL": { @@ -288,7 +292,7 @@ function VolumeTracingReducer( } case "SET_LARGEST_SEGMENT_ID": { - return setLargestSegmentIdReducer(state, volumeTracing, action.cellId); + return setLargestSegmentIdReducer(state, volumeTracing, action.segmentId); } case "FINISH_ANNOTATION_STROKE": { diff --git a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts index 66f510f07f..e2ad6ec76d 100644 --- a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts @@ -72,7 +72,7 @@ import { getDracoLoader } from "libs/draco"; import messages from "messages"; import processTaskWithPool from "libs/task_pool"; import { getBaseSegmentationName } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; -import { UpdateSegmentAction } from "../actions/volumetracing_actions"; +import { RemoveSegmentAction, UpdateSegmentAction } from "../actions/volumetracing_actions"; export const NO_LOD_MESH_INDEX = -1; const MAX_RETRY_COUNT = 5; @@ -175,7 +175,7 @@ function* loadAdHocIsosurfaceFromAction(action: LoadAdHocMeshAction): Saga yield* call( loadAdHocIsosurface, action.seedPosition, - action.cellId, + action.segmentId, false, action.layerName, action.extraInfo, @@ -184,7 +184,7 @@ function* loadAdHocIsosurfaceFromAction(action: LoadAdHocMeshAction): Saga function* loadAdHocIsosurface( seedPosition: Vector3, - cellId: number, + segmentId: number, removeExistingIsosurface: boolean = false, layerName?: string | null | undefined, maybeExtraInfo?: AdHocIsosurfaceInfo, @@ -192,7 +192,7 @@ function* loadAdHocIsosurface( const layer = layerName != null ? Model.getLayerByName(layerName) : Model.getVisibleSegmentationLayer(); - if (cellId === 0 || layer == null) { + if (segmentId === 0 || layer == null) { return; } @@ -200,7 +200,7 @@ function* loadAdHocIsosurface( yield* call( loadIsosurfaceForSegmentId, - cellId, + segmentId, seedPosition, isosurfaceExtraInfo, removeExistingIsosurface, @@ -278,7 +278,7 @@ function* loadIsosurfaceForSegmentId( cancel: take( (action: Action) => action.type === "REMOVE_ISOSURFACE" && - action.cellId === segmentId && + action.segmentId === segmentId && action.layerName === layer.name, ), }); @@ -448,20 +448,20 @@ function* refreshIsosurfaces(): Saga { adhocIsosurfacesMapByLayer[segmentationLayer.name] || new Map(); const isosurfacesMapForLayer = adhocIsosurfacesMapByLayer[segmentationLayer.name]; - for (const [cellId, threeDMap] of Array.from(isosurfacesMapForLayer.entries())) { - if (!currentlyModifiedCells.has(cellId)) { + for (const [segmentId, threeDMap] of Array.from(isosurfacesMapForLayer.entries())) { + if (!currentlyModifiedCells.has(segmentId)) { continue; } - yield* call(_refreshIsosurfaceWithMap, cellId, threeDMap, segmentationLayer.name); + yield* call(_refreshIsosurfaceWithMap, segmentId, threeDMap, segmentationLayer.name); } } function* refreshIsosurface(action: RefreshIsosurfaceAction): Saga { - const { cellId, layerName } = action; + const { segmentId, layerName } = action; const isosurfaceInfo = yield* select( - (state) => state.localSegmentationData[layerName].isosurfaces[cellId], + (state) => state.localSegmentationData[layerName].isosurfaces[segmentId], ); if (isosurfaceInfo.isPrecomputed) { @@ -475,19 +475,19 @@ function* refreshIsosurface(action: RefreshIsosurfaceAction): Saga { ), ); } else { - const threeDMap = adhocIsosurfacesMapByLayer[action.layerName].get(cellId); + const threeDMap = adhocIsosurfacesMapByLayer[action.layerName].get(segmentId); if (threeDMap == null) return; - yield* call(_refreshIsosurfaceWithMap, cellId, threeDMap, layerName); + yield* call(_refreshIsosurfaceWithMap, segmentId, threeDMap, layerName); } } function* _refreshIsosurfaceWithMap( - cellId: number, + segmentId: number, threeDMap: ThreeDMap, layerName: string, ): Saga { const isosurfaceInfo = yield* select( - (state) => state.localSegmentationData[layerName].isosurfaces[cellId], + (state) => state.localSegmentationData[layerName].isosurfaces[segmentId], ); yield* call( [ErrorHandling, ErrorHandling.assert], @@ -502,23 +502,23 @@ function* _refreshIsosurfaceWithMap( return; } - yield* put(startedLoadingIsosurfaceAction(layerName, cellId)); + yield* put(startedLoadingIsosurfaceAction(layerName, segmentId)); // Remove isosurface from cache. - yield* call(removeIsosurface, removeIsosurfaceAction(layerName, cellId), false); + yield* call(removeIsosurface, removeIsosurfaceAction(layerName, segmentId), false); // The isosurface should only be removed once after re-fetching the isosurface first position. let shouldBeRemoved = true; for (const [, position] of isosurfacePositions) { // Reload the isosurface at the given position if it isn't already loaded there. // This is done to ensure that every voxel of the isosurface is reloaded. - yield* call(loadAdHocIsosurface, position, cellId, shouldBeRemoved, layerName, { + yield* call(loadAdHocIsosurface, position, segmentId, shouldBeRemoved, layerName, { mappingName, mappingType, }); shouldBeRemoved = false; } - yield* put(finishedLoadingIsosurfaceAction(layerName, cellId)); + yield* put(finishedLoadingIsosurfaceAction(layerName, segmentId)); } /* @@ -581,7 +581,7 @@ function* maybeFetchMeshFiles(action: MaybeFetchMeshFilesAction): Saga { } function* loadPrecomputedMesh(action: LoadPrecomputedMeshAction) { - const { cellId, seedPosition, meshFileName, layerName } = action; + const { segmentId, seedPosition, meshFileName, layerName } = action; const layer = yield* select((state) => layerName != null ? getSegmentationLayerByName(state.dataset, layerName) @@ -595,7 +595,7 @@ function* loadPrecomputedMesh(action: LoadPrecomputedMeshAction) { yield* race({ loadPrecomputedMeshForSegmentId: call( loadPrecomputedMeshForSegmentId, - cellId, + segmentId, seedPosition, meshFileName, layer, @@ -604,7 +604,7 @@ function* loadPrecomputedMesh(action: LoadPrecomputedMeshAction) { // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'otherAction' implicitly has an 'any' ty... Remove this comment to see the full error message (otherAction) => otherAction.type === "REMOVE_ISOSURFACE" && - otherAction.cellId === cellId && + otherAction.segmentId === segmentId && otherAction.layerName === layer.name, ), }); @@ -816,9 +816,9 @@ function* loadPrecomputedMeshForSegmentId( * Ad Hoc and Precomputed Meshes * */ -function* downloadIsosurfaceCellById(cellName: string, cellId: number): Saga { +function* downloadIsosurfaceCellById(cellName: string, segmentId: number): Saga { const sceneController = getSceneController(); - const geometry = sceneController.getIsosurfaceGeometryInBestLOD(cellId); + const geometry = sceneController.getIsosurfaceGeometryInBestLOD(segmentId); if (geometry == null) { const errorMessage = messages["tracing.not_isosurface_available_to_download"]; @@ -830,23 +830,23 @@ function* downloadIsosurfaceCellById(cellName: string, cellId: number): Saga { stl.setUint8(index, marker); }); - stl.setUint32(cellIdIndex, cellId, true); + stl.setUint32(segmentIdIndex, segmentId, true); const blob = new Blob([stl]); - yield* call(saveAs, blob, `${cellName}-${cellId}.stl`); + yield* call(saveAs, blob, `${cellName}-${segmentId}.stl`); } function* downloadIsosurfaceCell(action: TriggerIsosurfaceDownloadAction): Saga { - yield* call(downloadIsosurfaceCellById, action.cellName, action.cellId); + yield* call(downloadIsosurfaceCellById, action.cellName, action.segmentId); } function* importIsosurfaceFromStl(action: ImportIsosurfaceFromStlAction): Saga { const { layerName, buffer } = action; const dataView = new DataView(buffer); - const segmentId = dataView.getUint32(stlIsosurfaceConstants.cellIdIndex, true); + const segmentId = dataView.getUint32(stlIsosurfaceConstants.segmentIdIndex, true); const geometry = yield* call(parseStlBuffer, buffer); getSceneController().addIsosurfaceFromGeometry( geometry, @@ -865,14 +865,22 @@ function* importIsosurfaceFromStl(action: ImportIsosurfaceFromStlAction): Saga { @@ -902,9 +910,10 @@ export default function* isosurfaceSaga(): Saga { yield* takeEvery("TRIGGER_ISOSURFACE_DOWNLOAD", downloadIsosurfaceCell); yield* takeEvery("IMPORT_ISOSURFACE_FROM_STL", importIsosurfaceFromStl); yield* takeEvery("REMOVE_ISOSURFACE", removeIsosurface); + yield* takeEvery("REMOVE_SEGMENT", handleRemoveSegment); yield* takeEvery("REFRESH_ISOSURFACES", refreshIsosurfaces); yield* takeEvery("REFRESH_ISOSURFACE", refreshIsosurface); yield* takeEvery("UPDATE_ISOSURFACE_VISIBILITY", handleIsosurfaceVisibilityChange); yield* takeEvery(["START_EDITING", "COPY_SEGMENTATION_LAYER"], markEditedCellAsDirty); - yield* takeEvery(["UPDATE_SEGMENT"], handleIsosurfaceColorChange); + yield* takeEvery("UPDATE_SEGMENT", handleIsosurfaceColorChange); } diff --git a/frontend/javascripts/oxalis/model/sagas/root_saga.ts b/frontend/javascripts/oxalis/model/sagas/root_saga.ts index e6c06bea0e..a8d66e20d7 100644 --- a/frontend/javascripts/oxalis/model/sagas/root_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/root_saga.ts @@ -3,6 +3,7 @@ import { all, call, cancel, fork, take } from "typed-redux-saga"; import { alert } from "libs/window"; import VolumetracingSagas from "oxalis/model/sagas/volumetracing_saga"; import SaveSagas, { toggleErrorHighlighting } from "oxalis/model/sagas/save_saga"; +import UndoSaga from "oxalis/model/sagas/undo_saga"; import AnnotationSagas from "oxalis/model/sagas/annotation_saga"; import { watchDataRelevantChanges } from "oxalis/model/sagas/prefetch_saga"; import SkeletontracingSagas from "oxalis/model/sagas/skeletontracing_saga"; @@ -51,6 +52,7 @@ function* restartableSaga(): Saga { call(ProofreadSaga), ...AnnotationSagas.map((saga) => call(saga)), ...SaveSagas.map((saga) => call(saga)), + call(UndoSaga), ...VolumetracingSagas.map((saga) => call(saga)), call(watchZ1Downsampling), ]); diff --git a/frontend/javascripts/oxalis/model/sagas/save_saga.ts b/frontend/javascripts/oxalis/model/sagas/save_saga.ts index 7224b2f11a..7269e34dc2 100644 --- a/frontend/javascripts/oxalis/model/sagas/save_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/save_saga.ts @@ -1,751 +1,63 @@ -import { Task } from "redux-saga"; -import type { Saga } from "oxalis/model/sagas/effect-generators"; -import type { Action } from "oxalis/model/actions/actions"; +import { doWithToken, getNewestVersionForTracing } from "admin/admin_rest_api"; +import Date from "libs/date"; +import ErrorHandling from "libs/error_handling"; +import type { RequestOptionsWithData } from "libs/request"; +import Request from "libs/request"; +import Toast from "libs/toast"; +import { sleep } from "libs/utils"; +import window, { alert, document, location } from "libs/window"; +import _ from "lodash"; +import messages from "messages"; +import { ControlModeEnum } from "oxalis/constants"; +import { getResolutionInfo } from "oxalis/model/accessors/dataset_accessor"; +import { selectQueue } from "oxalis/model/accessors/save_accessor"; +import { selectTracing } from "oxalis/model/accessors/tracing_accessor"; +import { getVolumeTracingById } from "oxalis/model/accessors/volumetracing_accessor"; +import { FlycamActions } from "oxalis/model/actions/flycam_actions"; +import type { SaveQueueType } from "oxalis/model/actions/save_actions"; +import { + pushSaveQueueTransaction, + setLastSaveTimestampAction, + setSaveBusyAction, + setVersionNumberAction, + shiftSaveQueueAction, +} from "oxalis/model/actions/save_actions"; +import type { InitializeSkeletonTracingAction } from "oxalis/model/actions/skeletontracing_actions"; +import { SkeletonTracingSaveRelevantActions } from "oxalis/model/actions/skeletontracing_actions"; +import { ViewModeSaveRelevantActions } from "oxalis/model/actions/view_mode_actions"; import { - AddBucketToUndoAction, - FinishAnnotationStrokeAction, - ImportVolumeTracingAction, - MaybeUnmergedBucketLoadedPromise, - UpdateSegmentAction, - InitializeVolumeTracingAction, InitializeEditableMappingAction, - cancelQuickSelectAction, + InitializeVolumeTracingAction, VolumeTracingSaveRelevantActions, - setSegmentsAction, } from "oxalis/model/actions/volumetracing_actions"; -import type { UserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; -import { - AllUserBoundingBoxActions, - setUserBoundingBoxesAction, -} from "oxalis/model/actions/annotation_actions"; -import type { BucketDataArray } from "oxalis/model/bucket_data_handling/bucket"; -import { FlycamActions } from "oxalis/model/actions/flycam_actions"; +import compactSaveQueue from "oxalis/model/helpers/compaction/compact_save_queue"; +import compactUpdateActions from "oxalis/model/helpers/compaction/compact_update_actions"; +import { globalPositionToBucketPosition } from "oxalis/model/helpers/position_converter"; +import type { Saga } from "oxalis/model/sagas/effect-generators"; +import { select } from "oxalis/model/sagas/effect-generators"; import { + maximumActionCountPerSave, + MAX_SAVE_RETRY_WAITING_TIME, PUSH_THROTTLE_TIME, SAVE_RETRY_WAITING_TIME, - MAX_SAVE_RETRY_WAITING_TIME, - UNDO_HISTORY_SIZE, - maximumActionCountPerSave, } from "oxalis/model/sagas/save_saga_constants"; +import { diffSkeletonTracing } from "oxalis/model/sagas/skeletontracing_saga"; +import type { UpdateAction } from "oxalis/model/sagas/update_actions"; +import { updateTdCamera } from "oxalis/model/sagas/update_actions"; +import { diffVolumeTracing } from "oxalis/model/sagas/volumetracing_saga"; +import { ensureWkReady } from "oxalis/model/sagas/wk_ready_saga"; +import { Model } from "oxalis/singletons"; import type { - SkeletonTracing, + CameraData, Flycam, SaveQueueEntry, - CameraData, - UserBoundingBox, - SegmentMap, + SkeletonTracing, VolumeTracing, } from "oxalis/store"; -import type { - SkeletonTracingAction, - InitializeSkeletonTracingAction, -} from "oxalis/model/actions/skeletontracing_actions"; -import { - SkeletonTracingSaveRelevantActions, - centerActiveNodeAction, - setTracingAction, -} from "oxalis/model/actions/skeletontracing_actions"; -import type { UndoAction, RedoAction, SaveQueueType } from "oxalis/model/actions/save_actions"; -import { - shiftSaveQueueAction, - setSaveBusyAction, - setLastSaveTimestampAction, - pushSaveQueueTransaction, - setVersionNumberAction, -} from "oxalis/model/actions/save_actions"; -import type { UpdateAction } from "oxalis/model/sagas/update_actions"; -import { updateTdCamera } from "oxalis/model/sagas/update_actions"; -import { AnnotationToolEnum, type Vector4, ControlModeEnum } from "oxalis/constants"; -import { ViewModeSaveRelevantActions } from "oxalis/model/actions/view_mode_actions"; -import { - actionChannel, - all, - delay, - take, - takeEvery, - call, - fork, - join, - put, - race, -} from "typed-redux-saga"; -import { select } from "oxalis/model/sagas/effect-generators"; -import { - compressTypedArray, - decompressToTypedArray, -} from "oxalis/model/helpers/bucket_compression"; -import { diffSkeletonTracing } from "oxalis/model/sagas/skeletontracing_saga"; -import { diffVolumeTracing } from "oxalis/model/sagas/volumetracing_saga"; -import { doWithToken, getNewestVersionForTracing } from "admin/admin_rest_api"; -import { getResolutionInfo } from "oxalis/model/accessors/dataset_accessor"; -import { - getVolumeTracingById, - getVolumeTracingByLayerName, - getVolumeTracings, -} from "oxalis/model/accessors/volumetracing_accessor"; -import { globalPositionToBucketPosition } from "oxalis/model/helpers/position_converter"; -import { - getUserBoundingBoxesFromState, - selectTracing, -} from "oxalis/model/accessors/tracing_accessor"; -import { selectQueue } from "oxalis/model/accessors/save_accessor"; -import { setBusyBlockingInfoAction } from "oxalis/model/actions/ui_actions"; -import Date from "libs/date"; -import ErrorHandling from "libs/error_handling"; -import { Model } from "oxalis/singletons"; -import type { RequestOptionsWithData } from "libs/request"; -import Request from "libs/request"; -import Toast from "libs/toast"; -import compactSaveQueue from "oxalis/model/helpers/compaction/compact_save_queue"; -import compactUpdateActions from "oxalis/model/helpers/compaction/compact_update_actions"; -import createProgressCallback from "libs/progress_callback"; -import messages from "messages"; -import window, { alert, document, location } from "libs/window"; -import { enforceSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; -import _ from "lodash"; -import { sleep } from "libs/utils"; -import { ensureWkReady } from "oxalis/model/sagas/wk_ready_saga"; +import { call, delay, fork, put, race, take, takeEvery } from "typed-redux-saga"; const ONE_YEAR_MS = 365 * 24 * 3600 * 1000; -// This function is needed so that TS is satisfied -// with how a mere promise is awaited within a saga. -function unpackPromise(p: Promise): Promise { - return p; -} - -const UndoRedoRelevantBoundingBoxActions = AllUserBoundingBoxActions.filter( - (action) => action !== "SET_USER_BOUNDING_BOXES", -); -type UndoBucket = { - zoomedBucketAddress: Vector4; - // The following arrays are Uint8Array due to the compression - compressedData: Uint8Array; - compressedBackendData?: Promise; - maybeUnmergedBucketLoadedPromise: MaybeUnmergedBucketLoadedPromise; - pendingOperations: Array<(arg0: BucketDataArray) => void>; -}; -type VolumeUndoBuckets = Array; -type VolumeAnnotationBatch = { - buckets: VolumeUndoBuckets; - segments: SegmentMap; - tracingId: string; -}; -type SkeletonUndoState = { - type: "skeleton"; - data: SkeletonTracing; -}; -type VolumeUndoState = { - type: "volume"; - data: VolumeAnnotationBatch; -}; -type BoundingBoxUndoState = { - type: "bounding_box"; - data: Array; -}; -type WarnUndoState = { - type: "warning"; - reason: string; -}; -type UndoState = SkeletonUndoState | VolumeUndoState | BoundingBoxUndoState | WarnUndoState; -type RelevantActionsForUndoRedo = { - skeletonUserAction?: SkeletonTracingAction; - addBucketToUndoAction?: AddBucketToUndoAction; - finishAnnotationStrokeAction?: FinishAnnotationStrokeAction; - userBoundingBoxAction?: UserBoundingBoxAction; - importVolumeTracingAction?: ImportVolumeTracingAction; - undo?: UndoAction; - redo?: RedoAction; - updateSegment?: UpdateSegmentAction; -}; - -function unpackRelevantActionForUndo(action: Action): RelevantActionsForUndoRedo { - if (action.type === "ADD_BUCKET_TO_UNDO") { - return { - addBucketToUndoAction: action, - }; - } else if (action.type === "FINISH_ANNOTATION_STROKE") { - return { - finishAnnotationStrokeAction: action, - }; - } else if (action.type === "IMPORT_VOLUMETRACING") { - return { - importVolumeTracingAction: action, - }; - } else if (action.type === "UNDO") { - return { - undo: action, - }; - } else if (action.type === "REDO") { - return { - redo: action, - }; - } else if (action.type === "UPDATE_SEGMENT") { - return { - updateSegment: action, - }; - } else if (UndoRedoRelevantBoundingBoxActions.includes(action.type)) { - return { - userBoundingBoxAction: action as any as UserBoundingBoxAction, - }; - } - - if (SkeletonTracingSaveRelevantActions.includes(action.type)) { - return { - skeletonUserAction: action as any as SkeletonTracingAction, - }; - } - - throw new Error("Could not unpack redux action from channel"); -} - -export function* collectUndoStates(): Saga { - const undoStack: Array = []; - const redoStack: Array = []; - let previousAction: Action | null | undefined = null; - let prevSkeletonTracingOrNull: SkeletonTracing | null | undefined = null; - let prevUserBoundingBoxes: Array = []; - let pendingCompressions: Array = []; - const volumeInfoById: Record< - string, - { - currentVolumeUndoBuckets: VolumeUndoBuckets; - prevSegments: SegmentMap; - } - > = {}; - yield* take("WK_READY"); - prevSkeletonTracingOrNull = yield* select((state) => state.tracing.skeleton); - prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); - const volumeTracings = yield* select((state) => getVolumeTracings(state.tracing)); - - for (const volumeTracing of volumeTracings) { - volumeInfoById[volumeTracing.tracingId] = { - currentVolumeUndoBuckets: [], - // The copy of the segment list that needs to be added to the next volume undo stack entry. - // The SegmentMap is immutable. So, no need to copy. If there's no volume - // tracing, prevSegments can remain empty as it's not needed. - prevSegments: volumeTracing.segments, - }; - } - - const channel = yield* actionChannel([ - ...SkeletonTracingSaveRelevantActions, - ...UndoRedoRelevantBoundingBoxActions, - "ADD_BUCKET_TO_UNDO", - "FINISH_ANNOTATION_STROKE", - "IMPORT_VOLUMETRACING", - "UPDATE_SEGMENT", - "UNDO", - "REDO", - ]); - let loopCounter = 0; - - while (true) { - loopCounter++; - - if (loopCounter % 100 === 0) { - // See https://github.com/scalableminds/webknossos/pull/6076 for an explanation - // of this delay call. - yield* delay(0); - } - - const currentAction = yield* take(channel); - const { - skeletonUserAction, - addBucketToUndoAction, - finishAnnotationStrokeAction, - userBoundingBoxAction, - importVolumeTracingAction, - undo, - redo, - updateSegment, - } = unpackRelevantActionForUndo(currentAction); - - if ( - skeletonUserAction || - addBucketToUndoAction || - finishAnnotationStrokeAction || - userBoundingBoxAction - ) { - let shouldClearRedoState = false; - - if (skeletonUserAction && prevSkeletonTracingOrNull != null) { - const skeletonUndoState = yield* call( - getSkeletonTracingToUndoState, - skeletonUserAction, - prevSkeletonTracingOrNull, - previousAction, - ); - - if (skeletonUndoState) { - shouldClearRedoState = true; - undoStack.push(skeletonUndoState); - } - - previousAction = skeletonUserAction; - } else if (addBucketToUndoAction) { - shouldClearRedoState = true; - const { - zoomedBucketAddress, - bucketData, - maybeUnmergedBucketLoadedPromise, - pendingOperations, - tracingId, - } = addBucketToUndoAction; - // The bucket's (old) state should be added to the undo - // stack so that we can revert to its previous version. - // bucketData is compressed asynchronously, which is why - // the corresponding "task" is added to `pendingCompressions`. - pendingCompressions.push( - yield* fork( - compressBucketAndAddToList, - zoomedBucketAddress, - bucketData, - maybeUnmergedBucketLoadedPromise, - pendingOperations, - volumeInfoById[tracingId].currentVolumeUndoBuckets, - ), - ); - } else if (finishAnnotationStrokeAction) { - // FINISH_ANNOTATION_STROKE was dispatched which marks the end - // of a volume transaction. - // All compression tasks (see `pendingCompressions`) need to be - // awaited to add the proper entry to the undo stack. - shouldClearRedoState = true; - const activeVolumeTracing = yield* select((state) => - getVolumeTracingById(state.tracing, finishAnnotationStrokeAction.tracingId), - ); - yield* join(pendingCompressions); - pendingCompressions = []; - const volumeInfo = volumeInfoById[activeVolumeTracing.tracingId]; - undoStack.push({ - type: "volume", - data: { - buckets: volumeInfo.currentVolumeUndoBuckets, - segments: volumeInfo.prevSegments, - tracingId: activeVolumeTracing.tracingId, - }, - }); - // The SegmentMap is immutable. So, no need to copy. - volumeInfo.prevSegments = activeVolumeTracing.segments; - volumeInfo.currentVolumeUndoBuckets = []; - } else if (userBoundingBoxAction) { - const boundingBoxUndoState = getBoundingBoxToUndoState( - userBoundingBoxAction, - prevUserBoundingBoxes, - previousAction, - ); - - if (boundingBoxUndoState) { - shouldClearRedoState = true; - undoStack.push(boundingBoxUndoState); - } - - previousAction = userBoundingBoxAction; - } - - if (shouldClearRedoState) { - // Clear the redo stack when a new action is executed. - redoStack.splice(0); - } - - if (undoStack.length > UNDO_HISTORY_SIZE) { - undoStack.shift(); - } - } else if (importVolumeTracingAction) { - redoStack.splice(0); - undoStack.splice(0); - undoStack.push({ - type: "warning", - reason: messages["undo.import_volume_tracing"], - } as WarnUndoState); - } else if (undo) { - const wasInterpreted = yield* call(maybeInterpretUndoAsDiscardUiAction); - if (!wasInterpreted) { - previousAction = null; - yield* call( - applyStateOfStack, - undoStack, - redoStack, - prevSkeletonTracingOrNull, - prevUserBoundingBoxes, - "undo", - ); - } - - if (undo.callback != null) { - undo.callback(); - } - - yield* put(setBusyBlockingInfoAction(false)); - } else if (redo) { - previousAction = null; - yield* call( - applyStateOfStack, - redoStack, - undoStack, - prevSkeletonTracingOrNull, - prevUserBoundingBoxes, - "redo", - ); - - if (redo.callback != null) { - redo.callback(); - } - - yield* put(setBusyBlockingInfoAction(false)); - } else if (updateSegment) { - // Updates to the segment list should not create new undo states. Either, the segment list - // was updated by annotating (then, that action will have caused a new undo state) or - // the segment list was updated by selecting/hovering a cell (in that case, no new undo state - // should be created, either). - // If no volume tracing exists (but a segmentation layer exists, otherwise, the action wouldn't - // have been dispatched), prevSegments doesn't need to be updated, as it's not used. - const volumeTracing = yield* select((state) => - getVolumeTracingByLayerName(state.tracing, updateSegment.layerName), - ); - - if (volumeTracing != null) { - const volumeInfo = volumeInfoById[volumeTracing.tracingId]; - volumeInfo.prevSegments = volumeTracing.segments; - } - } - - // We need the updated tracing here - prevSkeletonTracingOrNull = yield* select((state) => state.tracing.skeleton); - prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); - } -} - -function* maybeInterpretUndoAsDiscardUiAction() { - // Sometimes the user hits undo because they want to undo something - // which isn't really undoable yet. For example, the quick select preview - // can be such a case. - // In that case, we re-interpret the undo action accordingly. - // The return value of this function signals whether undo was re-interpreted. - const isQuickSelectActive = yield* select((state) => state.uiInformation.isQuickSelectActive); - if (!isQuickSelectActive) { - return false; - } - yield* put(cancelQuickSelectAction()); - return true; -} - -function* getSkeletonTracingToUndoState( - skeletonUserAction: SkeletonTracingAction, - prevTracing: SkeletonTracing, - previousAction: Action | null | undefined, -): Saga { - const curTracing = yield* select((state) => enforceSkeletonTracing(state.tracing)); - - if (curTracing !== prevTracing) { - if (shouldAddToUndoStack(skeletonUserAction, previousAction)) { - return { - type: "skeleton", - data: prevTracing, - }; - } - } - - return null; -} - -function getBoundingBoxToUndoState( - userBoundingBoxAction: UserBoundingBoxAction, - prevUserBoundingBoxes: Array, - previousAction: Action | null | undefined, -): BoundingBoxUndoState | null | undefined { - const isSameActionOnSameBoundingBox = - previousAction != null && - "id" in userBoundingBoxAction && - "id" in previousAction && - userBoundingBoxAction.type === previousAction.type && - userBoundingBoxAction.id === previousAction.id; - // Used to distinguish between different resizing actions of the same bounding box. - const isFinishedResizingAction = - userBoundingBoxAction.type === "FINISHED_RESIZING_USER_BOUNDING_BOX"; - - if (!isSameActionOnSameBoundingBox && !isFinishedResizingAction) { - return { - type: "bounding_box", - data: prevUserBoundingBoxes, - }; - } - - return null; -} - -function* compressBucketAndAddToList( - zoomedBucketAddress: Vector4, - bucketData: BucketDataArray, - maybeUnmergedBucketLoadedPromise: MaybeUnmergedBucketLoadedPromise, - pendingOperations: Array<(arg0: BucketDataArray) => void>, - undoBucketList: VolumeUndoBuckets, -): Saga { - // The given bucket data is compressed, wrapped into a UndoBucket instance - // and appended to the passed VolumeAnnotationBatch. - // If backend data is being downloaded (MaybeUnmergedBucketLoadedPromise exists), - // the backend data will also be compressed and attached to the UndoBucket. - const compressedData = yield* call(compressTypedArray, bucketData); - - if (compressedData != null) { - const volumeUndoPart: UndoBucket = { - zoomedBucketAddress, - compressedData, - maybeUnmergedBucketLoadedPromise, - pendingOperations: pendingOperations.slice(), - }; - - if (maybeUnmergedBucketLoadedPromise != null) { - maybeUnmergedBucketLoadedPromise.then((backendBucketData) => { - // Once the backend data is fetched, do not directly merge it with the already saved undo data - // as this operation is only needed, when the volume action is undone. Additionally merging is more - // expensive than saving the backend data. Thus the data is only merged upon an undo action / when it is needed. - volumeUndoPart.compressedBackendData = compressTypedArray(backendBucketData); - }); - } - - undoBucketList.push(volumeUndoPart); - } -} - -function shouldAddToUndoStack( - currentUserAction: Action, - previousAction: Action | null | undefined, -) { - if (previousAction == null) { - return true; - } - - switch (currentUserAction.type) { - case "SET_NODE_POSITION": { - // We do not need to save the previous state if the previous and this action both move the same node. - // This causes the undo queue to only have the state before the node got moved and the state when moving the node finished. - return !( - previousAction.type === "SET_NODE_POSITION" && - currentUserAction.nodeId === previousAction.nodeId && - currentUserAction.treeId === previousAction.treeId - ); - } - - default: - return true; - } -} - -function* applyStateOfStack( - sourceStack: Array, - stackToPushTo: Array, - prevSkeletonTracingOrNull: SkeletonTracing | null | undefined, - prevUserBoundingBoxes: Array | null | undefined, - direction: "undo" | "redo", -): Saga { - if (sourceStack.length <= 0) { - const warningMessage = - direction === "undo" ? messages["undo.no_undo"] : messages["undo.no_redo"]; - Toast.info(warningMessage); - return; - } - - const activeTool = yield* select((state) => state.uiInformation.activeTool); - if (activeTool === AnnotationToolEnum.PROOFREAD) { - const warningMessage = - direction === "undo" - ? messages["undo.no_undo_during_proofread"] - : messages["undo.no_redo_during_proofread"]; - Toast.warning(warningMessage); - return; - } - - const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo); - - if (busyBlockingInfo.isBusy) { - console.warn(`Ignoring ${direction} request (reason: ${busyBlockingInfo.reason || "null"})`); - return; - } - - yield* put(setBusyBlockingInfoAction(true, `${direction} is being performed.`)); - const stateToRestore = sourceStack.pop(); - if (stateToRestore == null) { - // Emptiness of stack was already checked above. Satisfy typescript. - return; - } - - if (stateToRestore.type === "skeleton") { - if (prevSkeletonTracingOrNull != null) { - stackToPushTo.push({ - type: "skeleton", - data: prevSkeletonTracingOrNull, - }); - } - - const newTracing = stateToRestore.data; - yield* put(setTracingAction(newTracing)); - yield* put(centerActiveNodeAction()); - } else if (stateToRestore.type === "volume") { - const isMergerModeEnabled = yield* select( - (state) => state.temporaryConfiguration.isMergerModeEnabled, - ); - - if (isMergerModeEnabled) { - Toast.info(messages["tracing.edit_volume_in_merger_mode"]); - sourceStack.push(stateToRestore); - return; - } - - // Show progress information when undoing/redoing volume operations - // since this can take some time (as data has to be downloaded - // potentially). - const progressCallback = createProgressCallback({ - pauseDelay: 100, - successMessageDelay: 2000, - }); - yield* call(progressCallback, false, `Performing ${direction}...`); - const volumeBatchToApply = stateToRestore.data; - const currentVolumeState = yield* call(applyAndGetRevertingVolumeBatch, volumeBatchToApply); - stackToPushTo.push(currentVolumeState); - yield* call(progressCallback, true, `Finished ${direction}...`); - } else if (stateToRestore.type === "bounding_box") { - if (prevUserBoundingBoxes != null) { - stackToPushTo.push({ - type: "bounding_box", - data: prevUserBoundingBoxes, - }); - } - - const newBoundingBoxes = stateToRestore.data; - yield* put(setUserBoundingBoxesAction(newBoundingBoxes)); - } else if (stateToRestore.type === "warning") { - Toast.info(stateToRestore.reason); - } -} - -function mergeDataWithBackendDataInPlace( - originalData: BucketDataArray, - backendData: BucketDataArray, - pendingOperations: Array<(arg0: BucketDataArray) => void>, -) { - if (originalData.length !== backendData.length) { - throw new Error("Cannot merge data arrays with differing lengths"); - } - - // Transfer backend to originalData - // The `set` operation is not problematic, since the BucketDataArray types - // won't be mixed (either, they are BigInt or they aren't) - // @ts-ignore - originalData.set(backendData); - - for (const op of pendingOperations) { - op(originalData); - } -} - -function* applyAndGetRevertingVolumeBatch( - volumeAnnotationBatch: VolumeAnnotationBatch, -): Saga { - // Applies a VolumeAnnotationBatch and returns a VolumeUndoState (which simply wraps - // another VolumeAnnotationBatch) for reverting the undo operation. - const segmentationLayer = Model.getSegmentationTracingLayer(volumeAnnotationBatch.tracingId); - - if (!segmentationLayer) { - throw new Error("Undoing a volume annotation but no volume layer exists."); - } - - const { cube } = segmentationLayer; - const allCompressedBucketsOfCurrentState: VolumeUndoBuckets = []; - - for (const volumeUndoBucket of volumeAnnotationBatch.buckets) { - const { - zoomedBucketAddress, - compressedData: compressedBucketData, - compressedBackendData: compressedBackendDataPromise, - } = volumeUndoBucket; - let { maybeUnmergedBucketLoadedPromise } = volumeUndoBucket; - const bucket = cube.getOrCreateBucket(zoomedBucketAddress); - - if (bucket.type === "null") { - continue; - } - - // Prepare a snapshot of the bucket's current data so that it can be - // saved in an VolumeUndoState. - let bucketData = null; - const currentPendingOperations = bucket.pendingOperations.slice(); - - if (bucket.hasData()) { - // The bucket's data is currently available. - bucketData = bucket.getData(); - - if (compressedBackendDataPromise != null) { - // If the backend data for the bucket has been fetched in the meantime, - // the previous getData() call already returned the newest (merged) data. - // There should be no need to await the data from the backend. - maybeUnmergedBucketLoadedPromise = null; - } - } else { - // The bucket's data is not available, since it was gc'ed in the meantime (which - // means its state must have been persisted to the server). Thus, it's enough to - // persist an essentially empty data array (which is created by getOrCreateData) - // and passing maybeUnmergedBucketLoadedPromise around so that - // the back-end data is fetched upon undo/redo. - bucketData = bucket.getOrCreateData(); - maybeUnmergedBucketLoadedPromise = bucket.maybeUnmergedBucketLoadedPromise; - } - - // Append the compressed snapshot to allCompressedBucketsOfCurrentState. - yield* call( - compressBucketAndAddToList, - zoomedBucketAddress, - bucketData, - maybeUnmergedBucketLoadedPromise, - currentPendingOperations, - allCompressedBucketsOfCurrentState, - ); - // Decompress the bucket data which should be applied. - let decompressedBucketData = null; - let newPendingOperations = volumeUndoBucket.pendingOperations; - - if (compressedBackendDataPromise != null) { - const compressedBackendData = (yield* call( - unpackPromise, - compressedBackendDataPromise, - )) as Uint8Array; - let decompressedBackendData; - [decompressedBucketData, decompressedBackendData] = yield* all([ - call(decompressToTypedArray, bucket, compressedBucketData), - call(decompressToTypedArray, bucket, compressedBackendData), - ]); - mergeDataWithBackendDataInPlace( - decompressedBucketData, - decompressedBackendData, - volumeUndoBucket.pendingOperations, - ); - newPendingOperations = []; - } else { - decompressedBucketData = yield* call(decompressToTypedArray, bucket, compressedBucketData); - } - - // Set the new bucket data to add the bucket directly to the pushqueue. - cube.setBucketData(zoomedBucketAddress, decompressedBucketData, newPendingOperations); - } - - const activeVolumeTracing = yield* select((state) => - getVolumeTracingById(state.tracing, volumeAnnotationBatch.tracingId), - ); - // The SegmentMap is immutable. So, no need to copy. - const currentSegments = activeVolumeTracing.segments; - yield* put(setSegmentsAction(volumeAnnotationBatch.segments, volumeAnnotationBatch.tracingId)); - cube.triggerPushQueue(); - return { - type: "volume", - data: { - buckets: allCompressedBucketsOfCurrentState, - segments: currentSegments, - tracingId: volumeAnnotationBatch.tracingId, - }, - }; -} - export function* pushSaveQueueAsync(saveQueueType: SaveQueueType, tracingId: string): Saga { yield* call(ensureWkReady); @@ -1248,4 +560,4 @@ function* watchForSaveConflicts() { } } -export default [saveTracingAsync, collectUndoStates, watchForSaveConflicts]; +export default [saveTracingAsync, watchForSaveConflicts]; diff --git a/frontend/javascripts/oxalis/model/sagas/undo_saga.ts b/frontend/javascripts/oxalis/model/sagas/undo_saga.ts new file mode 100644 index 0000000000..967866f6f7 --- /dev/null +++ b/frontend/javascripts/oxalis/model/sagas/undo_saga.ts @@ -0,0 +1,775 @@ +import createProgressCallback from "libs/progress_callback"; +import Toast from "libs/toast"; +import messages from "messages"; +import { AnnotationToolEnum, type Vector4 } from "oxalis/constants"; +import { enforceSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; +import { getUserBoundingBoxesFromState } from "oxalis/model/accessors/tracing_accessor"; +import { + getVolumeTracingById, + getVolumeTracingByLayerName, + getVolumeTracings, +} from "oxalis/model/accessors/volumetracing_accessor"; +import type { Action } from "oxalis/model/actions/actions"; +import type { UserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; +import { + AllUserBoundingBoxActions, + setUserBoundingBoxesAction, +} from "oxalis/model/actions/annotation_actions"; +import type { RedoAction, UndoAction } from "oxalis/model/actions/save_actions"; +import type { SkeletonTracingAction } from "oxalis/model/actions/skeletontracing_actions"; +import { + centerActiveNodeAction, + setTracingAction, + SkeletonTracingSaveRelevantActions, +} from "oxalis/model/actions/skeletontracing_actions"; +import { setBusyBlockingInfoAction } from "oxalis/model/actions/ui_actions"; +import { + AddBucketToUndoAction, + cancelQuickSelectAction, + FinishAnnotationStrokeAction, + ImportVolumeTracingAction, + MaybeUnmergedBucketLoadedPromise, + RemoveSegmentAction, + setSegmentsAction, + UpdateSegmentAction, +} from "oxalis/model/actions/volumetracing_actions"; +import type { BucketDataArray } from "oxalis/model/bucket_data_handling/bucket"; +import { + compressTypedArray, + decompressToTypedArray, +} from "oxalis/model/helpers/bucket_compression"; +import type { Saga } from "oxalis/model/sagas/effect-generators"; +import { select } from "oxalis/model/sagas/effect-generators"; +import { UNDO_HISTORY_SIZE } from "oxalis/model/sagas/save_saga_constants"; +import { Model } from "oxalis/singletons"; +import type { SegmentMap, SkeletonTracing, UserBoundingBox } from "oxalis/store"; +import { Task } from "redux-saga"; +import { actionChannel, all, call, delay, fork, join, put, take } from "typed-redux-saga"; + +const UndoRedoRelevantBoundingBoxActions = AllUserBoundingBoxActions.filter( + (action) => action !== "SET_USER_BOUNDING_BOXES", +); +type UndoBucket = { + zoomedBucketAddress: Vector4; + // The following arrays are Uint8Array due to the compression + compressedData: Uint8Array; + compressedBackendData?: Promise; + maybeUnmergedBucketLoadedPromise: MaybeUnmergedBucketLoadedPromise; + pendingOperations: Array<(arg0: BucketDataArray) => void>; +}; +type VolumeUndoBuckets = Array; +type VolumeAnnotationBatch = { + buckets: VolumeUndoBuckets; + segments: SegmentMap; + tracingId: string; +}; +type SkeletonUndoState = { + type: "skeleton"; + data: SkeletonTracing; +}; +type VolumeUndoState = { + type: "volume"; + data: VolumeAnnotationBatch; +}; +type BoundingBoxUndoState = { + type: "bounding_box"; + data: Array; +}; +type WarnUndoState = { + type: "warning"; + reason: string; +}; +type UndoState = SkeletonUndoState | VolumeUndoState | BoundingBoxUndoState | WarnUndoState; +type RelevantActionsForUndoRedo = { + skeletonUserAction?: SkeletonTracingAction; + addBucketToUndoAction?: AddBucketToUndoAction; + finishAnnotationStrokeAction?: FinishAnnotationStrokeAction; + userBoundingBoxAction?: UserBoundingBoxAction; + importVolumeTracingAction?: ImportVolumeTracingAction; + undo?: UndoAction; + redo?: RedoAction; + updateSegment?: UpdateSegmentAction; + removeSegment?: RemoveSegmentAction; +}; + +// This function is needed so that TS is satisfied +// with how a mere promise is awaited within a saga. +function unpackPromise(p: Promise): Promise { + return p; +} + +function unpackRelevantActionForUndo(action: Action): RelevantActionsForUndoRedo { + if (action.type === "ADD_BUCKET_TO_UNDO") { + return { + addBucketToUndoAction: action, + }; + } else if (action.type === "FINISH_ANNOTATION_STROKE") { + return { + finishAnnotationStrokeAction: action, + }; + } else if (action.type === "IMPORT_VOLUMETRACING") { + return { + importVolumeTracingAction: action, + }; + } else if (action.type === "UNDO") { + return { + undo: action, + }; + } else if (action.type === "REDO") { + return { + redo: action, + }; + } else if (action.type === "UPDATE_SEGMENT") { + return { + updateSegment: action, + }; + } else if (action.type === "REMOVE_SEGMENT") { + return { + removeSegment: action, + }; + } else if (UndoRedoRelevantBoundingBoxActions.includes(action.type)) { + return { + userBoundingBoxAction: action as any as UserBoundingBoxAction, + }; + } + + if (SkeletonTracingSaveRelevantActions.includes(action.type)) { + return { + skeletonUserAction: action as any as SkeletonTracingAction, + }; + } + + throw new Error("Could not unpack redux action from channel"); +} + +export function* manageUndoStates(): Saga { + // At its core, this saga maintains an undo and redo stack to implement + // undo/redo functionality. + const undoStack: Array = []; + const redoStack: Array = []; + + // Declaration of local state necessary to manage the undo/redo mechanism. + let previousAction: Action | null | undefined = null; + let prevSkeletonTracingOrNull: SkeletonTracing | null | undefined = null; + let prevUserBoundingBoxes: Array = []; + let pendingCompressions: Array = []; + const volumeInfoById: Record< + string, // volume tracing id + { + // The set of volume buckets which were mutated during the current volume + // operation (e.g., brushing). After a volume operation, the set is added + // to the stack and cleared afterwards. This means the set is always + // empty unless a volume operation is ongoing. + currentVolumeUndoBuckets: VolumeUndoBuckets; + // The "old" segment list that needs to be added to the next volume undo stack + // entry so that that segment list can be restored upon undo. + prevSegments: SegmentMap; + } + > = {}; + + yield* take("WK_READY"); + + // Initialization of the local state variables from above. + prevSkeletonTracingOrNull = yield* select((state) => state.tracing.skeleton); + prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); + const volumeTracings = yield* select((state) => getVolumeTracings(state.tracing)); + for (const volumeTracing of volumeTracings) { + volumeInfoById[volumeTracing.tracingId] = { + currentVolumeUndoBuckets: [], + // The SegmentMap is immutable. So, no need to copy. If there's no volume + // tracing, prevSegments can remain empty as it's not needed. + prevSegments: volumeTracing.segments, + }; + } + + // Helper functions for functionality related to volumeInfoById. + function* setPrevSegmentsToCurrent() { + // Read the current segments map and store it in volumeInfoById for all volume layers. + const volumeTracings = yield* select((state) => getVolumeTracings(state.tracing)); + for (const volumeTracing of volumeTracings) { + volumeInfoById[volumeTracing.tracingId].prevSegments = volumeTracing.segments; + } + } + function* areCurrentVolumeUndoBucketsEmpty() { + // Check that currentVolumeUndoBuckets is empty for all layers (see above for an + // explanation of this invariant). + // In case the invariant is violated for some reason, we forbid undo/redo. + // The case can be provoked by brushing and hitting ctrl+z without lifting the + // mouse button. + const volumeTracings = yield* select((state) => getVolumeTracings(state.tracing)); + for (const volumeTracing of volumeTracings) { + if (volumeInfoById[volumeTracing.tracingId].currentVolumeUndoBuckets.length > 0) { + return false; + } + } + return true; + } + + const channel = yield* actionChannel([ + ...SkeletonTracingSaveRelevantActions, + ...UndoRedoRelevantBoundingBoxActions, + "ADD_BUCKET_TO_UNDO", + "FINISH_ANNOTATION_STROKE", + "IMPORT_VOLUMETRACING", + "UPDATE_SEGMENT", + "REMOVE_SEGMENT", + "UNDO", + "REDO", + ]); + let loopCounter = 0; + + while (true) { + loopCounter++; + + if (loopCounter % 100 === 0) { + // See https://github.com/scalableminds/webknossos/pull/6076 for an explanation + // of this delay call. + yield* delay(0); + } + + const currentAction = yield* take(channel); + const { + skeletonUserAction, + addBucketToUndoAction, + finishAnnotationStrokeAction, + userBoundingBoxAction, + importVolumeTracingAction, + undo, + redo, + updateSegment, + removeSegment, + } = unpackRelevantActionForUndo(currentAction); + + if (importVolumeTracingAction) { + redoStack.splice(0); + undoStack.splice(0); + undoStack.push({ + type: "warning", + reason: messages["undo.import_volume_tracing"], + } as WarnUndoState); + } else if (undo) { + if (!(yield* call(areCurrentVolumeUndoBucketsEmpty))) { + yield* call([Toast, Toast.warning], "Cannot redo at the moment. Please try again."); + continue; + } + const wasInterpreted = yield* call(maybeInterpretUndoAsDiscardUiAction); + if (!wasInterpreted) { + previousAction = null; + yield* call( + applyStateOfStack, + undoStack, + redoStack, + prevSkeletonTracingOrNull, + prevUserBoundingBoxes, + "undo", + ); + + // Since the current segments map changed, we need to update our reference to it. + // Note that we don't need to do this for currentVolumeUndoBuckets, as this + // was and is empty, anyway (due to the constraint we checked above). + yield* call(setPrevSegmentsToCurrent); + } + + if (undo.callback != null) { + undo.callback(); + } + + yield* put(setBusyBlockingInfoAction(false)); + } else if (redo) { + if (!(yield* call(areCurrentVolumeUndoBucketsEmpty))) { + yield* call([Toast, Toast.warning], "Cannot redo at the moment. Please try again."); + continue; + } + + previousAction = null; + yield* call( + applyStateOfStack, + redoStack, + undoStack, + prevSkeletonTracingOrNull, + prevUserBoundingBoxes, + "redo", + ); + + // See undo branch for an explanation. + yield* call(setPrevSegmentsToCurrent); + + if (redo.callback != null) { + redo.callback(); + } + + yield* put(setBusyBlockingInfoAction(false)); + } else { + // The received action in this branch potentially causes a new + // entry on the undo stack because the annotation was edited. + + let shouldClearRedoState = false; + + if (skeletonUserAction && prevSkeletonTracingOrNull != null) { + const skeletonUndoState = yield* call( + getSkeletonTracingToUndoState, + skeletonUserAction, + prevSkeletonTracingOrNull, + previousAction, + ); + + if (skeletonUndoState) { + shouldClearRedoState = true; + undoStack.push(skeletonUndoState); + } + + previousAction = skeletonUserAction; + } else if (addBucketToUndoAction) { + shouldClearRedoState = true; + const { + zoomedBucketAddress, + bucketData, + maybeUnmergedBucketLoadedPromise, + pendingOperations, + tracingId, + } = addBucketToUndoAction; + // The bucket's (old) state should be added to the undo + // stack so that we can revert to its previous version. + // bucketData is compressed asynchronously, which is why + // the corresponding "task" is added to `pendingCompressions`. + pendingCompressions.push( + yield* fork( + compressBucketAndAddToList, + zoomedBucketAddress, + bucketData, + maybeUnmergedBucketLoadedPromise, + pendingOperations, + volumeInfoById[tracingId].currentVolumeUndoBuckets, + ), + ); + } else if (finishAnnotationStrokeAction) { + // FINISH_ANNOTATION_STROKE was dispatched which marks the end + // of a volume transaction. + // All compression tasks (see `pendingCompressions`) need to be + // awaited to add the proper entry to the undo stack. + shouldClearRedoState = true; + const activeVolumeTracing = yield* select((state) => + getVolumeTracingById(state.tracing, finishAnnotationStrokeAction.tracingId), + ); + yield* join(pendingCompressions); + pendingCompressions = []; + const volumeInfo = volumeInfoById[activeVolumeTracing.tracingId]; + undoStack.push({ + type: "volume", + data: { + buckets: volumeInfo.currentVolumeUndoBuckets, + segments: volumeInfo.prevSegments, + tracingId: activeVolumeTracing.tracingId, + }, + }); + // The SegmentMap is immutable. So, no need to copy. + volumeInfo.prevSegments = activeVolumeTracing.segments; + volumeInfo.currentVolumeUndoBuckets = []; + } else if (userBoundingBoxAction) { + const boundingBoxUndoState = getBoundingBoxToUndoState( + userBoundingBoxAction, + prevUserBoundingBoxes, + previousAction, + ); + + if (boundingBoxUndoState) { + shouldClearRedoState = true; + undoStack.push(boundingBoxUndoState); + } + + previousAction = userBoundingBoxAction; + } else if (updateSegment || removeSegment) { + // Updates to the segment list shouldn't necessarily create new undo states. In particular, + // no new undo state is created when the updateSegment action is a byproduct of another + // UI action (mainly by annotating with volume tools). Also, if a segment's anchor position is + // updated automatically (e.g., by clicking), this should also not add another undo state. + // On the other hand, a new undo state should be created when the user explicitly caused a + // change to a segment. For example: + // - by selecting/hovering a cell so that a new entry gets added to the list + // - renaming or removing a segment + // - changing the color of a segment + const action = updateSegment || removeSegment; + if (!action) { + throw new Error("Unexpected action"); + } + + const createNewUndoState = removeSegment != null || updateSegment?.createsNewUndoState; + if (createNewUndoState) { + shouldClearRedoState = true; + const activeVolumeTracing = yield* select((state) => + getVolumeTracingByLayerName(state.tracing, action.layerName), + ); + if (activeVolumeTracing) { + const volumeInfo = volumeInfoById[activeVolumeTracing.tracingId]; + undoStack.push({ + type: "volume", + data: { + buckets: [], + segments: volumeInfo.prevSegments, + tracingId: activeVolumeTracing.tracingId, + }, + }); + // The SegmentMap is immutable. So, no need to copy. + volumeInfo.prevSegments = activeVolumeTracing.segments; + } + } else { + // Update most recent undo stack entry in-place. + const volumeTracing = yield* select((state) => + getVolumeTracingByLayerName(state.tracing, action.layerName), + ); + + // If no volume tracing exists (but a segmentation layer exists, otherwise, the action wouldn't + // have been dispatched), prevSegments doesn't need to be updated, as it's not used. + if (volumeTracing != null) { + const volumeInfo = volumeInfoById[volumeTracing.tracingId]; + volumeInfo.prevSegments = volumeTracing.segments; + } + } + } + + if (shouldClearRedoState) { + // Clear the redo stack when a new action is executed. + redoStack.splice(0); + } + + if (undoStack.length > UNDO_HISTORY_SIZE) { + undoStack.shift(); + } + } + + // We need the updated tracing here + prevSkeletonTracingOrNull = yield* select((state) => state.tracing.skeleton); + prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); + } +} + +function* maybeInterpretUndoAsDiscardUiAction() { + // Sometimes the user hits undo because they want to undo something + // which isn't really undoable yet. For example, the quick select preview + // can be such a case. + // In that case, we re-interpret the undo action accordingly. + // The return value of this function signals whether undo was re-interpreted. + const isQuickSelectActive = yield* select((state) => state.uiInformation.isQuickSelectActive); + if (!isQuickSelectActive) { + return false; + } + yield* put(cancelQuickSelectAction()); + return true; +} + +function* getSkeletonTracingToUndoState( + skeletonUserAction: SkeletonTracingAction, + prevTracing: SkeletonTracing, + previousAction: Action | null | undefined, +): Saga { + const curTracing = yield* select((state) => enforceSkeletonTracing(state.tracing)); + + if (curTracing !== prevTracing) { + if (shouldAddToUndoStack(skeletonUserAction, previousAction)) { + return { + type: "skeleton", + data: prevTracing, + }; + } + } + + return null; +} + +function getBoundingBoxToUndoState( + userBoundingBoxAction: UserBoundingBoxAction, + prevUserBoundingBoxes: Array, + previousAction: Action | null | undefined, +): BoundingBoxUndoState | null | undefined { + const isSameActionOnSameBoundingBox = + previousAction != null && + "id" in userBoundingBoxAction && + "id" in previousAction && + userBoundingBoxAction.type === previousAction.type && + userBoundingBoxAction.id === previousAction.id; + // Used to distinguish between different resizing actions of the same bounding box. + const isFinishedResizingAction = + userBoundingBoxAction.type === "FINISHED_RESIZING_USER_BOUNDING_BOX"; + + if (!isSameActionOnSameBoundingBox && !isFinishedResizingAction) { + return { + type: "bounding_box", + data: prevUserBoundingBoxes, + }; + } + + return null; +} + +function* compressBucketAndAddToList( + zoomedBucketAddress: Vector4, + bucketData: BucketDataArray, + maybeUnmergedBucketLoadedPromise: MaybeUnmergedBucketLoadedPromise, + pendingOperations: Array<(arg0: BucketDataArray) => void>, + undoBucketList: VolumeUndoBuckets, +): Saga { + // The given bucket data is compressed, wrapped into a UndoBucket instance + // and appended to the passed VolumeAnnotationBatch. + // If backend data is being downloaded (MaybeUnmergedBucketLoadedPromise exists), + // the backend data will also be compressed and attached to the UndoBucket. + const compressedData = yield* call(compressTypedArray, bucketData); + + if (compressedData != null) { + const volumeUndoPart: UndoBucket = { + zoomedBucketAddress, + compressedData, + maybeUnmergedBucketLoadedPromise, + pendingOperations: pendingOperations.slice(), + }; + + if (maybeUnmergedBucketLoadedPromise != null) { + maybeUnmergedBucketLoadedPromise.then((backendBucketData) => { + // Once the backend data is fetched, do not directly merge it with the already saved undo data + // as this operation is only needed, when the volume action is undone. Additionally merging is more + // expensive than saving the backend data. Thus the data is only merged upon an undo action / when it is needed. + volumeUndoPart.compressedBackendData = compressTypedArray(backendBucketData); + }); + } + + undoBucketList.push(volumeUndoPart); + } +} + +function shouldAddToUndoStack( + currentUserAction: Action, + previousAction: Action | null | undefined, +) { + if (previousAction == null) { + return true; + } + + switch (currentUserAction.type) { + case "SET_NODE_POSITION": { + // We do not need to save the previous state if the previous and this action both move the same node. + // This causes the undo queue to only have the state before the node got moved and the state when moving the node finished. + return !( + previousAction.type === "SET_NODE_POSITION" && + currentUserAction.nodeId === previousAction.nodeId && + currentUserAction.treeId === previousAction.treeId + ); + } + + default: + return true; + } +} + +function* applyStateOfStack( + sourceStack: Array, + stackToPushTo: Array, + prevSkeletonTracingOrNull: SkeletonTracing | null | undefined, + prevUserBoundingBoxes: Array | null | undefined, + direction: "undo" | "redo", +): Saga { + if (sourceStack.length <= 0) { + const warningMessage = + direction === "undo" ? messages["undo.no_undo"] : messages["undo.no_redo"]; + Toast.info(warningMessage); + return; + } + + const activeTool = yield* select((state) => state.uiInformation.activeTool); + if (activeTool === AnnotationToolEnum.PROOFREAD) { + const warningMessage = + direction === "undo" + ? messages["undo.no_undo_during_proofread"] + : messages["undo.no_redo_during_proofread"]; + Toast.warning(warningMessage); + return; + } + + const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo); + + if (busyBlockingInfo.isBusy) { + console.warn(`Ignoring ${direction} request (reason: ${busyBlockingInfo.reason || "null"})`); + return; + } + + yield* put(setBusyBlockingInfoAction(true, `${direction} is being performed.`)); + const stateToRestore = sourceStack.pop(); + if (stateToRestore == null) { + // Emptiness of stack was already checked above. Satisfy typescript. + return; + } + + if (stateToRestore.type === "skeleton") { + if (prevSkeletonTracingOrNull != null) { + stackToPushTo.push({ + type: "skeleton", + data: prevSkeletonTracingOrNull, + }); + } + + const newTracing = stateToRestore.data; + yield* put(setTracingAction(newTracing)); + yield* put(centerActiveNodeAction()); + } else if (stateToRestore.type === "volume") { + const isMergerModeEnabled = yield* select( + (state) => state.temporaryConfiguration.isMergerModeEnabled, + ); + + if (isMergerModeEnabled) { + Toast.info(messages["tracing.edit_volume_in_merger_mode"]); + sourceStack.push(stateToRestore); + return; + } + + // Show progress information when undoing/redoing volume operations + // since this can take some time (as data has to be downloaded + // potentially). + const progressCallback = createProgressCallback({ + pauseDelay: 100, + successMessageDelay: 2000, + }); + yield* call(progressCallback, false, `Performing ${direction}...`); + const volumeBatchToApply = stateToRestore.data; + const currentVolumeState = yield* call(applyAndGetRevertingVolumeBatch, volumeBatchToApply); + stackToPushTo.push(currentVolumeState); + yield* call(progressCallback, true, `Finished ${direction}...`); + } else if (stateToRestore.type === "bounding_box") { + if (prevUserBoundingBoxes != null) { + stackToPushTo.push({ + type: "bounding_box", + data: prevUserBoundingBoxes, + }); + } + + const newBoundingBoxes = stateToRestore.data; + yield* put(setUserBoundingBoxesAction(newBoundingBoxes)); + } else if (stateToRestore.type === "warning") { + Toast.info(stateToRestore.reason); + } +} + +function mergeDataWithBackendDataInPlace( + originalData: BucketDataArray, + backendData: BucketDataArray, + pendingOperations: Array<(arg0: BucketDataArray) => void>, +) { + if (originalData.length !== backendData.length) { + throw new Error("Cannot merge data arrays with differing lengths"); + } + + // Transfer backend to originalData + // The `set` operation is not problematic, since the BucketDataArray types + // won't be mixed (either, they are BigInt or they aren't) + // @ts-ignore + originalData.set(backendData); + + for (const op of pendingOperations) { + op(originalData); + } +} + +function* applyAndGetRevertingVolumeBatch( + volumeAnnotationBatch: VolumeAnnotationBatch, +): Saga { + // Applies a VolumeAnnotationBatch and returns a VolumeUndoState (which simply wraps + // another VolumeAnnotationBatch) for reverting the undo operation. + const segmentationLayer = Model.getSegmentationTracingLayer(volumeAnnotationBatch.tracingId); + + if (!segmentationLayer) { + throw new Error("Undoing a volume annotation but no volume layer exists."); + } + + const { cube } = segmentationLayer; + const allCompressedBucketsOfCurrentState: VolumeUndoBuckets = []; + + for (const volumeUndoBucket of volumeAnnotationBatch.buckets) { + const { + zoomedBucketAddress, + compressedData: compressedBucketData, + compressedBackendData: compressedBackendDataPromise, + } = volumeUndoBucket; + let { maybeUnmergedBucketLoadedPromise } = volumeUndoBucket; + const bucket = cube.getOrCreateBucket(zoomedBucketAddress); + + if (bucket.type === "null") { + continue; + } + + // Prepare a snapshot of the bucket's current data so that it can be + // saved in an VolumeUndoState. + let bucketData = null; + const currentPendingOperations = bucket.pendingOperations.slice(); + + if (bucket.hasData()) { + // The bucket's data is currently available. + bucketData = bucket.getData(); + + if (compressedBackendDataPromise != null) { + // If the backend data for the bucket has been fetched in the meantime, + // the previous getData() call already returned the newest (merged) data. + // There should be no need to await the data from the backend. + maybeUnmergedBucketLoadedPromise = null; + } + } else { + // The bucket's data is not available, since it was gc'ed in the meantime (which + // means its state must have been persisted to the server). Thus, it's enough to + // persist an essentially empty data array (which is created by getOrCreateData) + // and passing maybeUnmergedBucketLoadedPromise around so that + // the back-end data is fetched upon undo/redo. + bucketData = bucket.getOrCreateData(); + maybeUnmergedBucketLoadedPromise = bucket.maybeUnmergedBucketLoadedPromise; + } + + // Append the compressed snapshot to allCompressedBucketsOfCurrentState. + yield* call( + compressBucketAndAddToList, + zoomedBucketAddress, + bucketData, + maybeUnmergedBucketLoadedPromise, + currentPendingOperations, + allCompressedBucketsOfCurrentState, + ); + // Decompress the bucket data which should be applied. + let decompressedBucketData = null; + let newPendingOperations = volumeUndoBucket.pendingOperations; + + if (compressedBackendDataPromise != null) { + const compressedBackendData = (yield* call( + unpackPromise, + compressedBackendDataPromise, + )) as Uint8Array; + let decompressedBackendData; + [decompressedBucketData, decompressedBackendData] = yield* all([ + call(decompressToTypedArray, bucket, compressedBucketData), + call(decompressToTypedArray, bucket, compressedBackendData), + ]); + mergeDataWithBackendDataInPlace( + decompressedBucketData, + decompressedBackendData, + volumeUndoBucket.pendingOperations, + ); + newPendingOperations = []; + } else { + decompressedBucketData = yield* call(decompressToTypedArray, bucket, compressedBucketData); + } + + // Set the new bucket data to add the bucket directly to the pushqueue. + cube.setBucketData(zoomedBucketAddress, decompressedBucketData, newPendingOperations); + } + + const activeVolumeTracing = yield* select((state) => + getVolumeTracingById(state.tracing, volumeAnnotationBatch.tracingId), + ); + // The SegmentMap is immutable. So, no need to copy. + const currentSegments = activeVolumeTracing.segments; + yield* put(setSegmentsAction(volumeAnnotationBatch.segments, volumeAnnotationBatch.tracingId)); + cube.triggerPushQueue(); + return { + type: "volume", + data: { + buckets: allCompressedBucketsOfCurrentState, + segments: currentSegments, + tracingId: volumeAnnotationBatch.tracingId, + }, + }; +} + +export default manageUndoStates; diff --git a/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts b/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts index 792d2be9dc..b5c6a3e3c7 100644 --- a/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts +++ b/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts @@ -72,7 +72,7 @@ export function applyLabeledVoxelMapToAllMissingResolutions( dimensionIndices: DimensionMap, resolutionInfo: ResolutionInfo, segmentationCube: DataCube, - cellId: number, + segmentId: number, thirdDimensionOfSlice: number, // this value is specified in global (mag1) coords // If shouldOverwrite is false, a voxel is only overwritten if // its old value is equal to overwritableValue. @@ -135,7 +135,7 @@ export function applyLabeledVoxelMapToAllMissingResolutions( applyVoxelMap( currentLabeledVoxelMap, segmentationCube, - cellId, + segmentId, get3DAddressCreator(targetResolution), numberOfSlices, thirdDim, diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index 7472155e3c..fc0572d6db 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -43,6 +43,7 @@ import { getMaximumBrushSize, getRenderableResolutionForSegmentationTracing, getRequestedOrVisibleSegmentationLayer, + getSegmentsForLayer, isVolumeAnnotationDisallowedForZoom, } from "oxalis/model/accessors/volumetracing_accessor"; import type { Action } from "oxalis/model/actions/actions"; @@ -631,9 +632,9 @@ function* ensureSegmentExists( } const layerName = layer.name; - const cellId = action.cellId; + const segmentId = action.segmentId; - if (cellId === 0 || cellId == null) { + if (segmentId === 0 || segmentId == null) { return; } @@ -641,7 +642,7 @@ function* ensureSegmentExists( const { seedPosition } = action; yield* put( updateSegmentAction( - cellId, + segmentId, { somePosition: seedPosition, }, @@ -661,13 +662,19 @@ function* ensureSegmentExists( return; } + const doesSegmentExist = yield* select((state) => + getSegmentsForLayer(state, layerName).has(segmentId), + ); + yield* put( updateSegmentAction( - cellId, + segmentId, { somePosition, }, layerName, + undefined, + !doesSegmentExist, ), ); } diff --git a/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts b/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts index c55b2d482e..3a7a6bc834 100644 --- a/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts +++ b/frontend/javascripts/oxalis/model/volumetracing/volume_annotation_sampling.ts @@ -303,7 +303,7 @@ export default function sampleVoxelMapToResolution( export function applyVoxelMap( labeledVoxelMap: LabeledVoxelsMap, dataCube: DataCube, - cellId: number, + segmentId: number, get3DAddress: (arg0: number, arg1: number, arg2: Vector3 | Float32Array) => void, numberOfSlicesToApply: number, thirdDimensionIndex: 0 | 1 | 2, // If shouldOverwrite is false, a voxel is only overwritten if @@ -357,7 +357,7 @@ export function applyVoxelMap( bucket.applyVoxelMap( voxelMap, - cellId, + segmentId, get3DAddress, sliceCount, thirdDimensionIndex, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index 92339c6c99..802a35d2f7 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -154,7 +154,13 @@ type Props = { handleSegmentDropdownMenuVisibility: (arg0: number, arg1: boolean) => void; activeDropdownSegmentId: number | null | undefined; allowUpdate: boolean; - updateSegment: (arg0: number, arg1: Partial, arg2: string) => void; + updateSegment: ( + arg0: number, + arg1: Partial, + arg2: string, + createsNewUndoState: boolean, + ) => void; + removeSegment: (arg0: number, arg2: string) => void; onSelectSegment: (arg0: Segment) => void; visibleSegmentationLayer: APISegmentationLayer | null | undefined; loadAdHocMesh: (arg0: number, arg1: Vector3) => void; @@ -191,16 +197,13 @@ function _MeshInfoItem(props: { }; const { segment, isSelectedInList, isHovered, isosurface } = props; - const deemphasizedStyle = { - fontStyle: "italic", - color: "#989898", - }; if (!isosurface) { if (isSelectedInList) { return (
{ evt.preventDefault(); props.handleSegmentDropdownMenuVisibility(segment.id, true); @@ -215,7 +218,7 @@ function _MeshInfoItem(props: { } const { seedPosition, isLoading, isPrecomputed, isVisible } = isosurface; - const textStyle = isVisible ? {} : deemphasizedStyle; + const className = isVisible ? "" : "deemphasized italic"; const downloadButton = ( {toggleVisibilityCheckbox} { props.setPosition(seedPosition); }} - style={{ ...textStyle, marginLeft: 8 }} + style={{ marginLeft: 8 }} > {isPrecomputed ? "Mesh (precomputed)" : "Mesh (ad-hoc computed)"} @@ -320,6 +324,7 @@ function _SegmentListItem({ activeDropdownSegmentId, allowUpdate, updateSegment, + removeSegment, onSelectSegment, visibleSegmentationLayer, loadAdHocMesh, @@ -363,20 +368,20 @@ function _SegmentListItem({ andCloseContextMenu, ), getMakeSegmentActiveMenuItem(segment, setActiveCell, activeCellId, andCloseContextMenu), - /* - * Disable the change-color menu if the segment was mapped to another segment, because - * changing the color wouldn't do anything as long as the mapping is still active. - * This is because the id (A) is mapped to another one (B). So, the user would need - * to change the color of B to see the effect for A. - */ { key: "changeSegmentColor", + /* + * Disable the change-color menu if the segment was mapped to another segment, because + * changing the color wouldn't do anything as long as the mapping is still active. + * This is because the id (A) is mapped to another one (B). So, the user would need + * to change the color of B to see the effect for A. + */ disabled: isEditingDisabled || segment.id !== mappedId, label: ( { + onSetColor={(color, createsNewUndoState) => { if (visibleSegmentationLayer == null) { return; } @@ -386,6 +391,7 @@ function _SegmentListItem({ color, }, visibleSegmentationLayer.name, + createsNewUndoState, ); }} rgb={Utils.take3(segmentColorRGBA)} @@ -406,10 +412,23 @@ function _SegmentListItem({ color: null, }, visibleSegmentationLayer.name, + true, ); }, label: "Reset Segment Color", }, + { + key: "removeSegmentFromList", + disabled: isEditingDisabled, + onClick: () => { + if (isEditingDisabled || visibleSegmentationLayer == null) { + return; + } + removeSegment(segment.id, visibleSegmentationLayer.name); + andCloseContextMenu(); + }, + label: "Remove Segment From List", + }, ], }); @@ -417,7 +436,7 @@ function _SegmentListItem({ if (isJSONMappingEnabled && segment.id !== mappedId) return ( - + {segment.id} → {mappedId} @@ -425,7 +444,7 @@ function _SegmentListItem({ // Only if segment.name is truthy, render additional info. return segment.name ? ( - {segment.id} + {segment.id} ) : null; } @@ -475,6 +494,7 @@ function _SegmentListItem({ name, }, visibleSegmentationLayer.name, + true, ); } }} @@ -491,7 +511,7 @@ function _SegmentListItem({ {segment.id === centeredSegmentId ? ( ) => ({ dispatch(updateTemporarySettingAction("hoveredSegmentId", segmentId || null)); }, - loadAdHocMesh(cellId: number, seedPosition: Vector3) { - dispatch(loadAdHocMeshAction(cellId, seedPosition)); + loadAdHocMesh(segmentId: number, seedPosition: Vector3) { + dispatch(loadAdHocMeshAction(segmentId, seedPosition)); }, - loadPrecomputedMesh(cellId: number, seedPosition: Vector3, meshFileName: string) { - dispatch(loadPrecomputedMeshAction(cellId, seedPosition, meshFileName)); + loadPrecomputedMesh(segmentId: number, seedPosition: Vector3, meshFileName: string) { + dispatch(loadPrecomputedMeshAction(segmentId, seedPosition, meshFileName)); }, setActiveCell(segmentId: number, somePosition?: Vector3) { @@ -158,8 +159,19 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ dispatch(setPositionAction(position)); }, - updateSegment(segmentId: number, segmentShape: Partial, layerName: string) { - dispatch(updateSegmentAction(segmentId, segmentShape, layerName)); + updateSegment( + segmentId: number, + segmentShape: Partial, + layerName: string, + createsNewUndoState: boolean, + ) { + dispatch( + updateSegmentAction(segmentId, segmentShape, layerName, undefined, createsNewUndoState), + ); + }, + + removeSegment(segmentId: number, layerName: string) { + dispatch(removeSegmentAction(segmentId, layerName)); }, }); @@ -697,6 +709,7 @@ class SegmentsView extends React.Component { setHoveredSegmentId={this.props.setHoveredSegmentId} allowUpdate={this.props.allowUpdate} updateSegment={this.props.updateSegment} + removeSegment={this.props.removeSegment} visibleSegmentationLayer={this.props.visibleSegmentationLayer} loadAdHocMesh={this.props.loadAdHocMesh} loadPrecomputedMesh={this.props.loadPrecomputedMesh} diff --git a/frontend/stylesheets/_utils.less b/frontend/stylesheets/_utils.less index eb71950bb3..3da056a25c 100644 --- a/frontend/stylesheets/_utils.less +++ b/frontend/stylesheets/_utils.less @@ -126,3 +126,11 @@ td.nowrap * { .text-center { text-align: center; } + +.deemphasized { + color: #989898; +} + +.italic { + font-style: italic; +} diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 3eaa025ddf..e25c047755 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -51,11 +51,6 @@ } } -.deemphasized-segment-name { - font-style: italic; - color: #989898; -} - #commentList { .comment, .comment-tree {