diff --git a/common/reviews/api/core.api.md b/common/reviews/api/core.api.md index e58d61a55d..c5b01ef618 100644 --- a/common/reviews/api/core.api.md +++ b/common/reviews/api/core.api.md @@ -889,7 +889,7 @@ function deepEqual(obj1: unknown, obj2: unknown): boolean; const deepMerge: (target?: {}, source?: {}, optionsArgument?: any) => any; // @public (undocumented) -const DefaultHistoryMemo: HistoryMemo_2; +const DefaultHistoryMemo: HistoryMemo; // @public (undocumented) interface DicomDateObject { @@ -1416,17 +1416,8 @@ function hexToRgb(hex: any): { b: number; }; -declare namespace HistoryMemo { - export { - Memo, - Memoable, - HistoryMemo_2 as HistoryMemo, - DefaultHistoryMemo - } -} - // @public (undocumented) -class HistoryMemo_2 { +class HistoryMemo { constructor(label?: string, size?: number); // (undocumented) readonly label: any; @@ -1441,6 +1432,15 @@ class HistoryMemo_2 { undo(items?: number): void; } +declare namespace HistoryMemo_2 { + export { + Memo, + Memoable, + HistoryMemo, + DefaultHistoryMemo + } +} + // @public (undocumented) type IBaseVolumeViewport = BaseVolumeViewport; @@ -2567,6 +2567,7 @@ type Mat3 = [number, number, number, number, number, number, number, number, num // @public (undocumented) type Memo = { restoreMemo: (undo?: boolean) => void; + commitMemo?: () => boolean; }; // @public (undocumented) @@ -2784,7 +2785,7 @@ class PointsManager { // (undocumented) static create2(initialSize?: number): PointsManager; // (undocumented) - static create3(initialSize?: number): PointsManager; + static create3(initialSize?: number, points?: Point3[]): PointsManager; // (undocumented) data: Float32Array; // (undocumented) @@ -3883,7 +3884,11 @@ declare namespace Types { IBaseVolumeViewport, GeometryLoaderFn, ScrollOptions_2 as ScrollOptions, - JumpToSliceOptions + JumpToSliceOptions, + Memo, + HistoryMemo, + VoxelManager, + RLEVoxelMap } } export { Types } @@ -3962,7 +3967,7 @@ declare namespace utilities { isValidVolume, metadataProvider_2 as genericMetadataProvider, isVideoTransferSyntax, - HistoryMemo, + HistoryMemo_2 as HistoryMemo, generateVolumePropsFromImageIds, getBufferConfiguration, VoxelManager, diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 9c14d133ed..428684dcdb 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -312,7 +312,7 @@ export abstract class AnnotationDisplayTool extends BaseTool { // (undocumented) protected createAnnotation(evt: EventTypes_2.InteractionEventType): Annotation; // (undocumented) - filterInteractableAnnotationsForElement(element: HTMLDivElement, annotations: Annotations): Annotations | undefined; + filterInteractableAnnotationsForElement(element: HTMLDivElement, annotations: Annotations): Annotations; // (undocumented) protected getReferencedImageId(viewport: Types_2.IViewport, worldPos: Types_2.Point3, viewPlaneNormal: Types_2.Point3, viewUp?: Types_2.Point3): string; // (undocumented) @@ -510,6 +510,39 @@ export abstract class AnnotationTool extends AnnotationDisplayTool { // (undocumented) static createAnnotationForViewport(viewport: any, ...annotationBaseData: any[]): Annotation; // (undocumented) + static createAnnotationMemo(element: any, annotation: Annotation, options?: { + newAnnotation?: boolean; + deleting?: boolean; + }): { + restoreMemo: () => void; + }; + // (undocumented) + protected static createAnnotationState(annotation: Annotation, deleting?: boolean): { + annotationUID: string; + data: { + [key: string]: unknown; + handles?: { + points?: Types_2.Point3[]; + activeHandleIndex?: number | null; + textBox?: { + hasMoved?: boolean; + worldPosition?: Types_2.Point3; + worldBoundingBox?: { + topLeft: Types_2.Point3; + topRight: Types_2.Point3; + bottomLeft: Types_2.Point3; + bottomRight: Types_2.Point3; + }; + }; + [key: string]: unknown; + }; + cachedStats?: Record; + }; + deleting: boolean; + }; + // (undocumented) + protected createMemo(element: any, annotation: any, options?: any): void; + // (undocumented) protected getAnnotationStyle(context: { annotation: Annotation; styleSpecifier: StyleSpecifier; @@ -667,6 +700,8 @@ export abstract class BaseTool { }; }; // (undocumented) + doneEditMemo(): void; + // (undocumented) protected getTargetId(viewport: Types_2.IViewport): string | undefined; // (undocumented) protected getTargetImageData(targetId: string): Types_2.IImageData | Types_2.CPUIImageData; @@ -915,6 +950,8 @@ enum ChangeTypes { // (undocumented) HandlesUpdated = "HandlesUpdated", // (undocumented) + History = "History", + // (undocumented) InitialSetup = "InitialSetup", // (undocumented) Interaction = "Interaction", @@ -1185,7 +1222,7 @@ export class CircleROITool extends AnnotationTool { } // @public (undocumented) -export class CircleScissorsTool extends BaseTool { +export class CircleScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -1209,6 +1246,7 @@ export class CircleScissorsTool extends BaseTool { hasMoved?: boolean; imageId: string; centerCanvas?: Array; + memo?: LabelmapMemo_2; } | null; // (undocumented) _endCallback: (evt: EventTypes_2.InteractionEventType) => void; @@ -1322,6 +1360,8 @@ export class CobbAngleTool extends AnnotationTool { isNearSecondLine: boolean; }; // (undocumented) + _dragCallback: (evt: EventTypes_2.MouseDragEventType | EventTypes_2.MouseMoveEventType) => void; + // (undocumented) editData: { annotation: Annotation; viewportIdsToRender: string[]; @@ -1333,6 +1373,8 @@ export class CobbAngleTool extends AnnotationTool { isNearSecondLine?: boolean; } | null; // (undocumented) + _endCallback: (evt: EventTypes_2.MouseUpEventType | EventTypes_2.MouseClickEventType) => void; + // (undocumented) getArcsStartEndPoints: ({ firstLine, secondLine, mid1, mid2, }: { firstLine: any; secondLine: any; @@ -1357,10 +1399,6 @@ export class CobbAngleTool extends AnnotationTool { // (undocumented) _mouseDownCallback: (evt: EventTypes_2.MouseUpEventType | EventTypes_2.MouseClickEventType) => void; // (undocumented) - _mouseDragCallback: (evt: EventTypes_2.MouseDragEventType | EventTypes_2.MouseMoveEventType) => void; - // (undocumented) - _mouseUpCallback: (evt: EventTypes_2.MouseUpEventType | EventTypes_2.MouseClickEventType) => void; - // (undocumented) renderAnnotation: (enabledElement: Types_2.IEnabledElement, svgDrawingHelper: SVGDrawingHelper) => boolean; // (undocumented) _throttledCalculateCachedStats: Function; @@ -1542,10 +1580,12 @@ type ContourAnnotationCompletedEventDetail = AnnotationCompletedEventDetail & { // @public (undocumented) type ContourAnnotationData = { data: { + cachedStats?: Record; contour: { polyline: Types_2.Point3[]; closed: boolean; windingDirection?: ContourWindingDirection; + pointsManager?: Types_2.IPointsManager; }; }; onInterpolationComplete?: () => void; @@ -1636,6 +1676,23 @@ function createCameraPositionSynchronizer(synchronizerName: string): Synchronize // @public (undocumented) function createImageSliceSynchronizer(synchronizerName: string): Synchronizer; +// @public (undocumented) +function createLabelmapMemo(segmentationId: string, segmentationVoxelManager: Types_2.IVoxelManager, preview?: InitializedOperationData): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: Types_2.IVoxelManager; + voxelManager: Types_2.IVoxelManager; + memo: LabelmapMemo_2; + preview: InitializedOperationData; +} | { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: Types_2.IVoxelManager; + voxelManager: utilities_2.VoxelManager; +}; + // @public (undocumented) function createLabelmapVolumeForViewport(input: { viewportId: string; @@ -1655,6 +1712,26 @@ function createPresentationViewSynchronizer(synchronizerName: string, options?: // @public (undocumented) function createPresentationViewSynchronizer_2(synchronizerName: string): Synchronizer; +// @public (undocumented) +function createPreviewMemo(segmentationId: string, preview: InitializedOperationData): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: Types_2.IVoxelManager; + voxelManager: Types_2.IVoxelManager; + memo: LabelmapMemo_2; + preview: InitializedOperationData; +}; + +// @public (undocumented) +function createRleMemo(segmentationId: string, segmentationVoxelManager: Types_2.IVoxelManager): { + segmentationId: string; + restoreMemo: typeof restoreMemo; + commitMemo: typeof commitMemo; + segmentationVoxelManager: Types_2.IVoxelManager; + voxelManager: utilities_2.VoxelManager; +}; + // @public (undocumented) const createStackImageSynchronizer: typeof createImageSliceSynchronizer; @@ -3259,6 +3336,25 @@ type KeyUpEventDetail = KeyDownEventDetail; // @public (undocumented) type KeyUpEventType = Types_2.CustomEventType; +declare namespace LabelmapMemo { + export { + createLabelmapMemo, + restoreMemo, + createRleMemo, + createPreviewMemo, + LabelmapMemo_2 as LabelmapMemo + } +} + +// @public (undocumented) +type LabelmapMemo_2 = Types_2.Memo & { + segmentationVoxelManager: Types_2.IVoxelManager; + voxelManager: Types_2.IVoxelManager; + redoVoxelManager?: Types_2.IVoxelManager; + undoVoxelManager?: Types_2.IVoxelManager; + memo?: LabelmapMemo_2; +}; + // @public (undocumented) type LabelmapStyle = BaseLabelmapStyle & InactiveLabelmapStyle; @@ -3279,6 +3375,7 @@ type LabelmapToolOperationData = { }; preview: any; toolGroupId: string; + createMemo: (segmentId: any, segmentVoxels: any, previewVoxels?: any, previewMemo?: any) => LabelmapMemo_2; }; // @public (undocumented) @@ -3803,8 +3900,6 @@ export class PanTool extends BaseTool { // (undocumented) mouseDragCallback(evt: EventTypes_2.InteractionEventType): void; // (undocumented) - preMouseDownCallback: (evt: EventTypes_2.InteractionEventType) => boolean; - // (undocumented) static toolName: any; // (undocumented) touchDragCallback(evt: EventTypes_2.InteractionEventType): void; @@ -4427,7 +4522,7 @@ declare namespace rectangleROITool { } // @public (undocumented) -export class RectangleScissorsTool extends BaseTool { +export class RectangleScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -4666,6 +4761,9 @@ function resetElementCursor(element: HTMLDivElement): void; // @public (undocumented) function resetToGlobalStyle(): void; +// @public (undocumented) +function restoreMemo(isUndo?: boolean): void; + // @public (undocumented) interface ROICachedStats { // (undocumented) @@ -4864,7 +4962,8 @@ declare namespace segmentation_2 { getSegmentIndexAtLabelmapBorder, getHoveredContourSegmentationAnnotation, getBrushToolInstances, - growCut + growCut, + LabelmapMemo } } @@ -5118,7 +5217,7 @@ type SphereInfo = { }; // @public (undocumented) -export class SphereScissorsTool extends BaseTool { +export class SphereScissorsTool extends LabelmapBaseTool { constructor(toolProps?: PublicToolProps, defaultToolProps?: ToolProps); // (undocumented) _activateDraw: (element: any) => void; @@ -6251,6 +6350,8 @@ export class VideoRedactionTool extends AnnotationTool { // (undocumented) _deactivateModify: (element: any) => void; // (undocumented) + _dragCallback: (evt: any) => void; + // (undocumented) editData: { annotation: Annotation; viewportUIDsToRender: string[]; @@ -6259,6 +6360,8 @@ export class VideoRedactionTool extends AnnotationTool { hasMoved?: boolean; } | null; // (undocumented) + _endCallback: (evt: any) => void; + // (undocumented) getHandleNearImagePoint: (element: any, annotation: any, canvasCoords: any, proximity: any) => any; // (undocumented) _getImageVolumeFromTargetUID(targetUID: any, renderingEngine: any): { @@ -6287,10 +6390,6 @@ export class VideoRedactionTool extends AnnotationTool { // (undocumented) isPointNearTool: (element: any, annotation: any, canvasCoords: any, proximity: any) => boolean; // (undocumented) - _mouseDragCallback: (evt: any) => void; - // (undocumented) - _mouseUpCallback: (evt: any) => void; - // (undocumented) renderAnnotation: (enabledElement: Types_2.IEnabledElement, svgDrawingHelper: SVGDrawingHelper) => boolean; // (undocumented) _throttledCalculateCachedStats: Function; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 288d95b9c6..247032cdd7 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -98,6 +98,9 @@ import type ICachedGeometry from './ICachedGeometry'; import type { IContourSet } from './IContourSet'; import type { IContour } from './IContour'; import type RGB from './RGB'; +import type { Memo, HistoryMemo } from '../utilities/historyMemo'; +import type { VoxelManager } from '../utilities/VoxelManager'; +import type RLEVoxelMap from '../utilities/RLEVoxelMap'; import type { ColormapPublic, ColormapRegistration } from './Colormap'; import type { ViewportProperties } from './ViewportProperties'; import type { @@ -300,4 +303,8 @@ export type { GeometryLoaderFn, ScrollOptions, JumpToSliceOptions, + Memo, + HistoryMemo, + VoxelManager, + RLEVoxelMap, }; diff --git a/packages/core/src/utilities/PointsManager.ts b/packages/core/src/utilities/PointsManager.ts index f78d8644eb..6453040434 100644 --- a/packages/core/src/utilities/PointsManager.ts +++ b/packages/core/src/utilities/PointsManager.ts @@ -67,11 +67,11 @@ export default class PointsManager { } /** - * Returns a Float32Array view of the given point. + * Returns a `Float32Array` view of the given point. * Changes to the data in this point will affect the underlying data. * - * @param index - positive index from start, or negative from end - * @returns Float32Array view onto the point at the given index + * @param index - positive index from start, or negative from end + * @returns `Float32Array` view onto the point at the given index */ public getPoint(index: number): T { if (index < 0) { @@ -91,8 +91,8 @@ export default class PointsManager { * Returns a `number[]` version of the given point. * Changes to the array will NOT affect the underlying data. * - * @param index - positive index from start, or negative from end - * @returns A new number[] instance of the given point. + * @param index - positive index from start, or negative from end + * @returns A new `number[]` instance of the given point. */ public getPointArray(index: number): T { const array = []; @@ -179,8 +179,8 @@ export default class PointsManager { } /** - * A points object containing Float32Array instances referring to the underlying - * data, contained in a FloatArray32[] instance. + * A points object containing `Float32Array` instances referring to the underlying + * data, contained in a `FloatArray32[]` instance. * Note - changes to the data store will directly affect the points value * returned here, even if stored separately. */ @@ -214,8 +214,8 @@ export default class PointsManager { } /** - * Create an PointsArray3 from the x,y,z individual arrays (see toXYZ) - * Will create a Point3 array even if z is missing, with 0 as the value. + * Create an `PointsArray3` from the x,y,z individual arrays (see toXYZ) + * Will create a `Point3` array even if z is missing, with 0 as the value. */ public static fromXYZ({ x, y, z }: PointsXYZ): IPointsManager { const array = PointsManager.create3(x.length); @@ -247,10 +247,20 @@ export default class PointsManager { } /** - * Create a `PointsManager` instance with available capacity of initialSize + * Create a `PointsManager` instance with available capacity of `initialSize` + * + * @param initialSize - the starting size of the underlying array, however, it will still + * be empty of actual data initially. + * @param points - a set of points to add to the points array. Makes it easy to copy + * a set of points into a `PointsManager`. */ - public static create3(initialSize = 128) { - return new PointsManager({ initialSize, dimensions: 3 }); + public static create3(initialSize = 128, points?: Point3[]) { + initialSize = Math.max(initialSize, points?.length || 0); + const newPoints = new PointsManager({ initialSize, dimensions: 3 }); + if (points) { + points.forEach((point) => newPoints.push(point)); + } + return newPoints; } /** diff --git a/packages/core/src/utilities/VoxelManager.ts b/packages/core/src/utilities/VoxelManager.ts index 03d0f969e4..6fe9940ccd 100644 --- a/packages/core/src/utilities/VoxelManager.ts +++ b/packages/core/src/utilities/VoxelManager.ts @@ -230,9 +230,14 @@ export default class VoxelManager { * * If the boundsIJK is not provided, the iteration will be over the entire volume/data * - * * If the VoxelManager is backed by a Map, it will only iterate over the stored values. * Otherwise, it will iterate over all voxels within the specified or default bounds. + * + * @param callback - a callback to call with `value, index, pointIJK` for + * every point in the scalar data, map or rle map depending on the VoxelManager + * type. + * @param options - has an optional isWIthinObject to test to see if hte callback + * should be called or not. */ public forEach = ( callback: (args: { @@ -337,6 +342,11 @@ export default class VoxelManager { /** * Foreach callback optimized for RLE testing + * @param callback - a callback to call with `value, index, pointIJK` for + * every point in the rle map (see the rle map for callbacks that work at + * the row or rle level, as those can be faster/more efficient) + * @param options - has an optional isWIthinObject to test to see if hte callback + * should be called or not. */ public rleForEach(callback, options?) { const boundsIJK = options?.boundsIJK || this.getBoundsIJK(); @@ -437,9 +447,7 @@ export default class VoxelManager { * bounds. */ public clear() { - if (this.map) { - this.map.clear(); - } + this.map?.clear(); this.boundsIJK.map((bound) => { bound[0] = Infinity; bound[1] = -Infinity; diff --git a/packages/core/src/utilities/historyMemo/index.ts b/packages/core/src/utilities/historyMemo/index.ts index 8ebe33f4e6..34ffd08304 100644 --- a/packages/core/src/utilities/historyMemo/index.ts +++ b/packages/core/src/utilities/historyMemo/index.ts @@ -1,7 +1,27 @@ export type Memo = { + /** + * This restores memo state. It is an undo if undo is true, or a redo if it + * is false. + */ restoreMemo: (undo?: boolean) => void; + + /** + * An optional function that will be called to commit any changes that have + * occurred in a memo. This allows recording changes that are ongoing to a memo + * and then being able to undo them without having to record the entire state at the + * time the memo is initially created. See createLabelmapMemo for an example + * use. + * + * @return true if this memo contains any data, if so it should go on the memo ring + * after the commit is completed. + */ + commitMemo?: () => boolean; }; +/** + * This is a function which can be implemented to create a memo and then pass + * the implementing class instead of a new memo itself. + */ export type Memoable = { createMemo: () => Memo; }; @@ -25,6 +45,7 @@ export class HistoryMemo { this._size = size; } + /** The number of items that can be stored in the history */ public get size() { return this._size; } @@ -92,6 +113,10 @@ export class HistoryMemo { } } +/** + * The default HistoryMemo is a shared history state that can be used for + * any undo/redo memo items. + */ const DefaultHistoryMemo = new HistoryMemo(); export { DefaultHistoryMemo }; diff --git a/packages/tools/examples/toolHistory/index.ts b/packages/tools/examples/toolHistory/index.ts new file mode 100644 index 0000000000..8a078fb110 --- /dev/null +++ b/packages/tools/examples/toolHistory/index.ts @@ -0,0 +1,289 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + getRenderingEngine, + volumeLoader, + setVolumesForViewports, + utilities as csUtils, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addButtonToToolbar, + annotationTools, + labelmapTools, + contourTools, + addManipulationBindings, +} from '../../../../utils/demo/helpers'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +const { segmentation } = cornerstoneTools; +const { MouseBindings } = cornerstoneTools.Enums; +const { DefaultHistoryMemo } = csUtils.HistoryMemo; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + AnnotationTool, +} = cornerstoneTools; + +const { ViewportType } = Enums; +const renderingEngineId = 'myRenderingEngine'; +const viewportId = 'CT_STACK'; +const labelmapSegmentationId = 'labelmapSegmentationId'; +const contourSegmentationId = 'contourSegmentationId'; +const defaultTool = 'ThresholdCircle'; + +const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id + +const toolMap = new Map(annotationTools); +for (const [key, value] of labelmapTools.toolMap) { + toolMap.set(key, value); +} +for (const [key, value] of contourTools.toolMap) { + toolMap.set(key, value); +} + +// ======== Set up page ======== // +setTitleAndDescription('Tool History', 'Demonstrate undo/redo on tools'); + +const content = document.getElementById('content'); +const element = document.createElement('div'); + +// Disable right click context menu so we can have right click tools +element.oncontextmenu = (e) => e.preventDefault(); + +element.id = 'cornerstone-element'; +element.style.width = '500px'; +element.style.height = '500px'; + +content.appendChild(element); + +const info = document.createElement('div'); +content.appendChild(info); + +const instructions = document.createElement('p'); +instructions.innerText = ` +Left Click to use selected tool +z to undo, y to redo +`; +info.appendChild(instructions); + +// ============================= // + +const toolGroupId = 'STACK_TOOL_GROUP_ID'; + +const cancelToolDrawing = (evt) => { + const { element, key } = evt.detail; + if (key === 'Escape') { + cornerstoneTools.cancelActiveManipulations(element); + } +}; + +element.addEventListener(csToolsEnums.Events.KEY_DOWN, (evt) => { + cancelToolDrawing(evt); +}); + +addDropdownToToolbar({ + options: { map: toolMap, defaultValue: defaultTool }, + onSelectedValueChange: (newSelectedToolName, data) => { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the old tool passive + const selectedToolName = toolGroup.getActivePrimaryMouseButtonTool(); + if (selectedToolName) { + toolGroup.setToolPassive(selectedToolName); + } + + // Set the new tool active + toolGroup.setToolActive(newSelectedToolName as string, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); + const isContour = + data?.segmentationType === + csToolsEnums.SegmentationRepresentations.Contour; + segmentation.activeSegmentation.setActiveSegmentation( + viewportId, + isContour ? contourSegmentationId : labelmapSegmentationId + ); + }, +}); + +addDropdownToToolbar({ + options: { values: ['1', '2', '3'], defaultValue: '1' }, + labelText: 'Segment', + onSelectedValueChange: (segmentIndex) => { + segmentation.segmentIndex.setActiveSegmentIndex( + labelmapSegmentationId, + Number(segmentIndex) + ); + segmentation.segmentIndex.setActiveSegmentIndex( + contourSegmentationId, + Number(segmentIndex) + ); + }, +}); + +addButtonToToolbar({ + id: 'Undo', + title: 'Undo', + onClick() { + DefaultHistoryMemo.undo(); + }, +}); + +addButtonToToolbar({ + id: 'Redo', + title: 'Redo', + onClick() { + DefaultHistoryMemo.redo(); + }, +}); + +addButtonToToolbar({ + id: 'Delete', + title: 'Delete Annotation', + onClick() { + const annotationUIDs = + cornerstoneTools.annotation.selection.getAnnotationsSelected(); + + if (annotationUIDs.length === 0) { + return; + } + + const annotation = cornerstoneTools.annotation.state.getAnnotation( + annotationUIDs[0] + ); + + if (annotation) { + // Note that delete needs to have a memo created for it, as the underlying + // state manager doesn't record this directly. + // The deleting flag is set to true meaning that this annotation is about + // to be deleted (but is NOT yet deleted). + AnnotationTool.createAnnotationMemo(element, annotation, { + deleting: true, + }); + cornerstoneTools.annotation.state.removeAnnotation( + annotation.annotationUID + ); + getRenderingEngine(renderingEngineId).render(); + } + }, +}); + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + await volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: labelmapSegmentationId, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId: labelmapSegmentationId, + representation: { + // The type of segmentation + type: csToolsEnums.SegmentationRepresentations.Labelmap, + // The actual segmentation data, in the case of labelmap this is a + // reference to the source volume of the segmentation. + data: { + volumeId: labelmapSegmentationId, + }, + }, + }, + { + segmentationId: contourSegmentationId, + representation: { + type: csToolsEnums.SegmentationRepresentations.Contour, + }, + }, + ]); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // Define a tool group, which defines how mouse events map to tool commands for + // Any viewport using the group + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + addManipulationBindings(toolGroup, { toolMap }); + + // Get Cornerstone imageIds and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + await addSegmentationsToState(); + + // Instantiate a rendering engine + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create a stack viewport + const viewportInput = { + viewportId, + type: ViewportType.ORTHOGRAPHIC, + element, + defaultOptions: { + background: [0.2, 0, 0.2], + }, + }; + + renderingEngine.enableElement(viewportInput); + + // Set the tool group on the viewport + toolGroup.addViewport(viewportId, renderingEngineId); + + // Get the stack viewport that was created + const viewport = ( + renderingEngine.getViewport(viewportId) + ); + volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports(renderingEngine, [{ volumeId }], [viewportId]); + + // Render the image + viewport.render(); + + await segmentation.addLabelmapRepresentationToViewport(viewportId, [ + { segmentationId: labelmapSegmentationId }, + ]); + + // await segmentation.addContourRepresentationToViewport(viewportId, [ + // { segmentationId: contourSegmentationId }, + // ]); + + // segmentation.activeSegmentation.setActiveSegmentation( + // viewportId, + // labelmapSegmentationId + // ); +} + +run(); diff --git a/packages/tools/src/enums/ChangeTypes.ts b/packages/tools/src/enums/ChangeTypes.ts index 45271177d7..d88e11b1bd 100644 --- a/packages/tools/src/enums/ChangeTypes.ts +++ b/packages/tools/src/enums/ChangeTypes.ts @@ -31,6 +31,10 @@ enum ChangeTypes { * Occurs when an interpolation result is updated with more tool specific data. */ InterpolationUpdated = 'InterpolationUpdated', + /** + * Occurs when an annotation is changed do to an undo or redo. + */ + History = 'History', } export default ChangeTypes; diff --git a/packages/tools/src/stateManagement/annotation/annotationState.ts b/packages/tools/src/stateManagement/annotation/annotationState.ts index 9461a283d5..c67e20c946 100644 --- a/packages/tools/src/stateManagement/annotation/annotationState.ts +++ b/packages/tools/src/stateManagement/annotation/annotationState.ts @@ -201,6 +201,11 @@ function getNumberOfAnnotations( /** * Remove the annotation by UID of the annotation. + * + * Note - the annotation state is NOT preserved here in the HistoryMemo state. + * If you wish to preserve the state, you must call the create annotation memo + * BEFORE removing the annotation, and pass the deleting: true flag to it. + * * @param annotationUID - The unique identifier for the annotation. */ function removeAnnotation(annotationUID: string): void { diff --git a/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts b/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts index 95b58a4e26..74aeb61816 100644 --- a/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts +++ b/packages/tools/src/store/filterToolsWithAnnotationsForElement.ts @@ -41,7 +41,7 @@ export default function filterToolsWithAnnotationsForElement( ); } - if (annotations.length > 0) { + if (annotations?.length > 0) { result.push({ tool, annotations }); } } diff --git a/packages/tools/src/tools/AnnotationEraserTool.ts b/packages/tools/src/tools/AnnotationEraserTool.ts index 4f59321b08..03a2ddb0f0 100644 --- a/packages/tools/src/tools/AnnotationEraserTool.ts +++ b/packages/tools/src/tools/AnnotationEraserTool.ts @@ -1,7 +1,8 @@ -import { BaseTool } from './base'; +import { BaseTool, AnnotationTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { getAnnotations, + getAnnotation, removeAnnotation, } from '../stateManagement/annotation/annotationState'; import { setAnnotationSelected } from '../stateManagement/annotation/annotationSelection'; @@ -53,15 +54,15 @@ class AnnotationEraserTool extends BaseTool { const annotations = getAnnotations(toolName, element); - if (!annotations.length) { - continue; - } - const interactableAnnotations = toolInstance.filterInteractableAnnotationsForElement( element, annotations - ) || []; + ); + + if (!interactableAnnotations) { + continue; + } for (const annotation of interactableAnnotations) { if ( @@ -80,6 +81,10 @@ class AnnotationEraserTool extends BaseTool { for (const annotationUID of annotationsToRemove) { setAnnotationSelected(annotationUID); + const annotation = getAnnotation(annotationUID); + AnnotationTool.createAnnotationMemo(element, annotation, { + deleting: true, + }); removeAnnotation(annotationUID); } diff --git a/packages/tools/src/tools/CrosshairsTool.ts b/packages/tools/src/tools/CrosshairsTool.ts index b861ffa55f..fcfdb130b0 100644 --- a/packages/tools/src/tools/CrosshairsTool.ts +++ b/packages/tools/src/tools/CrosshairsTool.ts @@ -200,7 +200,7 @@ class CrosshairsTool extends AnnotationTool { annotations ); - if (annotations.length) { + if (annotations?.length) { // If found, it will override it by removing the annotation and adding it later removeAnnotation(annotations[0].annotationUID); } diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index 067b95342b..ff96b39c4f 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -26,15 +26,9 @@ class PanTool extends BaseTool { this._dragCallback(evt); } - preMouseDownCallback = (evt: EventTypes.InteractionEventType): boolean => { - this.memo = null; - return false; - }; - _dragCallback(evt: EventTypes.InteractionEventType) { const { element, deltaPoints } = evt.detail; const enabledElement = getEnabledElement(element); - this.memo ||= PanTool.createZoomPanMemo(enabledElement.viewport); const deltaPointsWorld = deltaPoints.world; // This occurs when the mouse event is fired but the mouse hasn't moved a full pixel yet (high resolution mice) diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 172aac0708..513fb3f32b 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -56,7 +56,6 @@ class ZoomTool extends BaseTool { const { focalPoint } = camera; this.initialMousePosWorld = worldPos; - this.memo = null; // The direction vector from the clicked location to the focal point // which would act as the vector to translate the image (if zoomToCenter is false) @@ -127,8 +126,6 @@ class ZoomTool extends BaseTool { const camera = viewport.getCamera(); - this.memo ||= ZoomTool.createZoomPanMemo(viewport); - if (camera.parallelProjection) { this._dragParallelProjection(evt, viewport, camera); } else { diff --git a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts index e6d7b17884..5bc8a217bc 100644 --- a/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts +++ b/packages/tools/src/tools/annotation/ArrowAnnotateTool.ts @@ -382,6 +382,8 @@ class ArrowAnnotateTool extends AnnotationTool { annotation.data.text = text; triggerAnnotationCompleted(annotation); + // This is only new if it wasn't already memoed + this.createMemo(element, annotation, { newAnnotation: !!this.memo }); triggerAnnotationRenderForViewportIds(viewportIdsToRender); }); @@ -389,6 +391,7 @@ class ArrowAnnotateTool extends AnnotationTool { triggerAnnotationModified(annotation, element); } + this.doneEditMemo(); this.editData = null; this.isDrawing = false; }; @@ -398,8 +401,15 @@ class ArrowAnnotateTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { diff --git a/packages/tools/src/tools/annotation/BidirectionalTool.ts b/packages/tools/src/tools/annotation/BidirectionalTool.ts index 7e34826b0b..d1972268f2 100644 --- a/packages/tools/src/tools/annotation/BidirectionalTool.ts +++ b/packages/tools/src/tools/annotation/BidirectionalTool.ts @@ -397,6 +397,8 @@ class BidirectionalTool extends AnnotationTool { return; } + this.doneEditMemo(); + data.handles.activeHandleIndex = null; this._deactivateModify(element); @@ -492,9 +494,12 @@ class BidirectionalTool extends AnnotationTool { const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { worldToCanvas } = viewport; - const { annotation, viewportIdsToRender, handleIndex } = this.editData; + const { annotation, viewportIdsToRender, handleIndex, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; const worldPos = currentPoints.world; @@ -575,10 +580,15 @@ class BidirectionalTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { const { deltaPoints } = eventDetail; diff --git a/packages/tools/src/tools/annotation/CircleROITool.ts b/packages/tools/src/tools/annotation/CircleROITool.ts index 2fd8512183..8a338784b2 100644 --- a/packages/tools/src/tools/annotation/CircleROITool.ts +++ b/packages/tools/src/tools/annotation/CircleROITool.ts @@ -373,6 +373,8 @@ class CircleROITool extends AnnotationTool { return; } + this.doneEditMemo(); + // Circle ROI tool should reset its highlight to false on mouse up (as opposed // to other tools that keep it highlighted until the user moves. The reason // is that we use top-left and bottom-right handles to define the circle, @@ -411,11 +413,12 @@ class CircleROITool extends AnnotationTool { const { currentPoints } = eventDetail; const currentCanvasPoints = currentPoints.canvas; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { canvasToWorld } = viewport; ////// - const { annotation, viewportIdsToRender } = this.editData; + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; data.handles.points = [ @@ -435,8 +438,14 @@ class CircleROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; if (movingTextBox) { diff --git a/packages/tools/src/tools/annotation/CobbAngleTool.ts b/packages/tools/src/tools/annotation/CobbAngleTool.ts index 795c192b15..faf7ef0b5d 100644 --- a/packages/tools/src/tools/annotation/CobbAngleTool.ts +++ b/packages/tools/src/tools/annotation/CobbAngleTool.ts @@ -295,15 +295,12 @@ class CobbAngleTool extends AnnotationTool { hideElementCursor(element); - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); } - _mouseUpCallback = ( + _endCallback = ( evt: EventTypes.MouseUpEventType | EventTypes.MouseClickEventType ) => { const eventDetail = evt.detail; @@ -319,6 +316,8 @@ class CobbAngleTool extends AnnotationTool { return; } + this.doneEditMemo(); + // If preventing new measurement means we are in the middle of an existing measurement // we shouldn't deactivate modify or draw if (this.angleStartedNotYetCompleted && data.handles.points.length < 4) { @@ -402,7 +401,7 @@ class CobbAngleTool extends AnnotationTool { this.editData.handleIndex = data.handles.points.length - 1; }; - _mouseDragCallback = ( + _dragCallback = ( evt: EventTypes.MouseDragEventType | EventTypes.MouseMoveEventType ) => { this.isDrawing = true; @@ -416,7 +415,10 @@ class CobbAngleTool extends AnnotationTool { movingTextBox, isNearFirstLine, isNearSecondLine, + newAnnotation, } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (movingTextBox) { @@ -517,19 +519,19 @@ class CobbAngleTool extends AnnotationTool { element.addEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); - // element.addEventListener(Events.TOUCH_END, this._mouseUpCallback) - // element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback) + // element.addEventListener(Events.TOUCH_END, this._endCallback) + // element.addEventListener(Events.TOUCH_DRAG, this._dragCallback) }; _deactivateModify = (element: HTMLDivElement) => { @@ -537,19 +539,19 @@ class CobbAngleTool extends AnnotationTool { element.removeEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); - // element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback) - // element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback) + // element.removeEventListener(Events.TOUCH_END, this._endCallback) + // element.removeEventListener(Events.TOUCH_DRAG, this._dragCallback) }; _activateDraw = (element: HTMLDivElement) => { @@ -557,27 +559,27 @@ class CobbAngleTool extends AnnotationTool { element.addEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_MOVE, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.addEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.addEventListener( Events.MOUSE_DOWN, this._mouseDownCallback as EventListener ); - // element.addEventListener(Events.TOUCH_END, this._mouseUpCallback) - // element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback) + // element.addEventListener(Events.TOUCH_END, this._endCallback) + // element.addEventListener(Events.TOUCH_DRAG, this._dragCallback) }; _deactivateDraw = (element: HTMLDivElement) => { @@ -585,27 +587,27 @@ class CobbAngleTool extends AnnotationTool { element.removeEventListener( Events.MOUSE_UP, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DRAG, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_MOVE, - this._mouseDragCallback as EventListener + this._dragCallback as EventListener ); element.removeEventListener( Events.MOUSE_CLICK, - this._mouseUpCallback as EventListener + this._endCallback as EventListener ); element.removeEventListener( Events.MOUSE_DOWN, this._mouseDownCallback as EventListener ); - // element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback) - // element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback) + // element.removeEventListener(Events.TOUCH_END, this._endCallback) + // element.removeEventListener(Events.TOUCH_DRAG, this._dragCallback) }; /** diff --git a/packages/tools/src/tools/annotation/EllipticalROITool.ts b/packages/tools/src/tools/annotation/EllipticalROITool.ts index e0fabe733d..11e4427d00 100644 --- a/packages/tools/src/tools/annotation/EllipticalROITool.ts +++ b/packages/tools/src/tools/annotation/EllipticalROITool.ts @@ -463,9 +463,6 @@ class EllipticalROITool extends AnnotationTool { hideElementCursor(element); - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); @@ -483,6 +480,8 @@ class EllipticalROITool extends AnnotationTool { return; } + this.doneEditMemo(); + // Elliptical ROI tool should reset its highlight to false on mouse up (as opposed // to other tools that keep it highlighted until the user moves. The reason // is that we use top-left and bottom-right handles to define the ellipse, @@ -495,8 +494,6 @@ class EllipticalROITool extends AnnotationTool { resetElementCursor(element); - const { renderingEngine } = getEnabledElement(element); - this.editData = null; this.isDrawing = false; @@ -521,11 +518,14 @@ class EllipticalROITool extends AnnotationTool { const { currentPoints } = eventDetail; const currentCanvasPoints = currentPoints.canvas; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { canvasToWorld } = viewport; ////// - const { annotation, viewportIdsToRender, centerWorld } = this.editData; + const { annotation, viewportIdsToRender, centerWorld, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const centerCanvas = viewport.worldToCanvas(centerWorld as Types.Point3); const { data } = annotation; @@ -557,8 +557,14 @@ class EllipticalROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; if (movingTextBox) { diff --git a/packages/tools/src/tools/annotation/KeyImageTool.ts b/packages/tools/src/tools/annotation/KeyImageTool.ts index fc1a40c445..ad74157a14 100644 --- a/packages/tools/src/tools/annotation/KeyImageTool.ts +++ b/packages/tools/src/tools/annotation/KeyImageTool.ts @@ -26,9 +26,9 @@ import type { PublicToolProps, ToolProps, SVGDrawingHelper, + Annotation, } from '../../types'; import type { StyleSpecifier } from '../../types/AnnotationStyle'; -import type { Annotation } from '../../types'; type Point2 = Types.Point2; @@ -75,7 +75,7 @@ class KeyImageTool extends AnnotationTool { const { currentPoints, element } = eventDetail; const worldPos = currentPoints.world; const enabledElement = getEnabledElement(element); - const { viewport, renderingEngine } = enabledElement; + const { viewport } = enabledElement; const camera = viewport.getCamera(); const { viewPlaneNormal, viewUp } = camera; @@ -116,6 +116,8 @@ class KeyImageTool extends AnnotationTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); }); + this.createMemo(element, annotation, { newAnnotation: true }); + return annotation; }; @@ -180,6 +182,8 @@ class KeyImageTool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; + this.doneEditMemo(); + this._deactivateModify(element); resetElementCursor(element); }; @@ -212,7 +216,7 @@ class KeyImageTool extends AnnotationTool { } const annotation = clickedAnnotation as Annotation; - + this.createMemo(element, annotation); this.configuration.changeTextCallback( clickedAnnotation, evt.detail, @@ -221,6 +225,7 @@ class KeyImageTool extends AnnotationTool { this.isDrawing = false; + this.doneEditMemo(); // This double click was handled and the dialogue was displayed. // No need for any other listener to handle it too - stopImmediatePropagation // helps ensure this primarily so that no other listeners on the target element diff --git a/packages/tools/src/tools/annotation/LengthTool.ts b/packages/tools/src/tools/annotation/LengthTool.ts index e079a3e9b5..a2c5ec3223 100644 --- a/packages/tools/src/tools/annotation/LengthTool.ts +++ b/packages/tools/src/tools/annotation/LengthTool.ts @@ -417,6 +417,7 @@ class LengthTool extends AnnotationTool { } triggerAnnotationRenderForViewportIds(viewportIdsToRender); + this.doneEditMemo(); if (newAnnotation) { triggerAnnotationCompleted(annotation); @@ -429,11 +430,19 @@ class LengthTool extends AnnotationTool { _dragCallback = (evt: EventTypes.InteractionEventType): void => { this.isDrawing = true; const eventDetail = evt.detail; + const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + if (movingTextBox) { // Drag mode - moving text box const { deltaPoints } = eventDetail as EventTypes.MouseDragEventDetail; diff --git a/packages/tools/src/tools/annotation/LivewireContourTool.ts b/packages/tools/src/tools/annotation/LivewireContourTool.ts index 91e3b42cee..24402af739 100644 --- a/packages/tools/src/tools/annotation/LivewireContourTool.ts +++ b/packages/tools/src/tools/annotation/LivewireContourTool.ts @@ -451,6 +451,8 @@ class LivewireContourTool extends ContourSegmentationBaseTool { } = this.editData; const { data } = annotation; + this.doneEditMemo(); + data.handles.activeHandleIndex = null; this._deactivateModify(element); @@ -518,8 +520,13 @@ class LivewireContourTool extends ContourSegmentationBaseTool { private _mouseDownCallback = (evt: EventTypes.InteractionEventType): void => { const doubleClick = evt.type === Events.MOUSE_DOUBLE_CLICK; - const { annotation, viewportIdsToRender, worldToSlice, sliceToWorld } = - this.editData; + const { + annotation, + viewportIdsToRender, + worldToSlice, + sliceToWorld, + newAnnotation, + } = this.editData; if (this.editData.closed) { return; @@ -535,6 +542,13 @@ class LivewireContourTool extends ContourSegmentationBaseTool { const controlPoints = this.editData.currentPath.getControlPoints(); let closePath = controlPoints.length >= 2 && doubleClick; + // There is a new point being added/changed, and we want that in a separate + // memo to allow undoing it, so need to call the done edit an extra time here. + this.doneEditMemo(); + this.createMemo(element, annotation, { + newAnnotation: newAnnotation && controlPoints.length === 1, + }); + // Check if user clicked on the first point to close the curve if (controlPoints.length >= 2) { const closestHandlePoint = { @@ -749,8 +763,14 @@ class LivewireContourTool extends ContourSegmentationBaseTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + movingTextBox, + handleIndex, + newAnnotation, + } = this.editData; + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; if (movingTextBox) { @@ -800,12 +820,9 @@ class LivewireContourTool extends ContourSegmentationBaseTool { removeAnnotation(annotation.annotationUID); } - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - this.editData = null; + this.doneEditMemo(); this.scissors = null; return annotation.annotationUID; }; diff --git a/packages/tools/src/tools/annotation/ProbeTool.ts b/packages/tools/src/tools/annotation/ProbeTool.ts index 7f3de45ff2..9f0e1ead37 100644 --- a/packages/tools/src/tools/annotation/ProbeTool.ts +++ b/packages/tools/src/tools/annotation/ProbeTool.ts @@ -314,9 +314,6 @@ class ProbeTool extends AnnotationTool { hideElementCursor(element); - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); @@ -338,8 +335,13 @@ class ProbeTool extends AnnotationTool { resetElementCursor(element); + if (newAnnotation) { + this.createMemo(element, annotation, { newAnnotation }); + } + this.editData = null; this.isDrawing = false; + this.doneEditMemo(); if ( this.isHandleOutsideImage && @@ -361,15 +363,14 @@ class ProbeTool extends AnnotationTool { const { currentPoints, element } = eventDetail; const worldPos = currentPoints.world; - const { annotation, viewportIdsToRender } = this.editData; + const { annotation, viewportIdsToRender, newAnnotation } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + data.handles.points[0] = [...worldPos] as Types.Point3; annotation.invalidated = true; - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; diff --git a/packages/tools/src/tools/annotation/RectangleROITool.ts b/packages/tools/src/tools/annotation/RectangleROITool.ts index 482f14d47c..3de75b4d53 100644 --- a/packages/tools/src/tools/annotation/RectangleROITool.ts +++ b/packages/tools/src/tools/annotation/RectangleROITool.ts @@ -367,7 +367,7 @@ class RectangleROITool extends AnnotationTool { resetElementCursor(element); - const { renderingEngine } = getEnabledElement(element); + this.doneEditMemo(); this.editData = null; this.isDrawing = false; @@ -392,8 +392,15 @@ class RectangleROITool extends AnnotationTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; + + this.createMemo(element, annotation, { newAnnotation }); const { data } = annotation; if (movingTextBox) { diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index 82dc7f40aa..e908b2031e 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -315,9 +315,6 @@ class SplineROITool extends ContourSegmentationBaseTool { }; this._activateModify(element); - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); @@ -376,6 +373,7 @@ class SplineROITool extends ContourSegmentationBaseTool { triggerAnnotationRenderForViewportIds(viewportIdsToRender); + this.doneEditMemo(); this.editData = null; this.isDrawing = false; }; @@ -435,8 +433,11 @@ class SplineROITool extends ContourSegmentationBaseTool { return; } + // Ensure new changes are captured in a new memo - otherwise some types of + // changes get merged when an endCallback is missed. + this.doneEditMemo(); + const eventDetail = evt.detail; - const { element } = eventDetail; const { currentPoints } = eventDetail; const { canvas: canvasPoint, world: worldPoint } = currentPoints; let closeContour = data.handles.points.length >= 2 && doubleClick; @@ -476,10 +477,17 @@ class SplineROITool extends ContourSegmentationBaseTool { const eventDetail = evt.detail; const { element } = eventDetail; - const { annotation, viewportIdsToRender, handleIndex, movingTextBox } = - this.editData; + const { + annotation, + viewportIdsToRender, + handleIndex, + movingTextBox, + newAnnotation, + } = this.editData; const { data } = annotation; + this.createMemo(element, annotation, { newAnnotation }); + if (movingTextBox) { // Drag mode - moving text box const { deltaPoints } = eventDetail as EventTypes.MouseDragEventDetail; diff --git a/packages/tools/src/tools/annotation/VideoRedactionTool.ts b/packages/tools/src/tools/annotation/VideoRedactionTool.ts index 8068edae72..84fbba6d38 100644 --- a/packages/tools/src/tools/annotation/VideoRedactionTool.ts +++ b/packages/tools/src/tools/annotation/VideoRedactionTool.ts @@ -254,7 +254,7 @@ class VideoRedactionTool extends AnnotationTool { evt.preventDefault(); }; - _mouseUpCallback = (evt) => { + _endCallback = (evt) => { const eventData = evt.detail; const { element } = eventData; @@ -266,6 +266,8 @@ class VideoRedactionTool extends AnnotationTool { return; } + this.doneEditMemo(); + data.active = false; data.handles.activeHandleIndex = null; @@ -274,8 +276,6 @@ class VideoRedactionTool extends AnnotationTool { resetElementCursor(element); - const enabledElement = getEnabledElement(element); - this.editData = null; this.isDrawing = false; @@ -289,13 +289,16 @@ class VideoRedactionTool extends AnnotationTool { triggerAnnotationRenderForViewportIds(viewportUIDsToRender); }; - _mouseDragCallback = (evt) => { + _dragCallback = (evt) => { this.isDrawing = true; const eventData = evt.detail; const { element } = eventData; - const { annotation, viewportUIDsToRender, handleIndex } = this.editData; + const { annotation, viewportUIDsToRender, handleIndex, newAnnotation } = + this.editData; + this.createMemo(element, annotation, { newAnnotation }); + const { data } = annotation; if (handleIndex === undefined) { @@ -414,13 +417,13 @@ class VideoRedactionTool extends AnnotationTool { _activateDraw = (element) => { state.isInteractingWithTool = true; - element.addEventListener(Events.MOUSE_UP, this._mouseUpCallback); - element.addEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.addEventListener(Events.MOUSE_MOVE, this._mouseDragCallback); - element.addEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_UP, this._endCallback); + element.addEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.addEventListener(Events.MOUSE_MOVE, this._dragCallback); + element.addEventListener(Events.MOUSE_CLICK, this._endCallback); - element.addEventListener(Events.TOUCH_END, this._mouseUpCallback); - element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); + element.addEventListener(Events.TOUCH_END, this._endCallback); + element.addEventListener(Events.TOUCH_DRAG, this._dragCallback); }; /** @@ -429,13 +432,13 @@ class VideoRedactionTool extends AnnotationTool { _deactivateDraw = (element) => { state.isInteractingWithTool = false; - element.removeEventListener(Events.MOUSE_UP, this._mouseUpCallback); - element.removeEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.removeEventListener(Events.MOUSE_MOVE, this._mouseDragCallback); - element.removeEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_UP, this._endCallback); + element.removeEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.removeEventListener(Events.MOUSE_MOVE, this._dragCallback); + element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); - element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback); - element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); + element.removeEventListener(Events.TOUCH_END, this._endCallback); + element.removeEventListener(Events.TOUCH_DRAG, this._dragCallback); }; /** @@ -444,12 +447,12 @@ class VideoRedactionTool extends AnnotationTool { _activateModify = (element) => { state.isInteractingWithTool = true; - element.addEventListener(Events.MOUSE_UP, this._mouseUpCallback); - element.addEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.addEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.addEventListener(Events.MOUSE_UP, this._endCallback); + element.addEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.addEventListener(Events.MOUSE_CLICK, this._endCallback); - element.addEventListener(Events.TOUCH_END, this._mouseUpCallback); - element.addEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); + element.addEventListener(Events.TOUCH_END, this._endCallback); + element.addEventListener(Events.TOUCH_DRAG, this._dragCallback); }; /** @@ -458,12 +461,12 @@ class VideoRedactionTool extends AnnotationTool { _deactivateModify = (element) => { state.isInteractingWithTool = false; - element.removeEventListener(Events.MOUSE_UP, this._mouseUpCallback); - element.removeEventListener(Events.MOUSE_DRAG, this._mouseDragCallback); - element.removeEventListener(Events.MOUSE_CLICK, this._mouseUpCallback); + element.removeEventListener(Events.MOUSE_UP, this._endCallback); + element.removeEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); - element.removeEventListener(Events.TOUCH_END, this._mouseUpCallback); - element.removeEventListener(Events.TOUCH_DRAG, this._mouseDragCallback); + element.removeEventListener(Events.TOUCH_END, this._endCallback); + element.removeEventListener(Events.TOUCH_DRAG, this._dragCallback); }; renderAnnotation = ( @@ -489,9 +492,6 @@ class VideoRedactionTool extends AnnotationTool { return renderStatus; } - const targetId = this.getTargetId(viewport); - const renderingEngine = viewport.getRenderingEngine(); - const styleSpecifier: StyleSpecifier = { toolGroupId: this.toolGroupId, toolName: this.getToolName(), @@ -501,7 +501,6 @@ class VideoRedactionTool extends AnnotationTool { for (let i = 0; i < annotations.length; i++) { const annotation = annotations[i]; const { annotationUID } = annotation; - const toolMetadata = annotation.metadata; const data = annotation.data; const { points, activeHandleIndex } = data.handles; diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts index d5f285ee2f..2b87e5ff0c 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/closedContourEditLoop.ts @@ -56,6 +56,7 @@ function activateClosedContourEdit( editCanvasPoints: [canvasPos], startCrossingIndex: undefined, editIndex: 0, + annotation, }; this.commonData = { @@ -146,10 +147,13 @@ function mouseDragClosedContourEditCallback( const worldPos = currentPoints.world; const canvasPos = currentPoints.canvas; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { viewportIdsToRender, xDir, yDir, spacing } = this.commonData; - const { editIndex, editCanvasPoints, startCrossingIndex } = this.editData; + const { editIndex, editCanvasPoints, startCrossingIndex, annotation } = + this.editData; + + this.createMemo(element, annotation); const lastCanvasPoint = editCanvasPoints[editCanvasPoints.length - 1]; const lastWorldPoint = viewport.canvasToWorld(lastCanvasPoint); @@ -250,6 +254,7 @@ function finishEditAndStartNewEdit(evt: EventTypes.InteractionEventType): void { startCrossingIndex: undefined, editIndex: 0, snapIndex: undefined, + annotation, }; triggerAnnotationRenderForViewportIds(viewportIdsToRender); @@ -438,9 +443,10 @@ function mouseUpClosedContourEditCallback( */ function completeClosedContourEdit(element: HTMLDivElement) { const enabledElement = getEnabledElement(element); - const { viewport, renderingEngine } = enabledElement; + const { viewport } = enabledElement; const { annotation, viewportIdsToRender } = this.commonData; + this.doneEditMemo(); const { fusedCanvasPoints, prevCanvasPoints } = this.editData; if (fusedCanvasPoints) { diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts index 69c3e660ab..e8214d7b83 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts @@ -55,6 +55,7 @@ function activateDraw( canvasPoints: [canvasPos], polylineIndex: 0, contourHoleProcessingEnabled, + newAnnotation: true, }; this.commonData = { @@ -103,7 +104,7 @@ function mouseDragDrawCallback(evt: EventTypes.InteractionEventType): void { const worldPos = currentPoints.world; const canvasPos = currentPoints.canvas; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { annotation, @@ -113,7 +114,8 @@ function mouseDragDrawCallback(evt: EventTypes.InteractionEventType): void { spacing, movingTextBox, } = this.commonData; - const { polylineIndex, canvasPoints } = this.drawData; + const { polylineIndex, canvasPoints, newAnnotation } = this.drawData; + this.createMemo(element, annotation, { newAnnotation }); const lastCanvasPoint = canvasPoints[canvasPoints.length - 1]; const lastWorldPoint = viewport.canvasToWorld(lastCanvasPoint); @@ -182,6 +184,9 @@ function mouseUpDrawCallback(evt: EventTypes.InteractionEventType): void { const eventDetail = evt.detail; const { element } = eventDetail; + this.doneEditMemo(); + this.drawData.newAnnotation = false; + if ( allowOpenContours && !pointsAreWithinCloseContourProximity( diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts index 21e6c2c823..68e3b45fdc 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/openContourEditLoop.ts @@ -36,6 +36,7 @@ function activateOpenContourEdit( const canvasPos = currentPoints.canvas; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; + this.doneEditMemo(); const prevCanvasPoints = annotation.data.contour.polyline.map( viewport.worldToCanvas @@ -139,7 +140,7 @@ function mouseDragOpenContourEditCallback( const worldPos = currentPoints.world; const canvasPos = currentPoints.canvas; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; const { viewportIdsToRender, xDir, yDir, spacing } = this.commonData; const { editIndex, editCanvasPoints, startCrossingIndex } = this.editData; @@ -149,6 +150,8 @@ function mouseDragOpenContourEditCallback( const worldPosDiff = vec3.create(); + this.createMemo(element, this.commonData.annotation); + vec3.subtract(worldPosDiff, worldPos, lastWorldPoint); const xDist = Math.abs(vec3.dot(worldPosDiff, xDir)); @@ -239,6 +242,8 @@ function openContourEditOverwriteEnd( this.editData = undefined; this.commonData = undefined; + this.doneEditMemo(); + // Jump to a normal line edit now. this.deactivateOpenContourEdit(element); this.activateOpenContourEndEdit(evt, annotation, viewportIdsToRender, null); @@ -548,9 +553,11 @@ function mouseUpOpenContourEditCallback( */ function completeOpenContourEdit(element: HTMLDivElement) { const enabledElement = getEnabledElement(element); - const { viewport, renderingEngine } = enabledElement; + const { viewport } = enabledElement; const { annotation, viewportIdsToRender } = this.commonData; + + this.doneEditMemo(); const { fusedCanvasPoints, prevCanvasPoints } = this.editData; if (fusedCanvasPoints) { diff --git a/packages/tools/src/tools/base/AnnotationDisplayTool.ts b/packages/tools/src/tools/base/AnnotationDisplayTool.ts index 0ead78e96b..f2fc89fa4e 100644 --- a/packages/tools/src/tools/base/AnnotationDisplayTool.ts +++ b/packages/tools/src/tools/base/AnnotationDisplayTool.ts @@ -63,9 +63,11 @@ abstract class AnnotationDisplayTool extends BaseTool { filterInteractableAnnotationsForElement( element: HTMLDivElement, annotations: Annotations - ): Annotations | undefined { - if (!annotations || !annotations.length) { - return; + ): Annotations { + if (!annotations?.length) { + // Some tools don't check the return value, so return an empty array + // Which is in fact the correct value here. + return []; } const enabledElement = getEnabledElement(element); diff --git a/packages/tools/src/tools/base/AnnotationTool.ts b/packages/tools/src/tools/base/AnnotationTool.ts index 20eae0dfa0..cb7dbfdcae 100644 --- a/packages/tools/src/tools/base/AnnotationTool.ts +++ b/packages/tools/src/tools/base/AnnotationTool.ts @@ -20,13 +20,25 @@ import type { InteractionTypes, ToolProps, PublicToolProps, + ContourSegmentationAnnotation, + ContourAnnotationData, } from '../../types'; -import { addAnnotation } from '../../stateManagement/annotation/annotationState'; +import { + addAnnotation, + removeAnnotation, + getAnnotation, +} from '../../stateManagement/annotation/annotationState'; import type { AnnotationStyle, StyleSpecifier, } from '../../types/AnnotationStyle'; import { triggerAnnotationModified } from '../../stateManagement/annotation/helpers/state'; +import ChangeTypes from '../../enums/ChangeTypes'; +import { setAnnotationSelected } from '../../stateManagement/annotation/annotationSelection'; +import { addContourSegmentationAnnotation } from '../../utilities/contourSegmentation'; + +const { DefaultHistoryMemo } = csUtils.HistoryMemo; +const { PointsManager } = csUtils; /** * Abstract class for tools which create and display annotations on the @@ -462,6 +474,167 @@ abstract class AnnotationTool extends AnnotationDisplayTool { return true; } } + + /** + * Creates an annotation state copy to allow storing the current state of + * an annotation. This class has knowledge about the contour and spline + * implementations in order to copy the contour object efficiently, and to + * allow copying the spline object (which has member variables etc). + * + * @param annotation - the annotation to create a clone of + * @param deleting - a flag to indicate that this object is about to be deleted (deleting true), + * or was just created (deleting false), or neither (deleting undefined). + * @returns state information for the given annotation. + */ + protected static createAnnotationState( + annotation: Annotation, + deleting?: boolean + ) { + const { data, annotationUID } = annotation; + + const cloneData = { + ...data, + cachedStats: {}, + } as typeof data; + + delete cloneData.contour; + delete cloneData.spline; + + const state = { + annotationUID, + data: structuredClone(cloneData), + deleting, + }; + + const contour = (data as ContourAnnotationData['data']).contour; + + if (contour) { + state.data.contour = { + ...contour, + polyline: null, + pointsManager: PointsManager.create3( + contour.polyline.length, + contour.polyline + ), + }; + } + + return state; + } + + /** + * Creates an annotation memo storing the current data state on the given + * annotation object. This will store/recover handles data, text box and contour + * data, and if the options are set for deletion, will apply that correctly. + * + * @param element - that the annotation is shown on. + * @param annotation - to store a memo for the current state. + * @param options - whether the annotation is being created (newAnnotation) or + * is in the process of being deleted (`deleting`) + * * Note the naming on deleting is to indicate the deletion is in progress, + * as the createAnnotationMemo needs to be called BEFORE the annotation + * is actually deleted. + * * deleting with a value of false is the same as newAnnotation=true, + * as it is simply the opposite direction. Use undefined for both + * newAnnotation and deleting for non-create/delete operations. + * @returns Memo containing the annotation data. + */ + public static createAnnotationMemo( + element, + annotation: Annotation, + options?: { newAnnotation?: boolean; deleting?: boolean } + ) { + if (!annotation) { + return; + } + const { newAnnotation, deleting = newAnnotation ? false : undefined } = + options || {}; + const { annotationUID } = annotation; + const state = AnnotationTool.createAnnotationState(annotation, deleting); + + const annotationMemo = { + restoreMemo: () => { + const newState = AnnotationTool.createAnnotationState( + annotation, + deleting + ); + if (state.deleting === true) { + // Handle undeletion - note the state of deleting is internally + // true/false/undefined to mean delete/re-create as these are opposite actions. + state.deleting = false; + Object.assign(annotation.data, state.data); + if (annotation.data.contour) { + const annotationData = + annotation.data as ContourAnnotationData['data']; + + annotationData.contour.polyline = ( + state.data.contour as ContourAnnotationData['data']['contour'] + ).pointsManager.points; + + delete ( + state.data.contour as ContourAnnotationData['data']['contour'] + ).pointsManager; + + // @ts-expect-error + if (annotationData.segmentation) { + addContourSegmentationAnnotation( + annotation as ContourSegmentationAnnotation + ); + } + } + state.data = newState.data; + addAnnotation(annotation, element); + setAnnotationSelected(annotation.annotationUID, true); + getEnabledElement(element)?.viewport.render(); + return; + } + if (state.deleting === false) { + // Handle deletion (undo of creation) + state.deleting = true; + // Use the current state as the restore state. + state.data = newState.data; + setAnnotationSelected(annotation.annotationUID); + removeAnnotation(annotation.annotationUID); + getEnabledElement(element)?.viewport.render(); + return; + } + const currentAnnotation = getAnnotation(annotationUID); + if (!currentAnnotation) { + console.warn('No current annotation'); + return; + } + Object.assign(currentAnnotation.data, state.data); + if (currentAnnotation.data.contour) { + ( + currentAnnotation.data + .contour as ContourAnnotationData['data']['contour'] + ).polyline = ( + state.data.contour as ContourAnnotationData['data']['contour'] + ).pointsManager.points; + } + state.data = newState.data; + currentAnnotation.invalidated = true; + triggerAnnotationModified( + currentAnnotation, + element, + ChangeTypes.History + ); + }, + }; + DefaultHistoryMemo.push(annotationMemo); + return annotationMemo; + } + + /** + * Creates a memo on the given annotation. + */ + protected createMemo(element, annotation, options?) { + this.memo ||= AnnotationTool.createAnnotationMemo( + element, + annotation, + options + ); + } } AnnotationTool.toolName = 'AnnotationTool'; diff --git a/packages/tools/src/tools/base/BaseTool.ts b/packages/tools/src/tools/base/BaseTool.ts index 3c7820ee21..33c71818ff 100644 --- a/packages/tools/src/tools/base/BaseTool.ts +++ b/packages/tools/src/tools/base/BaseTool.ts @@ -246,13 +246,15 @@ abstract class BaseTool { } /** - * Undo an action + * Undoes an action */ public undo() { + // It is possible a user has started another action here, so ensure that one + // gets completed/stored correctly. Normally this only occurs if the user + // starts an undo while dragging. + this.doneEditMemo(); DefaultHistoryMemo.undo(); - this.memo = null; } - /** * Redo an action (undo the undo) */ @@ -260,6 +262,10 @@ abstract class BaseTool { DefaultHistoryMemo.redo(); } + /** + * Creates a zoom/pan memo that remembers the original zoom/pan position for + * the given viewport. + */ public static createZoomPanMemo(viewport) { // TODO - move this to view callback as a utility const state = { @@ -280,6 +286,28 @@ abstract class BaseTool { DefaultHistoryMemo.push(zoomPanMemo); return zoomPanMemo; } + + /** + * This clears and edit memo storage to allow for further history functions + * to be called. Calls the complete function if present, and pushes the + * memo to the history memo stack. + * + * This should be called when a tool has finished making a change which should be + * separated from future/other changes in terms of the history. + * Usually that means on endCallback (mouse up), but some tools also make changes + * on the initial creation of an object or have alternate flows and the doneEditMemo + * has to be called on mouse down or other initiation events to ensure that new + * changes are correctly recorded. + * + * If the tool has no end callback, then the doneEditMemo is called from the + * pre mouse down callback. See ZoomTool for an example of this usage. + */ + public doneEditMemo() { + if (this.memo?.commitMemo?.()) { + DefaultHistoryMemo.push(this.memo); + } + this.memo = null; + } } // Note: this is a workaround since terser plugin does not support static blocks diff --git a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts index a459050021..c47aac2fb5 100644 --- a/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts +++ b/packages/tools/src/tools/base/ContourSegmentationBaseTool.ts @@ -24,16 +24,11 @@ import { } from '../../utilities/contourSegmentation'; import { triggerAnnotationRenderForToolGroupIds } from '../../utilities/triggerAnnotationRenderForToolGroupIds'; import { getToolGroupForViewport } from '../../store/ToolGroupManager'; -import { getSegmentIndexColor } from '../../stateManagement/segmentation/config/segmentationColor'; import { getSegmentationRepresentations } from '../../stateManagement/segmentation/getSegmentationRepresentation'; import { getActiveSegmentation } from '../../stateManagement/segmentation/getActiveSegmentation'; -import { getSegmentationRepresentationVisibility } from '../../stateManagement/segmentation/getSegmentationRepresentationVisibility'; import { getViewportIdsWithSegmentation } from '../../stateManagement/segmentation/getViewportIdsWithSegmentation'; import { getActiveSegmentIndex } from '../../stateManagement/segmentation/getActiveSegmentIndex'; import { getLockedSegmentIndices } from '../../stateManagement/segmentation/segmentLocking'; -import { segmentationStyle } from '../../stateManagement/segmentation/SegmentationStyle'; -import type { ContourStyle } from '../../types/ContourTypes'; -import { internalGetHiddenSegmentIndices } from '../../stateManagement/segmentation/helpers/internalGetHiddenSegmentIndices'; import { getSVGStyleForSegment } from '../../utilities/segmentation/getSVGStyleForSegment'; /** diff --git a/packages/tools/src/tools/segmentation/BrushTool.ts b/packages/tools/src/tools/segmentation/BrushTool.ts index ad14d09c51..ae77c78ca9 100644 --- a/packages/tools/src/tools/segmentation/BrushTool.ts +++ b/packages/tools/src/tools/segmentation/BrushTool.ts @@ -382,6 +382,7 @@ class BrushTool extends LabelmapBaseTool { this.applyActiveStrategy(enabledElement, operationData); } + this.doneEditMemo(); this._deactivateDraw(element); resetElementCursor(element); diff --git a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts index cb2288e44b..7c1fa1bab4 100644 --- a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts @@ -36,6 +36,8 @@ import { getSegmentation, } from '../../stateManagement/segmentation/segmentationState'; import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; +import LabelmapBaseTool from './LabelmapBaseTool'; +import type { LabelmapMemo } from '../../utilities/segmentation/createLabelmapMemo'; /** * Tool for manipulating segmentation data by drawing a circle. It acts on the @@ -44,7 +46,7 @@ import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; * for the segmentation to modify. You can use SegmentationModule to set the active * segmentation and segmentIndex. */ -class CircleScissorsTool extends BaseTool { +class CircleScissorsTool extends LabelmapBaseTool { static toolName; editData: { annotation: Annotation; @@ -61,6 +63,7 @@ class CircleScissorsTool extends BaseTool { hasMoved?: boolean; imageId: string; centerCanvas?: Array; + memo?: LabelmapMemo; } | null; isDrawing: boolean; isHandleOutsideImage: boolean; @@ -295,12 +298,15 @@ class CircleScissorsTool extends BaseTool { viewPlaneNormal, viewUp, strategySpecificConfiguration: {}, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts index 066f4b3436..f2b6724f80 100644 --- a/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts +++ b/packages/tools/src/tools/segmentation/LabelmapBaseTool.ts @@ -22,6 +22,7 @@ import { getStackSegmentationImageIdsForViewport } from '../../stateManagement/s import { getSegmentIndexColor } from '../../stateManagement/segmentation/config/segmentationColor'; import { getActiveSegmentIndex } from '../../stateManagement/segmentation/getActiveSegmentIndex'; import { StrategyCallbacks } from '../../enums'; +import * as LabelmapMemo from '../../utilities/segmentation/createLabelmapMemo'; /** * A type for preview data/information, used to setup previews on hover, or @@ -94,14 +95,12 @@ export default class LabelmapBaseTool extends BaseTool { * initial state. This memo is then committed once done so that the */ public createMemo(segmentId: string, segmentationVoxelManager, preview) { - // TODO: Implement this - console.warn('LabelmapBaseTool.createMemo not implemented yet'); - // this.memo ||= LabelmapMemo.createLabelmapMemo( - // segmentId, - // segmentationVoxelManager, - // preview - // ); - // return this.memo as LabelmapMemo.LabelmapMemo; + this.memo ||= LabelmapMemo.createLabelmapMemo( + segmentId, + segmentationVoxelManager, + preview + ); + return this.memo as LabelmapMemo.LabelmapMemo; } createEditData(element) { @@ -326,6 +325,7 @@ export default class LabelmapBaseTool extends BaseTool { this.configuration.strategySpecificConfiguration, // Provide the preview information so that data can be used directly preview: this._previewData?.preview, + createMemo: this.createMemo.bind(this), }; return operationData; } @@ -377,6 +377,9 @@ export default class LabelmapBaseTool extends BaseTool { if (!element) { return; } + + this.doneEditMemo(); + const enabledElement = getEnabledElement(element); this.applyActiveStrategyCallback( @@ -386,5 +389,7 @@ export default class LabelmapBaseTool extends BaseTool { ); this._previewData.isDrag = false; this._previewData.preview = null; + // Store the edit memo too + this.doneEditMemo(); } } diff --git a/packages/tools/src/tools/segmentation/PaintFillTool.ts b/packages/tools/src/tools/segmentation/PaintFillTool.ts index c772125ade..8973750add 100644 --- a/packages/tools/src/tools/segmentation/PaintFillTool.ts +++ b/packages/tools/src/tools/segmentation/PaintFillTool.ts @@ -93,10 +93,11 @@ class PaintFillTool extends BaseTool { let dimensions: Types.Point3; let direction: Types.Mat3; - let scalarData: Types.PixelDataTypedArray; let index: Types.Point3; let voxelManager; + this.doneEditMemo(); + if (viewport instanceof BaseVolumeViewport) { const { volumeId } = representationData[ SegmentationRepresentations.Labelmap diff --git a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts index 2e00a69798..fa75ce9835 100644 --- a/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/RectangleScissorsTool.ts @@ -39,8 +39,8 @@ import { import { getCurrentLabelmapImageIdForViewport, getSegmentation, - getStackSegmentationImageIdsForViewport, } from '../../stateManagement/segmentation/segmentationState'; +import LabelmapBaseTool from './LabelmapBaseTool'; /** * Tool for manipulating segmentation data by drawing a rectangle. It acts on the @@ -49,7 +49,7 @@ import { * for the segmentation to modify. You can use SegmentationModule to set the active * segmentation and segmentIndex. */ -class RectangleScissorsTool extends BaseTool { +class RectangleScissorsTool extends LabelmapBaseTool { static toolName; _throttledCalculateCachedStats: Function; editData: { @@ -318,11 +318,15 @@ class RectangleScissorsTool extends BaseTool { const operationData = { ...this.editData, points: data.handles.points, + strategySpecificConfiguration: {}, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index 90b8583053..0928d81814 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -33,6 +33,7 @@ import { import { getSegmentation } from '../../stateManagement/segmentation/segmentationState'; import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; +import LabelmapBaseTool from './LabelmapBaseTool'; /** * Tool for manipulating segmentation data by drawing a sphere in 3d space. It acts on the @@ -42,7 +43,7 @@ import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; * segmentation and segmentIndex. Todo: sphere scissor has some memory problem which * lead to ui blocking behavior that needs to be fixed. */ -class SphereScissorsTool extends BaseTool { +class SphereScissorsTool extends LabelmapBaseTool { static toolName; editData: { annotation: Annotation; @@ -100,6 +101,7 @@ class SphereScissorsTool extends BaseTool { return; } + this.doneEditMemo(); const eventDetail = evt.detail; const { currentPoints, element } = eventDetail; const worldPos = currentPoints.world; @@ -290,12 +292,14 @@ class SphereScissorsTool extends BaseTool { segmentsLocked, viewPlaneNormal, viewUp, + createMemo: this.createMemo.bind(this), }; this.editData = null; this.isDrawing = false; this.applyActiveStrategy(enabledElement, operationData); + this.doneEditMemo(); }; /** diff --git a/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts b/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts index 713c0981ec..80dae0a006 100644 --- a/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts +++ b/packages/tools/src/tools/segmentation/strategies/BrushStrategy.ts @@ -7,6 +7,7 @@ import { getStrategyData } from './utils/getStrategyData'; import { StrategyCallbacks } from '../../../enums'; import type { LabelmapToolOperationDataAny } from '../../../types/LabelmapToolOperationData'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { LabelmapMemo } from '../../../utilities/segmentation/createLabelmapMemo'; const { VoxelManager } = csUtils; @@ -34,6 +35,7 @@ export type InitializedOperationData = LabelmapToolOperationDataAny & { brushStrategy: BrushStrategy; // eslint-disable-next-line @typescript-eslint/no-explicit-any configuration?: Record; + memo?: LabelmapMemo; }; export type StrategyFunction = ( diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts b/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts index f8b406a17a..84922a01b7 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/preview.ts @@ -51,11 +51,23 @@ export default { }, [StrategyCallbacks.Initialize]: (operationData: InitializedOperationData) => { - const { segmentIndex, previewSegmentIndex, previewColors, preview } = - operationData; - if (previewColors === undefined) { + const { + segmentIndex, + previewSegmentIndex, + previewColors, + preview, + segmentationId, + segmentationVoxelManager, + } = operationData; + + if (previewColors === undefined || !previewSegmentIndex) { + operationData.memo = operationData.createMemo( + segmentationId, + segmentationVoxelManager + ); return; } + if (preview) { preview.previewVoxelManager.sourceVoxelManager = operationData.segmentationVoxelManager; @@ -63,12 +75,16 @@ export default { operationData.previewVoxelManager = preview.previewVoxelManager; } - if ( - segmentIndex === undefined || - segmentIndex === null || - !previewSegmentIndex - ) { - // Null means to reset the value, so we don't change the preview colour + // if ( + // segmentIndex === undefined || + // segmentIndex === null || + // !previewSegmentIndex + // ) { + // // Null means to reset the value, so we don't change the preview colour + // return; + // } + if (segmentIndex === null) { + // Null means to reset the value, so we don't change the preview colour, return; } @@ -98,31 +114,43 @@ export default { segmentationVoxelManager, previewVoxelManager: previewVoxelManager, previewSegmentIndex, + segmentationId, preview, } = operationData || {}; if (previewSegmentIndex === undefined) { return; } const segmentIndex = preview?.segmentIndex ?? operationData.segmentIndex; - const tracking = previewVoxelManager; - if (!tracking || tracking.modifiedSlices.size === 0) { + if (!previewVoxelManager || previewVoxelManager.modifiedSlices.size === 0) { return; } - const callback = ({ index }) => { + // TODO - figure out a better option for undo/redo of preview + const memo = operationData.createMemo( + segmentationId, + segmentationVoxelManager + ); + operationData.memo = memo; + const { voxelManager } = memo; + + const callback = ({ index, value }) => { const oldValue = segmentationVoxelManager.getAtIndex(index); if (oldValue === previewSegmentIndex) { - segmentationVoxelManager.setAtIndex(index, segmentIndex); + // First restore the segmentation voxel manager + segmentationVoxelManager.setAtIndex(index, value); + // Then set it to the final value so that the memo voxel manager has + // the correct values. + voxelManager.setAtIndex(index, segmentIndex); } }; - tracking.forEach(callback, {}); + previewVoxelManager.forEach(callback, {}); triggerSegmentationDataModified( operationData.segmentationId, - tracking.getArrayOfModifiedSlices(), + previewVoxelManager.getArrayOfModifiedSlices(), preview.segmentIndex ); - tracking.clear(); + previewVoxelManager.clear(); }, [StrategyCallbacks.RejectPreview]: ( diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts index 0469b8c1f2..072faea4a2 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/setValue.ts @@ -16,11 +16,14 @@ export default { const { segmentsLocked, segmentIndex, - previewVoxelManager, previewSegmentIndex, segmentationVoxelManager, + memo, } = operationData; + const previewVoxelManager = + memo?.voxelManager || operationData.previewVoxelManager; + const existingValue = segmentationVoxelManager.getAtIndex(index); let changed = false; diff --git a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts index f30981b1ca..a5d03973fb 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts @@ -1,5 +1,6 @@ +import { vec3 } from 'gl-matrix'; import { utilities as csUtils, StackViewport } from '@cornerstonejs/core'; -import type { Types } from '@cornerstonejs/core'; +import type { Types, BaseVolumeViewport } from '@cornerstonejs/core'; import { getBoundingBoxAroundShapeIJK, @@ -9,6 +10,11 @@ import { triggerSegmentationDataModified } from '../../../stateManagement/segmen import type { LabelmapToolOperationData } from '../../../types'; import { getStrategyData } from './utils/getStrategyData'; import { isAxisAlignedRectangle } from '../../../utilities/rectangleROITool/isAxisAlignedRectangle'; +import BrushStrategy from './BrushStrategy'; +import type { Composition, InitializedOperationData } from './BrushStrategy'; +import { StrategyCallbacks } from '../../../enums'; +import compositions from './compositions'; +import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; const { transformWorldToIndex } = csUtils; @@ -16,34 +22,59 @@ type OperationData = LabelmapToolOperationData & { points: [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; }; -/** - * For each point in the bounding box around the rectangle, if the point is inside - * the rectangle, set the scalar value to the segmentIndex - * @param toolGroupId - string - * @param operationData - OperationData - * @param inside - boolean - */ -// Todo: why we have another constraintFn? in addition to the one in the operationData? -function fillRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData -): void { - const { points, segmentsLocked, segmentIndex, segmentationId } = - operationData; - - const { viewport } = enabledElement; - const strategyData = getStrategyData({ - operationData, - viewport: enabledElement.viewport, - }); - - if (!strategyData) { - console.warn('No data found for fillRectangle'); - return; - } - - const { segmentationImageData, segmentationVoxelManager } = strategyData; - +const initializeRectangle = { + [StrategyCallbacks.Initialize]: (operationData: InitializedOperationData) => { + const { + points, // bottom, top, left, right + imageVoxelManager, + viewport, + segmentationImageData, + segmentationVoxelManager, + } = operationData; + + // Happens on a preview setup + if (!points) { + return; + } + // Average the points to get the center of the ellipse + const center = vec3.fromValues(0, 0, 0); + points.forEach((point) => { + vec3.add(center, center, point); + }); + vec3.scale(center, center, 1 / points.length); + + operationData.centerWorld = center as Types.Point3; + operationData.centerIJK = transformWorldToIndex( + segmentationImageData, + center as Types.Point3 + ); + + // 2. Find the extent of the ellipse (circle) in IJK index space of the image + + // const { boundsIJK, pointInShapeFn } = createPointInRectangle( + // viewport, + // points, + // segmentationImageData + // ); + // segmentationVoxelManager.boundsIJK = boundsIJK; + // imageVoxelManager.isInObject = pointInShapeFn; + + const { boundsIJK, pointInShapeFn } = createPointInRectangle( + viewport as BaseVolumeViewport, + points, + segmentationImageData + ); + + operationData.isInObject = pointInShapeFn; + operationData.isInObjectBoundsIJK = boundsIJK; + }, +} as Composition; + +function createPointInRectangle( + viewport: BaseVolumeViewport, + points: Types.Point3[], + segmentationImageData: vtkImageData +) { let rectangleCornersIJK = points.map((world) => { return transformWorldToIndex(segmentationImageData, world); }); @@ -103,45 +134,53 @@ function fillRectangle( return xInside && yInside && zInside; }; - const callback = ({ value, index }) => { - if (segmentsLocked.includes(value)) { - return; - } - - segmentationVoxelManager.setAtIndex(index, segmentIndex); - }; - - segmentationVoxelManager.forEach(callback, { - isInObject: pointInShapeFn, - boundsIJK, - imageData: segmentationImageData, - }); - - triggerSegmentationDataModified(segmentationId); + return { boundsIJK, pointInShapeFn }; } +const RECTANGLE_STRATEGY = new BrushStrategy( + 'Rectangle', + compositions.regionFill, + compositions.setValue, + initializeRectangle, + compositions.determineSegmentIndex, + compositions.preview, + compositions.labelmapStatistics + // compositions.labelmapInterpolation +); + +const RECTANGLE_THRESHOLD_STRATEGY = new BrushStrategy( + 'RectangleThreshold', + compositions.regionFill, + compositions.setValue, + initializeRectangle, + compositions.determineSegmentIndex, + compositions.dynamicThreshold, + compositions.threshold, + compositions.preview, + compositions.islandRemoval, + compositions.labelmapStatistics + // compositions.labelmapInterpolation +); + /** - * Fill the inside of a rectangle - * @param toolGroupId - The unique identifier of the tool group. - * @param operationData - The data that will be used to create the - * new rectangle. + * Fill inside the circular region segment inside the segmentation defined by the operationData. + * It fills the segmentation pixels inside the defined circle. + * @param enabledElement - The element for which the segment is being erased. + * @param operationData - EraseOperationData */ -export function fillInsideRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData -): void { - fillRectangle(enabledElement, operationData); -} +const fillInsideRectangle = RECTANGLE_STRATEGY.strategyFunction; /** - * Fill the area outside of a rectangle for the toolGroupId and . - * @param toolGroupId - The unique identifier of the tool group. - * @param operationData - The data that will be used to create the - * new rectangle. + * Fill inside the circular region segment inside the segmentation defined by the operationData. + * It fills the segmentation pixels inside the defined circle. + * @param enabledElement - The element for which the segment is being erased. + * @param operationData - EraseOperationData */ -export function fillOutsideRectangle( - enabledElement: Types.IEnabledElement, - operationData: OperationData -): void { - fillRectangle(enabledElement, operationData); -} +const thresholdInsideRectangle = RECTANGLE_THRESHOLD_STRATEGY.strategyFunction; + +export { + RECTANGLE_STRATEGY, + RECTANGLE_THRESHOLD_STRATEGY, + fillInsideRectangle, + thresholdInsideRectangle, +}; diff --git a/packages/tools/src/tools/segmentation/strategies/index.ts b/packages/tools/src/tools/segmentation/strategies/index.ts index 6bad5970dd..687ae67ade 100644 --- a/packages/tools/src/tools/segmentation/strategies/index.ts +++ b/packages/tools/src/tools/segmentation/strategies/index.ts @@ -1,9 +1,9 @@ -import { fillInsideRectangle, fillOutsideRectangle } from './fillRectangle'; +import { fillInsideRectangle, thresholdInsideRectangle } from './fillRectangle'; import { fillInsideCircle, fillOutsideCircle } from './fillCircle'; export { fillInsideRectangle, - fillOutsideRectangle, + thresholdInsideRectangle, fillInsideCircle, fillOutsideCircle, }; diff --git a/packages/tools/src/types/ContourAnnotation.ts b/packages/tools/src/types/ContourAnnotation.ts index dd2f14e7ec..7623def6fd 100644 --- a/packages/tools/src/types/ContourAnnotation.ts +++ b/packages/tools/src/types/ContourAnnotation.ts @@ -15,10 +15,12 @@ export enum ContourWindingDirection { export type ContourAnnotationData = { data: { + cachedStats?: Record; contour: { polyline: Types.Point3[]; closed: boolean; windingDirection?: ContourWindingDirection; + pointsManager?: Types.IPointsManager; }; }; onInterpolationComplete?: () => void; diff --git a/packages/tools/src/types/LabelmapToolOperationData.ts b/packages/tools/src/types/LabelmapToolOperationData.ts index 4967257f2e..52e7c9acd6 100644 --- a/packages/tools/src/types/LabelmapToolOperationData.ts +++ b/packages/tools/src/types/LabelmapToolOperationData.ts @@ -5,6 +5,7 @@ import type { LabelmapSegmentationDataVolume, } from './LabelmapTypes'; import type vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import type { LabelmapMemo } from '../utilities/segmentation/createLabelmapMemo'; type LabelmapToolOperationData = { segmentationId: string; @@ -33,6 +34,16 @@ type LabelmapToolOperationData = { // eslint-disable-next-line @typescript-eslint/no-explicit-any preview: any; toolGroupId: string; + /** + * Creates a labelmap memo, given the preview information and segment voxels. + * May return an already existing one when used for extension. + */ + createMemo: ( + segmentId, + segmentVoxels, + previewVoxels?, + previewMemo? + ) => LabelmapMemo; }; type LabelmapToolOperationDataStack = LabelmapToolOperationData & diff --git a/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts b/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts index 4059e59746..f7473d3ee9 100644 --- a/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts +++ b/packages/tools/src/utilities/math/polyline/planarFreehandROIInternalTypes.ts @@ -20,6 +20,7 @@ type PlanarFreehandROIEditData = { // The index on the prevCanvasPoints that the edit line should snap to in the // edit preview. snapIndex?: number; + newAnnotation?: boolean; }; type PlanarFreehandROICommonData = { diff --git a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts index 8461b534af..fe9c8dc056 100644 --- a/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts +++ b/packages/tools/src/utilities/planar/filterAnnotationsWithinSlice.ts @@ -97,6 +97,13 @@ export default function filterAnnotationsWithinSlice( const dir = vec3.create(); + // If the handles has no values, eg a key image or other annotation, it + // should just be included. + if (!point) { + annotationsWithinSlice.push(annotation); + return; + } + vec3.sub(dir, focalPoint, point); const dot = vec3.dot(dir, viewPlaneNormal); diff --git a/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts b/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts new file mode 100644 index 0000000000..a720c7a054 --- /dev/null +++ b/packages/tools/src/utilities/segmentation/createLabelmapMemo.ts @@ -0,0 +1,131 @@ +import { utilities } from '@cornerstonejs/core'; +import { triggerSegmentationDataModified } from '../../stateManagement/segmentation/triggerSegmentationEvents'; +import type { Types } from '@cornerstonejs/core'; +import type { InitializedOperationData } from '../../tools/segmentation/strategies/BrushStrategy'; + +const { VoxelManager, RLEVoxelMap } = utilities; + +/** + * The labelmap memo state, extending from the base Memo state + */ +export type LabelmapMemo = Types.Memo & { + /** The base segmentation voxel manager */ + segmentationVoxelManager: Types.IVoxelManager; + /** The history remembering voxel manager */ + voxelManager: Types.IVoxelManager; + /** The redo and undo voxel managers */ + redoVoxelManager?: Types.IVoxelManager; + undoVoxelManager?: Types.IVoxelManager; + memo?: LabelmapMemo; +}; + +/** + * Creates a labelmap memo instance. Does not push it to the + * stack, which is handled externally. + */ +export function createLabelmapMemo( + segmentationId: string, + segmentationVoxelManager: Types.IVoxelManager, + preview?: InitializedOperationData +) { + return preview + ? createPreviewMemo(segmentationId, preview) + : createRleMemo(segmentationId, segmentationVoxelManager); +} + +/** + * A restore memo function. This simply copies either the redo or the base + * voxel manager data to the segmentation state and triggers segmentation data + * modified. + */ +export function restoreMemo(isUndo?: boolean) { + const { segmentationVoxelManager, undoVoxelManager, redoVoxelManager } = this; + const useVoxelManager = + isUndo === false ? redoVoxelManager : undoVoxelManager; + useVoxelManager.forEach(({ value, pointIJK }) => { + segmentationVoxelManager.setAtIJKPoint(pointIJK, value); + }); + const slices = useVoxelManager.getArrayOfModifiedSlices(); + triggerSegmentationDataModified(this.segmentationId, slices); +} + +/** + * Creates an RLE memo state that stores additional changes to the voxel + * map. + */ +export function createRleMemo( + segmentationId: string, + segmentationVoxelManager: Types.IVoxelManager +) { + const voxelManager = VoxelManager.createRLEHistoryVoxelManager( + segmentationVoxelManager + ); + const state = { + segmentationId, + restoreMemo, + commitMemo, + segmentationVoxelManager, + voxelManager, + }; + return state; +} + +/** + * Creates a preview memo. + */ +export function createPreviewMemo( + segmentationId: string, + preview: InitializedOperationData +) { + const { + memo: previewMemo, + segmentationVoxelManager, + previewVoxelManager, + } = preview; + + const state = { + segmentationId, + restoreMemo, + commitMemo, + segmentationVoxelManager, + voxelManager: previewVoxelManager, + memo: previewMemo, + preview, + }; + return state; +} + +/** + * This is a member function of a memo that causes the completion of the + * storage - that is, it copies the RLE data and creates a reverse RLE map + */ +function commitMemo() { + if (this.redoVoxelManager) { + return true; + } + if (!this.voxelManager.modifiedSlices.size) { + return false; + } + const { segmentationVoxelManager } = this; + const undoVoxelManager = VoxelManager.createRLEHistoryVoxelManager( + segmentationVoxelManager + ); + // @ts-expect-error - TODO: fix this + RLEVoxelMap.copyMap(undoVoxelManager.map, this.voxelManager.map); + for (const key of this.voxelManager.modifiedSlices.keys()) { + undoVoxelManager.modifiedSlices.add(key); + } + this.undoVoxelManager = undoVoxelManager; + const redoVoxelManager = VoxelManager.createRLEVolumeVoxelManager({ + dimensions: this.segmentationVoxelManager.dimensions, + }); + this.redoVoxelManager = redoVoxelManager; + undoVoxelManager.forEach(({ index, pointIJK, value }) => { + const currentValue = segmentationVoxelManager.getAtIJKPoint(pointIJK); + if (currentValue === value) { + return; + } + redoVoxelManager.setAtIndex(index, currentValue); + }); + return true; +} diff --git a/packages/tools/src/utilities/segmentation/index.ts b/packages/tools/src/utilities/segmentation/index.ts index a54236c32c..801dd33ecc 100644 --- a/packages/tools/src/utilities/segmentation/index.ts +++ b/packages/tools/src/utilities/segmentation/index.ts @@ -27,6 +27,7 @@ import { getSegmentIndexAtLabelmapBorder } from './getSegmentIndexAtLabelmapBord import { getHoveredContourSegmentationAnnotation } from './getHoveredContourSegmentationAnnotation'; import { getBrushToolInstances } from './getBrushToolInstances'; import * as growCut from './growCut'; +import * as LabelmapMemo from './createLabelmapMemo'; export { thresholdVolumeByRange, @@ -52,4 +53,5 @@ export { getHoveredContourSegmentationAnnotation, getBrushToolInstances, growCut, + LabelmapMemo, }; diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index dc35e36ef5..b798166993 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -486,6 +486,10 @@ "dynamicallyAddAnnotations": { "name": "Dynamically Add Annotations", "description": "Demonstrates how to dynamically add annotations to a viewport" + }, + "toolHistory": { + "name": "Tool History", + "description": "Demonstrates how to use the tool history to undo and redo tool actions" } }, "polymorph-segmentation": {