diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 20e5639dff6..1739b9ee12f 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -11,7 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released [Commits](https://github.com/scalableminds/webknossos/compare/21.09.0...HEAD) ### Added -- +- Added a new bounding box tool that allows resizing and creating bounding boxes more easily. Additionally, the context menu now contains options to modify the bounding box close to the clicked position. [#5767](https://github.com/scalableminds/webknossos/pull/5767) ### Changed - diff --git a/frontend/javascripts/libs/window.js b/frontend/javascripts/libs/window.js index fa92f1bca28..c60adfc15d8 100644 --- a/frontend/javascripts/libs/window.js +++ b/frontend/javascripts/libs/window.js @@ -4,7 +4,7 @@ export const alert = typeof window === "undefined" ? console.log.bind(console) : window.alert; export const document = typeof window === "undefined" || !window.document - ? { getElementById: () => null } + ? { getElementById: () => null, body: null } : window.document; // See https://github.com/facebook/flow/blob/master/lib/bom.js#L294-L311 diff --git a/frontend/javascripts/oxalis/constants.js b/frontend/javascripts/oxalis/constants.js index d9de9afdaaf..75f52951fce 100644 --- a/frontend/javascripts/oxalis/constants.js +++ b/frontend/javascripts/oxalis/constants.js @@ -157,6 +157,7 @@ export const AnnotationToolEnum = { ERASE_TRACE: "ERASE_TRACE", FILL_CELL: "FILL_CELL", PICK_CELL: "PICK_CELL", + BOUNDING_BOX: "BOUNDING_BOX", }; export const VolumeTools = [ AnnotationToolEnum.BRUSH, @@ -232,7 +233,14 @@ export type LabeledVoxelsMap = Map; // e.g., z in XY viewport). export type LabelMasksByBucketAndW = Map>; -export type ShowContextMenuFunction = (number, number, ?number, Vector3, OrthoView) => void; +export type ShowContextMenuFunction = ( + number, + number, + ?number, + ?number, + Vector3, + OrthoView, +) => void; const Constants = { ARBITRARY_VIEW: 4, diff --git a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.js b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.js new file mode 100644 index 00000000000..2b49c2de2eb --- /dev/null +++ b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.js @@ -0,0 +1,293 @@ +// @flow +import { calculateGlobalPos } from "oxalis/model/accessors/view_mode_accessor"; +import _ from "lodash"; +import type { OrthoView, Point2, Vector3, BoundingBoxType } from "oxalis/constants"; +import Store from "oxalis/store"; +import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; +import Dimension, { type DimensionMap, type DimensionIndices } from "oxalis/model/dimensions"; +import { changeUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; +import { getBaseVoxelFactors } from "oxalis/model/scaleinfo"; +import getSceneController from "oxalis/controller/scene_controller_provider"; +import { document } from "libs/window"; + +const BOUNDING_BOX_HOVERING_THROTTLE_TIME = 100; + +const getNeighbourEdgeIndexByEdgeIndex = { + // The edges are indexed within the plane like this: + // See the distanceArray calculation as a reference. + // +---0---+ + // | | + // 2 3 + // | | + // +---1---+ + // + "0": [2, 3], + "1": [2, 3], + "2": [0, 1], + "3": [0, 1], +}; +// This value is in "mouse tolerance to trigger a selection". It is in "unzoomed worldcoordinates". +const MAX_DISTANCE_TO_SELECTION = 15; + +function getDistanceToBoundingBoxEdge( + pos: Vector3, + min: Vector3, + max: Vector3, + compareToMin: boolean, + primaryAndSecondaryDim: [DimensionIndices, DimensionIndices], + planeRatio: Vector3, +) { + // This method calculates the distance between the given pos and a single edge of a cross section + // of bounding box given min and max that is displayed in a certain viewport. + // The edge dimension that the edge extents along is given by the first entry of primaryAndSecondaryDim. + // Namely, 0 -> x direction (i.e., horizontal), 1 -> y direction and 2 -> z direction. + // The second entry of primaryAndSecondaryDim gives the other extent of the viewport / cross section. + // The boolean compareToMin tells which of the two edges in the primary direction should be compared with. + // To calculate the distance there are three cases depending on the primary dimension value of pos. + // - One when the given pos is on the left of the minimum of the edge (case 1), + // - one if pos is on the right of the maximum of the edge (case 2) + // - and the last if pos is inbeween the minimum and maximum of the edge (case 3). + // If the cross section is on the xy viewport and the edge to compare to is the min edge in x direction + // an example for the different cases is: compareToMin = true, primaryAndSecondaryDim = [0,1] = [x,y] + // ---> x + // | min.x max.x + // ↓ y ↓ ↓ + // ↓ ↓ + // case 1 ↓ case 3 case 3 ↓ case 2 + // '. | | .' + // '↘ ↓ ↓ ↙' + // -------------------------------------------------------- + // | | + // | edge extends along x direction -> primary dim = x | + // | | + + // As the planeRatio is multiplied to the global coordinates passed to this method, + // the distance between the mouse and the bounding box is distorted by the factor of planeRatio. + // That's why we later divide exactly by this factor to let the hit box / distance + // between the mouse and bounding box be the same in each dimension. + const [primaryEdgeDim, secondaryEdgeDim] = primaryAndSecondaryDim; + const cornerToCompareWith = compareToMin ? min : max; + const toScreenSpace = (value: number, dimension: DimensionIndices) => + value / planeRatio[dimension]; + if (pos[primaryEdgeDim] < min[primaryEdgeDim]) { + // Case 1: Distance to the min corner is needed in primaryEdgeDim. + return Math.hypot( + toScreenSpace(pos[primaryEdgeDim] - min[primaryEdgeDim], primaryEdgeDim), + toScreenSpace( + pos[secondaryEdgeDim] - cornerToCompareWith[secondaryEdgeDim], + secondaryEdgeDim, + ), + ); + } + if (pos[primaryEdgeDim] > max[primaryEdgeDim]) { + // Case 2: Distance to max Corner is needed in primaryEdgeDim. + return Math.hypot( + toScreenSpace(pos[primaryEdgeDim] - max[primaryEdgeDim], primaryEdgeDim), + toScreenSpace( + pos[secondaryEdgeDim] - cornerToCompareWith[secondaryEdgeDim], + secondaryEdgeDim, + ), + ); + } + // Case 3: + return Math.abs( + toScreenSpace(pos[secondaryEdgeDim] - cornerToCompareWith[secondaryEdgeDim], secondaryEdgeDim), + ); +} + +export type SelectedEdge = { + boxId: number, + direction: "horizontal" | "vertical", + isMaxEdge: boolean, + edgeId: number, + resizableDimension: 0 | 1 | 2, +}; + +type DistanceArray = [number, number, number, number]; + +function computeDistanceArray( + boundingBoxBounds: BoundingBoxType, + globalPosition: Vector3, + indices: DimensionMap, + planeRatio: Vector3, +): DistanceArray { + const { min, max } = boundingBoxBounds; + const distanceArray = [0, 1, 2, 3].map(edgeId => { + const direction = edgeId < 2 ? "horizontal" : "vertical"; + const isMaxEdge = edgeId % 2 === 1; + const primaryAndSecondaryDim = + direction === "horizontal" ? [indices[0], indices[1]] : [indices[1], indices[0]]; + return getDistanceToBoundingBoxEdge( + globalPosition, + min, + max, + !isMaxEdge, + primaryAndSecondaryDim, + planeRatio, + ); + }); + return ((distanceArray: any): DistanceArray); +} + +// Return the edge or edges of the bounding box closest to the mouse position if their distance is below a certain threshold. +// If no edge is close to the mouse null is returned instead. Otherwise the first entry is always the closest edge. +// If the mouse is near a corner, there is always an additional edge that is close to the mouse. +// If such an edge exists then this edge is the second entry of the array. +// If the mouse isn't close to a corner of a crossection, the second entry is null. +export function getClosestHoveredBoundingBox( + pos: Point2, + plane: OrthoView, +): [SelectedEdge, ?SelectedEdge] | null { + const state = Store.getState(); + const globalPosition = calculateGlobalPos(state, pos, plane); + const { userBoundingBoxes } = getSomeTracing(state.tracing); + const indices = Dimension.getIndices(plane); + const planeRatio = getBaseVoxelFactors(state.dataset.dataSource.scale); + const thirdDim = indices[2]; + + const zoomedMaxDistanceToSelection = MAX_DISTANCE_TO_SELECTION * state.flycam.zoomStep; + let currentNearestDistance = zoomedMaxDistanceToSelection; + let currentNearestBoundingBox = null; + let currentNearestDistanceArray = null; + + for (const bbox of userBoundingBoxes) { + const { min, max } = bbox.boundingBox; + const isCrossSectionOfViewportVisible = + globalPosition[thirdDim] >= min[thirdDim] && globalPosition[thirdDim] < max[thirdDim]; + if (!isCrossSectionOfViewportVisible || !bbox.isVisible) { + continue; + } + // In getNeighbourEdgeIndexByEdgeIndex is a visualization + // of how the indices of the array map to the visible bbox edges. + const distanceArray = computeDistanceArray( + bbox.boundingBox, + globalPosition, + indices, + planeRatio, + ); + const minimumDistance = Math.min(...distanceArray); + if (minimumDistance < currentNearestDistance) { + currentNearestDistance = minimumDistance; + currentNearestBoundingBox = bbox; + currentNearestDistanceArray = distanceArray; + } + } + if (currentNearestBoundingBox == null || currentNearestDistanceArray == null) { + return null; + } + const nearestBoundingBox = currentNearestBoundingBox; + const getEdgeInfoFromId = (edgeId: number) => { + const direction = edgeId < 2 ? "horizontal" : "vertical"; + const isMaxEdge = edgeId % 2 === 1; + const resizableDimension = direction === "horizontal" ? indices[1] : indices[0]; + return { + boxId: nearestBoundingBox.id, + direction, + isMaxEdge, + edgeId, + resizableDimension, + }; + }; + const nearestEdgeIndex = currentNearestDistanceArray.indexOf(currentNearestDistance); + const primaryEdge = getEdgeInfoFromId(nearestEdgeIndex); + let secondaryEdge = null; + const [firstNeighbourId, secondNeighbourId] = getNeighbourEdgeIndexByEdgeIndex[nearestEdgeIndex]; + const firstNeighbourEdgeDistance = currentNearestDistanceArray[firstNeighbourId]; + const secondNeighbourEdgeDistance = currentNearestDistanceArray[secondNeighbourId]; + if ( + firstNeighbourEdgeDistance < secondNeighbourEdgeDistance && + firstNeighbourEdgeDistance < zoomedMaxDistanceToSelection + ) { + secondaryEdge = getEdgeInfoFromId(firstNeighbourId); + } else if ( + secondNeighbourEdgeDistance < firstNeighbourEdgeDistance && + secondNeighbourEdgeDistance < zoomedMaxDistanceToSelection + ) { + secondaryEdge = getEdgeInfoFromId(secondNeighbourId); + } + return [primaryEdge, secondaryEdge]; +} + +export const highlightAndSetCursorOnHoveredBoundingBox = _.throttle( + (delta: Point2, position: Point2, planeId: OrthoView) => { + const hoveredEdgesInfo = getClosestHoveredBoundingBox(position, planeId); + const inputCatcher = document.getElementById(`inputcatcher_${planeId}`); + if (hoveredEdgesInfo != null && inputCatcher != null) { + const [primaryHoveredEdge, secondaryHoveredEdge] = hoveredEdgesInfo; + getSceneController().highlightUserBoundingBox(primaryHoveredEdge.boxId); + if (secondaryHoveredEdge != null) { + // If a corner is selected. + inputCatcher.style.cursor = + (primaryHoveredEdge.isMaxEdge && secondaryHoveredEdge.isMaxEdge) || + (!primaryHoveredEdge.isMaxEdge && !secondaryHoveredEdge.isMaxEdge) + ? "nwse-resize" + : "nesw-resize"; + } else if (primaryHoveredEdge.direction === "horizontal") { + inputCatcher.style.cursor = "row-resize"; + } else { + inputCatcher.style.cursor = "col-resize"; + } + } else { + getSceneController().highlightUserBoundingBox(null); + if (inputCatcher != null) { + inputCatcher.style.cursor = "auto"; + } + } + }, + BOUNDING_BOX_HOVERING_THROTTLE_TIME, +); + +export function handleResizingBoundingBox( + mousePosition: Point2, + planeId: OrthoView, + primaryEdge: SelectedEdge, + secondaryEdge: ?SelectedEdge, +) { + const state = Store.getState(); + const globalMousePosition = calculateGlobalPos(state, mousePosition, planeId); + const { userBoundingBoxes } = getSomeTracing(state.tracing); + const bboxToResize = userBoundingBoxes.find(bbox => bbox.id === primaryEdge.boxId); + if (!bboxToResize) { + return; + } + const updatedBounds = { + min: [...bboxToResize.boundingBox.min], + max: [...bboxToResize.boundingBox.max], + }; + function updateBoundsAccordingToEdge(edge: SelectedEdge): boolean { + const { resizableDimension } = edge; + // For a horizontal edge only consider delta.y, for vertical only delta.x + const newPositionValue = Math.round(globalMousePosition[resizableDimension]); + const minOrMax = edge.isMaxEdge ? "max" : "min"; + const oppositeOfMinOrMax = edge.isMaxEdge ? "min" : "max"; + const otherEdgeValue = bboxToResize.boundingBox[oppositeOfMinOrMax][resizableDimension]; + if (otherEdgeValue === newPositionValue) { + // Do not allow the same value for min and max for one dimension. + return false; + } + const areMinAndMaxEdgeCrossing = + // If the min / max edge is moved over the other one. + (edge.isMaxEdge && newPositionValue < otherEdgeValue) || + (!edge.isMaxEdge && newPositionValue > otherEdgeValue); + if (areMinAndMaxEdgeCrossing) { + // As the edge moved over the other one, the values for min and max must be switched. + updatedBounds[minOrMax][resizableDimension] = otherEdgeValue; + updatedBounds[oppositeOfMinOrMax][resizableDimension] = newPositionValue; + return true; + } else { + updatedBounds[minOrMax][resizableDimension] = newPositionValue; + return false; + } + } + let didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge(primaryEdge); + if (didMinAndMaxEdgeSwitch) { + primaryEdge.isMaxEdge = !primaryEdge.isMaxEdge; + } + if (secondaryEdge) { + didMinAndMaxEdgeSwitch = updateBoundsAccordingToEdge(secondaryEdge); + if (didMinAndMaxEdgeSwitch) { + secondaryEdge.isMaxEdge = !secondaryEdge.isMaxEdge; + } + } + Store.dispatch(changeUserBoundingBoxAction(primaryEdge.boxId, { boundingBox: updatedBounds })); +} diff --git a/frontend/javascripts/oxalis/controller/combinations/move_handlers.js b/frontend/javascripts/oxalis/controller/combinations/move_handlers.js index 21ac0bb52b0..d34924fd0f7 100644 --- a/frontend/javascripts/oxalis/controller/combinations/move_handlers.js +++ b/frontend/javascripts/oxalis/controller/combinations/move_handlers.js @@ -71,6 +71,15 @@ export const moveW = (deltaW: number, oneSlide: boolean): void => { } }; +export function moveWhenAltIsPressed(delta: Point2, position: Point2, _id: any, event: MouseEvent) { + // Always set the correct mouse position. Otherwise, using alt + mouse move and + // alt + scroll won't result in the correct zoomToMouse behavior. + setMousePosition(position); + if (event.altKey && !event.shiftKey && !event.ctrlKey) { + handleMovePlane(delta); + } +} + export const zoom = (value: number, zoomToMouse: boolean) => { const { activeViewport } = Store.getState().viewModeData.plane; if (OrthoViewValuesWithoutTDView.includes(activeViewport)) { diff --git a/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.js b/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.js index 619ec518ae5..366ab417de0 100644 --- a/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.js +++ b/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.js @@ -46,6 +46,7 @@ import getSceneController from "oxalis/controller/scene_controller_provider"; import { renderToTexture } from "oxalis/view/rendering_utils"; import { getBaseVoxelFactors } from "oxalis/model/scaleinfo"; import Dimensions from "oxalis/model/dimensions"; +import { getClosestHoveredBoundingBox } from "oxalis/controller/combinations/bounding_box_handlers"; const OrthoViewToNumber: OrthoViewMap = { [OrthoViews.PLANE_XY]: 0, @@ -139,7 +140,16 @@ export function handleOpenContextMenu( const nodeId = maybeGetNodeIdFromPosition(planeView, position, plane, isTouch); const state = Store.getState(); const globalPosition = calculateGlobalPos(state, position); - showNodeContextMenuAt(event.pageX, event.pageY, nodeId, globalPosition, activeViewport); + const hoveredEdgesInfo = getClosestHoveredBoundingBox(position, plane); + const clickedBoundingBoxId = hoveredEdgesInfo != null ? hoveredEdgesInfo[0].boxId : null; + showNodeContextMenuAt( + event.pageX, + event.pageY, + nodeId, + clickedBoundingBoxId, + globalPosition, + activeViewport, + ); } export function moveNode(dx: number, dy: number, nodeId: ?number) { @@ -168,25 +178,6 @@ export function moveNode(dx: number, dy: number, nodeId: ?number) { ); } -export function openContextMenu( - planeView: PlaneView, - position: Point2, - plane: OrthoView, - isTouch: boolean, - event: MouseEvent, - showNodeContextMenuAt: ShowContextMenuFunction, -) { - const state = Store.getState(); - const { activeViewport } = state.viewModeData.plane; - if (activeViewport === OrthoViews.TDView) { - return; - } - - const nodeId = maybeGetNodeIdFromPosition(planeView, position, plane, isTouch); - const globalPosition = calculateGlobalPos(state, position); - showNodeContextMenuAt(event.pageX, event.pageY, nodeId, globalPosition, activeViewport); -} - export function setWaypoint( position: Vector3, activeViewport: OrthoView, diff --git a/frontend/javascripts/oxalis/controller/combinations/tool_controls.js b/frontend/javascripts/oxalis/controller/combinations/tool_controls.js index e0bd03318c4..3450d1b5810 100644 --- a/frontend/javascripts/oxalis/controller/combinations/tool_controls.js +++ b/frontend/javascripts/oxalis/controller/combinations/tool_controls.js @@ -8,6 +8,7 @@ import { type ShowContextMenuFunction, type AnnotationTool, AnnotationToolEnum, + OrthoViewValuesWithoutTDView, } from "oxalis/constants"; import { getContourTracingMode, @@ -19,12 +20,21 @@ import { } from "oxalis/controller/combinations/segmentation_handlers"; import { hideBrushAction } from "oxalis/model/actions/volumetracing_actions"; import { isBrushTool } from "oxalis/model/accessors/tool_accessor"; +import getSceneController from "oxalis/controller/scene_controller_provider"; +import { finishedResizingUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import * as MoveHandlers from "oxalis/controller/combinations/move_handlers"; import PlaneView from "oxalis/view/plane_view"; import * as SkeletonHandlers from "oxalis/controller/combinations/skeleton_handlers"; +import { + type SelectedEdge, + getClosestHoveredBoundingBox, + handleResizingBoundingBox, + highlightAndSetCursorOnHoveredBoundingBox, +} from "oxalis/controller/combinations/bounding_box_handlers"; import Store from "oxalis/store"; import * as Utils from "libs/utils"; import * as VolumeHandlers from "oxalis/controller/combinations/volume_handlers"; +import { document } from "libs/window"; import api from "oxalis/api/internal_api"; /* @@ -97,14 +107,7 @@ export class MoveTool { handleClickSegment(pos); }, pinch: delta => MoveHandlers.zoom(delta, true), - mouseMove: (delta: Point2, position: Point2, id, event) => { - // Always set the correct mouse position. Otherwise, using alt + mouse move and - // alt + scroll won't result in the correct zoomToMouse behavior. - MoveHandlers.setMousePosition(position); - if (event.altKey && !event.shiftKey && !event.ctrlKey) { - MoveHandlers.handleMovePlane(delta); - } - }, + mouseMove: MoveHandlers.moveWhenAltIsPressed, out: () => { MoveHandlers.setMousePosition(null); }, @@ -121,7 +124,7 @@ export class MoveTool { showNodeContextMenuAt: ShowContextMenuFunction, ) { return (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => - SkeletonHandlers.openContextMenu( + SkeletonHandlers.handleOpenContextMenu( planeView, pos, plane, @@ -153,6 +156,8 @@ export class MoveTool { rightClick: "Context Menu", }; } + + static onToolDeselected() {} } export class SkeletonTool { @@ -302,6 +307,8 @@ export class SkeletonTool { rightClick: useLegacyBindings && !shiftKey ? "Place Node" : "Context Menu", }; } + + static onToolDeselected() {} } export class DrawTool { @@ -388,7 +395,7 @@ export class DrawTool { return; } - SkeletonHandlers.openContextMenu( + SkeletonHandlers.handleOpenContextMenu( planeView, pos, plane, @@ -423,6 +430,8 @@ export class DrawTool { rightClick, }; } + + static onToolDeselected() {} } export class EraseTool { @@ -445,7 +454,7 @@ export class EraseTool { }, rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => { - SkeletonHandlers.openContextMenu( + SkeletonHandlers.handleOpenContextMenu( planeView, pos, plane, @@ -473,6 +482,8 @@ export class EraseTool { rightClick: "Context Menu", }; } + + static onToolDeselected() {} } export class PickCellTool { @@ -496,6 +507,8 @@ export class PickCellTool { rightClick: "Context Menu", }; } + + static onToolDeselected() {} } export class FillCellTool { @@ -528,11 +541,95 @@ export class FillCellTool { rightClick: "Context Menu", }; } + + static onToolDeselected() {} +} + +export class BoundingBoxTool { + static getPlaneMouseControls( + planeId: OrthoView, + planeView: PlaneView, + showNodeContextMenuAt: ShowContextMenuFunction, + ): * { + let primarySelectedEdge: ?SelectedEdge = null; + let secondarySelectedEdge: ?SelectedEdge = null; + return { + leftDownMove: (delta: Point2, pos: Point2, _id: ?string, _event: MouseEvent) => { + if (primarySelectedEdge != null) { + handleResizingBoundingBox(pos, planeId, primarySelectedEdge, secondarySelectedEdge); + } else { + MoveHandlers.handleMovePlane(delta); + } + }, + leftMouseDown: (pos: Point2, _plane: OrthoView, _event: MouseEvent) => { + const hoveredEdgesInfo = getClosestHoveredBoundingBox(pos, planeId); + if (hoveredEdgesInfo) { + [primarySelectedEdge, secondarySelectedEdge] = hoveredEdgesInfo; + getSceneController().highlightUserBoundingBox(primarySelectedEdge.boxId); + } + }, + + leftMouseUp: () => { + if (primarySelectedEdge) { + Store.dispatch(finishedResizingUserBoundingBoxAction(primarySelectedEdge.boxId)); + } + primarySelectedEdge = null; + secondarySelectedEdge = null; + getSceneController().highlightUserBoundingBox(null); + }, + + mouseMove: (delta: Point2, position: Point2, _id, event: MouseEvent) => { + if (primarySelectedEdge == null && planeId !== OrthoViews.TDView) { + MoveHandlers.moveWhenAltIsPressed(delta, position, _id, event); + highlightAndSetCursorOnHoveredBoundingBox(delta, position, planeId); + } + }, + + rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => { + SkeletonHandlers.handleOpenContextMenu( + planeView, + pos, + plane, + isTouch, + event, + showNodeContextMenuAt, + ); + }, + }; + } + + static getActionDescriptors( + _activeTool: AnnotationTool, + _useLegacyBindings: boolean, + _shiftKey: boolean, + _ctrlKey: boolean, + _altKey: boolean, + ): Object { + return { + leftDrag: "Resize Bounding Boxes", + rightClick: "Context Menu", + }; + } + + static onToolDeselected() { + const { body } = document; + if (body == null) { + return; + } + for (const planeId of OrthoViewValuesWithoutTDView) { + const inputCatcher = document.getElementById(`inputcatcher_${planeId}`); + if (inputCatcher) { + inputCatcher.style.cursor = "auto"; + } + } + getSceneController().highlightUserBoundingBox(null); + } } const toolToToolClass = { [AnnotationToolEnum.MOVE]: MoveTool, [AnnotationToolEnum.SKELETON]: SkeletonTool, + [AnnotationToolEnum.BOUNDING_BOX]: BoundingBoxTool, [AnnotationToolEnum.BRUSH]: DrawTool, [AnnotationToolEnum.TRACE]: DrawTool, [AnnotationToolEnum.ERASE_TRACE]: EraseTool, diff --git a/frontend/javascripts/oxalis/controller/scene_controller.js b/frontend/javascripts/oxalis/controller/scene_controller.js index 98f2624c61f..d3e349b2f38 100644 --- a/frontend/javascripts/oxalis/controller/scene_controller.js +++ b/frontend/javascripts/oxalis/controller/scene_controller.js @@ -54,6 +54,7 @@ class SceneController { datasetBoundingBox: Cube; userBoundingBoxGroup: typeof THREE.Group; userBoundingBoxes: Array; + highlightedBBoxId: ?number; taskBoundingBox: ?Cube; contour: ContourGeometry; planes: OrthoViewMap; @@ -99,6 +100,7 @@ class SceneController { this.rootGroup.add(this.getRootNode()); this.isosurfacesRootGroup = new THREE.Group(); this.meshesRootGroup = new THREE.Group(); + this.highlightedBBoxId = null; // The dimension(s) with the highest resolution will not be distorted this.rootGroup.scale.copy(new THREE.Vector3(...Store.getState().dataset.dataSource.scale)); @@ -285,6 +287,7 @@ class SceneController { max: upperBoundary, color: CUBE_COLOR, showCrossSections: true, + isHighlighted: false, }); this.datasetBoundingBox.getMeshes().forEach(mesh => this.rootNode.add(mesh)); @@ -331,6 +334,7 @@ class SceneController { max: taskBoundingBox.max, color: 0x00ff00, showCrossSections: true, + isHighlighted: false, }); this.taskBoundingBox.getMeshes().forEach(mesh => this.rootNode.add(mesh)); if (constants.MODES_ARBITRARY.includes(viewMode)) { @@ -449,7 +453,7 @@ class SceneController { setUserBoundingBoxes(bboxes: Array): void { const newUserBoundingBoxGroup = new THREE.Group(); - this.userBoundingBoxes = bboxes.map(({ boundingBox, isVisible, color }) => { + this.userBoundingBoxes = bboxes.map(({ boundingBox, isVisible, color, id }) => { const { min, max } = boundingBox; const bbColor = [color[0] * 255, color[1] * 255, color[2] * 255]; const bbCube = new Cube({ @@ -457,6 +461,8 @@ class SceneController { max, color: Utils.rgbToInt(bbColor), showCrossSections: true, + id, + isHighlighted: this.highlightedBBoxId === id, }); bbCube.setVisibility(isVisible); bbCube.getMeshes().forEach(mesh => newUserBoundingBoxGroup.add(mesh)); @@ -467,6 +473,25 @@ class SceneController { this.rootNode.add(this.userBoundingBoxGroup); } + highlightUserBoundingBox(bboxId: ?number): void { + if (this.highlightedBBoxId === bboxId) { + return; + } + const setIsHighlighted = (id: number, isHighlighted: boolean) => { + const bboxToChangeHighlighting = this.userBoundingBoxes.find(bbCube => bbCube.id === id); + if (bboxToChangeHighlighting != null) { + bboxToChangeHighlighting.setIsHighlighted(isHighlighted); + } + }; + if (this.highlightedBBoxId != null) { + setIsHighlighted(this.highlightedBBoxId, false); + } + if (bboxId != null) { + setIsHighlighted(bboxId, true); + } + this.highlightedBBoxId = bboxId; + } + setSkeletonGroupVisibility(isVisible: boolean) { if (this.skeleton) { this.skeleton.getRootGroup().visible = isVisible; diff --git a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js index f9eccca2e64..a0c9bef079a 100644 --- a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js +++ b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.js @@ -17,6 +17,7 @@ import { toggleAllTreesAction, toggleInactiveTreesAction, } from "oxalis/model/actions/skeletontracing_actions"; +import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import { InputKeyboard, InputKeyboardNoLoop, InputMouse } from "libs/input"; import { document } from "libs/window"; import { getBaseVoxel } from "oxalis/model/scaleinfo"; @@ -42,6 +43,7 @@ import { EraseTool, PickCellTool, FillCellTool, + BoundingBoxTool, } from "oxalis/controller/combinations/tool_controls"; import constants, { type ShowContextMenuFunction, @@ -75,6 +77,10 @@ function ensureNonConflictingHandlers(skeletonControls: Object, volumeControls: type OwnProps = {| showContextMenuAt: ShowContextMenuFunction |}; +const cycleTools = () => { + Store.dispatch(cycleToolAction()); +}; + type StateProps = {| tracing: Tracing, activeTool: AnnotationTool, @@ -127,9 +133,7 @@ class VolumeKeybindings { static getKeyboardControls() { return { c: () => Store.dispatch(createCellAction()), - "1": () => { - Store.dispatch(cycleToolAction()); - }, + "1": cycleTools, v: () => { Store.dispatch(copySegmentationLayerAction()); }, @@ -140,6 +144,14 @@ class VolumeKeybindings { } } +class BoundingBoxKeybindings { + static getKeyboardControls() { + return { + c: () => Store.dispatch(addUserBoundingBoxAction()), + }; + } +} + const getMoveValue = timeFactor => { const state = Store.getState(); return ( @@ -287,6 +299,11 @@ class PlaneController extends React.PureComponent { ); const fillCellControls = FillCellTool.getPlaneMouseControls(planeId); const pickCellControls = PickCellTool.getPlaneMouseControls(planeId); + const boundingBoxControls = BoundingBoxTool.getPlaneMouseControls( + planeId, + this.planeView, + this.props.showContextMenuAt, + ); const allControlKeys = _.union( Object.keys(moveControls), @@ -295,6 +312,7 @@ class PlaneController extends React.PureComponent { Object.keys(eraseControls), Object.keys(fillCellControls), Object.keys(pickCellControls), + Object.keys(boundingBoxControls), ); const controls = {}; @@ -308,6 +326,7 @@ class PlaneController extends React.PureComponent { [AnnotationToolEnum.ERASE_TRACE]: eraseControls[controlKey], [AnnotationToolEnum.PICK_CELL]: pickCellControls[controlKey], [AnnotationToolEnum.FILL_CELL]: fillCellControls[controlKey], + [AnnotationToolEnum.BOUNDING_BOX]: boundingBoxControls[controlKey], }); } @@ -359,9 +378,7 @@ class PlaneController extends React.PureComponent { h: () => this.changeMoveValue(25), g: () => this.changeMoveValue(-25), - w: () => { - Store.dispatch(cycleToolAction()); - }, + w: cycleTools, ...loopedKeyboardControls, }, { @@ -424,6 +441,8 @@ class PlaneController extends React.PureComponent { ? VolumeKeybindings.getKeyboardControls() : emptyDefaultHandler; + const { c: boundingBoxCHandler } = BoundingBoxKeybindings.getKeyboardControls(); + ensureNonConflictingHandlers(skeletonControls, volumeControls); return { @@ -431,7 +450,11 @@ class PlaneController extends React.PureComponent { ...skeletonControls, // $FlowIssue[exponential-spread] See https://github.com/facebook/flow/issues/8299 ...volumeControls, - c: this.createToolDependentKeyboardHandler(skeletonCHandler, volumeCHandler), + c: this.createToolDependentKeyboardHandler( + skeletonCHandler, + volumeCHandler, + boundingBoxCHandler, + ), "1": this.createToolDependentKeyboardHandler(skeletonOneHandler, volumeOneHandler), }; } @@ -505,28 +528,42 @@ class PlaneController extends React.PureComponent { createToolDependentKeyboardHandler( skeletonHandler: ?Function, volumeHandler: ?Function, + boundingBoxHandler: ?Function, viewHandler?: ?Function, ): Function { return (...args) => { const tool = this.props.activeTool; - if (tool === AnnotationToolEnum.MOVE) { - if (viewHandler != null) { - viewHandler(...args); - } else if (skeletonHandler != null) { - skeletonHandler(...args); + switch (tool) { + case AnnotationToolEnum.MOVE: { + if (viewHandler != null) { + viewHandler(...args); + } else if (skeletonHandler != null) { + skeletonHandler(...args); + } + return; } - } else if (tool === AnnotationToolEnum.SKELETON) { - if (skeletonHandler != null) { - skeletonHandler(...args); - } else if (viewHandler != null) { - viewHandler(...args); + case AnnotationToolEnum.SKELETON: { + if (skeletonHandler != null) { + skeletonHandler(...args); + } else if (viewHandler != null) { + viewHandler(...args); + } + return; + } + case AnnotationToolEnum.BOUNDING_BOX: { + if (boundingBoxHandler != null) { + boundingBoxHandler(...args); + } else if (viewHandler != null) { + viewHandler(...args); + } + return; } - } else { - // eslint-disable-next-line no-lonely-if - if (volumeHandler != null) { - volumeHandler(...args); - } else if (viewHandler != null) { - viewHandler(...args); + default: { + if (volumeHandler != null) { + volumeHandler(...args); + } else if (viewHandler != null) { + viewHandler(...args); + } } } }; diff --git a/frontend/javascripts/oxalis/geometries/cube.js b/frontend/javascripts/oxalis/geometries/cube.js index eb4bb35df11..cfa94f8ea20 100644 --- a/frontend/javascripts/oxalis/geometries/cube.js +++ b/frontend/javascripts/oxalis/geometries/cube.js @@ -25,38 +25,42 @@ type Properties = { lineWidth?: number, color?: number, showCrossSections?: boolean, + id?: number, + isHighlighted: boolean, }; class Cube { crossSections: OrthoViewMap; + cube: typeof THREE.Line; min: Vector3; max: Vector3; showCrossSections: boolean; initialized: boolean; visible: boolean; + lineWidth: number; + color: number; + id: ?number; + isHighlighted: boolean; constructor(properties: Properties) { // min/max should denote a half-open interval. this.min = properties.min || [0, 0, 0]; this.max = properties.max; - const lineWidth = properties.lineWidth != null ? properties.lineWidth : 1; - const color = properties.color || 0x000000; + this.lineWidth = properties.lineWidth != null ? properties.lineWidth : 1; + this.color = properties.color || 0x000000; this.showCrossSections = properties.showCrossSections || false; + this.id = properties.id; this.initialized = false; this.visible = true; + this.isHighlighted = properties.isHighlighted; - const lineProperties = { color, linewidth: lineWidth }; - - this.cube = new THREE.Line(new THREE.Geometry(), new THREE.LineBasicMaterial(lineProperties)); + this.cube = new THREE.Line(new THREE.Geometry(), this.getLineMaterial()); this.crossSections = {}; for (const planeId of OrthoViewValuesWithoutTDView) { - this.crossSections[planeId] = new THREE.Line( - new THREE.Geometry(), - new THREE.LineBasicMaterial(lineProperties), - ); + this.crossSections[planeId] = new THREE.Line(new THREE.Geometry(), this.getLineMaterial()); } if (this.min != null && this.max != null) { @@ -69,6 +73,12 @@ class Cube { ); } + getLineMaterial() { + return this.isHighlighted + ? new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: this.lineWidth }) + : new THREE.LineBasicMaterial({ color: this.color, linewidth: this.lineWidth }); + } + setCorners(min: Vector3, max: Vector3) { this.min = min; this.max = max; @@ -136,10 +146,9 @@ class Cube { if (!this.initialized) { return; } - for (const planeId of OrthoViewValuesWithoutTDView) { const thirdDim = dimensions.thirdDimensionForPlane(planeId); - const geometry = this.crossSections[planeId].geometry; + const { geometry } = this.crossSections[planeId]; for (const vertex of geometry.vertices) { const array = vertex.toArray(); array[thirdDim] = position[thirdDim]; @@ -155,6 +164,17 @@ class Cube { return [this.cube].concat(_.values(this.crossSections)); } + setIsHighlighted(highlighted: boolean) { + if (highlighted === this.isHighlighted) { + return; + } + this.isHighlighted = highlighted; + this.getMeshes().forEach(mesh => { + mesh.material = this.getLineMaterial(); + }); + app.vent.trigger("rerender"); + } + updateForCam(id: OrthoView) { if (!this.initialized) { return; diff --git a/frontend/javascripts/oxalis/model/accessors/tool_accessor.js b/frontend/javascripts/oxalis/model/accessors/tool_accessor.js index 83aaf1f74b2..8bc6f037bb3 100644 --- a/frontend/javascripts/oxalis/model/accessors/tool_accessor.js +++ b/frontend/javascripts/oxalis/model/accessors/tool_accessor.js @@ -63,11 +63,12 @@ function _getDisabledInfoWhenVolumeIsDisabled( isDisabled: true, explanation: genericDisabledExplanation, }; + const notDisabledInfo = { + isDisabled: false, + explanation: "", + }; return { - [AnnotationToolEnum.MOVE]: { - isDisabled: false, - explanation: "", - }, + [AnnotationToolEnum.MOVE]: notDisabledInfo, [AnnotationToolEnum.SKELETON]: { isDisabled: !hasSkeleton, explanation: disabledSkeletonExplanation, @@ -78,6 +79,7 @@ function _getDisabledInfoWhenVolumeIsDisabled( [AnnotationToolEnum.ERASE_TRACE]: disabledInfo, [AnnotationToolEnum.FILL_CELL]: disabledInfo, [AnnotationToolEnum.PICK_CELL]: disabledInfo, + [AnnotationToolEnum.BOUNDING_BOX]: notDisabledInfo, }; } const getDisabledInfoWhenVolumeIsDisabled = memoizeOne(_getDisabledInfoWhenVolumeIsDisabled); @@ -122,6 +124,10 @@ function _getDisabledInfoFromArgs( isDisabled: false, explanation: genericDisabledExplanation, }, + [AnnotationToolEnum.BOUNDING_BOX]: { + isDisabled: false, + explanation: disabledSkeletonExplanation, + }, }; } const getDisabledInfoFromArgs = memoizeOne(_getDisabledInfoFromArgs); diff --git a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.js b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.js index 723659a7c12..ef5caefbac8 100644 --- a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.js +++ b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.js @@ -9,9 +9,9 @@ import type { import { TracingTypeEnum } from "types/api_flow_types"; import type { Tracing, VolumeTracing, SkeletonTracing, ReadOnlyTracing } from "oxalis/store"; -export function getSomeTracing( +export function maybeGetSomeTracing( tracing: Tracing, -): SkeletonTracing | VolumeTracing | ReadOnlyTracing { +): SkeletonTracing | VolumeTracing | ReadOnlyTracing | null { if (tracing.skeleton != null) { return tracing.skeleton; } else if (tracing.volume != null) { @@ -19,7 +19,17 @@ export function getSomeTracing( } else if (tracing.readOnly != null) { return tracing.readOnly; } - throw new Error("The active annotation does not contain skeletons nor volume data"); + return null; +} + +export function getSomeTracing( + tracing: Tracing, +): SkeletonTracing | VolumeTracing | ReadOnlyTracing { + const maybeSomeTracing = maybeGetSomeTracing(tracing); + if (maybeSomeTracing == null) { + throw new Error("The active annotation does not contain skeletons nor volume data"); + } + return maybeSomeTracing; } export function getSomeServerTracing( diff --git a/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.js b/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.js index 0da3634cd6b..c1328afec50 100644 --- a/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.js +++ b/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.js @@ -1,7 +1,7 @@ // @flow import memoizeOne from "memoize-one"; - +import _ from "lodash"; import { type OxalisState } from "oxalis/store"; import constants, { ArbitraryViewport, @@ -10,12 +10,18 @@ import constants, { type Rect, type Viewport, OrthoViews, + type OrthoView, type Point2, type Vector3, + OrthoViewValuesWithoutTDView, type ViewMode, } from "oxalis/constants"; +import { V3 } from "libs/mjs"; import { getBaseVoxelFactors } from "oxalis/model/scaleinfo"; -import { getPosition, getPlaneScalingFactor } from "oxalis/model/accessors/flycam_accessor"; +import { + getPosition, + getPlaneExtentInVoxelFromStore, +} from "oxalis/model/accessors/flycam_accessor"; import { reuseInstanceOnEquality } from "oxalis/model/accessors/accessor_helpers"; export function getTDViewportSize(state: OxalisState): [number, number] { @@ -77,7 +83,7 @@ export function getInputCatcherAspectRatio(state: OxalisState, viewport: Viewpor // // example I: // // 16 : 9 --> 1.78 -// // useWdth: false +// // useWidth: false // const useWidth = false; // const scaledWidth = width * (useWidth ? 1 : aspectRatio); @@ -97,19 +103,21 @@ export function getViewportScale(state: OxalisState, viewport: Viewport): [numbe return [xScale, yScale]; } -function _calculateMaybeGlobalPos(state: OxalisState, clickPos: Point2): ?Vector3 { +function _calculateMaybeGlobalPos( + state: OxalisState, + clickPos: Point2, + planeId: ?OrthoView, +): ?Vector3 { let position; - const { activeViewport } = state.viewModeData.plane; + planeId = planeId || state.viewModeData.plane.activeViewport; const curGlobalPos = getPosition(state.flycam); - const zoomFactors = getPlaneScalingFactor(state, state.flycam, activeViewport); - const viewportScale = getViewportScale(state, activeViewport); const planeRatio = getBaseVoxelFactors(state.dataset.dataSource.scale); - - const center = [0, 1].map(dim => (constants.VIEWPORT_WIDTH * viewportScale[dim]) / 2); - const diffX = ((center[0] - clickPos.x) / viewportScale[0]) * zoomFactors[0]; - const diffY = ((center[1] - clickPos.y) / viewportScale[1]) * zoomFactors[1]; - - switch (activeViewport) { + const { width, height } = getInputCatcherRect(state, planeId); + // Subtract clickPos from only half of the viewport extent as + // the center of the viewport / the flycam position is used as a reference point. + const diffX = (width / 2 - clickPos.x) * state.flycam.zoomStep; + const diffY = (height / 2 - clickPos.y) * state.flycam.zoomStep; + switch (planeId) { case OrthoViews.PLANE_XY: position = [ curGlobalPos[0] - diffX * planeRatio[0], @@ -138,8 +146,8 @@ function _calculateMaybeGlobalPos(state: OxalisState, clickPos: Point2): ?Vector return position; } -function _calculateGlobalPos(state: OxalisState, clickPos: Point2): Vector3 { - const position = _calculateMaybeGlobalPos(state, clickPos); +function _calculateGlobalPos(state: OxalisState, clickPos: Point2, planeId: ?OrthoView): Vector3 { + const position = _calculateMaybeGlobalPos(state, clickPos, planeId); if (!position) { console.error("Trying to calculate the global position, but no data viewport is active."); return [0, 0, 0]; @@ -148,6 +156,33 @@ function _calculateGlobalPos(state: OxalisState, clickPos: Point2): Vector3 { return position; } +export function getDisplayedDataExtentInPlaneMode(state: OxalisState) { + const planeRatio = getBaseVoxelFactors(state.dataset.dataSource.scale); + const curGlobalCenterPos = getPosition(state.flycam); + const extents = OrthoViewValuesWithoutTDView.map(orthoView => + getPlaneExtentInVoxelFromStore(state, state.flycam.zoomStep, orthoView), + ); + const [xyExtent, yzExtent, xzExtent] = extents; + const minExtent = 1; + const getMinExtent = (val1, val2) => _.min([val1, val2].filter(v => v >= minExtent)) || minExtent; + const xMinExtent = getMinExtent(xyExtent[0], xzExtent[0]) * planeRatio[0]; + const yMinExtent = getMinExtent(xyExtent[1], yzExtent[1]) * planeRatio[1]; + const zMinExtent = getMinExtent(xzExtent[1], yzExtent[0]) * planeRatio[2]; + // The new bounding box should cover half of what is displayed in the viewports. + // As the flycam position is taken as a center, the factor is halved again, resulting in a 0.25. + const extentFactor = 0.25; + const halfBoxExtent = [ + Math.max(xMinExtent * extentFactor, 1), + Math.max(yMinExtent * extentFactor, 1), + Math.max(zMinExtent * extentFactor, 1), + ]; + return { + min: V3.toArray(V3.round(V3.sub(curGlobalCenterPos, halfBoxExtent))), + max: V3.toArray(V3.round(V3.add(curGlobalCenterPos, halfBoxExtent))), + halfBoxExtent, + }; +} + export const calculateMaybeGlobalPos = reuseInstanceOnEquality(_calculateMaybeGlobalPos); export const calculateGlobalPos = reuseInstanceOnEquality(_calculateGlobalPos); diff --git a/frontend/javascripts/oxalis/model/actions/annotation_actions.js b/frontend/javascripts/oxalis/model/actions/annotation_actions.js index a7c731d5606..fd854835d2e 100644 --- a/frontend/javascripts/oxalis/model/actions/annotation_actions.js +++ b/frontend/javascripts/oxalis/model/actions/annotation_actions.js @@ -7,7 +7,11 @@ import type { APIAnnotationVisibility, } from "types/api_flow_types"; import type { Vector3 } from "oxalis/constants"; -import type { UserBoundingBox } from "oxalis/store"; +import type { + UserBoundingBox, + UserBoundingBoxWithoutId, + UserBoundingBoxWithoutIdMaybe, +} from "oxalis/store"; type InitializeAnnotationAction = { type: "INITIALIZE_ANNOTATION", @@ -39,11 +43,35 @@ type SetUserBoundingBoxesAction = { userBoundingBoxes: Array, }; +type FinishedResizingUserBoundingBoxAction = { + type: "FINISHED_RESIZING_USER_BOUNDING_BOX", + id: number, +}; + type AddUserBoundingBoxesAction = { type: "ADD_USER_BOUNDING_BOXES", userBoundingBoxes: Array, }; +type AddNewUserBoundingBox = { + type: "ADD_NEW_USER_BOUNDING_BOX", + newBoundingBox?: ?UserBoundingBoxWithoutId, + // Center is the passed position that the new bounding box should have as a center. + // If no center is given, the flycam center will be taken. + center?: Vector3, +}; + +type ChangeUserBoundingBoxAction = { + type: "CHANGE_USER_BOUNDING_BOX", + id: number, + newProps: UserBoundingBoxWithoutIdMaybe, +}; + +type DeleteUserBoundingBox = { + type: "DELETE_USER_BOUNDING_BOX", + id: number, +}; + export type UpdateRemoteMeshMetaDataAction = { type: "UPDATE_REMOTE_MESH_METADATA", id: string, @@ -149,6 +177,10 @@ export type AnnotationActionTypes = | SetAnnotationAllowUpdateAction | UpdateRemoteMeshMetaDataAction | SetUserBoundingBoxesAction + | ChangeUserBoundingBoxAction + | FinishedResizingUserBoundingBoxAction + | AddNewUserBoundingBox + | DeleteUserBoundingBox | AddUserBoundingBoxesAction | AddMeshMetadataAction | DeleteMeshAction @@ -168,6 +200,21 @@ export type AnnotationActionTypes = | RemoveIsosurfaceAction | AddIsosurfaceAction; +export type UserBoundingBoxAction = + | SetUserBoundingBoxesAction + | AddNewUserBoundingBox + | DeleteUserBoundingBox + | AddUserBoundingBoxesAction; + +export const AllUserBoundingBoxActions = [ + "SET_USER_BOUNDING_BOXES", + "ADD_NEW_USER_BOUNDING_BOX", + "CHANGE_USER_BOUNDING_BOX", + "FINISHED_RESIZING_USER_BOUNDING_BOX", + "DELETE_USER_BOUNDING_BOX", + "ADD_USER_BOUNDING_BOXES", +]; + export const initializeAnnotationAction = ( annotation: APIAnnotation, ): InitializeAnnotationAction => ({ @@ -210,6 +257,36 @@ export const setUserBoundingBoxesAction = ( userBoundingBoxes, }); +export const changeUserBoundingBoxAction = ( + id: number, + newProps: UserBoundingBoxWithoutIdMaybe, +): ChangeUserBoundingBoxAction => ({ + type: "CHANGE_USER_BOUNDING_BOX", + id, + newProps, +}); + +export const finishedResizingUserBoundingBoxAction = ( + id: number, +): FinishedResizingUserBoundingBoxAction => ({ + type: "FINISHED_RESIZING_USER_BOUNDING_BOX", + id, +}); + +export const addUserBoundingBoxAction = ( + newBoundingBox?: ?UserBoundingBoxWithoutId, + center?: Vector3, +): AddNewUserBoundingBox => ({ + type: "ADD_NEW_USER_BOUNDING_BOX", + newBoundingBox, + center, +}); + +export const deleteUserBoundingBoxAction = (id: number): DeleteUserBoundingBox => ({ + type: "DELETE_USER_BOUNDING_BOX", + id, +}); + export const addUserBoundingBoxesAction = ( userBoundingBoxes: Array, ): AddUserBoundingBoxesAction => ({ diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js index eb62535e045..5412e46568c 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js @@ -18,6 +18,7 @@ import Store, { } from "oxalis/store"; import messages from "messages"; import renderIndependently from "libs/render_independently"; +import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; type InitializeSkeletonTracingAction = { type: "INITIALIZE_SKELETONTRACING", @@ -198,8 +199,6 @@ export const SkeletonTracingSaveRelevantActions = [ "SHUFFLE_ALL_TREE_COLORS", "CREATE_COMMENT", "DELETE_COMMENT", - "SET_USER_BOUNDING_BOXES", - "ADD_USER_BOUNDING_BOXES", "SET_TREE_GROUPS", "SET_TREE_GROUP", "SET_MERGER_MODE_ENABLED", @@ -210,6 +209,7 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_TREE_COLOR", // Composited actions, only dispatched using `batchActions` "DELETE_GROUP_AND_TREES", + ...AllUserBoundingBoxActions, ]; const noAction = (): NoAction => ({ diff --git a/frontend/javascripts/oxalis/model/actions/ui_actions.js b/frontend/javascripts/oxalis/model/actions/ui_actions.js index df97a412938..adfbcee7fa0 100644 --- a/frontend/javascripts/oxalis/model/actions/ui_actions.js +++ b/frontend/javascripts/oxalis/model/actions/ui_actions.js @@ -3,9 +3,12 @@ import type { AnnotationTool } from "oxalis/constants"; import { type BorderOpenStatus, type Theme } from "oxalis/store"; -type SetToolAction = { type: "SET_TOOL", tool: AnnotationTool }; +export type SetToolAction = { + type: "SET_TOOL", + tool: AnnotationTool, +}; -type CycleToolAction = { type: "CYCLE_TOOL" }; +export type CycleToolAction = { type: "CYCLE_TOOL" }; type SetDropzoneModalVisibilityAction = { type: "SET_DROPZONE_MODAL_VISIBILITY", diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js index 037c00be5e7..9d6c5db6a74 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.js @@ -8,6 +8,7 @@ import type { BucketDataArray } from "oxalis/model/bucket_data_handling/bucket"; import type { Segment, SegmentMap } from "oxalis/store"; import Deferred from "libs/deferred"; import { type Dispatch } from "redux"; +import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; type InitializeVolumeTracingAction = { type: "INITIALIZE_VOLUMETRACING", @@ -102,11 +103,10 @@ export type VolumeTracingAction = export const VolumeTracingSaveRelevantActions = [ "CREATE_CELL", "SET_ACTIVE_CELL", - "SET_USER_BOUNDING_BOXES", - "ADD_USER_BOUNDING_BOXES", "FINISH_ANNOTATION_STROKE", "UPDATE_SEGMENT", "SET_SEGMENTS", + ...AllUserBoundingBoxActions, ]; export const VolumeTracingUndoRelevantActions = ["START_EDITING", "COPY_SEGMENTATION_LAYER"]; diff --git a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.js b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.js index 54bb0d553be..40e17a03c61 100644 --- a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.js @@ -4,12 +4,16 @@ import update from "immutability-helper"; import type { Action } from "oxalis/model/actions/actions"; import type { OxalisState, UserBoundingBox } from "oxalis/store"; +import { V3 } from "libs/mjs"; import { type StateShape1, updateKey, updateKey2, updateKey4, } from "oxalis/model/helpers/deep_update"; +import { maybeGetSomeTracing } from "oxalis/model/accessors/tracing_accessor"; +import * as Utils from "libs/utils"; +import { getDisplayedDataExtentInPlaneMode } from "oxalis/model/accessors/view_mode_accessor"; import { convertServerAnnotationToFrontendAnnotation } from "oxalis/model/reducers/reducer_helpers"; const updateTracing = (state: OxalisState, shape: StateShape1<"tracing">): OxalisState => @@ -71,16 +75,64 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { return updateUserBoundingBoxes(state, action.userBoundingBoxes); } + case "CHANGE_USER_BOUNDING_BOX": { + const tracing = maybeGetSomeTracing(state.tracing); + if (tracing == null) { + return state; + } + const updatedUserBoundingBoxes = tracing.userBoundingBoxes.map(bbox => + bbox.id === action.id + ? { + id: bbox.id, + ...bbox, + ...action.newProps, + } + : bbox, + ); + return updateUserBoundingBoxes(state, updatedUserBoundingBoxes); + } + + case "ADD_NEW_USER_BOUNDING_BOX": { + const tracing = maybeGetSomeTracing(state.tracing); + if (tracing == null) { + return state; + } + const { userBoundingBoxes } = tracing; + const highestBoundingBoxId = Math.max(0, ...userBoundingBoxes.map(bb => bb.id)); + const boundingBoxId = highestBoundingBoxId + 1; + let newBoundingBox: UserBoundingBox; + if (action.newBoundingBox != null) { + newBoundingBox = ({ id: boundingBoxId, ...action.newBoundingBox }: UserBoundingBox); + } else { + const { min, max, halfBoxExtent } = getDisplayedDataExtentInPlaneMode(state); + newBoundingBox = { + boundingBox: { min, max }, + id: boundingBoxId, + name: `Bounding box ${boundingBoxId}`, + color: Utils.getRandomColor(), + isVisible: true, + }; + if (action.center != null) { + newBoundingBox.boundingBox = { + min: V3.toArray(V3.round(V3.sub(action.center, halfBoxExtent))), + max: V3.toArray(V3.round(V3.add(action.center, halfBoxExtent))), + }; + } + } + const updatedUserBoundingBoxes = [...userBoundingBoxes, newBoundingBox]; + return updateUserBoundingBoxes(state, updatedUserBoundingBoxes); + } + case "ADD_USER_BOUNDING_BOXES": { - const tracing = state.tracing.skeleton || state.tracing.volume || state.tracing.readOnly; + const tracing = maybeGetSomeTracing(state.tracing); if (tracing == null) { return state; } - let highestBoundingBoxId = Math.max(-1, ...tracing.userBoundingBoxes.map(bb => bb.id)); - const additionalUserBoundingBoxes = action.userBoundingBoxes.map(bb => { - highestBoundingBoxId++; - return { ...bb, id: highestBoundingBoxId }; - }); + const highestBoundingBoxId = Math.max(0, ...tracing.userBoundingBoxes.map(bb => bb.id)); + const additionalUserBoundingBoxes = action.userBoundingBoxes.map((bb, index) => ({ + ...bb, + id: highestBoundingBoxId + index + 1, + })); const mergedUserBoundingBoxes = [ ...tracing.userBoundingBoxes, ...additionalUserBoundingBoxes, @@ -88,7 +140,17 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { return updateUserBoundingBoxes(state, mergedUserBoundingBoxes); } - case "UPDATE_LOCAL_MESH_METADATA": + case "DELETE_USER_BOUNDING_BOX": { + const tracing = maybeGetSomeTracing(state.tracing); + if (tracing == null) { + return state; + } + const updatedUserBoundingBoxes = tracing.userBoundingBoxes.filter( + bbox => bbox.id !== action.id, + ); + return updateUserBoundingBoxes(state, updatedUserBoundingBoxes); + } + case "UPDATE_REMOTE_MESH_METADATA": { const { id, meshShape } = action; const newMeshes = state.tracing.meshes.map(mesh => { diff --git a/frontend/javascripts/oxalis/model/reducers/reducer_helpers.js b/frontend/javascripts/oxalis/model/reducers/reducer_helpers.js index 54eb1e0c4c7..79f6d936f5e 100644 --- a/frontend/javascripts/oxalis/model/reducers/reducer_helpers.js +++ b/frontend/javascripts/oxalis/model/reducers/reducer_helpers.js @@ -15,9 +15,11 @@ import type { OxalisState, } from "oxalis/store"; import type { Boundary } from "oxalis/model/accessors/dataset_accessor"; +import { AnnotationToolEnum } from "oxalis/constants"; import type { BoundingBoxType, AnnotationTool } from "oxalis/constants"; import { V3 } from "libs/mjs"; import * as Utils from "libs/utils"; +import { getDisabledInfoForTools } from "oxalis/model/accessors/tool_accessor"; import { isVolumeTool, isVolumeAnnotationDisallowedForZoom, @@ -53,7 +55,7 @@ export function convertUserBoundingBoxesFromServerToFrontend( boundingBox: convertedBoundingBox, color: color ? Utils.colorObjectToRGBArray(color) : Utils.getRandomColor(), id, - name: name || `user bounding box ${id}`, + name: name || `Bounding box ${id}`, isVisible: isVisible != null ? isVisible : true, }; }); @@ -135,6 +137,25 @@ export function convertServerAnnotationToFrontendAnnotation(annotation: APIAnnot }; } +export function getNextTool(state: OxalisState): AnnotationTool | null { + const disabledToolInfo = getDisabledInfoForTools(state); + + const tools = Object.keys(AnnotationToolEnum); + const currentToolIndex = tools.indexOf(state.uiInformation.activeTool); + // Search for the next tool which is not disabled. + for ( + let newToolIndex = currentToolIndex + 1; + newToolIndex < currentToolIndex + tools.length; + newToolIndex++ + ) { + const newTool = tools[newToolIndex % tools.length]; + if (!disabledToolInfo[newTool].isDisabled) { + return newTool; + } + } + return null; +} + export function setToolReducer(state: OxalisState, tool: AnnotationTool) { if (tool === state.uiInformation.activeTool) { return state; @@ -142,6 +163,5 @@ export function setToolReducer(state: OxalisState, tool: AnnotationTool) { if (isVolumeTool(tool) && isVolumeAnnotationDisallowedForZoom(tool, state)) { return state; } - return updateKey(state, "uiInformation", { activeTool: tool }); } diff --git a/frontend/javascripts/oxalis/model/reducers/ui_reducer.js b/frontend/javascripts/oxalis/model/reducers/ui_reducer.js index 0cc061e674f..d18e67703bc 100644 --- a/frontend/javascripts/oxalis/model/reducers/ui_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/ui_reducer.js @@ -3,10 +3,8 @@ import type { Action } from "oxalis/model/actions/actions"; import type { OxalisState } from "oxalis/store"; import { updateKey } from "oxalis/model/helpers/deep_update"; -import { setToolReducer } from "oxalis/model/reducers/reducer_helpers"; -import { AnnotationToolEnum } from "oxalis/constants"; +import { setToolReducer, getNextTool } from "oxalis/model/reducers/reducer_helpers"; import { hideBrushReducer } from "oxalis/model/reducers/volumetracing_reducer_helpers"; -import { getDisabledInfoForTools } from "oxalis/model/accessors/tool_accessor"; function UiReducer(state: OxalisState, action: Action): OxalisState { switch (action.type) { @@ -50,25 +48,12 @@ function UiReducer(state: OxalisState, action: Action): OxalisState { if (!state.tracing.restrictions.allowUpdate) { return state; } - - const disabledToolInfo = getDisabledInfoForTools(state); - - const tools = Object.keys(AnnotationToolEnum); - const currentToolIndex = tools.indexOf(state.uiInformation.activeTool); - - // Search for the next tool which is not disabled. - for ( - let newToolIndex = currentToolIndex + 1; - newToolIndex < currentToolIndex + tools.length; - newToolIndex++ - ) { - const newTool = tools[newToolIndex % tools.length]; - if (!disabledToolInfo[newTool].isDisabled) { - return setToolReducer(hideBrushReducer(state), newTool); - } + const nextTool = getNextTool(state); + if (nextTool == null) { + // Don't change the current tool if another tool could not be selected. + return state; } - // Don't change the current tool if another tool could not be selected. - return state; + return setToolReducer(hideBrushReducer(state), nextTool); } case "SET_THEME": { diff --git a/frontend/javascripts/oxalis/model/sagas/annotation_tool_saga.js b/frontend/javascripts/oxalis/model/sagas/annotation_tool_saga.js new file mode 100644 index 00000000000..204b836f412 --- /dev/null +++ b/frontend/javascripts/oxalis/model/sagas/annotation_tool_saga.js @@ -0,0 +1,27 @@ +// @flow +/* eslint-disable import/prefer-default-export */ +import { type Saga, select, take } from "oxalis/model/sagas/effect-generators"; +import type { SetToolAction, CycleToolAction } from "oxalis/model/actions/ui_actions"; +import { getNextTool } from "oxalis/model/reducers/reducer_helpers"; +import { getToolClassForAnnotationTool } from "oxalis/controller/combinations/tool_controls"; + +export function* watchToolDeselection(): Saga { + yield* take("WK_READY"); + let previousTool = yield* select(state => state.uiInformation.activeTool); + while (true) { + const action = ((yield* take(["SET_TOOL", "CYCLE_TOOL"]): any): + | SetToolAction + | CycleToolAction); + const storeState = yield* select(state => state); + let executeDeselect = false; + if (action.type === "SET_TOOL") { + executeDeselect = true; + } else if (getNextTool(storeState) != null) { + executeDeselect = true; + } + if (executeDeselect) { + getToolClassForAnnotationTool(previousTool).onToolDeselected(); + } + previousTool = storeState.uiInformation.activeTool; + } +} diff --git a/frontend/javascripts/oxalis/model/sagas/root_saga.js b/frontend/javascripts/oxalis/model/sagas/root_saga.js index d03c9269856..d164e9f2010 100644 --- a/frontend/javascripts/oxalis/model/sagas/root_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/root_saga.js @@ -14,6 +14,7 @@ import ErrorHandling from "libs/error_handling"; import handleMeshChanges from "oxalis/model/sagas/handle_mesh_changes"; import isosurfaceSaga from "oxalis/model/sagas/isosurface_saga"; import { watchMaximumRenderableLayers } from "oxalis/model/sagas/dataset_saga"; +import { watchToolDeselection } from "oxalis/model/sagas/annotation_tool_saga"; import SettingsSaga from "oxalis/model/sagas/settings_saga"; import watchTasksAsync, { warnAboutMagRestriction } from "oxalis/model/sagas/task_saga"; import HistogramSaga from "oxalis/model/sagas/load_histogram_data_saga"; @@ -47,6 +48,7 @@ function* restartableSaga(): Saga { _call(watchMaximumRenderableLayers), _call(MappingSaga), _call(watchAgglomerateLoading), + _call(watchToolDeselection), ...AnnotationSagas.map(saga => _call(saga)), ...SaveSagas.map(saga => _call(saga)), ...VolumetracingSagas.map(saga => _call(saga)), diff --git a/frontend/javascripts/oxalis/model/sagas/save_saga.js b/frontend/javascripts/oxalis/model/sagas/save_saga.js index acda513f310..fd2c3c3cbbe 100644 --- a/frontend/javascripts/oxalis/model/sagas/save_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/save_saga.js @@ -8,6 +8,7 @@ import Maybe from "data.maybe"; import type { Action } from "oxalis/model/actions/actions"; import { FlycamActions } from "oxalis/model/actions/flycam_actions"; +import { maybeGetSomeTracing } from "oxalis/model/accessors/tracing_accessor"; import { enforceVolumeTracing } from "oxalis/model/accessors/volumetracing_accessor"; import { PUSH_THROTTLE_TIME, @@ -29,6 +30,7 @@ import type { Flycam, SaveQueueEntry, CameraData, + UserBoundingBox, SegmentMap, } from "oxalis/store"; import createProgressCallback from "libs/progress_callback"; @@ -88,12 +90,21 @@ import compactSaveQueue from "oxalis/model/helpers/compaction/compact_save_queue import compactUpdateActions from "oxalis/model/helpers/compaction/compact_update_actions"; import compressLz4Block from "oxalis/workers/byte_array_lz4_compression.worker"; import messages from "messages"; +import { + AllUserBoundingBoxActions, + setUserBoundingBoxesAction, + type UserBoundingBoxAction, +} from "oxalis/model/actions/annotation_actions"; import window, { alert, document, location } from "libs/window"; import { enforceSkeletonTracing } from "../accessors/skeletontracing_accessor"; const byteArrayToLz4Array = createWorker(compressLz4Block); +const UndoRedoRelevantBoundingBoxActions = AllUserBoundingBoxActions.filter( + action => action !== "SET_USER_BOUNDING_BOXES", +); + type UndoBucket = { zoomedBucketAddress: Vector4, data: Uint8Array, @@ -104,13 +115,15 @@ type VolumeUndoBuckets = Array; type VolumeAnnotationBatch = { buckets: VolumeUndoBuckets, segments: SegmentMap }; 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 | WarnUndoState; +type UndoState = SkeletonUndoState | VolumeUndoState | BoundingBoxUndoState | WarnUndoState; type RelevantActionsForUndoRedo = { skeletonUserAction?: SkeletonTracingAction, addBucketToUndoAction?: AddBucketToUndoAction, finishAnnotationStrokeAction?: FinishAnnotationStrokeAction, + userBoundingBoxAction?: UserBoundingBoxAction, importVolumeTracingAction?: ImportVolumeTracingAction, undo?: UndoAction, redo?: RedoAction, @@ -142,15 +155,22 @@ function unpackRelevantActionForUndo(action): RelevantActionsForUndoRedo { return { updateSegment: action, }; - } else if (SkeletonTracingSaveRelevantActions.includes(action.type)) { - return { - skeletonUserAction: ((action: any): SkeletonTracingAction), - }; + } else if (UndoRedoRelevantBoundingBoxActions.includes(action.type)) { + return { userBoundingBoxAction: ((action: any): UserBoundingBoxAction) }; + } + + if (SkeletonTracingSaveRelevantActions.includes(action.type)) { + return { skeletonUserAction: ((action: any): SkeletonTracingAction) }; } throw new Error("Could not unpack redux action from channel"); } +const getUserBoundingBoxesFromState = state => { + const maybeSomeTracing = maybeGetSomeTracing(state.tracing); + return maybeSomeTracing != null ? maybeSomeTracing.userBoundingBoxes : []; +}; + function getNullableSegments(state: OxalisState): ?SegmentMap { if (state.tracing.volume) { return state.tracing.volume.segments; @@ -161,8 +181,10 @@ function getNullableSegments(state: OxalisState): ?SegmentMap { export function* collectUndoStates(): Saga { const undoStack: Array = []; const redoStack: Array = []; + // This variable must be any (no Action) as otherwise cyclic dependencies are created which flow cannot handle. let previousAction: ?any = null; let prevSkeletonTracingOrNull: ?SkeletonTracing = null; + let prevUserBoundingBoxes: Array = []; let pendingCompressions: Array> = []; let currentVolumeUndoBuckets: VolumeUndoBuckets = []; // The copy of the segment list that needs to be added to the next volume undo stack entry. @@ -170,6 +192,7 @@ export function* collectUndoStates(): Saga { yield* take(["INITIALIZE_SKELETONTRACING", "INITIALIZE_VOLUMETRACING"]); prevSkeletonTracingOrNull = yield* select(state => state.tracing.skeleton); + prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); // The SegmentMap is immutable. So, no need to copy. If there's no volume // tracing, prevSegments can remain empty as it's not needed. @@ -177,6 +200,7 @@ export function* collectUndoStates(): Saga { const actionChannel = yield _actionChannel([ ...SkeletonTracingSaveRelevantActions, + ...UndoRedoRelevantBoundingBoxActions, "ADD_BUCKET_TO_UNDO", "FINISH_ANNOTATION_STROKE", "IMPORT_VOLUMETRACING", @@ -184,20 +208,25 @@ export function* collectUndoStates(): Saga { "UNDO", "REDO", ]); + while (true) { const currentAction = yield* take(actionChannel); - const { skeletonUserAction, addBucketToUndoAction, finishAnnotationStrokeAction, + userBoundingBoxAction, importVolumeTracingAction, undo, redo, updateSegment, } = unpackRelevantActionForUndo(currentAction); - - if (skeletonUserAction || addBucketToUndoAction || finishAnnotationStrokeAction) { + if ( + skeletonUserAction || + addBucketToUndoAction || + finishAnnotationStrokeAction || + userBoundingBoxAction + ) { let shouldClearRedoState = addBucketToUndoAction != null || finishAnnotationStrokeAction != null; if (skeletonUserAction && prevSkeletonTracingOrNull != null) { @@ -235,6 +264,17 @@ export function* collectUndoStates(): Saga { prevSegments = segments; currentVolumeUndoBuckets = []; pendingCompressions = []; + } 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. @@ -250,19 +290,29 @@ export function* collectUndoStates(): Saga { ({ type: "warning", reason: messages["undo.import_volume_tracing"] }: WarnUndoState), ); } else if (undo) { - if (undoStack.length > 0 && undoStack[undoStack.length - 1].type === "skeleton") { - previousAction = null; - } - yield* call(applyStateOfStack, undoStack, redoStack, prevSkeletonTracingOrNull, "undo"); + previousAction = null; + yield* call( + applyStateOfStack, + undoStack, + redoStack, + prevSkeletonTracingOrNull, + prevUserBoundingBoxes, + "undo", + ); if (undo.callback != null) { undo.callback(); } yield* put(setBusyBlockingInfoAction(false)); } else if (redo) { - if (redoStack.length > 0 && redoStack[redoStack.length - 1].type === "skeleton") { - previousAction = null; - } - yield* call(applyStateOfStack, redoStack, undoStack, prevSkeletonTracingOrNull, "redo"); + previousAction = null; + yield* call( + applyStateOfStack, + redoStack, + undoStack, + prevSkeletonTracingOrNull, + prevUserBoundingBoxes, + "redo", + ); if (redo.callback != null) { redo.callback(); } @@ -278,13 +328,14 @@ export function* collectUndoStates(): Saga { } // We need the updated tracing here prevSkeletonTracingOrNull = yield* select(state => state.tracing.skeleton); + prevUserBoundingBoxes = yield* select(getUserBoundingBoxesFromState); } } function* getSkeletonTracingToUndoState( skeletonUserAction: SkeletonTracingAction, prevTracing: SkeletonTracing, - previousAction: ?SkeletonTracingAction, + previousAction: ?Action, ): Saga { const curTracing = yield* select(state => enforceSkeletonTracing(state.tracing)); if (curTracing !== prevTracing) { @@ -295,6 +346,26 @@ function* getSkeletonTracingToUndoState( return null; } +function getBoundingBoxToUndoState( + userBoundingBoxAction: UserBoundingBoxAction, + prevUserBoundingBoxes: Array, + previousAction: ?Action, +): ?BoundingBoxUndoState { + const isSameActionOnSameBoundingBox = + previousAction != null && + userBoundingBoxAction.id != null && + previousAction.id != null && + 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, @@ -358,6 +429,7 @@ function* applyStateOfStack( sourceStack: Array, stackToPushTo: Array, prevSkeletonTracingOrNull: ?SkeletonTracing, + prevUserBoundingBoxes: ?Array, direction: "undo" | "redo", ): Saga { if (sourceStack.length <= 0) { @@ -407,6 +479,12 @@ function* applyStateOfStack( 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); } diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js index 673ef60f29e..b70d0b5f183 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.js @@ -14,10 +14,9 @@ import { type ClickSegmentAction, } from "oxalis/model/actions/volumetracing_actions"; import { - addUserBoundingBoxesAction, + addUserBoundingBoxAction, type AddIsosurfaceAction, } from "oxalis/model/actions/annotation_actions"; -import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; import { updateTemporarySettingAction, type UpdateTemporarySettingAction, @@ -617,24 +616,15 @@ export function* floodFill(): Saga { }, ); - const userBoundingBoxes = yield* select( - state => getSomeTracing(state.tracing).userBoundingBoxes, - ); - const highestBoundingBoxId = Math.max(-1, ...userBoundingBoxes.map(bb => bb.id)); - const boundingBoxId = highestBoundingBoxId + 1; - yield* put( - addUserBoundingBoxesAction([ - { - id: boundingBoxId, - boundingBox: coveredBoundingBox, - name: `Limits of flood-fill (source_id=${oldSegmentIdAtSeed}, target_id=${activeCellId}, seed=${seedPosition.join( - ",", - )}, timestamp=${new Date().getTime()})`, - color: Utils.getRandomColor(), - isVisible: true, - }, - ]), + addUserBoundingBoxAction({ + boundingBox: coveredBoundingBox, + name: `Limits of flood-fill (source_id=${oldSegmentIdAtSeed}, target_id=${activeCellId}, seed=${seedPosition.join( + ",", + )}, timestamp=${new Date().getTime()})`, + color: Utils.getRandomColor(), + isVisible: true, + }), ); } else { yield* call(progressCallback, true, "Floodfill done."); diff --git a/frontend/javascripts/oxalis/model_initialization.js b/frontend/javascripts/oxalis/model_initialization.js index 38f828e6d18..682d4c06036 100644 --- a/frontend/javascripts/oxalis/model_initialization.js +++ b/frontend/javascripts/oxalis/model_initialization.js @@ -74,7 +74,7 @@ import UrlManager, { type UrlStateByLayer, } from "oxalis/controller/url_manager"; import * as Utils from "libs/utils"; -import constants, { ControlModeEnum } from "oxalis/constants"; +import constants, { ControlModeEnum, AnnotationToolEnum } from "oxalis/constants"; import messages from "messages"; import window from "libs/window"; @@ -313,7 +313,7 @@ function setInitialTool() { // We are in a annotation which contains a skeleton. Due to the // enabled legacy-bindings, the user can expect to immediately create new nodes // with right click. Therefore, switch to the skeleton tool. - Store.dispatch(setToolAction("SKELETON")); + Store.dispatch(setToolAction(AnnotationToolEnum.SKELETON)); } } diff --git a/frontend/javascripts/oxalis/store.js b/frontend/javascripts/oxalis/store.js index 2e071b290dc..13b2bc813aa 100644 --- a/frontend/javascripts/oxalis/store.js +++ b/frontend/javascripts/oxalis/store.js @@ -113,14 +113,24 @@ export type UserBoundingBoxToServer = { isVisible?: boolean, }; -export type UserBoundingBox = { - id: number, +export type UserBoundingBoxWithoutIdMaybe = {| + boundingBox?: BoundingBoxType, + name?: string, + color?: Vector3, + isVisible?: boolean, +|}; + +export type UserBoundingBoxWithoutId = {| boundingBox: BoundingBoxType, name: string, color: Vector3, isVisible: boolean, -}; +|}; +export type UserBoundingBox = { + id: number, + ...UserBoundingBoxWithoutId, +}; export type MutableTree = {| treeId: number, groupId: ?number, diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.js b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.js index 72e24511113..e84547e24ae 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.js +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.js @@ -10,6 +10,7 @@ import Constants, { type OverwriteMode, OverwriteModeEnum, FillModeEnum, + VolumeTools, } from "oxalis/constants"; import { convertCellIdToCSS } from "oxalis/view/left-border-tabs/mapping_settings_view"; import { document } from "libs/window"; @@ -33,6 +34,7 @@ import { createTreeAction, setMergerModeEnabledAction, } from "oxalis/model/actions/skeletontracing_actions"; +import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import { getActiveTree } from "oxalis/model/accessors/skeletontracing_accessor"; import { LogSliderSetting } from "oxalis/view/components/setting_input_views"; import { userSettings } from "types/schemas/user_settings.schema"; @@ -44,8 +46,8 @@ const narrowButtonStyle = { }; const imgStyleForSpaceyIcons = { - width: 14, - height: 14, + width: 19, + height: 19, lineHeight: 10, marginTop: -2, }; @@ -98,6 +100,10 @@ const handleCreateCell = () => { Store.dispatch(createCellAction()); }; +const handleAddNewUserBoundingBox = () => { + Store.dispatch(addUserBoundingBoxAction()); +}; + const handleSetOverwriteMode = (event: { target: { value: OverwriteMode } }) => { Store.dispatch(updateUserSettingAction("overwriteMode", event.target.value)); }; @@ -250,6 +256,23 @@ function CreateCellButton() { ); } +function CreateNewBoundingBoxButton() { + return ( + + + New Bounding Box Icon + + + ); +} + function CreateTreeButton() { const dispatch = useDispatch(); const activeTree = useSelector(state => toNullable(getActiveTree(state.tracing.skeleton))); @@ -554,6 +577,23 @@ export default function ToolbarView() { }} /> + + Bounding Box Icon + ) : null} @@ -577,8 +617,8 @@ function ToolSpecificSettings({ isShiftPressed, }) { const showCreateTreeButton = hasSkeleton && adaptedActiveTool === AnnotationToolEnum.SKELETON; - const showCreateCellButton = - isVolumeSupported && !showCreateTreeButton && adaptedActiveTool !== AnnotationToolEnum.MOVE; + const showNewBoundingBoxButton = adaptedActiveTool === AnnotationToolEnum.BOUNDING_BOX; + const showCreateCellButton = isVolumeSupported && VolumeTools.includes(adaptedActiveTool); const showChangeBrushSizeButton = showCreateCellButton && (adaptedActiveTool === AnnotationToolEnum.BRUSH || @@ -593,6 +633,12 @@ function ToolSpecificSettings({ ) : null} + {showNewBoundingBoxButton ? ( + + + + ) : null} + {showCreateCellButton || showChangeBrushSizeButton ? ( {showCreateCellButton ? : null} diff --git a/frontend/javascripts/oxalis/view/components/setting_input_views.js b/frontend/javascripts/oxalis/view/components/setting_input_views.js index 9126103a26f..de307cd3aca 100644 --- a/frontend/javascripts/oxalis/view/components/setting_input_views.js +++ b/frontend/javascripts/oxalis/view/components/setting_input_views.js @@ -1,6 +1,6 @@ // @flow import { Row, Col, Slider, InputNumber, Switch, Tooltip, Input, Select, Popover } from "antd"; -import { DeleteOutlined, DownloadOutlined, EditOutlined } from "@ant-design/icons"; +import { DeleteOutlined, DownloadOutlined, EditOutlined, ScanOutlined } from "@ant-design/icons"; import * as React from "react"; import _ from "lodash"; @@ -286,13 +286,6 @@ export function NumberInputPopoverSetting(props: NumberInputPopoverSettingProps) ); } -export type UserBoundingBoxInputUpdate = { - boundingBox?: Vector6, - name?: string, - color?: Vector3, - isVisible?: boolean, -}; - type UserBoundingBoxInputProps = { value: Vector6, name: string, @@ -300,9 +293,13 @@ type UserBoundingBoxInputProps = { isVisible: boolean, isExportEnabled: boolean, tooltipTitle: string, - onChange: UserBoundingBoxInputUpdate => void, + onBoundingChange: Vector6 => void, onDelete: () => void, onExport: () => void, + onGoToBoundingBox: () => void, + onVisibilityChange: boolean => void, + onNameChange: string => void, + onColorChange: Vector3 => void, }; type State = { @@ -363,33 +360,40 @@ export class UserBoundingBoxInput extends React.PureComponent { color = ((color.map(colorPart => colorPart / 255): any): Vector3); - this.props.onChange({ color }); - }; - - handleVisibilityChange = (isVisible: boolean) => { - this.props.onChange({ isVisible }); + this.props.onColorChange(color); }; handleNameChanged = (evt: SyntheticInputEvent<>) => { const currentEnteredName = evt.target.value; if (currentEnteredName !== this.props.name) { - this.props.onChange({ name: evt.target.value }); + this.props.onNameChange(currentEnteredName); } }; render() { const { name } = this.state; const tooltipStyle = this.state.isValid ? null : { backgroundColor: "red" }; - const { tooltipTitle, color, isVisible, onDelete, onExport, isExportEnabled } = this.props; + const { + tooltipTitle, + color, + isVisible, + onDelete, + onExport, + isExportEnabled, + onGoToBoundingBox, + } = this.props; const upscaledColor = ((color.map(colorPart => colorPart * 255): any): Vector3); - const iconStyle = { margin: "auto 0px auto 6px" }; + const iconStyle = { + marginRight: 0, + marginLeft: 6, + }; const exportIconStyle = isExportEnabled ? iconStyle : { ...iconStyle, opacity: 0.5, cursor: "not-allowed" }; @@ -403,19 +407,19 @@ export class UserBoundingBoxInput extends React.PureComponent ) : null; - const nameColSpan = exportColumn == null ? 17 : 15; return ( - + + - + + + - + + + + + + ); diff --git a/frontend/javascripts/oxalis/view/context_menu.js b/frontend/javascripts/oxalis/view/context_menu.js index 88ce144354d..29296c1053e 100644 --- a/frontend/javascripts/oxalis/view/context_menu.js +++ b/frontend/javascripts/oxalis/view/context_menu.js @@ -1,6 +1,6 @@ // @flow import React, { useEffect, type Node } from "react"; -import { Menu, notification, Divider, Tooltip } from "antd"; +import { Menu, notification, Divider, Tooltip, Popover, Input } from "antd"; import { CopyOutlined } from "@ant-design/icons"; import { type AnnotationTool, @@ -10,12 +10,19 @@ import { VolumeTools, } from "oxalis/constants"; +import { maybeGetSomeTracing } from "oxalis/model/accessors/tracing_accessor"; import type { OxalisState, SkeletonTracing, VolumeTracing } from "oxalis/store"; import type { APIDataset, APIDataLayer } from "types/api_flow_types"; import type { Dispatch } from "redux"; import { connect, useDispatch } from "react-redux"; import { V3 } from "libs/mjs"; import { changeActiveIsosurfaceCellAction } from "oxalis/model/actions/segmentation_actions"; +import { + addUserBoundingBoxAction, + deleteUserBoundingBoxAction, + changeUserBoundingBoxAction, +} from "oxalis/model/actions/annotation_actions"; +import { type UserBoundingBox } from "oxalis/store"; import { deleteEdgeAction, mergeTreesAction, @@ -41,7 +48,7 @@ import messages from "messages"; import { getVisibleSegmentationLayer } from "oxalis/model/accessors/dataset_accessor"; import { getNodeAndTree, findTreeByNodeId } from "oxalis/model/accessors/skeletontracing_accessor"; import { formatNumberToLength, formatLengthAsVx } from "libs/format_utils"; -import { roundTo } from "libs/utils"; +import { roundTo, hexToRgb, rgbToHex } from "libs/utils"; import Shortcut from "libs/shortcut_component"; import { getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; @@ -51,6 +58,7 @@ import { getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; type OwnProps = {| contextMenuPosition: [number, number], clickedNodeId: ?number, + clickedBoundingBoxId: ?number, globalPosition: Vector3, viewport: OrthoView, hideContextMenu: () => void, @@ -63,6 +71,13 @@ type DispatchProps = {| setActiveNode: number => void, hideTree: number => void, createTree: () => void, + hideBoundingBox: number => void, + setActiveCell: number => void, + addNewBoundingBox: Vector3 => void, + setBoundingBoxColor: (number, Vector3) => void, + setBoundingBoxName: (number, string) => void, + addNewBoundingBox: Vector3 => void, + deleteBoundingBox: number => void, setActiveCell: (number, somePosition?: Vector3) => void, |}; @@ -76,6 +91,7 @@ type StateProps = {| volumeTracing: ?VolumeTracing, activeTool: AnnotationTool, useLegacyBindings: boolean, + userBoundingBoxes: Array, |}; /* eslint-enable react/no-unused-prop-types */ @@ -287,20 +303,138 @@ function NodeContextMenuOptions({ ); } -function NoNodeContextMenuOptions({ - skeletonTracing, - volumeTracing, +function getBoundingBoxMenuOptions({ + addNewBoundingBox, + globalPosition, activeTool, + clickedBoundingBoxId, + userBoundingBoxes, + setBoundingBoxName, hideContextMenu, - globalPosition, - viewport, - createTree, - segmentIdAtPosition, - visibleSegmentationLayer, - dataset, - currentMeshFile, - setActiveCell, + setBoundingBoxColor, + hideBoundingBox, + deleteBoundingBox, }: NoNodeContextMenuProps) { + const isBoundingBoxToolActive = activeTool === AnnotationToolEnum.BOUNDING_BOX; + const newBoundingBoxMenuItem = ( + { + addNewBoundingBox(globalPosition); + }} + > + Create new Bounding Box + {isBoundingBoxToolActive ? shortcutBuilder(["C"]) : null} + + ); + if (clickedBoundingBoxId == null) { + return [newBoundingBoxMenuItem]; + } + const hoveredBBox = userBoundingBoxes.find(bbox => bbox.id === clickedBoundingBoxId); + if (hoveredBBox == null) { + return [newBoundingBoxMenuItem]; + } + const setBBoxName = (evt: SyntheticInputEvent<>) => { + setBoundingBoxName(clickedBoundingBoxId, evt.target.value); + }; + const preventContextMenuFromClosing = evt => { + evt.stopPropagation(); + }; + const upscaledBBoxColor = ((hoveredBBox.color.map(colorPart => colorPart * 255): any): Vector3); + return [ + newBoundingBoxMenuItem, + + { + setBBoxName(evt); + hideContextMenu(); + }} + onBlur={setBBoxName} + onClick={preventContextMenuFromClosing} + /> + } + trigger="click" + > + + Change Bounding Box Name + + + , + + + Change Bounding Box Color + ) => { + let color = hexToRgb(evt.target.value); + color = ((color.map(colorPart => colorPart / 255): any): Vector3); + setBoundingBoxColor(clickedBoundingBoxId, color); + }} + value={rgbToHex(upscaledBBoxColor)} + /> + + , + { + hideBoundingBox(clickedBoundingBoxId); + }} + > + Hide Bounding Box + , + { + deleteBoundingBox(clickedBoundingBoxId); + }} + > + Delete Bounding Box + , + ]; +} + +function NoNodeContextMenuOptions(props: NoNodeContextMenuProps) { + const { + skeletonTracing, + volumeTracing, + activeTool, + globalPosition, + viewport, + createTree, + segmentIdAtPosition, + visibleSegmentationLayer, + dataset, + currentMeshFile, + hideContextMenu, + setActiveCell, + } = props; + const dispatch = useDispatch(); useEffect(() => { (async () => { @@ -340,6 +474,7 @@ function NoNodeContextMenuOptions({ }; const isVolumeBasedToolActive = VolumeTools.includes(activeTool); + const isBoundingBoxToolActive = activeTool === AnnotationToolEnum.BOUNDING_BOX; const skeletonActions = skeletonTracing != null @@ -359,7 +494,8 @@ function NoNodeContextMenuOptions({ setWaypoint(globalPosition, viewport, false); }} > - Create new Tree here {!isVolumeBasedToolActive ? shortcutBuilder(["C"]) : null} + Create new Tree here{" "} + {!isVolumeBasedToolActive && !isBoundingBoxToolActive ? shortcutBuilder(["C"]) : null} , ] : []; @@ -413,15 +549,22 @@ function NoNodeContextMenuOptions({ , ] : []; + + const boundingBoxActions = getBoundingBoxMenuOptions(props); + if (volumeTracing == null && visibleSegmentationLayer != null) { nonSkeletonActions.push(loadPrecomputedMeshItem); nonSkeletonActions.push(computeMeshAdHocItem); } const isSkeletonToolActive = activeTool === AnnotationToolEnum.SKELETON; - - const allActions = isSkeletonToolActive - ? skeletonActions.concat(nonSkeletonActions) - : nonSkeletonActions.concat(skeletonActions); + let allActions = []; + if (isSkeletonToolActive) { + allActions = skeletonActions.concat(nonSkeletonActions).concat(boundingBoxActions); + } else if (isBoundingBoxToolActive) { + allActions = boundingBoxActions.concat(nonSkeletonActions).concat(skeletonActions); + } else { + allActions = nonSkeletonActions.concat(skeletonActions).concat(boundingBoxActions); + } if (allActions.length === 0) { return null; @@ -588,11 +731,26 @@ const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ setActiveCell(segmentId: number, somePosition?: Vector3) { dispatch(setActiveCellAction(segmentId, somePosition)); }, + addNewBoundingBox(center: Vector3) { + dispatch(addUserBoundingBoxAction(null, center)); + }, + setBoundingBoxName(id: number, name: string) { + dispatch(changeUserBoundingBoxAction(id, { name })); + }, + setBoundingBoxColor(id: number, color: Vector3) { + dispatch(changeUserBoundingBoxAction(id, { color })); + }, + deleteBoundingBox(id: number) { + dispatch(deleteUserBoundingBoxAction(id)); + }, + hideBoundingBox(id: number) { + dispatch(changeUserBoundingBoxAction(id, { isVisible: false })); + }, }); function mapStateToProps(state: OxalisState): StateProps { const visibleSegmentationLayer = getVisibleSegmentationLayer(state); - + const someTracing = maybeGetSomeTracing(state.tracing); return { skeletonTracing: state.tracing.skeleton, volumeTracing: state.tracing.volume, @@ -606,6 +764,7 @@ function mapStateToProps(state: OxalisState): StateProps { ? state.localSegmentationData[visibleSegmentationLayer.name].currentMeshFile : null, useLegacyBindings: state.userConfiguration.useLegacyBindings, + userBoundingBoxes: someTracing != null ? someTracing.userBoundingBoxes : [], }; } diff --git a/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.js b/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.js index ef243f5d288..d5ab449ba6b 100644 --- a/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.js +++ b/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.js @@ -82,6 +82,7 @@ type State = { status: ControllerStatus, contextMenuPosition: ?[number, number], clickedNodeId: ?number, + clickedBoundingBoxId: ?number, contextMenuGlobalPosition: Vector3, contextMenuViewport: ?OrthoView, model: Object, @@ -111,6 +112,7 @@ class TracingLayoutView extends React.PureComponent { status: "loading", contextMenuPosition: null, clickedNodeId: null, + clickedBoundingBoxId: null, contextMenuGlobalPosition: [0, 0, 0], contextMenuViewport: null, model: layout, @@ -161,6 +163,7 @@ class TracingLayoutView extends React.PureComponent { xPos: number, yPos: number, nodeId: ?number, + boundingBoxId: ?number, globalPosition: Vector3, viewport: OrthoView, ) => { @@ -173,6 +176,7 @@ class TracingLayoutView extends React.PureComponent { this.setState({ contextMenuPosition: [xPos, yPos], clickedNodeId: nodeId, + clickedBoundingBoxId: boundingBoxId, contextMenuGlobalPosition: globalPosition, contextMenuViewport: viewport, }), @@ -184,6 +188,7 @@ class TracingLayoutView extends React.PureComponent { this.setState({ contextMenuPosition: null, clickedNodeId: null, + clickedBoundingBoxId: null, contextMenuGlobalPosition: [0, 0, 0], contextMenuViewport: null, }); @@ -243,14 +248,7 @@ class TracingLayoutView extends React.PureComponent { ); } - const { - clickedNodeId, - contextMenuPosition, - contextMenuGlobalPosition, - contextMenuViewport, - status, - activeLayoutName, - } = this.state; + const { contextMenuPosition, contextMenuViewport, status, activeLayoutName } = this.state; const layoutType = determineLayout( this.props.initialCommandType.type, @@ -276,9 +274,10 @@ class TracingLayoutView extends React.PureComponent { {contextMenuPosition != null && contextMenuViewport != null ? ( ) : null} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.js b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.js index 4fb79fdaef7..8cf8ed94df0 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.js +++ b/frontend/javascripts/oxalis/view/right-border-tabs/bounding_box_tab.js @@ -4,76 +4,60 @@ */ import { Tooltip } from "antd"; import { PlusSquareOutlined } from "@ant-design/icons"; -import type { Dispatch } from "redux"; -import { connect } from "react-redux"; +import { useSelector, useDispatch } from "react-redux"; import React, { useState } from "react"; import _ from "lodash"; -import type { APIDataset } from "types/api_flow_types"; -import { - UserBoundingBoxInput, - type UserBoundingBoxInputUpdate, -} from "oxalis/view/components/setting_input_views"; -import type { OxalisState, Tracing, UserBoundingBox } from "oxalis/store"; +import { setPositionAction } from "oxalis/model/actions/flycam_actions"; +import type { Vector3, Vector6, BoundingBoxType } from "oxalis/constants"; +import { UserBoundingBoxInput } from "oxalis/view/components/setting_input_views"; import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; -import { getDatasetExtentInVoxel } from "oxalis/model/accessors/dataset_accessor"; -import { setUserBoundingBoxesAction } from "oxalis/model/actions/annotation_actions"; +import { + changeUserBoundingBoxAction, + addUserBoundingBoxAction, + deleteUserBoundingBoxAction, +} from "oxalis/model/actions/annotation_actions"; import * as Utils from "libs/utils"; import ExportBoundingBoxModal from "oxalis/view/right-border-tabs/export_bounding_box_modal"; -type BoundingBoxTabProps = { - tracing: Tracing, - onChangeBoundingBoxes: (value: Array) => void, - dataset: APIDataset, -}; - -function BoundingBoxTab(props: BoundingBoxTabProps) { +export default function BoundingBoxTab() { const [selectedBoundingBoxForExport, setSelectedBoundingBoxForExport] = useState(null); - const { tracing, dataset, onChangeBoundingBoxes } = props; + const tracing = useSelector(state => state.tracing); + const dataset = useSelector(state => state.dataset); const { userBoundingBoxes } = getSomeTracing(tracing); - function handleChangeUserBoundingBox( - id: number, - { boundingBox, name, color, isVisible }: UserBoundingBoxInputUpdate, - ) { - const maybeUpdatedBoundingBox = boundingBox - ? Utils.computeBoundingBoxFromArray(boundingBox) - : undefined; + const dispatch = useDispatch(); + const setChangeBoundingBoxBounds = (id: number, boundingBox: BoundingBoxType) => + dispatch(changeUserBoundingBoxAction(id, { boundingBox })); + const addNewBoundingBox = () => dispatch(addUserBoundingBoxAction()); - const updatedUserBoundingBoxes = userBoundingBoxes.map(bb => - bb.id === id - ? { - ...bb, - boundingBox: maybeUpdatedBoundingBox || bb.boundingBox, - name: name != null ? name : bb.name, - color: color || bb.color, - isVisible: isVisible != null ? isVisible : bb.isVisible, - } - : bb, - ); - onChangeBoundingBoxes(updatedUserBoundingBoxes); - } + const setPosition = (position: Vector3) => dispatch(setPositionAction(position)); - function handleAddNewUserBoundingBox() { - const datasetBoundingBox = getDatasetExtentInVoxel(dataset); - // We use the default of -1 to get the id 0 for the first user bounding box. - const highestBoundingBoxId = Math.max(-1, ...userBoundingBoxes.map(bb => bb.id)); - const boundingBoxId = highestBoundingBoxId + 1; - const newUserBoundingBox = { - boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(datasetBoundingBox), - id: boundingBoxId, - name: `user bounding box ${boundingBoxId}`, - color: Utils.getRandomColor(), - isVisible: true, - }; - const updatedUserBoundingBoxes = [...userBoundingBoxes, newUserBoundingBox]; - onChangeBoundingBoxes(updatedUserBoundingBoxes); + const deleteBoundingBox = (id: number) => dispatch(deleteUserBoundingBoxAction(id)); + const setBoundingBoxVisibility = (id: number, isVisible: boolean) => + dispatch(changeUserBoundingBoxAction(id, { isVisible })); + const setBoundingBoxName = (id: number, name: string) => + dispatch(changeUserBoundingBoxAction(id, { name })); + const setBoundingBoxColor = (id: number, color: Vector3) => + dispatch(changeUserBoundingBoxAction(id, { color })); + + function handleBoundingBoxBoundingChange(id: number, boundingBox: Vector6) { + setChangeBoundingBoxBounds(id, Utils.computeBoundingBoxFromArray(boundingBox)); } - function handleDeleteUserBoundingBox(id: number) { - const updatedUserBoundingBoxes = userBoundingBoxes.filter(boundingBox => boundingBox.id !== id); - onChangeBoundingBoxes(updatedUserBoundingBoxes); + function handleGoToBoundingBox(id: number) { + const boundingBoxEntry = userBoundingBoxes.find(bbox => bbox.id === id); + if (!boundingBoxEntry) { + return; + } + const { min, max } = boundingBoxEntry.boundingBox; + const center = [ + min[0] + (max[0] - min[0]) / 2, + min[1] + (max[1] - min[1]) / 2, + min[2] + (max[2] - min[2]) / 2, + ]; + setPosition(center); } return ( @@ -88,11 +72,15 @@ function BoundingBoxTab(props: BoundingBoxTabProps) { name={bb.name} isExportEnabled={dataset.jobsEnabled} isVisible={bb.isVisible} - onChange={_.partial(handleChangeUserBoundingBox, bb.id)} - onDelete={_.partial(handleDeleteUserBoundingBox, bb.id)} + onBoundingChange={_.partial(handleBoundingBoxBoundingChange, bb.id)} + onDelete={_.partial(deleteBoundingBox, bb.id)} onExport={ dataset.jobsEnabled ? _.partial(setSelectedBoundingBoxForExport, bb) : () => {} } + onGoToBoundingBox={_.partial(handleGoToBoundingBox, bb.id)} + onVisibilityChange={_.partial(setBoundingBoxVisibility, bb.id)} + onNameChange={_.partial(setBoundingBoxName, bb.id)} + onColorChange={_.partial(setBoundingBoxColor, bb.id)} /> )) ) : ( @@ -101,7 +89,7 @@ function BoundingBoxTab(props: BoundingBoxTabProps) {
); } - -const mapStateToProps = (state: OxalisState) => ({ - tracing: state.tracing, - dataset: state.dataset, -}); - -const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ - onChangeBoundingBoxes(userBoundingBoxes: Array) { - dispatch(setUserBoundingBoxesAction(userBoundingBoxes)); - }, -}); - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(BoundingBoxTab); diff --git a/frontend/javascripts/oxalis/view/version_entry.js b/frontend/javascripts/oxalis/view/version_entry.js index 7047624bb2d..53b17866165 100644 --- a/frontend/javascripts/oxalis/view/version_entry.js +++ b/frontend/javascripts/oxalis/view/version_entry.js @@ -49,7 +49,7 @@ const descriptionFns = { icon: , }), updateUserBoundingBoxes: (): Description => ({ - description: "Updated a user bounding box.", + description: "Updated a bounding box.", icon: , }), removeFallbackLayer: (): Description => ({ diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.js b/frontend/javascripts/test/fixtures/volumetracing_object.js new file mode 100644 index 00000000000..a56399aa941 --- /dev/null +++ b/frontend/javascripts/test/fixtures/volumetracing_object.js @@ -0,0 +1,91 @@ +/* eslint-disable import/prefer-default-export */ +// @flow +import update from "immutability-helper"; + +import Constants, { AnnotationToolEnum } from "oxalis/constants"; +import mockRequire from "mock-require"; +import defaultState from "oxalis/default_state"; + +mockRequire("app", { currentUser: { firstName: "SCM", lastName: "Boy" } }); + +const volumeTracing = { + type: "volume", + activeCellId: 0, + activeTool: AnnotationToolEnum.MOVE, + maxCellId: 0, + contourList: [], + lastCentroid: null, +}; + +const notEmptyViewportRect = { + top: 0, + left: 0, + width: Constants.VIEWPORT_WIDTH, + height: Constants.VIEWPORT_WIDTH, +}; + +export const initialState = update(defaultState, { + tracing: { + annotationType: { $set: "Explorational" }, + name: { $set: "" }, + restrictions: { + $set: { + branchPointsAllowed: true, + allowUpdate: true, + allowFinish: true, + allowAccess: true, + allowDownload: true, + resolutionRestrictions: { min: null, max: null }, + }, + }, + volume: { $set: volumeTracing }, + }, + dataset: { + dataSource: { + dataLayers: { + $set: [ + { + // We need to have some resolutions. Otherwise, + // getRequestLogZoomStep will always return 0 + resolutions: [[1, 1, 1], [2, 2, 2], [4, 4, 4]], + category: "segmentation", + name: "segmentation", + isTracingLayer: true, + }, + ], + }, + }, + }, + datasetConfiguration: { + layers: { + segmentation: { + $set: { + color: [0, 0, 0], + alpha: 100, + intensityRange: [0, 255], + isDisabled: false, + isInverted: false, + isInEditMode: false, + }, + }, + }, + }, + // To get a valid calculated current zoomstep, the viewport rects are required not to be empty. + viewModeData: { + plane: { + inputCatcherRects: { + $set: { + PLANE_XY: notEmptyViewportRect, + PLANE_YZ: notEmptyViewportRect, + PLANE_XZ: notEmptyViewportRect, + TDView: notEmptyViewportRect, + }, + }, + }, + arbitrary: { + $set: { + inputCatcherRect: notEmptyViewportRect, + }, + }, + }, +}); diff --git a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js index 9cee26d2fbc..c7b32ff176c 100644 --- a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js +++ b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.js @@ -2,7 +2,7 @@ import update from "immutability-helper"; import { getVolumeTracingOrFail } from "test/helpers/apiHelpers"; -import Constants, { AnnotationToolEnum } from "oxalis/constants"; +import { AnnotationToolEnum } from "oxalis/constants"; import { getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; import * as VolumeTracingActions from "oxalis/model/actions/volumetracing_actions"; import * as UiActions from "oxalis/model/actions/ui_actions"; @@ -10,92 +10,10 @@ import VolumeTracingReducer from "oxalis/model/reducers/volumetracing_reducer"; import UiReducer from "oxalis/model/reducers/ui_reducer"; import mockRequire from "mock-require"; import test from "ava"; -import defaultState from "oxalis/default_state"; +import { initialState } from "test/fixtures/volumetracing_object"; mockRequire("app", { currentUser: { firstName: "SCM", lastName: "Boy" } }); -const volumeTracing = { - type: "volume", - activeCellId: 0, - activeTool: AnnotationToolEnum.MOVE, - maxCellId: 0, - contourList: [], - lastCentroid: null, -}; - -const notEmptyViewportRect = { - top: 0, - left: 0, - width: Constants.VIEWPORT_WIDTH, - height: Constants.VIEWPORT_WIDTH, -}; - -const initialState = update(defaultState, { - tracing: { - annotationType: { $set: "Explorational" }, - name: { $set: "" }, - restrictions: { - $set: { - branchPointsAllowed: true, - allowUpdate: true, - allowFinish: true, - allowAccess: true, - allowDownload: true, - resolutionRestrictions: { min: null, max: null }, - }, - }, - volume: { $set: volumeTracing }, - }, - dataset: { - dataSource: { - dataLayers: { - $set: [ - { - // We need to have some resolutions. Otherwise, - // getRequestLogZoomStep will always return 0 - resolutions: [[1, 1, 1], [2, 2, 2], [4, 4, 4]], - category: "segmentation", - name: "segmentation", - isTracingLayer: true, - }, - ], - }, - }, - }, - datasetConfiguration: { - layers: { - segmentation: { - $set: { - color: [0, 0, 0], - alpha: 100, - intensityRange: [0, 255], - isDisabled: false, - isInverted: false, - isInEditMode: false, - }, - }, - }, - }, - // To get a valid calculated current zoomstep, the viewport rects are required not to be empty. - viewModeData: { - plane: { - inputCatcherRects: { - $set: { - PLANE_XY: notEmptyViewportRect, - PLANE_YZ: notEmptyViewportRect, - PLANE_XZ: notEmptyViewportRect, - TDView: notEmptyViewportRect, - }, - }, - }, - arbitrary: { - $set: { - inputCatcherRect: notEmptyViewportRect, - }, - }, - }, -}); - test("VolumeTracing should set a new active cell", t => { const createCellAction = VolumeTracingActions.createCellAction(); const setActiveCellAction = VolumeTracingActions.setActiveCellAction(1); @@ -236,30 +154,33 @@ test("VolumeTracing should not allow to set trace tool if getRequestLogZoomStep( }); test("VolumeTracing should cycle trace/view/brush tool", t => { - const cycleToolAction = UiActions.cycleToolAction(); + const cycleToolAction = () => UiActions.cycleToolAction(); // Cycle tool to Brush - let newState = UiReducer(initialState, cycleToolAction); + let newState = UiReducer(initialState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.BRUSH); - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.ERASE_BRUSH); // Cycle tool to Trace - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.TRACE); - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.ERASE_TRACE); - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.FILL_CELL); - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.PICK_CELL); + newState = UiReducer(newState, cycleToolAction()); + t.is(newState.uiInformation.activeTool, AnnotationToolEnum.BOUNDING_BOX); + // Cycle tool back to MOVE - newState = UiReducer(newState, cycleToolAction); + newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.MOVE); }); diff --git a/frontend/javascripts/test/sagas/annotation_tool_saga.spec.js b/frontend/javascripts/test/sagas/annotation_tool_saga.spec.js new file mode 100644 index 00000000000..81984c08f76 --- /dev/null +++ b/frontend/javascripts/test/sagas/annotation_tool_saga.spec.js @@ -0,0 +1,118 @@ +// @flow + +import test from "ava"; +import { AnnotationToolEnum } from "oxalis/constants"; + +import mockRequire from "mock-require"; +import { initialState } from "test/fixtures/volumetracing_object"; +import sinon from "sinon"; + +const disabledInfoMock: { [any]: any } = {}; +Object.values(AnnotationToolEnum).forEach(annotationTool => { + disabledInfoMock[annotationTool] = { isDisabled: false, explanation: "" }; +}); +mockRequire("oxalis/model/accessors/tool_accessor", { + getDisabledInfoForTools: () => disabledInfoMock, +}); +const { + MoveTool, + SkeletonTool, + BoundingBoxTool, + DrawTool, + EraseTool, + FillCellTool, + PickCellTool, +} = mockRequire.reRequire("oxalis/controller/combinations/tool_controls"); +const UiReducer = mockRequire.reRequire("oxalis/model/reducers/ui_reducer").default; +const { wkReadyAction } = mockRequire.reRequire("oxalis/model/actions/actions"); +const { cycleToolAction, setToolAction } = mockRequire.reRequire("oxalis/model/actions/ui_actions"); + +const { watchToolDeselection } = mockRequire.reRequire("oxalis/model/sagas/annotation_tool_saga"); + +const allTools = [ + MoveTool, + SkeletonTool, + BoundingBoxTool, + DrawTool, + EraseTool, + FillCellTool, + PickCellTool, +]; + +const spies = allTools.map(tool => sinon.spy(tool, "onToolDeselected")); +test.beforeEach(() => { + spies.forEach(spy => spy.resetHistory()); +}); + +test.serial( + "Cycling through the annotation tools should trigger a deselection of the previous tool.", + t => { + let newState = initialState; + const saga = watchToolDeselection(); + saga.next(); + saga.next(wkReadyAction()); + saga.next(newState.uiInformation.activeTool); + const cycleTool = () => { + const action = cycleToolAction(); + newState = UiReducer(newState, action); + saga.next(action); + saga.next(newState); + }; + + cycleTool(); + t.true(MoveTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(SkeletonTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(DrawTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(EraseTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(DrawTool.onToolDeselected.calledTwice); + cycleTool(); + t.true(EraseTool.onToolDeselected.calledTwice); + cycleTool(); + t.true(FillCellTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(PickCellTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(BoundingBoxTool.onToolDeselected.calledOnce); + cycleTool(); + t.true(MoveTool.onToolDeselected.calledTwice); + }, +); + +test.serial("Selecting another tool should trigger a deselection of the previous tool.", t => { + let newState = initialState; + const saga = watchToolDeselection(); + saga.next(); + saga.next(wkReadyAction()); + saga.next(newState.uiInformation.activeTool); + const cycleTool = nextTool => { + const action = setToolAction(nextTool); + newState = UiReducer(newState, action); + saga.next(action); + saga.next(newState); + }; + + cycleTool(AnnotationToolEnum.SKELETON); + t.true(MoveTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.BRUSH); + t.true(SkeletonTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.ERASE_BRUSH); + t.true(DrawTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.TRACE); + t.true(EraseTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.ERASE_TRACE); + t.true(DrawTool.onToolDeselected.calledTwice); + cycleTool(AnnotationToolEnum.FILL_CELL); + t.true(EraseTool.onToolDeselected.calledTwice); + cycleTool(AnnotationToolEnum.PICK_CELL); + t.true(FillCellTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.BOUNDING_BOX); + t.true(PickCellTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.MOVE); + t.true(BoundingBoxTool.onToolDeselected.calledOnce); + cycleTool(AnnotationToolEnum.SKELETON); + t.true(MoveTool.onToolDeselected.calledTwice); +}); diff --git a/frontend/javascripts/test/sagas/volumetracing_saga_integration.spec.js b/frontend/javascripts/test/sagas/volumetracing_saga_integration.spec.js index 222635c3079..58dd8590332 100644 --- a/frontend/javascripts/test/sagas/volumetracing_saga_integration.spec.js +++ b/frontend/javascripts/test/sagas/volumetracing_saga_integration.spec.js @@ -12,7 +12,7 @@ import { createBucketResponseFunction, getVolumeTracingOrFail, } from "test/helpers/apiHelpers"; -import { OrthoViews, FillModeEnum } from "oxalis/constants"; +import { OrthoViews, FillModeEnum, AnnotationToolEnum } from "oxalis/constants"; import { restartSagaAction, wkReadyAction } from "oxalis/model/actions/actions"; import Store from "oxalis/store"; import { updateUserSettingAction } from "oxalis/model/actions/settings_actions"; @@ -62,7 +62,7 @@ test.serial("Executing a floodfill in mag 1", async t => { Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 43])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); Store.dispatch(addToLayerAction(paintCenter)); @@ -147,7 +147,7 @@ test.serial("Executing a floodfill in mag 2", async t => { Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 43])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); Store.dispatch(addToLayerAction(paintCenter)); @@ -262,7 +262,8 @@ test.serial("Executing a floodfill in mag 1 (long operation)", async t => { // Assert state after flood-fill await assertFloodFilledState(); - // Undo and assert initial state + // Undo created bounding box by flood fill and flood fill and assert initial state. + await dispatchUndoAsync(Store.dispatch); await dispatchUndoAsync(Store.dispatch); await assertInitialState(); @@ -319,7 +320,7 @@ test.serial("Brushing/Tracing with a new segment id should update the bucket dat Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 0])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); Store.dispatch(addToLayerAction(paintCenter)); @@ -380,7 +381,7 @@ test.serial("Brushing/Tracing with already existing backend data", async t => { Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 0])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); Store.dispatch(addToLayerAction(paintCenter)); @@ -428,7 +429,7 @@ test.serial("Brushing/Tracing with undo (I)", async t => { Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 0])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); @@ -465,7 +466,7 @@ test.serial("Brushing/Tracing with undo (II)", async t => { Store.dispatch(updateUserSettingAction("brushSize", brushSize)); Store.dispatch(setPositionAction([0, 0, 0])); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); Store.dispatch(startEditingAction(paintCenter, OrthoViews.PLANE_XY)); @@ -514,7 +515,7 @@ test.serial( ); test.serial( - "Brushing/Tracing should send buckets to backend and restore dirty flag afterwards", + "Brushing/Tracing should not crash when too many buckets are labeled at once with saving inbetween", async t => { await t.context.api.tracing.save(); @@ -560,7 +561,7 @@ async function testLabelingManyBuckets(t, saveInbetween) { ]); Store.dispatch(updateUserSettingAction("brushSize", brushSize)); - Store.dispatch(setToolAction("BRUSH")); + Store.dispatch(setToolAction(AnnotationToolEnum.BRUSH)); Store.dispatch(setActiveCellAction(newCellId)); for (const paintPosition of paintPositions1) { diff --git a/package.json b/package.json index 5b292281b2a..b6c24e3a5f2 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "retoggle": "^0.3.0", "rimraf": "^2.6.2", "shelljs": "^0.8.2", - "sinon": "^1.17.3", + "sinon": "^12.0.1", "style-loader": "^0.20.2", "terser-webpack-plugin": "^1.1.0", "tmp": "0.0.33", diff --git a/public/images/bounding-box.svg b/public/images/bounding-box.svg new file mode 100644 index 00000000000..191a863c317 --- /dev/null +++ b/public/images/bounding-box.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/public/images/new-bounding-box.svg b/public/images/new-bounding-box.svg new file mode 100644 index 00000000000..28329a10290 --- /dev/null +++ b/public/images/new-bounding-box.svg @@ -0,0 +1,70 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/yarn.lock b/yarn.lock index d3a2891fc17..f83aa4b5c92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1341,6 +1341,41 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== +"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" + integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^7.0.4": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" + integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/fake-timers@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz#3fdc2b6cb58935b21bfb8d1625eb1300484316e7" + integrity sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/samsam@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.0.2.tgz#a0117d823260f282c04bff5f8704bdc2ac6910bb" + integrity sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" + integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -2320,13 +2355,6 @@ ava@^3.13.0: write-file-atomic "^3.0.3" yargs "^16.2.0" -available-typed-arrays@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5" - integrity sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ== - dependencies: - array-filter "^1.0.0" - aws-sign2@~0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" @@ -4184,6 +4212,11 @@ diff@^1.3.2: resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" integrity sha1-fyjS657nsVqX79ic5j3P2qPMur8= +diff@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -5664,11 +5697,6 @@ for-in@^1.0.2: resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= - foreground-child@^1.5.6: version "1.5.6" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-1.5.6.tgz#4fd71ad2dfde96789b980a5c0a295937cb2f5ce9" @@ -5700,13 +5728,6 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -formatio@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.1.1.tgz#5ed3ccd636551097383465d996199100e86161e9" - integrity sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek= - dependencies: - samsam "~1.1" - forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" @@ -7048,11 +7069,6 @@ is-function@^1.0.1: resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ== -is-generator-function@^1.0.7: - version "1.0.8" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.8.tgz#dfb5c2b120e02b0a8d9d2c6806cd5621aa922f7b" - integrity sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ== - is-glob@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" @@ -7252,17 +7268,6 @@ is-symbol@^1.0.2: dependencies: has-symbols "^1.0.1" -is-typed-array@^1.1.3: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.4.tgz#1f66f34a283a3c94a4335434661ca53fff801120" - integrity sha512-ILaRgn4zaSrVNXNGtON6iFNotXW3hAPF3+0fB1usg2jFlWqo5fEDdmJkz0zBfoi7Dgskr8Khi2xZ8cXqZEfXNA== - dependencies: - available-typed-arrays "^1.0.2" - call-bind "^1.0.0" - es-abstract "^1.18.0-next.1" - foreach "^2.0.5" - has-symbols "^1.0.1" - is-typedarray@^1.0.0, is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -7667,6 +7672,11 @@ jszip@^3.5.0: readable-stream "~2.3.6" set-immediate-shim "~1.0.1" +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -8024,6 +8034,11 @@ lodash.flattendeep@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI= +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -8109,11 +8124,6 @@ loglevel@^1.6.8: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== -lolex@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.3.2.tgz#7c3da62ffcb30f0f5a80a2566ca24e45d8a01f31" - integrity sha1-fD2mL/yzDw9agKJWbKJORdigHzE= - long@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -8811,6 +8821,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.0.tgz#713ef3ed138252daef20ec035ab62b7a28be645c" + integrity sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers" "^7.0.4" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + no-case@^2.2.0: version "2.3.2" resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" @@ -11647,16 +11668,6 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -samsam@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - integrity sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc= - -samsam@~1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.3.tgz#9f5087419b4d091f232571e7fa52e90b0f552621" - integrity sha1-n1CHQZtNCR8jJXHn+lLpCw9VJiE= - sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -11922,15 +11933,17 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" -sinon@^1.17.3: - version "1.17.7" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-1.17.7.tgz#4542a4f49ba0c45c05eb2e9dd9d203e2b8efe0bf" - integrity sha1-RUKk9JugxFwF6y6d2dID4rjv4L8= +sinon@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-12.0.1.tgz#331eef87298752e1b88a662b699f98e403c859e9" + integrity sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg== dependencies: - formatio "1.1.1" - lolex "1.3.2" - samsam "1.1.2" - util ">=0.10.3 <1" + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^8.1.0" + "@sinonjs/samsam" "^6.0.2" + diff "^5.0.0" + nise "^5.1.0" + supports-color "^7.2.0" slash@^1.0.0: version "1.0.0" @@ -12541,7 +12554,7 @@ supports-color@^6.1.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0: +supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -12952,6 +12965,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-detect@4.0.8, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-fest@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.11.0.tgz#97abf0872310fed88a5c466b25681576145e33f1" @@ -13330,18 +13348,6 @@ util@0.10.3: dependencies: inherits "2.0.1" -"util@>=0.10.3 <1": - version "0.12.3" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.3.tgz#971bb0292d2cc0c892dab7c6a5d37c2bec707888" - integrity sha512-I8XkoQwE+fPQEhy9v012V+TSdH2kp9ts29i20TaaDUXsg7x/onePbhFJUExBfv/2ay1ZOp/Vsm3nDlmnFGSAog== - dependencies: - inherits "^2.0.3" - is-arguments "^1.0.4" - is-generator-function "^1.0.7" - is-typed-array "^1.1.3" - safe-buffer "^5.1.2" - which-typed-array "^1.1.2" - util@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" @@ -13762,19 +13768,6 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which-typed-array@^1.1.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.4.tgz#8fcb7d3ee5adf2d771066fba7cf37e32fe8711ff" - integrity sha512-49E0SpUe90cjpoc7BOJwyPHRqSAd12c10Qm2amdEZrJPCY2NDxaW01zHITrem+rnETY3dwrbH3UUrUwagfCYDA== - dependencies: - available-typed-arrays "^1.0.2" - call-bind "^1.0.0" - es-abstract "^1.18.0-next.1" - foreach "^2.0.5" - function-bind "^1.1.1" - has-symbols "^1.0.1" - is-typed-array "^1.1.3" - which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.0, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"