diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 062eb47b70a..13c803566df 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -15,6 +15,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - The find data function now works for volume tracings, too. [#4847](https://github.com/scalableminds/webknossos/pull/4847) - Added admins and dataset managers to dataset access list, as they can access all datasets of the organization. [#4862](https://github.com/scalableminds/webknossos/pull/4862) - Sped up the NML parsing via dashboard import. [#4872](https://github.com/scalableminds/webknossos/pull/4872) +- Movements in the 3D viewport are now time-tracked. [#4876](https://github.com/scalableminds/webknossos/pull/4876) ### Changed - Brush circles are now connected with rectangles to provide a continuous stroke even if the brush is moved quickly. [#4785](https://github.com/scalableminds/webknossos/pull/4822) diff --git a/frontend/javascripts/libs/trackball_controls.js b/frontend/javascripts/libs/trackball_controls.js index db1efe35997..44b8b87f47b 100644 --- a/frontend/javascripts/libs/trackball_controls.js +++ b/frontend/javascripts/libs/trackball_controls.js @@ -269,7 +269,7 @@ function TrackballControls(object, domElement, target, updateCallback) { } }; - this.update = (externalUpdate = false) => { + this.update = (externalUpdate = false, userTriggered = false) => { _eye.subVectors(_this.object.position, _this.lastTarget); if (!_this.noRotate) { @@ -298,7 +298,7 @@ function TrackballControls(object, domElement, target, updateCallback) { _this.lastTarget = _this.target.clone(); if (!externalUpdate) { - _this.updateCallback(); + _this.updateCallback(userTriggered); } }; @@ -383,7 +383,7 @@ function TrackballControls(object, domElement, target, updateCallback) { } else if (_state === STATE.PAN && !_this.noPan) { _this.getMouseOnScreen(event.pageX, event.pageY, _panEnd); } - _this.update(); + _this.update(false, true); } function mouseup(event) { @@ -490,7 +490,7 @@ function TrackballControls(object, domElement, target, updateCallback) { default: _state = STATE.NONE; } - _this.update(); + _this.update(false, true); } function touchend(event) { diff --git a/frontend/javascripts/oxalis/controller/camera_controller.js b/frontend/javascripts/oxalis/controller/camera_controller.js index d8bf9151fe3..2f4473c178f 100644 --- a/frontend/javascripts/oxalis/controller/camera_controller.js +++ b/frontend/javascripts/oxalis/controller/camera_controller.js @@ -27,7 +27,7 @@ import { getPosition, } from "oxalis/model/accessors/flycam_accessor"; import { listenToStoreProperty } from "oxalis/model/helpers/listener_helpers"; -import { setTDCameraAction } from "oxalis/model/actions/view_mode_actions"; +import { setTDCameraWithoutTimeTrackingAction } from "oxalis/model/actions/view_mode_actions"; import { voxelToNm, getBaseVoxel } from "oxalis/model/scaleinfo"; import Store, { type CameraData } from "oxalis/store"; import api from "oxalis/api/internal_api"; @@ -94,7 +94,7 @@ class CameraController extends React.PureComponent { } Store.dispatch( - setTDCameraAction({ + setTDCameraWithoutTimeTrackingAction({ near: 0, far, }), @@ -324,7 +324,7 @@ export function rotate3DViewTo(id: OrthoView, animate: boolean = true): void { ); Store.dispatch( - setTDCameraAction({ + setTDCameraWithoutTimeTrackingAction({ position: newPosition, up: tweened.up, left, diff --git a/frontend/javascripts/oxalis/controller/td_controller.js b/frontend/javascripts/oxalis/controller/td_controller.js index 699e38e28ea..8951f1a3113 100644 --- a/frontend/javascripts/oxalis/controller/td_controller.js +++ b/frontend/javascripts/oxalis/controller/td_controller.js @@ -19,10 +19,11 @@ import { setPositionAction } from "oxalis/model/actions/flycam_actions"; import { setViewportAction, setTDCameraAction, + setTDCameraWithoutTimeTrackingAction, zoomTDViewAction, moveTDViewXAction, moveTDViewYAction, - moveTDViewByVectorAction, + moveTDViewByVectorWithoutTimeTrackingAction, } from "oxalis/model/actions/view_mode_actions"; import { getActiveNode } from "oxalis/model/accessors/skeletontracing_accessor"; import { voxelToNm } from "oxalis/model/scaleinfo"; @@ -124,10 +125,18 @@ class TDController extends React.PureComponent { initTrackballControls(view: HTMLElement): void { const pos = voxelToNm(this.props.scale, getPosition(this.props.flycam)); const tdCamera = this.props.cameras[OrthoViews.TDView]; - this.controls = new TrackballControls(tdCamera, view, new THREE.Vector3(...pos), () => { - // write threeJS camera into store - Store.dispatch(setTDCameraAction(threeCameraToCameraData(tdCamera))); - }); + this.controls = new TrackballControls( + tdCamera, + view, + new THREE.Vector3(...pos), + (userTriggered: boolean = true) => { + const setCameraAction = userTriggered + ? setTDCameraAction + : setTDCameraWithoutTimeTrackingAction; + // write threeJS camera into store + Store.dispatch(setCameraAction(threeCameraToCameraData(tdCamera))); + }, + ); this.controls.noZoom = true; this.controls.noPan = true; @@ -225,7 +234,7 @@ class TDController extends React.PureComponent { nmVector.applyEuler(rotation); - Store.dispatch(moveTDViewByVectorAction(nmVector.x, nmVector.y)); + Store.dispatch(moveTDViewByVectorWithoutTimeTrackingAction(nmVector.x, nmVector.y)); } zoomTDView(value: number, zoomToMouse: boolean = true): void { diff --git a/frontend/javascripts/oxalis/model/actions/view_mode_actions.js b/frontend/javascripts/oxalis/model/actions/view_mode_actions.js index c4b498bb63d..c323dd7f886 100644 --- a/frontend/javascripts/oxalis/model/actions/view_mode_actions.js +++ b/frontend/javascripts/oxalis/model/actions/view_mode_actions.js @@ -39,6 +39,23 @@ type MoveTDViewByVectorAction = { y: number, }; +// These two actions are used instead of their functionally identical counterparts +// (without the `_WITHOUT_TIME_TRACKING` suffix) +// when dispatching these actions should not trigger the save queue diffing. +// Therefore, the save queue will not become dirty and no time is tracked by the backend. +// The actions are used by initialization code and by the `setTargetAndFixPosition` +// workaround in the td_controller.js. +type SetTDCameraWithoutTimeTrackingAction = { + type: "SET_TD_CAMERA_WITHOUT_TIME_TRACKING", + cameraData: PartialCameraData, +}; + +type MoveTDViewByVectorWithoutTimeTrackingAction = { + type: "MOVE_TD_VIEW_BY_VECTOR_WITHOUT_TIME_TRACKING", + x: number, + y: number, +}; + type SetInputCatcherRect = { type: "SET_INPUT_CATCHER_RECT", viewport: Viewport, @@ -60,6 +77,14 @@ export const setTDCameraAction = (cameraData: PartialCameraData): SetTDCameraAct cameraData, }); +// See the explanation further up for when to use this action instead of the setTDCameraAction +export const setTDCameraWithoutTimeTrackingAction = ( + cameraData: PartialCameraData, +): SetTDCameraWithoutTimeTrackingAction => ({ + type: "SET_TD_CAMERA_WITHOUT_TIME_TRACKING", + cameraData, +}); + export const centerTDViewAction = (): CenterTDViewAction => ({ type: "CENTER_TD_VIEW", }); @@ -83,6 +108,16 @@ export const moveTDViewByVectorAction = (x: number, y: number): MoveTDViewByVect y, }); +// See the explanation further up for when to use this action instead of the moveTDViewByVectorAction +export const moveTDViewByVectorWithoutTimeTrackingAction = ( + x: number, + y: number, +): MoveTDViewByVectorWithoutTimeTrackingAction => ({ + type: "MOVE_TD_VIEW_BY_VECTOR_WITHOUT_TIME_TRACKING", + x, + y, +}); + export const moveTDViewXAction = (x: number): MoveTDViewByVectorAction => { const state = Store.getState(); return moveTDViewByVectorAction((x * getTDViewportSize(state)[0]) / constants.VIEWPORT_WIDTH, 0); @@ -107,10 +142,19 @@ export const setInputCatcherRects = (viewportRects: ViewportRects): SetInputCatc export type ViewModeAction = | SetViewportAction | SetTDCameraAction + | SetTDCameraWithoutTimeTrackingAction | CenterTDViewAction | ZoomTDViewAction | MoveTDViewByVectorAction + | MoveTDViewByVectorWithoutTimeTrackingAction | SetInputCatcherRect | SetInputCatcherRects; +export const ViewModeSaveRelevantActions = [ + "SET_TD_CAMERA", + "CENTER_TD_VIEW", + "ZOOM_TD_VIEW", + "MOVE_TD_VIEW_BY_VECTOR", +]; + export default {}; diff --git a/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.js b/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.js index b2a3339e847..f5f2e0f367b 100644 --- a/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.js +++ b/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.js @@ -13,6 +13,15 @@ function removeAllButLastUpdateTracingAction(updateActionsBatches: Array) { + // This part of the code removes all entries from the save queue that consist only of + // one updateTdCamera update action, except for the last one + const updateTracingOnlyBatches = updateActionsBatches.filter( + batch => batch.actions.length === 1 && batch.actions[0].name === "updateTdCamera", + ); + return _.without(updateActionsBatches, ...updateTracingOnlyBatches.slice(0, -1)); +} + function removeSubsequentUpdateTreeActions(updateActionsBatches: Array) { const obsoleteUpdateActions = []; // If two updateTree update actions for the same treeId follow one another, the first one is obsolete @@ -32,6 +41,25 @@ function removeSubsequentUpdateTreeActions(updateActionsBatches: Array) { + const obsoleteUpdateActions = []; + // If two updateNode update actions for the same nodeId follow one another, the first one is obsolete + for (let i = 0; i < updateActionsBatches.length - 1; i++) { + const actions1 = updateActionsBatches[i].actions; + const actions2 = updateActionsBatches[i + 1].actions; + if ( + actions1.length === 1 && + actions1[0].name === "updateNode" && + actions2.length === 1 && + actions2[0].name === "updateNode" && + actions1[0].value.id === actions2[0].value.id + ) { + obsoleteUpdateActions.push(updateActionsBatches[i]); + } + } + return _.without(updateActionsBatches, ...obsoleteUpdateActions); +} + export default function compactSaveQueue( updateActionsBatches: Array, ): Array { @@ -40,5 +68,9 @@ export default function compactSaveQueue( updateActionsBatch => updateActionsBatch.actions.length > 0, ); - return removeSubsequentUpdateTreeActions(removeAllButLastUpdateTracingAction(result)); + return removeSubsequentUpdateTreeActions( + removeSubsequentUpdateNodeActions( + removeAllButLastUpdateTdCameraAction(removeAllButLastUpdateTracingAction(result)), + ), + ); } diff --git a/frontend/javascripts/oxalis/model/reducers/view_mode_reducer.js b/frontend/javascripts/oxalis/model/reducers/view_mode_reducer.js index fccad5aaed6..b3016b62af9 100644 --- a/frontend/javascripts/oxalis/model/reducers/view_mode_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/view_mode_reducer.js @@ -20,6 +20,7 @@ function ViewModeReducer(state: OxalisState, action: Action): OxalisState { }, }); } + case "SET_TD_CAMERA_WITHOUT_TIME_TRACKING": case "SET_TD_CAMERA": { return setTDCameraReducer(state, action.cameraData); } @@ -35,6 +36,7 @@ function ViewModeReducer(state: OxalisState, action: Action): OxalisState { action.curHeight, ); } + case "MOVE_TD_VIEW_BY_VECTOR_WITHOUT_TIME_TRACKING": case "MOVE_TD_VIEW_BY_VECTOR": { return moveTDViewByVectorReducer(state, action.x, action.y); } diff --git a/frontend/javascripts/oxalis/model/sagas/save_saga.js b/frontend/javascripts/oxalis/model/sagas/save_saga.js index 43e8ac9cc89..011388385e8 100644 --- a/frontend/javascripts/oxalis/model/sagas/save_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/save_saga.js @@ -14,8 +14,8 @@ import { UNDO_HISTORY_SIZE, maximumActionCountPerSave, } from "oxalis/model/sagas/save_saga_constants"; -import type { Tracing, SkeletonTracing, Flycam, SaveQueueEntry } from "oxalis/store"; -import { type UpdateAction } from "oxalis/model/sagas/update_actions"; +import type { Tracing, SkeletonTracing, Flycam, SaveQueueEntry, CameraData } from "oxalis/store"; +import { type UpdateAction, updateTdCamera } from "oxalis/model/sagas/update_actions"; import { VolumeTracingSaveRelevantActions, type AddBucketToUndoAction, @@ -41,6 +41,7 @@ import { setTracingAction, centerActiveNodeAction, } from "oxalis/model/actions/skeletontracing_actions"; +import { ViewModeSaveRelevantActions } from "oxalis/model/actions/view_mode_actions"; import type { Action } from "oxalis/model/actions/actions"; import { diffSkeletonTracing } from "oxalis/model/sagas/skeletontracing_saga"; import { diffVolumeTracing } from "oxalis/model/sagas/volumetracing_saga"; @@ -460,6 +461,8 @@ export function performDiffTracing( tracing: Tracing, prevFlycam: Flycam, flycam: Flycam, + prevTdCamera: CameraData, + tdCamera: CameraData, ): Array { let actions = []; if (tracingType === "skeleton" && tracing.skeleton != null && prevTracing.skeleton != null) { @@ -474,6 +477,10 @@ export function performDiffTracing( ); } + if (prevTdCamera !== tdCamera) { + actions = actions.concat(updateTdCamera()); + } + return actions; } @@ -488,6 +495,7 @@ export function* saveTracingTypeAsync(tracingType: "skeleton" | "volume"): Saga< let prevTracing = yield* select(state => state.tracing); let prevFlycam = yield* select(state => state.flycam); + let prevTdCamera = yield* select(state => state.viewModeData.plane.tdCamera); yield* take("WK_READY"); const initialAllowUpdate = yield* select( @@ -500,9 +508,18 @@ export function* saveTracingTypeAsync(tracingType: "skeleton" | "volume"): Saga< while (true) { if (tracingType === "skeleton") { - yield* take([...SkeletonTracingSaveRelevantActions, ...FlycamActions, "SET_TRACING"]); + yield* take([ + ...SkeletonTracingSaveRelevantActions, + ...FlycamActions, + ...ViewModeSaveRelevantActions, + "SET_TRACING", + ]); } else { - yield* take([...VolumeTracingSaveRelevantActions, ...FlycamActions]); + yield* take([ + ...VolumeTracingSaveRelevantActions, + ...FlycamActions, + ...ViewModeSaveRelevantActions, + ]); } // The allowUpdate setting could have changed in the meantime const allowUpdate = yield* select( @@ -515,10 +532,20 @@ export function* saveTracingTypeAsync(tracingType: "skeleton" | "volume"): Saga< const tracing = yield* select(state => state.tracing); const flycam = yield* select(state => state.flycam); + const tdCamera = yield* select(state => state.viewModeData.plane.tdCamera); const items = compactUpdateActions( // $FlowFixMe[incompatible-call] Should be resolved when we improve the typing of sagas in general Array.from( - yield* call(performDiffTracing, tracingType, prevTracing, tracing, prevFlycam, flycam), + yield* call( + performDiffTracing, + tracingType, + prevTracing, + tracing, + prevFlycam, + flycam, + prevTdCamera, + tdCamera, + ), ), tracing, ); @@ -527,5 +554,6 @@ export function* saveTracingTypeAsync(tracingType: "skeleton" | "volume"): Saga< } prevTracing = tracing; prevFlycam = flycam; + prevTdCamera = tdCamera; } } diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.js b/frontend/javascripts/oxalis/model/sagas/update_actions.js index f3f5ca77aba..e1afb5c7fef 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.js +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.js @@ -52,7 +52,7 @@ export type CreateNodeUpdateAction = {| name: "createNode", value: NodeWithTreeId, |}; -type UpdateNodeUpdateAction = {| +export type UpdateNodeUpdateAction = {| name: "updateNode", value: NodeWithTreeId, |}; @@ -63,7 +63,7 @@ export type UpdateTreeVisibilityUpdateAction = {| isVisible: boolean, |}, |}; -type UpdateTreeGroupVisibilityUpdateAction = {| +export type UpdateTreeGroupVisibilityUpdateAction = {| name: "updateTreeGroupVisibility", value: {| treeGroupId: ?number, @@ -77,7 +77,7 @@ export type DeleteNodeUpdateAction = {| nodeId: number, |}, |}; -type CreateEdgeUpdateAction = {| +export type CreateEdgeUpdateAction = {| name: "createEdge", value: {| treeId: number, @@ -85,7 +85,7 @@ type CreateEdgeUpdateAction = {| target: number, |}, |}; -type DeleteEdgeUpdateAction = {| +export type DeleteEdgeUpdateAction = {| name: "deleteEdge", value: {| treeId: number, @@ -140,6 +140,10 @@ export type RemoveFallbackLayerAction = {| name: "removeFallbackLayer", value: {}, |}; +export type UpdateTdCameraAction = {| + name: "updateTdCamera", + value: {}, +|}; export type UpdateAction = | UpdateTreeUpdateAction @@ -159,7 +163,8 @@ export type UpdateAction = | UpdateTreeGroupVisibilityUpdateAction | RevertToVersionUpdateAction | UpdateTreeGroupsUpdateAction - | RemoveFallbackLayerAction; + | RemoveFallbackLayerAction + | UpdateTdCameraAction; // This update action is only created in the frontend for display purposes type CreateTracingUpdateAction = {| @@ -196,7 +201,8 @@ export type ServerUpdateAction = | AsServerAction | AsServerAction | AsServerAction - | AsServerAction; + | AsServerAction + | AsServerAction; export function createTree(tree: Tree): UpdateTreeUpdateAction { return { @@ -407,6 +413,13 @@ export function removeFallbackLayer(): RemoveFallbackLayerAction { }; } +export function updateTdCamera(): UpdateTdCameraAction { + return { + name: "updateTdCamera", + value: {}, + }; +} + export function serverCreateTracing(timestamp: number) { return { name: "createTracing", diff --git a/frontend/javascripts/oxalis/view/version_entry.js b/frontend/javascripts/oxalis/view/version_entry.js index c4f468560ce..c7e7d67b7d5 100644 --- a/frontend/javascripts/oxalis/view/version_entry.js +++ b/frontend/javascripts/oxalis/view/version_entry.js @@ -11,18 +11,46 @@ import type { UpdateTreeUpdateAction, DeleteTreeUpdateAction, RevertToVersionUpdateAction, + UpdateNodeUpdateAction, + UpdateTreeVisibilityUpdateAction, + UpdateTreeGroupVisibilityUpdateAction, + CreateEdgeUpdateAction, + DeleteEdgeUpdateAction, } from "oxalis/model/sagas/update_actions"; import FormattedDate from "components/formatted_date"; +import { MISSING_GROUP_ID } from "oxalis/view/right-menu/tree_hierarchy_view_helpers"; type Description = { description: string, type: string }; +// The order in which the update actions are added to this object, +// determines the order in which update actions are checked +// to describe an update action batch. See also the comment +// of the `getDescriptionForBatch` function. const descriptionFns = { - deleteTree: (action: DeleteTreeUpdateAction): Description => ({ - description: `Deleted the tree with id ${action.value.id}.`, + importVolumeTracing: (): Description => ({ + description: "Imported a volume tracing.", + type: "plus", + }), + createTracing: (): Description => ({ + description: "Created the annotation.", + type: "rocket", + }), + updateUserBoundingBoxes: (): Description => ({ + description: "Updated a user bounding box.", + type: "codepen", + }), + removeFallbackLayer: (): Description => ({ + description: "Removed the segmentation fallback layer.", + type: "delete", + }), + deleteTree: (action: DeleteTreeUpdateAction, count: number): Description => ({ + description: + count > 1 ? `Deleted ${count} trees.` : `Deleted the tree with id ${action.value.id}.`, type: "delete", }), - deleteNode: (action: DeleteNodeUpdateAction): Description => ({ - description: `Deleted the node with id ${action.value.nodeId}.`, + deleteNode: (action: DeleteNodeUpdateAction, count: number): Description => ({ + description: + count > 1 ? `Deleted ${count} nodes.` : `Deleted the node with id ${action.value.nodeId}.`, type: "delete", }), revertToVersion: (action: RevertToVersionUpdateAction): Description => ({ @@ -49,13 +77,35 @@ const descriptionFns = { description: "Updated the segmentation.", type: "picture", }), - importVolumeTracing: (): Description => ({ - description: "Imported a Volume Tracing.", + updateNode: (action: UpdateNodeUpdateAction): Description => ({ + description: `Updated the node with id ${action.value.id}.`, + type: "edit", + }), + updateTreeVisibility: (action: UpdateTreeVisibilityUpdateAction): Description => ({ + description: `Updated the visibility of the tree with id ${action.value.treeId}.`, + type: "eye", + }), + updateTreeGroupVisibility: (action: UpdateTreeGroupVisibilityUpdateAction): Description => ({ + description: `Updated the visibility of the group with id ${ + action.value.treeGroupId != null ? action.value.treeGroupId : MISSING_GROUP_ID + }.`, + type: "eye", + }), + createEdge: (action: CreateEdgeUpdateAction): Description => ({ + description: `Created the edge between node ${action.value.source} and node ${ + action.value.target + }.`, type: "plus", }), - createTracing: (): Description => ({ - description: "Created the annotation.", - type: "rocket", + deleteEdge: (action: DeleteEdgeUpdateAction): Description => ({ + description: `Deleted the edge between node ${action.value.source} and node ${ + action.value.target + }.`, + type: "delete", + }), + updateTdCamera: (): Description => ({ + description: "Updated the 3D view.", + type: "code-sandbox", }), }; @@ -67,9 +117,20 @@ function getDescriptionForSpecificBatch( if (firstAction.name !== type) { throw new Error("Flow constraint violated"); } - return descriptionFns[type](firstAction); + return descriptionFns[type](firstAction, actions.length); } +// An update action batch can consist of more than one update action as a single user action +// can lead to multiple update actions. For example, deleting a node in a tree with multiple +// nodes will also delete an edge, so the batch contains those two update actions. +// This particular action and many more actions modify the active node of the tracing +// which results in an updateTracing action being part of the batch as well. +// The key of this function is to identify the most prominent action of a batch and label the +// batch with a description of this action. +// The order in which the actions are checked is, therefore, important. Check for +// "more expressive" update actions first and for more general ones later. +// The order is determined by the order in which the update actions are added to the +// `descriptionFns` object. function getDescriptionForBatch(actions: Array): Description { const groupedUpdateActions = _.groupBy(actions, "name"); @@ -96,6 +157,7 @@ function getDescriptionForBatch(actions: Array): Description // If more than one createNode update actions are part of one batch, that is not a tree merge or split // an NML was uploaded. + // TODO: This could've also been an undo/redo action. const createNodeUAs = groupedUpdateActions.createNode; if (createNodeUAs != null && createNodeUAs.length > 1) { return { @@ -104,56 +166,14 @@ function getDescriptionForBatch(actions: Array): Description }; } - const deleteTreeUAs = groupedUpdateActions.deleteTree; - if (deleteTreeUAs != null) { - return getDescriptionForSpecificBatch(deleteTreeUAs, "deleteTree"); - } - - const deleteNodeUAs = groupedUpdateActions.deleteNode; - if (deleteNodeUAs != null) { - return getDescriptionForSpecificBatch(deleteNodeUAs, "deleteNode"); - } - - const revertToVersionUAs = groupedUpdateActions.revertToVersion; - if (revertToVersionUAs != null) { - return getDescriptionForSpecificBatch(revertToVersionUAs, "revertToVersion"); - } - - if (createNodeUAs != null) { - return getDescriptionForSpecificBatch(createNodeUAs, "createNode"); - } - - const createTreeUAs = groupedUpdateActions.createTree; - if (createTreeUAs != null) { - return getDescriptionForSpecificBatch(createTreeUAs, "createTree"); - } - - const updateTreeGroupsUAs = groupedUpdateActions.updateTreeGroups; - if (updateTreeGroupsUAs != null) { - return getDescriptionForSpecificBatch(updateTreeGroupsUAs, "updateTreeGroups"); - } - - const updateTreeUAs = groupedUpdateActions.updateTree; - if (updateTreeUAs != null) { - return getDescriptionForSpecificBatch(updateTreeUAs, "updateTree"); - } - - const updateBucketUAs = groupedUpdateActions.updateBucket; - if (updateBucketUAs != null) { - return getDescriptionForSpecificBatch(updateBucketUAs, "updateBucket"); - } - - const importVolumeTracingUAs = groupedUpdateActions.importVolumeTracing; - if (importVolumeTracingUAs != null) { - return getDescriptionForSpecificBatch(importVolumeTracingUAs, "importVolumeTracing"); - } - - const createTracingUAs = groupedUpdateActions.createTracing; - if (createTracingUAs != null) { - return getDescriptionForSpecificBatch(createTracingUAs, "createTracing"); + for (const key of Object.keys(descriptionFns)) { + const updateActions = groupedUpdateActions[key]; + if (updateActions != null) { + return getDescriptionForSpecificBatch(updateActions, key); + } } - // Catch-all for other update actions, currently updateNode and updateTracing. + // Catch-all for other update actions, currently updateTracing. return { description: "Modified the annotation.", type: "edit", diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.js b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.js index b6f3178e07f..35b060fe97b 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.js +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.js @@ -2,7 +2,7 @@ import "test/sagas/skeletontracing_saga.mock.js"; -import type { SaveQueueEntry } from "oxalis/store"; +import type { SaveQueueEntry, SkeletonTracing } from "oxalis/store"; import ChainReducer from "test/helpers/chainReducer"; import DiffableMap from "libs/diffable_map"; import EdgeCollection from "oxalis/model/edge_collection"; @@ -10,6 +10,8 @@ import compactSaveQueue from "oxalis/model/helpers/compaction/compact_save_queue import compactUpdateActions from "oxalis/model/helpers/compaction/compact_update_actions"; import mockRequire from "mock-require"; import test from "ava"; +import defaultState from "oxalis/default_state"; +import update from "immutability-helper"; import { createSaveQueueFromUpdateActions, withoutUpdateTracing } from "../helpers/saveHelpers"; import { expectValueDeepEqual, execCall } from "../helpers/sagaHelpers"; @@ -39,7 +41,6 @@ const SkeletonTracingReducer = mockRequire.reRequire( "oxalis/model/reducers/skeletontracing_reducer", ).default; const { take, put } = mockRequire.reRequire("redux-saga/effects"); -const { M4x4 } = mockRequire.reRequire("libs/mjs"); function testDiffing(prevTracing, nextTracing, prevFlycam, flycam) { return withoutUpdateTracing( @@ -59,56 +60,42 @@ function compactSaveQueueWithUpdateActions( ); } -const initialState = { - dataset: { - dataSource: { - scale: [5, 5, 5], - }, - }, - task: { - id: 1, - }, - datasetConfiguration: { - fourBit: false, - interpolation: false, - }, +const skeletonTracing: SkeletonTracing = { + type: "skeleton", + createdTimestamp: 0, + tracingId: "tracingId", + version: 0, + trees: {}, + treeGroups: [], + activeGroupId: -1, + activeTreeId: 1, + activeNodeId: null, + cachedMaxNodeId: 0, + boundingBox: null, + userBoundingBoxes: [], + navigationList: { list: [], activeIndex: -1 }, +}; + +skeletonTracing.trees[1] = { + treeId: 1, + name: "TestTree", + nodes: new DiffableMap(), + timestamp: 12345678, + branchPoints: [], + edges: new EdgeCollection(), + comments: [], + color: [23, 23, 23], + isVisible: true, + groupId: -1, +}; + +const initialState = update(defaultState, { tracing: { - restrictions: { - branchPointsAllowed: true, - allowUpdate: true, - allowFinish: true, - allowAccess: true, - allowDownload: true, - }, - annotationType: "Explorational", - name: "", - skeleton: { - type: "skeleton", - trees: { - "1": { - treeId: 1, - name: "TestTree", - nodes: new DiffableMap(), - timestamp: 12345678, - branchPoints: [], - edges: new EdgeCollection(), - comments: [], - color: [23, 23, 23], - isVisible: true, - }, - }, - activeTreeId: 1, - activeNodeId: null, - cachedMaxNodeId: 0, - }, - volume: null, + restrictions: { allowUpdate: { $set: true }, branchPointsAllowed: { $set: true } }, + skeleton: { $set: skeletonTracing }, }, - flycam: { - zoomStep: 2, - currentMatrix: M4x4.identity, - spaceDirectionOrtho: [1, 1, 1], - }, -}; +}); + const createNodeAction = SkeletonTracingActions.createNodeAction([1, 2, 3], [0, 1, 0], 0, 1.2); const deleteNodeAction = SkeletonTracingActions.deleteNodeAction(); const createTreeAction = SkeletonTracingActions.createTreeAction(12345678); @@ -127,13 +114,15 @@ test("SkeletonTracingSaga shouldn't do anything if unchanged (saga test)", t => saga.next(); saga.next(initialState.tracing); saga.next(initialState.flycam); + saga.next(initialState.viewModeData.plane.tdCamera); saga.next(); saga.next(true); saga.next(); saga.next(true); saga.next(initialState.tracing); + saga.next(initialState.flycam); // only updateTracing - const items = execCall(t, saga.next(initialState.flycam)); + const items = execCall(t, saga.next(initialState.viewModeData.plane.tdCamera)); t.is(withoutUpdateTracing(items).length, 0); }); @@ -145,12 +134,14 @@ test("SkeletonTracingSaga should do something if changed (saga test)", t => { saga.next(); saga.next(initialState.tracing); saga.next(initialState.flycam); + saga.next(initialState.viewModeData.plane.tdCamera); saga.next(); saga.next(true); saga.next(); saga.next(true); saga.next(newState.tracing); - const items = execCall(t, saga.next(newState.flycam)); + saga.next(newState.flycam); + const items = execCall(t, saga.next(newState.viewModeData.plane.tdCamera)); t.true(withoutUpdateTracing(items).length > 0); expectValueDeepEqual(t, saga.next(items), put(pushSaveQueueTransaction(items, "skeleton"))); }); @@ -369,7 +360,6 @@ test("SkeletonTracingSaga should emit an updateTree update actions (branchpoints testState.flycam, newState.flycam, ); - t.is(updateActions[0].name, "updateTree"); t.is(updateActions[0].value.id, 1); t.deepEqual(updateActions[0].value.branchPoints, [{ nodeId: 1, timestamp: 12345678 }]); diff --git a/frontend/javascripts/test/sagas/volumetracing_saga.spec.js b/frontend/javascripts/test/sagas/volumetracing_saga.spec.js index 85908614f01..b04b0ecbdfd 100644 --- a/frontend/javascripts/test/sagas/volumetracing_saga.spec.js +++ b/frontend/javascripts/test/sagas/volumetracing_saga.spec.js @@ -12,6 +12,7 @@ import VolumeTracingReducer from "oxalis/model/reducers/volumetracing_reducer"; import defaultState from "oxalis/default_state"; import mockRequire from "mock-require"; import test from "ava"; +import type { VolumeTracing } from "oxalis/store"; import { expectValueDeepEqual, execCall } from "../helpers/sagaHelpers"; import { withoutUpdateTracing } from "../helpers/saveHelpers"; @@ -35,23 +36,20 @@ const { saveTracingTypeAsync } = require("oxalis/model/sagas/save_saga"); const { editVolumeLayerAsync, finishLayer } = require("oxalis/model/sagas/volumetracing_saga"); const VolumeLayer = require("oxalis/model/volumetracing/volumelayer").default; -const volumeTracing = { +const volumeTracing: VolumeTracing = { type: "volume", - annotationType: "Explorational", - name: "", + createdTimestamp: 0, + tracingId: "tracingId", + version: 0, activeTool: VolumeToolEnum.TRACE, activeCellId: 0, - cells: [], - viewMode: 0, + cells: {}, maxCellId: 0, contourList: [[1, 2, 3], [7, 8, 9]], - restrictions: { - branchPointsAllowed: true, - allowUpdate: true, - allowFinish: true, - allowAccess: true, - allowDownload: true, - }, + boundingBox: null, + userBoundingBoxes: [], + lastCentroid: null, + contourTracingMode: ContourModeEnum.IDLE, }; const initialState = update(defaultState, { @@ -74,13 +72,15 @@ test("VolumeTracingSaga shouldn't do anything if unchanged (saga test)", t => { saga.next(); saga.next(initialState.tracing); saga.next(initialState.flycam); + saga.next(initialState.viewModeData.plane.tdCamera); saga.next(); saga.next(true); saga.next(); saga.next(true); saga.next(initialState.tracing); + saga.next(initialState.flycam); // only updateTracing - const items = execCall(t, saga.next(initialState.flycam)); + const items = execCall(t, saga.next(initialState.viewModeData.plane.tdCamera)); t.is(withoutUpdateTracing(items).length, 0); }); @@ -92,12 +92,14 @@ test("VolumeTracingSaga should do something if changed (saga test)", t => { saga.next(); saga.next(initialState.tracing); saga.next(initialState.flycam); + saga.next(initialState.viewModeData.plane.tdCamera); saga.next(); saga.next(true); saga.next(); saga.next(true); saga.next(newState.tracing); - const items = execCall(t, saga.next(newState.flycam)); + saga.next(newState.flycam); + const items = execCall(t, saga.next(newState.viewModeData.plane.tdCamera)); t.is(withoutUpdateTracing(items).length, 0); t.true(items[0].value.activeSegmentId === ACTIVE_CELL_ID); expectValueDeepEqual(t, saga.next(items), put(pushSaveQueueTransaction(items, "volume"))); diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala index 2d023c068e0..808b48ac370 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala @@ -396,6 +396,16 @@ case class UpdateUserBoundingBoxVisibility(boundingBoxId: Option[Int], override def addInfo(info: Option[String]): UpdateAction[SkeletonTracing] = this.copy(info = info) } +case class UpdateTdCamera(actionTimestamp: Option[Long] = None, info: Option[String] = None) + extends UpdateAction.SkeletonUpdateAction { + + override def applyOn(tracing: SkeletonTracing): SkeletonTracing = tracing + + override def addTimestamp(timestamp: Long): UpdateAction[SkeletonTracing] = + this.copy(actionTimestamp = Some(timestamp)) + override def addInfo(info: Option[String]): UpdateAction[SkeletonTracing] = this.copy(info = info) +} + object CreateTreeSkeletonAction { implicit val jsonFormat = Json.format[CreateTreeSkeletonAction] } object DeleteTreeSkeletonAction { implicit val jsonFormat = Json.format[DeleteTreeSkeletonAction] } object UpdateTreeSkeletonAction { implicit val jsonFormat = Json.format[UpdateTreeSkeletonAction] } @@ -413,6 +423,7 @@ object UpdateTreeVisibility { implicit val jsonFormat = Json.format[UpdateTreeVi object UpdateTreeGroupVisibility { implicit val jsonFormat = Json.format[UpdateTreeGroupVisibility] } object UpdateUserBoundingBoxes { implicit val jsonFormat = Json.format[UpdateUserBoundingBoxes] } object UpdateUserBoundingBoxVisibility { implicit val jsonFormat = Json.format[UpdateUserBoundingBoxVisibility] } +object UpdateTdCamera { implicit val jsonFormat = Json.format[UpdateTdCamera] } object SkeletonUpdateAction { @@ -437,10 +448,11 @@ object SkeletonUpdateAction { case "updateTreeGroupVisibility" => deserialize[UpdateTreeGroupVisibility](jsonValue) case "updateUserBoundingBoxes" => deserialize[UpdateUserBoundingBoxes](jsonValue) case "updateUserBoundingBoxVisibility" => deserialize[UpdateUserBoundingBoxVisibility](jsonValue) + case "updateTdCamera" => deserialize[UpdateTdCamera](jsonValue) } } - def deserialize[T](json: JsValue, shouldTransformPositions: Boolean = false)(implicit tjs: Reads[T]) = + def deserialize[T](json: JsValue, shouldTransformPositions: Boolean = false)(implicit tjs: Reads[T]): JsResult[T] = if (shouldTransformPositions) json.transform(positionTransform).get.validate[T] else @@ -449,7 +461,7 @@ object SkeletonUpdateAction { private val positionTransform = (JsPath \ 'position).json.update(JsPath.read[List[Float]].map(position => Json.toJson(position.map(_.toInt)))) - override def writes(a: UpdateAction[SkeletonTracing]) = a match { + override def writes(a: UpdateAction[SkeletonTracing]): JsObject = a match { case s: CreateTreeSkeletonAction => Json.obj("name" -> "createTree", "value" -> Json.toJson(s)(CreateTreeSkeletonAction.jsonFormat)) case s: DeleteTreeSkeletonAction => @@ -485,6 +497,8 @@ object SkeletonUpdateAction { case s: UpdateUserBoundingBoxVisibility => Json.obj("name" -> "updateUserBoundingBoxVisibility", "value" -> Json.toJson(s)(UpdateUserBoundingBoxVisibility.jsonFormat)) + case s: UpdateTdCamera => + Json.obj("name" -> "updateTdCamera", "value" -> Json.toJson(s)(UpdateTdCamera.jsonFormat)) } } } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index 8474a0577f2..387208d2f34 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -105,6 +105,7 @@ class VolumeTracingService @Inject()( case a: UpdateUserBoundingBoxVisibility => updateBoundingBoxVisibility(t, a.boundingBoxId, a.isVisible) case _: RemoveFallbackLayer => Fox.successful(t.clearFallbackLayer) case a: ImportVolumeData => Fox.successful(t.withLargestSegmentId(a.largestSegmentId)) + case _: UpdateTdCamera => Fox.successful(t) case _ => Fox.failure("Unknown action.") } case Empty => diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala index 7863fce5a42..1ed674c75d4 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala @@ -113,6 +113,20 @@ object ImportVolumeData { implicit val importVolumeData = Json.format[ImportVolumeData] } +case class UpdateTdCamera(actionTimestamp: Option[Long] = None, info: Option[String] = None) + extends VolumeUpdateAction { + + override def addTimestamp(timestamp: Long): VolumeUpdateAction = + this.copy(actionTimestamp = Some(timestamp)) + + override def transformToCompact: CompactVolumeUpdateAction = + CompactVolumeUpdateAction("updateTdCamera", actionTimestamp, Json.obj()) +} + +object UpdateTdCamera { + implicit val updateTdCameraFormat = Json.format[UpdateTdCamera] +} + case class CompactVolumeUpdateAction(name: String, actionTimestamp: Option[Long], value: JsObject) extends VolumeUpdateAction @@ -142,6 +156,7 @@ object VolumeUpdateAction { case "updateUserBoundingBoxVisibility" => (json \ "value").validate[UpdateUserBoundingBoxVisibility] case "removeFallbackLayer" => (json \ "value").validate[RemoveFallbackLayer] case "importVolumeTracing" => (json \ "value").validate[ImportVolumeData] + case "updateTdCamera" => (json \ "value").validate[UpdateTdCamera] case unknownAction: String => JsError(s"Invalid update action s'$unknownAction'") } @@ -165,6 +180,8 @@ object VolumeUpdateAction { Json.obj("name" -> "removeFallbackLayer", "value" -> Json.toJson(s)(RemoveFallbackLayer.removeFallbackLayer)) case s: ImportVolumeData => Json.obj("name" -> "importVolumeTracing", "value" -> Json.toJson(s)(ImportVolumeData.importVolumeData)) + case s: UpdateTdCamera => + Json.obj("name" -> "updateTdCamera", "value" -> Json.toJson(s)(UpdateTdCamera.updateTdCameraFormat)) case s: CompactVolumeUpdateAction => Json.toJson(s)(CompactVolumeUpdateAction.compactVolumeUpdateActionFormat) } }