diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 459a8d387b4..bac96da1683 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released [Commits](https://github.com/scalableminds/webknossos/compare/24.07.0...HEAD) ### Added +- Added the option to move a bounding box via dragging while pressing ctrl / meta. [#7892](https://github.com/scalableminds/webknossos/pull/7892) - Added route `/import?url=` to automatically import and view remote datasets. [#7844](https://github.com/scalableminds/webknossos/pull/7844) - The context menu that is opened upon right-clicking a segment in the dataview port now contains the segment's name. [#7920](https://github.com/scalableminds/webknossos/pull/7920) @@ -18,6 +19,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - The warning about a mismatch between the scale of a pre-computed mesh and the dataset scale's factor now also considers all supported mags of the active segmentation layer. This reduces the false posive rate regarding this warning. [#7921](https://github.com/scalableminds/webknossos/pull/7921/) ### Fixed +- Fixed a bug that allowed the default newly created bounding box to appear outside the dataset. In case the whole bounding box would be outside it is created regardless. [#7892](https://github.com/scalableminds/webknossos/pull/7892) ### Removed diff --git a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts index 56a4da644ea..54e1c3ee130 100644 --- a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts +++ b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts @@ -1,10 +1,11 @@ import { + calculateGlobalDelta, calculateGlobalPos, calculateMaybeGlobalPos, } from "oxalis/model/accessors/view_mode_accessor"; import _ from "lodash"; import type { OrthoView, Point2, Vector3, BoundingBoxType, Vector2 } from "oxalis/constants"; -import Store from "oxalis/store"; +import Store, { OxalisState, UserBoundingBox } from "oxalis/store"; import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; import type { DimensionMap, DimensionIndices } from "oxalis/model/dimensions"; import Dimension from "oxalis/model/dimensions"; @@ -241,7 +242,7 @@ export function createBoundingBoxAndGetEdges( addUserBoundingBoxAction({ boundingBox: { min: globalPosition, - max: V3.add(globalPosition, [1, 1, 1], [0, 0, 0]), + max: V3.add(globalPosition, [1, 1, 1]), }, }), ); @@ -264,7 +265,7 @@ export function createBoundingBoxAndGetEdges( } export const highlightAndSetCursorOnHoveredBoundingBox = _.throttle( - (position: Point2, planeId: OrthoView) => { + (position: Point2, planeId: OrthoView, event: MouseEvent) => { const hoveredEdgesInfo = getClosestHoveredBoundingBox(position, planeId); // Access the parent element as that is where the cursor style property is set const inputCatcher = document.getElementById(`inputcatcher_${planeId}`)?.parentElement; @@ -272,8 +273,9 @@ export const highlightAndSetCursorOnHoveredBoundingBox = _.throttle( if (hoveredEdgesInfo != null && inputCatcher != null) { const [primaryHoveredEdge, secondaryHoveredEdge] = hoveredEdgesInfo; getSceneController().highlightUserBoundingBox(primaryHoveredEdge.boxId); - - if (secondaryHoveredEdge != null) { + if (event.ctrlKey || event.metaKey) { + inputCatcher.style.cursor = "move"; + } else if (secondaryHoveredEdge != null) { // If a corner is selected. inputCatcher.style.cursor = (primaryHoveredEdge.isMaxEdge && secondaryHoveredEdge.isMaxEdge) || @@ -295,6 +297,15 @@ export const highlightAndSetCursorOnHoveredBoundingBox = _.throttle( }, BOUNDING_BOX_HOVERING_THROTTLE_TIME, ); + +function getBoundingBoxOfPrimaryEdge( + primaryEdge: SelectedEdge, + state: OxalisState, +): UserBoundingBox | undefined { + const { userBoundingBoxes } = getSomeTracing(state.tracing); + return userBoundingBoxes.find((bbox) => bbox.id === primaryEdge.boxId); +} + export function handleResizingBoundingBox( mousePosition: Point2, planeId: OrthoView, @@ -303,8 +314,7 @@ export function handleResizingBoundingBox( ) { const state = Store.getState(); const globalMousePosition = calculateGlobalPos(state, mousePosition, planeId); - const { userBoundingBoxes } = getSomeTracing(state.tracing); - const bboxToResize = userBoundingBoxes.find((bbox) => bbox.id === primaryEdge.boxId); + const bboxToResize = getBoundingBoxOfPrimaryEdge(primaryEdge, state); if (!bboxToResize) { return; @@ -364,3 +374,28 @@ export function handleResizingBoundingBox( }), ); } + +export function handleMovingBoundingBox( + delta: Point2, + planeId: OrthoView, + primaryEdge: SelectedEdge, +) { + const state = Store.getState(); + const globalDelta = calculateGlobalDelta(state, delta, planeId); + const bboxToResize = getBoundingBoxOfPrimaryEdge(primaryEdge, state); + + if (!bboxToResize) { + return; + } + + const updatedBounds = { + min: V3.toArray(V3.add(bboxToResize.boundingBox.min, globalDelta)), + max: V3.toArray(V3.add(bboxToResize.boundingBox.max, globalDelta)), + }; + + Store.dispatch( + changeUserBoundingBoxAction(primaryEdge.boxId, { + boundingBox: updatedBounds, + }), + ); +} diff --git a/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts b/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts index e309185ef67..3288d7d093b 100644 --- a/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts +++ b/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts @@ -26,6 +26,7 @@ import PlaneView from "oxalis/view/plane_view"; import * as SkeletonHandlers from "oxalis/controller/combinations/skeleton_handlers"; import { createBoundingBoxAndGetEdges, + handleMovingBoundingBox, SelectedEdge, } from "oxalis/controller/combinations/bounding_box_handlers"; import { @@ -561,12 +562,16 @@ export class BoundingBoxTool { delta: Point2, pos: Point2, _id: string | null | undefined, - _event: MouseEvent, + event: MouseEvent, ) => { - if (primarySelectedEdge != null) { - handleResizingBoundingBox(pos, planeId, primarySelectedEdge, secondarySelectedEdge); - } else { + if (primarySelectedEdge == null) { MoveHandlers.handleMovePlane(delta); + return; + } + if (event.ctrlKey || event.metaKey) { + handleMovingBoundingBox(delta, planeId, primarySelectedEdge); + } else { + handleResizingBoundingBox(pos, planeId, primarySelectedEdge, secondarySelectedEdge); } }, leftMouseDown: (pos: Point2, _plane: OrthoView, _event: MouseEvent) => { @@ -596,7 +601,7 @@ export class BoundingBoxTool { mouseMove: (delta: Point2, position: Point2, _id: any, event: MouseEvent) => { if (primarySelectedEdge == null && planeId !== OrthoViews.TDView) { MoveHandlers.moveWhenAltIsPressed(delta, position, _id, event); - highlightAndSetCursorOnHoveredBoundingBox(position, planeId); + highlightAndSetCursorOnHoveredBoundingBox(position, planeId, event); } }, rightClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => { @@ -609,12 +614,12 @@ export class BoundingBoxTool { _activeTool: AnnotationTool, _useLegacyBindings: boolean, _shiftKey: boolean, - _ctrlOrMetaKey: boolean, + ctrlOrMetaKey: boolean, _altKey: boolean, _isTDViewportActive: boolean, ): ActionDescriptor { return { - leftDrag: "Create/Resize Bounding Boxes", + leftDrag: ctrlOrMetaKey ? "Move Bounding Boxes" : "Create/Resize Bounding Boxes", rightClick: "Context Menu", }; } diff --git a/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.ts b/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.ts index e848820ed26..29c058f6873 100644 --- a/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/view_mode_accessor.ts @@ -191,6 +191,40 @@ function _calculateMaybePlaneScreenPos( return point; } +function _calculateMaybeGlobalDelta( + state: OxalisState, + delta: Point2, + planeId?: OrthoView | null | undefined, +): Vector3 | null | undefined { + let position: Vector3; + planeId = planeId || state.viewModeData.plane.activeViewport; + const planeRatio = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); + const diffX = delta.x * state.flycam.zoomStep; + const diffY = delta.y * state.flycam.zoomStep; + + switch (planeId) { + case OrthoViews.PLANE_XY: { + position = [Math.round(diffX * planeRatio[0]), Math.round(diffY * planeRatio[1]), 0]; + break; + } + + case OrthoViews.PLANE_YZ: { + position = [0, Math.round(diffY * planeRatio[1]), Math.round(diffX * planeRatio[2])]; + break; + } + + case OrthoViews.PLANE_XZ: { + position = [Math.round(diffX * planeRatio[0]), 0, Math.round(diffY * planeRatio[2])]; + break; + } + + default: + return null; + } + + return position; +} + function _calculateGlobalPos( state: OxalisState, clickPos: Point2, @@ -206,6 +240,21 @@ function _calculateGlobalPos( return position; } +function _calculateGlobalDelta( + state: OxalisState, + delta: Point2, + planeId?: OrthoView | null | undefined, +): Vector3 { + const position = _calculateMaybeGlobalDelta(state, delta, planeId); + + if (!position) { + console.error("Trying to calculate the global position, but no data viewport is active."); + return [0, 0, 0]; + } + + return position; +} + export function getDisplayedDataExtentInPlaneMode(state: OxalisState) { const planeRatio = getBaseVoxelFactorsInUnit(state.dataset.dataSource.scale); const curGlobalCenterPos = getPosition(state.flycam); @@ -238,6 +287,7 @@ export function getDisplayedDataExtentInPlaneMode(state: OxalisState) { } export const calculateMaybeGlobalPos = reuseInstanceOnEquality(_calculateMaybeGlobalPos); export const calculateGlobalPos = reuseInstanceOnEquality(_calculateGlobalPos); +export const calculateGlobalDelta = reuseInstanceOnEquality(_calculateGlobalDelta); export const calculateMaybePlaneScreenPos = reuseInstanceOnEquality(_calculateMaybePlaneScreenPos); export function getViewMode(state: OxalisState): ViewMode { return state.temporaryConfiguration.viewMode; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/bounding_box.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/bounding_box.ts index 4475e366975..3bc55a74f35 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/bounding_box.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/bounding_box.ts @@ -206,6 +206,13 @@ class BoundingBox { const size = this.getSize(); return { topLeft: this.min, width: size[0], height: size[1], depth: size[2] }; } + + toBoundingBoxType(): BoundingBoxType { + return { + min: this.min, + max: this.max, + }; + } } export default BoundingBox; diff --git a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts index 26d9f79551a..1decc661b3b 100644 --- a/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/annotation_reducer.ts @@ -11,6 +11,8 @@ import _ from "lodash"; import { getAdditionalCoordinatesAsString } from "../accessors/flycam_accessor"; import { getMeshesForAdditionalCoordinates } from "../accessors/volumetracing_accessor"; import { AdditionalCoordinate } from "types/api_flow_types"; +import { getDatasetBoundingBox } from "../accessors/dataset_accessor"; +import BoundingBox from "../bucket_data_handling/bounding_box"; const updateTracing = (state: OxalisState, shape: Partial): OxalisState => updateKey(state, "tracing", shape); @@ -178,17 +180,27 @@ function AnnotationReducer(state: OxalisState, action: Action): OxalisState { max: V3.toArray(V3.round(V3.add(action.center, halfBoxExtent))), }; } - let newBoundingBox: UserBoundingBox; + let newUserBoundingBox: UserBoundingBox; if (action.newBoundingBox != null) { - newBoundingBox = { + newUserBoundingBox = { ...newBoundingBoxTemplate, ...action.newBoundingBox, }; } else { - newBoundingBox = newBoundingBoxTemplate; + newUserBoundingBox = newBoundingBoxTemplate; } - const updatedUserBoundingBoxes = [...userBoundingBoxes, newBoundingBox]; + // Ensure the new bounding box is within the dataset bounding box. + const datasetBoundingBox = getDatasetBoundingBox(state.dataset); + const newBoundingBox = new BoundingBox(newUserBoundingBox.boundingBox); + const newBoundingBoxWithinDataset = newBoundingBox.intersectedWith(datasetBoundingBox); + // Only update the bounding box if the bounding box overlaps with the dataset bounds. + // Else the bounding box is completely outside the dataset bounds -> in that case just keep the bounding box and let the user cook. + if (newBoundingBoxWithinDataset.getVolume() > 0) { + newUserBoundingBox.boundingBox = newBoundingBoxWithinDataset.toBoundingBoxType(); + } + + const updatedUserBoundingBoxes = [...userBoundingBoxes, newUserBoundingBox]; return updateUserBoundingBoxes(state, updatedUserBoundingBoxes); } diff --git a/package.json b/package.json index 305f5683902..3695c6bd90f 100644 --- a/package.json +++ b/package.json @@ -220,23 +220,14 @@ "**/rc-tree": "^5.7.12" }, "ava": { - "files": [ - "./public-test/test-bundle/**/*.{js,jsx}" - ], - "ignoredByWatcher": [ - "./binaryData/**/*.*" - ], - "require": [ - "./frontend/javascripts/test/_ava_polyfill_provider.ts" - ], + "files": ["./public-test/test-bundle/**/*.{js,jsx}"], + "ignoredByWatcher": ["./binaryData/**/*.*"], + "require": ["./frontend/javascripts/test/_ava_polyfill_provider.ts"], "snapshotDir": "frontend/javascripts/test/snapshots", "concurrency": 8 }, "c8": { - "exclude": [ - "public-test/test-bundle/test/**/*.*", - "frontend/javascripts/test/**/*.*" - ], + "exclude": ["public-test/test-bundle/test/**/*.*", "frontend/javascripts/test/**/*.*"], "reporter": "lcov" } }