diff --git a/packages/core/src/utilities/historyMemo/index.ts b/packages/core/src/utilities/historyMemo/index.ts index b615663c6c..0f334afa10 100644 --- a/packages/core/src/utilities/historyMemo/index.ts +++ b/packages/core/src/utilities/historyMemo/index.ts @@ -240,6 +240,47 @@ export class HistoryMemo { this.ring[this.position] = memo; return memo; } + + /** + * Replaces a memo in the current undo item while preserving its position in + * history. This is useful when an operation starts with a provisional memo + * and later replaces the affected state as part of committing that operation. + * + * @param item - The replacement memo. + * @param condition - Identifies the provisional memo to replace. + * @returns True when a matching current memo was replaced. + */ + public replaceCurrentMemo( + item: Memo | Memoable, + condition: (memo: Memo) => boolean + ): boolean { + if (!item || this.undoAvailable === 0) { + return false; + } + + const currentItem = this.ring[this.position]; + const currentMemos = asArray(currentItem); + const memoIndex = currentMemos.findIndex(condition); + + if (memoIndex === -1) { + return false; + } + + const memo = (item as Memo).restoreMemo + ? (item as Memo) + : (item as Memoable).createMemo?.(); + if (!memo) { + return false; + } + + if (Array.isArray(currentItem)) { + currentItem[memoIndex] = memo; + } else { + this.ring[this.position] = memo; + } + + return true; + } } /** diff --git a/packages/core/test/utilities/historyMemo.jest.js b/packages/core/test/utilities/historyMemo.jest.js index 4ceebff4a6..2b677a2732 100644 --- a/packages/core/test/utilities/historyMemo.jest.js +++ b/packages/core/test/utilities/historyMemo.jest.js @@ -52,4 +52,56 @@ describe('HistoryMemo', function () { defaultHistoryMemo.undo(); expect(defaultHistoryMemo.canRedo).toBe(true); }); + + it('replaces the current memo without adding another undo step', () => { + const provisionalMemo = createMemo(0); + provisionalMemo.id = 'provisional'; + historyMemo.push(provisionalMemo); + state.testState = 1; + + const replacementMemo = createMemo(10); + replacementMemo.id = 'replacement'; + + expect( + historyMemo.replaceCurrentMemo( + replacementMemo, + (memo) => memo.id === 'provisional' + ) + ).toBe(true); + + historyMemo.undo(); + expect(state.testState).toBe(10); + expect(historyMemo.canUndo).toBe(false); + + historyMemo.redo(); + expect(state.testState).toBe(1); + }); + + it('replaces a matching memo in the current group', () => { + const firstMemo = createMemo(0); + firstMemo.id = 'first'; + const provisionalMemo = createMemo(0); + provisionalMemo.id = 'provisional'; + + historyMemo.startGroupRecording(); + historyMemo.push(firstMemo); + historyMemo.push(provisionalMemo); + historyMemo.endGroupRecording(); + + const replacementMemo = createMemo(10); + replacementMemo.id = 'replacement'; + + expect( + historyMemo.replaceCurrentMemo( + replacementMemo, + (memo) => memo.id === 'provisional' + ) + ).toBe(true); + expect( + historyMemo.replaceCurrentMemo( + createMemo(20), + (memo) => memo.id === 'missing' + ) + ).toBe(false); + }); }); diff --git a/packages/tools/examples/planarFreehandContourSegmentationTool/index.ts b/packages/tools/examples/planarFreehandContourSegmentationTool/index.ts index 2acacdc164..8c2d0b91a3 100644 --- a/packages/tools/examples/planarFreehandContourSegmentationTool/index.ts +++ b/packages/tools/examples/planarFreehandContourSegmentationTool/index.ts @@ -1,5 +1,10 @@ import type { Types } from '@cornerstonejs/core'; -import { RenderingEngine, Enums, volumeLoader } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + volumeLoader, + utilities as csUtils, +} from '@cornerstonejs/core'; import { initDemo, createImageIdsAndCacheMetaData, @@ -20,6 +25,7 @@ console.warn( let renderingEngine; const { KeyboardBindings } = cornerstoneTools.Enums; +const { DefaultHistoryMemo } = csUtils.HistoryMemo; const { PlanarFreehandContourSegmentationTool, @@ -49,7 +55,7 @@ let activeSegmentIndex = 0; // ======== Set up page ======== // setTitleAndDescription( 'Planar Freehand Contour Segmentation Tool', - 'Demonstrates how to create contour segmentations using planar freehand ROI tool' + 'Demonstrates contour segmentation drawing, editing, undo, and redo using the planar freehand ROI tool' ); const size = '512px'; @@ -80,6 +86,9 @@ content.appendChild(viewportGrid); createInfoSection(content) .addInstruction('Select a segment index') .addInstruction('Left click and drag to draw a contour') + .addInstruction( + 'Use the Undo/Redo buttons, Ctrl/Cmd+Z to undo, or Ctrl/Cmd+Shift+Z (and Ctrl+Y) to redo' + ) .openNestedSection() .addInstruction( 'Segmentation contours are closed automatically if the mouse button is released before joining the start and end points' @@ -177,6 +186,39 @@ addDropdownToToolbar({ }, }); +addButtonToToolbar({ + id: 'Undo', + title: 'Undo', + onClick: () => { + DefaultHistoryMemo.undo(); + }, +}); + +addButtonToToolbar({ + id: 'Redo', + title: 'Redo', + onClick: () => { + DefaultHistoryMemo.redo(); + }, +}); + +document.addEventListener('keydown', (evt) => { + const isModifier = evt.ctrlKey || evt.metaKey; + const key = evt.key.toLowerCase(); + + if (!isModifier || (key !== 'z' && key !== 'y')) { + return; + } + + evt.preventDefault(); + + if (key === 'y' || evt.shiftKey) { + DefaultHistoryMemo.redo(); + } else { + DefaultHistoryMemo.undo(); + } +}); + addToggleButtonToToolbar({ title: 'Show/Hide All Segments', onClick: function (toggle) { diff --git a/packages/tools/src/utilities/contourSegmentation/applyContourStroke.ts b/packages/tools/src/utilities/contourSegmentation/applyContourStroke.ts index aeaeb0f549..da4c3deee8 100644 --- a/packages/tools/src/utilities/contourSegmentation/applyContourStroke.ts +++ b/packages/tools/src/utilities/contourSegmentation/applyContourStroke.ts @@ -10,7 +10,7 @@ * Spatial overlap is not pre-filtered; Clipper decides the geometry. */ -import type { Types } from '@cornerstonejs/core'; +import { utilities as csUtils, type Types } from '@cornerstonejs/core'; import type { ContourSegmentationAnnotation } from '../../types'; import { ContourWindingDirection } from '../../types/ContourAnnotation'; import { @@ -26,7 +26,10 @@ import { getContourHolesData, updateViewportsForAnnotations, } from './sharedOperations'; -import { getAllAnnotations } from '../../stateManagement/annotation/annotationState'; +import { + getAllAnnotations, + getAnnotation, +} from '../../stateManagement/annotation/annotationState'; import { addAnnotation, addChildAnnotation, @@ -40,6 +43,7 @@ import isContourSegmentationAnnotation from './isContourSegmentationAnnotation'; import { hasToolByName } from '../../store/addTool'; const DEFAULT_CONTOUR_SEG_TOOL_NAME = 'PlanarFreehandContourSegmentationTool'; +const { DefaultHistoryMemo } = csUtils.HistoryMemo; /** Add the stroke to the segment (ยง3.2). Always additive, never removes area. */ export function addContourStroke( @@ -116,13 +120,14 @@ function applyStroke( : booleanResult; // Collect every annotation we're about to discard. - const toRemove: ContourSegmentationAnnotation[] = [sourceAnnotation]; + const previousAnnotations: ContourSegmentationAnnotation[] = []; for (const t of targets) { - toRemove.push(t); + previousAnnotations.push(t); for (const h of getContourHolesData(viewport, t)) { - toRemove.push(h.annotation); + previousAnnotations.push(h.annotation); } } + const toRemove = [sourceAnnotation, ...previousAnnotations]; for (const annotation of toRemove) { removeAnnotationCompletely(annotation); } @@ -130,6 +135,7 @@ function applyStroke( // Rebuild from clipper output. const template = targets[0] ?? sourceAnnotation; const { element } = viewport; + const resultAnnotations: ContourSegmentationAnnotation[] = []; for (const polygon of resultPolygons) { if (polygon.outer.length < 3) { @@ -143,6 +149,7 @@ function applyStroke( ); addAnnotation(parent, element); addContourSegmentationAnnotation(parent); + resultAnnotations.push(parent); polygon.holes?.forEach((holePolyline) => { if (holePolyline.length < 3) { @@ -156,6 +163,7 @@ function applyStroke( ); addAnnotation(hole, element); addChildAnnotation(parent, hole); + resultAnnotations.push(hole); triggerAnnotationModified(hole, element); }); @@ -164,9 +172,74 @@ function applyStroke( triggerAnnotationModified(parent, element); } + replaceContourStrokeHistory( + sourceAnnotation, + previousAnnotations, + resultAnnotations, + viewport + ); updateViewportsForAnnotations(viewport, toRemove); } +function replaceContourStrokeHistory( + sourceAnnotation: ContourSegmentationAnnotation, + previousAnnotations: ContourSegmentationAnnotation[], + resultAnnotations: ContourSegmentationAnnotation[], + viewport: Types.IViewport +): void { + const memo: Types.Memo = { + id: sourceAnnotation.annotationUID, + operationType: 'annotation', + restoreMemo: (isUndo = true) => { + const annotationsToRemove = isUndo + ? resultAnnotations + : previousAnnotations; + const annotationsToAdd = isUndo ? previousAnnotations : resultAnnotations; + + removeStoredAnnotations(annotationsToRemove); + addStoredAnnotations(annotationsToAdd, viewport.element); + updateViewportsForAnnotations(viewport, [ + ...annotationsToRemove, + ...annotationsToAdd, + ]); + }, + }; + + DefaultHistoryMemo.replaceCurrentMemo( + memo, + (currentMemo) => + currentMemo.id === sourceAnnotation.annotationUID && + currentMemo.operationType === 'annotation' + ); +} + +function removeStoredAnnotations( + annotations: ContourSegmentationAnnotation[] +): void { + for (let i = annotations.length - 1; i >= 0; i--) { + const annotation = annotations[i]; + + if (getAnnotation(annotation.annotationUID)) { + removeAnnotation(annotation.annotationUID); + } + removeContourSegmentationAnnotation(annotation); + } +} + +function addStoredAnnotations( + annotations: ContourSegmentationAnnotation[], + element: HTMLDivElement +): void { + for (const annotation of annotations) { + if (getAnnotation(annotation.annotationUID)) { + continue; + } + + addAnnotation(annotation, element); + addContourSegmentationAnnotation(annotation); + } +} + function collectSamePlaneSegmentTargets( viewport: Types.IViewport, sourceAnnotation: ContourSegmentationAnnotation diff --git a/tests/vitest-browser/harness/createPlanarViewport.ts b/tests/vitest-browser/harness/createPlanarViewport.ts index 9e8f97b297..2cdcde97b0 100644 --- a/tests/vitest-browser/harness/createPlanarViewport.ts +++ b/tests/vitest-browser/harness/createPlanarViewport.ts @@ -20,7 +20,7 @@ import { type FakeStackOptions, } from './fakeImageStack'; -const { Events, OrientationAxis, RenderBackend, ViewportType } = Enums; +const { Events, OrientationAxis, RenderBackends, ViewportType } = Enums; export type PlanarRenderMode = | 'vtkImage' @@ -202,7 +202,7 @@ export async function createPlanarViewport( displaySetId, options: { orientation, - renderBackend: isCpu ? RenderBackend.CPU : RenderBackend.GPU, + renderBackend: isCpu ? RenderBackends.CPU : RenderBackends.GPU, }, }); } catch (error) { diff --git a/tests/vitest-browser/harness/tools.ts b/tests/vitest-browser/harness/tools.ts index 6b88409512..ce720999b5 100644 --- a/tests/vitest-browser/harness/tools.ts +++ b/tests/vitest-browser/harness/tools.ts @@ -88,7 +88,8 @@ export async function setupTools( const viewportCtx = await createPlanarViewport(opts.viewport); const toolGroupId = - opts.toolGroupId ?? nextToolGroupId(`vitest-tools:${viewportCtx.viewportId}`); + opts.toolGroupId ?? + nextToolGroupId(`vitest-tools:${viewportCtx.viewportId}`); const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); if (!toolGroup) { @@ -305,6 +306,35 @@ export function mouseDrag( dispatchMouseUpOnDocument(element, to); } +/** + * Freehand drag gesture: mousedown at the first canvas point, one mousemove + * per remaining point, then mouseup at the final point. This mirrors + * {@link mouseDrag} but preserves a caller-supplied path for tools that record + * every drag point, such as planar freehand contours. + */ +export function mouseDragPath( + element: HTMLElement, + canvasPoints: [number, number][], + opts: { button?: number } = {} +): void { + if (canvasPoints.length < 2) { + throw new Error('mouseDragPath requires at least two canvas points'); + } + + const buttons = opts.button ?? MouseBindings.Primary; + const points = canvasPoints.map(roundCanvasPoint); + const first = points[0]; + const last = points[points.length - 1]; + + dispatchMouseDownOnElement(element, first, buttons); + + for (let i = 1; i < points.length; i++) { + dispatchMouseMoveOnDocument(element, points[i], buttons); + } + + dispatchMouseUpOnDocument(element, last); +} + /** * Hover-only move: a single `mousemove` dispatched directly on `element` * with no button held. This is the listener that stays bound to the element diff --git a/tests/vitest-browser/undoRedoHistory.browser.test.ts b/tests/vitest-browser/undoRedoHistory.browser.test.ts index 92c7eb6cca..1280598280 100644 --- a/tests/vitest-browser/undoRedoHistory.browser.test.ts +++ b/tests/vitest-browser/undoRedoHistory.browser.test.ts @@ -40,12 +40,18 @@ // `safeStructuredClone`, so redo is byte-exact). This is pinned as test 8 // below. import { afterEach, beforeEach, describe, expect, test } from 'vitest'; -import { cache, eventTarget, imageLoader, utilities } from '@cornerstonejs/core'; +import { + cache, + eventTarget, + imageLoader, + utilities, +} from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; import { mouseClick, mouseDrag, + mouseDragPath, renderAndWait, setupTools, waitForAnnotationRendered, @@ -53,7 +59,13 @@ import { type ToolsContext, } from './harness'; -const { BrushTool, LengthTool, annotation, segmentation } = cornerstoneTools; +const { + BrushTool, + LengthTool, + PlanarFreehandContourSegmentationTool, + annotation, + segmentation, +} = cornerstoneTools; const { Events: ToolsEvents, SegmentationRepresentations } = cornerstoneTools.Enums; const { DefaultHistoryMemo } = utilities.HistoryMemo; @@ -78,6 +90,10 @@ interface LabelmapHistoryContext extends ToolsContext { labelmapImageIds: string[]; } +interface ContourHistoryContext extends ToolsContext { + segmentationId: string; +} + /** * Local labelmap-on-generic-stack setup helper, derived from the public * incantation in packages/tools/examples/genericStackLabelmapSegmentation @@ -132,6 +148,54 @@ async function setupLabelmapHistoryTest(): Promise { return { ...ctx, segmentationId, labelmapImageIds }; } +async function setupContourHistoryTest(): Promise { + resetHistory(); + + const ctx = await setupTools({ + tools: [PlanarFreehandContourSegmentationTool], + activeTool: PlanarFreehandContourSegmentationTool.toolName, + viewport: { width: 400, height: 400 }, + }); + const segmentationId = `vitest-history-contour-${utilities.uuidv4()}`; + + segmentation.addSegmentations([ + { + segmentationId, + representation: { + type: SegmentationRepresentations.Contour, + }, + }, + ]); + + await segmentation.addSegmentationRepresentations(ctx.viewportId, [ + { + segmentationId, + type: SegmentationRepresentations.Contour, + }, + ]); + segmentation.activeSegmentation.setActiveSegmentation( + ctx.viewportId, + segmentationId + ); + segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 1); + + await renderAndWait(ctx.element, ctx.viewport); + + return { ...ctx, segmentationId }; +} + +async function drawContour( + ctx: ContourHistoryContext, + points: [number, number][] +): Promise { + const completed = waitForToolsEvent( + eventTarget, + ToolsEvents.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED + ); + mouseDragPath(ctx.element, points); + await completed; +} + function readSliceScalarData(imageId: string): Uint8Array { const image = cache.getImage(imageId); return image.voxelManager.getScalarData() as Uint8Array; @@ -554,9 +618,9 @@ describe('undoRedoHistory', () => { // Now: paint, undo (consumes the only entry), undo again past the // beginning -> second undo must also be a safe no-op. await paintAt(ctx, [10, 10]); - expect(nonZeroMap(readSliceScalarData(sliceImageId)).size).toBeGreaterThan( - 0 - ); + expect( + nonZeroMap(readSliceScalarData(sliceImageId)).size + ).toBeGreaterThan(0); const undone = waitForToolsEvent( eventTarget, @@ -669,5 +733,53 @@ describe('undoRedoHistory', () => { ); expect(restoredHandlePoints).toEqual(originalHandlePoints); }); + + test('contour merge undo restores the previous contour and redo restores the merged result', async () => { + const ctx = await setupContourHistoryTest(); + active = ctx; + const { element } = ctx; + const toolName = PlanarFreehandContourSegmentationTool.toolName; + + await drawContour(ctx, [ + [100, 100], + [240, 100], + [240, 240], + [100, 240], + [100, 100], + ]); + + const firstAnnotations = + annotation.state.getAnnotations(toolName, element) ?? []; + expect(firstAnnotations.length).toBe(1); + const firstAnnotationUID = firstAnnotations[0].annotationUID; + + await drawContour(ctx, [ + [180, 160], + [300, 160], + [300, 280], + [180, 280], + [180, 160], + ]); + + const mergedAnnotations = + annotation.state.getAnnotations(toolName, element) ?? []; + expect(mergedAnnotations.length).toBe(1); + const mergedAnnotationUID = mergedAnnotations[0].annotationUID; + expect(mergedAnnotationUID).not.toBe(firstAnnotationUID); + + DefaultHistoryMemo.undo(); + + const afterUndo = + annotation.state.getAnnotations(toolName, element) ?? []; + expect(afterUndo.length).toBe(1); + expect(afterUndo[0].annotationUID).toBe(firstAnnotationUID); + + DefaultHistoryMemo.redo(); + + const afterRedo = + annotation.state.getAnnotations(toolName, element) ?? []; + expect(afterRedo.length).toBe(1); + expect(afterRedo[0].annotationUID).toBe(mergedAnnotationUID); + }); }); });