diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index cde160b19..443b4ce9a 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -3,7 +3,7 @@ import { classes, Types } from '@ohif/core'; import { cache, metaData } from '@cornerstonejs/core'; import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools'; import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters'; -import { createReportDialogPrompt } from '@ohif/extension-default'; +import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default'; import { DicomMetadataStore } from '@ohif/core'; import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES'; @@ -37,7 +37,7 @@ const commandsModule = ({ servicesManager, extensionManager, }: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => { - const { segmentationService, displaySetService, viewportGridService, toolGroupService } = + const { segmentationService, displaySetService, viewportGridService } = servicesManager.services as AppTypes.Services; const actions = { @@ -307,6 +307,17 @@ const commandsModule = ({ console.warn(e); } }, + toggleActiveSegmentationUtility: ({ itemId: buttonId }) => { + const { uiState, setUIState } = useUIStateStore.getState(); + const isButtonActive = uiState['activeSegmentationUtility'] === buttonId; + console.log('toggleActiveSegmentationUtility', isButtonActive, buttonId); + // if the button is active, clear the active segmentation utility + if (isButtonActive) { + setUIState('activeSegmentationUtility', null); + } else { + setUIState('activeSegmentationUtility', buttonId); + } + }, }; const definitions = { @@ -326,6 +337,9 @@ const commandsModule = ({ downloadRTSS: { commandFn: actions.downloadRTSS, }, + toggleActiveSegmentationUtility: { + commandFn: actions.toggleActiveSegmentationUtility, + }, }; return { diff --git a/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx b/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx new file mode 100644 index 000000000..33a7b7cc3 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useRunCommand, useSystem } from '@ohif/core'; +import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone'; +import { + Button, + cn, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Switch, + Tabs, + TabsList, + TabsTrigger, +} from '@ohif/ui-next'; +import { Icons } from '@ohif/ui-next'; +import { contourSegmentation } from '@cornerstonejs/tools/utilities'; +import { Segment } from '@cornerstonejs/tools/types'; + +const { LogicalOperation } = contourSegmentation; +const options = [ + { + value: 'merge', + logicalOperation: LogicalOperation.Union, + label: 'Merge', + icon: 'actions-combine-merge', + helperIcon: 'helper-combine-merge', + }, + { + value: 'intersect', + logicalOperation: LogicalOperation.Intersect, + label: 'Intersect', + icon: 'actions-combine-intersect', + helperIcon: 'helper-combine-intersect', + }, + { + value: 'subtract', + logicalOperation: LogicalOperation.Subtract, + label: 'Subtract', + icon: 'actions-combine-subtract', + helperIcon: 'helper-combine-subtract', + }, +]; + +// Shared component for segment selection +function SegmentSelector({ + label, + value, + onValueChange, + segments, + placeholder = 'Select a segment', +}: { + label: string; + value: string; + onValueChange: (value: string) => void; + segments: Segment[]; + placeholder?: string; +}) { + return ( +
+
{label}
+ +
+ ); +} + +function LogicalContourOperationOptions() { + const { servicesManager } = useSystem(); + const { segmentationService } = servicesManager.services; + const { segmentationsWithRepresentations } = useActiveViewportSegmentationRepresentations(); + + const activeRepresentation = segmentationsWithRepresentations?.find( + ({ representation }) => representation?.active + ); + + const segments = activeRepresentation + ? Object.values(activeRepresentation.segmentation.segments) + : []; + + // Calculate the next available segment index + const nextSegmentIndex = activeRepresentation + ? segmentationService.getNextAvailableSegmentIndex( + activeRepresentation.segmentation.segmentationId + ) + : 1; + + const activeSegment = segments.find(segment => segment.active); + + const activeSegmentIndex = activeSegment?.segmentIndex || 0; + + const [operation, setOperation] = useState(options[0]); + const [segmentA, setSegmentA] = useState(activeSegmentIndex?.toString() || ''); + const [segmentB, setSegmentB] = useState(''); + const [createNewSegment, setCreateNewSegment] = useState(false); + const [newSegmentName, setNewSegmentName] = useState(''); + + useEffect(() => { + setSegmentA(activeSegmentIndex?.toString() || null); + }, [activeSegmentIndex]); + + useEffect(() => { + setNewSegmentName(`Segment ${nextSegmentIndex}`); + }, [nextSegmentIndex]); + + const runCommand = useRunCommand(); + + const applyLogicalContourOperation = useCallback(() => { + let resultSegmentIndex = segmentA; + if (createNewSegment) { + resultSegmentIndex = nextSegmentIndex.toString(); + runCommand('addSegment', { + segmentationId: activeRepresentation.segmentation.segmentationId, + config: { + label: newSegmentName, + segmentIndex: nextSegmentIndex, + }, + }); + } + runCommand('applyLogicalContourOperation', { + segmentAInfo: { + segmentationId: activeRepresentation.segmentation.segmentationId, + segmentIndex: parseInt(segmentA), + }, + segmentBInfo: { + segmentationId: activeRepresentation.segmentation.segmentationId, + segmentIndex: parseInt(segmentB), + }, + resultSegmentInfo: { + segmentationId: activeRepresentation.segmentation.segmentationId, + segmentIndex: parseInt(resultSegmentIndex), + }, + logicalOperation: operation.logicalOperation, + }); + }, [ + activeRepresentation?.segmentation?.segmentationId, + createNewSegment, + newSegmentName, + nextSegmentIndex, + operation.logicalOperation, + runCommand, + segmentA, + segmentB, + ]); + + return ( +
+
+
+ + + {options.map(option => { + const { value, icon } = option; + return ( + setOperation(option)} + > + + + ); + })} + + +
{operation.label}
+
+
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ setNewSegmentName(e.target.value)} + /> +
+
+
+ ); +} + +export default LogicalContourOperationOptions; diff --git a/extensions/cornerstone-dicom-seg/src/components/SimplifyContourOptions.tsx b/extensions/cornerstone-dicom-seg/src/components/SimplifyContourOptions.tsx new file mode 100644 index 000000000..db17eafd8 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/components/SimplifyContourOptions.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { Button, Input, Label, Separator } from '@ohif/ui-next'; +import { useRunCommand } from '@ohif/core'; + +function SimplifyContourOptions() { + const [areaThreshold, setAreaThreshold] = useState(10); + + const runCommand = useRunCommand(); + + return ( +
+
+
Fill contour holes
+ + +
+
+
Remove Small Contours
+
+ + setAreaThreshold(Number(e.target.value))} + /> +
+ + +
+
+
Create New Segment from Holes
+ +
+
+ ); +} + +export default SimplifyContourOptions; diff --git a/extensions/cornerstone-dicom-seg/src/components/SmoothContoursOptions.tsx b/extensions/cornerstone-dicom-seg/src/components/SmoothContoursOptions.tsx new file mode 100644 index 000000000..5ff10be8e --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/components/SmoothContoursOptions.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Button, Separator } from '@ohif/ui-next'; +import { useRunCommand } from '@ohif/core'; + +function SmoothContoursOptions() { + const runCommand = useRunCommand(); + + return ( +
+
+
Smooth all edges
+ + +
+
+
Remove extra points
+ +
+
+ ); +} + +export default SmoothContoursOptions; diff --git a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts index 8c023b49f..35a576f29 100644 --- a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts +++ b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts @@ -1,6 +1,32 @@ +import { useUIStateStore } from '@ohif/extension-default'; +import LogicalContourOperationsOptions from './components/LogicalContourOperationsOptions'; +import SimplifyContourOptions from './components/SimplifyContourOptions'; +import SmoothContoursOptions from './components/SmoothContoursOptions'; + export function getToolbarModule({ servicesManager }: withAppTypes) { const { segmentationService, toolbarService, toolGroupService } = servicesManager.services; return [ + { + name: 'cornerstone.SimplifyContourOptions', + defaultComponent: SimplifyContourOptions, + }, + { + name: 'cornerstone.LogicalContourOperationsOptions', + defaultComponent: LogicalContourOperationsOptions, + }, + { + name: 'cornerstone.SmoothContoursOptions', + defaultComponent: SmoothContoursOptions, + }, + { + name: 'cornerstone.isActiveSegmentationUtility', + evaluate: ({ button }) => { + const { uiState } = useUIStateStore.getState(); + return { + isActive: uiState[`activeSegmentationUtility`] === button.id, + }; + }, + }, { name: 'evaluate.cornerstone.hasSegmentation', evaluate: ({ viewportId }) => { @@ -10,6 +36,30 @@ export function getToolbarModule({ servicesManager }: withAppTypes) { }; }, }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + evaluate: ({ viewportId, segmentationRepresentationType }) => { + const segmentations = segmentationService.getSegmentationRepresentations(viewportId); + + if (!segmentations?.length) { + return { + disabled: true, + disabledText: 'No segmentations available', + }; + } + + if ( + !segmentations.some(segmentation => + Boolean(segmentation.type === segmentationRepresentationType) + ) + ) { + return { + disabled: true, + disabledText: `No ${segmentationRepresentationType} segmentations available`, + }; + } + }, + }, { name: 'evaluate.cornerstone.segmentation', evaluate: ({ viewportId, button, toolNames, disabledText }) => { diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 5006535d1..61c0679f7 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -14,13 +14,18 @@ import { utilities as cstUtils, annotation, Types as ToolTypes, + SplineContourSegmentationTool, } from '@cornerstonejs/tools'; +import { + SegmentInfo, + LogicalOperation, + OperatorOptions, +} from '@cornerstonejs/tools/utilities/contourSegmentation/logicalOperators'; import * as cornerstoneTools from '@cornerstonejs/tools'; import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation'; import { ONNXSegmentationController } from '@cornerstonejs/ai'; import { Types as OhifTypes, utils } from '@ohif/core'; -import i18n from '@ohif/i18n'; import { callInputDialogAutoComplete, createReportAsync, @@ -33,7 +38,11 @@ import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/u import { getViewportEnabledElement } from './utils/getViewportEnabledElement'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; import toggleVOISliceSync from './utils/toggleVOISliceSync'; -import { usePositionPresentationStore, useSegmentationPresentationStore } from './stores'; +import { + usePositionPresentationStore, + useSegmentationPresentationStore, + useSelectedSegmentationsForViewportStore, +} from './stores'; import { toolNames } from './initCornerstoneTools'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats'; @@ -43,6 +52,11 @@ import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport'; import { getCenterExtent } from './utils/getCenterExtent'; import { EasingFunctionEnum } from './utils/transitions'; +import { createSegmentationForViewport } from './utils/createSegmentationForViewport'; +import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation'; +import i18n from '@ohif/i18n'; + +const { add, intersect, subtract, copy } = cstUtils.contourSegmentation; const { DefaultHistoryMemo } = csUtils.HistoryMemo; const toggleSyncFunctions = { @@ -963,17 +977,21 @@ function commandsModule({ } } }, - setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [] }) => { + setToolActiveToolbar: ({ value, itemId, toolName, toolGroupIds = [], bindings }) => { // Sometimes it is passed as value (tools with options), sometimes as itemId (toolbar buttons) toolName = toolName || itemId || value; toolGroupIds = toolGroupIds.length ? toolGroupIds : toolGroupService.getToolGroupIds(); toolGroupIds.forEach(toolGroupId => { - actions.setToolActive({ toolName, toolGroupId }); + actions.setToolActive({ toolName, toolGroupId, bindings }); }); }, - setToolActive: ({ toolName, toolGroupId = null }) => { + setToolActive: ({ + toolName, + toolGroupId = null, + bindings = [{ mouseButton: Enums.MouseBindings.Primary }], + }) => { const { viewports } = viewportGridService.getState(); if (!viewports.size) { @@ -1001,11 +1019,7 @@ function commandsModule({ // Set the new toolName to be active toolGroup.setToolActive(toolName, { - bindings: [ - { - mouseButton: Enums.MouseBindings.Primary, - }, - ], + bindings, }); }, // capture viewport @@ -1478,48 +1492,24 @@ function commandsModule({ * as a segmentation representation to the viewport. */ createLabelmapForViewport: async ({ viewportId, options = {} }) => { - const { viewportGridService, displaySetService, segmentationService } = - servicesManager.services; - const { viewports } = viewportGridService.getState(); - const targetViewportId = viewportId; - - const viewport = viewports.get(targetViewportId); - - // Todo: add support for multiple display sets - const displaySetInstanceUID = - options.displaySetInstanceUID || viewport.displaySetInstanceUIDs[0]; - - const segs = segmentationService.getSegmentations(); - - const label = options.label || `${i18n.t('Tools:Segmentation')} ${segs.length + 1}`; - const segmentationId = options.segmentationId || `${csUtils.uuidv4()}`; - - const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - - // This will create the segmentation and register it as a display set - const generatedSegmentationId = await segmentationService.createLabelmapForDisplaySet( - displaySet, - { - label, - segmentationId, - segments: options.createInitialSegment - ? { - 1: { - label: `${i18n.t('Tools:Segment')} 1`, - active: true, - }, - } - : {}, - } - ); - - // Also add the segmentation representation to the viewport - await segmentationService.addSegmentationRepresentation(viewportId, { - segmentationId, - type: Enums.SegmentationRepresentations.Labelmap, + return createSegmentationForViewport(servicesManager, { + viewportId, + options, + segmentationType: SegmentationRepresentations.Labelmap, + }); + }, + /** + * Creates a contour for the active viewport + * + * The created contour will be registered as a display set and also added + * as a segmentation representation to the viewport. + */ + createContourForViewport: async ({ viewportId, options = {} }) => { + return createSegmentationForViewport(servicesManager, { + viewportId, + options, + segmentationType: SegmentationRepresentations.Contour, }); - - return generatedSegmentationId; }, /** @@ -1538,9 +1528,9 @@ function commandsModule({ * Adds a new segment to a segmentation * @param props.segmentationId - The ID of the segmentation to add the segment to */ - addSegmentCommand: ({ segmentationId }) => { + addSegmentCommand: ({ segmentationId, config }) => { const { segmentationService } = servicesManager.services; - segmentationService.addSegment(segmentationId); + segmentationService.addSegment(segmentationId, config); }, /** @@ -2269,6 +2259,207 @@ function commandsModule({ deleting, }); }, + activateSelectedSegmentationOfType: ({ segmentationRepresentationType }) => { + const { segmentationService, viewportGridService } = servicesManager.services; + const activeViewportId = viewportGridService.getActiveViewportId(); + const { selectedSegmentationsForViewport } = + useSelectedSegmentationsForViewportStore.getState(); + const segmentationId = selectedSegmentationsForViewport[activeViewportId]?.get( + segmentationRepresentationType + ); + + if (!segmentationId) { + return; + } + + segmentationService.setActiveSegmentation(activeViewportId, segmentationId); + }, + setDynamicCursorSizeForSculptorTool: ({ value: isDynamicCursorSize }) => { + const viewportId = viewportGridService.getActiveViewportId(); + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + const sculptorToolInstance = toolGroup.getToolInstance(toolNames.SculptorTool); + const oldConfiguration = sculptorToolInstance.configuration; + + sculptorToolInstance.configuration = { + ...oldConfiguration, + updateCursorSize: isDynamicCursorSize ? 'dynamic' : '', + }; + }, + setInterpolationToolConfiguration: ({ value: interpolateContours, toolNames }) => { + const viewportId = viewportGridService.getActiveViewportId(); + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + + // Set the interpolation configuration for the active tool. + const activeTool = toolGroupService.getActiveToolForViewport(viewportId); + const interpolationConfig = { + interpolation: { + enabled: interpolateContours, + showInterpolationPolyline: true, + }, + }; + toolGroup.setToolConfiguration(activeTool, interpolationConfig); + + // Now set the interpolation configuration for the other tools specified. + if (toolNames) { + Object.values(toolGroup.getToolInstances()).forEach(toolInstance => { + if (toolNames?.includes(toolInstance.toolName)) { + toolGroup.setToolConfiguration(toolInstance.toolName, interpolationConfig); + } + }); + } + }, + setSimplifiedSplineForSplineContourSegmentationTool: ({ value: simplifiedSpline }) => { + const viewportId = viewportGridService.getActiveViewportId(); + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + Object.values(toolGroup.getToolInstances()).forEach(toolInstance => { + if (toolInstance instanceof SplineContourSegmentationTool) { + const oldConfiguration = toolInstance.configuration; + toolInstance.configuration = { + ...oldConfiguration, + simplifiedSpline, + }; + } + }); + }, + removeSmallContours: ({ areaThreshold: threshold }) => { + const viewportId = viewportGridService.getActiveViewportId(); + const activeSegmentation = segmentationService.getActiveSegmentation(viewportId); + const activeSegment = segmentationService.getActiveSegment(viewportId); + + if (!activeSegmentation || !activeSegment) { + return; + } + + const { removeContourIslands } = segmentationUtilities; + removeContourIslands(activeSegmentation.segmentationId, activeSegment.segmentIndex, { + threshold, + }); + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + }, + applyLogicalContourOperation: ({ + segmentAInfo, + segmentBInfo, + resultSegmentInfo, + logicalOperation, + }: { + segmentAInfo: SegmentInfo; + segmentBInfo: SegmentInfo; + resultSegmentInfo: OperatorOptions; + logicalOperation: LogicalOperation; + }) => { + switch (logicalOperation) { + case LogicalOperation.Union: + add(segmentAInfo, segmentBInfo, resultSegmentInfo); + break; + case LogicalOperation.Intersect: + intersect(segmentAInfo, segmentBInfo, resultSegmentInfo); + break; + case LogicalOperation.Subtract: + subtract(segmentAInfo, segmentBInfo, resultSegmentInfo); + break; + default: + throw new Error('Unsupported logical operation'); + break; + } + }, + copyContourSegment: ({ + sourceSegmentInfo, + targetSegmentInfo, + }: { + sourceSegmentInfo: SegmentInfo; + targetSegmentInfo?: SegmentInfo; + }) => { + if (!targetSegmentInfo) { + targetSegmentInfo = { + segmentationId: sourceSegmentInfo.segmentationId, + segmentIndex: segmentationService.getNextAvailableSegmentIndex( + sourceSegmentInfo.segmentationId + ), + }; + segmentationService.addSegment(targetSegmentInfo.segmentationId, { + segmentIndex: targetSegmentInfo.segmentIndex, + }); + } + + copy(sourceSegmentInfo, targetSegmentInfo); + }, + smoothContours: () => { + const viewportId = viewportGridService.getActiveViewportId(); + const activeSegmentation = segmentationService.getActiveSegmentation(viewportId); + const activeSegment = segmentationService.getActiveSegment(viewportId); + + if (!activeSegmentation || !activeSegment) { + return; + } + + const { smoothContours } = segmentationUtilities; + smoothContours(activeSegmentation.segmentationId, activeSegment.segmentIndex); + + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + }, + removeContourHoles: () => { + const viewportId = viewportGridService.getActiveViewportId(); + const activeSegmentation = segmentationService.getActiveSegmentation(viewportId); + const activeSegment = segmentationService.getActiveSegment(viewportId); + + if (!activeSegmentation || !activeSegment) { + return; + } + + const { removeContourHoles } = segmentationUtilities; + removeContourHoles(activeSegmentation.segmentationId, activeSegment.segmentIndex); + + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + }, + decimateContours: () => { + const viewportId = viewportGridService.getActiveViewportId(); + const activeSegmentation = segmentationService.getActiveSegmentation(viewportId); + const activeSegment = segmentationService.getActiveSegment(viewportId); + + if (!activeSegmentation || !activeSegment) { + return; + } + + const { decimateContours } = segmentationUtilities; + decimateContours(activeSegmentation.segmentationId, activeSegment.segmentIndex); + + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + }, + convertContourHoles: () => { + const viewportId = viewportGridService.getActiveViewportId(); + const activeSegmentation = segmentationService.getActiveSegmentation(viewportId); + const activeSegment = segmentationService.getActiveSegment(viewportId); + + if (!activeSegmentation || !activeSegment) { + return; + } + + const targetSegmentInfo = { + segmentationId: activeSegmentation.segmentationId, + segmentIndex: segmentationService.getNextAvailableSegmentIndex( + activeSegmentation.segmentationId + ), + }; + + segmentationService.addSegment(targetSegmentInfo.segmentationId, { + segmentIndex: targetSegmentInfo.segmentIndex, + }); + + const { convertContourHoles } = segmentationUtilities; + convertContourHoles( + activeSegmentation.segmentationId, + activeSegment.segmentIndex, + targetSegmentInfo.segmentationId, + targetSegmentInfo.segmentIndex + ); + + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + renderingEngine.render(); + }, }; const definitions = { @@ -2467,6 +2658,9 @@ function commandsModule({ createLabelmapForViewport: { commandFn: actions.createLabelmapForViewport, }, + createContourForViewport: { + commandFn: actions.createContourForViewport, + }, setActiveSegmentation: { commandFn: actions.setActiveSegmentation, }, @@ -2568,6 +2762,18 @@ function commandsModule({ toggleSegmentLabel: actions.toggleSegmentLabel, jumpToMeasurementViewport: actions.jumpToMeasurementViewport, initializeSegmentLabelTool: actions.initializeSegmentLabelTool, + activateSelectedSegmentationOfType: actions.activateSelectedSegmentationOfType, + setDynamicCursorSizeForSculptorTool: actions.setDynamicCursorSizeForSculptorTool, + setSimplifiedSplineForSplineContourSegmentationTool: + actions.setSimplifiedSplineForSplineContourSegmentationTool, + removeSmallContours: actions.removeSmallContours, + applyLogicalContourOperation: actions.applyLogicalContourOperation, + copyContourSegment: actions.copyContourSegment, + smoothContours: actions.smoothContours, + removeContourHoles: actions.removeContourHoles, + decimateContours: actions.decimateContours, + convertContourHoles: actions.convertContourHoles, + setInterpolationToolConfiguration: actions.setInterpolationToolConfiguration, }; return { diff --git a/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx b/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx new file mode 100644 index 000000000..77419963c --- /dev/null +++ b/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuPortal, + DropdownMenuSubContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuSeparator, + Icons, +} from '@ohif/ui-next'; + +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; + +interface ExportSegmentationSubMenuItemProps { + segmentationId: string; + segmentationRepresentationType: string; + allowExport: boolean; + actions: { + storeSegmentation: (segmentationId: string) => Promise; + onSegmentationDownloadRTSS: (segmentationId: string) => void; + onSegmentationDownload: (segmentationId: string) => void; + downloadCSVSegmentationReport: (segmentationId: string) => void; + }; +} + +export const ExportSegmentationSubMenuItem: React.FC = ({ + segmentationId, + segmentationRepresentationType, + allowExport, + actions, +}) => { + const { t } = useTranslation('SegmentationTable'); + + return ( + <> + {segmentationRepresentationType === SegmentationRepresentations.Labelmap && ( + + + + {t('Download & Export')} + + + + + + {t('Download')} + + { + e.preventDefault(); + actions.downloadCSVSegmentationReport(segmentationId); + }} + disabled={!allowExport} + > + {t('CSV Report')} + + { + e.preventDefault(); + actions.onSegmentationDownload(segmentationId); + }} + disabled={!allowExport} + > + {t('DICOM SEG')} + + { + e.preventDefault(); + actions.onSegmentationDownloadRTSS(segmentationId); + }} + disabled={!allowExport} + > + {t('DICOM RTSS')} + + + + + {t('Export')} + + { + e.preventDefault(); + actions.storeSegmentation(segmentationId); + }} + disabled={!allowExport} + > + {t('DICOM SEG')} + + + + + )} + {segmentationRepresentationType === SegmentationRepresentations.Contour && ( + <> + { + e.preventDefault(); + actions.onSegmentationDownloadRTSS(segmentationId); + }} + disabled={!allowExport} + > + + {t('Download DICOM RTSS')} + + + )} + + ); +}; diff --git a/extensions/cornerstone/src/components/SegmentationToolConfig.tsx b/extensions/cornerstone/src/components/SegmentationToolConfig.tsx new file mode 100644 index 000000000..626d0c013 --- /dev/null +++ b/extensions/cornerstone/src/components/SegmentationToolConfig.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { Switch } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; + +export default function SegmentationToolConfig() { + const { commandsManager } = useSystem(); + + // Get initial states based on current configuration + const [previewEdits, setPreviewEdits] = useState(false); + const [segmentLabelEnabled, setSegmentLabelEnabled] = useState(false); + const [toggleSegmentEnabled, setToggleSegmentEnabled] = useState(false); + const [useCenterAsSegmentIndex, setUseCenterAsSegmentIndex] = useState(false); + + const handlePreviewEditsChange = checked => { + setPreviewEdits(checked); + commandsManager.run('toggleSegmentPreviewEdit', { toggle: checked }); + }; + + const handleToggleSegmentEnabledChange = checked => { + setToggleSegmentEnabled(checked); + commandsManager.run('toggleSegmentSelect', { toggle: checked }); + }; + + const handleUseCenterAsSegmentIndexChange = checked => { + setUseCenterAsSegmentIndex(checked); + commandsManager.run('toggleUseCenterSegmentIndex', { toggle: checked }); + }; + + const handleSegmentLabelEnabledChange = checked => { + setSegmentLabelEnabled(checked); + commandsManager.run('toggleSegmentLabel', { enabled: checked }); + }; + + return ( +
+
+ + Preview edits before creating +
+ +
+ + Use center as segment index +
+ +
+ + Hover on segment border to activate +
+ +
+ + Show segment name on hover +
+
+ ); +} diff --git a/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx b/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx new file mode 100644 index 000000000..1dd29020c --- /dev/null +++ b/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx @@ -0,0 +1,64 @@ +import React, { useCallback } from 'react'; +import { cn, ToolButton } from '@ohif/ui-next'; +import { useUIStateStore } from '@ohif/extension-default'; + +interface SegmentationUtilityButtonProps { + className?: string; + isActive?: boolean; + id: string; +} + +/** + * A button that represents a segmentation utility. + * It is implicitly the PopoverTrigger for the options Popover panel that is + * opened in the PanelSegmentation component. It in essence is the PopoverTrigger + * because typically this button has the associated options to display in the Popover. + * Furthermore it also typically has a command for toggling the activeSegmentationUtility + * value in the UIStateStore that triggers the fetching of its options. + + * @param props - The props for the button. + * @param props.className - The class name for the button. + * @param props.isActive - Whether the button is active + * @param props.id - The id of the button. + */ +function SegmentationUtilityButton(props: SegmentationUtilityButtonProps) { + const { className, isActive, id } = props; + + const activeSegmentationUtility = useUIStateStore( + store => store.uiState.activeSegmentationUtility + ); + + const toolButtonClassName = cn( + 'w-7 h-7 text-primary hover:text-primary hover:!bg-primary/30', + className, + isActive && 'bg-primary/30' + ); + + const handleMouseDownCapture = useCallback( + event => { + if (activeSegmentationUtility === id) { + // If this active button is clicked, prevent the default Popover + // behaviour of closing the Popover on a pointer/mouse down. + // Not doing this will cause the Popover to close and then reopen again. + // Why? Because propagating this event will cause PanelSegmentation to + // close the Popover by clearing the activeSegmentationUtility. Then + // this button will set the activeSegmentationUtility again and the + // Popover will reopen. + event.preventDefault(); + event.stopPropagation(); + } + }, + [activeSegmentationUtility, id] + ); + + return ( +
+ +
+ ); +} + +export default SegmentationUtilityButton; diff --git a/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx b/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx index aed2eb8cc..507688bfa 100644 --- a/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx +++ b/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx @@ -3,17 +3,14 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuPortal, DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, Icons, useSegmentationTableContext, useSegmentationExpanded, } from '@ohif/ui-next'; import { useTranslation } from 'react-i18next'; import { useSystem } from '@ohif/core/src'; +import { ExportSegmentationSubMenuItem } from '../components/ExportSegmentationSubMenuItem'; /** * Custom dropdown menu component for segmentation panel that uses context for data @@ -29,6 +26,8 @@ export const CustomDropdownMenuContent = () => { exportOptions, activeSegmentation, activeSegmentationId, + segmentationRepresentationType, + disableEditing, } = useSegmentationTableContext('CustomDropdownMenu'); // Try to get segmentation data from expanded context first, fall back to table context @@ -78,10 +77,14 @@ export const CustomDropdownMenuContent = () => { return ( - onSegmentationAdd(segmentationId)}> - - {t('Create New Segmentation')} - + {!disableEditing && ( + onSegmentationAdd({ segmentationId, segmentationRepresentationType })} + > + + {t('Create New Segmentation')} + + )} {t('Manage Current Segmentation')} onSegmentationRemoveFromViewport(segmentationId)}> @@ -92,61 +95,12 @@ export const CustomDropdownMenuContent = () => { {t('Rename')} - - - - {t('Download & Export')} - - - - - - {t('Download')} - - { - e.preventDefault(); - actions.downloadCSVSegmentationReport(segmentationId); - }} - disabled={!allowExport} - > - {t('CSV Report')} - - { - e.preventDefault(); - actions.onSegmentationDownload(segmentationId); - }} - disabled={!allowExport} - > - {t('DICOM SEG')} - - { - e.preventDefault(); - actions.onSegmentationDownloadRTSS(segmentationId); - }} - disabled={!allowExport} - > - {t('DICOM RTSS')} - - - - - {t('Export')} - - { - e.preventDefault(); - actions.storeSegmentation(segmentationId); - }} - disabled={!allowExport} - > - {t('DICOM SEG')} - - - - + onSegmentationDelete(segmentationId)}> diff --git a/extensions/cornerstone/src/customizations/segmentationPanelCustomization.tsx b/extensions/cornerstone/src/customizations/segmentationPanelCustomization.tsx index 33114372d..ffad85152 100644 --- a/extensions/cornerstone/src/customizations/segmentationPanelCustomization.tsx +++ b/extensions/cornerstone/src/customizations/segmentationPanelCustomization.tsx @@ -1,7 +1,9 @@ import { CustomDropdownMenuContent } from './CustomDropdownMenuContent'; import { CustomSegmentStatisticsHeader } from './CustomSegmentStatisticsHeader'; -import React, { useState } from 'react'; -import { Switch } from '@ohif/ui-next'; +import SegmentationToolConfig from '../components/SegmentationToolConfig'; +import React from 'react'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; +import * as cornerstoneTools from '@cornerstonejs/tools'; export default function getSegmentationPanelCustomization({ commandsManager, servicesManager }) { return { @@ -9,10 +11,25 @@ export default function getSegmentationPanelCustomization({ commandsManager, ser 'panelSegmentation.customSegmentStatisticsHeader': CustomSegmentStatisticsHeader, 'panelSegmentation.disableEditing': false, 'panelSegmentation.showAddSegment': true, - 'panelSegmentation.onSegmentationAdd': () => { + 'panelSegmentation.onSegmentationAdd': async ({ + segmentationRepresentationType = SegmentationRepresentations.Labelmap, + }) => { const { viewportGridService } = servicesManager.services; const viewportId = viewportGridService.getState().activeViewportId; - commandsManager.run('createLabelmapForViewport', { viewportId }); + if (segmentationRepresentationType === SegmentationRepresentations.Labelmap) { + commandsManager.run('createLabelmapForViewport', { viewportId }); + } else if (segmentationRepresentationType === SegmentationRepresentations.Contour) { + const segmentationId = await commandsManager.run('createContourForViewport', { + viewportId, + }); + cornerstoneTools.segmentation.config.style.setStyle( + { segmentationId, type: SegmentationRepresentations.Contour }, + { + fillAlpha: 0.5, + renderFill: true, + } + ); + } }, 'panelSegmentation.tableMode': 'collapsed', 'panelSegmentation.readableText': { @@ -33,67 +50,11 @@ export default function getSegmentationPanelCustomization({ commandsManager, ser lesionGlycolysis: 'Lesion Glycolysis', center: 'Center', }, - 'segmentationToolbox.config': () => { - // Get initial states based on current configuration - const [previewEdits, setPreviewEdits] = useState(false); - const [segmentLabelEnabled, setSegmentLabelEnabled] = useState(false); - const [toggleSegmentEnabled, setToggleSegmentEnabled] = useState(false); - const [useCenterAsSegmentIndex, setUseCenterAsSegmentIndex] = useState(false); - const handlePreviewEditsChange = checked => { - setPreviewEdits(checked); - commandsManager.run('toggleSegmentPreviewEdit', { toggle: checked }); - }; - - const handleToggleSegmentEnabledChange = checked => { - setToggleSegmentEnabled(checked); - commandsManager.run('toggleSegmentSelect', { toggle: checked }); - }; - - const handleUseCenterAsSegmentIndexChange = checked => { - setUseCenterAsSegmentIndex(checked); - commandsManager.run('toggleUseCenterSegmentIndex', { toggle: checked }); - }; - - const handleSegmentLabelEnabledChange = checked => { - setSegmentLabelEnabled(checked); - commandsManager.run('toggleSegmentLabel', { enabled: checked }); - }; - - return ( -
-
- - Preview edits before creating -
- -
- - Use center as segment index -
- -
- - Hover on segment border to activate -
- -
- - Show segment name on hover -
-
- ); + 'labelMapSegmentationToolbox.config': () => { + return ; + }, + 'contourSegmentationToolbox.config': () => { + return ; }, }; } diff --git a/extensions/cornerstone/src/getPanelModule.tsx b/extensions/cornerstone/src/getPanelModule.tsx index 19ec9d76e..d495f455f 100644 --- a/extensions/cornerstone/src/getPanelModule.tsx +++ b/extensions/cornerstone/src/getPanelModule.tsx @@ -1,53 +1,67 @@ import React from 'react'; - +import { useTranslation } from 'react-i18next'; import { Toolbox } from '@ohif/extension-default'; import PanelSegmentation from './panels/PanelSegmentation'; import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel'; import PanelMeasurement from './panels/PanelMeasurement'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; +import i18n from '@ohif/i18n'; const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => { - const wrappedPanelSegmentation = ({ configuration }) => { + const { toolbarService } = servicesManager.services; + + const toolSectionMap = { + [SegmentationRepresentations.Labelmap]: toolbarService.sections.labelMapSegmentationToolbox, + [SegmentationRepresentations.Contour]: toolbarService.sections.contourSegmentationToolbox, + }; + + const wrappedPanelSegmentation = props => { return ( ); }; - const wrappedPanelSegmentationNoHeader = ({ configuration }) => { + const wrappedPanelSegmentationNoHeader = props => { return ( ); }; - const wrappedPanelSegmentationWithTools = ({ configuration }) => { - const { toolbarService } = servicesManager.services; + const wrappedPanelSegmentationWithTools = props => { + const { t } = useTranslation('SegmentationTable'); + const tKey = `${props.segmentationRepresentationType ?? 'Segmentation'} tools`; + const tValue = t(tKey); return ( <> ); @@ -82,11 +96,26 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: component: wrappedPanelSegmentationNoHeader, }, { - name: 'panelSegmentationWithTools', + name: 'panelSegmentationWithToolsLabelMap', iconName: 'tab-segmentation', iconLabel: 'Segmentation', - label: 'Segmentation', - component: wrappedPanelSegmentationWithTools, + label: i18n.t('SegmentationTable:Labelmap'), + component: props => + wrappedPanelSegmentationWithTools({ + ...props, + segmentationRepresentationType: SegmentationRepresentations.Labelmap, + }), + }, + { + name: 'panelSegmentationWithToolsContour', + iconName: 'tab-contours', + iconLabel: 'Segmentation', + label: i18n.t('SegmentationTable:Contour'), + component: props => + wrappedPanelSegmentationWithTools({ + ...props, + segmentationRepresentationType: SegmentationRepresentations.Contour, + }), }, ]; }; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index ff292aa8b..b668de610 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -44,6 +44,7 @@ import { usePositionPresentationStore, useSegmentationPresentationStore, useSynchronizersStore, + useSelectedSegmentationsForViewportStore, } from './stores'; import { useToggleOneUpViewportGridStore } from '@ohif/extension-default'; import { useActiveViewportSegmentationRepresentations } from './hooks/useActiveViewportSegmentationRepresentations'; @@ -109,6 +110,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { toolbarService.registerEventForToolbarUpdate(segmentationService, [ segmentationService.EVENTS.SEGMENTATION_REMOVED, segmentationService.EVENTS.SEGMENTATION_MODIFIED, + segmentationService.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, ]); toolbarService.registerEventForToolbarUpdate(cornerstone.eventTarget, [ @@ -153,6 +155,9 @@ const cornerstoneExtension: Types.Extensions.Extension = { useSynchronizersStore.getState().clearSynchronizersStore(); useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore(); useSegmentationPresentationStore.getState().clearSegmentationPresentationStore(); + useSelectedSegmentationsForViewportStore + .getState() + .clearSelectedSegmentationsForViewportStore(); segmentationService.removeAllSegmentations(); }, @@ -256,6 +261,7 @@ export { usePositionPresentationStore, useSegmentationPresentationStore, useSynchronizersStore, + useSelectedSegmentationsForViewportStore, Enums, useMeasurements, useActiveViewportSegmentationRepresentations, diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 84e3539e9..f73b0158e 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -106,6 +106,12 @@ export default async function init({ colorbarService.EVENTS.STATE_CHANGED, ]); + toolbarService.registerEventForToolbarUpdate(segmentationService, [ + segmentationService.EVENTS.SEGMENTATION_MODIFIED, + segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, + segmentationService.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, + ]); + window.services = servicesManager.services; window.extensionManager = extensionManager; window.commandsManager = commandsManager; diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 73eacffd9..27bbca70c 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -41,6 +41,10 @@ import { SegmentSelectTool, RegionSegmentPlusTool, SegmentLabelTool, + LivewireContourSegmentationTool, + SculptorTool, + SplineContourSegmentationTool, + LabelMapEditWithContourTool, } from '@cornerstonejs/tools'; import { LabelmapSlicePropagationTool, MarkerLabelmapTool } from '@cornerstonejs/ai'; import * as polySeg from '@cornerstonejs/polymorphic-segmentation'; @@ -109,6 +113,10 @@ export default function initCornerstoneTools(configuration = {}) { addTool(LabelmapSlicePropagationTool); addTool(MarkerLabelmapTool); addTool(RegionSegmentPlusTool); + addTool(LivewireContourSegmentationTool); + addTool(SculptorTool); + addTool(SplineContourSegmentationTool); + addTool(LabelMapEditWithContourTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { textBoxFontSize: '15px', @@ -168,6 +176,10 @@ const toolNames = { LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName, MarkerLabelmap: MarkerLabelmapTool.toolName, RegionSegmentPlus: RegionSegmentPlusTool.toolName, + LivewireContourSegmentation: LivewireContourSegmentationTool.toolName, + SculptorTool: SculptorTool.toolName, + SplineContourSegmentation: SplineContourSegmentationTool.toolName, + LabelMapEditWithContourTool: LabelMapEditWithContourTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/panels/PanelSegmentation.tsx b/extensions/cornerstone/src/panels/PanelSegmentation.tsx index 2baf9d145..8eb8fea50 100644 --- a/extensions/cornerstone/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone/src/panels/PanelSegmentation.tsx @@ -1,16 +1,92 @@ -import React from 'react'; -import { SegmentationTable } from '@ohif/ui-next'; +import React, { useCallback, useEffect } from 'react'; +import { + IconPresentationProvider, + Popover, + PopoverAnchor, + PopoverContent, + SegmentationTable, + ToolSettings, +} from '@ohif/ui-next'; import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations'; -import { metaData, cache } from '@cornerstonejs/core'; -import { useSystem } from '@ohif/core/src'; +import { useActiveToolOptions, useSystem } from '@ohif/core/src'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; +import { Toolbar, useUIStateStore } from '@ohif/extension-default'; +import SegmentationUtilityButton from '../components/SegmentationUtilityButton'; +import { useSelectedSegmentationsForViewportStore } from '../stores'; +import { + hasExportableLabelMapData, + hasExportableContourData, +} from '../utils/segmentationExportUtils'; -export default function PanelSegmentation({ children }: withAppTypes) { +type PanelSegmentationProps = { + children?: React.ReactNode; + + // The representation type for this segmentation panel. Undefined means all types. + segmentationRepresentationType?: SegmentationRepresentations; +} & withAppTypes; + +export default function PanelSegmentation({ + children, + segmentationRepresentationType, +}: PanelSegmentationProps) { const { commandsManager, servicesManager } = useSystem(); - const { customizationService, displaySetService } = servicesManager.services; + const { + customizationService, + displaySetService, + viewportGridService, + toolbarService, + segmentationService, + } = servicesManager.services; + const { activeViewportId } = viewportGridService.getState(); + + const utilitiesSectionMap = { + [SegmentationRepresentations.Labelmap]: toolbarService.sections.labelMapSegmentationUtilities, + [SegmentationRepresentations.Contour]: toolbarService.sections.contourSegmentationUtilities, + }; + + const selectedSegmentationsForViewportMap = useSelectedSegmentationsForViewportStore( + store => store.selectedSegmentationsForViewport[activeViewportId] + ); + + const selectedSegmentationIdForType = segmentationRepresentationType + ? selectedSegmentationsForViewportMap?.get(segmentationRepresentationType) + : segmentationService?.getActiveSegmentation(activeViewportId)?.segmentationId; + + const buttonSection = utilitiesSectionMap[segmentationRepresentationType]; + + const { activeToolOptions: activeUtilityOptions } = useActiveToolOptions({ + buttonSectionId: buttonSection, + }); const { segmentationsWithRepresentations, disabled } = useActiveViewportSegmentationRepresentations(); + const setUIState = useUIStateStore(store => store.setUIState); + + // useEffect for handling clicks on any of the non-active viewports. + // The ViewportGrid stops the propagation of pointer/mouse events + // for non-active viewports so the Popover below + // is not closed when clicking on any of the non-active viewports. + useEffect(() => { + setUIState('activeSegmentationUtility', null); + toolbarService.refreshToolbarState({ viewportId: activeViewportId }); + }, [activeViewportId, setUIState, toolbarService]); + + // The callback for handling clicks outside of the Popover and, the SegmentationUtilityButton + // that triggered it to open. Clicks outside those components must close the Popover. + // The Popover is made visible whenever the options associated with the + // activeSegmentationUtility exist. Thus clearing the activeSegmentationUtility + // clears the associated options and will keep the Popover closed. + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + if (!open) { + setUIState('activeSegmentationUtility', null); + toolbarService.refreshToolbarState({ viewportId: activeViewportId }); + } + }, + [activeViewportId, setUIState, toolbarService] + ); + // Extract customization options const segmentationTableMode = customizationService.getCustomization( 'panelSegmentation.tableMode' @@ -35,6 +111,7 @@ export default function PanelSegmentation({ children }: withAppTypes) { }, onSegmentAdd: segmentationId => { commandsManager.run('addSegment', { segmentationId }); + commandsManager.run('setActiveSegmentation', { segmentationId }); }, onSegmentClick: (segmentationId, segmentIndex) => { commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex }); @@ -51,6 +128,14 @@ export default function PanelSegmentation({ children }: withAppTypes) { onSegmentDelete: (segmentationId, segmentIndex) => { commandsManager.run('deleteSegment', { segmentationId, segmentIndex }); }, + onSegmentCopy: + segmentationRepresentationType === SegmentationRepresentations.Contour + ? (segmentationId, segmentIndex) => { + commandsManager.run('copyContourSegment', { + sourceSegmentInfo: { segmentationId, segmentIndex }, + }); + } + : undefined, onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => { commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type }); }, @@ -96,52 +181,26 @@ export default function PanelSegmentation({ children }: withAppTypes) { }; // Generate export options + // Map each segmentation to an export option for it. + // A segmentation is exportable if it has any labelmap or contour data. const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => { const { representationData, segmentationId } = segmentation; - const { Labelmap } = representationData; + const { Labelmap, Contour } = representationData; - if (!Labelmap) { + if (!Labelmap && !Contour) { return { segmentationId, isExportable: true }; } - // Check if any segments have anything drawn in any of the viewports - const hasAnySegmentData = (() => { - const imageIds = Labelmap.imageIds; - if (!imageIds?.length) return false; - - for (const imageId of imageIds) { - const pixelData = cache.getImage(imageId)?.getPixelData(); - if (!pixelData) continue; - - for (let i = 0; i < pixelData.length; i++) { - if (pixelData[i] !== 0) return true; - } - } - return false; - })(); - - if (!hasAnySegmentData) { + if ( + !hasExportableLabelMapData(Labelmap, displaySetService) && + !hasExportableContourData(Contour) + ) { return { segmentationId, isExportable: false }; } - const referencedImageIds = Labelmap.referencedImageIds; - const firstImageId = referencedImageIds[0]; - const instance = metaData.get('instance', firstImageId); - - if (!instance) { - return { segmentationId, isExportable: false }; - } - - const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID; - const SeriesInstanceUID = instance.SeriesInstanceUID; - const displaySet = displaySetService.getDisplaySetForSOPInstanceUID( - SOPInstanceUID, - SeriesInstanceUID - ); - return { segmentationId, - isExportable: displaySet?.isReconstructable, + isExportable: true, }; }); @@ -150,15 +209,34 @@ export default function PanelSegmentation({ children }: withAppTypes) { disabled, data: segmentationsWithRepresentations, mode: segmentationTableMode, - title: 'Segmentations', + title: `${segmentationRepresentationType ? `${segmentationRepresentationType} ` : ''}Segmentations`, exportOptions, disableEditing, onSegmentationAdd, showAddSegment, renderInactiveSegmentations: handlers.getRenderInactiveSegmentations(), + segmentationRepresentationType, + selectedSegmentationIdForType, ...handlers, }; + const renderUtilitiesToolbar = () => { + if (!buttonSection) { + return null; + } + + return ( + +
+ +
+
+ ); + }; + const renderSegments = () => { return ( @@ -175,6 +253,7 @@ export default function PanelSegmentation({ children }: withAppTypes) { if (tableProps.mode === 'collapsed') { return ( + {renderUtilitiesToolbar()} @@ -193,6 +272,7 @@ export default function PanelSegmentation({ children }: withAppTypes) { return ( <> + {renderUtilitiesToolbar()} @@ -211,11 +291,27 @@ export default function PanelSegmentation({ children }: withAppTypes) { }; return ( - - {children} - - - {renderModeContent()} - + + + + {children} + + + {renderModeContent()} + + + {activeUtilityOptions && ( + + + + )} + ); } diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts index 8800fb7c0..e5abe17e5 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts @@ -221,7 +221,7 @@ describe('SegmentationService', () => { it('should add event listeners', () => { service.onModeEnter(); - expect(eventTarget.addEventListener).toHaveBeenCalledTimes(7); + expect(eventTarget.addEventListener).toHaveBeenCalledTimes(8); expect(eventTarget.addEventListener).toHaveBeenCalledWith( csToolsEnums.Events.SEGMENTATION_MODIFIED, @@ -251,6 +251,10 @@ describe('SegmentationService', () => { csToolsEnums.Events.SEGMENTATION_ADDED, expect.any(Function) ); + expect(eventTarget.addEventListener).toHaveBeenCalledWith( + csToolsEnums.Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, + expect.any(Function) + ); }); }); @@ -1999,7 +2003,7 @@ describe('SegmentationService', () => { service.addSegment(segmentationId, config); - expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(1); + expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledTimes(2); expect(cstSegmentation.state.getSegmentation).toHaveBeenCalledWith(segmentationId); expect(cstSegmentation.updateSegmentations).toHaveBeenCalledTimes(1); diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index d51a9c008..9363078ef 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -81,6 +81,8 @@ const EVENTS = { SEGMENT_LOADING_COMPLETE: 'event::segment_loading_complete', // loading completed for all segments SEGMENTATION_LOADING_COMPLETE: 'event::segmentation_loading_complete', + // fired when a contour annotation cut merge process is completed + ANNOTATION_CUT_MERGE_PROCESS_COMPLETED: 'event::annotation_cut_merge_process_completed', }; const VALUE_TYPES = {}; @@ -350,6 +352,39 @@ class SegmentationService extends PubSubService { FrameOfReferenceUID?: string; label?: string; } + ): Promise { + return this._createSegmentationForDisplaySet(displaySet, LABELMAP, options); + } + + public async createContourForDisplaySet( + displaySet: AppTypes.DisplaySet, + options?: { + segmentationId?: string; + segments?: { [segmentIndex: number]: Partial }; + FrameOfReferenceUID?: string; + label?: string; + } + ): Promise { + return this._createSegmentationForDisplaySet(displaySet, CONTOUR, options); + } + + /** + * Private method to create segmentation for a display set with the specified type + * + * @param displaySet - The display set to create the segmentation for + * @param segmentationType - The type of segmentation (SegmentationRepresentations enum) + * @param options - Optional parameters for creating the segmentation + * @returns A promise that resolves to the created segmentation ID + */ + private async _createSegmentationForDisplaySet( + displaySet: AppTypes.DisplaySet, + segmentationType: SegmentationRepresentations, + options?: { + segmentationId?: string; + segments?: { [segmentIndex: number]: Partial }; + FrameOfReferenceUID?: string; + label?: string; + } ): Promise { // Todo: random does not makes sense, make this better, like // labelmap 1, 2, 3 etc @@ -375,7 +410,7 @@ class SegmentationService extends PubSubService { const segmentationPublicInput: cstTypes.SegmentationPublicInput = { segmentationId, representation: { - type: LABELMAP, + type: segmentationType, data: { imageIds: segImageIds, // referencedVolumeId: this._getVolumeIdForDisplaySet(displaySet), @@ -808,6 +843,15 @@ class SegmentationService extends PubSubService { cstSegmentation.config.style.resetToGlobalStyle(); }; + public getNextAvailableSegmentIndex(segmentationId: string): number { + const csSegmentation = this.getCornerstoneSegmentation(segmentationId); + // grab the next available segment index based on the object keys, + // so basically get the highest segment index value + 1 + + const segmentKeys = Object.keys(csSegmentation.segments); + return segmentKeys.length === 0 ? 1 : Math.max(...segmentKeys.map(Number)) + 1; + } + /** * Adds a new segment to the specified segmentation. * @param segmentationId - The ID of the segmentation to add the segment to. @@ -841,10 +885,7 @@ class SegmentationService extends PubSubService { let segmentIndex = config.segmentIndex; if (!segmentIndex) { - // grab the next available segment index based on the object keys, - // so basically get the highest segment index value + 1 - const segmentKeys = Object.keys(csSegmentation.segments); - segmentIndex = segmentKeys.length === 0 ? 1 : Math.max(...segmentKeys.map(Number)) + 1; + segmentIndex = this.getNextAvailableSegmentIndex(segmentationId); } // update the segmentation @@ -1627,6 +1668,11 @@ class SegmentationService extends PubSubService { csToolsEnums.Events.SEGMENTATION_ADDED, this._onSegmentationAddedFromSource ); + + eventTarget.addEventListener( + csToolsEnums.Events.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, + this._onAnnotationCutMergeProcessCompletedFromSource + ); } private getCornerstoneSegmentation(segmentationId: string) { @@ -1879,6 +1925,13 @@ class SegmentationService extends PubSubService { segmentationId, }); }; + + private _onAnnotationCutMergeProcessCompletedFromSource = evt => { + const { segmentationId } = evt.detail; + this._broadcastEvent(this.EVENTS.ANNOTATION_CUT_MERGE_PROCESS_COMPLETED, { + segmentationId, + }); + }; } export default SegmentationService; diff --git a/extensions/cornerstone/src/stores/index.ts b/extensions/cornerstone/src/stores/index.ts index f834c825e..2a1a519f2 100644 --- a/extensions/cornerstone/src/stores/index.ts +++ b/extensions/cornerstone/src/stores/index.ts @@ -2,3 +2,4 @@ export { useLutPresentationStore } from './useLutPresentationStore'; export { usePositionPresentationStore } from './usePositionPresentationStore'; export { useSegmentationPresentationStore } from './useSegmentationPresentationStore'; export { useSynchronizersStore } from './useSynchronizersStore'; +export { useSelectedSegmentationsForViewportStore } from './useSelectedSegmentationsForViewportStore'; diff --git a/extensions/cornerstone/src/stores/useSelectedSegmentationsForViewportStore.ts b/extensions/cornerstone/src/stores/useSelectedSegmentationsForViewportStore.ts new file mode 100644 index 000000000..efd30a7de --- /dev/null +++ b/extensions/cornerstone/src/stores/useSelectedSegmentationsForViewportStore.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import { SelectedSegmentationByTypeMap } from '../types/SelectedSegmentationByTypeMap'; + +const PRESENTATION_TYPE_ID = 'selectedSegmentationsForViewportId'; +const DEBUG_STORE = false; + +/** + * Represents the state and actions for managing selected segmentations for a viewport by representation type. + */ +type SelectedSegmentationsForViewportState = { + /** + * Stores a mapping from viewport id to a map of representation type to segmentation id. + */ + selectedSegmentationsForViewport: Record; + + /** + * Sets the selected segmentations for a given viewport id. + * + * @param key - The viewport id. + * @param value - The `SelectedSegmentationByTypeMap` to associate with the viewport id. + */ + setSelectedSegmentationsForViewport: (key: string, value: SelectedSegmentationByTypeMap) => void; + + /** + * Clears all selected segmentations for all viewports. + */ + clearSelectedSegmentationsForViewportStore: () => void; + + /** + * Type identifier for the store. + */ + type: string; +}; + +/** + * Creates the Selected Segmentations For Viewport store. + * + * @param set - The zustand set function. + * @returns The selected segmentations for viewport store state and actions. + */ +const createSelectedSegmentationsForViewportStore = ( + set +): SelectedSegmentationsForViewportState => ({ + selectedSegmentationsForViewport: {}, + type: PRESENTATION_TYPE_ID, + + /** + * Sets the selected segmentations for a given viewport id. + */ + setSelectedSegmentationsForViewport: (key, value) => + set( + state => ({ + selectedSegmentationsForViewport: { + ...state.selectedSegmentationsForViewport, + [key]: value, + }, + }), + false, + 'setSelectedSegmentationsForViewport' + ), + + /** + * Clears all selected segmentations for all viewports. + */ + clearSelectedSegmentationsForViewportStore: () => + set( + { selectedSegmentationsForViewport: {} }, + false, + 'clearSelectedSegmentationsForViewportStore' + ), +}); + +/** + * Zustand store for managing selected segmentations for a viewport by representation type. + * Applies devtools middleware when DEBUG_STORE is enabled. + */ +export const useSelectedSegmentationsForViewportStore = + create()( + DEBUG_STORE + ? devtools(createSelectedSegmentationsForViewportStore, { + name: 'SelectedSegmentationsForViewportStore', + }) + : createSelectedSegmentationsForViewportStore + ); diff --git a/extensions/cornerstone/src/types/SelectedSegmentationByTypeMap.ts b/extensions/cornerstone/src/types/SelectedSegmentationByTypeMap.ts new file mode 100644 index 000000000..05092d817 --- /dev/null +++ b/extensions/cornerstone/src/types/SelectedSegmentationByTypeMap.ts @@ -0,0 +1,3 @@ +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; + +export type SelectedSegmentationByTypeMap = Map; diff --git a/extensions/cornerstone/src/utils/createSegmentationForViewport.ts b/extensions/cornerstone/src/utils/createSegmentationForViewport.ts new file mode 100644 index 000000000..497050623 --- /dev/null +++ b/extensions/cornerstone/src/utils/createSegmentationForViewport.ts @@ -0,0 +1,74 @@ +import { utilities as csUtils } from '@cornerstonejs/core'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; +import i18n from '@ohif/i18n'; +import { ServicesManager } from '@ohif/core'; + +function _createDefaultSegments(createInitialSegment?: boolean) { + return createInitialSegment + ? { + 1: { + label: `${i18n.t('Tools:Segment')} 1`, + active: true, + }, + } + : {}; +} + +type CreateSegmentationForViewportOptions = { + displaySetInstanceUID?: string; + label?: string; + segmentationId?: string; + createInitialSegment?: boolean; +}; + +type CreateSegmentationForViewportParams = { + viewportId: string; + options?: CreateSegmentationForViewportOptions; + segmentationType: SegmentationRepresentations; +}; + +/** + * Creates a segmentation for the active viewport + * + * The created segmentation will be registered as a display set and also added + * as a segmentation representation to the viewport. + */ +export async function createSegmentationForViewport( + servicesManager: ServicesManager, + { viewportId, options = {}, segmentationType }: CreateSegmentationForViewportParams +): Promise { + const { viewportGridService, displaySetService, segmentationService } = servicesManager.services; + const { viewports } = viewportGridService.getState(); + const targetViewportId = viewportId; + + const viewport = viewports.get(targetViewportId); + + // Todo: add support for multiple display sets + const displaySetInstanceUID = options.displaySetInstanceUID || viewport.displaySetInstanceUIDs[0]; + + const segs = segmentationService.getSegmentations(); + + const label = options.label || `${i18n.t('Tools:Segmentation')} ${segs.length + 1}`; + const segmentationId = options.segmentationId || `${csUtils.uuidv4()}`; + + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + + const segmentationCreationOptions = { + label, + segmentationId, + segments: _createDefaultSegments(options.createInitialSegment), + }; + + // This will create the segmentation and register it as a display set + const generatedSegmentationId = await (segmentationType === SegmentationRepresentations.Labelmap + ? segmentationService.createLabelmapForDisplaySet(displaySet, segmentationCreationOptions) + : segmentationService.createContourForDisplaySet(displaySet, segmentationCreationOptions)); + + // Also add the segmentation representation to the viewport + await segmentationService.addSegmentationRepresentation(viewportId, { + segmentationId, + type: segmentationType, + }); + + return generatedSegmentationId; +} diff --git a/extensions/cornerstone/src/utils/index.ts b/extensions/cornerstone/src/utils/index.ts index 6e0224d15..467b6cb68 100644 --- a/extensions/cornerstone/src/utils/index.ts +++ b/extensions/cornerstone/src/utils/index.ts @@ -10,6 +10,7 @@ import promptHydrationDialog, { HydrationSRResult, } from './promptHydrationDialog'; import { getCenterExtent } from './getCenterExtent'; +import { createSegmentationForViewport } from './createSegmentationForViewport'; const utils = { handleSegmentChange, @@ -18,6 +19,7 @@ const utils = { setupSegmentationModifiedHandler, promptHydrationDialog, getCenterExtent, + createSegmentationForViewport, }; export type { HydrationDialogProps, HydrationCallback, HydrationSRResult }; diff --git a/extensions/cornerstone/src/utils/segmentationExportUtils.ts b/extensions/cornerstone/src/utils/segmentationExportUtils.ts new file mode 100644 index 000000000..4977a2c28 --- /dev/null +++ b/extensions/cornerstone/src/utils/segmentationExportUtils.ts @@ -0,0 +1,86 @@ +import { cache, metaData } from '@cornerstonejs/core'; +import { Types as cstTypes } from '@cornerstonejs/tools'; + +/** + * Checks if a labelmap segmentation has exportable data + * A label map is exportable if it has any pixel data and + * it is referenced by a display set that is reconstructable. + * + * @param labelmap - The labelmap representation data + * @param displaySetService - The display set service instance + * @returns boolean - Whether the labelmap has exportable data + */ +export const hasExportableLabelMapData = ( + labelmap: cstTypes.LabelmapSegmentationData | undefined, + displaySetService: any +): boolean => { + if (!labelmap) { + return false; + } + + const imageIds = labelmap?.imageIds; + if (!imageIds?.length) { + return false; + } + + const hasPixelData = imageIds.some(imageId => { + const pixelData = cache.getImage(imageId)?.getPixelData(); + if (!pixelData) { + return false; + } + + for (let i = 0; i < pixelData.length; i++) { + if (pixelData[i] !== 0) { + return true; + } + } + }); + + if (!hasPixelData) { + return false; + } + + const referencedImageIds = labelmap?.referencedImageIds; + + if (!referencedImageIds) { + return false; + } + const firstImageId = referencedImageIds[0]; + const instance = metaData.get('instance', firstImageId); + + if (!instance) { + return false; + } + + const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID; + const SeriesInstanceUID = instance.SeriesInstanceUID; + const displaySet = displaySetService.getDisplaySetForSOPInstanceUID( + SOPInstanceUID, + SeriesInstanceUID + ); + + return displaySet?.isReconstructable; +}; + +/** + * Checks if a contour segmentation has exportable data + * A contour is exportable if it has any contour annotation/geometry data. + * + * @param contour - The contour representation data + * @returns boolean - Whether the contour has exportable data + */ +export const hasExportableContourData = ( + contour: cstTypes.ContourSegmentationData | undefined +): boolean => { + if (!contour) { + return false; + } + + const contourAnnotationUIDsMap = contour?.annotationUIDsMap; + + if (!contourAnnotationUIDsMap) { + return false; + } + + return contourAnnotationUIDsMap.size > 0; +}; diff --git a/extensions/cornerstone/src/utils/segmentationHandlers.ts b/extensions/cornerstone/src/utils/segmentationHandlers.ts index 0368356c0..3b7cd4970 100644 --- a/extensions/cornerstone/src/utils/segmentationHandlers.ts +++ b/extensions/cornerstone/src/utils/segmentationHandlers.ts @@ -1,5 +1,7 @@ import * as cornerstoneTools from '@cornerstonejs/tools'; import { updateSegmentationStats } from './updateSegmentationStats'; +import { useSelectedSegmentationsForViewportStore } from '../stores'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; /** * Sets up the handler for segmentation data modification events @@ -110,3 +112,49 @@ export function setupSegmentationModifiedHandler({ segmentationService }) { return { unsubscribe }; } + +/** + * Sets up auto tab switching for when the first segmentation is added into the viewer. + */ +export function setUpSelectedSegmentationsForViewportHandler({ segmentationService }) { + const selectedSegmentationsForViewportEvents = [ + segmentationService.EVENTS.SEGMENTATION_MODIFIED, + segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, + ]; + + const unsubscribeSelectedSegmentationsForViewportEvents = selectedSegmentationsForViewportEvents + .map(eventName => + segmentationService.subscribe(eventName, event => { + const { viewportId } = event; + + if (!viewportId) { + return; + } + + const { selectedSegmentationsForViewport, setSelectedSegmentationsForViewport } = + useSelectedSegmentationsForViewportStore.getState(); + + const representations = segmentationService.getSegmentationRepresentations(viewportId); + + const activeRepresentation = representations.find(representation => representation.active); + + const typeToSegmentationIdMap = + selectedSegmentationsForViewport[viewportId] ?? + new Map(); + + if (activeRepresentation) { + typeToSegmentationIdMap.set( + activeRepresentation.type, + activeRepresentation.segmentationId + ); + } else { + typeToSegmentationIdMap.clear(); + } + + setSelectedSegmentationsForViewport(viewportId, typeToSegmentationIdMap); + }) + ) + .map(subscription => subscription.unsubscribe); + + return { unsubscribeSelectedSegmentationsForViewportEvents }; +} diff --git a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts index 5503b5d43..0f4117ebf 100644 --- a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts +++ b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.test.ts @@ -2,11 +2,13 @@ import { setUpSegmentationEventHandlers } from './setUpSegmentationEventHandlers import { setupSegmentationDataModifiedHandler, setupSegmentationModifiedHandler, + setUpSelectedSegmentationsForViewportHandler, } from './segmentationHandlers'; jest.mock('./segmentationHandlers', () => ({ setupSegmentationDataModifiedHandler: jest.fn(), setupSegmentationModifiedHandler: jest.fn(), + setUpSelectedSegmentationsForViewportHandler: jest.fn(), })); describe('setUpSegmentationEventHandlers', () => { @@ -38,6 +40,7 @@ describe('setUpSegmentationEventHandlers', () => { const mockUnsubscribeDataModified = jest.fn(); const mockUnsubscribeModified = jest.fn(); const mockUnsubscribeCreated = jest.fn(); + const mockUnsubscribeSelectedSegmentationsForViewportEvents = [jest.fn(), jest.fn()]; const defaultParameters = { servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager, @@ -52,6 +55,10 @@ describe('setUpSegmentationEventHandlers', () => { (setupSegmentationModifiedHandler as jest.Mock).mockReturnValue({ unsubscribe: mockUnsubscribeModified, }); + (setUpSelectedSegmentationsForViewportHandler as jest.Mock).mockReturnValue({ + unsubscribeSelectedSegmentationsForViewportEvents: + mockUnsubscribeSelectedSegmentationsForViewportEvents, + }); mockSegmentationService.subscribe.mockReturnValue({ unsubscribe: mockUnsubscribeCreated, }); @@ -92,6 +99,7 @@ describe('setUpSegmentationEventHandlers', () => { mockUnsubscribeDataModified, mockUnsubscribeModified, mockUnsubscribeCreated, + ...mockUnsubscribeSelectedSegmentationsForViewportEvents, ], }); }); diff --git a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts index 678ce2229..bd255ae1b 100644 --- a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts +++ b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts @@ -1,4 +1,5 @@ import { + setUpSelectedSegmentationsForViewportHandler, setupSegmentationDataModifiedHandler, setupSegmentationModifiedHandler, } from './segmentationHandlers'; @@ -28,7 +29,9 @@ export const setUpSegmentationEventHandlers = ({ servicesManager, commandsManage const segmentation = segmentationService.getSegmentation(segmentationId); const label = segmentation.cachedStats.info; - const imageIds = segmentation.representationData.Labelmap.imageIds; + const imageIds = + segmentation.representationData?.Labelmap?.imageIds ?? + segmentation.representationData?.Contour?.imageIds; // Create a display set for the segmentation const segmentationDisplaySet = { @@ -50,10 +53,16 @@ export const setUpSegmentationEventHandlers = ({ servicesManager, commandsManage } ); + const { unsubscribeSelectedSegmentationsForViewportEvents } = + setUpSelectedSegmentationsForViewportHandler({ + segmentationService, + }); + const unsubscriptions = [ unsubscribeSegmentationDataModifiedHandler, unsubscribeSegmentationModifiedHandler, unsubscribeSegmentationCreated, + ...unsubscribeSelectedSegmentationsForViewportEvents, ]; return { unsubscriptions }; diff --git a/extensions/default/src/Components/SidePanelWithServices.tsx b/extensions/default/src/Components/SidePanelWithServices.tsx index 298f5ae03..f200dbd56 100644 --- a/extensions/default/src/Components/SidePanelWithServices.tsx +++ b/extensions/default/src/Components/SidePanelWithServices.tsx @@ -28,7 +28,7 @@ const SidePanelWithServices = ({ onClose, ...props }: SidePanelWithServicesProps) => { - const panelService = servicesManager?.services?.panelService; + const { panelService, toolbarService, viewportGridService } = servicesManager.services; // Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM. // Thus going to the Study List page and back to the viewer resets this flag for a SidePanel. @@ -37,9 +37,15 @@ const SidePanelWithServices = ({ const [closedManually, setClosedManually] = useState(false); const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side)); - const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => { - setActiveTabIndex(activeTabIndex); - }, []); + const handleActiveTabIndexChange = useCallback( + ({ activeTabIndex }) => { + const { activeViewportId: viewportId } = viewportGridService.getState(); + toolbarService.refreshToolbarState({ viewportId }); + + setActiveTabIndex(activeTabIndex); + }, + [toolbarService, viewportGridService] + ); const handleOpen = useCallback(() => { setSidePanelExpanded(true); diff --git a/extensions/default/src/Toolbar/Toolbar.tsx b/extensions/default/src/Toolbar/Toolbar.tsx index 3cf27b840..02971484f 100644 --- a/extensions/default/src/Toolbar/Toolbar.tsx +++ b/extensions/default/src/Toolbar/Toolbar.tsx @@ -1,9 +1,30 @@ import React from 'react'; import { useToolbar } from '@ohif/core'; +/** + * Props for the Toolbar component that renders a collection of toolbar buttons and/or button sections. + * + * @interface ToolbarProps + */ interface ToolbarProps { + /** + * The section of buttons to display in the toolbar. + * Common values include 'primary', 'secondary', 'tertiary', etc. + * Defaults to 'primary' if not specified. + * + * @default 'primary' + */ buttonSection?: string; + + /** + * The unique identifier of the viewport this toolbar is associated with. + */ viewportId?: string; + + /** + * The numeric position or location of the toolbar. + * Used for ordering and layout purposes in the UI. + */ location?: number; } @@ -60,7 +81,17 @@ export function Toolbar({ buttonSection = 'primary', viewportId, location }: Too /> ); - return
{tool}
; + return ( +
+ {tool} +
+ ); })} ); diff --git a/extensions/default/src/getToolbarModule.tsx b/extensions/default/src/getToolbarModule.tsx index 6e9b32d4a..0c295accf 100644 --- a/extensions/default/src/getToolbarModule.tsx +++ b/extensions/default/src/getToolbarModule.tsx @@ -10,6 +10,7 @@ import ToolButtonListWrapper from './Toolbar/ToolButtonListWrapper'; import ToolRowWrapper from './Toolbar/ToolRowWrapper'; import { ToolBoxButtonGroupWrapper, ToolBoxButtonWrapper } from './Toolbar/ToolBoxWrapper'; import { ToolButtonWrapper } from './Toolbar/ToolButtonWrapper'; +import { Toolbar } from './Toolbar'; export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) { const { cineService } = servicesManager.services; @@ -45,6 +46,10 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w name: 'ohif.progressDropdown', defaultComponent: ProgressDropdownWithService, }, + { + name: 'ohif.Toolbar', + defaultComponent: Toolbar, + }, { name: 'evaluate.cine', evaluate: () => { diff --git a/extensions/default/src/utils/Toolbox.tsx b/extensions/default/src/utils/Toolbox.tsx index 42781fd8e..90aa0f756 100644 --- a/extensions/default/src/utils/Toolbox.tsx +++ b/extensions/default/src/utils/Toolbox.tsx @@ -1,12 +1,21 @@ import React, { useState } from 'react'; import { Icons, PanelSection, ToolSettings } from '@ohif/ui-next'; -import { useSystem, useToolbar } from '@ohif/core'; -import classnames from 'classnames'; +import { useSystem, useToolbar, useActiveToolOptions } from '@ohif/core'; import { useTranslation } from 'react-i18next'; -interface ButtonProps { - isActive?: boolean; - options?: unknown; +/** + * Props for the Toolbox component that renders a collection of toolbar button sections. + */ +interface ToolboxProps { + /** + * The unique identifier of the button section this toolbox represents. + */ + buttonSectionId: string; + + /** + * The display title for the toolbox. + */ + title: string; } /** @@ -19,7 +28,7 @@ interface ButtonProps { * role in enhancing the app with a toolbox by providing a way to integrate * and display various tools and their corresponding options */ -export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; title: string }) { +export function Toolbox({ buttonSectionId, title }: ToolboxProps) { const { servicesManager } = useSystem(); const { t } = useTranslation(); @@ -30,6 +39,8 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t buttonSection: buttonSectionId, }); + const { activeToolOptions } = useActiveToolOptions({ buttonSectionId }); + if (!toolboxSections.length) { return null; } @@ -41,35 +52,6 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t ); } - // Helper to check a list of buttons for an active tool. - const findActiveOptions = (buttons: any[]): unknown => { - for (const tool of buttons) { - if (tool.componentProps.isActive) { - return tool.componentProps.options; - } - if (tool.componentProps.buttonSection) { - const nestedButtons = toolbarService.getButtonPropsInButtonSection( - tool.componentProps.buttonSection - ) as ButtonProps[]; - const activeNested = nestedButtons.find(nested => nested.isActive); - if (activeNested) { - return activeNested.options; - } - } - } - return null; - }; - - // Look for active tool options across all sections. - const activeToolOptions = toolboxSections.reduce((activeOptions, section) => { - if (activeOptions) { - return activeOptions; - } - const sectionId = section.componentProps.buttonSection; - const buttons = toolbarService.getButtonSection(sectionId); - return findActiveOptions(buttons); - }, null); - // Define the interaction handler once. const handleInteraction = ({ itemId }: { itemId: string }) => { onInteraction?.({ itemId }); @@ -103,19 +85,20 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t return (
{buttons.map(tool => { - if (!tool) { + // Skip over tools that are not visible. The visible flag is typically set to + // false as a result of the evaluator function. The evaluator might explicitly + // set visible to false. Alternatively, the ToolbarService will set the visible flag to + // false when the evaluator sets disabled to true and the tool has the hideWhenDisabled flag set to true. + if (!tool || !tool.componentProps.visible) { return null; } const { id, Component, componentProps } = tool; return ( -
+
{ - const { measurementService, toolbarService, toolGroupService, customizationService } = - servicesManager.services; + const { + measurementService, + toolbarService, + toolGroupService, + segmentationService, + viewportGridService, + panelService, + } = servicesManager.services; measurementService.clearMeasurements(); @@ -114,22 +125,54 @@ function modeFactory({ modeConfiguration }) { 'TagBrowser', ]); - toolbarService.updateSection(toolbarService.sections.segmentationToolbox, [ - 'SegmentationUtilities', - 'SegmentationTools', + toolbarService.updateSection(toolbarService.sections.labelMapSegmentationToolbox, [ + 'LabelMapTools', ]); - toolbarService.updateSection('SegmentationUtilities', [ + toolbarService.updateSection(toolbarService.sections.contourSegmentationToolbox, [ + 'ContourTools', + ]); + + toolbarService.updateSection('LabelMapTools', [ 'LabelmapSlicePropagation', - 'InterpolateLabelmap', -'SegmentBidirectional', - ]); - toolbarService.updateSection('SegmentationTools', [ 'BrushTools', 'MarkerLabelmap', 'RegionSegmentPlus', 'Shapes', + 'LabelMapEditWithContour', ]); + toolbarService.updateSection('ContourTools', [ + 'PlanarFreehandContourSegmentationTool', + 'SculptorTool', + 'SplineContourSegmentationTool', + 'LivewireContourSegmentationTool', + ]); + + toolbarService.updateSection(toolbarService.sections.labelMapSegmentationUtilities, [ + 'LabelMapUtilities', + ]); + toolbarService.updateSection(toolbarService.sections.contourSegmentationUtilities, [ + 'ContourUtilities', + ]); + + toolbarService.updateSection('LabelMapUtilities', [ + 'InterpolateLabelmap', + 'SegmentBidirectional', + ]); + toolbarService.updateSection('ContourUtilities', [ + 'LogicalContourOperations', + 'SimplifyContours', + 'SmoothContours', + ]); + toolbarService.updateSection('BrushTools', ['Brush', 'Eraser', 'Threshold']); + + const { unsubscribeAutoTabSwitchEvents } = setUpAutoTabSwitchHandler({ + segmentationService, + viewportGridService, + panelService, + }); + + _unsubscriptions.push(...unsubscribeAutoTabSwitchEvents); }, onModeExit: ({ servicesManager }: withAppTypes) => { const { @@ -141,6 +184,9 @@ function modeFactory({ modeConfiguration }) { uiModalService, } = servicesManager.services; + _unsubscriptions.forEach(unsubscribe => unsubscribe()); + _unsubscriptions.length = 0; + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); @@ -192,7 +238,10 @@ function modeFactory({ modeConfiguration }) { props: { leftPanels: [ohif.leftPanel], leftPanelResizable: true, - rightPanels: [cornerstone.panelTool], + rightPanels: [ + cornerstone.contourSegmentationPanel, + cornerstone.labelMapSegmentationPanel, + ], rightPanelResizable: true, // leftPanelClosed: true, viewports: [ diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index 48a153032..bef3b1d3e 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -106,6 +106,9 @@ function createTools({ utilityModule, commandsManager }) { }, }, }, + { + toolName: toolNames.LabelMapEditWithContourTool, + }, { toolName: toolNames.CircleScissors }, { toolName: toolNames.RectangleScissors }, { toolName: toolNames.SphereScissors }, @@ -114,6 +117,42 @@ function createTools({ utilityModule, commandsManager }) { { toolName: toolNames.WindowLevelRegion }, { toolName: toolNames.UltrasoundDirectional }, + { + toolName: toolNames.PlanarFreehandContourSegmentation, + }, + { toolName: toolNames.LivewireContourSegmentation }, + { toolName: toolNames.SculptorTool }, + { toolName: toolNames.PlanarFreehandROI }, + { + toolName: 'CatmullRomSplineROI', + parentTool: toolNames.SplineContourSegmentation, + configuration: { + spline: { + type: 'CATMULLROM', + enableTwoPointPreview: true, + }, + }, + }, + { + toolName: 'LinearSplineROI', + parentTool: toolNames.SplineContourSegmentation, + configuration: { + spline: { + type: 'LINEAR', + enableTwoPointPreview: true, + }, + }, + }, + { + toolName: 'BSplineROI', + parentTool: toolNames.SplineContourSegmentation, + configuration: { + spline: { + type: 'BSPLINE', + enableTwoPointPreview: true, + }, + }, + }, ], disabled: [{ toolName: toolNames.ReferenceLines }, { toolName: toolNames.AdvancedMagnify }], }; diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 03bc7c7a5..ced9497c7 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -174,17 +174,31 @@ const toolbarButtons: Button[] = [ buttonSection: true, }, }, - // Section containers for the nested toolbox + // Section containers for the nested toolboxes and toolbars. { - id: 'SegmentationUtilities', - uiType: 'ohif.toolBoxButton', + id: 'LabelMapUtilities', + uiType: 'ohif.Toolbar', props: { buttonSection: true, }, }, { - id: 'SegmentationTools', - uiType: 'ohif.toolBoxButton', + id: 'ContourUtilities', + uiType: 'ohif.Toolbar', + props: { + buttonSection: true, + }, + }, + { + id: 'LabelMapTools', + uiType: 'ohif.toolBoxButtonGroup', + props: { + buttonSection: true, + }, + }, + { + id: 'ContourTools', + uiType: 'ohif.toolBoxButtonGroup', props: { buttonSection: true, }, @@ -399,17 +413,254 @@ const toolbarButtons: Button[] = [ commands: 'openDICOMTagViewer', }, }, - + { + id: 'PlanarFreehandContourSegmentationTool', + uiType: 'ohif.toolBoxButton', + props: { + icon: 'icon-tool-freehand-roi', + label: 'Freehand Segmentation', + tooltip: 'Freehand Segmentation', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['PlanarFreehandContourSegmentationTool'], + disabledText: 'Create new segmentation to enable this tool.', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Contour', + }, + ], + commands: [ + { + commandName: 'setToolActiveToolbar', + commandOptions: { + bindings: [ + { + mouseButton: 1, // Left Click + }, + { + mouseButton: 1, // Left Click+Shift to create a hole + modifierKey: 16, // Shift + }, + ], + }, + }, + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Contour', + }, + }, + ], + options: [ + { + name: 'Interpolate Contours', + type: 'switch', + id: 'planarFreehandInterpolateContours', + value: false, + commands: { + commandName: 'setInterpolationToolConfiguration', + }, + }, + ], + }, + }, + { + id: 'LivewireContourSegmentationTool', + uiType: 'ohif.toolBoxButton', + props: { + icon: 'icon-tool-livewire', + label: 'Livewire Contour', + tooltip: 'Livewire Contour', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['LivewireContourSegmentationTool'], + disabledText: 'Create new segmentation to enable this tool.', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Contour', + }, + ], + commands: [ + { + commandName: 'setToolActiveToolbar', + commandOptions: { + bindings: [ + { + mouseButton: 1, // Left Click + }, + { + mouseButton: 1, // Left Click+Shift to create a hole + modifierKey: 16, // Shift + }, + ], + }, + }, + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Contour', + }, + }, + ], + options: [ + { + name: 'Interpolate Contours', + type: 'switch', + id: 'livewireInterpolateContours', + value: false, + commands: { + commandName: 'setInterpolationToolConfiguration', + }, + }, + ], + }, + }, + { + id: 'SplineContourSegmentationTool', + uiType: 'ohif.toolBoxButton', + props: { + icon: 'icon-tool-spline-roi', + label: 'Spline Contour Segmentation Tool', + tooltip: 'Spline Contour Segmentation Tool', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['CatmullRomSplineROI', 'LinearSplineROI', 'BSplineROI'], + disabledText: 'Create new segmentation to enable this tool.', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Contour', + }, + ], + commands: [ + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Contour', + }, + }, + ], + options: [ + { + name: 'Spline Type', + type: 'select', + id: 'splineTypeSelect', + value: 'CatmullRomSplineROI', + values: [ + { + id: 'CatmullRomSplineROI', + value: 'CatmullRomSplineROI', + label: 'Catmull Rom Spline', + }, + { id: 'LinearSplineROI', value: 'LinearSplineROI', label: 'Linear Spline' }, + { id: 'BSplineROI', value: 'BSplineROI', label: 'B-Spline' }, + ], + commands: { + commandName: 'setToolActiveToolbar', + commandOptions: { + bindings: [ + { + mouseButton: 1, // Left Click + }, + { + mouseButton: 1, // Left Click+Shift to create a hole + modifierKey: 16, // Shift + }, + ], + }, + }, + }, + { + name: 'Simplified Spline', + type: 'switch', + id: 'simplifiedSpline', + value: true, + commands: { + commandName: 'setSimplifiedSplineForSplineContourSegmentationTool', + }, + }, + { + name: 'Interpolate Contours', + type: 'switch', + id: 'splineInterpolateContours', + value: false, + commands: { + commandName: 'setInterpolationToolConfiguration', + commandOptions: { + toolNames: ['CatmullRomSplineROI', 'LinearSplineROI', 'BSplineROI'], + }, + }, + }, + ], + }, + }, + { + id: 'SculptorTool', + uiType: 'ohif.toolBoxButton', + props: { + icon: 'icon-tool-sculptor', + label: 'Sculptor Tool', + tooltip: 'Sculptor Tool', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['SculptorTool'], + disabledText: 'Create new segmentation to enable this tool.', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Contour', + }, + ], + commands: [ + 'setToolActiveToolbar', + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Contour', + }, + }, + ], + options: [ + { + name: 'Dynamic Cursor Size', + type: 'switch', + id: 'dynamicCursorSize', + value: true, + commands: { + commandName: 'setDynamicCursorSizeForSculptorTool', + }, + }, + ], + }, + }, { id: 'Brush', uiType: 'ohif.toolBoxButton', props: { icon: 'icon-tool-brush', label: i18n.t('Buttons:Brush'), - evaluate: { - name: 'evaluate.cornerstone.segmentation', - toolNames: ['CircularBrush', 'SphereBrush'], - disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['CircularBrush', 'SphereBrush'], + disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, }, options: [ { @@ -420,10 +671,12 @@ const toolbarButtons: Button[] = [ max: 99.5, step: 0.5, value: 25, - commands: { - commandName: 'setBrushSize', - commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] }, - }, + commands: [ + { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + ], }, { name: 'Shape', @@ -434,44 +687,72 @@ const toolbarButtons: Button[] = [ { value: 'CircularBrush', label: 'Circle' }, { value: 'SphereBrush', label: 'Sphere' }, ], - commands: 'setToolActiveToolbar', + commands: ['setToolActiveToolbar'], }, ], }, }, { id: 'InterpolateLabelmap', - uiType: 'ohif.toolBoxButton', + uiType: 'ohif.toolButton', props: { - icon: 'icon-tool-interpolation', + icon: 'actions-interpolate', label: i18n.t('Buttons:Interpolate Labelmap'), tooltip: i18n.t( 'Buttons:Automatically fill in missing slices between drawn segments. Use brush or threshold tools on at least two slices, then click to interpolate across slices. Works in any direction. Volume must be reconstructable.' ), evaluate: [ - 'evaluate.cornerstone.segmentation', + { + name: 'evaluate.cornerstone.segmentation', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, { name: 'evaluate.displaySetIsReconstructable', disabledText: i18n.t('Buttons:The current viewport cannot handle interpolation.'), }, ], - commands: 'interpolateLabelmap', + commands: [ + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, + 'interpolateLabelmap', + ], }, }, { id: 'SegmentBidirectional', - uiType: 'ohif.toolBoxButton', + uiType: 'ohif.toolButton', props: { - icon: 'icon-tool-bidirectional-segment', + icon: 'actions-bidirectional', label: i18n.t('Buttons:Segment Bidirectional'), tooltip: i18n.t( 'Buttons:Automatically detects the largest length and width across slices for the selected segment and displays a bidirectional measurement.' ), - evaluate: { - name: 'evaluate.cornerstone.segmentation', - disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), - }, - commands: 'runSegmentBidirectional', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: [ + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, + 'runSegmentBidirectional', + ], }, }, { @@ -483,12 +764,26 @@ const toolbarButtons: Button[] = [ tooltip: i18n.t( 'Buttons:Detects segmentable regions with one click. Hover for visual feedback—click when a plus sign appears to auto-segment the lesion.' ), - evaluate: { - name: 'evaluate.cornerstone.segmentation', - toolNames: ['RegionSegmentPlus'], - disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), - }, - commands: 'setToolActiveToolbar', + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['RegionSegmentPlus'], + disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'), + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: [ + 'setToolActiveToolbar', + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, + ], }, }, { @@ -503,7 +798,8 @@ const toolbarButtons: Button[] = [ evaluate: [ 'evaluate.cornerstoneTool.toggle', { - name: 'evaluate.cornerstone.hasSegmentation', + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', }, ], listeners: { @@ -512,7 +808,15 @@ const toolbarButtons: Button[] = [ ), [ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('LabelmapSlicePropagation'), }, - commands: 'toggleEnabledDisabledToolbar', + commands: [ + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, + 'toggleEnabledDisabledToolbar', + ], }, }, { @@ -529,8 +833,20 @@ const toolbarButtons: Button[] = [ name: 'evaluate.cornerstone.segmentation', toolNames: ['MarkerLabelmap', 'MarkerInclude', 'MarkerExclude'], }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: [ + 'setToolActiveToolbar', + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, ], - commands: 'setToolActiveToolbar', listeners: { [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('MarkerLabelmap'), [ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('MarkerLabelmap'), @@ -573,10 +889,16 @@ const toolbarButtons: Button[] = [ props: { icon: 'icon-tool-eraser', label: i18n.t('Buttons:Eraser'), - evaluate: { - name: 'evaluate.cornerstone.segmentation', - toolNames: ['CircularEraser', 'SphereEraser'], - }, + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['CircularEraser', 'SphereEraser'], + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], options: [ { name: 'Radius (mm)', @@ -603,6 +925,12 @@ const toolbarButtons: Button[] = [ commands: 'setToolActiveToolbar', }, ], + commands: { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, + }, }, }, { @@ -611,15 +939,28 @@ const toolbarButtons: Button[] = [ props: { icon: 'icon-tool-threshold', label: 'Threshold Tool', - evaluate: { - name: 'evaluate.cornerstone.segmentation', - toolNames: [ - 'ThresholdCircularBrush', - 'ThresholdSphereBrush', - 'ThresholdCircularBrushDynamic', - 'ThresholdSphereBrushDynamic', - ], + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: [ + 'ThresholdCircularBrush', + 'ThresholdSphereBrush', + 'ThresholdCircularBrushDynamic', + 'ThresholdSphereBrushDynamic', + ], + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, }, + options: [ { name: 'Radius (mm)', @@ -728,10 +1069,22 @@ const toolbarButtons: Button[] = [ props: { icon: 'icon-tool-shape', label: i18n.t('Buttons:Shapes'), - evaluate: { - name: 'evaluate.cornerstone.segmentation', - toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'], - disabledText: i18n.t('Buttons:Create new segmentation to enable shapes tool.'), + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'], + disabledText: i18n.t('Buttons:Create new segmentation to enable shapes tool.'), + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + commands: { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { + segmentationRepresentationType: 'Labelmap', + }, }, options: [ { @@ -749,6 +1102,81 @@ const toolbarButtons: Button[] = [ ], }, }, + { + id: 'SimplifyContours', + uiType: 'ohif.toolButton', + props: { + icon: 'actions-simplify', + label: 'Simplify Contours', + tooltip: 'Simplify Contours', + commands: ['toggleActiveSegmentationUtility'], + evaluate: [ + { + name: 'cornerstone.isActiveSegmentationUtility', + }, + ], + options: 'cornerstone.SimplifyContourOptions', + }, + }, + { + id: 'SmoothContours', + uiType: 'ohif.toolButton', + props: { + icon: 'actions-smooth', + label: 'Smooth Contours', + tooltip: 'Smooth Contours', + commands: ['toggleActiveSegmentationUtility'], + evaluate: [ + { + name: 'cornerstone.isActiveSegmentationUtility', + }, + ], + options: 'cornerstone.SmoothContoursOptions', + }, + }, + { + id: 'LogicalContourOperations', + uiType: 'ohif.toolButton', + props: { + icon: 'actions-combine', + label: 'Combine Contours', + tooltip: 'Combine Contours', + commands: ['toggleActiveSegmentationUtility'], + evaluate: [ + { + name: 'cornerstone.isActiveSegmentationUtility', + }, + ], + options: 'cornerstone.LogicalContourOperationsOptions', + }, + }, + { + id: 'LabelMapEditWithContour', + uiType: 'ohif.toolBoxButton', + props: { + icon: 'tool-labelmap-edit-with-contour', + label: 'Labelmap Edit with Contour Tool', + tooltip: 'Labelmap Edit with Contour Tool', + commands: [ + 'setToolActiveToolbar', + { + commandName: 'activateSelectedSegmentationOfType', + commandOptions: { segmentationRepresentationType: 'Labelmap' }, + }, + ], + evaluate: [ + { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['LabelMapEditWithContour'], + disabledText: 'Create new segmentation to enable this tool.', + }, + { + name: 'evaluate.cornerstone.hasSegmentationOfType', + segmentationRepresentationType: 'Labelmap', + }, + ], + }, + }, ]; export default toolbarButtons; diff --git a/modes/segmentation/src/utils/setUpAutoTabSwitchHandler.ts b/modes/segmentation/src/utils/setUpAutoTabSwitchHandler.ts new file mode 100644 index 000000000..a0594bace --- /dev/null +++ b/modes/segmentation/src/utils/setUpAutoTabSwitchHandler.ts @@ -0,0 +1,56 @@ +/** + * Sets up auto tab switching for when the first segmentation is added into the viewer. + */ +export default function setUpAutoTabSwitchHandler({ + segmentationService, + viewportGridService, + panelService, +}) { + const autoTabSwitchEvents = [ + segmentationService.EVENTS.SEGMENTATION_MODIFIED, + segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, + ]; + + // Initially there are no segmentations, so we should switch the tab whenever the first segmentation is added. + let shouldSwitchTab = true; + + const unsubscribeAutoTabSwitchEvents = autoTabSwitchEvents + .map(eventName => + segmentationService.subscribe(eventName, () => { + const segmentations = segmentationService.getSegmentations(); + + if (!segmentations.length) { + // If all the segmentations are removed, then the next time a segmentation is added, we should switch the tab. + shouldSwitchTab = true; + return; + } + + const activeViewportId = viewportGridService.getActiveViewportId(); + const activeRepresentation = segmentationService + .getSegmentationRepresentations(activeViewportId) + ?.find(representation => representation.active); + + if (activeRepresentation && shouldSwitchTab) { + shouldSwitchTab = false; + + switch (activeRepresentation.type) { + case 'Labelmap': + panelService.activatePanel( + '@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap', + true + ); + break; + case 'Contour': + panelService.activatePanel( + '@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour', + true + ); + break; + } + } + }) + ) + .map(subscription => subscription.unsubscribe); + + return { unsubscribeAutoTabSwitchEvents }; +} diff --git a/platform/core/src/hooks/index.ts b/platform/core/src/hooks/index.ts index eb47ffed4..78db3cd3c 100644 --- a/platform/core/src/hooks/index.ts +++ b/platform/core/src/hooks/index.ts @@ -3,3 +3,5 @@ export * from './types'; export * from './useViewportRef'; export * from './useViewportSize'; export * from './useViewportMousePosition'; +export * from './useActiveToolOptions'; +export * from './useRunCommand'; diff --git a/platform/core/src/hooks/useActiveToolOptions.tsx b/platform/core/src/hooks/useActiveToolOptions.tsx new file mode 100644 index 000000000..0aea85de2 --- /dev/null +++ b/platform/core/src/hooks/useActiveToolOptions.tsx @@ -0,0 +1,53 @@ +import { useSystem } from '../contextProviders/SystemProvider'; +import { ButtonProps } from '../services/ToolBarService/types'; +import { useToolbar } from './useToolbar'; + +type UseActiveToolOptionsProps = { + buttonSectionId: string; +}; + +export function useActiveToolOptions({ buttonSectionId }: UseActiveToolOptionsProps) { + const { servicesManager } = useSystem(); + const { toolbarService } = servicesManager.services; + + const { toolbarButtons } = useToolbar({ + buttonSection: buttonSectionId, + }); + + // Helper to check a list of buttons for an active tool. + const findActiveOptions = (buttons: any[]): unknown => { + for (const tool of buttons) { + if (tool.componentProps.isActive) { + return { + activeToolOptions: tool.componentProps.options, + activeToolButtonId: tool.componentProps.id, + }; + } + if (tool.componentProps.buttonSection) { + const nestedButtons = toolbarService.getButtonPropsInButtonSection( + tool.componentProps.buttonSection + ) as ButtonProps[]; + const activeNested = nestedButtons.find(nested => nested.isActive); + if (activeNested) { + return { + activeToolOptions: activeNested.options, + activeToolButtonId: activeNested.id, + }; + } + } + } + return null; + }; + + // Look for active tool options across all sections. + const activeOptions = toolbarButtons.reduce((activeOptions, button) => { + if (activeOptions) { + return activeOptions; + } + const sectionId = button.componentProps.buttonSection; + const buttons = sectionId ? toolbarService.getButtonSection(sectionId) : [button]; + return findActiveOptions(buttons); + }, null); + + return activeOptions ?? {}; +} diff --git a/platform/core/src/hooks/useRunCommand.tsx b/platform/core/src/hooks/useRunCommand.tsx new file mode 100644 index 000000000..cb5c5a592 --- /dev/null +++ b/platform/core/src/hooks/useRunCommand.tsx @@ -0,0 +1,19 @@ +import { useCallback } from 'react'; +import { useSystem } from '../contextProviders/SystemProvider'; + +/** + * Hook that provides a runCommand function for executing commands + * @returns A memoized runCommand function + */ +export function useRunCommand() { + const { commandsManager } = useSystem(); + + const runCommand = useCallback( + (commandName: string, commandOptions: Record = {}) => { + return commandsManager.runCommand(commandName, commandOptions); + }, + [commandsManager] + ); + + return runCommand; +} diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 873a886ca..515df0583 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -38,7 +38,10 @@ export const TOOLBAR_SECTIONS = { }, // mode specific - segmentationToolbox: 'segmentationToolbox', + labelMapSegmentationToolbox: 'labelMapSegmentationToolbox', + contourSegmentationToolbox: 'contourSegmentationToolbox', + labelMapSegmentationUtilities: 'labelMapSegmentationUtilities', + contourSegmentationUtilities: 'contourSegmentationUtilities', dynamicToolbox: 'dynamic-toolbox', roiThresholdToolbox: 'ROIThresholdToolbox', }; @@ -314,12 +317,22 @@ export default class ToolbarService extends PubSubService { : undefined; // Check hideWhenDisabled at both evaluateProps level and props level const hideWhenDisabled = evaluateProps?.hideWhenDisabled || props.hideWhenDisabled; + + // Visibility is first determined by the evaluate function. If it is not returned from there, + // then we check hideWhenDisabled and disabled to determine visibility. + const visible = + evaluated?.visible === undefined + ? hideWhenDisabled && evaluated?.disabled + ? false + : true + : evaluated?.visible; + const updatedProps = { ...props, ...evaluated, disabled: evaluated?.disabled || false, - visible: hideWhenDisabled && evaluated?.disabled ? false : true, - className: evaluated?.className || '', + visible, + className: evaluated?.className || props?.className || '', isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop }; evaluationResults.set(button.id, updatedProps); @@ -570,7 +583,8 @@ export default class ToolbarService extends PubSubService { btn.component = buttonType.defaultComponent; } - if (!buttonType) { + if (!buttonType && !btn.component) { + console.warn(`Neither button type nor a component found for button: ${id}`); return; } diff --git a/platform/core/src/services/ToolBarService/types.ts b/platform/core/src/services/ToolBarService/types.ts index d684172b5..317197cb5 100644 --- a/platform/core/src/services/ToolBarService/types.ts +++ b/platform/core/src/services/ToolBarService/types.ts @@ -34,7 +34,7 @@ export type EvaluateObject = { export type ButtonOptions = { id: string; - type: 'range' | 'radio' | 'double-range' | 'custom'; + type: 'range' | 'radio' | 'double-range' | 'custom' | 'checkbox' | 'select' | 'button'; name?: string; min?: number; max?: number; @@ -59,6 +59,7 @@ export type ButtonProps = { listeners?: Record; options?: ButtonOptions[]; buttonSection?: string | boolean; + isActive?: boolean; }; export type Button = { diff --git a/platform/docs/docs/platform/extensions/modules/toolbar.md b/platform/docs/docs/platform/extensions/modules/toolbar.md index 63197d6b2..b45500400 100644 --- a/platform/docs/docs/platform/extensions/modules/toolbar.md +++ b/platform/docs/docs/platform/extensions/modules/toolbar.md @@ -117,6 +117,13 @@ Let's look at one of the evaluators (for `evaluate.cornerstoneTool`) as you can see the job of this evaluator is to determine if the button should be disabled or not. It does so by checking the `toolGroup` and the `toolName` and then returns an object with `disabled` and `className` properties. +Various information can be returned by an evaluator. In particular... +- `disabled`: flag indicating if the tool should be disabled or not +- `disabledText`: the text or tooltip to show if the `disabled` flag above is set to `true` +- `visible`: flag indicating if the tool show be visible or not; this is a convenient way to force a tool to hide based on some custom (evaluator) logic +- `isActive`: flag to indicating where the tool is currently active or not +- `className`: custom CSS class names to add to the tool's component + The following evaluators are provided by us: - `evaluate.cornerstoneTool`: If assigned to a button (see next), it will make the button react to the active viewport state based on its toolGroup. diff --git a/platform/docs/docs/platform/services/data/SegmentationService.md b/platform/docs/docs/platform/services/data/SegmentationService.md index 031594862..fd8f257b4 100644 --- a/platform/docs/docs/platform/services/data/SegmentationService.md +++ b/platform/docs/docs/platform/services/data/SegmentationService.md @@ -34,6 +34,17 @@ createLabelmapForDisplaySet( } } ) + +createContourForDisplaySet( + displaySet, + { + segmentationId?: string, + label: string, + segments?: { + [segmentIndex: number]: Partial + } + } +) ``` ### Segmentation Management @@ -85,17 +96,33 @@ interface Segmentation { ## Code Examples -### Creating a Segmentation +### Creating a Label Map Segmentation ```typescript const displaySet = displaySetService.getDisplaySetByUID(displaySetUID); const segmentationId = await segmentationService.createLabelmapForDisplaySet( displaySet, { - label: 'New Segmentation', + label: 'New Label Map Segmentation', segments: { 1: { - label: 'First Segment', + label: 'First Label Map Segment', + active: true + } + } + } +); + +### Creating a Label Map Segmentation + +const displaySet = displaySetService.getDisplaySetByUID(displaySetUID); +const segmentationId = await segmentationService.createContourForDisplaySet( + displaySet, + { + label: 'New Contour Segmentation', + segments: { + 1: { + label: 'First Contour Segment', active: true } } diff --git a/platform/i18n/src/locales/en-US/SegmentationTable.json b/platform/i18n/src/locales/en-US/SegmentationTable.json index 8d73016f3..c5457cb67 100644 --- a/platform/i18n/src/locales/en-US/SegmentationTable.json +++ b/platform/i18n/src/locales/en-US/SegmentationTable.json @@ -3,16 +3,25 @@ "Add new segmentation": "Add new segmentation", "Add segment": "Add segment", "Add segmentation": "Add segmentation", + "Contour": "Contour", + "Contour tools": "Contour tools", + "Contour Segmentations": "Contour segmentations", "Delete": "Delete", "Display inactive segmentations": "Display inactive segmentations", + "Download DICOM RTSS": "Download DICOM RTSS", "Export DICOM SEG": "Export DICOM SEG", "Download DICOM SEG": "Download DICOM SEG", "Download DICOM RTSTRUCT": "Download DICOM RTSTRUCT", "Fill": "Fill", "Inactive segmentations": "Inactive segmentations", + "Labelmap": "Label map", + "Labelmap tools": "Label map tools", + "Labelmap Segmentations": "Label map segmentations", "Opacity": "Opacity", "Outline": "Outline", "Rename": "Rename", "Segmentation": "Segmentation", + "Segmentations": "Segmentations", + "Segmentation not supported": "Segmentation not supported", "Size": "Size" } diff --git a/platform/i18n/src/locales/test-LNG/SegmentationTable.json b/platform/i18n/src/locales/test-LNG/SegmentationTable.json index 45f6aae72..31eb2f6e0 100644 --- a/platform/i18n/src/locales/test-LNG/SegmentationTable.json +++ b/platform/i18n/src/locales/test-LNG/SegmentationTable.json @@ -3,16 +3,24 @@ "Add new segmentation": "Test Add new segmentation", "Add segment": "Test Add segment", "Add segmentation": "Test Add segmentation", + "Contour": "Test Contour", + "Contour tools": "Test Contour tools", + "Contour Segmentations": "Contour segmentations", "Delete": "Test Delete", "Display inactive segmentations": "Test Display inactive segmentations", + "Download DICOM RTSS": "Test Download DICOM RTSS", "Export DICOM SEG": "Test Export DICOM SEG", "Download DICOM SEG": "Test Download DICOM SEG", "Download DICOM RTSTRUCT": "Test Download DICOM RTSTRUCT", "Fill": "Test Fill", "Inactive segmentations": "Test Inactive segmentations", + "Labelmap": "Test Label map", + "Labelmap tools": "Test Label map tools", + "Labelmap Segmentations": "Test Label map segmentations", "Opacity": "Test Opacity", "Outline": "Test Outline", "Rename": "Test Rename", "Segmentation": "Test Segmentation", + "Segmentation not supported": "Test Segmentation not supported", "Size": "Test Size" } diff --git a/platform/ui-next/src/components/DataRow/DataRow.tsx b/platform/ui-next/src/components/DataRow/DataRow.tsx index 5ff447cd0..eac1ea6a8 100644 --- a/platform/ui-next/src/components/DataRow/DataRow.tsx +++ b/platform/ui-next/src/components/DataRow/DataRow.tsx @@ -87,7 +87,10 @@ interface DataRowProps { description: string; details?: { primary: string[]; secondary: string[] }; // + /** Primary selection: selected and in the active segmentation */ isSelected?: boolean; + /** Secondary selection: selected but in an inactive segmentation */ + isSecondarySelected?: boolean; onSelect?: (e) => void; // isVisible: boolean; @@ -103,6 +106,7 @@ interface DataRowProps { // colorHex?: string; onColor: (e) => void; + onCopy?: (e) => void; className?: string; children?: React.ReactNode; } @@ -121,7 +125,9 @@ const DataRowComponent = React.forwardRef( onRename, onDelete, onColor, + onCopy, isSelected = false, + isSecondarySelected = false, isVisible = true, disableEditing = false, className, @@ -146,6 +152,9 @@ const DataRowComponent = React.forwardRef( case 'Rename': onRename(e); break; + case 'Copy': + onCopy?.(e); + break; case 'Lock': onToggleLocked(e); break; @@ -234,7 +243,11 @@ const DataRowComponent = React.forwardRef( onClick={onSelect} data-cy="data-row" > - {/* Hover Overlay */} + {/* Secondary Selection Tint (below hover, always visible when secondary-selected) */} + {isSecondarySelected && ( +
+ )} +
{/* Number Box */} @@ -351,6 +364,17 @@ const DataRowComponent = React.forwardRef( Rename + {onCopy && ( + handleAction('Copy', e)}> + + + Duplicate + + + )} handleAction('Delete', e)}> ; @@ -674,6 +677,8 @@ export const Icons = { 'status-tracked': (props: IconProps) => StatusTracking(props), 'status-untracked': (props: IconProps) => StatusUntracked(props), 'status-locked': (props: IconProps) => StatusLocked(props), + 'tab-contours': (props: IconProps) => TabContours(props), + TabContours: (props: IconProps) => TabContours(props), 'tab-segmentation': (props: IconProps) => TabSegmentation(props), 'tab-studies': (props: IconProps) => TabStudies(props), 'tab-linear': (props: IconProps) => TabLinear(props), @@ -720,6 +725,7 @@ export const Icons = { 'tool-referenceLines': (props: IconProps) => ToolReferenceLines(props), 'tool-reset': (props: IconProps) => ToolReset(props), 'tool-rotate-right': (props: IconProps) => ToolRotateRight(props), + 'icon-tool-sculptor': (props: IconProps) => ToolSculptor(props), 'tool-seg-brush': (props: IconProps) => ToolSegBrush(props), 'tool-seg-eraser': (props: IconProps) => ToolSegEraser(props), 'tool-seg-shape': (props: IconProps) => ToolSegShape(props), @@ -775,6 +781,7 @@ export const Icons = { 'old-trash': (props: IconProps) => Trash(props), 'tool-point': (props: IconProps) => ToolCircle(props), 'tool-freehand-line': (props: IconProps) => ToolFreehand(props), + 'tool-labelmap-edit-with-contour': (props: IconProps) => ToolLabelmapEditWithContour(props), 'actions-smooth': (props: IconProps) => ActionsSmooth(props), 'actions-simplify': (props: IconProps) => ActionsSimplify(props), 'actions-combine': (props: IconProps) => ActionsCombine(props), diff --git a/platform/ui-next/src/components/Icons/Sources/TabContours.tsx b/platform/ui-next/src/components/Icons/Sources/TabContours.tsx new file mode 100644 index 000000000..fb59e8800 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/TabContours.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const TabContours = (props: IconProps) => ( + + + + + + + + + + +); + +export default TabContours; diff --git a/platform/ui-next/src/components/Icons/Sources/Tools.tsx b/platform/ui-next/src/components/Icons/Sources/Tools.tsx index 2a612dabf..e98c44d82 100644 --- a/platform/ui-next/src/components/Icons/Sources/Tools.tsx +++ b/platform/ui-next/src/components/Icons/Sources/Tools.tsx @@ -2995,16 +2995,16 @@ export const ToolSegmentLabel = (props: IconProps) => ( ); @@ -3544,3 +3544,57 @@ export const ToolContract = (props: IconProps) => ( /> ); +export const ToolSculptor = (props: IconProps) => ( + + + + + +); +export const ToolLabelmapEditWithContour = (props: IconProps) => ( + + + + + + +); diff --git a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx index 32df1e89b..002801c44 100644 --- a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx +++ b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx @@ -1,8 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import RowInputRange from './RowInputRange'; import RowSegmentedControl from './RowSegmentedControl'; import RowDoubleRange from './RowDoubleRange'; import { Button } from '../Button'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { Label } from '../Label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../Select'; +import { Switch } from '../Switch'; const SETTING_TYPES = { RANGE: 'range', @@ -10,6 +14,9 @@ const SETTING_TYPES = { CUSTOM: 'custom', DOUBLE_RANGE: 'double-range', BUTTON: 'button', + CHECKBOX: 'checkbox', + SWITCH: 'switch', + SELECT: 'select', }; function ToolSettings({ options }) { @@ -18,7 +25,8 @@ function ToolSettings({ options }) { } if (typeof options === 'function') { - return options(); + const OptionsComponent = options; + return ; } return ( @@ -39,6 +47,12 @@ function ToolSettings({ options }) { return renderCustomSetting(option); case SETTING_TYPES.BUTTON: return renderButtonSetting(option); + case SETTING_TYPES.CHECKBOX: + return renderCheckboxSetting(option); + case SETTING_TYPES.SWITCH: + return renderSwitchSetting(option); + case SETTING_TYPES.SELECT: + return renderSelectSetting(option); default: return null; } @@ -116,4 +130,74 @@ const renderButtonSetting = option => { ); }; +const renderCheckboxSetting = option => { + return ( +
+ { + option.onChange?.(checked); + }} + /> + +
+ ); +}; + +const renderSwitchSetting = option => { + return ( +
+ { + option.onChange?.(checked); + }} + /> + +
+ ); +}; + +const renderSelectSetting = option => { + return ( + + ); +}; + export default ToolSettings; diff --git a/platform/ui-next/src/components/SegmentationTable/AddSegmentationRow.tsx b/platform/ui-next/src/components/SegmentationTable/AddSegmentationRow.tsx index f04f2c0ae..533d7e6a3 100644 --- a/platform/ui-next/src/components/SegmentationTable/AddSegmentationRow.tsx +++ b/platform/ui-next/src/components/SegmentationTable/AddSegmentationRow.tsx @@ -8,12 +8,21 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({ }) => { const { t } = useTranslation('SegmentationTable'); - const { onSegmentationAdd, data, disableEditing, mode, disabled } = - useSegmentationTableContext('AddSegmentationRow'); + const { + onSegmentationAdd, + data, + disableEditing, + mode, + disabled, + segmentationRepresentationType, + } = useSegmentationTableContext('AddSegmentationRow'); - const isEmpty = data.length === 0; + // Check if we have at least one segmentation of the representation type for the panel this component is contained in. + const hasRepresentationType = + (!segmentationRepresentationType && data.length > 0) || + data.some(info => segmentationRepresentationType === info.representation?.type); - if (!isEmpty && mode === 'collapsed') { + if (hasRepresentationType && mode === 'collapsed') { return null; } @@ -25,7 +34,9 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({
!disabled && onSegmentationAdd('')} + onClick={() => + !disabled && onSegmentationAdd({ segmentationId: '', segmentationRepresentationType }) + } > {children}
@@ -33,7 +44,7 @@ export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({ {disabled ? : }
- {t(`${disabled ? 'Segmentation Not Supported' : 'Add Segmentation'}`)} + {t(disabled ? 'Segmentation not supported' : 'Add segmentation')}
diff --git a/platform/ui-next/src/components/SegmentationTable/SegmentationCollapsed.tsx b/platform/ui-next/src/components/SegmentationTable/SegmentationCollapsed.tsx index d51d30ab3..72ba3f391 100644 --- a/platform/ui-next/src/components/SegmentationTable/SegmentationCollapsed.tsx +++ b/platform/ui-next/src/components/SegmentationTable/SegmentationCollapsed.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { PanelSection } from '../PanelSection'; -import { useSegmentationTableContext, SegmentationExpandedProvider } from './contexts'; +import { + useSegmentationTableContext, + SegmentationExpandedProvider, + useSegmentationExpanded, +} from './contexts'; import { useTranslation } from 'react-i18next'; import { Button, @@ -46,23 +50,31 @@ const SegmentationCollapsedDropdownMenu = ({ children }: { children: React.React // Selector component - for the segmentation selection dropdown const SegmentationCollapsedSelector = () => { const { t } = useTranslation('SegmentationTable.HeaderCollapsed'); - const { data, activeSegmentationId, onSegmentationClick } = useSegmentationTableContext( + const { data, onSegmentationClick, segmentationRepresentationType } = useSegmentationTableContext( 'SegmentationCollapsedSelector' ); + const { segmentation } = useSegmentationExpanded('SegmentationCollapsedSelector'); if (!data?.length) { return null; } - const segmentations = data.map(seg => ({ - id: seg.segmentation.segmentationId, - label: seg.segmentation.label, - })); + const segmentations = data + // Only show segmentations of the representation type for this panel. Show all segmentations if no type is specified. + .filter( + seg => + !segmentationRepresentationType || + segmentationRepresentationType === seg.representation.type + ) + .map(seg => ({ + id: seg.segmentation.segmentationId, + label: seg.segmentation.label, + })); return (