diff --git a/packages/tools/test/annotationStateManagement.jest.js b/packages/tools/test/annotationStateManagement.jest.js new file mode 100644 index 0000000000..c0d580f6b2 --- /dev/null +++ b/packages/tools/test/annotationStateManagement.jest.js @@ -0,0 +1,1529 @@ +/* + * Jest coverage for src/stateManagement/annotation/* + * + * These modules keep module-level singletons (the default annotation + * manager, the locked/selected/hidden Sets), so every test resets that + * shared state in beforeEach via the exported reset helpers. + */ + +const mockEventTarget = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), +}; + +const mockTriggerEvent = jest.fn(); + +const mockEnabledElements = new Map(); +let mockUuidCounter = 0; + +function mockGetEnabledElement(element) { + return mockEnabledElements.get(element); +} + +jest.mock('@cornerstonejs/core', () => ({ + Enums: { + Events: { + IMAGE_VOLUME_MODIFIED: 'CORNERSTONE_IMAGE_VOLUME_MODIFIED', + }, + }, + eventTarget: mockEventTarget, + triggerEvent: (...args) => mockTriggerEvent(...args), + getEnabledElement: (...args) => mockGetEnabledElement(...args), + getEnabledElementByIds: jest.fn(), + utilities: { + uuidv4: jest.fn(() => `uuid-${mockUuidCounter++}`), + deepClone: (value) => + value === undefined ? value : JSON.parse(JSON.stringify(value)), + }, +})); + +const { + addAnnotation, + getAnnotation, + getAnnotations, + getAllAnnotations, + getNumberOfAnnotations, + removeAnnotation, + removeAnnotations, + removeAllAnnotations, + invalidateAnnotation, + getParentAnnotation, + getChildAnnotations, + addChildAnnotation, + clearParentAnnotation, + setAnnotationManager, + getAnnotationManager, +} = require('../src/stateManagement/annotation/annotationState'); + +const FrameOfReferenceSpecificAnnotationManager = + require('../src/stateManagement/annotation/FrameOfReferenceSpecificAnnotationManager').default; +const { + defaultFrameOfReferenceSpecificAnnotationManager, +} = require('../src/stateManagement/annotation/FrameOfReferenceSpecificAnnotationManager'); + +const { + setAnnotationLocked, + unlockAllAnnotations, + getAnnotationsLocked, + getAnnotationsLockedCount, + isAnnotationLocked, + checkAndSetAnnotationLocked, +} = require('../src/stateManagement/annotation/annotationLocking'); + +const { + setAnnotationSelected, + getAnnotationsSelected, + getAnnotationsSelectedByToolName, + getAnnotationsSelectedCount, + deselectAnnotation, + isAnnotationSelected, +} = require('../src/stateManagement/annotation/annotationSelection'); + +const { + setAnnotationVisibility, + showAllAnnotations, + isAnnotationVisible, + checkAndSetAnnotationVisibility, +} = require('../src/stateManagement/annotation/annotationVisibility'); + +const AnnotationGroup = + require('../src/stateManagement/annotation/AnnotationGroup').default; + +const { Events, ChangeTypes } = require('../src/enums'); + +const { + triggerAnnotationModified, + triggerAnnotationCompleted, + triggerContourAnnotationCompleted, +} = require('../src/stateManagement/annotation/helpers/state'); + +// Use the real default manager singleton for all tests (this is what +// annotationState.ts operates on once it has been set). +setAnnotationManager(defaultFrameOfReferenceSpecificAnnotationManager); + +function makeAnnotation({ + annotationUID, + toolName = 'ToolA', + FrameOfReferenceUID = 'FOR-1', + data = {}, +} = {}) { + const annotation = { + metadata: { + toolName, + FrameOfReferenceUID, + viewPlaneNormal: [0, 0, 1], + viewUp: [0, -1, 0], + }, + data, + }; + if (annotationUID) { + annotation.annotationUID = annotationUID; + } + return annotation; +} + +function registerEnabledElement(element, overrides = {}) { + const enabledElement = { + FrameOfReferenceUID: 'FOR-1', + renderingEngine: { id: 'engine-1' }, + viewportId: 'viewport-1', + ...overrides, + }; + mockEnabledElements.set(element, enabledElement); + return enabledElement; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockEnabledElements.clear(); + // IMPORTANT: showAllAnnotations()/deselectAnnotation() must run BEFORE + // removeAllAnnotations(). annotationVisibility's show()/hide() and the + // single-uid form of annotationSelection's deselectAnnotation() dereference + // getAnnotation(uid) without a null-guard (unlike annotationLocking's + // lock()/unlock(), which do guard) -- see the "suspected product bugs" + // tests below. If annotations left over from the previous test were + // already removed from the manager before we clear the hidden/selected + // sets, these calls throw instead of quietly clearing stale UIDs. + showAllAnnotations(); + deselectAnnotation(); + unlockAllAnnotations(); + removeAllAnnotations(); + // The above calls all synchronously trigger events via mockTriggerEvent; + // clear the mock call history so each test starts from a clean slate. + mockTriggerEvent.mockClear(); +}); + +describe('annotationState', () => { + describe('addAnnotation / getAnnotation', () => { + it('assigns a new annotationUID when one is not provided', () => { + const annotation = makeAnnotation(); + const uid = addAnnotation(annotation, 'FOR-1'); + + expect(uid).toBeTruthy(); + expect(annotation.annotationUID).toBe(uid); + expect(getAnnotation(uid)).toBe(annotation); + }); + + it('preserves a provided annotationUID', () => { + const annotation = makeAnnotation({ annotationUID: 'fixed-uid' }); + const uid = addAnnotation(annotation, 'FOR-1'); + + expect(uid).toBe('fixed-uid'); + expect(getAnnotation('fixed-uid')).toBe(annotation); + }); + + it('returns undefined for an unknown annotationUID', () => { + expect(getAnnotation('does-not-exist')).toBeUndefined(); + }); + + it('adds the annotation under the FrameOfReferenceUID group when a string selector is used', () => { + const annotation = makeAnnotation({ + annotationUID: 'uid-1', + FrameOfReferenceUID: 'FOR-XYZ', + }); + addAnnotation(annotation, 'FOR-XYZ'); + + const annotations = getAnnotations('ToolA', 'FOR-XYZ'); + expect(annotations).toEqual([annotation]); + }); + + it('adds the annotation under the FrameOfReferenceUID resolved from an enabled element', () => { + const element = document.createElement('div'); + registerEnabledElement(element, { FrameOfReferenceUID: 'FOR-ELEMENT' }); + + const annotation = makeAnnotation({ + annotationUID: 'uid-element', + FrameOfReferenceUID: 'FOR-ELEMENT', + }); + addAnnotation(annotation, element); + + expect(getAnnotations('ToolA', 'FOR-ELEMENT')).toEqual([annotation]); + }); + + it('triggers ANNOTATION_ADDED with viewport info when added via an enabled element', () => { + const element = document.createElement('div'); + registerEnabledElement(element, { + FrameOfReferenceUID: 'FOR-ELEMENT', + renderingEngine: { id: 'engine-42' }, + viewportId: 'viewport-42', + }); + + const annotation = makeAnnotation({ + annotationUID: 'uid-event', + FrameOfReferenceUID: 'FOR-ELEMENT', + }); + addAnnotation(annotation, element); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_ADDED, + { + annotation, + viewportId: 'viewport-42', + renderingEngineId: 'engine-42', + } + ); + }); + + it('does not trigger ANNOTATION_ADDED when added via FrameOfReferenceUID and no tool group is registered', () => { + // With no registered tool groups, triggerAnnotationAddedForFOR bails out + // before calling triggerEvent at all. + const annotation = makeAnnotation({ annotationUID: 'uid-for-only' }); + addAnnotation(annotation, 'FOR-1'); + + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + }); + + describe('getAnnotations / getAllAnnotations / getNumberOfAnnotations', () => { + it('returns an empty array for a group/tool with no annotations', () => { + expect(getAnnotations('ToolA', 'FOR-EMPTY')).toEqual([]); + expect(getNumberOfAnnotations('ToolA', 'FOR-EMPTY')).toBe(0); + }); + + it('accumulates multiple annotations for the same tool and FrameOfReferenceUID', () => { + const a1 = makeAnnotation({ annotationUID: 'a1' }); + const a2 = makeAnnotation({ annotationUID: 'a2' }); + addAnnotation(a1, 'FOR-1'); + addAnnotation(a2, 'FOR-1'); + + expect(getAnnotations('ToolA', 'FOR-1')).toEqual([a1, a2]); + expect(getNumberOfAnnotations('ToolA', 'FOR-1')).toBe(2); + }); + + it('separates annotations by tool name within the same FrameOfReferenceUID', () => { + const a1 = makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }); + const b1 = makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }); + addAnnotation(a1, 'FOR-1'); + addAnnotation(b1, 'FOR-1'); + + expect(getAnnotations('ToolA', 'FOR-1')).toEqual([a1]); + expect(getAnnotations('ToolB', 'FOR-1')).toEqual([b1]); + }); + + it('separates annotations across different FrameOfReferenceUIDs', () => { + const a1 = makeAnnotation({ + annotationUID: 'a1', + FrameOfReferenceUID: 'FOR-1', + }); + const a2 = makeAnnotation({ + annotationUID: 'a2', + FrameOfReferenceUID: 'FOR-2', + }); + addAnnotation(a1, 'FOR-1'); + addAnnotation(a2, 'FOR-2'); + + expect(getAnnotations('ToolA', 'FOR-1')).toEqual([a1]); + expect(getAnnotations('ToolA', 'FOR-2')).toEqual([a2]); + expect( + getAllAnnotations().sort((x, y) => + x.annotationUID.localeCompare(y.annotationUID) + ) + ).toEqual([a1, a2]); + }); + + it('getAllAnnotations returns an empty array when nothing has been added', () => { + expect(getAllAnnotations()).toEqual([]); + }); + }); + + describe('removeAnnotation', () => { + it('removes the annotation and triggers ANNOTATION_REMOVED with the manager uid', () => { + const annotation = makeAnnotation({ annotationUID: 'remove-me' }); + addAnnotation(annotation, 'FOR-1'); + mockTriggerEvent.mockClear(); + + removeAnnotation('remove-me'); + + expect(getAnnotation('remove-me')).toBeUndefined(); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_REMOVED, + { + annotation, + annotationManagerUID: + defaultFrameOfReferenceSpecificAnnotationManager.uid, + } + ); + }); + + it('is a no-op (no throw, no event) for an unknown annotationUID', () => { + expect(() => removeAnnotation('unknown-uid')).not.toThrow(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('is a no-op for an empty/falsy annotationUID', () => { + expect(() => removeAnnotation('')).not.toThrow(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('recursively removes child annotations first', () => { + const parent = makeAnnotation({ annotationUID: 'parent' }); + const child = makeAnnotation({ annotationUID: 'child' }); + addAnnotation(parent, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + addChildAnnotation(parent, child); + + removeAnnotation('parent'); + + expect(getAnnotation('parent')).toBeUndefined(); + expect(getAnnotation('child')).toBeUndefined(); + }); + }); + + describe('removeAnnotations (by group/tool)', () => { + it('removes only annotations for the given tool within the group and triggers one event per annotation', () => { + const a1 = makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }); + const b1 = makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }); + addAnnotation(a1, 'FOR-1'); + addAnnotation(b1, 'FOR-1'); + mockTriggerEvent.mockClear(); + + removeAnnotations('ToolA', 'FOR-1'); + + expect(getAnnotation('a1')).toBeUndefined(); + expect(getAnnotation('b1')).toBe(b1); + expect(mockTriggerEvent).toHaveBeenCalledTimes(1); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_REMOVED, + expect.objectContaining({ annotation: a1 }) + ); + }); + }); + + describe('removeAllAnnotations', () => { + it('removes every annotation across all groups and triggers one event per annotation', () => { + addAnnotation( + makeAnnotation({ annotationUID: 'a1', FrameOfReferenceUID: 'FOR-1' }), + 'FOR-1' + ); + addAnnotation( + makeAnnotation({ annotationUID: 'a2', FrameOfReferenceUID: 'FOR-2' }), + 'FOR-2' + ); + mockTriggerEvent.mockClear(); + + removeAllAnnotations(); + + expect(getAllAnnotations()).toEqual([]); + expect(mockTriggerEvent).toHaveBeenCalledTimes(2); + }); + + it('is a no-op when there are no annotations', () => { + removeAllAnnotations(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + }); + + describe('parent/child annotation helpers', () => { + it('associates a child with a parent and exposes it via getChildAnnotations/getParentAnnotation', () => { + const parent = makeAnnotation({ annotationUID: 'parent' }); + const child = makeAnnotation({ annotationUID: 'child' }); + addAnnotation(parent, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + + addChildAnnotation(parent, child); + + expect(parent.childAnnotationUIDs).toEqual(['child']); + expect(child.parentAnnotationUID).toBe('parent'); + expect(getChildAnnotations(parent)).toEqual([child]); + expect(getParentAnnotation(child)).toBe(parent); + }); + + it('does not duplicate an existing child association', () => { + const parent = makeAnnotation({ annotationUID: 'parent' }); + const child = makeAnnotation({ annotationUID: 'child' }); + addAnnotation(parent, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + + addChildAnnotation(parent, child); + addChildAnnotation(parent, child); + + expect(parent.childAnnotationUIDs).toEqual(['child']); + }); + + it('clears the parent association via clearParentAnnotation', () => { + const parent = makeAnnotation({ annotationUID: 'parent' }); + const child = makeAnnotation({ annotationUID: 'child' }); + addAnnotation(parent, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + addChildAnnotation(parent, child); + + clearParentAnnotation(child); + + expect(parent.childAnnotationUIDs).toEqual([]); + expect(child.parentAnnotationUID).toBeUndefined(); + }); + + it('re-parents a child that already had a different parent', () => { + const parentA = makeAnnotation({ annotationUID: 'parentA' }); + const parentB = makeAnnotation({ annotationUID: 'parentB' }); + const child = makeAnnotation({ annotationUID: 'child' }); + addAnnotation(parentA, 'FOR-1'); + addAnnotation(parentB, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + + addChildAnnotation(parentA, child); + addChildAnnotation(parentB, child); + + expect(parentA.childAnnotationUIDs).toEqual([]); + expect(parentB.childAnnotationUIDs).toEqual(['child']); + expect(child.parentAnnotationUID).toBe('parentB'); + }); + + it('getParentAnnotation/getChildAnnotations return sensible defaults with no association', () => { + const lonely = makeAnnotation({ annotationUID: 'lonely' }); + addAnnotation(lonely, 'FOR-1'); + + expect(getParentAnnotation(lonely)).toBeUndefined(); + expect(getChildAnnotations(lonely)).toEqual([]); + }); + }); + + describe('invalidateAnnotation', () => { + it('marks the annotation as invalidated', () => { + const annotation = makeAnnotation({ annotationUID: 'inv-1' }); + addAnnotation(annotation, 'FOR-1'); + + invalidateAnnotation(annotation); + + expect(annotation.invalidated).toBe(true); + }); + + it('walks up and invalidates all ancestor annotations', () => { + const grandparent = makeAnnotation({ annotationUID: 'gp' }); + const parent = makeAnnotation({ annotationUID: 'p' }); + const child = makeAnnotation({ annotationUID: 'c' }); + addAnnotation(grandparent, 'FOR-1'); + addAnnotation(parent, 'FOR-1'); + addAnnotation(child, 'FOR-1'); + addChildAnnotation(grandparent, parent); + addChildAnnotation(parent, child); + + invalidateAnnotation(child); + + expect(child.invalidated).toBe(true); + expect(parent.invalidated).toBe(true); + expect(grandparent.invalidated).toBe(true); + }); + }); + + describe('setAnnotationManager / getAnnotationManager', () => { + it('allows swapping in a custom manager and restores the default afterwards', () => { + const customManager = new FrameOfReferenceSpecificAnnotationManager( + 'custom' + ); + setAnnotationManager(customManager); + expect(getAnnotationManager()).toBe(customManager); + + const annotation = makeAnnotation({ annotationUID: 'in-custom' }); + addAnnotation(annotation, 'FOR-1'); + expect(customManager.getAnnotation('in-custom')).toBe(annotation); + + // Restore default manager for subsequent tests. + setAnnotationManager(defaultFrameOfReferenceSpecificAnnotationManager); + expect(getAnnotation('in-custom')).toBeUndefined(); + }); + }); +}); + +describe('FrameOfReferenceSpecificAnnotationManager', () => { + let manager; + + beforeEach(() => { + manager = new FrameOfReferenceSpecificAnnotationManager( + 'manager-under-test' + ); + }); + + it('generates a uuid-based uid when none is provided', () => { + const auto = new FrameOfReferenceSpecificAnnotationManager(); + expect(auto.uid).toMatch(/^uuid-/); + }); + + it('getGroupKey returns the string selector directly', () => { + expect(manager.getGroupKey('FOR-STRING')).toBe('FOR-STRING'); + }); + + it('getGroupKey resolves an element to its FrameOfReferenceUID', () => { + const element = document.createElement('div'); + registerEnabledElement(element, { FrameOfReferenceUID: 'FOR-RESOLVED' }); + expect(manager.getGroupKey(element)).toBe('FOR-RESOLVED'); + }); + + it('getGroupKey throws for an element that is not enabled', () => { + const element = document.createElement('div'); + expect(() => manager.getGroupKey(element)).toThrow(/not enabled/); + }); + + it('addAnnotation falls back to metadata.FrameOfReferenceUID when no groupKey is given', () => { + const annotation = makeAnnotation({ + annotationUID: 'a1', + FrameOfReferenceUID: 'FOR-META', + }); + manager.addAnnotation(annotation); + + expect(manager.getAnnotation('a1')).toBe(annotation); + expect(manager.getAnnotations('FOR-META', 'ToolA')).toEqual([annotation]); + }); + + it('getAnnotations returns [] for a group that does not exist', () => { + expect(manager.getAnnotations('missing-group')).toEqual([]); + }); + + it('getAnnotations returns [] for an existing group but missing tool', () => { + manager.addAnnotation(makeAnnotation({ annotationUID: 'a1' }), 'FOR-1'); + expect(manager.getAnnotations('FOR-1', 'OtherTool')).toEqual([]); + }); + + it('getAnnotations without a toolName returns the whole group map', () => { + const a1 = makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }); + const b1 = makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }); + manager.addAnnotation(a1, 'FOR-1'); + manager.addAnnotation(b1, 'FOR-1'); + + expect(manager.getAnnotations('FOR-1')).toEqual({ + ToolA: [a1], + ToolB: [b1], + }); + }); + + it('rejects unsafe group keys and tool names without throwing', () => { + const annotation = makeAnnotation({ annotationUID: 'unsafe' }); + manager.addAnnotation(annotation, '__proto__'); + expect(manager.getNumberOfAllAnnotations()).toBe(0); + + const unsafeToolAnnotation = { + metadata: { toolName: 'constructor', FrameOfReferenceUID: 'FOR-1' }, + data: {}, + annotationUID: 'unsafe-tool', + }; + manager.addAnnotation(unsafeToolAnnotation, 'FOR-1'); + expect(manager.getNumberOfAllAnnotations()).toBe(0); + }); + + it('getNumberOfAnnotations counts per tool', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }), + 'FOR-1' + ); + + expect(manager.getNumberOfAnnotations('FOR-1', 'ToolA')).toBe(2); + expect(manager.getNumberOfAnnotations('missing-group')).toBe(0); + }); + + it('BUG: getNumberOfAnnotations(groupKey) without a toolName incorrectly returns 0 even when the group has annotations', () => { + // getNumberOfAnnotations() does `const annotations = this.getAnnotations(groupKey, toolName); + // if (!annotations.length) return 0;`. When toolName is omitted, + // getAnnotations returns the {toolName: Annotations[]} map, which has no + // `.length` property, so `!annotations.length` is `!undefined` -> true, + // and the function bails out to 0 before ever reaching the + // `for (const toolName in annotations)` summation below. The "total + // annotations in a group across every tool" mode of this API is + // effectively dead code. + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }), + 'FOR-1' + ); + + expect(manager.getNumberOfAnnotations('FOR-1')).toBe(0); + }); + + it('getNumberOfAllAnnotations sums across every group and tool', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', FrameOfReferenceUID: 'FOR-1' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', FrameOfReferenceUID: 'FOR-2' }), + 'FOR-2' + ); + manager.addAnnotation( + makeAnnotation({ + annotationUID: 'a3', + FrameOfReferenceUID: 'FOR-2', + toolName: 'ToolB', + }), + 'FOR-2' + ); + + expect(manager.getNumberOfAllAnnotations()).toBe(3); + }); + + it('removeAnnotation deletes the tool bucket and group when they become empty', () => { + manager.addAnnotation(makeAnnotation({ annotationUID: 'a1' }), 'FOR-1'); + + manager.removeAnnotation('a1'); + + expect(manager.getAnnotations('FOR-1')).toEqual([]); + expect(manager.getFramesOfReference()).toEqual([]); + }); + + it('removeAnnotation leaves sibling annotations/tools intact', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }), + 'FOR-1' + ); + + manager.removeAnnotation('a1'); + + expect( + manager.getAnnotations('FOR-1', 'ToolA').map((a) => a.annotationUID) + ).toEqual(['a2']); + expect(manager.getAnnotations('FOR-1', 'ToolB')).toHaveLength(1); + }); + + it('removeAnnotations removes only the given tool and returns the removed annotations', () => { + const a1 = makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }); + const b1 = makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }); + manager.addAnnotation(a1, 'FOR-1'); + manager.addAnnotation(b1, 'FOR-1'); + + const removed = manager.removeAnnotations('FOR-1', 'ToolA'); + + expect(removed).toEqual([a1]); + expect(manager.getAnnotation('b1')).toBe(b1); + }); + + it('removeAnnotations with no toolName removes the entire group', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }), + 'FOR-1' + ); + + const removed = manager.removeAnnotations('FOR-1'); + + expect(removed.map((a) => a.annotationUID).sort()).toEqual(['a1', 'b1']); + expect(manager.getFramesOfReference()).toEqual([]); + }); + + it('removeAnnotations returns [] for a group that does not exist', () => { + expect(manager.removeAnnotations('missing-group')).toEqual([]); + }); + + it('removeAllAnnotations empties every group and returns the removed annotations', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', FrameOfReferenceUID: 'FOR-1' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', FrameOfReferenceUID: 'FOR-2' }), + 'FOR-2' + ); + + const removed = manager.removeAllAnnotations(); + + expect(removed.map((a) => a.annotationUID).sort()).toEqual(['a1', 'a2']); + expect(manager.getAllAnnotations()).toEqual([]); + expect(manager.getFramesOfReference()).toEqual([]); + }); + + describe('saveAnnotations / restoreAnnotations', () => { + it('round-trips the entire state when called with no arguments', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', FrameOfReferenceUID: 'FOR-1' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', FrameOfReferenceUID: 'FOR-2' }), + 'FOR-2' + ); + + const saved = manager.saveAnnotations(); + + const restoredInto = new FrameOfReferenceSpecificAnnotationManager( + 'restored' + ); + restoredInto.restoreAnnotations(saved); + + expect(restoredInto.getNumberOfAllAnnotations()).toBe(2); + expect(restoredInto.getAnnotation('a1')).toEqual( + expect.objectContaining({ annotationUID: 'a1' }) + ); + }); + + it('round-trips a single FrameOfReferenceUID group', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', FrameOfReferenceUID: 'FOR-1' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a2', FrameOfReferenceUID: 'FOR-2' }), + 'FOR-2' + ); + + const savedGroup = manager.saveAnnotations('FOR-1'); + + const restoredInto = new FrameOfReferenceSpecificAnnotationManager( + 'restored-group' + ); + restoredInto.restoreAnnotations(savedGroup, 'FOR-1'); + + expect(restoredInto.getNumberOfAllAnnotations()).toBe(1); + expect(restoredInto.getAnnotation('a1')).toBeTruthy(); + expect(restoredInto.getAnnotation('a2')).toBeUndefined(); + }); + + it('round-trips a single group+tool selection', () => { + manager.addAnnotation( + makeAnnotation({ annotationUID: 'a1', toolName: 'ToolA' }), + 'FOR-1' + ); + manager.addAnnotation( + makeAnnotation({ annotationUID: 'b1', toolName: 'ToolB' }), + 'FOR-1' + ); + + const savedToolAnnotations = manager.saveAnnotations('FOR-1', 'ToolA'); + expect(savedToolAnnotations).toEqual([ + expect.objectContaining({ annotationUID: 'a1' }), + ]); + + const restoredInto = new FrameOfReferenceSpecificAnnotationManager( + 'restored-tool' + ); + restoredInto.restoreAnnotations(savedToolAnnotations, 'FOR-1', 'ToolA'); + + expect(restoredInto.getAnnotations('FOR-1', 'ToolA')).toEqual( + savedToolAnnotations + ); + expect(restoredInto.getNumberOfAllAnnotations()).toBe(1); + }); + + it('saveAnnotations returns a deep clone, not a live reference', () => { + const annotation = makeAnnotation({ annotationUID: 'a1' }); + manager.addAnnotation(annotation, 'FOR-1'); + + const saved = manager.saveAnnotations('FOR-1', 'ToolA'); + saved[0].data.mutated = true; + + expect(annotation.data.mutated).toBeUndefined(); + }); + + it('saveAnnotations returns undefined for a group+tool combination that does not exist', () => { + expect(manager.saveAnnotations('missing-group', 'ToolA')).toBeUndefined(); + }); + }); + + describe('_imageVolumeModifiedHandler (registered for Enums.Events.IMAGE_VOLUME_MODIFIED at construction)', () => { + it('registers the handler on eventTarget when constructed', () => { + expect(mockEventTarget.addEventListener).toHaveBeenCalledWith( + 'CORNERSTONE_IMAGE_VOLUME_MODIFIED', + manager._imageVolumeModifiedHandler + ); + }); + + it('invalidates annotations for the affected FrameOfReferenceUID that already have an invalidated flag defined', () => { + const a1 = makeAnnotation({ + annotationUID: 'a1', + FrameOfReferenceUID: 'FOR-VOL', + }); + a1.invalidated = false; + const a2 = makeAnnotation({ + annotationUID: 'a2', + FrameOfReferenceUID: 'FOR-VOL', + }); + // a2.invalidated is intentionally left undefined: the handler only + // flips the flag for annotations where `invalidated !== undefined`. + manager.addAnnotation(a1, 'FOR-VOL'); + manager.addAnnotation(a2, 'FOR-VOL'); + + manager._imageVolumeModifiedHandler({ + detail: { FrameOfReferenceUID: 'FOR-VOL' }, + }); + + expect(a1.invalidated).toBe(true); + expect(a2.invalidated).toBeUndefined(); + }); + + it('does nothing for a FrameOfReferenceUID with no tracked annotations', () => { + expect(() => + manager._imageVolumeModifiedHandler({ + detail: { FrameOfReferenceUID: 'unknown-for' }, + }) + ).not.toThrow(); + }); + }); + + it('setPreprocessingFn transforms annotations as they are added', () => { + manager.setPreprocessingFn((annotation) => { + annotation.data.preprocessed = true; + return annotation; + }); + + const annotation = makeAnnotation({ annotationUID: 'a1' }); + manager.addAnnotation(annotation, 'FOR-1'); + + expect(manager.getAnnotation('a1').data.preprocessed).toBe(true); + }); +}); + +describe('annotationLocking', () => { + let annotation; + + beforeEach(() => { + annotation = makeAnnotation({ annotationUID: 'lock-target' }); + addAnnotation(annotation, 'FOR-1'); + mockTriggerEvent.mockClear(); + }); + + it('locks an annotation, sets isLocked, and triggers ANNOTATION_LOCK_CHANGE with added/locked payload', () => { + setAnnotationLocked('lock-target', true); + + expect(annotation.isLocked).toBe(true); + expect(isAnnotationLocked('lock-target')).toBe(true); + expect(getAnnotationsLockedCount()).toBe(1); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_LOCK_CHANGE, + { added: ['lock-target'], removed: [], locked: ['lock-target'] } + ); + }); + + it('defaults the locked parameter to true', () => { + setAnnotationLocked('lock-target'); + expect(isAnnotationLocked('lock-target')).toBe(true); + }); + + it('unlocks a locked annotation and triggers ANNOTATION_LOCK_CHANGE with removed payload', () => { + setAnnotationLocked('lock-target', true); + mockTriggerEvent.mockClear(); + + setAnnotationLocked('lock-target', false); + + expect(annotation.isLocked).toBe(false); + expect(isAnnotationLocked('lock-target')).toBe(false); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_LOCK_CHANGE, + { added: [], removed: ['lock-target'], locked: [] } + ); + }); + + it('does not trigger an event when locking an already-locked annotation', () => { + setAnnotationLocked('lock-target', true); + mockTriggerEvent.mockClear(); + + setAnnotationLocked('lock-target', true); + + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('does not trigger an event when unlocking an annotation that is not locked', () => { + setAnnotationLocked('lock-target', false); + + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('does nothing for a falsy annotationUID beyond publishing (no added/removed => no event)', () => { + setAnnotationLocked('', true); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('isAnnotationLocked returns false for an unknown annotationUID', () => { + expect(isAnnotationLocked('unknown')).toBe(false); + }); + + it('unlockAllAnnotations clears the locked set and triggers one combined event', () => { + const other = makeAnnotation({ annotationUID: 'lock-target-2' }); + addAnnotation(other, 'FOR-1'); + setAnnotationLocked('lock-target', true); + setAnnotationLocked('lock-target-2', true); + mockTriggerEvent.mockClear(); + + unlockAllAnnotations(); + + expect(getAnnotationsLocked()).toEqual([]); + expect(getAnnotationsLockedCount()).toBe(0); + expect(annotation.isLocked).toBe(false); + expect(other.isLocked).toBe(false); + expect(mockTriggerEvent).toHaveBeenCalledTimes(1); + const [, , detail] = mockTriggerEvent.mock.calls[0]; + expect(detail.removed.sort()).toEqual(['lock-target', 'lock-target-2']); + expect(detail.locked).toEqual([]); + }); + + it('unlockAllAnnotations is a no-op when nothing is locked', () => { + unlockAllAnnotations(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('contrast with annotationVisibility bugs: unlocking/relocking does NOT throw once the underlying annotation has been removed', () => { + // lock()/unlock() in annotationLocking.ts guard with + // `if (annotation) { annotation.isLocked = ...; }` before dereferencing, + // unlike annotationVisibility's show()/hide() or the single-uid form of + // annotationSelection's deselectAnnotation() (see the bug tests in those + // suites). This is the "correct" pattern the other two modules should + // follow. + const orphan = makeAnnotation({ annotationUID: 'orphan-lock' }); + addAnnotation(orphan, 'FOR-1'); + setAnnotationLocked('orphan-lock', true); + removeAnnotation('orphan-lock'); + + expect(() => setAnnotationLocked('orphan-lock', false)).not.toThrow(); + expect(isAnnotationLocked('orphan-lock')).toBe(false); + }); + + describe('checkAndSetAnnotationLocked', () => { + it('re-applies the current (unlocked) state and returns it without emitting an event', () => { + const result = checkAndSetAnnotationLocked('lock-target'); + + expect(result).toBe(false); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('re-applies the current (locked) state and returns it without emitting an event', () => { + setAnnotationLocked('lock-target', true); + mockTriggerEvent.mockClear(); + + const result = checkAndSetAnnotationLocked('lock-target'); + + expect(result).toBe(true); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + }); +}); + +describe('annotationSelection', () => { + let a1; + let a2; + + beforeEach(() => { + a1 = makeAnnotation({ annotationUID: 'sel-1' }); + a2 = makeAnnotation({ annotationUID: 'sel-2' }); + addAnnotation(a1, 'FOR-1'); + addAnnotation(a2, 'FOR-1'); + mockTriggerEvent.mockClear(); + }); + + it('selects an annotation and triggers ANNOTATION_SELECTION_CHANGE with added/selection payload', () => { + setAnnotationSelected('sel-1', true); + + expect(a1.isSelected).toBe(true); + expect(isAnnotationSelected('sel-1')).toBe(true); + expect(getAnnotationsSelected()).toEqual(['sel-1']); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_SELECTION_CHANGE, + { added: ['sel-1'], removed: [], selection: ['sel-1'] } + ); + }); + + it('defaults selected and preserveSelected such that a second select replaces the first', () => { + setAnnotationSelected('sel-1'); + setAnnotationSelected('sel-2'); + + expect(a1.isSelected).toBe(false); + expect(a2.isSelected).toBe(true); + expect(getAnnotationsSelected()).toEqual(['sel-2']); + }); + + it('preserveSelected=true accumulates selections instead of replacing them', () => { + setAnnotationSelected('sel-1', true, true); + setAnnotationSelected('sel-2', true, true); + + expect(getAnnotationsSelected().sort()).toEqual(['sel-1', 'sel-2']); + expect(a1.isSelected).toBe(true); + expect(a2.isSelected).toBe(true); + }); + + it('selecting an already-selected annotation with preserveSelected=false clears then reselects (still selected, event still added)', () => { + setAnnotationSelected('sel-1', true, false); + mockTriggerEvent.mockClear(); + + setAnnotationSelected('sel-1', true, false); + + // clearSelectionSet removes sel-1 (added to `removed`), then it is + // immediately re-added (added to `added`); net selection is unchanged + // but an event is still triggered because removed/added are non-empty. + expect(getAnnotationsSelected()).toEqual(['sel-1']); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_SELECTION_CHANGE, + { added: ['sel-1'], removed: ['sel-1'], selection: ['sel-1'] } + ); + }); + + it('setAnnotationSelected(uid, false) deselects a single annotation', () => { + setAnnotationSelected('sel-1', true, true); + setAnnotationSelected('sel-2', true, true); + mockTriggerEvent.mockClear(); + + setAnnotationSelected('sel-1', false); + + expect(a1.isSelected).toBe(false); + expect(getAnnotationsSelected()).toEqual(['sel-2']); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_SELECTION_CHANGE, + { added: [], removed: ['sel-1'], selection: ['sel-2'] } + ); + }); + + it('deselectAnnotation() with no argument clears all selections', () => { + setAnnotationSelected('sel-1', true, true); + setAnnotationSelected('sel-2', true, true); + mockTriggerEvent.mockClear(); + + deselectAnnotation(); + + expect(getAnnotationsSelected()).toEqual([]); + expect(a1.isSelected).toBe(false); + expect(a2.isSelected).toBe(false); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_SELECTION_CHANGE, + { + added: [], + removed: expect.arrayContaining(['sel-1', 'sel-2']), + selection: [], + } + ); + }); + + it('does not trigger an event deselecting an annotation that is not selected', () => { + deselectAnnotation('sel-1'); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('does not trigger an event when deselecting all with nothing selected', () => { + deselectAnnotation(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('BUG: deselecting a single annotation UID throws once the underlying annotation has been removed', () => { + // The single-uid branch of deselectAnnotation() does + // `const annotation = getAnnotation(annotationUID); annotation.isSelected = false;` + // with no guard, unlike its own no-arg branch (clearSelectionSet, which + // guards with `if (annotation) { ... }`) and unlike annotationLocking's + // unlock(). Once the annotation has been removed from the manager while + // still selected, deselecting it by uid throws instead of silently + // clearing the stale UID. + const orphan = makeAnnotation({ annotationUID: 'orphan-sel' }); + addAnnotation(orphan, 'FOR-1'); + setAnnotationSelected('orphan-sel', true); + removeAnnotation('orphan-sel'); + + expect(() => deselectAnnotation('orphan-sel')).toThrow(TypeError); + }); + + it('getAnnotationsSelectedCount reflects the current selection size', () => { + expect(getAnnotationsSelectedCount()).toBe(0); + setAnnotationSelected('sel-1', true, true); + setAnnotationSelected('sel-2', true, true); + expect(getAnnotationsSelectedCount()).toBe(2); + }); + + it('getAnnotationsSelectedByToolName filters selected annotations by tool name', () => { + const b1 = makeAnnotation({ annotationUID: 'sel-b1', toolName: 'ToolB' }); + addAnnotation(b1, 'FOR-1'); + + setAnnotationSelected('sel-1', true, true); + setAnnotationSelected('sel-b1', true, true); + + expect(getAnnotationsSelectedByToolName('ToolA')).toEqual(['sel-1']); + expect(getAnnotationsSelectedByToolName('ToolB')).toEqual(['sel-b1']); + }); + + it('isAnnotationSelected returns false for an unknown annotationUID', () => { + expect(isAnnotationSelected('unknown')).toBe(false); + }); +}); + +describe('annotationVisibility', () => { + let annotation; + + beforeEach(() => { + annotation = makeAnnotation({ annotationUID: 'vis-1' }); + addAnnotation(annotation, 'FOR-1'); + mockTriggerEvent.mockClear(); + }); + + it('an annotation is visible by default (never hidden)', () => { + expect(isAnnotationVisible('vis-1')).toBe(true); + }); + + it('isAnnotationVisible returns undefined for an unknown annotationUID', () => { + expect(isAnnotationVisible('unknown')).toBeUndefined(); + }); + + it('hides an annotation and triggers ANNOTATION_VISIBILITY_CHANGE with lastHidden/hidden payload', () => { + setAnnotationVisibility('vis-1', false); + + expect(annotation.isVisible).toBe(false); + expect(isAnnotationVisible('vis-1')).toBe(false); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_VISIBILITY_CHANGE, + { lastVisible: [], lastHidden: ['vis-1'], hidden: ['vis-1'] } + ); + }); + + it('shows a hidden annotation and triggers ANNOTATION_VISIBILITY_CHANGE with lastVisible payload', () => { + setAnnotationVisibility('vis-1', false); + mockTriggerEvent.mockClear(); + + setAnnotationVisibility('vis-1', true); + + expect(annotation.isVisible).toBe(true); + expect(isAnnotationVisible('vis-1')).toBe(true); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_VISIBILITY_CHANGE, + { lastVisible: ['vis-1'], lastHidden: [], hidden: [] } + ); + }); + + it('does not trigger an event hiding an already-hidden annotation', () => { + setAnnotationVisibility('vis-1', false); + mockTriggerEvent.mockClear(); + + setAnnotationVisibility('vis-1', false); + + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('does not trigger an event showing an already-visible annotation', () => { + setAnnotationVisibility('vis-1', true); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('hiding a selected annotation deselects it as a side effect', () => { + setAnnotationSelected('vis-1', true); + mockTriggerEvent.mockClear(); + + setAnnotationVisibility('vis-1', false); + + expect(isAnnotationSelected('vis-1')).toBe(false); + // Two events: one for the selection-change side effect, one for visibility. + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_SELECTION_CHANGE, + expect.objectContaining({ removed: ['vis-1'] }) + ); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_VISIBILITY_CHANGE, + expect.objectContaining({ lastHidden: ['vis-1'] }) + ); + }); + + it('showAllAnnotations clears every hidden annotation with a single combined event', () => { + const other = makeAnnotation({ annotationUID: 'vis-2' }); + addAnnotation(other, 'FOR-1'); + setAnnotationVisibility('vis-1', false); + setAnnotationVisibility('vis-2', false); + mockTriggerEvent.mockClear(); + + showAllAnnotations(); + + expect(isAnnotationVisible('vis-1')).toBe(true); + expect(isAnnotationVisible('vis-2')).toBe(true); + expect(mockTriggerEvent).toHaveBeenCalledTimes(1); + const [, , detail] = mockTriggerEvent.mock.calls[0]; + expect(detail.lastVisible.sort()).toEqual(['vis-1', 'vis-2']); + expect(detail.hidden).toEqual([]); + }); + + it('showAllAnnotations is a no-op when nothing is hidden', () => { + showAllAnnotations(); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + describe('checkAndSetAnnotationVisibility', () => { + it('re-applies the current (visible) state and returns it without throwing', () => { + const result = checkAndSetAnnotationVisibility('vis-1'); + expect(result).toBe(true); + }); + + it('re-applies the current (hidden) state and returns it', () => { + setAnnotationVisibility('vis-1', false); + mockTriggerEvent.mockClear(); + + const result = checkAndSetAnnotationVisibility('vis-1'); + + expect(result).toBe(false); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + }); + + describe('suspected product bug: missing null-guard on orphaned UIDs', () => { + it('BUG: re-showing a hidden annotation throws once the underlying annotation has been removed', () => { + // annotationVisibility's show()/hide() do + // `const annotation = getAnnotation(annotationUID); annotation.isVisible = ...;` + // with no guard for a missing annotation, unlike annotationLocking's + // lock()/unlock() which check `if (annotation) { ... }` first (see the + // contrast test in the annotationLocking suite). Once an annotation is + // removed from the manager while still present in the hidden set (e.g. + // removeAnnotation() does not touch visibility state), un-hiding it + // throws a TypeError instead of silently clearing the stale UID. + const orphan = makeAnnotation({ annotationUID: 'orphan-vis' }); + addAnnotation(orphan, 'FOR-1'); + setAnnotationVisibility('orphan-vis', false); + removeAnnotation('orphan-vis'); + + expect(() => setAnnotationVisibility('orphan-vis', true)).toThrow( + TypeError + ); + }); + + it('BUG: showAllAnnotations() throws if any hidden UID has since been removed from the manager', () => { + const orphan = makeAnnotation({ annotationUID: 'orphan-vis-2' }); + addAnnotation(orphan, 'FOR-1'); + setAnnotationVisibility('orphan-vis-2', false); + removeAnnotation('orphan-vis-2'); + + expect(() => showAllAnnotations()).toThrow(TypeError); + }); + }); +}); + +describe('AnnotationGroup', () => { + let group; + let annotation; + const baseEvent = { viewportId: 'vp-1', renderingEngineId: 'engine-1' }; + + beforeEach(() => { + group = new AnnotationGroup(); + annotation = makeAnnotation({ annotationUID: 'group-member', data: {} }); + annotation.isVisible = true; + addAnnotation(annotation, 'FOR-1'); + mockTriggerEvent.mockClear(); + }); + + it('is visible by default and has no members', () => { + expect(group.isVisible).toBe(true); + expect(group.has('group-member')).toBe(false); + }); + + it('add() registers members that has() reports back', () => { + group.add('group-member', 'another-uid'); + + expect(group.has('group-member')).toBe(true); + expect(group.has('another-uid')).toBe(true); + expect(group.has('unrelated')).toBe(false); + }); + + it('remove() drops a member', () => { + group.add('group-member'); + group.remove('group-member'); + + expect(group.has('group-member')).toBe(false); + }); + + it('clear() drops every member', () => { + group.add('a', 'b', 'c'); + group.clear(); + + expect(group.has('a')).toBe(false); + expect(group.has('b')).toBe(false); + }); + + it('setVisible(false) hides member annotations and triggers ANNOTATION_MODIFIED per changed annotation', () => { + group.add('group-member'); + + group.setVisible(false, baseEvent); + + expect(group.isVisible).toBe(false); + expect(annotation.isVisible).toBe(false); + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_MODIFIED, + { viewportId: 'vp-1', renderingEngineId: 'engine-1', annotation } + ); + }); + + it('setVisible is a no-op when the visibility flag does not change', () => { + group.setVisible(true, baseEvent); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('setVisible skips annotations whose isVisible already matches the target', () => { + annotation.isVisible = false; + group.add('group-member'); + + group.setVisible(false, baseEvent); + + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('setVisible respects a filter that blocks hiding for a given uid', () => { + group.add('group-member'); + + group.setVisible(false, baseEvent, () => false); + + expect(annotation.isVisible).toBe(true); + expect(mockTriggerEvent).not.toHaveBeenCalled(); + }); + + it('setVisible drops members whose annotation no longer exists', () => { + group.add('gone-uid'); + + group.setVisible(false, baseEvent); + + expect(group.has('gone-uid')).toBe(false); + }); + + describe('findNearby', () => { + it('returns null when the group is empty', () => { + expect(group.findNearby('anything', 1)).toBeNull(); + }); + + it('returns the first member when no uid is given, moving forward', () => { + group.add('m1', 'm2', 'm3'); + expect(group.findNearby(undefined, 1)).toBe('m1'); + }); + + it('returns the next member in the forward direction', () => { + group.add('m1', 'm2', 'm3'); + expect(group.findNearby('m1', 1)).toBe('m2'); + }); + + it('returns null when already at the last member moving forward', () => { + group.add('m1', 'm2', 'm3'); + expect(group.findNearby('m3', 1)).toBeNull(); + }); + + it('returns null for a uid that is not a member', () => { + group.add('m1', 'm2'); + expect(group.findNearby('not-a-member', 1)).toBeNull(); + }); + }); +}); + +describe('helpers/state (direct trigger* exports)', () => { + let annotation; + + beforeEach(() => { + annotation = makeAnnotation({ annotationUID: 'trigger-target' }); + addAnnotation(annotation, 'FOR-1'); + mockTriggerEvent.mockClear(); + }); + + describe('triggerAnnotationModified', () => { + it('triggers ANNOTATION_MODIFIED with viewport info resolved from the element and defaults to HandlesUpdated', () => { + const element = document.createElement('div'); + // Unlike triggerAnnotationAddedForElement, this helper destructures + // viewportId/renderingEngineId directly off the enabled element (no + // renderingEngine.id indirection). + mockEnabledElements.set(element, { + viewportId: 'viewport-mod', + renderingEngineId: 'engine-mod', + }); + + triggerAnnotationModified(annotation, element); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_MODIFIED, + { + annotation, + viewportId: 'viewport-mod', + renderingEngineId: 'engine-mod', + changeType: ChangeTypes.HandlesUpdated, + } + ); + }); + + it('triggers ANNOTATION_MODIFIED with undefined viewport info when no element is given', () => { + triggerAnnotationModified(annotation); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_MODIFIED, + { + annotation, + viewportId: undefined, + renderingEngineId: undefined, + changeType: ChangeTypes.HandlesUpdated, + } + ); + }); + + it('accepts an explicit changeType', () => { + triggerAnnotationModified( + annotation, + undefined, + ChangeTypes.StatsUpdated + ); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_MODIFIED, + expect.objectContaining({ changeType: ChangeTypes.StatsUpdated }) + ); + }); + }); + + it('triggerAnnotationCompleted triggers ANNOTATION_COMPLETED with the annotation', () => { + triggerAnnotationCompleted(annotation); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_COMPLETED, + { annotation } + ); + }); + + it('triggerContourAnnotationCompleted triggers ANNOTATION_COMPLETED with the contourHoleProcessingEnabled flag', () => { + triggerContourAnnotationCompleted(annotation, true); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_COMPLETED, + { annotation, contourHoleProcessingEnabled: true } + ); + }); + + it('triggerContourAnnotationCompleted defaults contourHoleProcessingEnabled to false', () => { + triggerContourAnnotationCompleted(annotation); + + expect(mockTriggerEvent).toHaveBeenCalledWith( + mockEventTarget, + Events.ANNOTATION_COMPLETED, + { annotation, contourHoleProcessingEnabled: false } + ); + }); +}); + +describe('resetAnnotationManager', () => { + // Imported lazily inside an isolated module registry so that wiring the + // preprocessingFn onto the shared default manager singleton (which is what + // this module does as a side effect of being imported) does not leak into + // the rest of this file's tests, which exercise the default manager + // without a preprocessingFn attached. + it('resets the default manager singleton and wires up the lock/visibility/textBox preprocessing pipeline', () => { + let resetAnnotationManager; + let isolatedAddAnnotation; + let isolatedGetAnnotation; + let isolatedGetAnnotationManager; + let isolatedSetAnnotationManager; + let isolatedDefaultManager; + let IsolatedManagerClass; + + jest.isolateModules(() => { + ({ + resetAnnotationManager, + } = require('../src/stateManagement/annotation/resetAnnotationManager')); + ({ + addAnnotation: isolatedAddAnnotation, + getAnnotation: isolatedGetAnnotation, + getAnnotationManager: isolatedGetAnnotationManager, + setAnnotationManager: isolatedSetAnnotationManager, + } = require('../src/stateManagement/annotation/annotationState')); + ({ + defaultFrameOfReferenceSpecificAnnotationManager: + isolatedDefaultManager, + default: IsolatedManagerClass, + } = require('../src/stateManagement/annotation/FrameOfReferenceSpecificAnnotationManager')); + }); + + // Point the manager elsewhere, then confirm reset restores the default. + const otherManager = new IsolatedManagerClass('other'); + isolatedSetAnnotationManager(otherManager); + expect(isolatedGetAnnotationManager()).toBe(otherManager); + + resetAnnotationManager(); + + expect(isolatedGetAnnotationManager()).toBe(isolatedDefaultManager); + + const annotation = makeAnnotation({ annotationUID: 'preprocessed' }); + isolatedAddAnnotation(annotation, 'FOR-1'); + + const stored = isolatedGetAnnotation('preprocessed'); + expect(stored.data.cachedStats).toEqual({}); + expect(stored.data.handles.textBox).toEqual({}); + expect(stored.isLocked).toBe(false); + expect(stored.isVisible).toBe(true); + }); +}); diff --git a/packages/tools/test/storeToolFilters.jest.js b/packages/tools/test/storeToolFilters.jest.js new file mode 100644 index 0000000000..f12afeb2a8 --- /dev/null +++ b/packages/tools/test/storeToolFilters.jest.js @@ -0,0 +1,535 @@ +// Covers packages/tools/src/store/ pure-logic modules that run on every +// mouse move to find candidate tools/handles for hit-testing: +// - filterToolsWithAnnotationsForElement.ts +// - filterMoveableAnnotationTools.ts +// - filterToolsWithMoveableHandles.ts +// - cancelActiveManipulations.ts +// - addTool.ts / state.ts registry logic +// +// None of these modules touch rendering/WebGL: they operate on plain tool +// instances and annotation POJOs, so we mock the stateManagement annotation +// module and the ToolGroupManager rather than pulling in real ones. + +jest.mock('@cornerstonejs/core', () => ({ + utilities: { + deepClone: (value) => JSON.parse(JSON.stringify(value)), + }, + getEnabledElement: jest.fn(), +})); + +jest.mock('../src/stateManagement/annotation/annotationState', () => ({ + getAnnotations: jest.fn(), +})); + +jest.mock('../src/store/ToolGroupManager', () => ({ + getToolGroupForViewport: jest.fn(), +})); + +import { getEnabledElement } from '@cornerstonejs/core'; +import { getAnnotations } from '../src/stateManagement/annotation/annotationState'; +import { getToolGroupForViewport } from '../src/store/ToolGroupManager'; + +import filterToolsWithAnnotationsForElement from '../src/store/filterToolsWithAnnotationsForElement'; +import filterMoveableAnnotationTools from '../src/store/filterMoveableAnnotationTools'; +import filterToolsWithMoveableHandles from '../src/store/filterToolsWithMoveableHandles'; +import cancelActiveManipulations from '../src/store/cancelActiveManipulations'; +import { + addTool, + hasTool, + hasToolByName, + removeTool, +} from '../src/store/addTool'; +import { state, resetCornerstoneToolsState } from '../src/store/state'; +import { ToolModes } from '../src/enums'; + +function createFakeAnnotation({ + isLocked = false, + isVisible = true, + uid = 'annotation-1', +} = {}) { + return { + annotationUID: uid, + isLocked, + isVisible, + metadata: {}, + data: {}, + }; +} + +function createFakeTool(toolName, overrides = {}) { + class FakeTool { + static toolName = toolName; + } + return Object.assign(new FakeTool(), overrides); +} + +describe('store/filterToolsWithAnnotationsForElement', () => { + const element = document.createElement('div'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('excludes tools with no annotations for the element', () => { + const tool = createFakeTool('ToolA'); + getAnnotations.mockReturnValue([]); + + const result = filterToolsWithAnnotationsForElement(element, [tool]); + + expect(result).toEqual([]); + }); + + it('excludes tools whose annotations lookup returns undefined', () => { + const tool = createFakeTool('ToolA'); + getAnnotations.mockReturnValue(undefined); + + const result = filterToolsWithAnnotationsForElement(element, [tool]); + + expect(result).toEqual([]); + }); + + it('returns {tool, annotations} pairs for tools that have annotations', () => { + const toolA = createFakeTool('ToolA'); + const toolB = createFakeTool('ToolB'); + const annotationsA = [createFakeAnnotation({ uid: 'a1' })]; + const annotationsB = [createFakeAnnotation({ uid: 'b1' })]; + + getAnnotations.mockImplementation((toolName) => + toolName === 'ToolA' ? annotationsA : annotationsB + ); + + const result = filterToolsWithAnnotationsForElement(element, [ + toolA, + toolB, + ]); + + expect(result).toEqual([ + { tool: toolA, annotations: annotationsA }, + { tool: toolB, annotations: annotationsB }, + ]); + // Called with (toolName, element) per tool. + expect(getAnnotations).toHaveBeenCalledWith('ToolA', element); + expect(getAnnotations).toHaveBeenCalledWith('ToolB', element); + }); + + it('skips undefined entries in the tools array without throwing', () => { + const toolA = createFakeTool('ToolA'); + const annotationsA = [createFakeAnnotation()]; + getAnnotations.mockReturnValue(annotationsA); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = filterToolsWithAnnotationsForElement(element, [ + undefined, + toolA, + ]); + + expect(result).toEqual([{ tool: toolA, annotations: annotationsA }]); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('applies filterInteractableAnnotationsForElement when a tool defines it', () => { + const filtered = [createFakeAnnotation({ uid: 'kept' })]; + const filterInteractableAnnotationsForElement = jest.fn(() => filtered); + const tool = createFakeTool('ToolA', { + filterInteractableAnnotationsForElement, + }); + const rawAnnotations = [ + createFakeAnnotation({ uid: 'kept' }), + createFakeAnnotation({ uid: 'dropped' }), + ]; + getAnnotations.mockReturnValue(rawAnnotations); + + const result = filterToolsWithAnnotationsForElement(element, [tool]); + + expect(filterInteractableAnnotationsForElement).toHaveBeenCalledWith( + element, + rawAnnotations + ); + expect(result).toEqual([{ tool, annotations: filtered }]); + }); + + it('excludes a tool if its custom filter empties out the annotations', () => { + const tool = createFakeTool('ToolA', { + filterInteractableAnnotationsForElement: jest.fn(() => []), + }); + getAnnotations.mockReturnValue([createFakeAnnotation()]); + + const result = filterToolsWithAnnotationsForElement(element, [tool]); + + expect(result).toEqual([]); + }); +}); + +describe('store/filterMoveableAnnotationTools', () => { + const element = document.createElement('div'); + const canvasCoords = [10, 10]; + + it('keeps a tool whose isPointNearTool returns true', () => { + const annotation = createFakeAnnotation(); + const isPointNearTool = jest.fn(() => true); + const tool = createFakeTool('ToolA', { isPointNearTool }); + + const result = filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([{ tool, annotation }]); + // proximity for mouse interaction is 6 (see source comment / constant) + expect(isPointNearTool).toHaveBeenCalledWith( + element, + annotation, + canvasCoords, + 6, + 'mouse' + ); + }); + + it('drops a tool whose isPointNearTool returns false', () => { + const annotation = createFakeAnnotation(); + const tool = createFakeTool('ToolA', { + isPointNearTool: jest.fn(() => false), + }); + + const result = filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([]); + }); + + it('skips locked annotations without calling isPointNearTool', () => { + const annotation = createFakeAnnotation({ isLocked: true }); + const isPointNearTool = jest.fn(() => true); + const tool = createFakeTool('ToolA', { isPointNearTool }); + + const result = filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([]); + expect(isPointNearTool).not.toHaveBeenCalled(); + }); + + it('skips invisible annotations without calling isPointNearTool', () => { + const annotation = createFakeAnnotation({ isVisible: false }); + const isPointNearTool = jest.fn(() => true); + const tool = createFakeTool('ToolA', { isPointNearTool }); + + const result = filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([]); + expect(isPointNearTool).not.toHaveBeenCalled(); + }); + + it('uses the larger touch proximity (36) for touch interaction', () => { + const annotation = createFakeAnnotation(); + const isPointNearTool = jest.fn(() => true); + const tool = createFakeTool('ToolA', { isPointNearTool }); + + filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation] }], + canvasCoords, + 'touch' + ); + + expect(isPointNearTool).toHaveBeenCalledWith( + element, + annotation, + canvasCoords, + 36, + 'touch' + ); + }); + + it('stops at the first moveable annotation per tool (one result per tool)', () => { + const annotation1 = createFakeAnnotation({ uid: 'a1' }); + const annotation2 = createFakeAnnotation({ uid: 'a2' }); + const isPointNearTool = jest.fn(() => true); + const tool = createFakeTool('ToolA', { isPointNearTool }); + + const result = filterMoveableAnnotationTools( + element, + [{ tool, annotations: [annotation1, annotation2] }], + canvasCoords + ); + + expect(result).toEqual([{ tool, annotation: annotation1 }]); + expect(isPointNearTool).toHaveBeenCalledTimes(1); + }); +}); + +describe('store/filterToolsWithMoveableHandles', () => { + const element = document.createElement('div'); + const canvasCoords = [5, 5]; + + it('keeps a tool and reports the handle + annotation when a handle is found', () => { + const annotation = createFakeAnnotation(); + const handle = [1, 2, 3]; + const getHandleNearImagePoint = jest.fn(() => handle); + const tool = createFakeTool('ToolA', { getHandleNearImagePoint }); + + const result = filterToolsWithMoveableHandles( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([{ tool, annotation, handle }]); + expect(getHandleNearImagePoint).toHaveBeenCalledWith( + element, + annotation, + canvasCoords, + 6 + ); + }); + + it('excludes a tool when no handle is found nearby', () => { + const annotation = createFakeAnnotation(); + const tool = createFakeTool('ToolA', { + getHandleNearImagePoint: jest.fn(() => undefined), + }); + + const result = filterToolsWithMoveableHandles( + element, + [{ tool, annotations: [annotation] }], + canvasCoords + ); + + expect(result).toEqual([]); + }); + + it('skips locked or invisible annotations', () => { + const lockedAnnotation = createFakeAnnotation({ isLocked: true }); + const hiddenAnnotation = createFakeAnnotation({ isVisible: false }); + const getHandleNearImagePoint = jest.fn(() => [0, 0, 0]); + const tool = createFakeTool('ToolA', { getHandleNearImagePoint }); + + const result = filterToolsWithMoveableHandles( + element, + [{ tool, annotations: [lockedAnnotation, hiddenAnnotation] }], + canvasCoords + ); + + expect(result).toEqual([]); + expect(getHandleNearImagePoint).not.toHaveBeenCalled(); + }); + + it('uses touch proximity (36) for touch interaction', () => { + const annotation = createFakeAnnotation(); + const getHandleNearImagePoint = jest.fn(() => [1, 1, 1]); + const tool = createFakeTool('ToolA', { getHandleNearImagePoint }); + + filterToolsWithMoveableHandles( + element, + [{ tool, annotations: [annotation] }], + canvasCoords, + 'touch' + ); + + expect(getHandleNearImagePoint).toHaveBeenCalledWith( + element, + annotation, + canvasCoords, + 36 + ); + }); +}); + +describe('store/cancelActiveManipulations', () => { + const element = document.createElement('div'); + + beforeEach(() => { + jest.clearAllMocks(); + getEnabledElement.mockReturnValue({ + renderingEngineId: 'engine1', + viewportId: 'viewport1', + }); + }); + + it('returns undefined when there is no toolGroup for the element', () => { + getToolGroupForViewport.mockReturnValue(undefined); + + const result = cancelActiveManipulations(element); + + expect(result).toBeUndefined(); + }); + + it('calls cancel() on active/passive tools and returns the first annotationUID cancelled', () => { + const cancelToolA = jest.fn(() => undefined); + const cancelToolB = jest.fn(() => 'annotation-uid-b'); + const toolInstanceA = createFakeTool('ToolA', { cancel: cancelToolA }); + const toolInstanceB = createFakeTool('ToolB', { cancel: cancelToolB }); + + getToolGroupForViewport.mockReturnValue({ + toolOptions: { + ToolA: { mode: ToolModes.Active }, + ToolB: { mode: ToolModes.Passive }, + }, + getToolInstance: (toolName) => + toolName === 'ToolA' ? toolInstanceA : toolInstanceB, + }); + + // Both tools need annotations on the element to be considered by + // filterToolsWithAnnotationsForElement. + getAnnotations.mockReturnValue([createFakeAnnotation()]); + + const result = cancelActiveManipulations(element); + + expect(cancelToolA).toHaveBeenCalledWith(element); + // ToolA's cancel() returned undefined (nothing being manipulated), so + // iteration continues and ToolB's cancel() result is returned. + expect(cancelToolB).toHaveBeenCalledWith(element); + expect(result).toBe('annotation-uid-b'); + }); + + it('short-circuits and does not call cancel() on subsequent tools once one succeeds', () => { + const cancelToolA = jest.fn(() => 'annotation-uid-a'); + const cancelToolB = jest.fn(() => 'annotation-uid-b'); + const toolInstanceA = createFakeTool('ToolA', { cancel: cancelToolA }); + const toolInstanceB = createFakeTool('ToolB', { cancel: cancelToolB }); + + getToolGroupForViewport.mockReturnValue({ + toolOptions: { + ToolA: { mode: ToolModes.Active }, + ToolB: { mode: ToolModes.Active }, + }, + getToolInstance: (toolName) => + toolName === 'ToolA' ? toolInstanceA : toolInstanceB, + }); + getAnnotations.mockReturnValue([createFakeAnnotation()]); + + const result = cancelActiveManipulations(element); + + expect(result).toBe('annotation-uid-a'); + expect(cancelToolB).not.toHaveBeenCalled(); + }); + + it('excludes tools with disabled/enabled modes that are not active/passive', () => { + const cancelDisabledTool = jest.fn(() => 'should-not-be-returned'); + const toolInstance = createFakeTool('ToolDisabled', { + cancel: cancelDisabledTool, + }); + + getToolGroupForViewport.mockReturnValue({ + toolOptions: { + ToolDisabled: { mode: ToolModes.Disabled }, + }, + getToolInstance: () => toolInstance, + }); + getAnnotations.mockReturnValue([createFakeAnnotation()]); + + const result = cancelActiveManipulations(element); + + expect(result).toBeUndefined(); + expect(cancelDisabledTool).not.toHaveBeenCalled(); + }); + + it('ignores toolOptions entries with no options (falsy)', () => { + getToolGroupForViewport.mockReturnValue({ + toolOptions: { + ToolWithoutOptions: undefined, + }, + getToolInstance: jest.fn(), + }); + + const result = cancelActiveManipulations(element); + + expect(result).toBeUndefined(); + }); +}); + +describe('store/addTool & state registry', () => { + beforeEach(() => { + resetCornerstoneToolsState(); + }); + + it('registers a tool class under its static toolName', () => { + class MyFakeTool { + static toolName = 'MyFakeTool'; + } + + addTool(MyFakeTool); + + expect(state.tools['MyFakeTool']).toEqual({ toolClass: MyFakeTool }); + expect(hasTool(MyFakeTool)).toBe(true); + expect(hasToolByName('MyFakeTool')).toBe(true); + }); + + it('throws when the tool class has no static toolName', () => { + class NoNameTool {} + + expect(() => addTool(NoNameTool)).toThrow( + /No Tool Found for the ToolClass/ + ); + }); + + it('does not overwrite an already-registered tool on duplicate addTool calls', () => { + class MyFakeTool { + static toolName = 'MyFakeTool'; + } + class MyFakeToolV2 { + static toolName = 'MyFakeTool'; + } + + addTool(MyFakeTool); + addTool(MyFakeToolV2); + + // First registration wins; addTool is a no-op if the name already exists. + expect(state.tools['MyFakeTool'].toolClass).toBe(MyFakeTool); + }); + + it('hasTool/hasToolByName return false for tools that were never registered', () => { + class NeverAdded { + static toolName = 'NeverAdded'; + } + + expect(hasTool(NeverAdded)).toBe(false); + expect(hasToolByName('NeverAdded')).toBe(false); + }); + + it('removeTool deletes a previously registered tool', () => { + class MyFakeTool { + static toolName = 'MyFakeTool'; + } + + addTool(MyFakeTool); + expect(hasTool(MyFakeTool)).toBe(true); + + removeTool(MyFakeTool); + + expect(hasTool(MyFakeTool)).toBe(false); + expect(state.tools['MyFakeTool']).toBeUndefined(); + }); + + it('removeTool throws when the tool class has no static toolName', () => { + class NoNameTool {} + + expect(() => removeTool(NoNameTool)).toThrow(/No tool found for/); + }); + + // SUSPECTED PRODUCT BUG (src/store/addTool.ts removeTool): + // `if (!state.tools[toolName] !== undefined)` always evaluates to true + // (a boolean is never === undefined), so the `else throw` branch documented + // as "cannot be removed because it has not been added" is unreachable dead + // code. Calling removeTool on a tool that was never registered silently + // no-ops instead of throwing. This test documents the actual behavior. + it('removeTool on a never-registered tool does not throw (dead-code else branch)', () => { + class NeverAdded { + static toolName = 'NeverAddedForRemoval'; + } + + expect(() => removeTool(NeverAdded)).not.toThrow(); + }); +}); diff --git a/packages/tools/test/synchronizerCore.jest.js b/packages/tools/test/synchronizerCore.jest.js new file mode 100644 index 0000000000..8983599be2 --- /dev/null +++ b/packages/tools/test/synchronizerCore.jest.js @@ -0,0 +1,1047 @@ +jest.mock('@cornerstonejs/core', () => { + class BaseVolumeViewport {} + class StackViewport {} + + return { + Enums: { + Events: { + ELEMENT_DISABLED: 'CORNERSTONE_ELEMENT_DISABLED', + }, + }, + eventTarget: new EventTarget(), + getRenderingEngine: jest.fn(), + getEnabledElement: jest.fn(), + getEnabledElementByViewportId: jest.fn(), + utilities: { + isGenericViewport: jest.fn(() => false), + }, + viewportProjection: { + getPresentation: jest.fn(() => undefined), + withPresentation: jest.fn(() => undefined), + }, + BaseVolumeViewport, + StackViewport, + }; +}); + +import { + getRenderingEngine, + getEnabledElement, + getEnabledElementByViewportId, + utilities, + eventTarget, + viewportProjection, + BaseVolumeViewport, + StackViewport, +} from '@cornerstonejs/core'; + +import Synchronizer from '../src/store/SynchronizerManager/Synchronizer'; +import { + createSynchronizer, + getSynchronizer, + getAllSynchronizers, + getSynchronizersForViewport, + destroySynchronizer, + destroy as destroyAllSynchronizers, +} from '../src/store/SynchronizerManager'; +import { state } from '../src/store/state'; + +import cameraSyncCallback from '../src/synchronizers/callbacks/cameraSyncCallback'; +import voiSyncCallback from '../src/synchronizers/callbacks/voiSyncCallback'; +import slabThicknessSyncCallback from '../src/synchronizers/callbacks/slabThicknessSyncCallback'; +import presentationViewSyncCallback from '../src/synchronizers/callbacks/presentationViewSyncCallback'; +import areViewportsCoplanar from '../src/synchronizers/callbacks/areViewportsCoplanar'; + +/** + * Builds a tiny fake "cornerstone world": a map of renderingEngineId -> + * fake rendering engine (with a real DOM element per viewport), wired up to + * the mocked getRenderingEngine/getEnabledElement so that Synchronizer's + * source-viewport lookups and real DOM event dispatch both work end to end. + */ +function createWorld() { + const engines = new Map(); + const elementToViewportInfo = new Map(); + + function addViewport(renderingEngineId, viewportId) { + const element = document.createElement('div'); + + let engine = engines.get(renderingEngineId); + if (!engine) { + const viewportMap = new Map(); + engine = { + __viewportMap: viewportMap, + getViewport: jest.fn((id) => viewportMap.get(id)), + }; + engines.set(renderingEngineId, engine); + } + engine.__viewportMap.set(viewportId, { element }); + elementToViewportInfo.set(element, { renderingEngineId, viewportId }); + + return { renderingEngineId, viewportId, element }; + } + + getRenderingEngine.mockImplementation((id) => engines.get(id)); + getEnabledElement.mockImplementation((el) => elementToViewportInfo.get(el)); + + return { addViewport }; +} + +describe('Synchronizer', () => { + let world; + + beforeEach(() => { + jest.clearAllMocks(); + world = createWorld(); + }); + + it('dispatches the event to all targets except the source itself', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-a', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + // The source is also registered as a target (as `add()` would do); it + // must not receive its own event. + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-source' }); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target-1' }); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target-2' }); + + const event = new CustomEvent('my-event'); + source.element.dispatchEvent(event); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenCalledWith( + sync, + { renderingEngineId: 're1', viewportId: 'vp-source' }, + { renderingEngineId: 're1', viewportId: 'vp-target-1' }, + event, + {} + ); + expect(callback).toHaveBeenCalledWith( + sync, + { renderingEngineId: 're1', viewportId: 'vp-source' }, + { renderingEngineId: 're1', viewportId: 'vp-target-2' }, + event, + {} + ); + }); + + it('forwards the synchronizer options as the last callback argument', () => { + const callback = jest.fn(); + const options = { syncColormap: true }; + const sync = new Synchronizer( + 'sync-options', + 'my-event', + callback, + options + ); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + source.element.dispatchEvent(new CustomEvent('my-event')); + + expect(callback).toHaveBeenCalledWith( + sync, + expect.objectContaining({ viewportId: 'vp-source' }), + { renderingEngineId: 're1', viewportId: 'vp-target' }, + expect.anything(), + options + ); + }); + + it('does not fire while disabled and resumes firing once re-enabled', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-b', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + sync.setEnabled(false); + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).not.toHaveBeenCalled(); + + sync.setEnabled(true); + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('isDisabled is true when there are no source viewports, even if enabled', () => { + const sync = new Synchronizer('sync-c', 'my-event', jest.fn()); + expect(sync.isDisabled()).toBe(true); + + const source = world.addViewport('re1', 'vp-source'); + sync.addSource(source); + expect(sync.isDisabled()).toBe(false); + }); + + it('does not add a duplicate source listener and does not double-fire', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-d', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + const addListenerSpy = jest.spyOn(source.element, 'addEventListener'); + + sync.addSource(source); + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + // The synchronizer also (re)registers an ELEMENT_DISABLED listener on + // every addSource/addTarget call, so filter to the main event name. + const mainEventAddCalls = addListenerSpy.mock.calls.filter( + (call) => call[0] === 'my-event' + ); + expect(mainEventAddCalls).toHaveLength(1); + expect(sync.getSourceViewports()).toHaveLength(1); + + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not add a duplicate target', () => { + const sync = new Synchronizer('sync-d2', 'my-event', jest.fn()); + const target = { renderingEngineId: 're1', viewportId: 'vp-target' }; + + sync.addTarget(target); + sync.addTarget(target); + + expect(sync.getTargetViewports()).toHaveLength(1); + }); + + it('tracks source/target membership via hasSourceViewport/hasTargetViewport', () => { + const sync = new Synchronizer('sync-e', 'my-event', jest.fn()); + world.addViewport('re1', 'vp-source'); + + // Pass a plain {renderingEngineId, viewportId} (rather than the fixture's + // element-carrying object) so getSourceViewports() below can be compared + // with a plain toEqual. + sync.addSource({ renderingEngineId: 're1', viewportId: 'vp-source' }); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + expect(sync.hasSourceViewport('re1', 'vp-source')).toBe(true); + expect(sync.hasSourceViewport('re1', 'vp-target')).toBe(false); + expect(sync.hasTargetViewport('re1', 'vp-target')).toBe(true); + expect(sync.hasTargetViewport('re1', 'vp-source')).toBe(false); + + expect(sync.getSourceViewports()).toEqual([ + { renderingEngineId: 're1', viewportId: 'vp-source' }, + ]); + expect(sync.getTargetViewports()).toEqual([ + { renderingEngineId: 're1', viewportId: 'vp-target' }, + ]); + }); + + it('stops notifying a target after it is removed, and stops entirely after removeSource', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-f', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + const target1 = { renderingEngineId: 're1', viewportId: 'vp-target-1' }; + const target2 = { renderingEngineId: 're1', viewportId: 'vp-target-2' }; + + sync.addSource(source); + sync.addTarget(target1); + sync.addTarget(target2); + + sync.removeTarget(target1); + source.element.dispatchEvent(new CustomEvent('my-event')); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + sync, + expect.objectContaining({ viewportId: 'vp-source' }), + target2, + expect.anything(), + {} + ); + + callback.mockClear(); + sync.removeSource(source); + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).not.toHaveBeenCalled(); + }); + + it('remove() removes a viewport from both the source and target lists', () => { + const sync = new Synchronizer('sync-remove', 'my-event', jest.fn()); + const viewport = world.addViewport('re1', 'vp1'); + + sync.add(viewport); + expect(sync.hasSourceViewport('re1', 'vp1')).toBe(true); + expect(sync.hasTargetViewport('re1', 'vp1')).toBe(true); + + sync.remove(viewport); + expect(sync.hasSourceViewport('re1', 'vp1')).toBe(false); + expect(sync.hasTargetViewport('re1', 'vp1')).toBe(false); + }); + + it('destroy tears down all listeners and clears source/target lists', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-g', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + sync.destroy(); + + expect(sync.getSourceViewports()).toHaveLength(0); + expect(sync.getTargetViewports()).toHaveLength(0); + + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).not.toHaveBeenCalled(); + }); + + it('supports the eventTarget event source option', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-h', 'my-event', callback, { + eventSource: 'eventTarget', + }); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + getEnabledElementByViewportId.mockImplementation((viewportId) => + viewportId === 'vp-source' + ? { renderingEngineId: 're1', viewportId: 'vp-source' } + : undefined + ); + + eventTarget.dispatchEvent( + new CustomEvent('my-event', { detail: { viewportId: 'vp-source' } }) + ); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('no longer receives eventTarget-sourced events after being removed', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-h2', 'my-event', callback, { + eventSource: 'eventTarget', + }); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + getEnabledElementByViewportId.mockImplementation((viewportId) => + viewportId === 'vp-source' + ? { renderingEngineId: 're1', viewportId: 'vp-source' } + : undefined + ); + + sync.removeSource(source); + eventTarget.dispatchEvent( + new CustomEvent('my-event', { detail: { viewportId: 'vp-source' } }) + ); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('listens for auxiliary events in addition to the main event', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-i', 'main-event', callback, { + auxiliaryEvents: [{ name: 'aux-event' }], + }); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + sync.addTarget({ renderingEngineId: 're1', viewportId: 'vp-target' }); + + source.element.dispatchEvent(new CustomEvent('aux-event')); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('stores and retrieves per-viewport options', () => { + const sync = new Synchronizer('sync-j', 'my-event', jest.fn()); + + expect(sync.getOptions('vp1')).toBeUndefined(); + sync.setOptions('vp1', { foo: 'bar' }); + expect(sync.getOptions('vp1')).toEqual({ foo: 'bar' }); + }); + + it('does not fire while there are no target viewports at all', () => { + const callback = jest.fn(); + const sync = new Synchronizer('sync-k', 'my-event', callback); + const source = world.addViewport('re1', 'vp-source'); + + sync.addSource(source); + // no targets added + + source.element.dispatchEvent(new CustomEvent('my-event')); + expect(callback).not.toHaveBeenCalled(); + }); +}); + +describe('SynchronizerManager', () => { + beforeEach(() => { + state.synchronizers.length = 0; + }); + + it('creates a synchronizer and retrieves it by id', () => { + const handler = jest.fn(); + const sync = createSynchronizer('sync-x', 'evt', handler); + + expect(getSynchronizer('sync-x')).toBe(sync); + expect(getAllSynchronizers()).toContain(sync); + }); + + it('throws when creating a synchronizer with a duplicate id', () => { + createSynchronizer('dup', 'evt', jest.fn()); + + expect(() => createSynchronizer('dup', 'evt', jest.fn())).toThrow( + "Synchronizer with id 'dup' already exists." + ); + }); + + it('getSynchronizer returns undefined for an unknown id', () => { + expect(getSynchronizer('does-not-exist')).toBeUndefined(); + }); + + it('destroySynchronizer tears the instance down and removes it from state', () => { + const sync = createSynchronizer('sync-y', 'evt', jest.fn()); + const destroySpy = jest.spyOn(sync, 'destroy'); + + destroySynchronizer('sync-y'); + + expect(destroySpy).toHaveBeenCalledTimes(1); + expect(getSynchronizer('sync-y')).toBeUndefined(); + }); + + it('destroySynchronizer is a no-op for an unknown id', () => { + expect(() => destroySynchronizer('does-not-exist')).not.toThrow(); + }); + + it('destroy() clears every synchronizer', () => { + createSynchronizer('sync-z1', 'evt', jest.fn()); + createSynchronizer('sync-z2', 'evt', jest.fn()); + + expect(getAllSynchronizers()).toHaveLength(2); + + destroyAllSynchronizers(); + + expect(getAllSynchronizers()).toHaveLength(0); + }); + + describe('getSynchronizersForViewport', () => { + let world; + + beforeEach(() => { + jest.clearAllMocks(); + world = createWorld(); + }); + + it('returns only enabled synchronizers referencing the given viewport', () => { + const syncA = createSynchronizer('sync-filter-a', 'evt', jest.fn()); + syncA.addSource(world.addViewport('re1', 'vp1')); + + // A synchronizer is only "enabled" (per isDisabled()) once it has at + // least one source viewport, so give syncB its own source elsewhere + // and target vp1 to be picked up via hasTargetViewport. + const syncB = createSynchronizer('sync-filter-b', 'evt', jest.fn()); + syncB.addSource(world.addViewport('re1', 'vp-other')); + syncB.addTarget({ renderingEngineId: 're1', viewportId: 'vp1' }); + + const syncCDisabled = createSynchronizer( + 'sync-filter-c', + 'evt', + jest.fn() + ); + syncCDisabled.addSource(world.addViewport('re1', 'vp1')); + syncCDisabled.setEnabled(false); + + const syncUnrelated = createSynchronizer( + 'sync-filter-d', + 'evt', + jest.fn() + ); + syncUnrelated.addSource(world.addViewport('re1', 'vp2')); + + const result = getSynchronizersForViewport('vp1', 're1'); + + expect(result).toEqual([syncA, syncB]); + }); + + it('throws when neither renderingEngineId nor viewportId is given', () => { + expect(() => getSynchronizersForViewport(undefined, undefined)).toThrow(); + }); + }); +}); + +describe('cameraSyncCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + utilities.isGenericViewport.mockReturnValue(false); + }); + + it('copies the camera onto a legacy (non-Generic) target and renders it', () => { + const camera = { position: [0, 0, 1] }; + const targetViewport = { setCamera: jest.fn(), render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => targetViewport), + }); + + cameraSyncCallback( + {}, + { renderingEngineId: 're1', viewportId: 'source' }, + { renderingEngineId: 're1', viewportId: 'target' }, + { detail: { camera } } + ); + + expect(targetViewport.setCamera).toHaveBeenCalledWith(camera); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('throws when there is no rendering engine for the target', () => { + getRenderingEngine.mockReturnValue(undefined); + + expect(() => + cameraSyncCallback( + {}, + { renderingEngineId: 're1', viewportId: 'source' }, + { renderingEngineId: 're1', viewportId: 'target' }, + { detail: { camera: {} } } + ) + ).toThrow(); + }); + + it('copies the view reference and zoom/pan presentation for Generic viewports', () => { + utilities.isGenericViewport.mockReturnValue(true); + + const sourceViewReference = { FrameOfReferenceUID: 'FOR1' }; + const zoomPanPresentation = { zoom: 2, pan: [1, 1] }; + const sourceViewport = { + getViewReference: jest.fn(() => sourceViewReference), + getViewPresentation: jest.fn(() => zoomPanPresentation), + }; + const targetViewport = { + setViewReference: jest.fn(), + setViewPresentation: jest.fn(), + render: jest.fn(), + }; + + getRenderingEngine.mockImplementation((id) => + id === 're-source' + ? { getViewport: jest.fn(() => sourceViewport) } + : { getViewport: jest.fn(() => targetViewport) } + ); + + cameraSyncCallback( + {}, + { renderingEngineId: 're-source', viewportId: 'source' }, + { renderingEngineId: 're-target', viewportId: 'target' }, + { detail: { camera: {} } } + ); + + expect(targetViewport.setViewReference).toHaveBeenCalledWith( + sourceViewReference + ); + expect(targetViewport.setViewPresentation).toHaveBeenCalledWith( + zoomPanPresentation + ); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('bails out for Generic viewports when the source viewport cannot be resolved', () => { + utilities.isGenericViewport.mockReturnValue(true); + + const targetViewport = { setViewReference: jest.fn(), render: jest.fn() }; + getRenderingEngine.mockImplementation((id) => + id === 're-target' + ? { getViewport: jest.fn(() => targetViewport) } + : undefined + ); + + cameraSyncCallback( + {}, + { renderingEngineId: 're-source', viewportId: 'source' }, + { renderingEngineId: 're-target', viewportId: 'target' }, + { detail: { camera: {} } } + ); + + expect(targetViewport.setViewReference).not.toHaveBeenCalled(); + expect(targetViewport.render).not.toHaveBeenCalled(); + }); +}); + +describe('voiSyncCallback', () => { + function makeVolumeViewport(overrides) { + return Object.assign( + Object.create(BaseVolumeViewport.prototype), + overrides + ); + } + function makeStackViewport(overrides) { + return Object.assign(Object.create(StackViewport.prototype), overrides); + } + + beforeEach(() => { + jest.clearAllMocks(); + utilities.isGenericViewport.mockReturnValue(false); + }); + + it('applies voiRange to a non-fusion volume viewport (no volumeId argument)', () => { + const tViewport = makeVolumeViewport({ + _actors: new Map([['v1', {}]]), + setProperties: jest.fn(), + render: jest.fn(), + }); + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { volumeId: 'v1', range: { lower: 0, upper: 100 } } } + ); + + expect(tViewport.setProperties).toHaveBeenCalledWith({ + voiRange: { lower: 0, upper: 100 }, + }); + expect(tViewport.render).toHaveBeenCalledTimes(1); + }); + + it('scopes voiRange to the volumeId for fusion volume viewports (multiple actors)', () => { + const tViewport = makeVolumeViewport({ + _actors: new Map([ + ['v1', {}], + ['v2', {}], + ]), + setProperties: jest.fn(), + render: jest.fn(), + }); + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { volumeId: 'v2', range: { lower: 1, upper: 2 } } } + ); + + expect(tViewport.setProperties).toHaveBeenCalledWith( + { voiRange: { lower: 1, upper: 2 } }, + 'v2' + ); + }); + + it('applies voiRange to a stack viewport', () => { + const tViewport = makeStackViewport({ + setProperties: jest.fn(), + render: jest.fn(), + }); + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { range: { lower: 5, upper: 6 } } } + ); + + expect(tViewport.setProperties).toHaveBeenCalledWith({ + voiRange: { lower: 5, upper: 6 }, + }); + }); + + it('propagates invert and colormap only when the matching sync options are enabled', () => { + const tViewport = makeStackViewport({ + setProperties: jest.fn(), + render: jest.fn(), + }); + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { + detail: { + range: { lower: 0, upper: 1 }, + invertStateChanged: true, + invert: true, + colormap: { name: 'hot' }, + }, + }, + { syncInvertState: true, syncColormap: true } + ); + + expect(tViewport.setProperties).toHaveBeenCalledWith({ + voiRange: { lower: 0, upper: 1 }, + invert: true, + colormap: { name: 'hot' }, + }); + }); + + it('does not propagate invert or colormap without the matching options', () => { + const tViewport = makeStackViewport({ + setProperties: jest.fn(), + render: jest.fn(), + }); + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { + detail: { + range: { lower: 0, upper: 1 }, + invertStateChanged: true, + invert: true, + colormap: { name: 'hot' }, + }, + } + ); + + expect(tViewport.setProperties).toHaveBeenCalledWith({ + voiRange: { lower: 0, upper: 1 }, + }); + }); + + it('routes to the matching display-set binding on Generic viewports', () => { + utilities.isGenericViewport.mockReturnValue(true); + const tViewport = { + findDataIdByVolumeId: jest.fn(() => 'data-1'), + setDisplaySetPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { volumeId: 'v1', range: { lower: 0, upper: 1 } } } + ); + + expect(tViewport.setDisplaySetPresentation).toHaveBeenCalledWith('data-1', { + voiRange: { lower: 0, upper: 1 }, + }); + expect(tViewport.render).toHaveBeenCalledTimes(1); + }); + + it('applies to the default binding on Generic viewports when there is no source volumeId', () => { + utilities.isGenericViewport.mockReturnValue(true); + const tViewport = { + setDisplaySetPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { range: { lower: 0, upper: 1 } } } + ); + + expect(tViewport.setDisplaySetPresentation).toHaveBeenCalledWith({ + voiRange: { lower: 0, upper: 1 }, + }); + }); + + it('skips Generic viewports without a matching volume binding, but still renders', () => { + utilities.isGenericViewport.mockReturnValue(true); + const tViewport = { + findDataIdByVolumeId: jest.fn(() => undefined), + setDisplaySetPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { + detail: { volumeId: 'unmatched-volume', range: { lower: 0, upper: 1 } }, + } + ); + + expect(tViewport.setDisplaySetPresentation).not.toHaveBeenCalled(); + expect(tViewport.render).toHaveBeenCalledTimes(1); + }); + + it('throws for unsupported viewport types', () => { + const tViewport = { render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn(() => tViewport), + }); + + expect(() => + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: { range: {} } } + ) + ).toThrow('Viewport type not supported.'); + }); + + it('throws when there is no rendering engine for the target', () => { + getRenderingEngine.mockReturnValue(undefined); + + expect(() => + voiSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + { detail: {} } + ) + ).toThrow(); + }); +}); + +describe('slabThicknessSyncCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('propagates slab thickness from source to target and renders', () => { + const sourceViewport = { getSlabThickness: jest.fn(() => 5) }; + const targetViewport = { setSlabThickness: jest.fn(), render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + slabThicknessSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ); + + expect(targetViewport.setSlabThickness).toHaveBeenCalledWith(5); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the source has no slab thickness', () => { + const sourceViewport = { getSlabThickness: jest.fn(() => undefined) }; + const targetViewport = { setSlabThickness: jest.fn(), render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + slabThicknessSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ); + + expect(targetViewport.setSlabThickness).not.toHaveBeenCalled(); + expect(targetViewport.render).not.toHaveBeenCalled(); + }); + + it('does nothing when the source viewport has no getSlabThickness (e.g. a stack viewport)', () => { + const sourceViewport = {}; + const targetViewport = { setSlabThickness: jest.fn(), render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + slabThicknessSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ); + + expect(targetViewport.setSlabThickness).not.toHaveBeenCalled(); + }); + + it('does not throw if the target lacks setSlabThickness (e.g. a stack viewport target)', () => { + const sourceViewport = { getSlabThickness: jest.fn(() => 3) }; + const targetViewport = { render: jest.fn() }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + expect(() => + slabThicknessSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ) + ).not.toThrow(); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('throws when there is no rendering engine for the target', () => { + getRenderingEngine.mockReturnValue(undefined); + + expect(() => + slabThicknessSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ) + ).toThrow(); + }); +}); + +describe('presentationViewSyncCallback', () => { + beforeEach(() => { + jest.clearAllMocks(); + viewportProjection.getPresentation.mockReturnValue(undefined); + viewportProjection.withPresentation.mockReturnValue(undefined); + }); + + it('applies the source presentation to the target and renders', () => { + const presentation = { zoom: 1.5 }; + const sourceViewport = { getViewPresentation: jest.fn(() => presentation) }; + const targetViewport = { + setViewPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + presentationViewSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' }, + {}, + { zoom: true } + ); + + expect(sourceViewport.getViewPresentation).toHaveBeenCalledWith({ + zoom: true, + }); + expect(targetViewport.setViewPresentation).toHaveBeenCalledWith( + presentation + ); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('does not render when there is no presentation to apply', () => { + const sourceViewport = { getViewPresentation: jest.fn(() => undefined) }; + const targetViewport = { + setViewPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + presentationViewSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ); + + expect(targetViewport.setViewPresentation).not.toHaveBeenCalled(); + expect(targetViewport.render).not.toHaveBeenCalled(); + }); + + it('uses setViewState when the projection registry resolves a Generic view state', () => { + const presentation = { rotation: 90 }; + const nextViewState = { rotation: 90, resolved: true }; + viewportProjection.withPresentation.mockReturnValue(nextViewState); + + const sourceViewport = { getViewPresentation: jest.fn(() => presentation) }; + const targetViewport = { + setViewState: jest.fn(), + setViewPresentation: jest.fn(), + render: jest.fn(), + }; + getRenderingEngine.mockReturnValue({ + getViewport: jest.fn((id) => + id === 'source' ? sourceViewport : targetViewport + ), + }); + + presentationViewSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ); + + expect(targetViewport.setViewState).toHaveBeenCalledWith(nextViewState); + expect(targetViewport.setViewPresentation).not.toHaveBeenCalled(); + expect(targetViewport.render).toHaveBeenCalledTimes(1); + }); + + it('throws when there is no rendering engine for the target', () => { + getRenderingEngine.mockReturnValue(undefined); + + expect(() => + presentationViewSyncCallback( + {}, + { renderingEngineId: 're', viewportId: 'source' }, + { renderingEngineId: 're', viewportId: 'target' } + ) + ).toThrow(); + }); +}); + +describe('areViewportsCoplanar', () => { + const makeViewport = (viewPlaneNormal) => ({ + getCamera: () => ({ viewPlaneNormal }), + }); + + it('returns true for viewports with the same normal', () => { + expect( + areViewportsCoplanar(makeViewport([0, 0, 1]), makeViewport([0, 0, 1])) + ).toBe(true); + }); + + it('treats antiparallel (opposite-sign) normals as coplanar', () => { + expect( + areViewportsCoplanar(makeViewport([0, 0, 1]), makeViewport([0, 0, -1])) + ).toBe(true); + }); + + it('returns false for perpendicular normals', () => { + expect( + areViewportsCoplanar(makeViewport([0, 0, 1]), makeViewport([1, 0, 0])) + ).toBe(false); + }); + + it('returns false for normals beyond the 0.9 dot-product tolerance', () => { + // dot([0,0,1], [0, 0.5, 0.866]) === 0.866, which is < 0.9 + expect( + areViewportsCoplanar( + makeViewport([0, 0, 1]), + makeViewport([0, 0.5, 0.866]) + ) + ).toBe(false); + }); + + it('returns true for normals within the 0.9 dot-product tolerance', () => { + // dot([0,0,1], [0, 0.3, 0.9539]) === 0.9539, which is > 0.9 + expect( + areViewportsCoplanar( + makeViewport([0, 0, 1]), + makeViewport([0, 0.3, 0.9539]) + ) + ).toBe(true); + }); +}); diff --git a/packages/tools/test/utilities/geometryPrimitives.jest.js b/packages/tools/test/utilities/geometryPrimitives.jest.js new file mode 100644 index 0000000000..2262485545 --- /dev/null +++ b/packages/tools/test/utilities/geometryPrimitives.jest.js @@ -0,0 +1,763 @@ +import { describe, it, expect } from '@jest/globals'; + +import pointInEllipse, { + precalculatePointInEllipse, +} from '../../src/utilities/math/ellipse/pointInEllipse'; +import getCanvasEllipseCorners from '../../src/utilities/math/ellipse/getCanvasEllipseCorners'; + +import getCanvasCircleCorners from '../../src/utilities/math/circle/getCanvasCircleCorners'; +import getCanvasCircleRadius from '../../src/utilities/math/circle/getCanvasCircleRadius'; + +import intersectLine from '../../src/utilities/math/line/intersectLine'; +import isPointOnLineSegment from '../../src/utilities/math/line/isPointOnLineSegment'; +import lineDistanceToPoint from '../../src/utilities/math/line/distanceToPoint'; +import lineDistanceToPointSquared from '../../src/utilities/math/line/distanceToPointSquared'; +import lineDistanceToPointSquaredInfo from '../../src/utilities/math/line/distanceToPointSquaredInfo'; + +import rectangleDistanceToPoint from '../../src/utilities/math/rectangle/distanceToPoint'; + +import intersectAABB from '../../src/utilities/math/aabb/intersectAABB'; +import aabbDistanceToPoint from '../../src/utilities/math/aabb/distanceToPoint'; +import aabbDistanceToPointSquared from '../../src/utilities/math/aabb/distanceToPointSquared'; + +import pointInSphere from '../../src/utilities/math/sphere/pointInSphere'; + +import angleBetweenLines from '../../src/utilities/math/angle/angleBetweenLines'; + +import pointDistanceToPoint from '../../src/utilities/math/point/distanceToPoint'; +import pointDistanceToPointSquared from '../../src/utilities/math/point/distanceToPointSquared'; +import mirror from '../../src/utilities/math/point/mirror'; + +import liangBarksyClip from '../../src/utilities/math/vec2/liangBarksyClip'; +import findClosestPoint from '../../src/utilities/math/vec2/findClosestPoint'; + +import { interpolateVec3 } from '../../src/utilities/math/vec3/interpolateVec3'; + +import midPoint from '../../src/utilities/math/midPoint'; + +describe('math/ellipse/pointInEllipse', () => { + // Axis-aligned ellipse centered at (0,0,0), xRadius=10, yRadius=5, zRadius=0 + // (a flat, on-plane ellipse, since most call sites are 2D-in-3D anyway). + const ellipse = { + center: [0, 0, 0], + xRadius: 10, + yRadius: 5, + zRadius: 0, + }; + + it('returns true for the center point', () => { + expect(pointInEllipse(ellipse, [0, 0, 0])).toBe(true); + }); + + it('returns true for a point exactly on the x-axis edge', () => { + // (10/10)^2 + (0/5)^2 + 0 = 1 <= 1 -> inside (boundary is inclusive) + expect(pointInEllipse(ellipse, [10, 0, 0])).toBe(true); + }); + + it('returns true for a point exactly on the y-axis edge', () => { + expect(pointInEllipse(ellipse, [0, 5, 0])).toBe(true); + }); + + it('returns false for a point just outside the x-axis edge', () => { + expect(pointInEllipse(ellipse, [10.001, 0, 0])).toBe(false); + }); + + it('returns false for a point just outside the y-axis edge', () => { + expect(pointInEllipse(ellipse, [0, 5.001, 0])).toBe(false); + }); + + it('returns true for a point strictly inside the ellipse', () => { + // (5/10)^2 + (2/5)^2 = 0.25 + 0.16 = 0.41 <= 1 + expect(pointInEllipse(ellipse, [5, 2, 0])).toBe(true); + }); + + it('returns false for a point clearly outside the ellipse', () => { + // (8/10)^2 + (4/5)^2 = 0.64 + 0.64 = 1.28 > 1 + expect(pointInEllipse(ellipse, [8, 4, 0])).toBe(false); + }); + + it('respects zRadius for off-plane points (3D ellipsoid)', () => { + const ellipsoid = { + center: [0, 0, 0], + xRadius: 10, + yRadius: 5, + zRadius: 2, + }; + // On the z edge, x=y=0 -> inside + expect(pointInEllipse(ellipsoid, [0, 0, 2])).toBe(true); + // Beyond z edge -> outside + expect(pointInEllipse(ellipsoid, [0, 0, 2.5])).toBe(false); + }); + + it('handles an off-center ellipse', () => { + const offCenter = { + center: [10, 10, 0], + xRadius: 4, + yRadius: 4, + zRadius: 0, + }; + expect(pointInEllipse(offCenter, [10, 10, 0])).toBe(true); + expect(pointInEllipse(offCenter, [14, 10, 0])).toBe(true); + expect(pointInEllipse(offCenter, [15, 10, 0])).toBe(false); + }); + + it('treats a zero-radius (degenerate) axis as an infinite constraint (invSq=0 contributes 0)', () => { + // With xRadius=0, invXRadiusSq is defined as 0 (not Infinity), so any dx + // contributes 0 to the "inside" sum regardless of magnitude - i.e. the + // ellipse becomes unbounded along a degenerate axis rather than + // collapsing to a line. This is a documented product behavior (see + // precalculatePointInEllipse: `xRadius !== 0 ? 1 / xRadius ** 2 : 0`). + const degenerate = { + center: [0, 0, 0], + xRadius: 0, + yRadius: 5, + zRadius: 0, + }; + expect(pointInEllipse(degenerate, [1000, 0, 0])).toBe(true); + // y-axis is still constrained normally + expect(pointInEllipse(degenerate, [0, 5.001, 0])).toBe(false); + }); + + it('supports the fast/cached inverts path across repeated calls with different points', () => { + const inverts = { fast: true }; + expect(pointInEllipse(ellipse, [0, 0, 0], inverts)).toBe(true); + // Once cached, inverts.precalculated exists and is reused as-is. + expect(typeof inverts.precalculated).toBe('function'); + expect(pointInEllipse(ellipse, [10, 0, 0], inverts)).toBe(true); + expect(pointInEllipse(ellipse, [10.001, 0, 0], inverts)).toBe(false); + }); + + it('precalculatePointInEllipse exposes a reusable precalculated predicate', () => { + const inverts = precalculatePointInEllipse(ellipse); + expect(inverts.invXRadiusSq).toBeCloseTo(1 / 100, 10); + expect(inverts.invYRadiusSq).toBeCloseTo(1 / 25, 10); + expect(inverts.invZRadiusSq).toBe(0); + expect(inverts.precalculated([5, 2, 0])).toBe(true); + expect(inverts.precalculated([8, 4, 0])).toBe(false); + }); + + it('caching does not recompute the ellipse geometry if invert values are pre-set', () => { + // If invXRadiusSq/invYRadiusSq/invZRadiusSq are already all defined, + // precalculatePointInEllipse will not touch them, but it will still + // rebuild `precalculated` from the (unrelated) ellipse passed in - + // demonstrating the caching only applies to the radius-squared math. + const inverts = { invXRadiusSq: 1, invYRadiusSq: 1, invZRadiusSq: 1 }; + precalculatePointInEllipse(ellipse, inverts); + // Because invXRadiusSq/invYRadiusSq were forced to 1 (not the ellipse's + // real 1/100, 1/25), the predicate uses those forced values. + // dx=5,dy=2 -> 25*1 + 4*1 = 29 > 1 -> false + expect(inverts.precalculated([5, 2, 0])).toBe(false); + }); +}); + +describe('math/ellipse/getCanvasEllipseCorners', () => { + it('extracts top-left/bottom-right from [bottom, top, left, right] canvas points', () => { + // bottom=(5,10) top=(5,0) left=(0,5) right=(10,5) + const bottom = [5, 10]; + const top = [5, 0]; + const left = [0, 5]; + const right = [10, 5]; + const [topLeft, bottomRight] = getCanvasEllipseCorners([ + bottom, + top, + left, + right, + ]); + // topLeft = [left.x, top.y] = [0, 0] + expect(topLeft).toEqual([0, 0]); + // bottomRight = [right.x, bottom.y] = [10, 10] + expect(bottomRight).toEqual([10, 10]); + }); + + it('does not normalize/re-order when the "corners" are inverted (pure field pick)', () => { + // If the caller passes points such that left.x > right.x, the function + // does not swap them - it purely extracts fields. This documents that + // callers are responsible for ordering, matching the source comment + // that this returns "top left and bottom right" assuming correct input. + const bottom = [5, 0]; // note: bottom.y (0) < top.y (10) here - inverted + const top = [5, 10]; + const left = [8, 5]; // left.x (8) > right.x (2) - inverted + const right = [2, 5]; + const [topLeft, bottomRight] = getCanvasEllipseCorners([ + bottom, + top, + left, + right, + ]); + expect(topLeft).toEqual([8, 10]); + expect(bottomRight).toEqual([2, 0]); + }); +}); + +describe('math/circle/getCanvasCircleRadius', () => { + it('computes the radius as the distance between center and end points', () => { + // 3-4-5 triangle + const radius = getCanvasCircleRadius([ + [0, 0], + [3, 4], + ]); + expect(radius).toBeCloseTo(5, 10); + }); + + it('returns 0 when center and end coincide', () => { + const radius = getCanvasCircleRadius([ + [2, 2], + [2, 2], + ]); + expect(radius).toBe(0); + }); +}); + +describe('math/circle/getCanvasCircleCorners', () => { + it('computes a bounding square from center/end (radius = distance)', () => { + const [topLeft, bottomRight] = getCanvasCircleCorners([ + [10, 10], + [13, 14], // radius = 5 (3-4-5 triangle) + ]); + expect(topLeft).toEqual([5, 5]); + expect(bottomRight).toEqual([15, 15]); + }); +}); + +describe('math/line/intersectLine', () => { + it('finds the exact intersection point of two crossing segments', () => { + // Segment A: (0,0)-(4,4), Segment B: (0,4)-(4,0) -> cross at (2,2) + const result = intersectLine([0, 0], [4, 4], [0, 4], [4, 0]); + expect(result[0]).toBeCloseTo(2, 10); + expect(result[1]).toBeCloseTo(2, 10); + }); + + it('returns undefined for non-intersecting (parallel) segments', () => { + // Two horizontal, vertically-offset segments never cross + const result = intersectLine([0, 0], [4, 0], [0, 1], [4, 1]); + expect(result).toBeUndefined(); + }); + + it('returns undefined for parallel segments using the infinite=true path', () => { + const result = intersectLine([0, 0], [4, 0], [0, 1], [4, 1], true); + expect(result).toBeUndefined(); + }); + + it('computes intersection of infinite lines beyond segment bounds', () => { + // Segment path treats these as bounded and would return undefined since + // they don't overlap in range, but infinite=true extends them. + // Line A: (0,0)-(1,1) i.e. y=x. Line B: (2,0)-(3,-1) i.e. y = -(x-2) = -x+2 + // Intersection of y=x and y=-x+2 -> x=1,y=1 + const result = intersectLine([0, 0], [1, 1], [2, 0], [3, -1], true); + expect(result[0]).toBeCloseTo(1, 10); + expect(result[1]).toBeCloseTo(1, 10); + }); + + it('returns undefined for segments that do not overlap even though the lines would cross', () => { + // Line A: (0,0)-(1,1). Line B extended crosses at (1,1) but segment B is (2,0)-(3,-1), + // which does not include (1,1) -> non-infinite mode must reject it. + const result = intersectLine([0, 0], [1, 1], [2, 0], [3, -1], false); + expect(result).toBeUndefined(); + }); + + it('handles collinear, overlapping segments by treating endpoints as touching', () => { + // Both segments lie on y=0. Segment A: (0,0)-(2,0). Segment B: (1,0)-(3,0). + // r3/r4 (and r1/r2) will be 0 since all points are collinear, so signs + // are not "opposite same sign" and the code proceeds to divide, but the + // denominator (a1*b2 - a2*b1) is 0 for collinear lines -> result is NaN. + // This documents current behavior: collinear overlap is NOT specially handled. + const result = intersectLine([0, 0], [2, 0], [1, 0], [3, 0]); + expect(result[0]).toBeNaN(); + expect(result[1]).toBeNaN(); + }); + + it('detects intersection exactly at touching endpoints', () => { + // Segment A ends where Segment B starts: (2,2) + const result = intersectLine([0, 0], [2, 2], [2, 2], [4, 0]); + expect(result[0]).toBeCloseTo(2, 10); + expect(result[1]).toBeCloseTo(2, 10); + }); +}); + +describe('math/line/isPointOnLineSegment', () => { + it('returns true for a point exactly on the segment (midpoint)', () => { + expect(isPointOnLineSegment([0, 0], [10, 0], [5, 0])).toBe(true); + }); + + it('returns false for a point off the line entirely', () => { + expect(isPointOnLineSegment([0, 0], [10, 0], [5, 5])).toBe(false); + }); + + it('returns false for a point on the infinite line but beyond the segment endpoints', () => { + // Collinear with (0,0)-(10,0), but x=15 is beyond the endpoint at x=10 + expect(isPointOnLineSegment([0, 0], [10, 0], [15, 0])).toBe(false); + }); + + it('applies an orientation/AABB tolerance of 1e-2 near the segment', () => { + // The orientation (cross-product) formula scales with segment length: + // for this horizontal 10-unit-long segment, orientation = -10 * point.y, + // so ORIENTATION_TOLERANCE (1e-2) on |orientation| corresponds to + // |point.y| <= 0.001, not 0.01 - the tolerance is not a plain + // perpendicular-distance tolerance once the segment length != 1. + // 0.0005 off the line -> orientation = 0.005 <= 0.01 -> within tolerance + expect(isPointOnLineSegment([0, 0], [10, 0], [5, 0.0005])).toBe(true); + // 0.005 off the line -> orientation = 0.05 > 0.01 -> exceeds tolerance + expect(isPointOnLineSegment([0, 0], [10, 0], [5, 0.005])).toBe(false); + }); + + it('applies the same tolerance just beyond the endpoint bounds', () => { + // 10.005 is within 1e-2 of the AABB max x (10) + expect(isPointOnLineSegment([0, 0], [10, 0], [10.005, 0])).toBe(true); + // 10.05 is outside the AABB tolerance + expect(isPointOnLineSegment([0, 0], [10, 0], [10.05, 0])).toBe(false); + }); +}); + +describe('math/line/distanceToPoint family', () => { + it('distanceToPoint: perpendicular distance for a foot that lands inside the segment', () => { + // Segment (0,0)-(10,0), point (5,5) -> perpendicular foot is (5,0), distance 5 + expect(lineDistanceToPoint([0, 0], [10, 0], [5, 5])).toBeCloseTo(5, 10); + }); + + it('distanceToPoint: clamps to the nearest endpoint when the foot falls outside the segment', () => { + // Point (15,3) projects beyond the end (10,0); closest point is the + // endpoint (10,0) -> distance = sqrt(5^2+3^2) = sqrt(34) + expect(lineDistanceToPoint([0, 0], [10, 0], [15, 3])).toBeCloseTo( + Math.sqrt(34), + 10 + ); + }); + + it('distanceToPoint: throws for points/lines that are not 2D', () => { + expect(() => lineDistanceToPoint([0, 0, 0], [10, 0], [5, 5])).toThrow(); + }); + + it('distanceToPointSquared matches the square of distanceToPoint', () => { + const start = [1, 1]; + const end = [4, 5]; + const point = [0, 10]; + const dist = lineDistanceToPoint(start, end, point); + const distSq = lineDistanceToPointSquared(start, end, point); + expect(distSq).toBeCloseTo(dist * dist, 8); + }); + + it('distanceToPointSquaredInfo returns both the closest point and the squared distance (foot inside segment)', () => { + const info = lineDistanceToPointSquaredInfo([0, 0], [10, 0], [5, 5]); + expect(info.point[0]).toBeCloseTo(5, 10); + expect(info.point[1]).toBeCloseTo(0, 10); + expect(info.distanceSquared).toBeCloseTo(25, 10); + }); + + it('distanceToPointSquaredInfo clamps the closest point to the start endpoint', () => { + // Point (-5,3) projects before the start (0,0) -> dotProduct < 0 + const info = lineDistanceToPointSquaredInfo([0, 0], [10, 0], [-5, 3]); + expect(info.point).toEqual([0, 0]); + expect(info.distanceSquared).toBeCloseTo(25 + 9, 10); + }); + + it('distanceToPointSquaredInfo clamps the closest point to the end endpoint', () => { + // Point (15,4) projects beyond the end (10,0) -> dotProduct > 1 + const info = lineDistanceToPointSquaredInfo([0, 0], [10, 0], [15, 4]); + expect(info.point).toEqual([10, 0]); + expect(info.distanceSquared).toBeCloseTo(25 + 16, 10); + }); + + it('distanceToPointSquaredInfo handles a degenerate (zero-length) segment', () => { + // lineStart === lineEnd -> closest point is that single point + const info = lineDistanceToPointSquaredInfo([3, 3], [3, 3], [3, 7]); + expect(info.point).toEqual([3, 3]); + expect(info.distanceSquared).toBeCloseTo(16, 10); + }); +}); + +describe('math/rectangle/distanceToPoint', () => { + // Rectangle: left=0, top=0, width=10, height=10 -> corners (0,0)-(10,10) + const rect = [0, 0, 10, 10]; + + it('is 0 for a point inside the rectangle (closest edge distance still computed from segments, so on-boundary is 0, but strictly-inside is > 0 by design)', () => { + // The implementation measures distance to the 4 edges (as line segments), + // not signed "inside" distance, so a strictly interior point still + // returns a positive distance to the closest edge. + // Center point (5,5): closest edge distance is 5 (to any side) + expect(rectangleDistanceToPoint(rect, [5, 5])).toBeCloseTo(5, 10); + }); + + it('is 0 for a point exactly on an edge', () => { + expect(rectangleDistanceToPoint(rect, [5, 0])).toBeCloseTo(0, 10); + }); + + it('computes the distance to the nearest corner when outside diagonally', () => { + // Point (13,14) is outside; nearest corner is (10,10) -> sqrt(3^2+4^2)=5 + expect(rectangleDistanceToPoint(rect, [13, 14])).toBeCloseTo(5, 10); + }); + + it('computes the perpendicular distance to the nearest edge when outside straight off one side', () => { + // Point (5,-4) is straight below the top edge (y=0) -> distance 4 + expect(rectangleDistanceToPoint(rect, [5, -4])).toBeCloseTo(4, 10); + }); + + it('throws for malformed rect/point arguments', () => { + expect(() => rectangleDistanceToPoint([0, 0, 10], [5, 5])).toThrow(); + expect(() => rectangleDistanceToPoint(rect, [5, 5, 5])).toThrow(); + }); +}); + +describe('math/aabb/intersectAABB', () => { + const base = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + + it('detects overlapping AABBs', () => { + const other = { minX: 5, minY: 5, maxX: 15, maxY: 15 }; + expect(intersectAABB(base, other)).toBe(true); + }); + + it('detects AABBs that only touch at an edge as intersecting', () => { + // Touching exactly at x=10 boundary + const other = { minX: 10, minY: 0, maxX: 20, maxY: 10 }; + expect(intersectAABB(base, other)).toBe(true); + }); + + it('detects disjoint AABBs as non-intersecting', () => { + const other = { minX: 11, minY: 11, maxX: 20, maxY: 20 }; + expect(intersectAABB(base, other)).toBe(false); + }); +}); + +describe('math/aabb/distanceToPoint(Squared)', () => { + const aabb = { minX: 0, minY: 0, maxX: 10, maxY: 10 }; + + it('is 0 for a point inside the AABB', () => { + expect(aabbDistanceToPointSquared(aabb, [5, 5])).toBe(0); + expect(aabbDistanceToPoint(aabb, [5, 5])).toBe(0); + }); + + it('is 0 for a point exactly on the boundary', () => { + expect(aabbDistanceToPointSquared(aabb, [10, 5])).toBe(0); + }); + + it('computes the perpendicular squared distance for a point outside one side', () => { + // Point (5,-3): directly below minY -> distance 3, squared 9 + expect(aabbDistanceToPointSquared(aabb, [5, -3])).toBeCloseTo(9, 10); + expect(aabbDistanceToPoint(aabb, [5, -3])).toBeCloseTo(3, 10); + }); + + it('computes the diagonal squared distance for a point outside a corner', () => { + // Point (13,14): outside both maxX (by 3) and maxY (by 4) -> corner + // distance sqrt(3^2+4^2)=5, squared=25 + expect(aabbDistanceToPointSquared(aabb, [13, 14])).toBeCloseTo(25, 10); + expect(aabbDistanceToPoint(aabb, [13, 14])).toBeCloseTo(5, 10); + }); +}); + +describe('math/sphere/pointInSphere', () => { + const sphere = { center: [0, 0, 0], radius: 5 }; + + it('returns true for the center point', () => { + expect(pointInSphere(sphere, [0, 0, 0])).toBe(true); + }); + + it('returns true for a point exactly on the surface', () => { + expect(pointInSphere(sphere, [5, 0, 0])).toBe(true); + expect(pointInSphere(sphere, [3, 4, 0])).toBe(true); // 3-4-5 triangle + }); + + it('returns false for a point outside the sphere', () => { + expect(pointInSphere(sphere, [5.001, 0, 0])).toBe(false); + }); + + it('uses the precomputed radius2 when supplied, ignoring recompute of radius*radius', () => { + // Deliberately mismatched radius2 to prove it takes precedence. + const sphereWithRadius2 = { center: [0, 0, 0], radius: 5, radius2: 4 }; + // distance^2 from origin to (1.9,0,0) is 3.61 <= 4 -> inside per radius2 + expect(pointInSphere(sphereWithRadius2, [1.9, 0, 0])).toBe(true); + // distance^2 to (2.1,0,0) is 4.41 > 4 -> outside per radius2, even though + // it would be well within radius=5 + expect(pointInSphere(sphereWithRadius2, [2.1, 0, 0])).toBe(false); + }); +}); + +describe('math/angle/angleBetweenLines', () => { + it('returns 90 for perpendicular 2D lines', () => { + const line1 = [ + [0, 0], + [1, 0], + ]; + const line2 = [ + [0, 0], + [0, 1], + ]; + expect(angleBetweenLines(line1, line2)).toBeCloseTo(90, 10); + }); + + it('returns 0 for parallel 2D lines pointing the same direction', () => { + // Note: angle is measured between vector line1[1]->line1[0] and + // line2[0]->line2[1] (see source docstring) - i.e. line1 is reversed. + // line1 reversed: (1,0)->(0,0) direction = (-1,0) + // line2: (0,5)->(1,5) direction = (1,0) + // These point in opposite directions -> angle = 180, not 0. + const line1 = [ + [0, 0], + [1, 0], + ]; + const line2 = [ + [0, 5], + [1, 5], + ]; + expect(angleBetweenLines(line1, line2)).toBeCloseTo(180, 10); + }); + + it('returns 0 when line1 reversed direction matches line2 direction', () => { + // line1 reversed: (1,0)->(0,0) direction = (-1,0) + // line2: (1,5)->(0,5) direction = (-1,0) -- same direction as line1 reversed + const line1 = [ + [0, 0], + [1, 0], + ]; + const line2 = [ + [1, 5], + [0, 5], + ]; + expect(angleBetweenLines(line1, line2)).toBeCloseTo(0, 10); + }); + + it('returns 45 for a 45-degree angle between 2D lines', () => { + const line1 = [ + [0, 0], + [1, 0], + ]; + // line2 direction (1,1) normalized is 45 degrees from (1,0); + // line1 reversed direction is (-1,0), so the angle to (1,1) is 135. + // To get exactly 45 with the reversal semantics, construct line2 so + // its direction is at 45 degrees from (-1,0), i.e. direction (-1,-1) + // reversed... simplest: pick line1 so reversal doesn't matter by + // using a line1 whose reversed direction equals its forward direction + // is impossible, so instead directly verify via the underlying vectors. + const line2 = [ + [0, 0], + [-1, -1], + ]; + // line1 reversed direction: (1,0)->(0,0) = (-1,0) + // line2 direction: (0,0)->(-1,-1) = (-1,-1), normalized angle from (-1,0) is 45 deg + expect(angleBetweenLines(line1, line2)).toBeCloseTo(45, 10); + }); + + it('returns 90 for perpendicular 3D lines', () => { + const line1 = [ + [0, 0, 0], + [1, 0, 0], + ]; + const line2 = [ + [0, 0, 0], + [0, 1, 0], + ]; + expect(angleBetweenLines(line1, line2)).toBeCloseTo(90, 10); + }); + + it('returns 0 for opposite-direction 3D lines using the reversal semantics', () => { + // line1 reversed: (1,0,0)->(0,0,0) = (-1,0,0) + // line2: (1,0,0)->(0,0,0) direction = (-1,0,0) -- same as line1 reversed + const line1 = [ + [0, 0, 0], + [1, 0, 0], + ]; + const line2 = [ + [1, 0, 0], + [0, 0, 0], + ]; + expect(angleBetweenLines(line1, line2)).toBeCloseTo(0, 10); + }); +}); + +describe('math/point/distanceToPoint(Squared)', () => { + it('computes 2D distance (3-4-5 triangle)', () => { + expect(pointDistanceToPoint([0, 0], [3, 4])).toBeCloseTo(5, 10); + expect(pointDistanceToPointSquared([0, 0], [3, 4])).toBeCloseTo(25, 10); + }); + + it('computes 3D distance', () => { + // sqrt(1+4+4) = 3 + expect(pointDistanceToPoint([0, 0, 0], [1, 2, 2])).toBeCloseTo(3, 10); + expect(pointDistanceToPointSquared([0, 0, 0], [1, 2, 2])).toBeCloseTo( + 9, + 10 + ); + }); + + it('is 0 for identical points', () => { + expect(pointDistanceToPoint([2, 2], [2, 2])).toBe(0); + }); + + it('throws when dimensionality does not match', () => { + expect(() => pointDistanceToPointSquared([0, 0], [0, 0, 0])).toThrow(); + }); +}); + +describe('math/point/mirror', () => { + it('reflects a point across a static point (static is the midpoint of input/output)', () => { + // Mirror (0,0) across static point (5,5) -> (10,10) + expect(mirror([0, 0], [5, 5])).toEqual([10, 10]); + }); + + it('returns the same point when mirroring a point across itself', () => { + expect(mirror([3, 3], [3, 3])).toEqual([3, 3]); + }); + + it('handles negative coordinates', () => { + // Mirror (-2,-2) across (0,0) -> (2,2) + expect(mirror([-2, -2], [0, 0])).toEqual([2, 2]); + }); +}); + +describe('math/vec2/liangBarksyClip', () => { + const box = [0, 0, 10, 10]; // xmin, ymin, xmax, ymax + + it('keeps a segment fully inside the clip window unchanged', () => { + const a = [2, 2]; + const b = [8, 8]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(1); // INSIDE + expect(a).toEqual([2, 2]); + expect(b).toEqual([8, 8]); + }); + + it('clips a segment crossing one edge', () => { + // Segment from (5,5) to (15,5) crosses the right edge at x=10 + const a = [5, 5]; + const b = [15, 5]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(1); + expect(a).toEqual([5, 5]); // start unchanged (already inside) + expect(b[0]).toBeCloseTo(10, 10); // clipped to right edge + expect(b[1]).toBeCloseTo(5, 10); + }); + + it('clips a segment crossing two edges (both ends outside)', () => { + // Segment from (-5,5) to (15,5) crosses left edge at x=0 and right edge at x=10 + const a = [-5, 5]; + const b = [15, 5]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(1); + expect(a[0]).toBeCloseTo(0, 10); + expect(a[1]).toBeCloseTo(5, 10); + expect(b[0]).toBeCloseTo(10, 10); + expect(b[1]).toBeCloseTo(5, 10); + }); + + it('rejects a segment fully outside the clip window', () => { + const a = [20, 20]; + const b = [30, 30]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(0); // OUTSIDE + }); + + it('rejects a segment lying exactly along a horizontal clip boundary (dy=0 edge case)', () => { + // Segment along y=0 (== box ymin), spanning x in [xmin, xmax]. + // Suspected product/library quirk: for the boundary parallel to a clip + // edge (dy === 0 here), clipT() short-circuits with `return num < 0` + // where num = box[1] - y1 = ymin - y1. When the segment sits exactly on + // that edge, num is exactly 0, so `0 < 0` is false and the segment is + // rejected as OUTSIDE - even though it lies entirely within the box's + // x-range on the boundary itself. The same happens on the y = ymax edge. + // A segment offset by an epsilon to the inside (y=0.001) is correctly + // kept, confirming the exact-boundary case is what triggers rejection. + const a = [2, 0]; + const b = [8, 0]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(0); // OUTSIDE - see note above + + const aInside = [2, 0.001]; + const bInside = [8, 0.001]; + const resultInside = liangBarksyClip(aInside, bInside, box); + expect(resultInside).toBe(1); // INSIDE once nudged off the exact boundary + }); + + it('handles a degenerate (point) segment inside the box as INSIDE', () => { + const a = [5, 5]; + const b = [5, 5]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(1); + }); + + it('handles a degenerate (point) segment outside the box as OUTSIDE', () => { + const a = [50, 50]; + const b = [50, 50]; + const result = liangBarksyClip(a, b, box); + expect(result).toBe(0); + }); + + it('writes clipped results into optional da/db output points without mutating a/b', () => { + const a = [5, 5]; + const b = [15, 5]; + const da = [0, 0]; + const db = [0, 0]; + const result = liangBarksyClip(a, b, box, da, db); + expect(result).toBe(1); + // Original a/b are untouched (this overload copies into da/db) + expect(a).toEqual([5, 5]); + expect(b).toEqual([15, 5]); + expect(da).toEqual([5, 5]); + expect(db[0]).toBeCloseTo(10, 10); + expect(db[1]).toBeCloseTo(5, 10); + }); +}); + +describe('math/vec2/findClosestPoint', () => { + it('finds the closest point among several candidates', () => { + const sources = [ + [0, 0], + [10, 10], + [4, 4], + ]; + const target = [5, 5]; + // distances: sqrt(50)=7.07, sqrt(50)=7.07, sqrt(2)=1.41 -> closest is (4,4) + expect(findClosestPoint(sources, target)).toEqual([4, 4]); + }); + + it('returns the single point when only one candidate is given', () => { + expect(findClosestPoint([[1, 1]], [100, 100])).toEqual([1, 1]); + }); + + it('returns [0, 0] default when sourcePoints is empty', () => { + expect(findClosestPoint([], [1, 1])).toEqual([0, 0]); + }); +}); + +describe('math/vec3/interpolateVec3', () => { + const a = [0, 0, 0]; + const b = [10, 20, 30]; + + it('returns vector a at t=0', () => { + expect(interpolateVec3(a, b, 0)).toEqual([0, 0, 0]); + }); + + it('returns vector b at t=1', () => { + expect(interpolateVec3(a, b, 1)).toEqual([10, 20, 30]); + }); + + it('returns the exact midpoint at t=0.5', () => { + expect(interpolateVec3(a, b, 0.5)).toEqual([5, 10, 15]); + }); + + it('linearly interpolates at an arbitrary t', () => { + // t=0.25 -> a + 0.25*(b-a) + const result = interpolateVec3(a, b, 0.25); + expect(result[0]).toBeCloseTo(2.5, 10); + expect(result[1]).toBeCloseTo(5, 10); + expect(result[2]).toBeCloseTo(7.5, 10); + }); +}); + +describe('math/midPoint', () => { + it('computes the midpoint of two 2D points', () => { + expect(midPoint([0, 0], [10, 10])).toEqual([5, 5]); + }); + + it('computes the midpoint of two 3D points', () => { + expect(midPoint([0, 0, 0], [10, 20, 30])).toEqual([5, 10, 15]); + }); + + it('supports more than two points (variadic average)', () => { + // Average of (0,0), (6,0), (0,6) -> (2,2) + const result = midPoint([0, 0], [6, 0], [0, 6]); + expect(result[0]).toBeCloseTo(2, 10); + expect(result[1]).toBeCloseTo(2, 10); + }); + + it('returns the same point for a single-point input', () => { + expect(midPoint([3, 4])).toEqual([3, 4]); + }); +}); diff --git a/packages/tools/test/utilities/planarAndBounds.jest.js b/packages/tools/test/utilities/planarAndBounds.jest.js new file mode 100644 index 0000000000..b1417c0605 --- /dev/null +++ b/packages/tools/test/utilities/planarAndBounds.jest.js @@ -0,0 +1,851 @@ +// Covers packages/tools/src/utilities/planar/ and src/utilities/boundingBox/ +// (all ~0% coverage). These are pure-geometry helpers used to decide which +// annotations render on a given slice/plane and to compute pixel/index +// bounding boxes for segmentation strategies. +// +// EPSILON below matches the real @cornerstonejs/core CONSTANTS.EPSILON +// (packages/core/src/constants/epsilon.ts -> 1e-3), which several of these +// modules use as a parallel-normal / clip tolerance. +const EPSILON = 1e-3; + +jest.mock('@cornerstonejs/core', () => { + class StackViewport {} + class VolumeViewport {} + + return { + CONSTANTS: { EPSILON: 1e-3 }, + metaData: { get: jest.fn() }, + utilities: { + // Minimal re-implementation of the real isEqual (numbers only), see + // packages/utils/src/utilities/math/isEqual.ts. + isEqual: (v1, v2, tolerance = 1e-5) => Math.abs(v1 - v2) <= tolerance, + getViewportContentMode: jest.fn(), + getTargetVolumeAndSpacingInNormalDir: jest.fn(), + }, + StackViewport, + VolumeViewport, + }; +}); + +// filterAnnotationsForDisplay's native-volume branch calls getViewportICamera +// to bridge a resolved-view viewport into an ICamera-shaped object. That +// bridging logic (isGenericViewport / clonePoint3 / getResolvedView) is +// outside the scope of this filter, so we stub it directly. +jest.mock('../../src/utilities/getViewportICamera', () => ({ + __esModule: true, + default: jest.fn(), +})); + +import { metaData, utilities as csUtils } from '@cornerstonejs/core'; +import { StackViewport, VolumeViewport } from '@cornerstonejs/core'; +import getViewportICamera from '../../src/utilities/getViewportICamera'; + +import filterAnnotationsWithinSlice from '../../src/utilities/planar/filterAnnotationsWithinSlice'; +import { filterAnnotationsWithinSamePlane } from '../../src/utilities/planar/filterAnnotationsWithinPlane'; +import filterAnnotationsForDisplay from '../../src/utilities/planar/filterAnnotationsForDisplay'; +import getWorldWidthAndHeightFromCorners from '../../src/utilities/planar/getWorldWidthAndHeightFromCorners'; +import getWorldWidthAndHeightFromTwoPoints from '../../src/utilities/planar/getWorldWidthAndHeightFromTwoPoints'; +import { isPlaneIntersectingAABB } from '../../src/utilities/planar/isPlaneIntersectingAABB'; +import { + getPointInLineOfSightWithCriteria, + getPointsInLineOfSight, +} from '../../src/utilities/planar/getPointInLineOfSightWithCriteria'; + +import { + getBoundingBoxAroundShapeIJK, + getBoundingBoxAroundShapeWorld, +} from '../../src/utilities/boundingBox/getBoundingBoxAroundShape'; +import extend2DBoundingBoxInViewAxis from '../../src/utilities/boundingBox/extend2DBoundingBoxInViewAxis'; +import snapIndexBounds from '../../src/utilities/boundingBox/snapIndexBounds'; + +function makeAnnotation({ + viewPlaneNormal, + referencedImageId, + FrameOfReferenceUID, + planeRestriction, + points, + contourPoints, + isVisible = true, + isCanvasAnnotation = false, +} = {}) { + return { + isVisible, + metadata: { + viewPlaneNormal, + referencedImageId, + FrameOfReferenceUID, + planeRestriction, + }, + data: { + isCanvasAnnotation, + handles: points ? { points } : undefined, + contour: contourPoints ? { polyline: contourPoints } : undefined, + }, + }; +} + +describe('utilities/planar/filterAnnotationsWithinSlice', () => { + const camera = { + focalPoint: [0, 0, 0], + viewPlaneNormal: [0, 0, 1], + }; + const spacingInNormalDirection = 2; // halfSpacing = 1 + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps an annotation with a parallel normal within half the slice spacing', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 0.5]], // distance to focal point along normal = 0.5 < 1 + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + }); + + it('excludes an annotation with a parallel normal but outside the slice spacing', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 5]], // distance = 5, not < halfSpacing (1) + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([]); + }); + + it('treats an antiparallel normal as parallel (flip-safe by design)', () => { + // dot([0,0,1],[0,0,-1]) = -1, abs(-1) = 1 > PARALLEL_THRESHOLD (0.999) + // The source comments explain this is intentional: camera flips should + // not hide annotations on the same physical slice. + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, -1], + points: [[0, 0, 0]], + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + }); + + it('excludes an annotation whose normal is perpendicular to the camera normal', () => { + // dot([0,0,1],[1,0,0]) = 0, not > 0.999 -> not parallel, regardless of + // distance. + const annotation = makeAnnotation({ + viewPlaneNormal: [1, 0, 0], + points: [[0, 0, 0]], + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([]); + }); + + it('excludes invisible annotations even if otherwise in-slice', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 0]], + isVisible: false, + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([]); + }); + + it('includes an annotation with no handle/contour points unconditionally (e.g. key images)', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + }); + + it('reads the first contour polyline point when handles are absent', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + contourPoints: [[0, 0, 5]], // outside half-spacing (1) + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([]); + }); + + it('planeRestriction: keeps annotation when both in-plane vectors are perpendicular to the camera normal', () => { + const annotation = makeAnnotation({ + planeRestriction: { + inPlaneVector1: [1, 0, 0], + inPlaneVector2: [0, 1, 0], + point: [0, 0, 0], + }, + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + }); + + it('planeRestriction: excludes annotation when an in-plane vector is parallel to the camera normal', () => { + const annotation = makeAnnotation({ + planeRestriction: { + inPlaneVector1: [0, 0, 1], // parallel to camera normal -> restriction violated + inPlaneVector2: [0, 1, 0], + point: [0, 0, 0], + }, + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([]); + }); + + it('derives the normal from FrameOfReferenceUID + handle points lying on the camera plane', () => { + // No referencedImageId/viewPlaneNormal, but all handle points are + // coplanar with the focal point w.r.t. the camera normal (dot === 0), + // so the annotation is accepted and stamped with the camera's normal. + const annotation = makeAnnotation({ + FrameOfReferenceUID: 'for-1', + points: [[5, 5, 0]], + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + expect(annotation.metadata.viewPlaneNormal).toEqual(camera.viewPlaneNormal); + }); + + it('derives the normal from imagePlaneModule metadata when referencedImageId is present', () => { + // Identity-like orientation: row cosine = X axis, column cosine = Y axis + // -> cross(row, col) = [0,0,1], which matches the camera normal exactly. + metaData.get.mockReturnValue({ + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + }); + const annotation = makeAnnotation({ + referencedImageId: 'image:1', + points: [[0, 0, 0]], + }); + + const result = filterAnnotationsWithinSlice( + [annotation], + camera, + spacingInNormalDirection + ); + + expect(result).toEqual([annotation]); + expect(metaData.get).toHaveBeenCalledWith('imagePlaneModule', 'image:1'); + }); + + it('returns an empty array up-front when nothing has a parallel normal', () => { + const annotation = makeAnnotation({ + viewPlaneNormal: [1, 0, 0], + }); + + const result = filterAnnotationsWithinSlice([annotation], camera, 2); + + expect(result).toEqual([]); + }); +}); + +describe('utilities/planar/filterAnnotationsWithinPlane (filterAnnotationsWithinSamePlane)', () => { + const camera = { + focalPoint: [0, 0, 0], + viewPlaneNormal: [0, 0, 1], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps annotations whose stored normal is parallel to the camera normal', () => { + const annotation = makeAnnotation({ viewPlaneNormal: [0, 0, 1] }); + + const result = filterAnnotationsWithinSamePlane([annotation], camera); + + expect(result).toEqual([annotation]); + }); + + it('excludes annotations whose stored normal is not parallel', () => { + const annotation = makeAnnotation({ viewPlaneNormal: [0, 1, 0] }); + + const result = filterAnnotationsWithinSamePlane([annotation], camera); + + expect(result).toEqual([]); + }); + + it('derives the normal from imagePlaneModule metadata when missing, then keeps parallel matches', () => { + metaData.get.mockReturnValue({ + imageOrientationPatient: [1, 0, 0, 0, 1, 0], + }); + const annotation = makeAnnotation({ referencedImageId: 'image:1' }); + + const result = filterAnnotationsWithinSamePlane([annotation], camera); + + expect(result).toEqual([annotation]); + // vec3.cross returns a Float32Array; compare contents via Array.from + // rather than relying on toEqual's typed-array/array type matching. + expect(Array.from(annotation.metadata.viewPlaneNormal)).toEqual([0, 0, 1]); + }); + + it('returns an empty array when no annotation matches', () => { + const annotation = makeAnnotation({ viewPlaneNormal: [1, 0, 0] }); + + const result = filterAnnotationsWithinSamePlane([annotation], camera); + + expect(result).toEqual([]); + }); +}); + +describe('utilities/planar/filterAnnotationsForDisplay', () => { + const camera = { + focalPoint: [0, 0, 0], + viewPlaneNormal: [0, 0, 1], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('legacy VolumeViewport: delegates to filterAnnotationsWithinSlice using getCamera()', () => { + const inSlice = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 0.2]], + }); + const outOfSlice = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 5]], + }); + const viewport = Object.assign(Object.create(VolumeViewport.prototype), { + getCamera: jest.fn(() => camera), + }); + csUtils.getTargetVolumeAndSpacingInNormalDir.mockReturnValue({ + spacingInNormalDirection: 2, + }); + + const result = filterAnnotationsForDisplay(viewport, [inSlice, outOfSlice]); + + expect(result).toEqual([inSlice]); + expect(viewport.getCamera).toHaveBeenCalled(); + }); + + it('native volume-mode viewport: uses getViewportICamera bridge + geometric filter', () => { + csUtils.getViewportContentMode.mockReturnValue('volume'); + csUtils.getTargetVolumeAndSpacingInNormalDir.mockReturnValue({ + spacingInNormalDirection: 2, + }); + getViewportICamera.mockReturnValue(camera); + + const inSlice = makeAnnotation({ + viewPlaneNormal: [0, 0, 1], + points: [[0, 0, 0.2]], + }); + // A viewport that is neither the mocked VolumeViewport nor StackViewport. + const viewport = {}; + + const result = filterAnnotationsForDisplay(viewport, [inSlice]); + + expect(result).toEqual([inSlice]); + expect(getViewportICamera).toHaveBeenCalledWith(viewport); + }); + + it('StackViewport: returns [] when there is no current image id', () => { + const viewport = Object.assign(Object.create(StackViewport.prototype), { + getCurrentImageId: jest.fn(() => undefined), + }); + csUtils.getViewportContentMode.mockReturnValue('stack'); + + const result = filterAnnotationsForDisplay(viewport, [makeAnnotation()]); + + expect(result).toEqual([]); + }); + + it('StackViewport: strips the data-loader scheme and delegates to isReferenceViewable', () => { + const isReferenceViewable = jest.fn(() => true); + const viewport = Object.assign(Object.create(StackViewport.prototype), { + getCurrentImageId: jest.fn(() => 'volumeLoader://1.2.3'), + isReferenceViewable, + }); + csUtils.getViewportContentMode.mockReturnValue('stack'); + const annotation = makeAnnotation(); + + const result = filterAnnotationsForDisplay(viewport, [annotation]); + + expect(result).toEqual([annotation]); + // colonIndex is the first ':' (after the "volumeLoader" scheme); the + // "://" separator is preserved because substring starts at colonIndex+1. + expect(isReferenceViewable).toHaveBeenCalledWith(annotation.metadata, { + imageURI: '//1.2.3', + }); + }); + + it('StackViewport: excludes invisible annotations without calling isReferenceViewable', () => { + const isReferenceViewable = jest.fn(() => true); + const viewport = Object.assign(Object.create(StackViewport.prototype), { + getCurrentImageId: jest.fn(() => 'stack://image1'), + isReferenceViewable, + }); + csUtils.getViewportContentMode.mockReturnValue('stack'); + const annotation = makeAnnotation({ isVisible: false }); + + const result = filterAnnotationsForDisplay(viewport, [annotation]); + + expect(result).toEqual([]); + expect(isReferenceViewable).not.toHaveBeenCalled(); + }); + + it('generic viewport: always keeps canvas annotations without calling isReferenceViewable', () => { + const isReferenceViewable = jest.fn(() => false); + const viewport = { isReferenceViewable }; + csUtils.getViewportContentMode.mockReturnValue(undefined); + const annotation = makeAnnotation({ isCanvasAnnotation: true }); + + const result = filterAnnotationsForDisplay(viewport, [annotation]); + + expect(result).toEqual([annotation]); + expect(isReferenceViewable).not.toHaveBeenCalled(); + }); + + it('generic viewport: falls through to isReferenceViewable with the given filterOptions', () => { + const isReferenceViewable = jest.fn(() => false); + const viewport = { isReferenceViewable }; + csUtils.getViewportContentMode.mockReturnValue(undefined); + const annotation = makeAnnotation(); + + const result = filterAnnotationsForDisplay(viewport, [annotation], { + withNavigation: true, + }); + + expect(result).toEqual([]); + expect(isReferenceViewable).toHaveBeenCalledWith(annotation.metadata, { + withNavigation: true, + }); + }); +}); + +describe('utilities/planar/getWorldWidthAndHeightFromCorners', () => { + // Axial-style basis: viewPlaneNormal = +Z, viewUp = +Y. + // viewRight = cross(viewUp, viewPlaneNormal) = cross([0,1,0],[0,0,1]) = [1,0,0] + const viewPlaneNormal = [0, 0, 1]; + const viewUp = [0, 1, 0]; + + it('pure width case: diagonal antiparallel to viewRight -> sinTheta=0', () => { + // diagonal = topLeft - bottomRight = [0,0,0]-[10,0,0] = [-10,0,0] + // diagonalLength = 10; dot(diagonal, viewRight=[1,0,0]) = -10 + // cosTheta = -10/10 = -1 -> sinTheta = sqrt(1-1) = 0 + // worldWidth = 0*10 = 0 ; worldHeight = -1*10 = -10 + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromCorners( + viewPlaneNormal, + viewUp, + [0, 0, 0], + [10, 0, 0] + ); + + expect(worldWidth).toBeCloseTo(0, 10); + expect(worldHeight).toBeCloseTo(-10, 10); + }); + + it('pure height case: diagonal perpendicular to viewRight -> cosTheta=0', () => { + // diagonal = [0,0,0]-[0,10,0] = [0,-10,0]; dot with viewRight=[1,0,0] = 0 + // cosTheta = 0 -> sinTheta = 1 + // worldWidth = 1*10 = 10 ; worldHeight = 0*10 = 0 + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromCorners( + viewPlaneNormal, + viewUp, + [0, 0, 0], + [0, 10, 0] + ); + + expect(worldWidth).toBeCloseTo(10, 10); + expect(worldHeight).toBeCloseTo(0, 10); + }); + + it('oblique 3-4-5 case: exact width/height decomposition', () => { + // diagonal = [0,0,0]-[-3,-4,0] = [3,4,0]; diagonalLength = 5 + // dot(diagonal, viewRight=[1,0,0]) = 3 -> cosTheta = 3/5 = 0.6 + // sinTheta = sqrt(1-0.36) = sqrt(0.64) = 0.8 + // worldWidth = 0.8*5 = 4 ; worldHeight = 0.6*5 = 3 + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromCorners( + viewPlaneNormal, + viewUp, + [0, 0, 0], + [-3, -4, 0] + ); + + expect(worldWidth).toBeCloseTo(4, 10); + expect(worldHeight).toBeCloseTo(3, 10); + }); + + it('degenerate case: near-coincident points return {0,0} to avoid NaN', () => { + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromCorners( + viewPlaneNormal, + viewUp, + [1, 1, 1], + [1, 1, 1.00001] // diagonalLength = 0.00001 < 0.0001 threshold + ); + + expect(worldWidth).toBe(0); + expect(worldHeight).toBe(0); + }); +}); + +describe('utilities/planar/getWorldWidthAndHeightFromTwoPoints', () => { + // Same math as FromCorners (this is effectively a duplicate implementation + // with different argument names), spot-checked here for coverage. + const viewPlaneNormal = [0, 0, 1]; + const viewUp = [0, 1, 0]; + + it('oblique 3-4-5 case matches getWorldWidthAndHeightFromCorners', () => { + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromTwoPoints( + viewPlaneNormal, + viewUp, + [0, 0, 0], + [-3, -4, 0] + ); + + expect(worldWidth).toBeCloseTo(4, 10); + expect(worldHeight).toBeCloseTo(3, 10); + }); + + it('degenerate case returns {0,0}', () => { + const { worldWidth, worldHeight } = getWorldWidthAndHeightFromTwoPoints( + viewPlaneNormal, + viewUp, + [0, 0, 0], + [0, 0, 0.00001] + ); + + expect(worldWidth).toBe(0); + expect(worldHeight).toBe(0); + }); +}); + +describe('utilities/planar/isPlaneIntersectingAABB', () => { + const box = { minX: 0, minY: 0, minZ: 0, maxX: 10, maxY: 10, maxZ: 10 }; + + it('returns true when the plane passes through the middle of the box', () => { + // Plane z=5 with normal +Z: bottom face (z=0) is below, top face (z=10) + // is above -> vertices on both sides. + const result = isPlaneIntersectingAABB( + [5, 5, 5], + [0, 0, 1], + box.minX, + box.minY, + box.minZ, + box.maxX, + box.maxY, + box.maxZ + ); + + expect(result).toBe(true); + }); + + it('returns false when the plane is entirely outside the box', () => { + // Plane at z=-100 with normal +Z: every vertex (z in [0,10]) is on the + // same (positive) side. + const result = isPlaneIntersectingAABB( + [-100, -100, -100], + [0, 0, 1], + box.minX, + box.minY, + box.minZ, + box.maxX, + box.maxY, + box.maxZ + ); + + expect(result).toBe(false); + }); + + it('tangent case: plane coincides with the box bottom face still counts as intersecting', () => { + // Plane z=0 with normal +Z: the 4 bottom vertices sit exactly on the + // plane (signed distance 0) while the 4 top vertices are strictly + // positive (distance 10) -> Math.sign(0) !== Math.sign(10) -> true. + const result = isPlaneIntersectingAABB( + [0, 0, 0], + [0, 0, 1], + box.minX, + box.minY, + box.minZ, + box.maxX, + box.maxY, + box.maxZ + ); + + expect(result).toBe(true); + }); + + it('tangent case: plane touching only the far corner still counts as intersecting', () => { + // Plane through (10,10,10) with normal (1,1,1): 7 vertices are strictly + // on the negative side, the (max,max,max) corner has signed distance 0. + // Math.sign(0) !== Math.sign(negative) -> true (touching == intersecting + // under this algorithm's convention). + const result = isPlaneIntersectingAABB( + [10, 10, 10], + [1, 1, 1], + box.minX, + box.minY, + box.minZ, + box.maxX, + box.maxY, + box.maxZ + ); + + expect(result).toBe(true); + }); +}); + +describe('utilities/planar/getPointInLineOfSightWithCriteria', () => { + function makeViewport({ intensityFn }) { + return { + getCamera: jest.fn(() => ({ + focalPoint: [0, 0, 0], + viewPlaneNormal: [0, 0, 1], + })), + // Bounds padded by 10 inside _inBounds -> usable range is (-5,5) on + // every axis for these tight bounds. + getBounds: jest.fn(() => [-15, 15, -15, 15, -15, 15]), + getIntensityFromWorld: jest.fn(intensityFn), + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('getPointsInLineOfSight samples symmetric steps along the normal within bounds', () => { + csUtils.getTargetVolumeAndSpacingInNormalDir.mockReturnValue({ + spacingInNormalDirection: 4, // step = 4 * stepSize(0.25) = 1 + }); + const viewport = makeViewport({ intensityFn: () => 0 }); + + const points = getPointsInLineOfSight(viewport, [0, 0, 0], { + targetVolumeId: 'vol1', + stepSize: 0.25, + }); + + // Positive direction: z = 0..4 (z=5 fails the strict "< 5" bound check). + // Negative direction (restarts from worldPos): z = 0,-1,-2,-3,-4. + const zValues = points.map((p) => p[2]); + expect(zValues).toEqual([0, 1, 2, 3, 4, 0, -1, -2, -3, -4]); + }); + + it('picks the point with the running-maximum intensity along the line of sight', () => { + csUtils.getTargetVolumeAndSpacingInNormalDir.mockReturnValue({ + spacingInNormalDirection: 4, + }); + // Intensity defined as the z coordinate of the sampled point. + const viewport = makeViewport({ intensityFn: (point) => point[2] }); + + let maxIntensity = -Infinity; + const keepRunningMax = (intensity, point) => { + if (intensity > maxIntensity) { + maxIntensity = intensity; + return point; + } + return undefined; + }; + + const result = getPointInLineOfSightWithCriteria( + viewport, + [0, 0, 0], + 'vol1', + keepRunningMax, + 0.25 + ); + + // Sampling order (see test above) visits z=4 (the max) before any of the + // negative-direction samples, all of which are smaller -> final pick is + // the last point where a new max was set: z=4. + expect(result).toEqual([0, 0, 4]); + }); +}); + +describe('utilities/boundingBox/getBoundingBoxAroundShapeIJK', () => { + it('computes exact min/max per axis for a 2D point cloud with no dimensions', () => { + const bounds = getBoundingBoxAroundShapeIJK([ + [2, 9], + [7, 3], + ]); + + expect(bounds).toEqual([[2, 7], [3, 9], null]); + }); + + it('clamps to [0, dimension-1] per axis when dimensions are provided (2D)', () => { + const bounds = getBoundingBoxAroundShapeIJK( + [ + [-5, -5], + [15, 3], + ], + [10, 10] + ); + + // xMin: max(0,-5)=0 ; xMax: min(9,15)=9 + // yMin: max(0,-5)=0 ; yMax: min(9,3)=3 + expect(bounds).toEqual([[0, 9], [0, 3], null]); + }); + + it('handles 3D point clouds and clamps all three axes', () => { + const bounds = getBoundingBoxAroundShapeIJK( + [ + [-2, -2, -2], + [20, 20, 20], + ], + [10, 10, 10] + ); + + expect(bounds).toEqual([ + [0, 9], + [0, 9], + [0, 9], + ]); + }); + + it('degenerate single-point 3D case collapses min===max on every axis', () => { + const bounds = getBoundingBoxAroundShapeIJK([[4, 4, 4]]); + + expect(bounds).toEqual([ + [4, 4], + [4, 4], + [4, 4], + ]); + }); +}); + +describe('utilities/boundingBox/getBoundingBoxAroundShapeWorld', () => { + it('returns exact raw min/max per axis when no clip bounds are provided', () => { + const bounds = getBoundingBoxAroundShapeWorld([ + [-3.5, 2.25, 0], + [10, -1, 8], + ]); + + expect(bounds).toEqual([ + [-3.5, 10], + [-1, 2.25], + [0, 8], + ]); + }); + + // SUSPECTED PRODUCT BUG (getBoundingBoxAroundShape.ts calculateBoundingBox, + // isWorld branch): when clip dimensions are supplied, xMin is clamped to + // >= dimensions[0]+EPSILON while xMax is clamped to <= dimensions[0]-EPSILON + // -- i.e. both the lower AND upper clip use the *same* dimensions[0] value + // (offset by +/-EPSILON), rather than a [min,max] pair. For any positive + // dimensions[0] this forces xMin > xMax (an inverted, empty range) whenever + // clamping actually kicks in. getBoundingBoxAroundShapeWorld has no callers + // in src/ today, which is likely why this has gone unnoticed. + it('documents the inverted-range clip bug when world clipBounds are supplied', () => { + const bounds = getBoundingBoxAroundShapeWorld( + [ + [-5, -5, -5], + [5, 5, 5], + ], + [3, 3, 3] + ); + + // xMin = max(3+EPSILON, -5) = 3.001 ; xMax = min(3-EPSILON, 5) = 2.999 + // (same formula applies to y and z) -- min ends up greater than max. + expect(bounds[0][0]).toBeCloseTo(3 + EPSILON, 10); + expect(bounds[0][1]).toBeCloseTo(3 - EPSILON, 10); + expect(bounds[0][0]).toBeGreaterThan(bounds[0][1]); + }); +}); + +describe('utilities/boundingBox/extend2DBoundingBoxInViewAxis', () => { + it('extends the axis whose min/max already collapse to a single slice index', () => { + // k-axis (index 2) is the slice-normal axis: 7 === 7. + const boundsIJK = [ + [2, 8], + [1, 6], + [7, 7], + ]; + + const result = extend2DBoundingBoxInViewAxis(boundsIJK, 2); + + expect(result).toEqual([ + [2, 8], + [1, 6], + [5, 9], // 7-2, 7+2 + ]); + // The function mutates its input argument in place (returns same ref). + expect(result).toBe(boundsIJK); + }); + + it('throws when no axis has a collapsed min===max (oblique / 3D box)', () => { + const boundsIJK = [ + [1, 2], + [3, 4], + [5, 6], + ]; + + expect(() => extend2DBoundingBoxInViewAxis(boundsIJK, 1)).toThrow( + /3D bounding boxes not supported in an oblique plane/ + ); + }); +}); + +describe('utilities/boundingBox/snapIndexBounds', () => { + it('collapses to a single rounded index when delta <= 1', () => { + // delta = 0.6 <= 1 -> index = round((3.2+3.8)/2) = round(3.5) = 4 + expect(snapIndexBounds(3.2, 3.8)).toEqual([4, 4]); + }); + + it('collapses to a single rounded index at the delta===1 boundary', () => { + // delta = 1 <= 1 -> index = round(2.5) = 3 (JS rounds .5 up) + expect(snapIndexBounds(2, 3)).toEqual([3, 3]); + }); + + it('uses floor/ceil to preserve coverage when delta > 1', () => { + // delta = 3.5 > 1 -> [floor(2.2), ceil(5.7)] = [2, 6] + expect(snapIndexBounds(2.2, 5.7)).toEqual([2, 6]); + }); + + it('uses floor/ceil just past the delta===1 boundary', () => { + // delta = 1.0001 > 1 -> [floor(0), ceil(1.0001)] = [0, 2] + expect(snapIndexBounds(0, 1.0001)).toEqual([0, 2]); + }); +}); diff --git a/packages/tools/test/utilities/polylineBooleanOps.jest.js b/packages/tools/test/utilities/polylineBooleanOps.jest.js new file mode 100644 index 0000000000..2e02b49423 --- /dev/null +++ b/packages/tools/test/utilities/polylineBooleanOps.jest.js @@ -0,0 +1,254 @@ +/** + * Unit tests for the polygon boolean-op "workhorses" used by contour + * segmentation (region-growing merges, hole punching, freehand ROI + * editing): `intersectPolylines`, `subtractPolylines`, and `mergePolylines` + * (aka the polyline union, exported from `combinePolyline.ts`). + * + * These modules import only `import type { Types } from '@cornerstonejs/core'` + * directly, but `intersectPolylines`/`subtractPolylines` pull in + * `robustSegmentIntersection.ts`, which *does* have a real runtime import + * (`utilities.isEqual` from `@cornerstonejs/core`). That is a small, pure, + * side-effect-free function, so we let the real `@cornerstonejs/core` + * resolve here rather than mocking it (confirmed empirically to load fine + * under jsdom with no canvas/WebGL involvement). + * + * We assert semantic properties (result area via `getArea`, point-in-polygon + * spot checks, closedness) rather than exact vertex order/count, since the + * boolean-op algorithms are free to introduce extra vertices at intersection + * points. + */ +import { describe, it, expect } from '@jest/globals'; + +import getArea from '../../src/utilities/math/polyline/getArea'; +import containsPoint from '../../src/utilities/math/polyline/containsPoint'; +import isClosed from '../../src/utilities/math/polyline/isClosed'; +import intersectPolylines from '../../src/utilities/math/polyline/intersectPolylines'; +import subtractPolylines from '../../src/utilities/math/polyline/subtractPolylines'; +import { mergePolylines } from '../../src/utilities/math/polyline/combinePolyline'; + +// Two 10-wide-by-10/12-tall rectangles overlapping in a 5x10 region: +// A: x in [0, 10], y in [0, 10] (area 100) +// B: x in [5, 15], y in [-1, 11] (area 120) +// overlap: x in [5, 10], y in [0, 10] (area 50) +// +// B is deliberately made taller than A (y in [-1, 11] instead of [0, 10]) +// so that its top/bottom edges do NOT sit exactly on the same lines as A's +// top/bottom edges. Using two same-height rectangles offset only in x (the +// "textbook" example) makes every corner of the overlap region coincide +// exactly with a pre-existing vertex of one of the two polygons, which +// triggers a real bug in the intersection-pairing logic - see the +// suspected-bug note below and in the final report. +const A = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], +]; +const B = [ + [5, -1], + [15, -1], + [15, 11], + [5, 11], +]; + +function totalArea(polygons) { + return polygons.reduce((sum, poly) => sum + getArea(poly), 0); +} + +describe('intersectPolylines', () => { + it('computes the overlap region of two overlapping rectangles (area 50)', () => { + const result = intersectPolylines(A, B); + expect(result).toHaveLength(1); + expect(isClosed([...result[0], result[0][0]])).toBe(true); + // Overlap is x in [5,10], y in [0,10] -> area 5*10 = 50. + expect(totalArea(result)).toBeCloseTo(50, 6); + // Spot checks: (7,5) is in the overlap; (2,5) is only in A; (12,5) is + // only in B. + expect(containsPoint(result[0], [7, 5])).toBe(true); + expect(containsPoint(result[0], [2, 5])).toBe(false); + expect(containsPoint(result[0], [12, 5])).toBe(false); + }); + + it('returns an empty array for two disjoint polygons', () => { + const far = [ + [100, 100], + [110, 100], + [110, 110], + [100, 110], + ]; + expect(intersectPolylines(A, far)).toEqual([]); + }); + + it('returns the fully-contained polygon when one polygon is entirely inside the other', () => { + const inner = [ + [3, 3], + [7, 3], + [7, 7], + [3, 7], + ]; + const result = intersectPolylines(A, inner); + expect(result).toHaveLength(1); + // Inner is fully inside A, so the intersection equals inner: area + // (7-3)*(7-3) = 16. + expect(totalArea(result)).toBeCloseTo(16, 6); + }); + + it('returns an empty array for degenerate (fewer than 3 point) input', () => { + expect( + intersectPolylines(A, [ + [0, 0], + [1, 1], + ]) + ).toEqual([]); + }); +}); + +describe('subtractPolylines', () => { + it('computes target-minus-source for two overlapping rectangles (area 50)', () => { + const result = subtractPolylines(A, B); + expect(result).toHaveLength(1); + expect(isClosed([...result[0], result[0][0]])).toBe(true); + // A (area 100) minus the 50-area overlap leaves the x in [0,5] strip of + // A, area 5*10 = 50. + expect(totalArea(result)).toBeCloseTo(50, 6); + // Spot checks: (2,5) is in the remaining strip; (7,5) was subtracted + // away (it's in the overlap); (12,5) was never part of A. + expect(containsPoint(result[0], [2, 5])).toBe(true); + expect(containsPoint(result[0], [7, 5])).toBe(false); + expect(containsPoint(result[0], [12, 5])).toBe(false); + }); + + it('returns the target unchanged when subtracting a disjoint polygon', () => { + const far = [ + [100, 100], + [110, 100], + [110, 110], + [100, 110], + ]; + const result = subtractPolylines(A, far); + expect(result).toHaveLength(1); + expect(totalArea(result)).toBeCloseTo(100, 6); + }); + + it('returns an empty set when subtracting an identical polygon', () => { + expect(subtractPolylines(A, A.slice())).toEqual([]); + }); + + it('returns the target polyline as-is when the source has fewer than 3 points', () => { + const result = subtractPolylines(A, [ + [0, 0], + [1, 1], + ]); + expect(result).toEqual([A]); + }); + + it('KNOWN LIMITATION: does not carve a hole when the subtracted polygon is fully interior and never touches the target boundary', () => { + // A polygon-with-a-hole cannot be represented by a single simple ring + // (the return type here is a flat point array per result element), and + // this algorithm identifies the region to remove purely from boundary + // intersections between target and source. When the source never + // touches the target's boundary at all (fully enclosed, no shared + // points), there is nothing to trace a hole from, so the target comes + // back completely unmodified instead of "target with a hole" or an + // island result. Documented here as current behavior, not a hard + // correctness bug, since the data structure can't express the "true" + // answer anyway - callers needing hole support use a different + // representation (see contourSegmentation's hole-aware polyline ops). + const inner = [ + [3, 3], + [7, 3], + [7, 7], + [3, 7], + ]; + const result = subtractPolylines(A, inner); + expect(result).toHaveLength(1); + expect(totalArea(result)).toBeCloseTo(100, 6); // unchanged, NOT 100-16=84 + }); +}); + +describe('mergePolylines (union)', () => { + it('computes the union of two overlapping rectangles', () => { + const result = mergePolylines(A, B); + expect(isClosed([...result, result[0]])).toBe(true); + // Union area = area(A) + area(B) - area(overlap) = 100 + 120 - 50 = 170. + expect(getArea(result)).toBeCloseTo(170, 6); + // Spot checks: a point unique to A, a point unique to B, and a point + // in neither should all resolve correctly against the merged outline. + expect(containsPoint(result, [2, 5])).toBe(true); // only in A + expect(containsPoint(result, [12, 5])).toBe(true); // only in B + expect(containsPoint(result, [50, 50])).toBe(false); // in neither + }); + + it('returns the larger polygon unchanged when one polygon fully contains the other', () => { + const inner = [ + [3, 3], + [7, 3], + [7, 7], + [3, 7], + ]; + const result = mergePolylines(A, inner); + expect(getArea(result)).toBeCloseTo(100, 6); + }); + + it('KNOWN LIMITATION: silently drops the second polygon for disjoint (non-touching, non-overlapping) inputs', () => { + // `mergePolylines` returns a single flat polyline (Types.Point2[]), + // which cannot represent a two-piece union. When the two input + // polygons never intersect and neither contains the other, the + // boundary-walk never has a reason to visit the second polygon at all, + // so the function quietly returns just the first (target) polygon + // instead of raising an error or returning both shapes. Worth knowing + // about for callers that might pass genuinely disjoint contours + // expecting some kind of "combined" representation. + const far = [ + [100, 100], + [110, 100], + [110, 110], + [100, 110], + ]; + const result = mergePolylines(A, far); + expect(getArea(result)).toBeCloseTo(100, 6); // just A; far is dropped + }); +}); + +describe('suspected bug: intersectPolylines/subtractPolylines pairing breaks when overlap corners coincide exactly with existing vertices', () => { + // Two same-height 10x10 squares offset only in x (the "textbook" example + // of overlapping squares) put all four corners of the 5x10 overlap region + // exactly on top of pre-existing vertices/edges of the two input squares. + // When a single polygon edge produces two coincident "intersection" + // records at the same corner (e.g. edge A-bottom crosses both B's bottom + // edge and B's left edge at the exact same point (5,0), since that point + // is simultaneously a vertex of B), the `buildAugmentedList` consolidation + // pass can end up overwriting a *different* corner's `intersectionInfo` + // with the wrong (seg1Idx, seg2Idx) pair while merging the wraparound + // duplicate node. That corrupted pair then fails to find its partner node + // on the other polygon, gets demoted from "intersection" back to a plain + // vertex, and the boundary tracer walks straight through it instead of + // switching polygons - producing a garbled, self-overlapping result whose + // area is far from the geometrically-correct 50. + // + // This is captured as a documented, currently-failing-would-be-correctness + // expectation rather than an assertion on the (wrong) observed output, so + // it doesn't encode buggy behavior as "passing". See the final report for + // the full manual trace of the corrupted (seg1Idx, seg2Idx) pairing. + it('is worth knowing about when both inputs are exactly grid/vertex-aligned', () => { + const sameHeightA = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]; + const sameHeightB = [ + [5, 0], + [15, 0], + [15, 10], + [5, 10], + ]; + const result = intersectPolylines(sameHeightA, sameHeightB); + // Documented current (incorrect) behavior: instead of a single 50-area + // rectangle, a single garbled, self-overlapping ring is produced. If + // this assertion ever starts failing because the area became ~50, the + // underlying bug has likely been fixed - update/remove this test then. + expect(result).toHaveLength(1); + expect(totalArea(result)).not.toBeCloseTo(50, 0); + }); +}); diff --git a/packages/tools/test/utilities/polylineMath.jest.js b/packages/tools/test/utilities/polylineMath.jest.js new file mode 100644 index 0000000000..c8c947717e --- /dev/null +++ b/packages/tools/test/utilities/polylineMath.jest.js @@ -0,0 +1,760 @@ +/** + * Unit tests for the "core" polyline math primitives that underpin freehand + * ROI drawing and contour hit-testing: + * predicates/metrics, segment-intersection helpers, convex hull, decimation, + * 3D projection, and the robust segment intersection helper. + * + * NOTE on mocking: almost every module in `src/utilities/math/polyline` + * imports only `import type { Types } from '@cornerstonejs/core'`, which is + * erased at compile time by the TypeScript babel preset, so those modules + * have zero runtime dependency on `@cornerstonejs/core` and need no mock at + * all. + * + * `addCanvasPointsToArray.ts` is the one exception that truly needs the + * heavy runtime registry: it calls `getEnabledElement()`, which looks up a + * live RenderingEngine/viewport registered via `enable()` - something we do + * not want to spin up (no canvas/WebGL mock is configured for this harness). + * So we mock only `getEnabledElement` below, keeping every other real + * `@cornerstonejs/core` export (this is safe/fast - confirmed empirically + * that importing the real package in jsdom works fine for the pure-math + * helpers used elsewhere in this file, e.g. `utilities.isEqual` and the + * `StackViewport` class reference used for `instanceof` checks). + */ +jest.mock('@cornerstonejs/core', () => { + const actual = jest.requireActual('@cornerstonejs/core'); + return { + ...actual, + getEnabledElement: jest.fn(), + }; +}); + +import { describe, it, expect } from '@jest/globals'; +import { StackViewport, getEnabledElement } from '@cornerstonejs/core'; + +import containsPoint from '../../src/utilities/math/polyline/containsPoint'; +import containsPoints from '../../src/utilities/math/polyline/containsPoints'; +import getArea from '../../src/utilities/math/polyline/getArea'; +import getSignedArea from '../../src/utilities/math/polyline/getSignedArea'; +import getWindingDirection from '../../src/utilities/math/polyline/getWindingDirection'; +import isClosed from '../../src/utilities/math/polyline/isClosed'; +import getAABB from '../../src/utilities/math/polyline/getAABB'; +import getNormal2 from '../../src/utilities/math/polyline/getNormal2'; +import getNormal3 from '../../src/utilities/math/polyline/getNormal3'; +import arePolylinesIdentical from '../../src/utilities/math/polyline/arePolylinesIdentical'; +import getClosestLineSegmentIntersection from '../../src/utilities/math/polyline/getClosestLineSegmentIntersection'; +import areLineSegmentsIntersecting from '../../src/utilities/math/polyline/areLineSegmentsIntersecting'; +import { isPointInsidePolyline3D } from '../../src/utilities/math/polyline/isPointInsidePolyline3D'; +import { projectTo2D } from '../../src/utilities/math/polyline/projectTo2D'; +import convexHull from '../../src/utilities/math/polyline/convexHull'; +import decimate from '../../src/utilities/math/polyline/decimate'; +import getFirstLineSegmentIntersectionIndexes from '../../src/utilities/math/polyline/getFirstLineSegmentIntersectionIndexes'; +import getLineSegmentIntersectionsIndexes from '../../src/utilities/math/polyline/getLineSegmentIntersectionsIndexes'; +import getLineSegmentIntersectionsCoordinates from '../../src/utilities/math/polyline/getLineSegmentIntersectionsCoordinates'; +import intersectPolyline from '../../src/utilities/math/polyline/intersectPolyline'; +import pointCanProjectOnLine from '../../src/utilities/math/polyline/pointCanProjectOnLine'; +import pointsAreWithinCloseContourProximity from '../../src/utilities/math/polyline/pointsAreWithinCloseContourProximity'; +import { + robustSegmentIntersection, + pointsAreEqual, +} from '../../src/utilities/math/polyline/robustSegmentIntersection'; +import getSubPixelSpacingAndXYDirections from '../../src/utilities/math/polyline/getSubPixelSpacingAndXYDirections'; +import addCanvasPointsToArray from '../../src/utilities/math/polyline/addCanvasPointsToArray'; + +// A simple 10x10 axis-aligned square, CCW winding, open representation +// (first point not repeated at the end - the convention used throughout +// this codebase, e.g. `containsPoint`/`getArea`). +const square = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], +]; + +describe('containsPoint', () => { + it('returns true for a point strictly inside a square', () => { + expect(containsPoint(square, [5, 5])).toBe(true); + }); + + it('returns false for a point strictly outside a square', () => { + expect(containsPoint(square, [15, 15])).toBe(false); + }); + + it('returns false for polylines with fewer than 3 points', () => { + expect( + containsPoint( + [ + [0, 0], + [1, 1], + ], + [0, 0] + ) + ).toBe(false); + }); + + // The ray-casting algorithm used here treats the boundary with a + // "half-open" convention (a common trick to avoid double-counting shared + // edges between adjacent polygons): points on the bottom/right edges are + // "inside", but points on the top/left edges (and on vertices) are not. + // This differs from the literal docstring claim ("a point on the polyline + // is considered inside") - see the suspected-bug note in the final report. + it('treats the bottom/right edges as inside, and the top/left edges/vertices as outside', () => { + expect(containsPoint(square, [5, 0])).toBe(true); // bottom edge midpoint + expect(containsPoint(square, [10, 5])).toBe(true); // right edge midpoint + expect(containsPoint(square, [5, 10])).toBe(false); // top edge midpoint + expect(containsPoint(square, [0, 5])).toBe(false); // left edge midpoint + expect(containsPoint(square, [0, 0])).toBe(false); // vertex + expect(containsPoint(square, [10, 10])).toBe(false); // vertex + }); + + it('handles a concave (L-shaped) polygon correctly', () => { + // L-shape: a 10x10 square with the top-right 5x5 quadrant notched out. + const lShape = [ + [0, 0], + [10, 0], + [10, 5], + [5, 5], + [5, 10], + [0, 10], + ]; + expect(containsPoint(lShape, [2, 2])).toBe(true); // in the main body + expect(containsPoint(lShape, [7, 2])).toBe(true); // in the narrow bottom-right arm + expect(containsPoint(lShape, [7, 7])).toBe(false); // inside the notch (removed area) + }); + + it('excludes points that fall inside a hole', () => { + const hole = [ + [3, 3], + [7, 3], + [7, 7], + [3, 7], + ]; + expect(containsPoint(square, [5, 5], { holes: [hole] })).toBe(false); + expect(containsPoint(square, [1, 1], { holes: [hole] })).toBe(true); + }); +}); + +describe('containsPoints', () => { + it('returns true only when every point is inside', () => { + expect( + containsPoints(square, [ + [1, 1], + [2, 2], + ]) + ).toBe(true); + expect( + containsPoints(square, [ + [1, 1], + [20, 20], + ]) + ).toBe(false); + }); +}); + +describe('getArea', () => { + it('computes the area of a 10x10 square (shoelace) as 100', () => { + expect(getArea(square)).toBeCloseTo(100, 10); + }); + + it('is winding-independent (always positive)', () => { + expect(getArea(square.slice().reverse())).toBeCloseTo(100, 10); + }); + + it('computes the area of a right triangle (legs 4 and 3) as 6', () => { + const triangle = [ + [0, 0], + [4, 0], + [0, 3], + ]; + // Area of a right triangle = 0.5 * base * height = 0.5 * 4 * 3 = 6 + expect(getArea(triangle)).toBeCloseTo(6, 10); + }); +}); + +describe('getSignedArea / getWindingDirection', () => { + it('returns a positive signed area and winding of 1 for this CCW-ordered square', () => { + // (0,0) -> (10,0) -> (10,10) -> (0,10) sweeps counter-clockwise in a + // standard (+x right, +y up) plane, giving a positive signed area. + expect(getSignedArea(square)).toBeCloseTo(100, 10); + expect(getWindingDirection(square)).toBe(1); + }); + + it('flips sign/winding when the vertex order is reversed', () => { + const reversed = square.slice().reverse(); + expect(getSignedArea(reversed)).toBeCloseTo(-100, 10); + expect(getWindingDirection(reversed)).toBe(-1); + }); + + it('returns 0 for degenerate polylines with fewer than 3 points', () => { + expect( + getSignedArea([ + [0, 0], + [1, 1], + ]) + ).toBe(0); + }); +}); + +describe('isClosed', () => { + it('returns false for an open polyline', () => { + expect(isClosed(square)).toBe(false); + }); + + it('returns true when the last point repeats the first', () => { + expect(isClosed([...square, square[0]])).toBe(true); + }); + + it('returns false for polylines with fewer than 3 points', () => { + expect( + isClosed([ + [0, 0], + [0, 0], + ]) + ).toBe(false); + }); +}); + +describe('getAABB', () => { + it('computes the bounding box of a 2D polyline', () => { + expect(getAABB(square)).toEqual({ minX: 0, maxX: 10, minY: 0, maxY: 10 }); + }); + + it('computes the bounding box from a flat 2D number array', () => { + expect(getAABB([0, 0, 10, 0, 10, 10, 0, 10])).toEqual({ + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + }); + }); + + it('computes the bounding box of a 3D polyline', () => { + const cube = [ + [0, 0, 0], + [10, 0, 2], + [10, 10, -3], + [0, 10, 7], + ]; + expect(getAABB(cube, { numDimensions: 3 })).toEqual({ + minX: 0, + maxX: 10, + minY: 0, + maxY: 10, + minZ: -3, + maxZ: 7, + }); + }); +}); + +describe('getNormal2 / getNormal3', () => { + it('returns +Z for a CCW 2D polygon and -Z for a CW one', () => { + expect(getNormal2(square)).toEqual([0, 0, 1]); + expect(getNormal2(square.slice().reverse())).toEqual([0, 0, -1]); + }); + + it('returns a unit +Z normal for a planar CCW 3D polygon lying on z=5', () => { + const square3D = square.map((p) => [p[0], p[1], 5]); + const normal = getNormal3(square3D); + expect(normal[0]).toBeCloseTo(0, 5); + expect(normal[1]).toBeCloseTo(0, 5); + expect(normal[2]).toBeCloseTo(1, 5); + }); +}); + +describe('arePolylinesIdentical', () => { + it('returns true for identical point order', () => { + expect(arePolylinesIdentical(square, square.slice())).toBe(true); + }); + + it('returns true for reversed winding (same shape)', () => { + expect(arePolylinesIdentical(square, square.slice().reverse())).toBe(true); + }); + + it('returns true for a cyclic shift of the starting vertex', () => { + const shifted = [square[2], square[3], square[0], square[1]]; + expect(arePolylinesIdentical(square, shifted)).toBe(true); + }); + + it('returns false for a different shape', () => { + const other = [ + [0, 0], + [5, 0], + [5, 5], + [0, 5], + ]; + expect(arePolylinesIdentical(square, other)).toBe(false); + }); + + it('returns false when lengths differ', () => { + expect( + arePolylinesIdentical(square, [ + [0, 0], + [1, 1], + ]) + ).toBe(false); + }); +}); + +describe('areLineSegmentsIntersecting', () => { + it('detects a simple crossing (X shape)', () => { + expect( + areLineSegmentsIntersecting([0, 0], [10, 10], [0, 10], [10, 0]) + ).toBe(true); + }); + + it('detects touching at a shared endpoint', () => { + expect( + areLineSegmentsIntersecting([0, 0], [10, 10], [10, 10], [20, 0]) + ).toBe(true); + }); + + it('detects collinear overlapping segments', () => { + expect(areLineSegmentsIntersecting([0, 0], [10, 0], [5, 0], [15, 0])).toBe( + true + ); + }); + + it('returns false for parallel, non-overlapping (disjoint) segments', () => { + expect(areLineSegmentsIntersecting([0, 0], [10, 0], [0, 1], [10, 1])).toBe( + false + ); + }); +}); + +describe('getClosestLineSegmentIntersection', () => { + it('returns the closest intersecting edge to the ray start point', () => { + // Horizontal ray from x=-5 to x=15 at y=5 crosses the square's left + // edge (x=0) and right edge (x=10). The left edge's segment endpoints + // (0,10)/(0,0) have midpoint (0,5), which is 5 units from p1=(-5,5); + // the right edge's endpoints (10,0)/(10,10) have midpoint (10,5), 15 + // units away. So the left edge (indexes [3, 0]) should win. + const result = getClosestLineSegmentIntersection(square, [-5, 5], [15, 5]); + expect(result.segment).toEqual([3, 0]); + expect(result.distance).toBeCloseTo(5, 10); + }); + + it('returns undefined when there is no intersection', () => { + expect( + getClosestLineSegmentIntersection(square, [100, 100], [200, 200]) + ).toBeUndefined(); + }); +}); + +describe('isPointInsidePolyline3D', () => { + const poly3D = square.map((p) => [p[0], p[1], 5]); + + it('returns true for a point inside a planar 3D polygon', () => { + expect(isPointInsidePolyline3D([5, 5, 5], poly3D)).toBe(true); + }); + + it('returns false for a point outside a planar 3D polygon', () => { + expect(isPointInsidePolyline3D([15, 15, 5], poly3D)).toBe(false); + }); + + it('supports holes', () => { + const holePoly3D = [ + [3, 3, 5], + [7, 3, 5], + [7, 7, 5], + [3, 7, 5], + ]; + expect( + isPointInsidePolyline3D([5, 5, 5], poly3D, { holes: [holePoly3D] }) + ).toBe(false); + }); +}); + +describe('convexHull', () => { + it('drops interior points, keeping only the hull vertices', () => { + // A square plus a point right in the middle - the hull should be just + // the square, in CCW order starting at the leftmost point. + const pts = [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [5, 5], + ]; + expect(convexHull(pts)).toEqual([ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]); + }); + + it('reduces collinear points down to the two extremes', () => { + const collinear = [ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]; + expect(convexHull(collinear)).toEqual([ + [0, 0], + [3, 0], + ]); + }); + + it('returns the input as-is for fewer than 3 points', () => { + const pts = [ + [0, 0], + [1, 1], + ]; + expect(convexHull(pts)).toEqual(pts); + }); +}); + +describe('decimate', () => { + it('preserves start/end points and drops points within epsilon of the simplified line', () => { + // Points (1, 0.01) and (2, 0) are near-collinear with the (0,0)-(4,0) + // baseline (distances 0.01 and 0 respectively, both << default epsilon + // 0.1), while (3, 5) is far away (distance 5) and must be kept as the + // corner of the "spike". After (3,5) becomes a partition pivot, (2,0) + // is still >0.1 from the (0,0)-(3,5) line (~1.72 away) so it survives, + // but (1, 0.01) collapses onto the (0,0)-(2,0) sub-baseline (distance + // 0.01 < epsilon) and gets dropped. + const poly = [ + [0, 0], + [1, 0.01], + [2, 0], + [3, 5], + [4, 0], + ]; + expect(decimate(poly)).toEqual([ + [0, 0], + [2, 0], + [3, 5], + [4, 0], + ]); + }); + + it('collapses everything between the endpoints when epsilon is large', () => { + const poly = [ + [0, 0], + [1, 0.01], + [2, 0], + [3, 5], + [4, 0], + ]; + // With epsilon=10 even the (3,5) spike (distance 5 from the baseline) + // is within tolerance, so only the two endpoints remain. + expect(decimate(poly, 10)).toEqual([ + [0, 0], + [4, 0], + ]); + }); + + it('returns the input unchanged when it has fewer than 3 points', () => { + const poly = [ + [0, 0], + [1, 1], + ]; + expect(decimate(poly)).toBe(poly); + }); +}); + +describe('getFirstLineSegmentIntersectionIndexes / getLineSegmentIntersectionsIndexes / getLineSegmentIntersectionsCoordinates', () => { + // Horizontal ray from (-5,5) to (15,5) crosses both the left edge + // (index 3->0, x=0) and the right edge (index 1->2, x=10) of the square. + const p1 = [-5, 5]; + const q1 = [15, 5]; + + it('getFirstLineSegmentIntersectionIndexes returns the first crossing found (closed segment [3,0] checked first)', () => { + expect(getFirstLineSegmentIntersectionIndexes(square, p1, q1)).toEqual([ + 3, 0, + ]); + }); + + it('getFirstLineSegmentIntersectionIndexes returns undefined when there is no crossing', () => { + expect( + getFirstLineSegmentIntersectionIndexes(square, [100, 100], [200, 200]) + ).toBeUndefined(); + }); + + it('getLineSegmentIntersectionsIndexes returns every crossing edge, in edge-order', () => { + expect(getLineSegmentIntersectionsIndexes(square, p1, q1)).toEqual([ + [1, 2], + [3, 0], + ]); + }); + + it('getLineSegmentIntersectionsCoordinates returns the actual intersection coordinates', () => { + expect(getLineSegmentIntersectionsCoordinates(square, p1, q1)).toEqual([ + [10, 5], + [0, 5], + ]); + }); + + it('respects closed=false by skipping the closing (last-to-first) segment', () => { + // With closed=false, the polyline is treated as open, so segment + // [3 -> 0] (the closing edge) is never tested; only the left edge + // itself is missing here because index 3 has no "next" segment when + // open, so only the right-edge crossing remains. + expect(getLineSegmentIntersectionsIndexes(square, p1, q1, false)).toEqual([ + [1, 2], + ]); + }); +}); + +describe('intersectPolyline (existence check between two polylines)', () => { + it('returns true when two polylines cross', () => { + const crossingSquare = [ + [5, 5], + [15, 5], + [15, 15], + [5, 15], + ]; + expect(intersectPolyline(square, crossingSquare)).toBe(true); + }); + + it('returns false for disjoint polylines', () => { + const farSquare = [ + [100, 100], + [110, 100], + [110, 110], + [100, 110], + ]; + expect(intersectPolyline(square, farSquare)).toBe(false); + }); +}); + +describe('pointCanProjectOnLine', () => { + const p1 = [0, 0]; + const p2 = [10, 0]; + + it('returns true when the projection falls on the segment within proximity', () => { + expect(pointCanProjectOnLine([5, 1], p1, p2, 2)).toBe(true); + }); + + it('returns false when the projection is on the segment but too far away', () => { + expect(pointCanProjectOnLine([5, 5], p1, p2, 2)).toBe(false); + }); + + it('returns false when the projection falls beyond the segment end', () => { + expect(pointCanProjectOnLine([15, 0], p1, p2, 2)).toBe(false); + }); + + it('returns false when the point is behind the segment start (negative dot product)', () => { + expect(pointCanProjectOnLine([-5, 0], p1, p2, 2)).toBe(false); + }); +}); + +describe('pointsAreWithinCloseContourProximity', () => { + it('returns true when points are closer than the given proximity', () => { + expect(pointsAreWithinCloseContourProximity([0, 0], [1, 0], 2)).toBe(true); + }); + + it('returns false when points are farther than the given proximity', () => { + expect(pointsAreWithinCloseContourProximity([0, 0], [5, 0], 2)).toBe(false); + }); +}); + +describe('robustSegmentIntersection / pointsAreEqual', () => { + it('finds a simple crossing intersection', () => { + expect( + robustSegmentIntersection([0, 0], [10, 10], [0, 10], [10, 0]) + ).toEqual([5, 5]); + }); + + it('returns null for parallel, disjoint segments', () => { + expect( + robustSegmentIntersection([0, 0], [10, 0], [0, 1], [10, 1]) + ).toBeNull(); + }); + + it('returns a point for collinear overlapping segments', () => { + expect(robustSegmentIntersection([0, 0], [10, 0], [5, 0], [15, 0])).toEqual( + [5, 0] + ); + }); + + it('returns null for collinear, non-overlapping segments', () => { + expect( + robustSegmentIntersection([0, 0], [10, 0], [20, 0], [30, 0]) + ).toBeNull(); + }); + + it('resolves a T-junction where an endpoint touches the interior of the other segment', () => { + expect(robustSegmentIntersection([0, 0], [10, 0], [5, -5], [5, 0])).toEqual( + [5, 0] + ); + }); + + it('does NOT resolve a fully-degenerate (zero-length) segment lying on the interior of another segment', () => { + // This is a real edge case in the implementation: the degenerate branch + // only special-cases a zero-length segment when it coincides exactly + // with one of the OTHER segment's *endpoints* - not when it lies + // somewhere along the other segment's interior. Documented here rather + // than treated as a hard bug, since zero-length input segments are an + // unusual/degenerate case in practice. + expect( + robustSegmentIntersection([5, 5], [5, 5], [0, 0], [10, 10]) + ).toBeNull(); + }); + + it('pointsAreEqual uses an epsilon tolerance', () => { + expect(pointsAreEqual([1, 1], [1, 1])).toBe(true); + expect(pointsAreEqual([1, 1], [1.01, 1])).toBe(false); + }); +}); + +describe('projectTo2D', () => { + it('projects a polygon planar on z, dropping the z dimension', () => { + const polyZ = [ + [0, 0, 5], + [10, 0, 5], + [10, 10, 5], + [0, 10, 5], + ]; + const { sharedDimensionIndex, projectedPolyline } = projectTo2D(polyZ); + expect(sharedDimensionIndex).toBe(2); + expect(projectedPolyline).toEqual([ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]); + }); + + it('projects a polygon planar on x, keeping (y, z)', () => { + const polyX = [ + [3, 0, 0], + [3, 10, 0], + [3, 10, 10], + [3, 0, 10], + ]; + const { sharedDimensionIndex, projectedPolyline } = projectTo2D(polyX); + expect(sharedDimensionIndex).toBe(0); + expect(projectedPolyline).toEqual([ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ]); + }); + + it('throws when no shared dimension can be found (oblique plane)', () => { + const oblique = [ + [0, 0, 0], + [1, 1, 1], + [2, 0, 3], + ]; + expect(() => projectTo2D(oblique)).toThrow(); + }); +}); + +describe('getSubPixelSpacingAndXYDirections', () => { + // `isGenericViewport`/`getViewportContentMode` are duck-typed (they only + // check for method presence), and `StackViewport` is only used for an + // `instanceof` check - so plain stub objects work without needing to + // mock `@cornerstonejs/core` for this function specifically. + + it('reads spacing/direction straight off the image data for a stack (image-slice) viewport', () => { + const viewport = Object.create(StackViewport.prototype); + viewport.getImageData = () => ({ + // direction is a 9-element row-major 3x3 matrix: rows are iVector, + // jVector, kVector. + direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], + spacing: [0.5, 0.25], + }); + + const result = getSubPixelSpacingAndXYDirections(viewport, 10); + + expect(viewport instanceof StackViewport).toBe(true); + expect(result.xDir).toEqual([1, 0, 0]); + expect(result.yDir).toEqual([0, 1, 0]); + // subPixelResolution divides the native spacing: 0.5/10 and 0.25/10. + expect(result.spacing[0]).toBeCloseTo(0.05, 10); + expect(result.spacing[1]).toBeCloseTo(0.025, 10); + }); + + it('derives spacing/direction from the camera for a volume viewport (axial view)', () => { + // Identity direction matrix (i=x, j=y, k=z), spacing [1,1,2] (mm per + // voxel along i,j,k). Axial view: normal along +k, up along -y. + const viewport = { + getImageData: () => ({ + direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], + spacing: [1, 1, 2], + }), + getCamera: () => ({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, -1, 0], + }), + }; + + const result = getSubPixelSpacingAndXYDirections(viewport, 10); + + // viewRight = cross(viewUp, viewPlaneNormal) = cross([0,-1,0],[0,0,1]) + // = [-1, 0, 0], which is parallel to the i (x) direction -> xSpacing + // comes from volumeSpacing[0] = 1; viewUp=[0,-1,0] is parallel to the + // j (y) direction -> ySpacing comes from volumeSpacing[1] = 1. + expect(result.xDir).toEqual([1, 0, 0]); + expect(result.yDir).toEqual([0, 1, 0]); + expect(result.spacing[0]).toBeCloseTo(0.1, 10); + expect(result.spacing[1]).toBeCloseTo(0.1, 10); + }); +}); + +describe('addCanvasPointsToArray (light coverage - heavy viewport dependency mocked)', () => { + const identityCanvasToWorld = (p) => [p[0], p[1], 0]; + + beforeEach(() => { + getEnabledElement.mockReset(); + }); + + it('pushes the point directly when the array is empty', () => { + getEnabledElement.mockReturnValue({ + viewport: { canvasToWorld: identityCanvasToWorld }, + }); + const canvasPoints = []; + const numAdded = addCanvasPointsToArray( + document.createElement('div'), + canvasPoints, + [5, 5], + { xDir: [1, 0, 0], yDir: [0, 1, 0], spacing: [1, 1] } + ); + expect(numAdded).toBe(1); + expect(canvasPoints).toEqual([[5, 5]]); + }); + + it('does not interpolate when the new point is within one sub-pixel spacing unit', () => { + getEnabledElement.mockReturnValue({ + viewport: { canvasToWorld: identityCanvasToWorld }, + }); + const canvasPoints = [[0, 0]]; + const numAdded = addCanvasPointsToArray( + document.createElement('div'), + canvasPoints, + [0.5, 0], + { xDir: [1, 0, 0], yDir: [0, 1, 0], spacing: [1, 1] } + ); + expect(numAdded).toBe(0); + expect(canvasPoints).toEqual([ + [0, 0], + [0.5, 0], + ]); + }); + + it('interpolates intermediate points when the gap exceeds the sub-pixel spacing', () => { + getEnabledElement.mockReturnValue({ + viewport: { canvasToWorld: identityCanvasToWorld }, + }); + const canvasPoints = [[0, 0]]; + // World distance in x is 5, spacing[0] is 1 -> numPointsToAdd = floor(5/1) = 5. + const numAdded = addCanvasPointsToArray( + document.createElement('div'), + canvasPoints, + [5, 0], + { xDir: [1, 0, 0], yDir: [0, 1, 0], spacing: [1, 1] } + ); + expect(numAdded).toBe(5); + // Original point + 5 evenly-spaced interpolated points ending exactly + // at the requested new canvas point. + expect(canvasPoints).toHaveLength(6); + expect(canvasPoints[0]).toEqual([0, 0]); + expect(canvasPoints[canvasPoints.length - 1]).toEqual([5, 0]); + for (let i = 1; i < canvasPoints.length; i++) { + expect(canvasPoints[i][0] - canvasPoints[i - 1][0]).toBeCloseTo(1, 10); + } + }); +}); diff --git a/packages/tools/test/utilities/viewportFilters.jest.js b/packages/tools/test/utilities/viewportFilters.jest.js new file mode 100644 index 0000000000..00d8046f3a --- /dev/null +++ b/packages/tools/test/utilities/viewportFilters.jest.js @@ -0,0 +1,411 @@ +jest.mock('@cornerstonejs/core', () => ({ + getEnabledElement: jest.fn(), + utilities: { + isGenericViewport: jest.fn(() => false), + clonePoint3: (point) => + point ? [point[0], point[1], point[2]] : undefined, + isEqual: (v1, v2, tolerance = 1e-5) => { + if (!v1 || !v2 || v1.length !== v2.length) { + return false; + } + for (let i = 0; i < v1.length; i++) { + if (Math.abs(v1[i] - v2[i]) > tolerance) { + return false; + } + } + return true; + }, + }, +})); + +jest.mock('../../src/store/ToolGroupManager', () => ({ + getToolGroupForViewport: jest.fn(), +})); + +import { getEnabledElement } from '@cornerstonejs/core'; +import { getToolGroupForViewport } from '../../src/store/ToolGroupManager'; + +import filterViewportsWithFrameOfReferenceUID from '../../src/utilities/viewportFilters/filterViewportsWithFrameOfReferenceUID'; +import filterViewportsWithSameOrientation from '../../src/utilities/viewportFilters/filterViewportsWithSameOrientation'; +import filterViewportsWithParallelNormals from '../../src/utilities/viewportFilters/filterViewportsWithParallelNormals'; +import filterViewportsWithToolEnabled from '../../src/utilities/viewportFilters/filterViewportsWithToolEnabled'; +import getViewportIdsWithToolToRender from '../../src/utilities/viewportFilters/getViewportIdsWithToolToRender'; + +function createViewport({ + id = 'vp', + renderingEngineId = 're1', + frameOfReferenceUID = 'FOR1', + viewPlaneNormal = [0, 0, 1], + viewUp = [0, 1, 0], + focalPoint = [0, 0, 0], +} = {}) { + return { + id, + renderingEngineId, + getFrameOfReferenceUID: jest.fn(() => frameOfReferenceUID), + getViewReference: jest.fn(() => ({ + cameraFocalPoint: focalPoint, + viewPlaneNormal, + viewUp, + })), + }; +} + +describe('filterViewportsWithFrameOfReferenceUID', () => { + it('keeps only viewports with a matching FrameOfReferenceUID', () => { + const match1 = createViewport({ id: 'a', frameOfReferenceUID: 'FOR1' }); + const match2 = createViewport({ id: 'b', frameOfReferenceUID: 'FOR1' }); + const other = createViewport({ id: 'c', frameOfReferenceUID: 'FOR2' }); + + const result = filterViewportsWithFrameOfReferenceUID( + [match1, other, match2], + 'FOR1' + ); + + expect(result).toEqual([match1, match2]); + }); + + it('returns an empty array when nothing matches', () => { + const other = createViewport({ frameOfReferenceUID: 'FOR2' }); + expect(filterViewportsWithFrameOfReferenceUID([other], 'FOR1')).toEqual([]); + }); + + it('returns an empty array for an empty input list', () => { + expect(filterViewportsWithFrameOfReferenceUID([], 'FOR1')).toEqual([]); + }); +}); + +describe('filterViewportsWithParallelNormals', () => { + it('keeps viewports with the same normal', () => { + const ref = createViewport({ viewPlaneNormal: [0, 0, 1] }); + const same = createViewport({ id: 'same', viewPlaneNormal: [0, 0, 1] }); + + expect(filterViewportsWithParallelNormals([same], ref)).toEqual([same]); + }); + + it('keeps viewports with an antiparallel (opposite-sign) normal', () => { + const ref = createViewport({ viewPlaneNormal: [0, 0, 1] }); + const opposite = createViewport({ id: 'opp', viewPlaneNormal: [0, 0, -1] }); + + expect(filterViewportsWithParallelNormals([opposite], ref)).toEqual([ + opposite, + ]); + }); + + it('excludes perpendicular normals', () => { + const ref = createViewport({ viewPlaneNormal: [0, 0, 1] }); + const perp = createViewport({ id: 'perp', viewPlaneNormal: [1, 0, 0] }); + + expect(filterViewportsWithParallelNormals([perp], ref)).toEqual([]); + }); + + it('keeps normals within the default tolerance and excludes normals beyond it', () => { + const ref = createViewport({ viewPlaneNormal: [0, 0, 1] }); + + // ~2 degree tilt: dot ~ 0.9994, which is above the default EPS of 0.999 + const closeAngle = (2 * Math.PI) / 180; + const close = createViewport({ + id: 'close', + viewPlaneNormal: [0, Math.sin(closeAngle), Math.cos(closeAngle)], + }); + + // ~5 degree tilt: dot ~ 0.9962, which is below the default EPS of 0.999 + const farAngle = (5 * Math.PI) / 180; + const far = createViewport({ + id: 'far', + viewPlaneNormal: [0, Math.sin(farAngle), Math.cos(farAngle)], + }); + + expect(filterViewportsWithParallelNormals([close], ref)).toEqual([close]); + expect(filterViewportsWithParallelNormals([far], ref)).toEqual([]); + }); + + it('returns an empty array when the reference viewport has no normal', () => { + const ref = { getViewReference: jest.fn(() => ({})) }; + const other = createViewport(); + + expect(filterViewportsWithParallelNormals([other], ref)).toEqual([]); + }); + + it('excludes candidate viewports that have no normal', () => { + const ref = createViewport({ viewPlaneNormal: [0, 0, 1] }); + const noNormal = { getViewReference: jest.fn(() => ({})) }; + + expect(filterViewportsWithParallelNormals([noNormal], ref)).toEqual([]); + }); +}); + +describe('filterViewportsWithSameOrientation', () => { + it('keeps viewports with a matching normal and viewUp', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const same = createViewport({ + id: 'same', + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + + expect(filterViewportsWithSameOrientation([same], ref)).toEqual([same]); + }); + + it('excludes an antiparallel normal even though it is "parallel"', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const opposite = createViewport({ + id: 'opp', + viewPlaneNormal: [0, 0, -1], + viewUp: [0, 1, 0], + }); + + expect(filterViewportsWithSameOrientation([opposite], ref)).toEqual([]); + }); + + it('excludes perpendicular normals', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const perp = createViewport({ + id: 'perp', + viewPlaneNormal: [1, 0, 0], + viewUp: [0, 1, 0], + }); + + expect(filterViewportsWithSameOrientation([perp], ref)).toEqual([]); + }); + + it('keeps normals within the isEqual epsilon tolerance', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const tiny = createViewport({ + id: 'tiny', + viewPlaneNormal: [0, 0, 1 + 1e-7], + viewUp: [0, 1, 0], + }); + + expect(filterViewportsWithSameOrientation([tiny], ref)).toEqual([tiny]); + }); + + it('excludes normals beyond the isEqual epsilon tolerance', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const off = createViewport({ + id: 'off', + viewPlaneNormal: [0, 0.01, 0.99995], + viewUp: [0, 1, 0], + }); + + expect(filterViewportsWithSameOrientation([off], ref)).toEqual([]); + }); + + it('excludes a matching normal with a different viewUp', () => { + const ref = createViewport({ + viewPlaneNormal: [0, 0, 1], + viewUp: [0, 1, 0], + }); + const differentUp = createViewport({ + id: 'diff-up', + viewPlaneNormal: [0, 0, 1], + viewUp: [1, 0, 0], + }); + + expect(filterViewportsWithSameOrientation([differentUp], ref)).toEqual([]); + }); + + it('returns an empty array when the reference has no orientation', () => { + const ref = { getViewReference: jest.fn(() => ({})) }; + + expect(filterViewportsWithSameOrientation([createViewport()], ref)).toEqual( + [] + ); + }); +}); + +describe('filterViewportsWithToolEnabled', () => { + beforeEach(() => { + getToolGroupForViewport.mockReset(); + }); + + it('keeps viewports whose tool group has the tool Active, Passive, or Enabled', () => { + const active = createViewport({ id: 'active' }); + const passive = createViewport({ id: 'passive' }); + const enabled = createViewport({ id: 'enabled' }); + const disabled = createViewport({ id: 'disabled' }); + const noGroup = createViewport({ id: 'no-group' }); + const noTool = createViewport({ id: 'no-tool' }); + + getToolGroupForViewport.mockImplementation((viewportId) => { + switch (viewportId) { + case 'active': + return { toolOptions: { MyTool: { mode: 'Active' } } }; + case 'passive': + return { toolOptions: { MyTool: { mode: 'Passive' } } }; + case 'enabled': + return { toolOptions: { MyTool: { mode: 'Enabled' } } }; + case 'disabled': + return { toolOptions: { MyTool: { mode: 'Disabled' } } }; + case 'no-tool': + return { toolOptions: {} }; + default: + return undefined; + } + }); + + const result = filterViewportsWithToolEnabled( + [active, passive, enabled, disabled, noGroup, noTool], + 'MyTool' + ); + + expect(result).toEqual([active, passive, enabled]); + expect(getToolGroupForViewport).toHaveBeenCalledWith('active', 're1'); + }); + + it('returns an empty array when no viewport has a tool group', () => { + getToolGroupForViewport.mockReturnValue(undefined); + const vp = createViewport(); + + expect(filterViewportsWithToolEnabled([vp], 'MyTool')).toEqual([]); + }); +}); + +describe('getViewportIdsWithToolToRender', () => { + beforeEach(() => { + getToolGroupForViewport.mockReset(); + getEnabledElement.mockReset(); + }); + + it('composes the frame-of-reference, tool-enabled, and parallel-normal filters', () => { + const current = createViewport({ + id: 'current', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [0, 0, 1], + }); + const parallelWithTool = createViewport({ + id: 'parallel-tool', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [0, 0, 1], + }); + const perpendicularWithTool = createViewport({ + id: 'perp-tool', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [1, 0, 0], + }); + const parallelWithoutTool = createViewport({ + id: 'parallel-no-tool', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [0, 0, 1], + }); + const otherFrame = createViewport({ + id: 'other-frame', + frameOfReferenceUID: 'FOR2', + viewPlaneNormal: [0, 0, 1], + }); + + const allViewports = [ + current, + parallelWithTool, + perpendicularWithTool, + parallelWithoutTool, + otherFrame, + ]; + + const renderingEngine = { + getViewports: jest.fn(() => allViewports), + getViewport: jest.fn((id) => allViewports.find((vp) => vp.id === id)), + }; + + getEnabledElement.mockReturnValue({ + renderingEngine, + FrameOfReferenceUID: 'FOR1', + viewportId: 'current', + }); + + getToolGroupForViewport.mockImplementation((viewportId) => { + const withTool = ['current', 'parallel-tool', 'perp-tool']; + return withTool.includes(viewportId) + ? { toolOptions: { MyTool: { mode: 'Active' } } } + : undefined; + }); + + const element = document.createElement('div'); + const result = getViewportIdsWithToolToRender(element, 'MyTool'); + + expect(result).toEqual(['current', 'parallel-tool']); + }); + + it('skips the parallel-normal filter when requireParallelNormals is false', () => { + const current = createViewport({ + id: 'current', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [0, 0, 1], + }); + const perpendicularWithTool = createViewport({ + id: 'perp-tool', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [1, 0, 0], + }); + const allViewports = [current, perpendicularWithTool]; + + const renderingEngine = { + getViewports: jest.fn(() => allViewports), + getViewport: jest.fn((id) => allViewports.find((vp) => vp.id === id)), + }; + + getEnabledElement.mockReturnValue({ + renderingEngine, + FrameOfReferenceUID: 'FOR1', + viewportId: 'current', + }); + + getToolGroupForViewport.mockReturnValue({ + toolOptions: { MyTool: { mode: 'Active' } }, + }); + + const element = document.createElement('div'); + const result = getViewportIdsWithToolToRender(element, 'MyTool', false); + + expect(result).toEqual(['current', 'perp-tool']); + }); + + it('excludes viewports outside the frame of reference even with the tool enabled', () => { + const current = createViewport({ + id: 'current', + frameOfReferenceUID: 'FOR1', + viewPlaneNormal: [0, 0, 1], + }); + const otherFrame = createViewport({ + id: 'other-frame', + frameOfReferenceUID: 'FOR2', + viewPlaneNormal: [0, 0, 1], + }); + const allViewports = [current, otherFrame]; + + const renderingEngine = { + getViewports: jest.fn(() => allViewports), + getViewport: jest.fn((id) => allViewports.find((vp) => vp.id === id)), + }; + + getEnabledElement.mockReturnValue({ + renderingEngine, + FrameOfReferenceUID: 'FOR1', + viewportId: 'current', + }); + + getToolGroupForViewport.mockReturnValue({ + toolOptions: { MyTool: { mode: 'Active' } }, + }); + + const element = document.createElement('div'); + const result = getViewportIdsWithToolToRender(element, 'MyTool'); + + expect(result).toEqual(['current']); + }); +});