diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md
index e4e02843cd7..b7518eecf03 100644
--- a/CHANGELOG.unreleased.md
+++ b/CHANGELOG.unreleased.md
@@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Added
- Added the total volume of a dataset to a tooltip in the dataset info tab. [#8229](https://github.com/scalableminds/webknossos/pull/8229)
- Optimized performance of data loading with “fill value“ chunks. [#8271](https://github.com/scalableminds/webknossos/pull/8271)
+- The fill tool can now be adapted so that it only acts within a specified bounding box. Use the new "Restrict Floodfill" mode for that in the toolbar. [#8267](https://github.com/scalableminds/webknossos/pull/8267)
### Changed
- Renamed "resolution" to "magnification" in more places within the codebase, including local variables. [#8168](https://github.com/scalableminds/webknossos/pull/8168)
diff --git a/docs/images/icon_restricted_floodfill.jpg b/docs/images/icon_restricted_floodfill.jpg
new file mode 100644
index 00000000000..bb7e1b21599
Binary files /dev/null and b/docs/images/icon_restricted_floodfill.jpg differ
diff --git a/docs/volume_annotation/tools.md b/docs/volume_annotation/tools.md
index bf8a8f40dcd..4b24f0dbdf2 100644
--- a/docs/volume_annotation/tools.md
+++ b/docs/volume_annotation/tools.md
@@ -68,6 +68,8 @@ The following interactions and modes become available when working with some of
![3D Fill Modifier](./images/3d-modifier.jpg){align=left width="60"}
**2D/3D Fill**: Modifies the flood filling tool to work in 2D (in-plane only) or 3D (volumetric fill/re-labeling). 3D flood fill is constrained to a small, regional bounding box for performance reasons. Read more about [flood fills](#volume-flood-fills) below.
+![Restrict Fill](./images/icon_restricted_floodfill.jpg){align=left width="60"}
+**Restrict Fill by Bounding Box**: When enabled, the fill operation will be restricted by the smallest bounding box that encloses the clicked position. This feature can be useful when correcting segmentation in a small bounding box (e.g., when curating training data).
## Quick-select tool
The Quick Select tool offers AI-powered automatic segmentation, powered by [Segment Anything Model 2](https://ai.meta.com/blog/segment-anything-2/). Simply draw a selection around your target structure, and WEBKNOSSOS will automatically segment it for you.
diff --git a/frontend/javascripts/components/async_clickables.tsx b/frontend/javascripts/components/async_clickables.tsx
index 9bbafae969d..1b63ccb17c7 100644
--- a/frontend/javascripts/components/async_clickables.tsx
+++ b/frontend/javascripts/components/async_clickables.tsx
@@ -1,4 +1,4 @@
-import { Button, type ButtonProps } from "antd";
+import { Button, ConfigProvider, type ButtonProps } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import * as React from "react";
import FastTooltip from "./fast_tooltip";
@@ -47,9 +47,12 @@ export function AsyncButton(props: AsyncButtonProps) {
const effectiveChildren = hideContentWhenLoading && isLoading ? null : children;
return (
-
+ {/* Avoid weird animation when icons swap */}
+
+
+
);
}
diff --git a/frontend/javascripts/libs/mjs.ts b/frontend/javascripts/libs/mjs.ts
index 4c5b6db8a43..4588fc10f63 100644
--- a/frontend/javascripts/libs/mjs.ts
+++ b/frontend/javascripts/libs/mjs.ts
@@ -250,6 +250,9 @@ const V2 = {
clone(a: Vector2): Vector2 {
return [a[0], a[1]];
},
+ prod(a: Vector2) {
+ return a[0] * a[1];
+ },
};
const _tmpVec: Vector3 = [0, 0, 0];
diff --git a/frontend/javascripts/libs/progress_callback.ts b/frontend/javascripts/libs/progress_callback.ts
index 49cd3aacef7..b9e6f7d60bd 100644
--- a/frontend/javascripts/libs/progress_callback.ts
+++ b/frontend/javascripts/libs/progress_callback.ts
@@ -1,6 +1,8 @@
import { message } from "antd";
import { sleep } from "libs/utils";
+
type HideFn = () => void;
+
export type ProgressCallback = (
isDone: boolean,
progressState: string | React.ReactNode,
@@ -9,11 +11,14 @@ export type ProgressCallback = (
) => Promise<{
hideFn: HideFn;
}>;
+
type Options = {
pauseDelay: number;
successMessageDelay: number;
key?: string;
-}; // This function returns another function which can be called within a longer running
+};
+
+// This function returns another function which can be called within a longer running
// process to update the UI with progress information. Example usage:
// const progressCallback = createProgressCallback({ pauseDelay: 100, successMessageDelay: 5000 });
// await progressCallback(false, "Beginning work...")
diff --git a/frontend/javascripts/oxalis/constants.ts b/frontend/javascripts/oxalis/constants.ts
index 3276804c564..14c1715ac5d 100644
--- a/frontend/javascripts/oxalis/constants.ts
+++ b/frontend/javascripts/oxalis/constants.ts
@@ -333,6 +333,9 @@ const Constants = {
_2D: (process.env.IS_TESTING ? [512, 512, 1] : [768, 768, 1]) as Vector3,
_3D: (process.env.IS_TESTING ? [64, 64, 32] : [96, 96, 96]) as Vector3,
},
+ // When the user uses the "isFloodfillRestrictedToBoundingBox" setting,
+ // we are more lax with the flood fill extent.
+ FLOOD_FILL_MULTIPLIER_FOR_BBOX_RESTRICTION: 10,
MAXIMUM_DATE_TIMESTAMP: 8640000000000000,
SCALEBAR_HEIGHT: 22,
SCALEBAR_OFFSET: 10,
diff --git a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts
index 8bba7bd64c9..ea389aaeec3 100644
--- a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts
+++ b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts
@@ -242,7 +242,9 @@ export function createBoundingBoxAndGetEdges(
addUserBoundingBoxAction({
boundingBox: {
min: globalPosition,
- max: V3.add(globalPosition, [1, 1, 1]),
+ // The last argument ensures that a Vector3 is used and not a
+ // Float32Array.
+ max: V3.add(globalPosition, [1, 1, 1], [0, 0, 0]),
},
}),
);
diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts
index da524d26c4f..1349081df4f 100644
--- a/frontend/javascripts/oxalis/default_state.ts
+++ b/frontend/javascripts/oxalis/default_state.ts
@@ -84,6 +84,7 @@ const defaultState: OxalisState = {
gpuMemoryFactor: Constants.DEFAULT_GPU_MEMORY_FACTOR,
overwriteMode: OverwriteModeEnum.OVERWRITE_ALL,
fillMode: FillModeEnum._2D,
+ isFloodfillRestrictedToBoundingBox: false,
interpolationMode: InterpolationModeEnum.INTERPOLATE,
useLegacyBindings: false,
quickSelect: {
diff --git a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts
index fc559dedd61..3cc0b15b089 100644
--- a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts
+++ b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts
@@ -10,6 +10,8 @@ import type {
import type { ServerTracing, TracingType } from "types/api_flow_types";
import { TracingTypeEnum } from "types/api_flow_types";
import type { SaveQueueType } from "oxalis/model/actions/save_actions";
+import BoundingBox from "../bucket_data_handling/bounding_box";
+import type { Vector3 } from "oxalis/constants";
export function maybeGetSomeTracing(
tracing: Tracing,
@@ -86,7 +88,17 @@ export function selectTracing(
return tracing;
}
+
export const getUserBoundingBoxesFromState = (state: OxalisState): Array => {
const maybeSomeTracing = maybeGetSomeTracing(state.tracing);
return maybeSomeTracing != null ? maybeSomeTracing.userBoundingBoxes : [];
};
+
+export const getUserBoundingBoxesThatContainPosition = (
+ state: OxalisState,
+ position: Vector3,
+): Array => {
+ const bboxes = getUserBoundingBoxesFromState(state);
+
+ return bboxes.filter((el) => new BoundingBox(el.boundingBox).containsPoint(position));
+};
diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts
index 94adfbfea5f..6bc025d7b67 100644
--- a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts
+++ b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts
@@ -39,6 +39,10 @@ const warnMergeWithoutPendingOperations = _.throttle(() => {
);
}, WARNING_THROTTLE_THRESHOLD);
+const warnAwaitedMissingBucket = _.throttle(() => {
+ ErrorHandling.notify(new Error("Awaited missing bucket"));
+}, WARNING_THROTTLE_THRESHOLD);
+
export function assertNonNullBucket(bucket: Bucket): asserts bucket is DataBucket {
if (bucket.type === "null") {
throw new Error("Unexpected null bucket.");
@@ -773,7 +777,7 @@ export class DataBucket {
// In the past, ensureLoaded() never returned if the bucket
// was MISSING. This log might help to discover potential
// bugs which could arise in combination with MISSING buckets.
- console.warn("Awaited missing bucket.");
+ warnAwaitedMissingBucket();
}
}
}
diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts
index 479d8962888..1df073770d6 100644
--- a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts
+++ b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts
@@ -487,7 +487,7 @@ class DataCube {
additionalCoordinates: AdditionalCoordinate[] | null,
segmentIdNumber: number,
dimensionIndices: DimensionMap,
- floodfillBoundingBox: BoundingBoxType,
+ _floodfillBoundingBox: BoundingBoxType,
zoomStep: number,
progressCallback: ProgressCallback,
use3D: boolean,
@@ -498,14 +498,17 @@ class DataCube {
}> {
// This flood-fill algorithm works in two nested levels and uses a list of buckets to flood fill.
// On the inner level a bucket is flood-filled and if the iteration of the buckets data
- // reaches an neighbour bucket, this bucket is added to this list of buckets to flood fill.
+ // reaches a neighbour bucket, this bucket is added to this list of buckets to flood fill.
// The outer level simply iterates over all buckets in the list and triggers the bucket-wise flood fill.
// Additionally a map is created that saves all labeled voxels for each bucket. This map is returned at the end.
//
- // Note: It is possible that a bucket is multiple times added to the list of buckets. This is intended
+ // Note: It is possible that a bucket is added multiple times to the list of buckets. This is intended
// because a border of the "neighbour volume shape" might leave the neighbour bucket and enter it somewhere else.
// If it would not be possible to have the same neighbour bucket in the list multiple times,
// not all of the target area in the neighbour bucket might be filled.
+
+ const floodfillBoundingBox = new BoundingBox(_floodfillBoundingBox);
+
// Helper function to convert between xyz and uvw (both directions)
const transpose = (voxel: Vector3): Vector3 =>
Dimensions.transDimWithIndices(voxel, dimensionIndices);
@@ -517,12 +520,12 @@ class DataCube {
zoomStep,
);
const seedBucket = this.getOrCreateBucket(seedBucketAddress);
- let coveredBBoxMin: Vector3 = [
+ const coveredBBoxMin: Vector3 = [
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
Number.POSITIVE_INFINITY,
];
- let coveredBBoxMax: Vector3 = [0, 0, 0];
+ const coveredBBoxMax: Vector3 = [0, 0, 0];
if (seedBucket.type === "null") {
return {
@@ -689,35 +692,37 @@ class DataCube {
} else {
// Label the current neighbour and add it to the neighbourVoxelStackUvw to iterate over its neighbours.
const neighbourVoxelIndex = this.getVoxelIndexByVoxelOffset(neighbourVoxelXyz);
+ const currentGlobalPosition = V3.add(
+ currentGlobalBucketPosition,
+ V3.scale3(adjustedNeighbourVoxelXyz, currentMag),
+ );
if (bucketData[neighbourVoxelIndex] === sourceSegmentId) {
- bucketData[neighbourVoxelIndex] = segmentId;
- markUvwInSliceAsLabeled(neighbourVoxelUvw);
- neighbourVoxelStackUvw.pushVoxel(neighbourVoxelUvw);
- labeledVoxelCount++;
- const currentGlobalPosition = V3.add(
- currentGlobalBucketPosition,
- V3.scale3(adjustedNeighbourVoxelXyz, currentMag),
- );
- coveredBBoxMin = [
- Math.min(coveredBBoxMin[0], currentGlobalPosition[0]),
- Math.min(coveredBBoxMin[1], currentGlobalPosition[1]),
- Math.min(coveredBBoxMin[2], currentGlobalPosition[2]),
- ];
- // The maximum is exclusive which is why we add 1 to the position
- coveredBBoxMax = [
- Math.max(coveredBBoxMax[0], currentGlobalPosition[0] + 1),
- Math.max(coveredBBoxMax[1], currentGlobalPosition[1] + 1),
- Math.max(coveredBBoxMax[2], currentGlobalPosition[2] + 1),
- ];
-
- if (labeledVoxelCount % 1000000 === 0) {
- console.log(`Labeled ${labeledVoxelCount} Vx. Continuing...`);
-
- await progressCallback(
- false,
- `Labeled ${labeledVoxelCount / 1000000} MVx. Continuing...`,
- );
+ if (floodfillBoundingBox.containsPoint(currentGlobalPosition)) {
+ bucketData[neighbourVoxelIndex] = segmentId;
+ markUvwInSliceAsLabeled(neighbourVoxelUvw);
+ neighbourVoxelStackUvw.pushVoxel(neighbourVoxelUvw);
+ labeledVoxelCount++;
+
+ coveredBBoxMin[0] = Math.min(coveredBBoxMin[0], currentGlobalPosition[0]);
+ coveredBBoxMin[1] = Math.min(coveredBBoxMin[1], currentGlobalPosition[1]);
+ coveredBBoxMin[2] = Math.min(coveredBBoxMin[2], currentGlobalPosition[2]);
+
+ // The maximum is exclusive which is why we add 1 to the position
+ coveredBBoxMax[0] = Math.max(coveredBBoxMax[0], currentGlobalPosition[0] + 1);
+ coveredBBoxMax[1] = Math.max(coveredBBoxMax[1], currentGlobalPosition[1] + 1);
+ coveredBBoxMax[2] = Math.max(coveredBBoxMax[2], currentGlobalPosition[2] + 1);
+
+ if (labeledVoxelCount % 1000000 === 0) {
+ console.log(`Labeled ${labeledVoxelCount} Vx. Continuing...`);
+
+ await progressCallback(
+ false,
+ `Labeled ${labeledVoxelCount / 1000000} MVx. Continuing...`,
+ );
+ }
+ } else {
+ wasBoundingBoxExceeded = true;
}
}
}
diff --git a/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx
new file mode 100644
index 00000000000..320cd51678c
--- /dev/null
+++ b/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx
@@ -0,0 +1,323 @@
+import { V2, V3 } from "libs/mjs";
+import createProgressCallback from "libs/progress_callback";
+import Toast from "libs/toast";
+import * as Utils from "libs/utils";
+import type {
+ BoundingBoxType,
+ LabeledVoxelsMap,
+ OrthoView,
+ Vector2,
+ Vector3,
+} from "oxalis/constants";
+import Constants, { FillModeEnum, Unicode } from "oxalis/constants";
+
+import { getDatasetBoundingBox, getMagInfo } from "oxalis/model/accessors/dataset_accessor";
+import { getActiveMagIndexForLayer } from "oxalis/model/accessors/flycam_accessor";
+import { enforceActiveVolumeTracing } from "oxalis/model/accessors/volumetracing_accessor";
+import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions";
+import { setBusyBlockingInfoAction } from "oxalis/model/actions/ui_actions";
+import {
+ finishAnnotationStrokeAction,
+ updateSegmentAction,
+} from "oxalis/model/actions/volumetracing_actions";
+import BoundingBox from "oxalis/model/bucket_data_handling/bounding_box";
+import Dimensions from "oxalis/model/dimensions";
+import type { Saga } from "oxalis/model/sagas/effect-generators";
+import { select, take } from "oxalis/model/sagas/effect-generators";
+import { requestBucketModificationInVolumeTracing } from "oxalis/model/sagas/saga_helpers";
+import { Model } from "oxalis/singletons";
+import { call, put } from "typed-redux-saga";
+import { getUserBoundingBoxesThatContainPosition } from "../../accessors/tracing_accessor";
+import { applyLabeledVoxelMapToAllMissingMags } from "./helpers";
+import _ from "lodash";
+
+const NO_FLOODFILL_BBOX_TOAST_KEY = "NO_FLOODFILL_BBOX";
+const NO_SUCCESS_MSG_WHEN_WITHIN_MS = 500;
+
+function* getBoundingBoxForFloodFill(
+ position: Vector3,
+ currentViewport: OrthoView,
+): Saga {
+ const isRestrictedToBoundingBox = yield* select(
+ (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox,
+ );
+ const fillMode = yield* select((state) => state.userConfiguration.fillMode);
+ if (isRestrictedToBoundingBox) {
+ const bboxes = yield* select((state) =>
+ getUserBoundingBoxesThatContainPosition(state, position),
+ );
+ if (bboxes.length > 0) {
+ const smallestBbox = _.sortBy(bboxes, (bbox) =>
+ new BoundingBox(bbox.boundingBox).getVolume(),
+ )[0];
+
+ const maximumVoxelSize =
+ Constants.FLOOD_FILL_MULTIPLIER_FOR_BBOX_RESTRICTION *
+ V3.prod(Constants.FLOOD_FILL_EXTENTS[fillMode]);
+ const bboxObj = new BoundingBox(smallestBbox.boundingBox);
+
+ const bboxVolume =
+ fillMode === FillModeEnum._3D
+ ? bboxObj.getVolume()
+ : // Only consider the 2D projection of the bounding box onto the current viewport
+ V2.prod(
+ Dimensions.getIndices(currentViewport).map(
+ (idx) => bboxObj.getSize()[idx],
+ ) as Vector2,
+ );
+ if (bboxVolume > maximumVoxelSize) {
+ return {
+ failureReason: `The bounding box that encloses the clicked position is too large. Shrink its size so that it does not contain more than ${maximumVoxelSize} voxels.`,
+ };
+ }
+ return smallestBbox.boundingBox;
+ } else {
+ return {
+ failureReason:
+ "No bounding box encloses the clicked position. Either disable the bounding box restriction or ensure a bounding box exists around the clicked position.",
+ };
+ }
+ }
+
+ const halfBoundingBoxSizeUVW = V3.scale(Constants.FLOOD_FILL_EXTENTS[fillMode], 0.5);
+ const currentViewportBounding = {
+ min: V3.sub(position, halfBoundingBoxSizeUVW),
+ max: V3.add(position, halfBoundingBoxSizeUVW),
+ };
+
+ if (fillMode === FillModeEnum._2D) {
+ // Only use current plane
+ const thirdDimension = Dimensions.thirdDimensionForPlane(currentViewport);
+ const numberOfSlices = 1;
+ currentViewportBounding.min[thirdDimension] = position[thirdDimension];
+ currentViewportBounding.max[thirdDimension] = position[thirdDimension] + numberOfSlices;
+ }
+
+ const datasetBoundingBox = yield* select((state) => getDatasetBoundingBox(state.dataset));
+ const { min: clippedMin, max: clippedMax } = new BoundingBox(
+ currentViewportBounding,
+ ).intersectedWith(datasetBoundingBox);
+ return {
+ min: clippedMin,
+ max: clippedMax,
+ };
+}
+
+export function* floodFill(): Saga {
+ yield* take("INITIALIZE_VOLUMETRACING");
+ const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate);
+
+ while (allowUpdate) {
+ const floodFillAction = yield* take("FLOOD_FILL");
+
+ if (floodFillAction.type !== "FLOOD_FILL") {
+ throw new Error("Unexpected action. Satisfy typescript.");
+ }
+
+ const { position: positionFloat, planeId } = floodFillAction;
+ const volumeTracing = yield* select(enforceActiveVolumeTracing);
+ if (volumeTracing.hasEditableMapping) {
+ const message = "Volume modification is not allowed when an editable mapping is active.";
+ Toast.error(message);
+ console.error(message);
+ continue;
+ }
+ const segmentationLayer = yield* call(
+ [Model, Model.getSegmentationTracingLayer],
+ volumeTracing.tracingId,
+ );
+ const { cube } = segmentationLayer;
+ const seedPosition = Dimensions.roundCoordinate(positionFloat);
+ const activeCellId = volumeTracing.activeCellId;
+ const dimensionIndices = Dimensions.getIndices(planeId);
+ const requestedZoomStep = yield* select((state) =>
+ getActiveMagIndexForLayer(state, segmentationLayer.name),
+ );
+ const magInfo = yield* call(getMagInfo, segmentationLayer.mags);
+ const labeledZoomStep = magInfo.getClosestExistingIndex(requestedZoomStep);
+ const additionalCoordinates = yield* select((state) => state.flycam.additionalCoordinates);
+ const oldSegmentIdAtSeed = cube.getDataValue(
+ seedPosition,
+ additionalCoordinates,
+ null,
+ labeledZoomStep,
+ );
+
+ if (activeCellId === oldSegmentIdAtSeed) {
+ Toast.warning("The clicked voxel's id is already equal to the active segment id.");
+ continue;
+ }
+
+ const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo);
+
+ if (busyBlockingInfo.isBusy) {
+ console.warn(`Ignoring floodfill request (reason: ${busyBlockingInfo.reason || "unknown"})`);
+ continue;
+ }
+ // As the flood fill will be applied to the volume layer,
+ // the potentially existing mapping should be locked to ensure a consistent state.
+ const isModificationAllowed = yield* call(
+ requestBucketModificationInVolumeTracing,
+ volumeTracing,
+ );
+ if (!isModificationAllowed) {
+ continue;
+ }
+ const boundingBoxForFloodFill = yield* call(getBoundingBoxForFloodFill, seedPosition, planeId);
+ if ("failureReason" in boundingBoxForFloodFill) {
+ Toast.warning(boundingBoxForFloodFill.failureReason, {
+ key: NO_FLOODFILL_BBOX_TOAST_KEY,
+ });
+ continue;
+ } else {
+ Toast.close(NO_FLOODFILL_BBOX_TOAST_KEY);
+ }
+ yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed."));
+ const progressCallback = createProgressCallback({
+ pauseDelay: 200,
+ successMessageDelay: 2000,
+ });
+ yield* call(progressCallback, false, "Performing floodfill...");
+ console.time("cube.floodFill");
+ const startTimeOfFloodfill = performance.now();
+ const fillMode = yield* select((state) => state.userConfiguration.fillMode);
+
+ const {
+ bucketsWithLabeledVoxelsMap: labelMasksByBucketAndW,
+ wasBoundingBoxExceeded,
+ coveredBoundingBox,
+ } = yield* call(
+ { context: cube, fn: cube.floodFill },
+ seedPosition,
+ additionalCoordinates,
+ activeCellId,
+ dimensionIndices,
+ boundingBoxForFloodFill,
+ labeledZoomStep,
+ progressCallback,
+ fillMode === FillModeEnum._3D,
+ );
+ console.timeEnd("cube.floodFill");
+ yield* call(progressCallback, false, "Finalizing floodfill...");
+ const indexSet: Set = new Set();
+
+ for (const labelMaskByIndex of labelMasksByBucketAndW.values()) {
+ for (const zIndex of labelMaskByIndex.keys()) {
+ indexSet.add(zIndex);
+ }
+ }
+
+ console.time("applyLabeledVoxelMapToAllMissingMags");
+
+ for (const indexZ of indexSet) {
+ const labeledVoxelMapFromFloodFill: LabeledVoxelsMap = new Map();
+
+ for (const [bucketAddress, labelMaskByIndex] of labelMasksByBucketAndW.entries()) {
+ const map = labelMaskByIndex.get(indexZ);
+
+ if (map != null) {
+ labeledVoxelMapFromFloodFill.set(bucketAddress, map);
+ }
+ }
+
+ applyLabeledVoxelMapToAllMissingMags(
+ labeledVoxelMapFromFloodFill,
+ labeledZoomStep,
+ dimensionIndices,
+ magInfo,
+ cube,
+ activeCellId,
+ indexZ,
+ true,
+ );
+ }
+
+ yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId));
+ yield* put(
+ updateSegmentAction(
+ volumeTracing.activeCellId,
+ {
+ somePosition: seedPosition,
+ someAdditionalCoordinates: additionalCoordinates || undefined,
+ },
+ volumeTracing.tracingId,
+ ),
+ );
+
+ console.timeEnd("applyLabeledVoxelMapToAllMissingMags");
+
+ let hideSuccessMsgFnBox: { hideFn: () => void } | undefined;
+ if (wasBoundingBoxExceeded) {
+ const isRestrictedToBoundingBox = yield* select(
+ (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox,
+ );
+ // Don't notify the user about early-terminated floodfills if the floodfill
+ // was configured to be restricted, anyway. Also, don't create a new bounding
+ // box in that case.
+ if (!isRestrictedToBoundingBox) {
+ // The bounding box is overkill for the 2D mode because in that case,
+ // it's trivial to check the borders manually.
+ const createNewBoundingBox = fillMode === FillModeEnum._3D;
+ const warningDetails = createNewBoundingBox
+ ? "A bounding box that represents the labeled volume was added so that you can check the borders manually."
+ : "Please check the borders of the filled area manually and use the fill tool again if necessary.";
+
+ // Pre-declare a variable for the hide function so that we can refer
+ // to that var within the toast content. We don't want to use message.destroy
+ // because this ignores the setTimeout within the progress callback utility.
+ // Without this, hide functions for older toasts could still be triggered (due to
+ // timeout) that act on new ones then.
+ let hideBox: { hideFn: () => void } | undefined;
+ hideBox = yield* call(
+ progressCallback,
+ true,
+ <>
+ Floodfill is done, but terminated because{" "}
+ {isRestrictedToBoundingBox
+ ? "the labeled volume touched the bounding box to which the floodfill was restricted"
+ : "the labeled volume got too large"}
+ .
+
+ {warningDetails} {Unicode.NonBreakingSpace}
+ hideBox?.hideFn()}>
+ Close
+
+ >,
+ {
+ successMessageDelay: 10000,
+ },
+ );
+ if (createNewBoundingBox) {
+ yield* put(
+ 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 {
+ hideSuccessMsgFnBox = yield* call(progressCallback, true, "Floodfill done.");
+ }
+ } else {
+ hideSuccessMsgFnBox = yield* call(progressCallback, true, "Floodfill done.");
+ }
+
+ const floodfillDuration = performance.now() - startTimeOfFloodfill;
+ const wasFloodfillQuick = floodfillDuration < NO_SUCCESS_MSG_WHEN_WITHIN_MS;
+
+ if (hideSuccessMsgFnBox != null && wasFloodfillQuick) {
+ hideSuccessMsgFnBox.hideFn();
+ }
+
+ cube.triggerPushQueue();
+ yield* put(setBusyBlockingInfoAction(false));
+
+ if (floodFillAction.callback != null) {
+ floodFillAction.callback();
+ }
+ }
+}
diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx
index 12e1919ac31..463e2ea4c13 100644
--- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx
+++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx
@@ -1,41 +1,27 @@
-import { message } from "antd";
import { diffDiffableMaps } from "libs/diffable_map";
import { V3 } from "libs/mjs";
-import createProgressCallback from "libs/progress_callback";
import Toast from "libs/toast";
-import * as Utils from "libs/utils";
import _ from "lodash";
import memoizeOne from "memoize-one";
import type {
AnnotationTool,
- BoundingBoxType,
ContourMode,
- LabeledVoxelsMap,
OrthoView,
OverwriteMode,
Vector3,
} from "oxalis/constants";
-import Constants, {
+import {
AnnotationToolEnum,
ContourModeEnum,
- FillModeEnum,
OrthoViews,
- Unicode,
OverwriteModeEnum,
} from "oxalis/constants";
import getSceneController from "oxalis/controller/scene_controller_provider";
import { CONTOUR_COLOR_DELETE, CONTOUR_COLOR_NORMAL } from "oxalis/geometries/helper_geometries";
-import {
- getDatasetBoundingBox,
- getMaximumSegmentIdForLayer,
- getMagInfo,
-} from "oxalis/model/accessors/dataset_accessor";
-import {
- getPosition,
- getActiveMagIndexForLayer,
- getRotation,
-} from "oxalis/model/accessors/flycam_accessor";
+import messages from "messages";
+import { getMaximumSegmentIdForLayer } from "oxalis/model/accessors/dataset_accessor";
+import { getPosition, getRotation } from "oxalis/model/accessors/flycam_accessor";
import {
isBrushTool,
isTraceTool,
@@ -56,7 +42,6 @@ import type {
AddAdHocMeshAction,
AddPrecomputedMeshAction,
} from "oxalis/model/actions/annotation_actions";
-import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions";
import {
updateTemporarySettingAction,
updateUserSettingAction,
@@ -64,9 +49,9 @@ import {
import { setBusyBlockingInfoAction, setToolAction } from "oxalis/model/actions/ui_actions";
import type {
ClickSegmentAction,
- SetActiveCellAction,
CreateCellAction,
DeleteSegmentDataAction,
+ SetActiveCellAction,
} from "oxalis/model/actions/volumetracing_actions";
import {
finishAnnotationStrokeAction,
@@ -74,9 +59,7 @@ import {
setSelectedSegmentsOrGroupAction,
updateSegmentAction,
} from "oxalis/model/actions/volumetracing_actions";
-import BoundingBox from "oxalis/model/bucket_data_handling/bounding_box";
import { markVolumeTransactionEnd } from "oxalis/model/bucket_data_handling/bucket";
-import Dimensions from "oxalis/model/dimensions";
import type { Saga } from "oxalis/model/sagas/effect-generators";
import { select, take } from "oxalis/model/sagas/effect-generators";
import listenToMinCut from "oxalis/model/sagas/min_cut_saga";
@@ -85,34 +68,27 @@ import {
requestBucketModificationInVolumeTracing,
takeEveryUnlessBusy,
} from "oxalis/model/sagas/saga_helpers";
-import {
- deleteSegmentDataVolumeAction,
- type UpdateAction,
- updateSegmentGroups,
-} from "oxalis/model/sagas/update_actions";
import {
createSegmentVolumeAction,
+ deleteSegmentDataVolumeAction,
deleteSegmentVolumeAction,
removeFallbackLayer,
+ updateMappingName,
+ updateSegmentGroups,
updateSegmentVolumeAction,
updateUserBoundingBoxes,
updateVolumeTracing,
- updateMappingName,
+ type UpdateAction,
} from "oxalis/model/sagas/update_actions";
import type VolumeLayer from "oxalis/model/volumetracing/volumelayer";
import { Model, api } from "oxalis/singletons";
import type { Flycam, SegmentMap, VolumeTracing } from "oxalis/store";
+import type { ActionPattern } from "redux-saga/effects";
import { actionChannel, call, fork, put, takeEvery, takeLatest } from "typed-redux-saga";
-import {
- applyLabeledVoxelMapToAllMissingMags,
- createVolumeLayer,
- labelWithVoxelBuffer2D,
- type BooleanBox,
-} from "./volume/helpers";
-import maybeInterpolateSegmentationLayer from "./volume/volume_interpolation_saga";
-import messages from "messages";
import { pushSaveQueueTransaction } from "../actions/save_actions";
-import type { ActionPattern } from "redux-saga/effects";
+import { createVolumeLayer, labelWithVoxelBuffer2D, type BooleanBox } from "./volume/helpers";
+import maybeInterpolateSegmentationLayer from "./volume/volume_interpolation_saga";
+import { floodFill } from "./volume/floodfill_saga";
const OVERWRITE_EMPTY_WARNING_KEY = "OVERWRITE-EMPTY-WARNING";
@@ -370,212 +346,6 @@ export function* editVolumeLayerAsync(): Saga {
}
}
-function* getBoundingBoxForFloodFill(
- position: Vector3,
- currentViewport: OrthoView,
-): Saga {
- const fillMode = yield* select((state) => state.userConfiguration.fillMode);
- const halfBoundingBoxSizeUVW = V3.scale(Constants.FLOOD_FILL_EXTENTS[fillMode], 0.5);
- const currentViewportBounding = {
- min: V3.sub(position, halfBoundingBoxSizeUVW),
- max: V3.add(position, halfBoundingBoxSizeUVW),
- };
-
- if (fillMode === FillModeEnum._2D) {
- // Only use current plane
- const thirdDimension = Dimensions.thirdDimensionForPlane(currentViewport);
- const numberOfSlices = 1;
- currentViewportBounding.min[thirdDimension] = position[thirdDimension];
- currentViewportBounding.max[thirdDimension] = position[thirdDimension] + numberOfSlices;
- }
-
- const datasetBoundingBox = yield* select((state) => getDatasetBoundingBox(state.dataset));
- const { min: clippedMin, max: clippedMax } = new BoundingBox(
- currentViewportBounding,
- ).intersectedWith(datasetBoundingBox);
- return {
- min: clippedMin,
- max: clippedMax,
- };
-}
-
-const FLOODFILL_PROGRESS_KEY = "FLOODFILL_PROGRESS_KEY";
-export function* floodFill(): Saga {
- yield* take("INITIALIZE_VOLUMETRACING");
- const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate);
-
- while (allowUpdate) {
- const floodFillAction = yield* take("FLOOD_FILL");
-
- if (floodFillAction.type !== "FLOOD_FILL") {
- throw new Error("Unexpected action. Satisfy typescript.");
- }
-
- const { position: positionFloat, planeId } = floodFillAction;
- const volumeTracing = yield* select(enforceActiveVolumeTracing);
- if (volumeTracing.hasEditableMapping) {
- const message = "Volume modification is not allowed when an editable mapping is active.";
- Toast.error(message);
- console.error(message);
- continue;
- }
- const segmentationLayer = yield* call(
- [Model, Model.getSegmentationTracingLayer],
- volumeTracing.tracingId,
- );
- const { cube } = segmentationLayer;
- const seedPosition = Dimensions.roundCoordinate(positionFloat);
- const activeCellId = volumeTracing.activeCellId;
- const dimensionIndices = Dimensions.getIndices(planeId);
- const requestedZoomStep = yield* select((state) =>
- getActiveMagIndexForLayer(state, segmentationLayer.name),
- );
- const magInfo = yield* call(getMagInfo, segmentationLayer.mags);
- const labeledZoomStep = magInfo.getClosestExistingIndex(requestedZoomStep);
- const additionalCoordinates = yield* select((state) => state.flycam.additionalCoordinates);
- const oldSegmentIdAtSeed = cube.getDataValue(
- seedPosition,
- additionalCoordinates,
- null,
- labeledZoomStep,
- );
-
- if (activeCellId === oldSegmentIdAtSeed) {
- Toast.warning("The clicked voxel's id is already equal to the active segment id.");
- continue;
- }
-
- const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo);
-
- if (busyBlockingInfo.isBusy) {
- console.warn(`Ignoring floodfill request (reason: ${busyBlockingInfo.reason || "unknown"})`);
- continue;
- }
- // As the flood fill will be applied to the volume layer,
- // the potentially existing mapping should be locked to ensure a consistent state.
- const isModificationAllowed = yield* call(
- requestBucketModificationInVolumeTracing,
- volumeTracing,
- );
- if (!isModificationAllowed) {
- continue;
- }
- yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed."));
- const boundingBoxForFloodFill = yield* call(getBoundingBoxForFloodFill, seedPosition, planeId);
- const progressCallback = createProgressCallback({
- pauseDelay: 200,
- successMessageDelay: 2000,
- // Since only one floodfill operation can be active at any time,
- // a hardcoded key is sufficient.
- key: "FLOODFILL_PROGRESS_KEY",
- });
- yield* call(progressCallback, false, "Performing floodfill...");
- console.time("cube.floodFill");
- const fillMode = yield* select((state) => state.userConfiguration.fillMode);
-
- const {
- bucketsWithLabeledVoxelsMap: labelMasksByBucketAndW,
- wasBoundingBoxExceeded,
- coveredBoundingBox,
- } = yield* call(
- { context: cube, fn: cube.floodFill },
- seedPosition,
- additionalCoordinates,
- activeCellId,
- dimensionIndices,
- boundingBoxForFloodFill,
- labeledZoomStep,
- progressCallback,
- fillMode === FillModeEnum._3D,
- );
- console.timeEnd("cube.floodFill");
- yield* call(progressCallback, false, "Finalizing floodfill...");
- const indexSet: Set = new Set();
-
- for (const labelMaskByIndex of labelMasksByBucketAndW.values()) {
- for (const zIndex of labelMaskByIndex.keys()) {
- indexSet.add(zIndex);
- }
- }
-
- console.time("applyLabeledVoxelMapToAllMissingMags");
-
- for (const indexZ of indexSet) {
- const labeledVoxelMapFromFloodFill: LabeledVoxelsMap = new Map();
-
- for (const [bucketAddress, labelMaskByIndex] of labelMasksByBucketAndW.entries()) {
- const map = labelMaskByIndex.get(indexZ);
-
- if (map != null) {
- labeledVoxelMapFromFloodFill.set(bucketAddress, map);
- }
- }
-
- applyLabeledVoxelMapToAllMissingMags(
- labeledVoxelMapFromFloodFill,
- labeledZoomStep,
- dimensionIndices,
- magInfo,
- cube,
- activeCellId,
- indexZ,
- true,
- );
- }
-
- yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId));
- yield* put(
- updateSegmentAction(
- volumeTracing.activeCellId,
- {
- somePosition: seedPosition,
- someAdditionalCoordinates: additionalCoordinates || undefined,
- },
- volumeTracing.tracingId,
- ),
- );
-
- console.timeEnd("applyLabeledVoxelMapToAllMissingMags");
-
- if (wasBoundingBoxExceeded) {
- yield* call(
- progressCallback,
- true,
- <>
- Floodfill is done, but terminated since the labeled volume got too large. A bounding box
-
- that represents the labeled volume was added.{Unicode.NonBreakingSpace}
- message.destroy(FLOODFILL_PROGRESS_KEY)}>
- Close
-
- >,
- {
- successMessageDelay: 10000,
- },
- );
- yield* put(
- 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.");
- }
-
- cube.triggerPushQueue();
- yield* put(setBusyBlockingInfoAction(false));
-
- if (floodFillAction.callback != null) {
- floodFillAction.callback();
- }
- }
-}
-
export function* finishLayer(
layer: VolumeLayer,
activeTool: AnnotationTool,
diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts
index 61762134fd8..6b03af7e3f4 100644
--- a/frontend/javascripts/oxalis/store.ts
+++ b/frontend/javascripts/oxalis/store.ts
@@ -394,6 +394,7 @@ export type UserConfiguration = {
// how volume annotations overwrite existing voxels.
readonly overwriteMode: OverwriteMode;
readonly fillMode: FillMode;
+ readonly isFloodfillRestrictedToBoundingBox: boolean;
readonly interpolationMode: InterpolationMode;
readonly useLegacyBindings: boolean;
readonly quickSelect: QuickSelectConfig;
diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx
index 2693a3d8cc5..a48140cc1b6 100644
--- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx
+++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx
@@ -1285,7 +1285,7 @@ function ToolSpecificSettings({
) : null}
- {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null}
+ {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null}
{adaptedActiveTool === AnnotationToolEnum.PROOFREAD ? : null}
@@ -1369,6 +1369,41 @@ const handleSetFillMode = (event: RadioChangeEvent) => {
Store.dispatch(updateUserSettingAction("fillMode", event.target.value));
};
+function FloodFillSettings() {
+ const dispatch = useDispatch();
+ const isRestrictedToBoundingBox = useSelector(
+ (state: OxalisState) => state.userConfiguration.isFloodfillRestrictedToBoundingBox,
+ );
+ const toggleRestrictFloodfillToBoundingBox = () => {
+ dispatch(
+ updateUserSettingAction("isFloodfillRestrictedToBoundingBox", !isRestrictedToBoundingBox),
+ );
+ };
+ return (
+
+
+
+
+
+
+
+ );
+}
+
function FillModeSwitch() {
const fillMode = useSelector((state: OxalisState) => state.userConfiguration.fillMode);
return (
diff --git a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx
index a73f91a2e55..ef0fcf610f0 100644
--- a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx
+++ b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx
@@ -496,7 +496,7 @@ class TracingActionsView extends React.PureComponent {
hasTracing
? [
{
,
void;
};
type StateProps = {
@@ -180,6 +188,8 @@ type NoNodeContextMenuProps = Props & {
infoRows: ItemType[];
};
+const hideContextMenu = () => Store.dispatch(hideContextMenuAction());
+
export const getNoActionsAvailableMenu = (hideContextMenu: () => void): MenuProps => ({
onClick: hideContextMenu,
style: {
@@ -760,7 +770,6 @@ function getBoundingBoxMenuOptions({
activeTool,
clickedBoundingBoxId,
userBoundingBoxes,
- hideContextMenu,
allowUpdate,
}: NoNodeContextMenuProps): ItemType[] {
if (globalPosition == null) return [];
@@ -1331,14 +1340,46 @@ export function GenericContextMenuContainer(props: {
// @ts-ignore
ref={inputRef}
/>
- {props.children}
+ {/* Disable animations for the context menu (for performance reasons). */}
+
+
+ {props.children}
+
+
);
}
-const hideContextMenu = () => Store.dispatch(hideContextMenuAction());
function WkContextMenu() {
+ const contextMenuPosition = useSelector((state: OxalisState) => {
+ return state.uiInformation.contextInfo.contextMenuPosition;
+ });
+
+ return (
+
+ {contextMenuPosition != null ? : }
+
+ );
+}
+
+function getInfoMenuItem(
+ key: MenuItemType["key"],
+ label: MenuItemType["label"],
+): MenuItemGroupType {
+ /*
+ * This component is a work-around. We want antd menu entries that can not be selected
+ * or otherwise interacted with. An "empty" menu group will only display the group header
+ * which gives us the desired behavior.
+ */
+
+ return { key, label, type: "group" };
+}
+
+function ContextMenuInner() {
const props = useSelector((state: OxalisState) => {
const visibleSegmentationLayer = getVisibleSegmentationLayer(state);
const mappingInfo = getMappingInfo(
@@ -1380,46 +1421,19 @@ function WkContextMenu() {
maybeClickedMeshId: contextInfo.meshId,
maybeMeshIntersectionPosition: contextInfo.meshIntersectionPosition,
maybeUnmappedSegmentId: contextInfo.unmappedSegmentId,
- hideContextMenu,
};
});
- return (
-
- {props.contextMenuPosition != null ? : }
-
- );
-}
-
-function getInfoMenuItem(
- key: MenuItemType["key"],
- label: MenuItemType["label"],
-): MenuItemGroupType {
- /*
- * This component is a work-around. We want antd menu entries that can not be selected
- * or otherwise interacted with. An "empty" menu group will only display the group header
- * which gives us the desired behavior.
- */
-
- return { key, label, type: "group" };
-}
-
-function ContextMenuInner(propsWithInputRef: Props) {
const [lastTimeSegmentInfoShouldBeFetched, setLastTimeSegmentInfoShouldBeFetched] = useState(
new Date(),
);
const inputRef = useContext(ContextMenuContext);
- const { ...props } = propsWithInputRef;
const {
skeletonTracing,
maybeClickedNodeId,
maybeClickedMeshId,
contextMenuPosition,
segments,
- hideContextMenu,
voxelSize,
globalPosition,
maybeViewport,
diff --git a/frontend/javascripts/test/sagas/saga_integration.mock.ts b/frontend/javascripts/test/sagas/saga_integration.mock.ts
index 355bd12cea6..9ddc7344564 100644
--- a/frontend/javascripts/test/sagas/saga_integration.mock.ts
+++ b/frontend/javascripts/test/sagas/saga_integration.mock.ts
@@ -14,10 +14,11 @@ mockRequire("antd", {
...antd,
Dropdown: {},
message: {
- show: () => {},
hide: () => {},
- loading: () => {},
- success: () => {},
+ // These return a "hide function"
+ show: () => () => {},
+ loading: () => () => {},
+ success: () => () => {},
},
});
diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts
index a4ff1868ad7..18e0b3cfc5b 100644
--- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts
+++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts
@@ -1,7 +1,7 @@
/* eslint-disable no-await-in-loop */
import "test/sagas/saga_integration.mock";
import _ from "lodash";
-import {
+import Constants, {
AnnotationToolEnum,
ContourModeEnum,
FillModeEnum,
@@ -274,6 +274,7 @@ test.serial("Executing a floodfill in mag 2", async (t) => {
);
}
});
+
test.serial("Executing a floodfill in mag 1 (long operation)", async (t) => {
t.context.mocks.Request.sendJSONReceiveArraybufferWithHeaders = createBucketResponseFunction(
Uint16Array,
@@ -291,6 +292,8 @@ test.serial("Executing a floodfill in mag 1 (long operation)", async (t) => {
Store.dispatch(updateUserSettingAction("fillMode", FillModeEnum._3D));
await dispatchFloodfillAsync(Store.dispatch, paintCenter, OrthoViews.PLANE_XY);
+ const EXPECTED_HALF_EXTENT = V3.scale(Constants.FLOOD_FILL_EXTENTS[FillModeEnum._3D], 0.5);
+
async function assertFloodFilledState() {
t.is(
await t.context.api.data.getDataValue(volumeTracingLayerName, paintCenter, 0),
@@ -298,8 +301,8 @@ test.serial("Executing a floodfill in mag 1 (long operation)", async (t) => {
);
t.false(hasRootSagaCrashed());
const cuboidData = await t.context.api.data.getDataForBoundingBox(volumeTracingLayerName, {
- min: [128 - 64, 128 - 64, 128 - 32],
- max: [128 + 64, 128 + 64, 128 + 32],
+ min: V3.sub(paintCenter, EXPECTED_HALF_EXTENT),
+ max: V3.add(paintCenter, EXPECTED_HALF_EXTENT),
});
// There should be no item which does not equal floodingCellId
t.is(
@@ -324,7 +327,7 @@ test.serial("Executing a floodfill in mag 1 (long operation)", async (t) => {
// Assert state after flood-fill
await assertFloodFilledState();
- // Undo created bounding box by flood fill and flood fill and assert initial state.
+ // Undo [the bounding box created by the flood fill] and [the flood fill itself] and assert initial state.
await dispatchUndoAsync(Store.dispatch);
await dispatchUndoAsync(Store.dispatch);
await assertInitialState();
diff --git a/frontend/stylesheets/trace_view/_action_bar.less b/frontend/stylesheets/trace_view/_action_bar.less
index 01de3b73df1..83db4c39caa 100644
--- a/frontend/stylesheets/trace_view/_action_bar.less
+++ b/frontend/stylesheets/trace_view/_action_bar.less
@@ -67,3 +67,8 @@
.selected-layout-item {
font-weight: bold;
}
+
+.undo-redo-button {
+ // Avoid width-flickering when clicking the button
+ width: 36px !important;
+}
diff --git a/public/images/icon-restrict-floodfill-to-bbox.svg b/public/images/icon-restrict-floodfill-to-bbox.svg
new file mode 100644
index 00000000000..65e4eb81d8b
--- /dev/null
+++ b/public/images/icon-restrict-floodfill-to-bbox.svg
@@ -0,0 +1,32 @@
+
+
+
diff --git a/tools/test.sh b/tools/test.sh
index d2965686c97..404ce6d72d5 100755
--- a/tools/test.sh
+++ b/tools/test.sh
@@ -90,7 +90,7 @@ elif [ $cmd == "test-changed" ]
then
ensureUpToDateTests
# Find modified *.spec.* files, trim their extension (since ts != js) and look them up in the compiled bundle
- changed_files=$(git ls-files --modified | grep \\.spec\\. | xargs -i basename {} | sed -r 's|^(.*?)\.\w+$|\1|' | xargs -i find public-test/test-bundle -name "{}*")
+ changed_files=$(git ls-files --modified | grep \\.spec\\. | xargs -i basename {} | sed -r 's|^(.*?)\.\w+$|\1|' | xargs -i find public-test/test-bundle -name "{}*" | grep -E -v "\.(md|snap)")
if [ -z "$changed_files" ]
then