diff --git a/.circleci/config.yml b/.circleci/config.yml index 6bf4b7692be..3130f457e45 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,18 +13,18 @@ version: 2.1 ## orbs: codecov: codecov/codecov@1.0.5 - cypress: cypress-io/cypress@1.26.0 -executors: - # Custom executor to override Cypress config - deploy-to-prod-executor: - docker: - - image: cimg/node:16.14 - environment: - CYPRESS_BASE_URL: https://ohif-staging.netlify.com/ - chrome-and-pacs: - docker: - # Primary container image where all steps run. - - image: 'cypress/browsers:node16.14.2-slim-chrome103-ff102' + cypress: cypress-io/cypress@3 +# executors: +# # Custom executor to override Cypress config +# deploy-to-prod-executor: +# docker: +# - image: cimg/node:16.14 +# environment: +# CYPRESS_BASE_URL: https://ohif-staging.netlify.com/ +# chrome-and-pacs: +# docker: +# # Primary container image where all steps run. +# - image: 'cypress/browsers:node18.12.0-chrome106-ff106' defaults: &defaults docker: @@ -363,32 +363,20 @@ jobs: fi workflows: - version: 2 - PR_CHECKS: jobs: - UNIT_TESTS - # E2E: PWA - cypress/run: name: 'E2E: PWA' - executor: chrome-and-pacs - browser: chrome - pre-steps: - - run: | - # Clear yarn cache; use yarn from image (update image to update yarn) - rm -rf ~/.yarn - yarn -v - yarn: true - record: true - store_artifacts: true - working_directory: platform/app - build: yarn test:data - start: yarn run test:e2e:serve - spec: 'cypress/integration/**/*' - wait-on: 'http://localhost:3000' - cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}' - no-workspace: true # Don't persist workspace + start-command: yarn run test:data && yarn run test:e2e:serve + install-browsers: true + cypress-command: + 'npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run + --record --browser chrome --parallel' + package-manager: 'yarn' + cypress-cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}' + cypress-cache-path: '~/.cache/Cypress' post-steps: - store_artifacts: path: platform/app/cypress/screenshots @@ -399,34 +387,34 @@ workflows: requires: - UNIT_TESTS - PR_OPTIONAL_VISUAL_TESTS: - jobs: - - AWAIT_APPROVAL: - type: approval - # Update hub.docker.org - - cypress/run: - name: 'Generate Percy Snapshots' - executor: cypress/browsers-chrome76 - browser: chrome - pre-steps: - - run: 'rm -rf ~/.yarn && yarn -v && yarn global add wait-on' - yarn: true - store_artifacts: false - working_directory: platform/app - build: - yarn test:data && npx cross-env QUICK_BUILD=true APP_CONFIG=config/dicomweb-server.js - yarn run build - # start server --> verify running --> percy + chrome + cypress - command: yarn run test:e2e:dist - cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}' - no-workspace: true # Don't persist workspace - post-steps: - - store_artifacts: - path: platform/app/cypress/screenshots - - store_artifacts: - path: platform/app/cypress/videos - requires: - - AWAIT_APPROVAL + # PR_OPTIONAL_VISUAL_TESTS: + # jobs: + # - AWAIT_APPROVAL: + # type: approval + # # Update hub.docker.org + # - cypress/run: + # name: 'Generate Percy Snapshots' + # executor: cypress/browsers-chrome76 + # browser: chrome + # pre-steps: + # - run: 'rm -rf ~/.yarn && yarn -v && yarn global add wait-on' + # yarn: true + # store_artifacts: false + # working_directory: platform/app + # build: + # yarn test:data && npx cross-env QUICK_BUILD=true APP_CONFIG=config/dicomweb-server.js + # yarn run build + # # start server --> verify running --> percy + chrome + cypress + # command: yarn run test:e2e:dist + # cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}' + # no-workspace: true # Don't persist workspace + # post-steps: + # - store_artifacts: + # path: platform/app/cypress/screenshots + # - store_artifacts: + # path: platform/app/cypress/videos + # requires: + # - AWAIT_APPROVAL # Our master branch deploys to viewer-dev.ohif.org, the viewer.ohif.org is # deployed from the release branch which is more stable and less frequently updated. diff --git a/extensions/cornerstone-dicom-rt/src/index.tsx b/extensions/cornerstone-dicom-rt/src/index.tsx index a8541a303b4..953c9a7c7da 100644 --- a/extensions/cornerstone-dicom-rt/src/index.tsx +++ b/extensions/cornerstone-dicom-rt/src/index.tsx @@ -2,7 +2,6 @@ import { id } from './id'; import React from 'react'; import { Types } from '@ohif/core'; import getSopClassHandlerModule from './getSopClassHandlerModule'; -import hydrateRTDisplaySet from './utils/_hydrateRT'; const Component = React.lazy(() => { return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneRTViewport'); @@ -60,4 +59,3 @@ const extension: Types.Extensions.Extension = { }; export default extension; -export { hydrateRTDisplaySet }; diff --git a/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts deleted file mode 100644 index d61e9e18654..00000000000 --- a/extensions/cornerstone-dicom-rt/src/utils/_hydrateRT.ts +++ /dev/null @@ -1,70 +0,0 @@ -async function _hydrateRTDisplaySet({ rtDisplaySet, viewportId, servicesManager }) { - const { segmentationService, hangingProtocolService, viewportGridService } = - servicesManager.services; - - const displaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID; - - let segmentationId = null; - - // We need the hydration to notify panels about the new segmentation added - const suppressEvents = false; - - segmentationId = await segmentationService.createSegmentationForRTDisplaySet( - rtDisplaySet, - segmentationId, - suppressEvents - ); - - segmentationService.hydrateSegmentation(rtDisplaySet.displaySetInstanceUID); - - const { viewports } = viewportGridService.getState(); - - const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( - viewportId, - displaySetInstanceUID - ); - - viewportGridService.setDisplaySetsForViewports(updatedViewports); - - // Todo: fix this after we have a better way for stack viewport segmentations - - // check every viewport in the viewports to see if the displaySetInstanceUID - // is being displayed, if so we need to update the viewport to use volume viewport - // (if already is not using it) since Cornerstone3D currently only supports - // volume viewport for segmentation - viewports.forEach(viewport => { - if (viewport.viewportId === viewportId) { - return; - } - - const shouldDisplaySeg = segmentationService.shouldRenderSegmentation( - viewport.displaySetInstanceUIDs, - rtDisplaySet.displaySetInstanceUID - ); - - if (shouldDisplaySeg) { - updatedViewports.push({ - viewportId: viewport.viewportId, - displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, - viewportOptions: { - // Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component - // Used for segmentation hydration right now, since the logic to decide whether - // a viewport needs to render a segmentation lives inside the CornerstoneViewportService - // so we need to re-render (force update via change of the needsRerendering) so that React - // does the diffing and decides we should render this again (although the id and element has not changed) - // so that the CornerstoneViewportService can decide whether to render the segmentation or not. - needsRerendering: true, - initialImageOptions: { - preset: 'middle', - }, - }, - }); - } - }); - - // Do the entire update at once - viewportGridService.setDisplaySetsForViewports(updatedViewports); - return true; -} - -export default _hydrateRTDisplaySet; diff --git a/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts index aaa27c28f89..f47f0089d8a 100644 --- a/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts +++ b/extensions/cornerstone-dicom-rt/src/utils/initRTToolGroup.ts @@ -1,7 +1,7 @@ function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) { const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {}; - return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } export default createRTToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts index c6069abc159..91492cbfbed 100644 --- a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts +++ b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts @@ -1,5 +1,4 @@ import { ButtonEnums } from '@ohif/ui'; -import hydrateRTDisplaySet from './_hydrateRT'; const RESPONSE = { NO_NEVER: -1, @@ -13,6 +12,7 @@ function promptHydrateRT({ viewportId, toolGroupId = 'default', preHydrateCallbacks, + hydrateRTDisplaySet, }) { const { uiViewportDialogService } = servicesManager.services; diff --git a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx index 3dd9588d7db..e9c7450c69a 100644 --- a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx +++ b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx @@ -3,11 +3,9 @@ import PropTypes from 'prop-types'; import OHIF, { utils } from '@ohif/core'; import { ViewportActionBar, useViewportGrid, LoadingIndicatorTotalPercent } from '@ohif/ui'; -import _hydrateRTdisplaySet from '../utils/_hydrateRT'; import promptHydrateRT from '../utils/promptHydrateRT'; import _getStatusComponent from './_getStatusComponent'; import createRTToolGroupAndAddTools from '../utils/initRTToolGroup'; -import _hydrateRTDisplaySet from '../utils/_hydrateRT'; const { formatDate } = utils; const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup'; @@ -95,6 +93,13 @@ function OHIFCornerstoneRTViewport(props) { }); }, [viewportGrid]); + const hydrateRTDisplaySet = ({ rtDisplaySet, viewportId }) => { + commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { + displaySets: [rtDisplaySet], + viewportId, + }); + }; + const getCornerstoneViewport = useCallback(() => { const { component: Component } = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.viewportModule.cornerstone' @@ -154,6 +159,7 @@ function OHIFCornerstoneRTViewport(props) { viewportId, rtDisplaySet, preHydrateCallbacks: [storePresentationState], + hydrateRTDisplaySet, }).then(isHydrated => { if (isHydrated) { setIsHydrated(true); @@ -295,10 +301,9 @@ function OHIFCornerstoneRTViewport(props) { // presentation state (w/l and invert) and then opens the RT. If we don't store // the presentation state, the viewport will be reset to the default presentation storePresentationState(); - const isHydrated = await _hydrateRTDisplaySet({ + const isHydrated = await hydrateRTDisplaySet({ rtDisplaySet, viewportId, - servicesManager, }); setIsHydrated(isHydrated); diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index 19118a58def..7e8132cb247 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -43,6 +43,7 @@ "react-router-dom": "^6.8.1" }, "dependencies": { + "@cornerstonejs/tools": "^1.16.4", "@babel/runtime": "^7.20.13", "react-color": "^2.19.3" } diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts new file mode 100644 index 00000000000..7cffb83f764 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -0,0 +1,383 @@ +import dcmjs from 'dcmjs'; +import { createReportDialogPrompt } from '@ohif/extension-default'; +import { ServicesManager, Types } from '@ohif/core'; +import { cache, metaData } from '@cornerstonejs/core'; +import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools'; +import { adaptersSEG, helpers } from '@cornerstonejs/adapters'; +import { DicomMetadataStore } from '@ohif/core'; + +import { + updateViewportsForSegmentationRendering, + getUpdatedViewportsForSegmentation, + getTargetViewport, +} from './utils/hydrationUtils'; + +const { + Cornerstone3D: { + Segmentation: { generateLabelMaps2DFrom3D, generateSegmentation }, + }, +} = adaptersSEG; + +const { downloadDICOMData } = helpers; + +const commandsModule = ({ + servicesManager, + extensionManager, +}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => { + const { + uiNotificationService, + segmentationService, + uiDialogService, + displaySetService, + viewportGridService, + } = (servicesManager as ServicesManager).services; + + const actions = { + /** + * Retrieves a list of viewports that require updates in preparation for segmentation rendering. + * This function evaluates viewports based on their compatibility with the provided segmentation's + * frame of reference UID and appends them to the updated list if they should render the segmentation. + * + * @param {Object} params - Parameters for the function. + * @param params.viewportId - the ID of the viewport to be updated. + * @param params.servicesManager - The services manager + * @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance. + * + * @returns {Array} Returns an array of viewports that require updates for segmentation rendering. + */ + getUpdatedViewportsForSegmentation, + /** + * Creates an empty segmentation for a specified viewport. + * It first checks if the display set associated with the viewport is reconstructable. + * If not, it raises a notification error. Otherwise, it creates a new segmentation + * for the display set after handling the necessary steps for making the viewport + * a volume viewport first + * + * @param {Object} params - Parameters for the function. + * @param params.viewportId - the target viewport ID. + * + */ + createEmptySegmentationForViewport: async ({ viewportId }) => { + const viewport = getTargetViewport({ viewportId, viewportGridService }); + // Todo: add support for multiple display sets + const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0]; + + const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + + if (!displaySet.isReconstructable) { + uiNotificationService.show({ + title: 'Segmentation', + message: 'Segmentation is not supported for non-reconstructible displaysets yet', + type: 'error', + }); + return; + } + + updateViewportsForSegmentationRendering({ + viewportId, + servicesManager, + loadFn: async () => { + const currentSegmentations = segmentationService.getSegmentations(); + const segmentationId = await segmentationService.createSegmentationForDisplaySet( + displaySetInstanceUID, + { label: `Segmentation ${currentSegmentations.length + 1}` } + ); + + const toolGroupId = viewport.viewportOptions.toolGroupId; + + await segmentationService.addSegmentationRepresentationToToolGroup( + toolGroupId, + segmentationId + ); + + // Add only one segment for now + segmentationService.addSegment(segmentationId, { + toolGroupId, + segmentIndex: 1, + properties: { + label: 'Segment 1', + }, + }); + + return segmentationId; + }, + }); + }, + /** + * Loads segmentations for a specified viewport. + * The function prepares the viewport for rendering, then loads the segmentation details. + * Additionally, if the segmentation has scalar data, it is set for the corresponding label map volume. + * + * @param {Object} params - Parameters for the function. + * @param params.segmentations - Array of segmentations to be loaded. + * @param params.viewportId - the target viewport ID. + * + */ + loadSegmentationsForViewport: async ({ segmentations, viewportId }) => { + updateViewportsForSegmentationRendering({ + viewportId, + servicesManager, + loadFn: async () => { + // Todo: handle adding more than one segmentation + const viewport = getTargetViewport({ viewportId, viewportGridService }); + const displaySetInstanceUID = viewport.displaySetInstanceUIDs[0]; + + const segmentation = segmentations[0]; + const segmentationId = segmentation.id; + const label = segmentation.label; + const segments = segmentation.segments; + + delete segmentation.segments; + + await segmentationService.createSegmentationForDisplaySet(displaySetInstanceUID, { + segmentationId, + label, + }); + + if (segmentation.scalarData) { + const labelmapVolume = segmentationService.getLabelmapVolume(segmentationId); + labelmapVolume.scalarData.set(segmentation.scalarData); + } + + segmentationService.addOrUpdateSegmentation(segmentation); + + const toolGroupId = viewport.viewportOptions.toolGroupId; + await segmentationService.addSegmentationRepresentationToToolGroup( + toolGroupId, + segmentationId + ); + + segments.forEach(segment => { + if (segment === null) { + return; + } + segmentationService.addSegment(segmentationId, { + segmentIndex: segment.segmentIndex, + toolGroupId, + properties: { + color: segment.color, + label: segment.label, + opacity: segment.opacity, + isLocked: segment.isLocked, + visibility: segment.isVisible, + active: segmentation.activeSegmentIndex === segment.segmentIndex, + }, + }); + }); + + if (segmentation.centroidsIJK) { + segmentationService.setCentroids(segmentation.id, segmentation.centroidsIJK); + } + + return segmentationId; + }, + }); + }, + /** + * Loads segmentation display sets for a specified viewport. + * Depending on the modality of the display set (SEG or RTSTRUCT), + * it chooses the appropriate service function to create + * the segmentation for the display set. + * The function then prepares the viewport for rendering segmentation. + * + * @param {Object} params - Parameters for the function. + * @param params.viewportId - ID of the viewport where the segmentation display sets should be loaded. + * @param params.displaySets - Array of display sets to be loaded for segmentation. + * + */ + loadSegmentationDisplaySetsForViewport: async ({ viewportId, displaySets }) => { + // Todo: handle adding more than one segmentation + const displaySet = displaySets[0]; + + updateViewportsForSegmentationRendering({ + viewportId, + servicesManager, + referencedDisplaySetInstanceUID: displaySet.referencedDisplaySetInstanceUID, + loadFn: async () => { + const segDisplaySet = displaySet; + const suppressEvents = false; + const serviceFunction = + segDisplaySet.Modality === 'SEG' + ? 'createSegmentationForSEGDisplaySet' + : 'createSegmentationForRTDisplaySet'; + + const boundFn = segmentationService[serviceFunction].bind(segmentationService); + const segmentationId = await boundFn(segDisplaySet, null, suppressEvents); + + return segmentationId; + }, + }); + }, + /** + * Generates a segmentation from a given segmentation ID. + * This function retrieves the associated segmentation and + * its referenced volume, extracts label maps from the + * segmentation volume, and produces segmentation data + * alongside associated metadata. + * + * @param {Object} params - Parameters for the function. + * @param params.segmentationId - ID of the segmentation to be generated. + * @param params.options - Optional configuration for the generation process. + * + * @returns Returns the generated segmentation data. + */ + generateSegmentation: ({ segmentationId, options = {} }) => { + const segmentation = cornerstoneToolsSegmentation.state.getSegmentation(segmentationId); + + const { referencedVolumeId } = segmentation.representationData.LABELMAP; + + const segmentationVolume = cache.getVolume(segmentationId); + const referencedVolume = cache.getVolume(referencedVolumeId); + const referencedImages = referencedVolume.getCornerstoneImages(); + + const labelmapObj = generateLabelMaps2DFrom3D(segmentationVolume); + + // Generate fake metadata as an example + labelmapObj.metadata = []; + + const segmentationInOHIF = segmentationService.getSegmentation(segmentationId); + labelmapObj.segmentsOnLabelmap.forEach(segmentIndex => { + // segmentation service already has a color for each segment + const segment = segmentationInOHIF?.segments[segmentIndex]; + const { label, color } = segment; + + const RecommendedDisplayCIELabValue = dcmjs.data.Colors.rgb2DICOMLAB( + color.slice(0, 3).map(value => value / 255) + ).map(value => Math.round(value)); + + const segmentMetadata = { + SegmentNumber: segmentIndex.toString(), + SegmentLabel: label, + SegmentAlgorithmType: 'MANUAL', + SegmentAlgorithmName: 'OHIF Brush', + RecommendedDisplayCIELabValue, + SegmentedPropertyCategoryCodeSequence: { + CodeValue: 'T-D0050', + CodingSchemeDesignator: 'SRT', + CodeMeaning: 'Tissue', + }, + SegmentedPropertyTypeCodeSequence: { + CodeValue: 'T-D0050', + CodingSchemeDesignator: 'SRT', + CodeMeaning: 'Tissue', + }, + }; + labelmapObj.metadata[segmentIndex] = segmentMetadata; + }); + + const generatedSegmentation = generateSegmentation( + referencedImages, + labelmapObj, + metaData, + options + ); + + return generatedSegmentation; + }, + /** + * Downloads a segmentation based on the provided segmentation ID. + * This function retrieves the associated segmentation and + * uses it to generate the corresponding DICOM dataset, which + * is then downloaded with an appropriate filename. + * + * @param {Object} params - Parameters for the function. + * @param params.segmentationId - ID of the segmentation to be downloaded. + * + */ + downloadSegmentation: ({ segmentationId }) => { + const segmentationInOHIF = segmentationService.getSegmentation(segmentationId); + const generatedSegmentation = actions.generateSegmentation({ + segmentationId, + }); + + downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`); + }, + /** + * Stores a segmentation based on the provided segmentationId into a specified data source. + * The SeriesDescription is derived from user input or defaults to the segmentation label, + * and in its absence, defaults to 'Research Derived Series'. + * + * @param {Object} params - Parameters for the function. + * @param params.segmentationId - ID of the segmentation to be stored. + * @param params.dataSource - Data source where the generated segmentation will be stored. + * + * @returns {Object|void} Returns the naturalized report if successfully stored, + * otherwise throws an error. + */ + storeSegmentation: async ({ segmentationId, dataSource }) => { + const promptResult = await createReportDialogPrompt(uiDialogService, { + extensionManager, + }); + + if (promptResult.action !== 1 && promptResult.value) { + return; + } + + const segmentation = segmentationService.getSegmentation(segmentationId); + + if (!segmentation) { + throw new Error('No segmentation found'); + } + + const { label } = segmentation; + const SeriesDescription = promptResult.value || label || 'Research Derived Series'; + + const generatedData = actions.generateSegmentation({ + segmentationId, + options: { + SeriesDescription, + }, + }); + + if (!generatedData || !generatedData.dataset) { + throw new Error('Error during segmentation generation'); + } + + const { dataset: naturalizedReport } = generatedData; + + await dataSource.store.dicom(naturalizedReport); + + // The "Mode" route listens for DicomMetadataStore changes + // When a new instance is added, it listens and + // automatically calls makeDisplaySets + + // add the information for where we stored it to the instance as well + naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot; + + DicomMetadataStore.addInstances([naturalizedReport], true); + + return naturalizedReport; + }, + }; + + const definitions = { + getUpdatedViewportsForSegmentation: { + commandFn: actions.getUpdatedViewportsForSegmentation, + }, + loadSegmentationDisplaySetsForViewport: { + commandFn: actions.loadSegmentationDisplaySetsForViewport, + }, + loadSegmentationsForViewport: { + commandFn: actions.loadSegmentationsForViewport, + }, + createEmptySegmentationForViewport: { + commandFn: actions.createEmptySegmentationForViewport, + }, + generateSegmentation: { + commandFn: actions.generateSegmentation, + }, + downloadSegmentation: { + commandFn: actions.downloadSegmentation, + }, + storeSegmentation: { + commandFn: actions.storeSegmentation, + }, + }; + + return { + actions, + definitions, + }; +}; + +export default commandsModule; diff --git a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx new file mode 100644 index 00000000000..e626c87d7bf --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { useAppConfig } from '@state'; +import PanelSegmentation from './panels/PanelSegmentation'; +import SegmentationToolbox from './panels/SegmentationToolbox'; + +const getPanelModule = ({ commandsManager, servicesManager, extensionManager, configuration }) => { + const { customizationService } = servicesManager.services; + + const wrappedPanelSegmentation = configuration => { + const [appConfig] = useAppConfig(); + + const disableEditingForMode = customizationService.get('segmentation.disableEditing'); + + return ( + + ); + }; + + const wrappedPanelSegmentationWithTools = configuration => { + const [appConfig] = useAppConfig(); + return ( + <> + + + + ); + }; + + return [ + { + name: 'panelSegmentation', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation', + component: wrappedPanelSegmentation, + }, + { + name: 'panelSegmentationWithTools', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation', + component: wrappedPanelSegmentationWithTools, + }, + ]; +}; + +export default getPanelModule; diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js index 33a4b523a49..6f7f06e4bdd 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.js @@ -60,7 +60,7 @@ function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) throw new Error('ReferencedSeriesSequence is missing for the SEG'); } - const referencedSeries = referencedSeriesSequence[0]; + const referencedSeries = referencedSeriesSequence[0] || referencedSeriesSequence; displaySet.referencedImages = instance.ReferencedSeriesSequence.ReferencedInstanceSequence; displaySet.referencedSeriesInstanceUID = referencedSeries.SeriesInstanceUID; diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index 48d75202d81..bb6a6d4b118 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -1,12 +1,11 @@ import { id } from './id'; import React from 'react'; -import { Types } from '@ohif/core'; - import getSopClassHandlerModule from './getSopClassHandlerModule'; -import PanelSegmentation from './panels/PanelSegmentation'; import getHangingProtocolModule from './getHangingProtocolModule'; -import hydrateSEGDisplaySet from './utils/_hydrateSEG'; +import getPanelModule from './getPanelModule'; +import getCommandsModule from './commandsModule'; +import preRegistration from './init'; const Component = React.lazy(() => { return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSEGViewport'); @@ -29,6 +28,7 @@ const extension = { * You ID can be anything you want, but it should be unique. */ id, + preRegistration, /** * PanelModule should provide a list of panels that will be available in OHIF @@ -36,27 +36,8 @@ const extension = { * iconName, iconLabel, label, component} object. Example of a panel module * is the StudyBrowserPanel that is provided by the default extension in OHIF. */ - getPanelModule: ({ servicesManager, commandsManager, extensionManager }): Types.Panel[] => { - const wrappedPanelSegmentation = () => { - return ( - - ); - }; - - return [ - { - name: 'panelSegmentation', - iconName: 'tab-segmentation', - iconLabel: 'Segmentation', - label: 'Segmentation', - component: wrappedPanelSegmentation, - }, - ]; - }, + getPanelModule, + getCommandsModule, getViewportModule({ servicesManager, extensionManager }) { const ExtendedOHIFCornerstoneSEGViewport = props => { @@ -83,4 +64,3 @@ const extension = { }; export default extension; -export { hydrateSEGDisplaySet }; diff --git a/extensions/cornerstone-dicom-seg/src/init.ts b/extensions/cornerstone-dicom-seg/src/init.ts new file mode 100644 index 00000000000..9702aa570b6 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/init.ts @@ -0,0 +1,5 @@ +import { addTool, BrushTool } from '@cornerstonejs/tools'; + +export default function init({ configuration = {} }): void { + addTool(BrushTool); +} diff --git a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx index 7f6e96b7059..d17c0397b9f 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -1,16 +1,22 @@ +import { createReportAsync } from '@ohif/extension-default'; import React, { useEffect, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import { SegmentationGroupTable } from '@ohif/ui'; + import callInputDialog from './callInputDialog'; -import { useAppConfig } from '@state'; +import callColorPickerDialog from './colorPickerDialog'; import { useTranslation } from 'react-i18next'; -export default function PanelSegmentation({ servicesManager, commandsManager }) { - const { segmentationService, uiDialogService } = servicesManager.services; - const [appConfig] = useAppConfig(); - const disableEditing = appConfig?.disableEditing; +export default function PanelSegmentation({ + servicesManager, + commandsManager, + extensionManager, + configuration, +}) { + const { segmentationService, viewportGridService, uiDialogService } = servicesManager.services; const { t } = useTranslation('PanelSegmentation'); + const [selectedSegmentationId, setSelectedSegmentationId] = useState(null); const [segmentationConfiguration, setSegmentationConfiguration] = useState( segmentationService.getConfiguration() @@ -18,29 +24,6 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations()); - const [isMinimized, setIsMinimized] = useState({}); - - const onToggleMinimizeSegmentation = useCallback( - id => { - setIsMinimized(prevState => ({ - ...prevState, - [id]: !prevState[id], - })); - }, - [setIsMinimized] - ); - - // Only expand the last segmentation added to the list and collapse the rest - useEffect(() => { - const lastSegmentationId = segmentations[segmentations.length - 1]?.id; - if (lastSegmentationId) { - setIsMinimized(prevState => ({ - ...prevState, - [lastSegmentationId]: false, - })); - } - }, [segmentations, setIsMinimized]); - useEffect(() => { // ~~ Subscription const added = segmentationService.EVENTS.SEGMENTATION_ADDED; @@ -64,6 +47,16 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) }; }, []); + const getToolGroupIds = segmentationId => { + const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId); + + return toolGroupIds; + }; + + const onSegmentationAdd = async () => { + commandsManager.runCommand('createEmptySegmentationForViewport'); + }; + const onSegmentationClick = (segmentationId: string) => { segmentationService.setActiveSegmentationForToolGroup(segmentationId); }; @@ -72,14 +65,12 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) segmentationService.remove(segmentationId); }; - const getToolGroupIds = segmentationId => { - const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId); - - return toolGroupIds; + const onSegmentAdd = segmentationId => { + segmentationService.addSegment(segmentationId); }; const onSegmentClick = (segmentationId, segmentIndex) => { - segmentationService.setActiveSegmentForSegmentation(segmentationId, segmentIndex); + segmentationService.setActiveSegment(segmentationId, segmentIndex); const toolGroupIds = getToolGroupIds(segmentationId); @@ -101,7 +92,7 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) return; } - segmentationService.setSegmentLabelForSegmentation(segmentationId, segmentIndex, label); + segmentationService.setSegmentLabel(segmentationId, segmentIndex, label); }); }; @@ -126,16 +117,34 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) }; const onSegmentColorClick = (segmentationId, segmentIndex) => { - // Todo: Implement color picker later - return; + const segmentation = segmentationService.getSegmentation(segmentationId); + + const segment = segmentation.segments[segmentIndex]; + const { color, opacity } = segment; + + const rgbaColor = { + r: color[0], + g: color[1], + b: color[2], + a: opacity / 255.0, + }; + + callColorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => { + if (actionId === 'cancel') { + return; + } + + segmentationService.setSegmentRGBAColor(segmentationId, segmentIndex, [ + newRgbaColor.r, + newRgbaColor.g, + newRgbaColor.b, + newRgbaColor.a * 255.0, + ]); + }); }; const onSegmentDelete = (segmentationId, segmentIndex) => { - // segmentationService.removeSegmentFromSegmentation( - // segmentationId, - // segmentIndex - // ); - console.warn('not implemented yet'); + segmentationService.removeSegment(segmentationId, segmentIndex); }; const onToggleSegmentVisibility = (segmentationId, segmentIndex) => { @@ -155,6 +164,10 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) }); }; + const onToggleSegmentLock = (segmentationId, segmentIndex) => { + segmentationService.toggleSegmentLocked(segmentationId, segmentIndex); + }; + const onToggleSegmentationVisibility = segmentationId => { segmentationService.toggleSegmentationVisibility(segmentationId); }; @@ -169,27 +182,62 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) [segmentationService] ); + const onSegmentationDownload = segmentationId => { + commandsManager.runCommand('downloadSegmentation', { + segmentationId, + }); + }; + + const storeSegmentation = async segmentationId => { + const datasources = extensionManager.getActiveDataSource(); + + const displaySetInstanceUIDs = await createReportAsync({ + servicesManager, + getReport: () => + commandsManager.runCommand('storeSegmentation', { + segmentationId, + dataSource: datasources[0], + }), + reportType: 'Segmentation', + }); + + // Show the exported report in the active viewport as read only (similar to SR) + if (displaySetInstanceUIDs) { + // clear the segmentation that we exported, similar to the storeMeasurement + // where we remove the measurements and prompt again the user if they would like + // to re-read the measurements in a SR read only viewport + segmentationService.remove(segmentationId); + + viewportGridService.setDisplaySetsForViewport({ + viewportId: viewportGridService.getActiveViewportId(), + displaySetInstanceUIDs, + }); + } + }; + return ( -
- {/* show segmentation table */} - {segmentations?.length ? ( + <> +
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value) @@ -217,8 +265,8 @@ export default function PanelSegmentation({ servicesManager, commandsManager }) _setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value) } /> - ) : null} -
+
+ ); } diff --git a/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx b/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx new file mode 100644 index 00000000000..674ae3d2e69 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/SegmentationToolbox.tsx @@ -0,0 +1,405 @@ +import React, { useCallback, useEffect, useState, useReducer } from 'react'; +import { AdvancedToolbox, InputDoubleRange, useViewportGrid } from '@ohif/ui'; +import { Types } from '@ohif/extension-cornerstone'; +import { utilities } from '@cornerstonejs/tools'; + +const { segmentation: segmentationUtils } = utilities; + +const TOOL_TYPES = { + CIRCULAR_BRUSH: 'CircularBrush', + SPHERE_BRUSH: 'SphereBrush', + CIRCULAR_ERASER: 'CircularEraser', + SPHERE_ERASER: 'SphereEraser', + CIRCLE_SCISSOR: 'CircleScissor', + RECTANGLE_SCISSOR: 'RectangleScissor', + SPHERE_SCISSOR: 'SphereScissor', + THRESHOLD_CIRCULAR_BRUSH: 'ThresholdCircularBrush', + THRESHOLD_SPHERE_BRUSH: 'ThresholdSphereBrush', +}; + +const ACTIONS = { + SET_TOOL_CONFIG: 'SET_TOOL_CONFIG', + SET_ACTIVE_TOOL: 'SET_ACTIVE_TOOL', +}; + +const initialState = { + Brush: { + brushSize: 15, + mode: 'CircularBrush', // Can be 'CircularBrush' or 'SphereBrush' + }, + Eraser: { + brushSize: 15, + mode: 'CircularEraser', // Can be 'CircularEraser' or 'SphereEraser' + }, + Scissors: { + brushSize: 15, + mode: 'CircleScissor', // E.g., 'CircleScissor', 'RectangleScissor', or 'SphereScissor' + }, + ThresholdBrush: { + brushSize: 15, + thresholdRange: [-500, 500], + }, + activeTool: null, +}; + +function toolboxReducer(state, action) { + switch (action.type) { + case ACTIONS.SET_TOOL_CONFIG: + const { tool, config } = action.payload; + return { + ...state, + [tool]: { + ...state[tool], + ...config, + }, + }; + case ACTIONS.SET_ACTIVE_TOOL: + return { ...state, activeTool: action.payload }; + default: + return state; + } +} + +function SegmentationToolbox({ servicesManager, extensionManager }) { + const { toolbarService, segmentationService, toolGroupService } = + servicesManager.services as Types.CornerstoneServices; + + const [viewportGrid] = useViewportGrid(); + const { viewports, activeViewportId } = viewportGrid; + + const [toolsEnabled, setToolsEnabled] = useState(false); + const [state, dispatch] = useReducer(toolboxReducer, initialState); + + const updateActiveTool = useCallback(() => { + if (!viewports?.size || activeViewportId === undefined) { + return; + } + const viewport = viewports.get(activeViewportId); + + if (!viewport) { + return; + } + + dispatch({ + type: ACTIONS.SET_ACTIVE_TOOL, + payload: toolGroupService.getActiveToolForViewport(viewport.viewportId), + }); + }, [activeViewportId, viewports, toolGroupService, dispatch]); + + const setToolActive = useCallback( + toolName => { + toolbarService.recordInteraction({ + interactionType: 'tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName, + }, + }, + ], + }); + + dispatch({ type: ACTIONS.SET_ACTIVE_TOOL, payload: toolName }); + }, + [toolbarService, dispatch] + ); + + /** + * sets the tools enabled IF there are segmentations + */ + useEffect(() => { + const events = [ + segmentationService.EVENTS.SEGMENTATION_ADDED, + segmentationService.EVENTS.SEGMENTATION_UPDATED, + segmentationService.EVENTS.SEGMENTATION_REMOVED, + ]; + + const unsubscriptions = []; + + events.forEach(event => { + const { unsubscribe } = segmentationService.subscribe(event, () => { + const segmentations = segmentationService.getSegmentations(); + + const activeSegmentation = segmentations?.find(seg => seg.isActive); + + setToolsEnabled(activeSegmentation?.segmentCount > 0); + }); + + unsubscriptions.push(unsubscribe); + }); + + updateActiveTool(); + + return () => { + unsubscriptions.forEach(unsubscribe => unsubscribe()); + }; + }, [activeViewportId, viewports, segmentationService, updateActiveTool]); + + /** + * Update the active tool when the toolbar state changes + */ + useEffect(() => { + const { unsubscribe } = toolbarService.subscribe( + toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED, + () => { + updateActiveTool(); + } + ); + + return () => { + unsubscribe(); + }; + }, [toolbarService, updateActiveTool]); + + useEffect(() => { + // if the active tool is not a brush tool then do nothing + if (!Object.values(TOOL_TYPES).includes(state.activeTool)) { + return; + } + + // if the tool is Segmentation and it is enabled then do nothing + if (toolsEnabled) { + return; + } + + // if the tool is Segmentation and it is disabled, then switch + // back to the window level tool to not confuse the user when no + // segmentation is active or when there is no segment in the segmentation + setToolActive('WindowLevel'); + }, [toolsEnabled, state.activeTool, setToolActive]); + + const updateBrushSize = useCallback( + (toolName, brushSize) => { + toolGroupService.getToolGroupIds()?.forEach(toolGroupId => { + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName); + }); + }, + [toolGroupService] + ); + + const onBrushSizeChange = useCallback( + (valueAsStringOrNumber, toolCategory) => { + const value = Number(valueAsStringOrNumber); + + _getToolNamesFromCategory(toolCategory).forEach(toolName => { + updateBrushSize(toolName, value); + }); + + dispatch({ + type: ACTIONS.SET_TOOL_CONFIG, + payload: { + tool: toolCategory, + config: { brushSize: value }, + }, + }); + }, + [toolGroupService, dispatch] + ); + + const handleRangeChange = useCallback( + newRange => { + if ( + newRange[0] === state.ThresholdBrush.thresholdRange[0] && + newRange[1] === state.ThresholdBrush.thresholdRange[1] + ) { + return; + } + + const toolNames = _getToolNamesFromCategory('ThresholdBrush'); + + toolNames.forEach(toolName => { + toolGroupService.getToolGroupIds()?.forEach(toolGroupId => { + const toolGroup = toolGroupService.getToolGroup(toolGroupId); + toolGroup.setToolConfiguration(toolName, { + strategySpecificConfiguration: { + THRESHOLD_INSIDE_CIRCLE: { + threshold: newRange, + }, + }, + }); + }); + }); + + dispatch({ + type: ACTIONS.SET_TOOL_CONFIG, + payload: { + tool: 'ThresholdBrush', + config: { thresholdRange: newRange }, + }, + }); + }, + [toolGroupService, dispatch, state.ThresholdBrush.thresholdRange] + ); + + return ( + setToolActive(TOOL_TYPES.CIRCULAR_BRUSH), + options: [ + { + name: 'Radius (mm)', + id: 'brush-radius', + type: 'range', + min: 0.01, + max: 100, + value: state.Brush.brushSize, + step: 0.5, + onChange: value => onBrushSizeChange(value, 'Brush'), + }, + { + name: 'Mode', + type: 'radio', + id: 'brush-mode', + value: state.Brush.mode, + values: [ + { value: TOOL_TYPES.CIRCULAR_BRUSH, label: 'Circle' }, + { value: TOOL_TYPES.SPHERE_BRUSH, label: 'Sphere' }, + ], + onChange: value => setToolActive(value), + }, + ], + }, + { + name: 'Eraser', + icon: 'icon-tool-eraser', + disabled: !toolsEnabled, + active: + state.activeTool === TOOL_TYPES.CIRCULAR_ERASER || + state.activeTool === TOOL_TYPES.SPHERE_ERASER, + onClick: () => setToolActive(TOOL_TYPES.CIRCULAR_ERASER), + options: [ + { + name: 'Radius (mm)', + type: 'range', + id: 'eraser-radius', + min: 0.01, + max: 100, + value: state.Eraser.brushSize, + step: 0.5, + onChange: value => onBrushSizeChange(value, 'Eraser'), + }, + { + name: 'Mode', + type: 'radio', + id: 'eraser-mode', + value: state.Eraser.mode, + values: [ + { value: TOOL_TYPES.CIRCULAR_ERASER, label: 'Circle' }, + { value: TOOL_TYPES.SPHERE_ERASER, label: 'Sphere' }, + ], + onChange: value => setToolActive(value), + }, + ], + }, + { + name: 'Scissor', + icon: 'icon-tool-scissor', + disabled: !toolsEnabled, + active: + state.activeTool === TOOL_TYPES.CIRCLE_SCISSOR || + state.activeTool === TOOL_TYPES.RECTANGLE_SCISSOR || + state.activeTool === TOOL_TYPES.SPHERE_SCISSOR, + onClick: () => setToolActive(TOOL_TYPES.CIRCLE_SCISSOR), + options: [ + { + name: 'Mode', + type: 'radio', + value: state.Scissors.mode, + id: 'scissor-mode', + values: [ + { value: TOOL_TYPES.CIRCLE_SCISSOR, label: 'Circle' }, + { value: TOOL_TYPES.RECTANGLE_SCISSOR, label: 'Rectangle' }, + { value: TOOL_TYPES.SPHERE_SCISSOR, label: 'Sphere' }, + ], + onChange: value => setToolActive(value), + }, + ], + }, + { + name: 'Threshold Tool', + icon: 'icon-tool-threshold', + disabled: !toolsEnabled, + active: + state.activeTool === TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH || + state.activeTool === TOOL_TYPES.THRESHOLD_SPHERE_BRUSH, + onClick: () => setToolActive(TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH), + options: [ + { + name: 'Radius (mm)', + id: 'threshold-radius', + type: 'range', + min: 0.01, + max: 100, + value: state.ThresholdBrush.brushSize, + step: 0.5, + onChange: value => onBrushSizeChange(value, 'ThresholdBrush'), + }, + { + name: 'Mode', + type: 'radio', + id: 'threshold-mode', + value: state.activeTool, + values: [ + { value: TOOL_TYPES.THRESHOLD_CIRCULAR_BRUSH, label: 'Circle' }, + { value: TOOL_TYPES.THRESHOLD_SPHERE_BRUSH, label: 'Sphere' }, + ], + onChange: value => setToolActive(value), + }, + { + type: 'custom', + id: 'segmentation-threshold-range', + children: () => { + return ( +
+
+
Threshold
+ +
+ ); + }, + }, + ], + }, + ]} + /> + ); +} + +function _getToolNamesFromCategory(category) { + let toolNames = []; + switch (category) { + case 'Brush': + toolNames = ['CircularBrush', 'SphereBrush']; + break; + case 'Eraser': + toolNames = ['CircularEraser', 'SphereEraser']; + break; + case 'ThresholdBrush': + toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush']; + break; + default: + break; + } + + return toolNames; +} + +export default SegmentationToolbox; diff --git a/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.css b/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.css new file mode 100644 index 00000000000..1c6bb206701 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.css @@ -0,0 +1,3 @@ +.chrome-picker { + background: #090c29 !important; +} diff --git a/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.tsx b/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.tsx new file mode 100644 index 00000000000..38e85efb29c --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/panels/colorPickerDialog.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Dialog } from '@ohif/ui'; +import { ChromePicker } from 'react-color'; + +import './colorPickerDialog.css'; + +function callColorPickerDialog(uiDialogService, rgbaColor, callback) { + const dialogId = 'pick-color'; + + const onSubmitHandler = ({ action, value }) => { + switch (action.id) { + case 'save': + callback(value.rgbaColor, action.id); + break; + case 'cancel': + callback('', action.id); + break; + } + uiDialogService.dismiss({ id: dialogId }); + }; + + if (uiDialogService) { + uiDialogService.create({ + id: dialogId, + centralize: true, + isDraggable: false, + showOverlay: true, + content: Dialog, + contentProps: { + title: 'Segment Color', + value: { rgbaColor }, + noCloseButton: true, + onClose: () => uiDialogService.dismiss({ id: dialogId }), + actions: [ + { id: 'cancel', text: 'Cancel', type: 'primary' }, + { id: 'save', text: 'Save', type: 'secondary' }, + ], + onSubmit: onSubmitHandler, + body: ({ value, setValue }) => { + const handleChange = color => { + setValue({ rgbaColor: color.rgb }); + }; + + return ( + + ); + }, + }, + }); + } +} + +export default callColorPickerDialog; diff --git a/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts deleted file mode 100644 index 84d075f97cc..00000000000 --- a/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts +++ /dev/null @@ -1,73 +0,0 @@ -async function _hydrateSEGDisplaySet({ - segDisplaySet, - viewportId: targetViewportId, - servicesManager, -}) { - const { segmentationService, hangingProtocolService, viewportGridService } = - servicesManager.services; - - const displaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID; - - let segmentationId = null; - - // We need the hydration to notify panels about the new segmentation added - const suppressEvents = false; - - segmentationId = await segmentationService.createSegmentationForSEGDisplaySet( - segDisplaySet, - segmentationId, - suppressEvents - ); - - segmentationService.hydrateSegmentation(segDisplaySet.displaySetInstanceUID); - - const { viewports } = viewportGridService.getState(); - - const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( - targetViewportId, - displaySetInstanceUID - ); - - // Todo: fix this after we have a better way for stack viewport segmentations - - // check every viewport in the viewports to see if the displaySetInstanceUID - // is being displayed, if so we need to update the viewport to use volume viewport - // (if already is not using it) since Cornerstone3D currently only supports - // volume viewport for segmentation - viewports.forEach((viewport, viewportId) => { - if (targetViewportId === viewportId) { - return; - } - - const shouldDisplaySeg = segmentationService.shouldRenderSegmentation( - viewport.displaySetInstanceUIDs, - segDisplaySet.displaySetInstanceUID - ); - - if (shouldDisplaySeg) { - updatedViewports.push({ - viewportId, - displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, - viewportOptions: { - // Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component - // Used for segmentation hydration right now, since the logic to decide whether - // a viewport needs to render a segmentation lives inside the CornerstoneViewportService - // so we need to re-render (force update via change of the needsRerendering) so that React - // does the diffing and decides we should render this again (although the id and element has not changed) - // so that the CornerstoneViewportService can decide whether to render the segmentation or not. - needsRerendering: true, - initialImageOptions: { - preset: 'middle', - }, - }, - }); - } - }); - - // Do the entire update at once - viewportGridService.setDisplaySetsForViewports(updatedViewports); - - return true; -} - -export default _hydrateSEGDisplaySet; diff --git a/extensions/cornerstone-dicom-seg/src/utils/hydrationUtils.ts b/extensions/cornerstone-dicom-seg/src/utils/hydrationUtils.ts new file mode 100644 index 00000000000..fa3c6d47c86 --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/utils/hydrationUtils.ts @@ -0,0 +1,190 @@ +import { Enums, cache } from '@cornerstonejs/core'; + +/** + * Updates the viewports in preparation for rendering segmentations. + * Evaluates each viewport to determine which need modifications, + * then for those viewports, changes them to a volume type and ensures + * they are ready for segmentation rendering. + * + * @param {Object} params - Parameters for the function. + * @param params.viewportId - ID of the viewport to be updated. + * @param params.loadFn - Function to load the segmentation data. + * @param params.servicesManager - The services manager. + * @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance. + * + * @returns Returns true upon successful update of viewports for segmentation rendering. + */ +async function updateViewportsForSegmentationRendering({ + viewportId, + loadFn, + servicesManager, + referencedDisplaySetInstanceUID, +}: { + viewportId: string; + loadFn: () => Promise; + servicesManager: any; + referencedDisplaySetInstanceUID?: string; +}) { + const { cornerstoneViewportService, segmentationService, viewportGridService } = + servicesManager.services; + + const viewport = getTargetViewport({ viewportId, viewportGridService }); + const targetViewportId = viewport.viewportOptions.viewportId; + + referencedDisplaySetInstanceUID = + referencedDisplaySetInstanceUID || viewport?.displaySetInstanceUIDs[0]; + + const updatedViewports = getUpdatedViewportsForSegmentation({ + servicesManager, + viewportId, + referencedDisplaySetInstanceUID, + }); + + // create Segmentation callback which needs to be waited until + // the volume is created (if coming from stack) + const createSegmentationForVolume = async () => { + const segmentationId = await loadFn(); + segmentationService.hydrateSegmentation(segmentationId); + }; + + // the reference volume that is used to draw the segmentation. so check if the + // volume exists in the cache (the target Viewport is already a volume viewport) + const volumeExists = Array.from(cache._volumeCache.keys()).some(volumeId => + volumeId.includes(referencedDisplaySetInstanceUID) + ); + + updatedViewports.forEach(async viewport => { + viewport.viewportOptions = { + ...viewport.viewportOptions, + viewportType: 'volume', + needsRerendering: true, + }; + const viewportId = viewport.viewportId; + + const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const prevCamera = csViewport.getCamera(); + + // only run the createSegmentationForVolume for the targetViewportId + // since the rest will get handled by cornerstoneViewportService + if (volumeExists && viewportId === targetViewportId) { + await createSegmentationForVolume(); + return; + } + + const createNewSegmentationWhenVolumeMounts = async evt => { + const isTheActiveViewportVolumeMounted = evt.detail.volumeActors?.find(ac => + ac.uid.includes(referencedDisplaySetInstanceUID) + ); + + // Note: make sure to re-grab the viewport since it might have changed + // during the time it took for the volume to be mounted, for instance + // the stack viewport has been changed to a volume viewport + const volumeViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + volumeViewport.setCamera(prevCamera); + + volumeViewport.element.removeEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + createNewSegmentationWhenVolumeMounts + ); + + if (!isTheActiveViewportVolumeMounted) { + // it means it is one of those other updated viewports so just update the camera + return; + } + + if (viewportId === targetViewportId) { + await createSegmentationForVolume(); + } + }; + + csViewport.element.addEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + createNewSegmentationWhenVolumeMounts + ); + }); + + // Set the displaySets for the viewports that require to be updated + viewportGridService.setDisplaySetsForViewports(updatedViewports); + + return true; +} + +const getTargetViewport = ({ viewportId, viewportGridService }) => { + const { viewports, activeViewportId } = viewportGridService.getState(); + const targetViewportId = viewportId || activeViewportId; + + const viewport = viewports.get(targetViewportId); + + return viewport; +}; + +/** + * Retrieves a list of viewports that require updates in preparation for segmentation rendering. + * This function evaluates viewports based on their compatibility with the provided segmentation's + * frame of reference UID and appends them to the updated list if they should render the segmentation. + * + * @param {Object} params - Parameters for the function. + * @param params.viewportId - the ID of the viewport to be updated. + * @param params.servicesManager - The services manager + * @param params.referencedDisplaySetInstanceUID - Optional UID for the referenced display set instance. + * + * @returns {Array} Returns an array of viewports that require updates for segmentation rendering. + */ +function getUpdatedViewportsForSegmentation({ + viewportId, + servicesManager, + referencedDisplaySetInstanceUID, +}) { + const { hangingProtocolService, displaySetService, segmentationService, viewportGridService } = + servicesManager.services; + + const { viewports } = viewportGridService.getState(); + + const viewport = getTargetViewport({ viewportId, viewportGridService }); + const targetViewportId = viewport.viewportOptions.viewportId; + + const displaySetInstanceUIDs = viewports.get(targetViewportId).displaySetInstanceUIDs; + + const referenceDisplaySetInstanceUID = + referencedDisplaySetInstanceUID || displaySetInstanceUIDs[0]; + + const referencedDisplaySet = displaySetService.getDisplaySetByUID(referenceDisplaySetInstanceUID); + const segmentationFrameOfReferenceUID = referencedDisplaySet.instances[0].FrameOfReferenceUID; + + const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( + targetViewportId, + referenceDisplaySetInstanceUID + ); + + viewports.forEach((viewport, viewportId) => { + if ( + targetViewportId === viewportId || + updatedViewports.find(v => v.viewportId === viewportId) + ) { + return; + } + + const shouldDisplaySeg = segmentationService.shouldRenderSegmentation( + viewport.displaySetInstanceUIDs, + segmentationFrameOfReferenceUID + ); + + if (shouldDisplaySeg) { + updatedViewports.push({ + viewportId, + displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, + viewportOptions: { + viewportType: 'volume', + needsRerendering: true, + }, + }); + } + }); + return updatedViewports; +} + +export { + updateViewportsForSegmentationRendering, + getUpdatedViewportsForSegmentation, + getTargetViewport, +}; diff --git a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts index f4fe36ca7ef..8ddc088c6c8 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/initSEGToolGroup.ts @@ -1,7 +1,7 @@ function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) { const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {}; - return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools, {}); + return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } export default createSEGToolGroupAndAddTools; diff --git a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts index 65f92c74358..9f8c1dddf78 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts @@ -1,5 +1,4 @@ import { ButtonEnums } from '@ohif/ui'; -import hydrateSEGDisplaySet from './_hydrateSEG'; const RESPONSE = { NO_NEVER: -1, @@ -7,7 +6,13 @@ const RESPONSE = { HYDRATE_SEG: 5, }; -function promptHydrateSEG({ servicesManager, segDisplaySet, viewportId, preHydrateCallbacks }) { +function promptHydrateSEG({ + servicesManager, + segDisplaySet, + viewportId, + preHydrateCallbacks, + hydrateSEGDisplaySet, +}) { const { uiViewportDialogService } = servicesManager.services; return new Promise(async function (resolve, reject) { @@ -21,7 +26,6 @@ function promptHydrateSEG({ servicesManager, segDisplaySet, viewportId, preHydra const isHydrated = await hydrateSEGDisplaySet({ segDisplaySet, viewportId, - servicesManager, }); resolve(isHydrated); diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx index 7e4d397786c..fb39f8c36fe 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -5,7 +5,6 @@ import OHIF, { utils } from '@ohif/core'; import { LoadingIndicatorTotalPercent, useViewportGrid, ViewportActionBar } from '@ohif/ui'; import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup'; import promptHydrateSEG from '../utils/promptHydrateSEG'; -import hydrateSEGDisplaySet from '../utils/_hydrateSEG'; import _getStatusComponent from './_getStatusComponent'; const { formatDate } = utils; @@ -158,6 +157,7 @@ function OHIFCornerstoneSEGViewport(props) { viewportId, segDisplaySet, preHydrateCallbacks: [storePresentationState], + hydrateSEGDisplaySet, }).then(isHydrated => { if (isHydrated) { setIsHydrated(true); @@ -291,6 +291,13 @@ function OHIFCornerstoneSEGViewport(props) { SpacingBetweenSlices, } = referencedDisplaySetRef.current.metadata; + const hydrateSEGDisplaySet = ({ segDisplaySet, viewportId }) => { + commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { + displaySets: [segDisplaySet], + viewportId, + }); + }; + const onStatusClick = async () => { // Before hydrating a SEG and make it added to all viewports in the grid // that share the same frameOfReferenceUID, we need to store the viewport grid @@ -302,7 +309,6 @@ function OHIFCornerstoneSEGViewport(props) { const isHydrated = await hydrateSEGDisplaySet({ segDisplaySet, viewportId, - servicesManager, }); setIsHydrated(isHydrated); @@ -369,12 +375,18 @@ OHIFCornerstoneSEGViewport.defaultProps = { }; function _getReferencedDisplaySetMetadata(referencedDisplaySet, segDisplaySet) { - const { - SharedFunctionalGroupsSequence: [SharedFunctionalGroup], - } = segDisplaySet.instance; - const { - PixelMeasuresSequence: [PixelMeasures], - } = SharedFunctionalGroup; + const { SharedFunctionalGroupsSequence } = segDisplaySet.instance; + + const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence) + ? SharedFunctionalGroupsSequence[0] + : SharedFunctionalGroupsSequence; + + const { PixelMeasuresSequence } = SharedFunctionalGroup; + + const PixelMeasures = Array.isArray(PixelMeasuresSequence) + ? PixelMeasuresSequence[0] + : PixelMeasuresSequence; + const { SpacingBetweenSlices, SliceThickness } = PixelMeasures; const image0 = referencedDisplaySet.images[0]; diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index 3089056b6a7..a1e728bdda7 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -240,7 +240,7 @@ function OHIFCornerstoneSRViewport(props) { const onDisplaySetsRemovedSubscription = displaySetService.subscribe( displaySetService.EVENTS.DISPLAY_SETS_REMOVED, ({ displaySetInstanceUIDs }) => { - const activeViewport = viewports[activeViewportId]; + const activeViewport = viewports.get(activeViewportId); if (displaySetInstanceUIDs.includes(activeViewport.displaySetInstanceUID)) { viewportGridService.setDisplaySetsForViewport({ viewportId: activeViewportId, diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 8f9e928e642..a5b12709f9f 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -18,7 +18,6 @@ import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; import { CornerstoneServices } from './types'; -import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; function commandsModule({ servicesManager, @@ -266,7 +265,6 @@ function commandsModule({ toolbarServiceRecordInteraction: props => { toolbarService.recordInteraction(props); }, - setToolActive: ({ toolName, toolGroupId = null, toggledState }) => { if (toolName === 'Crosshairs') { const activeViewportToolGroup = toolGroupService.getToolGroup(null); diff --git a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx index e4e080422e8..92bb1510327 100644 --- a/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx +++ b/extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx @@ -13,12 +13,12 @@ function WrappedCinePlayer({ enabledVPElement, viewportId, servicesManager }) { const handleCineClose = () => { toolbarService.recordInteraction({ groupId: 'MoreTools', - itemId: 'cine', interactionType: 'toggle', commands: [ { commandName: 'toggleCine', commandOptions: {}, + toolName: 'cine', context: 'CORNERSTONE', }, ], diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index d766776e638..10b4ce026ba 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -137,7 +137,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { export type { PublicViewportOptions }; export { measurementMappingUtils, - CornerstoneExtensionTypes, + CornerstoneExtensionTypes as Types, toolNames, getActiveViewportEnabledElement, }; diff --git a/extensions/cornerstone/src/initContextMenu.ts b/extensions/cornerstone/src/initContextMenu.ts index 60e16682429..488fdf85879 100644 --- a/extensions/cornerstone/src/initContextMenu.ts +++ b/extensions/cornerstone/src/initContextMenu.ts @@ -18,6 +18,7 @@ const DEFAULT_CONTEXT_MENU_CLICKS = { { commandName: 'showCornerstoneContextMenu', commandOptions: { + requireNearbyToolData: true, menuId: 'measurementsContextMenu', }, }, @@ -62,8 +63,20 @@ function initContextMenu({ const customizations = customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_CONTEXT_MENU_CLICKS; const toRun = customizations[name]; + + if (!toRun) { + return; + } + + // only find nearbyToolData if required, for the click (which closes the context menu + // we don't need to find nearbyToolData) + let nearbyToolData = null; + if (toRun.commands.some(command => command.commandOptions?.requireNearbyToolData)) { + nearbyToolData = findNearbyToolData(commandsManager, evt); + } + const options = { - nearbyToolData: findNearbyToolData(commandsManager, evt), + nearbyToolData, event: evt, }; commandsManager.run(toRun, options); diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index d6515a50888..767b607a772 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -25,6 +25,9 @@ import { annotation, ReferenceLinesTool, TrackballRotateTool, + CircleScissorsTool, + RectangleScissorsTool, + SphereScissorsTool, } from '@cornerstonejs/tools'; import CalibrationLineTool from './tools/CalibrationLineTool'; @@ -59,6 +62,9 @@ export default function initCornerstoneTools(configuration = {}) { addTool(ReferenceLinesTool); addTool(CalibrationLineTool); addTool(TrackballRotateTool); + addTool(CircleScissorsTool); + addTool(RectangleScissorsTool); + addTool(SphereScissorsTool); addTool(ImageOverlayViewerTool); // Modify annotation tools to use dashed lines on SR @@ -101,6 +107,9 @@ const toolNames = { ReferenceLines: ReferenceLinesTool.toolName, CalibrationLine: CalibrationLineTool.toolName, TrackballRotateTool: TrackballRotateTool.toolName, + CircleScissors: CircleScissorsTool.toolName, + RectangleScissors: RectangleScissorsTool.toolName, + SphereScissors: SphereScissorsTool.toolName, ImageOverlayViewer: ImageOverlayViewerTool.toolName, }; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 8d573f56413..a2fc8976013 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -210,7 +210,7 @@ class CornerstoneCacheService { } private _shouldRenderSegmentation(displaySets) { - const { segmentationService } = this.servicesManager.services; + const { segmentationService, displaySetService } = this.servicesManager.services; const viewportDisplaySetInstanceUIDs = displaySets.map( ({ displaySetInstanceUID }) => displaySetInstanceUID @@ -222,10 +222,11 @@ class CornerstoneCacheService { for (const segmentation of segmentations) { const segDisplaySetInstanceUID = segmentation.displaySetInstanceUID; + const segDisplaySet = displaySetService.getDisplaySetByUID(segDisplaySetInstanceUID); const shouldDisplaySeg = segmentationService.shouldRenderSegmentation( viewportDisplaySetInstanceUIDs, - segDisplaySetInstanceUID + segDisplaySet.instances[0].FrameOfReferenceUID ); if (shouldDisplaySeg) { diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 8ea071e5ab1..7d61a1dc550 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -99,43 +99,51 @@ class SegmentationService extends PubSubService { }; /** - * It adds a segment to a segmentation, basically just setting the properties for - * the segment. - * @param segmentationId - The ID of the segmentation you want to add a - * segment to. - * @param segmentIndex - The index of the segment to add. - * @param properties - The properties of the segment to add including - * -- label: the label of the segment - * -- color: the color of the segment - * -- opacity: the opacity of the segment - * -- visibility: the visibility of the segment (boolean) - * -- isLocked: whether the segment is locked for editing - * -- active: whether the segment is currently the active segment to be edited + * Adds a new segment to the specified segmentation. + * @param segmentationId - The ID of the segmentation to add the segment to. + * @param config - An object containing the configuration options for the new segment. + * - segmentIndex: (optional) The index of the segment to add. If not provided, the next available index will be used. + * - toolGroupId: (optional) The ID of the tool group to associate the new segment with. If not provided, the first available tool group will be used. + * - properties: (optional) An object containing the properties of the new segment. + * - label: (optional) The label of the new segment. If not provided, a default label will be used. + * - color: (optional) The color of the new segment in RGB format. If not provided, a default color will be used. + * - opacity: (optional) The opacity of the new segment. If not provided, a default opacity will be used. + * - visibility: (optional) Whether the new segment should be visible. If not provided, the segment will be visible by default. + * - isLocked: (optional) Whether the new segment should be locked for editing. If not provided, the segment will not be locked by default. + * - active: (optional) Whether the new segment should be the active segment to be edited. If not provided, the segment will not be active by default. */ public addSegment( segmentationId: string, - segmentIndex: number, - toolGroupId?: string, - properties?: { - label?: string; - color?: ohifTypes.RGB; - opacity?: number; - visibility?: boolean; - isLocked?: boolean; - active?: boolean; - } + config: { + segmentIndex?: number; + toolGroupId?: string; + properties?: { + label?: string; + color?: ohifTypes.RGB; + opacity?: number; + visibility?: boolean; + isLocked?: boolean; + active?: boolean; + }; + } = {} ): void { - if (segmentIndex === 0) { + if (config?.segmentIndex === 0) { throw new Error('Segment index 0 is reserved for "no label"'); } - toolGroupId = toolGroupId ?? this._getFirstToolGroupId(); + const toolGroupId = config.toolGroupId ?? this._getFirstToolGroupId(); const { segmentationRepresentationUID, segmentation } = this._getSegmentationInfo( segmentationId, toolGroupId ); + let segmentIndex = config.segmentIndex; + if (!segmentIndex) { + // grab the next available segment index + segmentIndex = segmentation.segments.length === 0 ? 1 : segmentation.segments.length; + } + if (this._getSegmentInfo(segmentation, segmentIndex)) { throw new Error(`Segment ${segmentIndex} already exists`); } @@ -147,7 +155,7 @@ class SegmentationService extends PubSubService { ); segmentation.segments[segmentIndex] = { - label: properties.label, + label: config.properties?.label ?? `Segment ${segmentIndex}`, segmentIndex: segmentIndex, color: [rgbaColor[0], rgbaColor[1], rgbaColor[2]], opacity: rgbaColor[3], @@ -157,9 +165,12 @@ class SegmentationService extends PubSubService { segmentation.segmentCount++; + // make the newly added segment the active segment + this._setActiveSegment(segmentationId, segmentIndex); + const suppressEvents = true; - if (properties !== undefined) { - const { color: newColor, opacity, isLocked, visibility, active } = properties; + if (config.properties !== undefined) { + const { color: newColor, opacity, isLocked, visibility, active } = config.properties; if (newColor !== undefined) { this._setSegmentColor(segmentationId, segmentIndex, newColor, toolGroupId, suppressEvents); @@ -281,17 +292,21 @@ class SegmentationService extends PubSubService { ); } - public setSegmentLockedForSegmentation( - segmentationId: string, - segmentIndex: number, - isLocked: boolean - ): void { + public setSegmentLocked(segmentationId: string, segmentIndex: number, isLocked: boolean): void { const suppressEvents = false; this._setSegmentLocked(segmentationId, segmentIndex, isLocked, suppressEvents); } - public setSegmentLabel(segmentationId: string, segmentIndex: number, segmentLabel: string): void { - this._setSegmentLabel(segmentationId, segmentIndex, segmentLabel); + /** + * Toggles the locked state of a segment in a segmentation. + * @param segmentationId - The ID of the segmentation. + * @param segmentIndex - The index of the segment to toggle. + */ + public toggleSegmentLocked(segmentationId: string, segmentIndex: number): void { + const segmentation = this.getSegmentation(segmentationId); + const segment = this._getSegmentInfo(segmentation, segmentIndex); + const isLocked = !segment.isLocked; + this._setSegmentLocked(segmentationId, segmentIndex, isLocked); } public setSegmentColor( @@ -353,7 +368,7 @@ class SegmentationService extends PubSubService { this._setActiveSegmentationForToolGroup(segmentationId, toolGroupId, suppressEvents); } - public setActiveSegmentForSegmentation(segmentationId: string, segmentIndex: number): void { + public setActiveSegment(segmentationId: string, segmentIndex: number): void { this._setActiveSegment(segmentationId, segmentIndex, false); } @@ -434,12 +449,14 @@ class SegmentationService extends PubSubService { }, ]); - // Define a new color LUT and associate it with this segmentation. - // Todo: need to be generalized to accept custom color LUTs - const newColorLUT = this.generateNewColorLUT(); - const newColorLUTIndex = this.getNextColorLUTIndex(); - - cstSegmentation.config.color.addColorLUT(newColorLUT, newColorLUTIndex); + // if first segmentation, we can use the default colorLUT, otherwise + // we need to generate a new one and use a new colorLUT + const colorLUTIndex = 0; + if (Object.keys(this.segmentations).length !== 0) { + const newColorLUT = this.generateNewColorLUT(); + const colorLUTIndex = this.getNextColorLUTIndex(); + cstSegmentation.config.color.addColorLUT(newColorLUT, colorLUTIndex); + } this.segmentations[segmentationId] = { ...segmentation, @@ -448,8 +465,8 @@ class SegmentationService extends PubSubService { activeSegmentIndex: segmentation.activeSegmentIndex ?? null, segmentCount: segmentation.segmentCount ?? 0, isActive: false, - colorLUTIndex: newColorLUTIndex, isVisible: true, + colorLUTIndex, }; cachedSegmentation = this.segmentations[segmentationId]; @@ -736,6 +753,97 @@ class SegmentationService extends PubSubService { return this.addOrUpdateSegmentation(segmentation, suppressEvents); } + // Todo: this should not run on the main thread + public calculateCentroids = ( + segmentationId: string, + segmentIndex?: number + ): Map => { + const segmentation = this.getSegmentation(segmentationId); + const volume = this.getLabelmapVolume(segmentationId); + const { dimensions, imageData } = volume; + const scalarData = volume.getScalarData(); + const [dimX, dimY, numFrames] = dimensions; + const frameLength = dimX * dimY; + + const segmentIndices = segmentIndex + ? [segmentIndex] + : segmentation.segments + .filter(segment => segment?.segmentIndex) + .map(segment => segment.segmentIndex); + + const segmentIndicesSet = new Set(segmentIndices); + + const centroids = new Map(); + for (const index of segmentIndicesSet) { + centroids.set(index, { x: 0, y: 0, z: 0, count: 0 }); + } + + let voxelIndex = 0; + for (let frame = 0; frame < numFrames; frame++) { + for (let p = 0; p < frameLength; p++) { + const segmentIndex = scalarData[voxelIndex++]; + if (segmentIndicesSet.has(segmentIndex)) { + const centroid = centroids.get(segmentIndex); + centroid.x += p % dimX; + centroid.y += (p / dimX) | 0; + centroid.z += frame; + centroid.count++; + } + } + } + + const result = new Map(); + for (const [index, centroid] of centroids) { + const count = centroid.count; + const normalizedCentroid = { + x: centroid.x / count, + y: centroid.y / count, + z: centroid.z / count, + }; + normalizedCentroid.world = imageData.indexToWorld([ + normalizedCentroid.x, + normalizedCentroid.y, + normalizedCentroid.z, + ]); + result.set(index, normalizedCentroid); + } + + this.setCentroids(segmentationId, result); + return result; + }; + + private setCentroids = ( + segmentationId: string, + centroids: Map + ): void => { + const segmentation = this.getSegmentation(segmentationId); + const imageData = this.getLabelmapVolume(segmentationId).imageData; // Assuming this method returns imageData + + if (!segmentation.cachedStats) { + segmentation.cachedStats = { segmentCenter: {} }; + } else if (!segmentation.cachedStats.segmentCenter) { + segmentation.cachedStats.segmentCenter = {}; + } + + for (const [segmentIndex, centroid] of centroids) { + let world = centroid.world; + + // If world coordinates are not provided, calculate them + if (!world || world.length === 0) { + world = imageData.indexToWorld(centroid.image); + } + + segmentation.cachedStats.segmentCenter[segmentIndex] = { + center: { + image: centroid.image, + world: world, + }, + }; + } + + this.addOrUpdateSegmentation(segmentation, true, true); + }; + public jumpToSegmentCenter( segmentationId: string, segmentIndex: number, @@ -749,6 +857,10 @@ class SegmentationService extends PubSubService { const { toolGroupService } = this.servicesManager.services; const center = this._getSegmentCenter(segmentationId, segmentIndex); + if (!center?.world) { + return; + } + const { world } = center; // todo: generalize @@ -831,6 +943,7 @@ class SegmentationService extends PubSubService { displaySetInstanceUID: string, options?: { segmentationId: string; + FrameOfReferenceUID: string; label: string; } ): Promise => { @@ -865,6 +978,8 @@ class SegmentationService extends PubSubService { // We should set it as active by default, as it created for display isActive: true, type: representationType, + FrameOfReferenceUID: + options?.FrameOfReferenceUID || displaySet.instances?.[0]?.FrameOfReferenceUID, representationData: { LABELMAP: { volumeId: segmentationId, @@ -892,7 +1007,8 @@ class SegmentationService extends PubSubService { toolGroupId: string, segmentationId: string, hydrateSegmentation = false, - representationType = csToolsEnums.SegmentationRepresentations.Labelmap + representationType = csToolsEnums.SegmentationRepresentations.Labelmap, + suppressEvents = false ): Promise => { const segmentation = this.getSegmentation(segmentationId); @@ -959,13 +1075,19 @@ class SegmentationService extends PubSubService { ); } - if (isLocked !== undefined) { + if (isLocked) { this._setSegmentLocked(segmentationId, segmentIndex, isLocked, suppressEvents); } } + + if (!suppressEvents) { + this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { + segmentation, + }); + } }; - public setSegmentRGBAColorForSegmentation = ( + public setSegmentRGBAColor = ( segmentationId: string, segmentIndex: number, rgbaColor, @@ -1008,11 +1130,11 @@ class SegmentationService extends PubSubService { if (!segmentation) { throw new Error(`Segmentation with segmentationId ${segmentationId} not found.`); } + segmentation.hydrated = true; + // Not all segmentations have dipslaysets, some of them are derived in the client this._setDisplaySetIsHydrated(segmentationId, true); - segmentation.hydrated = true; - if (!suppressEvents) { this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { segmentation, @@ -1021,8 +1143,13 @@ class SegmentationService extends PubSubService { }; private _setDisplaySetIsHydrated(displaySetUID: string, isHydrated: boolean): void { - const { DisplaySetService: displaySetService } = this.servicesManager.services; + const { displaySetService } = this.servicesManager.services; const displaySet = displaySetService.getDisplaySetByUID(displaySetUID); + + if (!displaySet) { + return; + } + displaySet.isHydrated = isHydrated; displaySetService.setDisplaySetMetadataInvalidated(displaySetUID, false); } @@ -1167,7 +1294,6 @@ class SegmentationService extends PubSubService { } const { colorLUTIndex } = segmentation; - this._removeSegmentationFromCornerstone(segmentationId); // Delete associated colormap @@ -1181,8 +1307,12 @@ class SegmentationService extends PubSubService { if (wasActive) { const remainingSegmentations = this._getSegmentations(); - if (remainingSegmentations.length) { - const { id } = remainingSegmentations[0]; + const remainingHydratedSegmentations = remainingSegmentations.filter( + segmentation => segmentation.hydrated + ); + + if (remainingHydratedSegmentations.length) { + const { id } = remainingHydratedSegmentations[0]; this._setActiveSegmentationForToolGroup(id, this._getFirstToolGroupId(), false); } @@ -1312,15 +1442,11 @@ class SegmentationService extends PubSubService { return cstSegmentation.state.getSegmentationRepresentations(toolGroupId); }; - public setSegmentLabelForSegmentation( - segmentationId: string, - segmentIndex: number, - label: string - ) { - this._setSegmentLabelForSegmentation(segmentationId, segmentIndex, label); + public setSegmentLabel(segmentationId: string, segmentIndex: number, label: string) { + this._setSegmentLabel(segmentationId, segmentIndex, label); } - private _setSegmentLabelForSegmentation( + private _setSegmentLabel( segmentationId: string, segmentIndex: number, label: string, @@ -1348,8 +1474,8 @@ class SegmentationService extends PubSubService { } } - public shouldRenderSegmentation(viewportDisplaySetInstanceUIDs, segDisplaySetInstanceUID) { - if (!viewportDisplaySetInstanceUIDs || !viewportDisplaySetInstanceUIDs.length) { + public shouldRenderSegmentation(viewportDisplaySetInstanceUIDs, segmentationFrameOfReferenceUID) { + if (!viewportDisplaySetInstanceUIDs?.length) { return false; } @@ -1357,10 +1483,6 @@ class SegmentationService extends PubSubService { let shouldDisplaySeg = false; - const segDisplaySet = displaySetService.getDisplaySetByUID(segDisplaySetInstanceUID); - - const segFrameOfReferenceUID = this._getFrameOfReferenceUIDForSeg(segDisplaySet); - // check if the displaySet is sharing the same frameOfReferenceUID // with the new segmentation for (const displaySetInstanceUID of viewportDisplaySetInstanceUIDs) { @@ -1370,7 +1492,7 @@ class SegmentationService extends PubSubService { // don't want to show the segmentation for all the frames if ( displaySet.isReconstructable && - displaySet?.images?.[0]?.FrameOfReferenceUID === segFrameOfReferenceUID + displaySet?.images?.[0]?.FrameOfReferenceUID === segmentationFrameOfReferenceUID ) { shouldDisplaySeg = true; break; @@ -1717,7 +1839,7 @@ class SegmentationService extends PubSubService { const segmentationRepresentations = this.getSegmentationRepresentationsForToolGroup(toolGroupId); - if (segmentationRepresentations.length === 0) { + if (!segmentationRepresentations?.length) { return; } diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts index e170197c36f..d6d231fd0ab 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts @@ -39,6 +39,8 @@ type Segmentation = { isActive: boolean; // if the segmentation is visible in the viewer isVisible: boolean; + // the frame of reference UID of the segmentation + FrameOfReferenceUID: string; // the label of the segmentation label: string; // the number of segments in the segmentation diff --git a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts index fc3d6c86c21..3a85342b381 100644 --- a/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts +++ b/extensions/cornerstone/src/services/ToolGroupService/ToolGroupService.ts @@ -97,9 +97,9 @@ export default class ToolGroupService { } public getActiveToolForViewport(viewportId: string): string { - const toolGroup = ToolGroupManager.getToolGroupForViewport(viewportId); + const toolGroup = this.getToolGroupForViewport(viewportId); if (!toolGroup) { - return null; + return; } return toolGroup.getActivePrimaryMouseButtonTool(); @@ -184,13 +184,9 @@ export default class ToolGroupService { this._setToolsMode(toolGroup, tools); } - public createToolGroupAndAddTools( - toolGroupId: string, - tools: Array, - configs: any = {} - ): Types.IToolGroup { + public createToolGroupAndAddTools(toolGroupId: string, tools: Array): Types.IToolGroup { const toolGroup = this.createToolGroup(toolGroupId); - this.addToolsToToolGroup(toolGroupId, tools, configs); + this.addToolsToToolGroup(toolGroupId, tools); return toolGroup; } @@ -239,34 +235,6 @@ export default class ToolGroupService { toolInstance.configuration = config; } - private _getToolNames(toolGroupTools: Tools): string[] { - const toolNames = []; - if (toolGroupTools.active) { - toolGroupTools.active.forEach(tool => { - toolNames.push(tool.toolName); - }); - } - if (toolGroupTools.passive) { - toolGroupTools.passive.forEach(tool => { - toolNames.push(tool.toolName); - }); - } - - if (toolGroupTools.enabled) { - toolGroupTools.enabled.forEach(tool => { - toolNames.push(tool.toolName); - }); - } - - if (toolGroupTools.disabled) { - toolGroupTools.disabled.forEach(tool => { - toolNames.push(tool.toolName); - }); - } - - return toolNames; - } - private _setToolsMode(toolGroup, tools) { const { active, passive, enabled, disabled } = tools; @@ -295,17 +263,33 @@ export default class ToolGroupService { } } - private _addTools(toolGroup, tools, configs) { - const toolNames = this._getToolNames(tools); - toolNames.forEach(toolName => { - // Initialize the toolConfig if no configuration is provided - const toolConfig = configs[toolName] ?? {}; + private _addTools(toolGroup, tools) { + const addTools = tools => { + tools.forEach(({ toolName, parentTool, configuration }) => { + if (parentTool) { + toolGroup.addToolInstance(toolName, parentTool, { + ...configuration, + }); + } else { + toolGroup.addTool(toolName, { ...configuration }); + } + }); + }; - // if (volumeUID) { - // toolConfig.volumeUID = volumeUID; - // } + if (tools.active) { + addTools(tools.active); + } - toolGroup.addTool(toolName, { ...toolConfig }); - }); + if (tools.passive) { + addTools(tools.passive); + } + + if (tools.enabled) { + addTools(tools.enabled); + } + + if (tools.disabled) { + addTools(tools.disabled); + } } } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 29ffe0d39d4..f6d0978972b 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -15,11 +15,7 @@ import { import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools'; import { IViewportService } from './IViewportService'; import { RENDERING_ENGINE_ID } from './constants'; -import ViewportInfo, { - ViewportOptions, - DisplaySetOptions, - PublicViewportOptions, -} from './Viewport'; +import ViewportInfo, { DisplaySetOptions, PublicViewportOptions } from './Viewport'; import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService'; import { Presentation, Presentations } from '../../types/Presentation'; @@ -586,10 +582,23 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi continue; } - // otherwise, check if the hydrated segmentations are in the same FOR + // otherwise, check if the hydrated segmentations are in the same FrameOfReferenceUID // as the primary displaySet, if so add the representation (since it was not there) - const { id: segDisplaySetInstanceUID, type } = segmentation; - const segFrameOfReferenceUID = this._getFrameOfReferenceUID(segDisplaySetInstanceUID); + const { id: segDisplaySetInstanceUID } = segmentation; + let segFrameOfReferenceUID = this._getFrameOfReferenceUID(segDisplaySetInstanceUID); + + if (!segFrameOfReferenceUID) { + // if the segmentation displaySet does not have a FrameOfReferenceUID, we might check the + // segmentation itself maybe it has a FrameOfReferenceUID + const { FrameOfReferenceUID } = segmentation; + if (FrameOfReferenceUID) { + segFrameOfReferenceUID = FrameOfReferenceUID; + } + } + + if (!segFrameOfReferenceUID) { + return; + } let shouldDisplaySeg = false; diff --git a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx index 2d402df5fbf..e3dead98514 100644 --- a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx +++ b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx @@ -1,5 +1,6 @@ -import { metaData } from '@cornerstonejs/core'; +import { VolumeViewport, metaData } from '@cornerstonejs/core'; import { utilities } from '@cornerstonejs/core'; +import { IStackViewport, IVolumeViewport, Point3 } from '@cornerstonejs/core/dist/esm/types'; import { AnnotationDisplayTool, drawing } from '@cornerstonejs/tools'; import { guid } from '@ohif/core/src/utils'; @@ -46,6 +47,15 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool { this._cachedOverlayMetadata = new Map(); }; + protected getReferencedImageId(viewport: IStackViewport | IVolumeViewport): string { + if (viewport instanceof VolumeViewport) { + return; + } + + const targetId = this.getTargetId(viewport); + return targetId.split('imageId:')[1]; + } + renderAnnotation = (enabledElement, svgDrawingHelper) => { const { viewport } = enabledElement; diff --git a/extensions/cornerstone/src/utils/dicomLoaderService.js b/extensions/cornerstone/src/utils/dicomLoaderService.js index cd52871e8ad..79cb2053ec1 100644 --- a/extensions/cornerstone/src/utils/dicomLoaderService.js +++ b/extensions/cornerstone/src/utils/dicomLoaderService.js @@ -97,7 +97,7 @@ class DicomLoaderService { if ( (!imageInstance && !nonImageInstance) || - !nonImageInstance.imageId.startsWith('dicomfile') + !nonImageInstance.imageId?.startsWith('dicomfile') ) { return; } diff --git a/extensions/default/src/Actions/createReportAsync.tsx b/extensions/default/src/Actions/createReportAsync.tsx index af9520917c6..d0f34a48a23 100644 --- a/extensions/default/src/Actions/createReportAsync.tsx +++ b/extensions/default/src/Actions/createReportAsync.tsx @@ -4,49 +4,31 @@ import { DicomMetadataStore } from '@ohif/core'; /** * * @param {*} servicesManager - * @param {*} dataSource - * @param {*} measurements - * @param {*} options - * @returns {string[]} displaySetInstanceUIDs */ -async function createReportAsync( - servicesManager, - commandsManager, - dataSource, - measurements, - options -) { +async function createReportAsync({ servicesManager, getReport, reportType = 'measurement' }) { const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services; const loadingDialogId = uiDialogService.create({ showOverlay: true, isDraggable: false, centralize: true, - // TODO: Create a loading indicator component + zeplin design? content: Loading, }); try { - const naturalizedReport = await commandsManager.runCommand( - 'storeMeasurements', - { - measurementData: measurements, - dataSource, - additionalFindingTypes: ['ArrowAnnotate'], - options, - }, - 'CORNERSTONE_STRUCTURED_REPORT' - ); + const naturalizedReport = await getReport(); // The "Mode" route listens for DicomMetadataStore changes // When a new instance is added, it listens and // automatically calls makeDisplaySets DicomMetadataStore.addInstances([naturalizedReport], true); - const displaySetInstanceUID = displaySetService.getMostRecentDisplaySet(); + const displaySet = displaySetService.getMostRecentDisplaySet(); + + const displaySetInstanceUID = displaySet.displaySetInstanceUID; uiNotificationService.show({ title: 'Create Report', - message: 'Measurements saved successfully', + message: `${reportType} saved successfully`, type: 'success', }); @@ -54,7 +36,7 @@ async function createReportAsync( } catch (error) { uiNotificationService.show({ title: 'Create Report', - message: error.message || 'Failed to store measurements', + message: error.message || `Failed to store ${reportType}`, type: 'error', }); } finally { diff --git a/extensions/default/src/DicomJSONDataSource/index.js b/extensions/default/src/DicomJSONDataSource/index.js index d75ab67e5ae..c4bfb75ce6c 100644 --- a/extensions/default/src/DicomJSONDataSource/index.js +++ b/extensions/default/src/DicomJSONDataSource/index.js @@ -119,18 +119,18 @@ function createDicomJSONApi(dicomJsonConfig) { }); }, processResults: () => { - console.debug(' DICOMJson QUERY processResults'); + console.warn(' DICOMJson QUERY processResults not implemented'); }, }, series: { // mapParams: mapParams.bind(), search: () => { - console.debug(' DICOMJson QUERY SERIES SEARCH'); + console.warn(' DICOMJson QUERY SERIES SEARCH not implemented'); }, }, instances: { search: () => { - console.debug(' DICOMJson QUERY instances SEARCH'); + console.warn(' DICOMJson QUERY instances SEARCH not implemented'); }, }, }, @@ -211,7 +211,7 @@ function createDicomJSONApi(dicomJsonConfig) { }, store: { dicom: () => { - console.debug(' DICOMJson store dicom'); + console.warn(' DICOMJson store dicom not implemented'); }, }, getImageIdsForDisplaySet(displaySet) { diff --git a/extensions/default/src/DicomLocalDataSource/index.js b/extensions/default/src/DicomLocalDataSource/index.js index 712acd05782..a7628e80a9c 100644 --- a/extensions/default/src/DicomLocalDataSource/index.js +++ b/extensions/default/src/DicomLocalDataSource/index.js @@ -85,7 +85,7 @@ function createDicomLocalApi(dicomLocalConfig) { }); }, processResults: () => { - console.debug(' DICOMLocal QUERY processResults'); + console.warn(' DICOMLocal QUERY processResults not implemented'); }, }, series: { @@ -107,7 +107,7 @@ function createDicomLocalApi(dicomLocalConfig) { }, instances: { search: () => { - console.debug(' DICOMLocal QUERY instances SEARCH'); + console.warn(' DICOMLocal QUERY instances SEARCH not implemented'); }, }, }, diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 688aa4f0037..b265e4a07a9 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -221,7 +221,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { await wadoDicomWebClient.storeInstances(options); } else { const meta = { - FileMetaInformationVersion: dataset._meta.FileMetaInformationVersion.Value, + FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value, MediaStorageSOPClassUID: dataset.SOPClassUID, MediaStorageSOPInstanceUID: dataset.SOPInstanceUID, TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN, @@ -295,6 +295,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { }); instance.imageId = imageId; + instance.wadoRoot = dicomWebConfig.wadoRoot; + instance.wadoUri = dicomWebConfig.wadoUri; metadataProvider.addImageIdToUIDs(imageId, { StudyInstanceUID, diff --git a/extensions/default/src/Panels/ActionButtons.tsx b/extensions/default/src/Panels/ActionButtons.tsx index 9382a2451da..c21f8b6b652 100644 --- a/extensions/default/src/Panels/ActionButtons.tsx +++ b/extensions/default/src/Panels/ActionButtons.tsx @@ -2,18 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { LegacyButton, ButtonGroup } from '@ohif/ui'; +import { LegacyButton, LegacyButtonGroup } from '@ohif/ui'; function ActionButtons({ onExportClick, onCreateReportClick }) { const { t } = useTranslation('MeasurementTable'); return ( - - {/* TODO Revisit design of ButtonGroup later - for now use LegacyButton for its children.*/} + {/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/} {t('Create Report')} - + ); } diff --git a/extensions/default/src/Panels/PanelMeasurementTable.tsx b/extensions/default/src/Panels/PanelMeasurementTable.tsx index 65b37018154..791d878aa24 100644 --- a/extensions/default/src/Panels/PanelMeasurementTable.tsx +++ b/extensions/default/src/Panels/PanelMeasurementTable.tsx @@ -103,13 +103,20 @@ export default function PanelMeasurementTable({ // creating too many series instances. const options = findSRWithSameSeriesDescription(SeriesDescription, displaySetService); - return createReportAsync( - servicesManager, - commandsManager, - dataSource, - trackedMeasurements, - options - ); + const getReport = async () => { + return commandsManager.runCommand( + 'storeMeasurements', + { + measurementData: trackedMeasurements, + dataSource, + additionalFindingTypes: ['ArrowAnnotate'], + options, + }, + 'CORNERSTONE_STRUCTURED_REPORT' + ); + }; + + return createReportAsync({ servicesManager, getReport }); } } diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index d868730d9f1..7498dc33475 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -103,8 +103,7 @@ function PanelStudyBrowser({ } StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [StudyInstanceUIDs, getStudiesForPatientByMRN]); + }, [StudyInstanceUIDs, dataSource, getStudiesForPatientByMRN, navigate]); // // ~~ Initial Thumbnails useEffect(() => { @@ -116,21 +115,23 @@ function PanelStudyBrowser({ const imageId = imageIds[Math.floor(imageIds.length / 2)]; // TODO: Is it okay that imageIds are not returned here for SR displaySets? - if (imageId && !displaySet?.unsupported) { - // When the image arrives, render it and store the result in the thumbnailImgSrcMap - newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId); - if (isMounted.current) { - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); - } + if (!imageId || displaySet?.unsupported) { + return; + } + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId); + if (!isMounted.current) { + return; } + + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); }); return () => { isMounted.current = false; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [StudyInstanceUIDs, dataSource, displaySetService, getImageSrc]); // ~~ displaySets useEffect(() => { @@ -140,8 +141,7 @@ function PanelStudyBrowser({ sortStudyInstances(mappedDisplaySets); setDisplaySets(mappedDisplaySets); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [thumbnailImageSrcMap]); + }, [StudyInstanceUIDs, thumbnailImageSrcMap, displaySetService]); // ~~ subscriptions --> displaySets useEffect(() => { @@ -149,32 +149,40 @@ function PanelStudyBrowser({ const SubscriptionDisplaySetsAdded = displaySetService.subscribe( displaySetService.EVENTS.DISPLAY_SETS_ADDED, data => { - const { displaySetsAdded } = data; + const { displaySetsAdded, options } = data; displaySetsAdded.forEach(async dSet => { const newImageSrcEntry = {}; const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID); - if (!displaySet?.unsupported) { - const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); - const imageId = imageIds[Math.floor(imageIds.length / 2)]; - - // TODO: Is it okay that imageIds are not returned here for SR displaysets? - if (imageId) { - // When the image arrives, render it and store the result in the thumbnailImgSrcMap - newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc( - imageId, - dSet.initialViewport - ); - if (isMounted.current) { - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); - } - } + if (displaySet?.unsupported) { + return; + } + + const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); + const imageId = imageIds[Math.floor(imageIds.length / 2)]; + + // TODO: Is it okay that imageIds are not returned here for SR displaysets? + if (!imageId) { + return; } + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc( + imageId, + dSet.initialViewport + ); + + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); }); } ); + return () => { + SubscriptionDisplaySetsAdded.unsubscribe(); + }; + }, [getImageSrc, dataSource, displaySetService]); + + useEffect(() => { // TODO: Will this always hold _all_ the displaySets we care about? // DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets` const SubscriptionDisplaySetsChanged = displaySetService.subscribe( @@ -198,12 +206,10 @@ function PanelStudyBrowser({ ); return () => { - SubscriptionDisplaySetsAdded.unsubscribe(); SubscriptionDisplaySetsChanged.unsubscribe(); SubscriptionDisplaySetMetaDataInvalidated.unsubscribe(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [StudyInstanceUIDs, thumbnailImageSrcMap, displaySetService]); const tabs = _createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets); diff --git a/extensions/default/src/Panels/index.js b/extensions/default/src/Panels/index.js index a5a81473ca7..e81fdc304ac 100644 --- a/extensions/default/src/Panels/index.js +++ b/extensions/default/src/Panels/index.js @@ -1,5 +1,11 @@ import PanelStudyBrowser from './PanelStudyBrowser'; import WrappedPanelStudyBrowser from './WrappedPanelStudyBrowser'; import PanelMeasurementTable from './PanelMeasurementTable'; +import createReportDialogPrompt from './createReportDialogPrompt'; -export { PanelStudyBrowser, WrappedPanelStudyBrowser, PanelMeasurementTable }; +export { + PanelStudyBrowser, + WrappedPanelStudyBrowser, + PanelMeasurementTable, + createReportDialogPrompt, +}; diff --git a/extensions/default/src/Toolbar/Toolbar.tsx b/extensions/default/src/Toolbar/Toolbar.tsx index 51aa20f7cda..c714968a1d0 100644 --- a/extensions/default/src/Toolbar/Toolbar.tsx +++ b/extensions/default/src/Toolbar/Toolbar.tsx @@ -1,51 +1,29 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import classnames from 'classnames'; export default function Toolbar({ servicesManager }) { const { toolbarService } = servicesManager.services; const [toolbarButtons, setToolbarButtons] = useState([]); - const [buttonState, setButtonState] = useState({ - primaryToolId: '', - toggles: {}, - groups: {}, - }); - // Could track buttons and state separately...? useEffect(() => { - const { unsubscribe: unsub1 } = toolbarService.subscribe( - toolbarService.EVENTS.TOOL_BAR_MODIFIED, - () => setToolbarButtons(toolbarService.getButtonSection('primary')) - ); - const { unsubscribe: unsub2 } = toolbarService.subscribe( - toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED, - () => setButtonState({ ...toolbarService.state }) + const { unsubscribe } = toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, () => + setToolbarButtons(toolbarService.getButtonSection('primary')) ); return () => { - unsub1(); - unsub2(); + unsubscribe(); }; }, [toolbarService]); + const onInteraction = useCallback( + args => toolbarService.recordInteraction(args), + [toolbarService] + ); + return ( <> {toolbarButtons.map(toolDef => { const { id, Component, componentProps } = toolDef; - // TODO: ... - - // isActive if: - // - id is primary? - // - id is in list of "toggled on"? - let isActive; - if (componentProps.type === 'toggle') { - isActive = buttonState.toggles[id]; - } - // Also need... to filter list for splitButton, and set primary based on most recently clicked - // Also need to kill the radioGroup button's magic logic - // Everything should be reactive off these props, so commands can inform ToolbarService - - // These can... Trigger toolbar events based on updates? - // Then sync using useEffect, or simply modify the state here? return ( // The margin for separating the tools on the toolbar should go here and NOT in each individual component (button) item. // This allows for the individual items to be included in other UI components where perhaps alternative margins are desired. @@ -56,9 +34,7 @@ export default function Toolbar({ servicesManager }) { toolbarService.recordInteraction(args)} + onInteraction={onInteraction} servicesManager={servicesManager} /> diff --git a/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx new file mode 100644 index 00000000000..24dda712b4d --- /dev/null +++ b/extensions/default/src/Toolbar/ToolbarButtonWithServices.tsx @@ -0,0 +1,75 @@ +import { ToolbarButton } from '@ohif/ui'; +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +function ToolbarButtonWithServices({ + id, + type, + commands, + onInteraction, + servicesManager, + ...props +}) { + const { toolbarService } = servicesManager?.services || {}; + + const [buttonsState, setButtonState] = useState({ + primaryToolId: '', + toggles: {}, + groups: {}, + }); + const { primaryToolId } = buttonsState; + + const isActive = + (type === 'tool' && id === primaryToolId) || + (type === 'toggle' && buttonsState.toggles[id] === true); + + useEffect(() => { + const { unsubscribe } = toolbarService.subscribe( + toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED, + state => { + setButtonState({ ...state }); + } + ); + + return () => { + unsubscribe(); + }; + }, [toolbarService]); + + return ( + + ); +} + +ToolbarButtonWithServices.propTypes = { + id: PropTypes.string.isRequired, + type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired, + commands: PropTypes.arrayOf( + PropTypes.shape({ + commandName: PropTypes.string.isRequired, + context: PropTypes.string, + }) + ), + onInteraction: PropTypes.func.isRequired, + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + toolbarService: PropTypes.shape({ + subscribe: PropTypes.func.isRequired, + state: PropTypes.shape({ + primaryToolId: PropTypes.string, + toggles: PropTypes.objectOf(PropTypes.bool), + groups: PropTypes.objectOf(PropTypes.object), + }).isRequired, + }).isRequired, + }).isRequired, + }).isRequired, +}; + +export default ToolbarButtonWithServices; diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx index 9db937fec2b..430cd5abae0 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx @@ -1,13 +1,37 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui'; - import { ServicesManager } from '@ohif/core'; -function LayoutSelector({ rows, columns, className, servicesManager, ...rest }) { - const [isOpen, setIsOpen] = useState(false); +function ToolbarLayoutSelectorWithServices({ servicesManager, ...props }) { + const { toolbarService } = servicesManager.services; + + const onSelection = useCallback( + props => { + toolbarService.recordInteraction({ + interactionType: 'action', + commands: [ + { + commandName: 'setViewportGridLayout', + commandOptions: { ...props }, + context: 'DEFAULT', + }, + ], + }); + }, + [toolbarService] + ); + + return ( + + ); +} - const { hangingProtocolService, toolbarService } = (servicesManager as ServicesManager).services; +function LayoutSelector({ rows, columns, className, onSelection, ...rest }) { + const [isOpen, setIsOpen] = useState(false); const closeOnOutsideClick = () => { if (isOpen) { @@ -15,19 +39,6 @@ function LayoutSelector({ rows, columns, className, servicesManager, ...rest }) } }; - useEffect(() => { - const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.PROTOCOL_CHANGED, - evt => { - const { protocol } = evt; - } - ); - - return () => { - unsubscribe(); - }; - }, [hangingProtocolService]); - useEffect(() => { window.addEventListener('click', closeOnOutsideClick); return () => { @@ -38,19 +49,6 @@ function LayoutSelector({ rows, columns, className, servicesManager, ...rest }) const onInteractionHandler = () => setIsOpen(!isOpen); const DropdownContent = isOpen ? OHIFLayoutSelector : null; - const onSelectionHandler = props => { - toolbarService.recordInteraction({ - interactionType: 'action', - commands: [ - { - commandName: 'setViewportGridLayout', - commandOptions: { ...props }, - context: 'DEFAULT', - }, - ], - }); - }; - return ( ) } @@ -87,4 +85,4 @@ LayoutSelector.defaultProps = { onLayoutChange: () => {}, }; -export default LayoutSelector; +export default ToolbarLayoutSelectorWithServices; diff --git a/extensions/default/src/Toolbar/ToolbarSplitButton.tsx b/extensions/default/src/Toolbar/ToolbarSplitButton.tsx deleted file mode 100644 index 484e8f97dca..00000000000 --- a/extensions/default/src/Toolbar/ToolbarSplitButton.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { SplitButton } from '@ohif/ui'; - -export default SplitButton; diff --git a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx new file mode 100644 index 00000000000..a8943b16fad --- /dev/null +++ b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx @@ -0,0 +1,186 @@ +import { SplitButton, Icon, ToolbarButton } from '@ohif/ui'; +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +function ToolbarSplitButtonWithServices({ + isRadio, + isAction, + groupId, + primary, + secondary, + items, + renderer, + onInteraction, + servicesManager, +}) { + const { toolbarService } = servicesManager?.services; + + const handleItemClick = (item, index) => { + const { id, type, commands } = item; + onInteraction({ + groupId, + itemId: id, + interactionType: type, + commands, + }); + + setState(state => ({ + ...state, + primary: !isAction && isRadio ? { ...item, index } : state.primary, + isExpanded: false, + items: getSplitButtonItems(items).filter(item => + isRadio && !isAction ? item.index !== index : true + ), + })); + }; + + /* Bubbles up individual item clicks */ + const getSplitButtonItems = items => + items.map((item, index) => ({ + ...item, + index, + onClick: () => handleItemClick(item, index), + })); + + const [buttonsState, setButtonState] = useState({ + primaryToolId: '', + toggles: {}, + groups: {}, + }); + + const [state, setState] = useState({ + primary, + items: getSplitButtonItems(items).filter(item => + isRadio && !isAction ? item.id !== primary.id : true + ), + }); + + const { primaryToolId, toggles } = buttonsState; + + const isPrimaryToggle = state.primary.type === 'toggle'; + + const isPrimaryActive = + (state.primary.type === 'tool' && primaryToolId === state.primary.id) || + (isPrimaryToggle && toggles[state.primary.id] === true); + + const PrimaryButtonComponent = + toolbarService?.getButtonComponentForUIType(state.primary.uiType) ?? ToolbarButton; + + useEffect(() => { + const { unsubscribe } = toolbarService.subscribe( + toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED, + state => { + setButtonState({ ...state }); + } + ); + + return () => { + unsubscribe(); + }; + }, [toolbarService]); + + const updatedItems = state.items.map(item => { + const isActive = item.type === 'tool' && primaryToolId === item.id; + + // We could have added the + // item.type === 'toggle' && toggles[item.id] === true + // too but that makes the button active when the toggle is active under it + // which feels weird + return { + ...item, + isActive, + }; + }); + + const DefaultListItemRenderer = ({ type, icon, label, t, id }) => { + const isActive = type === 'toggle' && toggles[id] === true; + + return ( +
+ {icon && ( + + + + )} + {t(label)} +
+ ); + }; + + const listItemRenderer = renderer || DefaultListItemRenderer; + + return ( + item.isActive)} + isToggle={isPrimaryToggle} + onInteraction={onInteraction} + Component={props => ( + + )} + /> + ); +} + +ToolbarSplitButtonWithServices.propTypes = { + isRadio: PropTypes.bool, + isAction: PropTypes.bool, + groupId: PropTypes.string, + primary: PropTypes.shape({ + id: PropTypes.string.isRequired, + type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired, + uiType: PropTypes.string, + }), + secondary: PropTypes.shape({ + id: PropTypes.string, + icon: PropTypes.string.isRequired, + label: PropTypes.string, + tooltip: PropTypes.string.isRequired, + isActive: PropTypes.bool, + }), + items: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + type: PropTypes.oneOf(['tool', 'action', 'toggle']).isRequired, + icon: PropTypes.string, + label: PropTypes.string, + tooltip: PropTypes.string, + }) + ), + renderer: PropTypes.func, + onInteraction: PropTypes.func.isRequired, + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + toolbarService: PropTypes.object, + }), + }), +}; + +ToolbarSplitButtonWithServices.defaultProps = { + isRadio: false, + isAction: false, +}; + +export default ToolbarSplitButtonWithServices; diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index d7e4335ca8e..a4556c36862 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -44,7 +44,7 @@ function ViewerLayout({ if (!entry) { throw new Error( - `${id} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.` + `${id} is not valid for an extension module. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file)` ); } diff --git a/extensions/default/src/getToolbarModule.tsx b/extensions/default/src/getToolbarModule.tsx index 60dd94ddc65..eacdffd7b9c 100644 --- a/extensions/default/src/getToolbarModule.tsx +++ b/extensions/default/src/getToolbarModule.tsx @@ -1,7 +1,7 @@ -import { ToolbarButton } from '@ohif/ui'; -import ToolbarDivider from './Toolbar/ToolbarDivider.tsx'; -import ToolbarLayoutSelector from './Toolbar/ToolbarLayoutSelector.tsx'; -import ToolbarSplitButton from './Toolbar/ToolbarSplitButton.tsx'; +import ToolbarDivider from './Toolbar/ToolbarDivider'; +import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector'; +import ToolbarSplitButtonWithServices from './Toolbar/ToolbarSplitButtonWithServices'; +import ToolbarButtonWithServices from './Toolbar/ToolbarButtonWithServices'; export default function getToolbarModule({ commandsManager, servicesManager }) { return [ @@ -12,27 +12,27 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { }, { name: 'ohif.action', - defaultComponent: ToolbarButton, + defaultComponent: ToolbarButtonWithServices, clickHandler: () => {}, }, { name: 'ohif.radioGroup', - defaultComponent: ToolbarButton, + defaultComponent: ToolbarButtonWithServices, clickHandler: () => {}, }, { name: 'ohif.splitButton', - defaultComponent: ToolbarSplitButton, + defaultComponent: ToolbarSplitButtonWithServices, clickHandler: () => {}, }, { name: 'ohif.layoutSelector', - defaultComponent: ToolbarLayoutSelector, + defaultComponent: ToolbarLayoutSelectorWithServices, clickHandler: (evt, clickedBtn, btnSectionName) => {}, }, { name: 'ohif.toggle', - defaultComponent: ToolbarButton, + defaultComponent: ToolbarButtonWithServices, clickHandler: () => {}, }, ]; diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index 25c37bc4bb0..aa466244366 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -13,6 +13,8 @@ import { id } from './id.js'; import preRegistration from './init'; import { ContextMenuController, CustomizableContextMenuTypes } from './CustomizableContextMenu'; import * as dicomWebUtils from './DicomWebDataSource/utils'; +import { createReportDialogPrompt } from './Panels'; +import createReportAsync from './Actions/createReportAsync'; const defaultExtension: Types.Extensions.Extension = { /** @@ -48,4 +50,6 @@ export { CustomizableContextMenuTypes, getStudiesForPatientByMRN, dicomWebUtils, + createReportDialogPrompt, + createReportAsync, }; diff --git a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx index 51ed6c69996..c57df827a69 100644 --- a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx +++ b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx @@ -338,29 +338,6 @@ function MicroscopyPanel(props: IMicroscopyPanelProps) { onEdit={onMeasurementItemEditHandler} /> -
- - {/* Let's hide the save button for now, as export SR for SM is a proof of concept */} - {/*{promptSave && ( - - )} */} - {/* */} - -
); } diff --git a/extensions/dicom-microscopy/src/utils/constructSR.ts b/extensions/dicom-microscopy/src/utils/constructSR.ts index 340e66d1ed0..07e50943e68 100644 --- a/extensions/dicom-microscopy/src/utils/constructSR.ts +++ b/extensions/dicom-microscopy/src/utils/constructSR.ts @@ -60,10 +60,10 @@ export default function constructSR(metadata, { SeriesDescription, SeriesNumber const { roiGraphic: roi, label } = annotations[i]; let { measurements, evaluations, marker, presentationState } = roi.properties; - console.debug('[SR] storing marker...', marker); - console.debug('[SR] storing measurements...', measurements); - console.debug('[SR] storing evaluations...', evaluations); - console.debug('[SR] storing presentation state...', presentationState); + console.log('[SR] storing marker...', marker); + console.log('[SR] storing measurements...', measurements); + console.log('[SR] storing evaluations...', evaluations); + console.log('[SR] storing presentation state...', presentationState); if (presentationState) { presentationState.marker = marker; diff --git a/extensions/dicom-microscopy/src/utils/loadSR.js b/extensions/dicom-microscopy/src/utils/loadSR.js index cf9509b8181..c300b6f5e88 100644 --- a/extensions/dicom-microscopy/src/utils/loadSR.js +++ b/extensions/dicom-microscopy/src/utils/loadSR.js @@ -149,12 +149,12 @@ async function _getROIsFromToolState(naturalizedDataset, FrameOfReferenceUID) { if (measurements && measurements.length) { properties.measurements = measurements; - console.debug('[SR] retrieving measurements...', measurements); + console.log('[SR] retrieving measurements...', measurements); } if (evaluations && evaluations.length) { properties.evaluations = evaluations; - console.debug('[SR] retrieving evaluations...', evaluations); + console.log('[SR] retrieving evaluations...', evaluations); } const roi = new DICOMMicroscopyViewer.roi.ROI({ scoord3d, properties }); diff --git a/extensions/measurement-tracking/src/_shared/createReportAsync.tsx b/extensions/measurement-tracking/src/_shared/createReportAsync.tsx deleted file mode 100644 index af9520917c6..00000000000 --- a/extensions/measurement-tracking/src/_shared/createReportAsync.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { DicomMetadataStore } from '@ohif/core'; - -/** - * - * @param {*} servicesManager - * @param {*} dataSource - * @param {*} measurements - * @param {*} options - * @returns {string[]} displaySetInstanceUIDs - */ -async function createReportAsync( - servicesManager, - commandsManager, - dataSource, - measurements, - options -) { - const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services; - const loadingDialogId = uiDialogService.create({ - showOverlay: true, - isDraggable: false, - centralize: true, - // TODO: Create a loading indicator component + zeplin design? - content: Loading, - }); - - try { - const naturalizedReport = await commandsManager.runCommand( - 'storeMeasurements', - { - measurementData: measurements, - dataSource, - additionalFindingTypes: ['ArrowAnnotate'], - options, - }, - 'CORNERSTONE_STRUCTURED_REPORT' - ); - - // The "Mode" route listens for DicomMetadataStore changes - // When a new instance is added, it listens and - // automatically calls makeDisplaySets - DicomMetadataStore.addInstances([naturalizedReport], true); - - const displaySetInstanceUID = displaySetService.getMostRecentDisplaySet(); - - uiNotificationService.show({ - title: 'Create Report', - message: 'Measurements saved successfully', - type: 'success', - }); - - return [displaySetInstanceUID]; - } catch (error) { - uiNotificationService.show({ - title: 'Create Report', - message: error.message || 'Failed to store measurements', - type: 'error', - }); - } finally { - uiDialogService.dismiss({ id: loadingDialogId }); - } -} - -function Loading() { - return
Loading...
; -} - -export default createReportAsync; diff --git a/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.tsx b/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.tsx deleted file mode 100644 index 33f13b20342..00000000000 --- a/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; -import { ButtonEnums, Dialog, Input } from '@ohif/ui'; -import RESPONSE from './PROMPT_RESPONSES'; - -export default function createReportDialogPrompt(uiDialogService) { - return new Promise(function (resolve, reject) { - let dialogId = undefined; - - const _handleClose = () => { - // Dismiss dialog - uiDialogService.dismiss({ id: dialogId }); - // Notify of cancel action - resolve({ action: RESPONSE.CANCEL, value: undefined }); - }; - - /** - * - * @param {string} param0.action - value of action performed - * @param {string} param0.value - value from input field - */ - const _handleFormSubmit = ({ action, value }) => { - switch (action.id) { - case 'save': - // Only save if description is not blank otherwise ignore - if (value.label && value.label.trim() !== '') { - resolve({ - action: RESPONSE.CREATE_REPORT, - value: value.label.trim(), - }); - uiDialogService.dismiss({ id: dialogId }); - } - break; - case 'cancel': - uiDialogService.dismiss({ id: dialogId }); - resolve({ action: RESPONSE.CANCEL, value: undefined }); - break; - } - }; - - dialogId = uiDialogService.create({ - centralize: true, - isDraggable: false, - content: Dialog, - useLastPosition: false, - showOverlay: true, - contentProps: { - title: 'Create Report', - value: { label: '' }, - noCloseButton: true, - onClose: _handleClose, - actions: [ - { id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary }, - { id: 'save', text: 'Save', type: ButtonEnums.type.primary }, - ], - // TODO: Should be on button press... - onSubmit: _handleFormSubmit, - body: ({ value, setValue }) => { - const onChangeHandler = event => { - event.persist(); - setValue(value => ({ ...value, label: event.target.value })); - }; - const onKeyPressHandler = event => { - if (event.key === 'Enter') { - // Trigger form submit - _handleFormSubmit({ action: { id: 'save' }, value }); - } - }; - return ( -
- -
- ); - }, - }, - }); - }); -} diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx index d750909a804..bc83efa7db7 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.tsx @@ -35,7 +35,7 @@ function TrackedMeasurementsContextProvider( const machineOptions = Object.assign({}, defaultOptions); machineOptions.actions = Object.assign({}, machineOptions.actions, { jumpToFirstMeasurementInActiveViewport: (ctx, evt) => { - const { trackedStudy, trackedSeries } = ctx; + const { trackedStudy, trackedSeries, activeViewportId } = ctx; const measurements = measurementService.getMeasurements(); const trackedMeasurements = measurements.filter( m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) @@ -43,7 +43,7 @@ function TrackedMeasurementsContextProvider( console.log( 'jumping to measurement reset viewport', - viewportGrid.activeViewportId, + activeViewportId, trackedMeasurements[0] ); @@ -71,7 +71,7 @@ function TrackedMeasurementsContextProvider( } viewportGridService.setDisplaySetsForViewport({ - viewportId: viewportGrid.activeViewportId, + viewportId: activeViewportId, displaySetInstanceUIDs: [referencedDisplaySetUID], viewportOptions: { initialImageOptions: { @@ -82,8 +82,7 @@ function TrackedMeasurementsContextProvider( }, showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => { if (evt.data.createdDisplaySetInstanceUIDs.length > 0) { - const StructuredReportDisplaySetInstanceUID = - evt.data.createdDisplaySetInstanceUIDs[0].displaySetInstanceUID; + const StructuredReportDisplaySetInstanceUID = evt.data.createdDisplaySetInstanceUIDs[0]; viewportGridService.setDisplaySetsForViewport({ viewportId: evt.data.viewportId, @@ -160,6 +159,13 @@ function TrackedMeasurementsContextProvider( measurementTrackingMachine ); + useEffect(() => { + // Update the state machine with the active viewport ID + sendTrackedMeasurementsEvent('UPDATE_ACTIVE_VIEWPORT_ID', { + activeViewportId, + }); + }, [activeViewportId, sendTrackedMeasurementsEvent]); + // ~~ Listen for changes to ViewportGrid for potential SRs hung in panes when idle useEffect(() => { if (viewports.size > 0) { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index 494a51d02b4..2b0b65ca157 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -15,6 +15,7 @@ const machineConfiguration = { id: 'measurementTracking', initial: 'idle', context: { + activeViewportId: null, trackedStudy: '', trackedSeries: [], ignoredSeries: [], @@ -47,6 +48,11 @@ const machineConfiguration = { }, RESTORE_PROMPT_HYDRATE_SR: 'promptHydrateStructuredReport', HYDRATE_SR: 'hydrateStructuredReport', + UPDATE_ACTIVE_VIEWPORT_ID: { + actions: assign({ + activeViewportId: (_, event) => event.activeViewportId, + }), + }, }, }, promptBeginTracking: { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js index e2475737606..1719c56dd39 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js @@ -1,5 +1,4 @@ -import createReportAsync from './../../_shared/createReportAsync'; -import createReportDialogPrompt from '../../_shared/createReportDialogPrompt'; +import { createReportAsync, createReportDialogPrompt } from '@ohif/extension-default'; import getNextSRSeriesNumber from '../../_shared/getNextSRSeriesNumber'; import RESPONSE from '../../_shared/PROMPT_RESPONSES'; @@ -15,7 +14,9 @@ function promptSaveReport({ servicesManager, commandsManager, extensionManager } return new Promise(async function (resolve, reject) { // TODO: Fallback if (uiDialogService) { - const promptResult = await createReportDialogPrompt(uiDialogService); + const promptResult = await createReportDialogPrompt(uiDialogService, { + extensionManager, + }); if (promptResult.action === RESPONSE.CREATE_REPORT) { const dataSources = extensionManager.getDataSources(); @@ -33,16 +34,25 @@ function promptSaveReport({ servicesManager, commandsManager, extensionManager } const SeriesNumber = getNextSRSeriesNumber(displaySetService); - displaySetInstanceUIDs = await createReportAsync( + const getReport = async () => { + return commandsManager.runCommand( + 'storeMeasurements', + { + measurementData: trackedMeasurements, + dataSource, + additionalFindingTypes: ['ArrowAnnotate'], + options: { + SeriesDescription, + SeriesNumber, + }, + }, + 'CORNERSTONE_STRUCTURED_REPORT' + ); + }; + displaySetInstanceUIDs = await createReportAsync({ servicesManager, - commandsManager, - dataSource, - trackedMeasurements, - { - SeriesDescription, - SeriesNumber, - } - ); + getReport, + }); } else if (promptResult.action === RESPONSE.CANCEL) { // Do nothing } diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index 06890e1d514..98bc050c36b 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -131,14 +131,16 @@ function PanelStudyBrowserTracking({ const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); const imageId = imageIds[Math.floor(imageIds.length / 2)]; - // TODO: Is it okay that imageIds are not returned here for SR displaysets? - if (imageId && !displaySet?.unsupported) { - // When the image arrives, render it and store the result in the thumbnailImgSrcMap - newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId); - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); + // TODO: Is it okay that imageIds are not returned here for SR displaySets? + if (!imageId || displaySet?.unsupported) { + return; } + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId); + + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); }); }, [displaySetService, dataSource, getImageSrc]); @@ -184,27 +186,38 @@ function PanelStudyBrowserTracking({ const newImageSrcEntry = {}; const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - if (!displaySet?.unsupported) { - if (options.madeInClient) { - setJumpToDisplaySet(displaySetInstanceUID); - } - - const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); - const imageId = imageIds[Math.floor(imageIds.length / 2)]; - - // TODO: Is it okay that imageIds are not returned here for SR displaysets? - if (imageId) { - // When the image arrives, render it and store the result in the thumbnailImgSrcMap - newImageSrcEntry[displaySetInstanceUID] = await getImageSrc(imageId); - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); - } + if (displaySet?.unsupported) { + return; + } + + if (options.madeInClient) { + setJumpToDisplaySet(displaySetInstanceUID); } + + const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); + const imageId = imageIds[Math.floor(imageIds.length / 2)]; + + // TODO: Is it okay that imageIds are not returned here for SR displaysets? + if (!imageId) { + return; + } + + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[displaySetInstanceUID] = await getImageSrc(imageId); + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); }); } ); + return () => { + SubscriptionDisplaySetsAdded.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displaySetService, dataSource, getImageSrc, thumbnailImageSrcMap, trackedSeries, viewports]); + + useEffect(() => { // TODO: Will this always hold _all_ the displaySets we care about? // DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets` const SubscriptionDisplaySetsChanged = displaySetService.subscribe( @@ -246,12 +259,10 @@ function PanelStudyBrowserTracking({ ); return () => { - SubscriptionDisplaySetsAdded.unsubscribe(); SubscriptionDisplaySetsChanged.unsubscribe(); SubscriptionDisplaySetMetaDataInvalidated.unsubscribe(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [displaySetService, dataSource, getImageSrc, thumbnailImageSrcMap, trackedSeries, viewports]); + }, [thumbnailImageSrcMap, trackedSeries, viewports, dataSource, displaySetService]); const tabs = _createStudyBrowserTabs( StudyInstanceUIDs, @@ -442,7 +453,7 @@ function _mapDisplaySets( body: () => (

Are you sure you want to delete this report?

-

This action cannot be undone.

+

This action cannot be undone.

), actions: [ diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ExportReports.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ExportReports.tsx index 1e85e9b9b80..628cb14eab3 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ExportReports.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ExportReports.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LegacyButton, ButtonGroup } from '@ohif/ui'; +import { LegacyButton, LegacyButtonGroup } from '@ohif/ui'; import { useTranslation } from 'react-i18next'; function ExportReports({ segmentations, tmtvValue, config, commandsManager }) { @@ -9,8 +9,8 @@ function ExportReports({ segmentations, tmtvValue, config, commandsManager }) { <> {segmentations?.length ? (
- {/* TODO Revisit design of ButtonGroup later - for now use LegacyButton for its children.*/} - @@ -27,8 +27,8 @@ function ExportReports({ segmentations, tmtvValue, config, commandsManager }) { > {t('Export CSV')} - - + @@ -41,7 +41,7 @@ function ExportReports({ segmentations, tmtvValue, config, commandsManager }) { > {t('Create RT Report')} - +
) : null} diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx index d30a50af9e1..274ae720da9 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Input, Label, Select, LegacyButton, ButtonGroup } from '@ohif/ui'; +import { Input, Label, Select, LegacyButton, LegacyButtonGroup } from '@ohif/ui'; import { useTranslation } from 'react-i18next'; export const ROI_STAT = 'roi_stat'; @@ -35,8 +35,8 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) { />
- {/* TODO Revisit design of ButtonGroup later - for now use LegacyButton for its children.*/} - + {/* TODO Revisit design of LegacyButtonGroup later - for now use LegacyButton for its children.*/} + {t('End')} - +
diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index f1e250ea0b2..411409e1f49 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -89,14 +89,13 @@ function modeFactory({ modeConfiguration }) { }; const toolGroupId = 'default'; - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, configs); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); let unsubscribe; const activateTool = () => { toolbarService.recordInteraction({ groupId: 'WindowLevel', - itemId: 'WindowLevel', interactionType: 'tool', commands: [ { diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index 1c6645c4b0e..ab68c94699e 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -81,7 +81,6 @@ function modeFactory() { const activateTool = () => { toolbarService.recordInteraction({ groupId: 'WindowLevel', - itemId: 'WindowLevel', interactionType: 'tool', commands: [ { diff --git a/modes/basic-test-mode/src/initToolGroups.js b/modes/basic-test-mode/src/initToolGroups.js index 452d7ff15b6..3110f24f885 100644 --- a/modes/basic-test-mode/src/initToolGroups.js +++ b/modes/basic-test-mode/src/initToolGroups.js @@ -23,7 +23,23 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage ], passive: [ { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, + { + toolName: toolNames.ArrowAnnotate, + configuration: { + getTextCallback: (callback, eventDetails) => + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }), + + changeTextCallback: (data, eventDetails, callback) => + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }), + }, + }, { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, @@ -39,24 +55,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage disabled: [{ toolName: toolNames.ReferenceLines }], }; - const toolsConfig = { - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { @@ -117,25 +116,8 @@ function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { // disabled }; - const toolsConfig = { - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - const toolGroupId = 'SRToolGroup'; - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { @@ -163,7 +145,23 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { ], passive: [ { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, + { + toolName: toolNames.ArrowAnnotate, + configuration: { + getTextCallback: (callback, eventDetails) => + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }), + + changeTextCallback: (data, eventDetails, callback) => + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }), + }, + }, { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, @@ -173,37 +171,25 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.Angle }, { toolName: toolNames.SegmentationDisplay }, ], - disabled: [{ toolName: toolNames.Crosshairs }, { toolName: toolNames.ReferenceLines }], + disabled: [ + { + toolName: toolNames.Crosshairs, + configuration: { + viewportIndicators: false, + autoPan: { + enabled: false, + panSize: 10, + }, + }, + }, + { toolName: toolNames.ReferenceLines }, + ], // enabled // disabled }; - const toolsConfig = { - [toolNames.Crosshairs]: { - viewportIndicators: false, - autoPan: { - enabled: false, - panSize: 10, - }, - }, - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - - toolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools('mpr', tools); } function initToolGroups(extensionManager, toolGroupService, commandsManager) { diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 2ed36a5b0aa..288d0787c0b 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -74,7 +74,7 @@ function modeFactory({ modeConfiguration }) { toolbarService, toolGroupService, panelService, - segmentationService, + customizationService, } = servicesManager.services; measurementService.clearMeasurements(); @@ -87,7 +87,6 @@ function modeFactory({ modeConfiguration }) { const activateTool = () => { toolbarService.recordInteraction({ groupId: 'WindowLevel', - itemId: 'WindowLevel', interactionType: 'tool', commands: [ { @@ -126,6 +125,13 @@ function modeFactory({ modeConfiguration }) { 'MoreTools', ]); + customizationService.addModeCustomizations([ + { + id: 'segmentation.disableEditing', + value: true, + }, + ]); + // // ActivatePanel event trigger for when a segmentation or measurement is added. // // Do not force activation so as to respect the state the user may have left the UI in. // _activatePanelTriggersSubscriptions = [ diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index cdc17c024f7..e7ea816db39 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -23,7 +23,23 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage ], passive: [ { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, + { + toolName: toolNames.ArrowAnnotate, + configuration: { + getTextCallback: (callback, eventDetails) => + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }), + + changeTextCallback: (data, eventDetails, callback) => + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }), + }, + }, { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, @@ -43,24 +59,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage disabled: [{ toolName: toolNames.ReferenceLines }], }; - const toolsConfig = { - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { @@ -68,6 +67,10 @@ function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { '@ohif/extension-cornerstone-dicom-sr.utilityModule.tools' ); + if (!SRUtilityModule) { + return; + } + const CS3DUtilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.utilityModule.tools' ); @@ -121,25 +124,8 @@ function initSRToolGroup(extensionManager, toolGroupService, commandsManager) { // disabled }; - const toolsConfig = { - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - const toolGroupId = 'SRToolGroup'; - toolGroupService.createToolGroupAndAddTools(toolGroupId, tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); } function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { @@ -167,7 +153,23 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { ], passive: [ { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, + { + toolName: toolNames.ArrowAnnotate, + configuration: { + getTextCallback: (callback, eventDetails) => + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }), + + changeTextCallback: (data, eventDetails, callback) => + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }), + }, + }, { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.EllipticalROI }, @@ -179,37 +181,25 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { { toolName: toolNames.PlanarFreehandROI }, { toolName: toolNames.SegmentationDisplay }, ], - disabled: [{ toolName: toolNames.Crosshairs }, { toolName: toolNames.ReferenceLines }], + disabled: [ + { + toolName: toolNames.Crosshairs, + configuration: { + viewportIndicators: false, + autoPan: { + enabled: false, + panSize: 10, + }, + }, + }, + { toolName: toolNames.ReferenceLines }, + ], // enabled // disabled }; - const toolsConfig = { - [toolNames.Crosshairs]: { - viewportIndicators: false, - autoPan: { - enabled: false, - panSize: 10, - }, - }, - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - - toolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools('mpr', tools); } function initVolume3DToolGroup(extensionManager, toolGroupService) { const utilityModule = extensionManager.getModuleEntry( diff --git a/modes/segmentation/.gitignore b/modes/segmentation/.gitignore new file mode 100644 index 00000000000..67045665db2 --- /dev/null +++ b/modes/segmentation/.gitignore @@ -0,0 +1,104 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port diff --git a/modes/segmentation/.webpack/webpack.prod.js b/modes/segmentation/.webpack/webpack.prod.js new file mode 100644 index 00000000000..163392a699a --- /dev/null +++ b/modes/segmentation/.webpack/webpack.prod.js @@ -0,0 +1,62 @@ +const path = require('path'); +const pkg = require('../package.json'); + +const outputFile = 'index.umd.js'; +const rootDir = path.resolve(__dirname, '../'); +const outputFolder = path.join(__dirname, `../dist/umd/${pkg.name}/`); + +// Todo: add ESM build for the mode in addition to umd build +const config = { + mode: 'production', + entry: rootDir + '/' + pkg.module, + devtool: 'source-map', + output: { + path: outputFolder, + filename: outputFile, + library: pkg.name, + libraryTarget: 'umd', + chunkFilename: '[name].chunk.js', + umdNamedDefine: true, + globalObject: "typeof self !== 'undefined' ? self : this", + }, + externals: [ + { + react: { + root: 'React', + commonjs2: 'react', + commonjs: 'react', + amd: 'react', + }, + '@ohif/core': { + commonjs2: '@ohif/core', + commonjs: '@ohif/core', + amd: '@ohif/core', + root: '@ohif/core', + }, + '@ohif/ui': { + commonjs2: '@ohif/ui', + commonjs: '@ohif/ui', + amd: '@ohif/ui', + root: '@ohif/ui', + }, + }, + ], + module: { + rules: [ + { + test: /(\.jsx|\.js|\.tsx|\.ts)$/, + loader: 'babel-loader', + exclude: /(node_modules|bower_components)/, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + ], + }, + resolve: { + modules: [path.resolve('./node_modules'), path.resolve('./src')], + extensions: ['.json', '.js', '.jsx', '.tsx', '.ts'], + }, +}; + +module.exports = config; diff --git a/modes/segmentation/LICENSE b/modes/segmentation/LICENSE new file mode 100644 index 00000000000..c58f05915f0 --- /dev/null +++ b/modes/segmentation/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 @ohif-segmentation-mode (contact@ohif.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/modes/segmentation/README.md b/modes/segmentation/README.md new file mode 100644 index 00000000000..5bf905d9270 --- /dev/null +++ b/modes/segmentation/README.md @@ -0,0 +1,7 @@ +# @ohif-segmentation-mode +## Description +OHIF segmentation mode which enables labelmap segmentation read/edit/export +## Author +@ohif +## License +MIT \ No newline at end of file diff --git a/modes/segmentation/babel.config.js b/modes/segmentation/babel.config.js new file mode 100644 index 00000000000..a38ddda2127 --- /dev/null +++ b/modes/segmentation/babel.config.js @@ -0,0 +1,44 @@ +module.exports = { + plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'], + env: { + test: { + presets: [ + [ + // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 + '@babel/preset-env', + { + modules: 'commonjs', + debug: false, + }, + '@babel/preset-typescript', + ], + '@babel/preset-react', + ], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-regenerator', + '@babel/plugin-transform-runtime', + ], + }, + production: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + development: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + plugins: ['react-hot-loader/babel'], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + }, +}; diff --git a/modes/segmentation/package.json b/modes/segmentation/package.json new file mode 100644 index 00000000000..81624cab818 --- /dev/null +++ b/modes/segmentation/package.json @@ -0,0 +1,64 @@ +{ + "name": "@ohif/mode-segmentation", + "version": "3.7.0-beta.41", + "description": "OHIF segmentation mode which enables labelmap segmentation read/edit/export", + "author": "@ohif", + "license": "MIT", + "main": "dist/umd/@ohif/mode-segmentation/index.umd.js", + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "repository": "OHIF/Viewers", + "keywords": [ + "ohif-mode" + ], + "module": "src/index.tsx", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo", + "dev:cornerstone": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "@ohif/core": "^3.7.0-beta.41" + }, + "dependencies": { + "@babel/runtime": "^7.20.13" + }, + "devDependencies": { + "@babel/core": "^7.21.4", + "@babel/plugin-proposal-class-properties": "^7.16.7", + "@babel/plugin-proposal-object-rest-spread": "^7.17.3", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.16.7", + "@babel/plugin-transform-regenerator": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.17.0", + "@babel/plugin-transform-typescript": "^7.13.0", + "@babel/preset-env": "^7.16.11", + "@babel/preset-react": "^7.16.7", + "@babel/preset-typescript": "^7.13.0", + "babel-eslint": "^8.0.3", + "babel-loader": "^8.0.0-beta.4", + "babel-plugin-inline-react-svg": "^2.0.1", + "clean-webpack-plugin": "^4.0.0", + "copy-webpack-plugin": "^10.2.0", + "cross-env": "^7.0.3", + "dotenv": "^14.1.0", + "eslint": "^5.0.1", + "eslint-loader": "^2.0.0", + "webpack": "^5.50.0", + "webpack-merge": "^5.7.3", + "webpack-cli": "^4.7.2" + } +} diff --git a/modes/segmentation/src/id.js b/modes/segmentation/src/id.js new file mode 100644 index 00000000000..ebe5acd98ae --- /dev/null +++ b/modes/segmentation/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx new file mode 100644 index 00000000000..a6f552c5aee --- /dev/null +++ b/modes/segmentation/src/index.tsx @@ -0,0 +1,179 @@ +import { hotkeys } from '@ohif/core'; +import { id } from './id'; +import toolbarButtons from './toolbarButtons'; +import initToolGroups from './initToolGroups'; + +const ohif = { + layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', + sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', + hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default', + leftPanel: '@ohif/extension-default.panelModule.seriesList', + rightPanel: '@ohif/extension-default.panelModule.measure', +}; + +const cornerstone = { + viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone', +}; + +const segmentation = { + panel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', + panelTool: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentationWithTools', + sopClassHandler: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg', + viewport: '@ohif/extension-cornerstone-dicom-seg.viewportModule.dicom-seg', +}; + +/** + * Just two dependencies to be able to render a viewport with panels in order + * to make sure that the mode is working. + */ +const extensionDependencies = { + '@ohif/extension-default': '^3.0.0', + '@ohif/extension-cornerstone': '^3.0.0', + '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', +}; + +function modeFactory({ modeConfiguration }) { + return { + /** + * Mode ID, which should be unique among modes used by the viewer. This ID + * is used to identify the mode in the viewer's state. + */ + id, + routeName: 'segmentation', + /** + * Mode name, which is displayed in the viewer's UI in the workList, for the + * user to select the mode. + */ + displayName: 'Segmentation', + /** + * Runs when the Mode Route is mounted to the DOM. Usually used to initialize + * Services and other resources. + */ + onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => { + const { measurementService, toolbarService, toolGroupService } = servicesManager.services; + + measurementService.clearMeasurements(); + + // Init Default and SR ToolGroups + initToolGroups(extensionManager, toolGroupService, commandsManager); + + let unsubscribe; + + const activateTool = () => { + toolbarService.recordInteraction({ + groupId: 'WindowLevel', + interactionType: 'tool', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'WindowLevel', + }, + context: 'CORNERSTONE', + }, + ], + }); + + // We don't need to reset the active tool whenever a viewport is getting + // added to the toolGroup. + unsubscribe(); + }; + + // Since we only have one viewport for the basic cs3d mode and it has + // only one hanging protocol, we can just use the first viewport + ({ unsubscribe } = toolGroupService.subscribe( + toolGroupService.EVENTS.VIEWPORT_ADDED, + activateTool + )); + + toolbarService.init(extensionManager); + toolbarService.addButtons(toolbarButtons); + toolbarService.createButtonSection('primary', [ + 'Zoom', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MPR', + 'Crosshairs', + 'MoreTools', + ]); + }, + onModeExit: ({ servicesManager }) => { + const { + toolGroupService, + syncGroupService, + toolbarService, + segmentationService, + cornerstoneViewportService, + } = servicesManager.services; + + toolGroupService.destroy(); + syncGroupService.destroy(); + segmentationService.destroy(); + cornerstoneViewportService.destroy(); + }, + /** */ + validationTags: { + study: [], + series: [], + }, + /** + * A boolean return value that indicates whether the mode is valid for the + * modalities of the selected studies. For instance a PET/CT mode should be + */ + isValidMode: ({ modalities }) => true, + /** + * Mode Routes are used to define the mode's behavior. A list of Mode Route + * that includes the mode's path and the layout to be used. The layout will + * include the components that are used in the layout. For instance, if the + * default layoutTemplate is used (id: '@ohif/extension-default.layoutTemplateModule.viewerLayout') + * it will include the leftPanels, rightPanels, and viewports. However, if + * you define another layoutTemplate that includes a Footer for instance, + * you should provide the Footer component here too. Note: We use Strings + * to reference the component's ID as they are registered in the internal + * ExtensionManager. The template for the string is: + * `${extensionId}.{moduleType}.${componentId}`. + */ + routes: [ + { + path: 'template', + layoutTemplate: ({ location, servicesManager }) => { + return { + id: ohif.layout, + props: { + leftPanels: [ohif.leftPanel], + rightPanels: [segmentation.panelTool], + viewports: [ + { + namespace: cornerstone.viewport, + displaySetsToDisplay: [ohif.sopClassHandler], + }, + { + namespace: segmentation.viewport, + displaySetsToDisplay: [segmentation.sopClassHandler], + }, + ], + }, + }; + }, + }, + ], + /** List of extensions that are used by the mode */ + extensions: extensionDependencies, + /** HangingProtocol used by the mode */ + // hangingProtocol: [''], + /** SopClassHandlers used by the mode */ + sopClassHandlers: [ohif.sopClassHandler, segmentation.sopClassHandler], + /** hotkeys for mode */ + hotkeys: [...hotkeys.defaults.hotkeyBindings], + }; +} + +const mode = { + id, + modeFactory, + extensionDependencies, +}; + +export default mode; diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts new file mode 100644 index 00000000000..58204839ee4 --- /dev/null +++ b/modes/segmentation/src/initToolGroups.ts @@ -0,0 +1,82 @@ +const brushInstanceNames = { + CircularBrush: 'CircularBrush', + CircularEraser: 'CircularEraser', + SphereBrush: 'SphereBrush', + SphereEraser: 'SphereEraser', + ThresholdCircularBrush: 'ThresholdCircularBrush', + ThresholdSphereBrush: 'ThresholdSphereBrush', +}; + +const brushStrategies = { + CircularBrush: 'FILL_INSIDE_CIRCLE', + CircularEraser: 'ERASE_INSIDE_CIRCLE', + SphereBrush: 'FILL_INSIDE_SPHERE', + SphereEraser: 'ERASE_INSIDE_SPHERE', + ThresholdCircularBrush: 'THRESHOLD_INSIDE_CIRCLE', + ThresholdSphereBrush: 'THRESHOLD_INSIDE_SPHERE', +}; + +function createTools(utilityModule) { + const { toolNames, Enums } = utilityModule.exports; + return { + active: [ + { toolName: toolNames.WindowLevel, bindings: [{ mouseButton: Enums.MouseBindings.Primary }] }, + { toolName: toolNames.Pan, bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }] }, + { toolName: toolNames.Zoom, bindings: [{ mouseButton: Enums.MouseBindings.Secondary }] }, + { toolName: toolNames.StackScrollMouseWheel, bindings: [] }, + ], + passive: Object.keys(brushInstanceNames) + .map(brushName => ({ + toolName: brushName, + parentTool: 'Brush', + configuration: { + activeStrategy: brushStrategies[brushName], + }, + })) + .concat([ + { toolName: toolNames.CircleScissors }, + { toolName: toolNames.RectangleScissors }, + { toolName: toolNames.SphereScissors }, + { toolName: toolNames.StackScroll }, + { toolName: toolNames.Magnify }, + { toolName: toolNames.SegmentationDisplay }, + ]), + disabled: [{ toolName: toolNames.ReferenceLines }], + }; +} + +function initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, toolGroupId) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.tools' + ); + const tools = createTools(utilityModule); + toolGroupService.createToolGroupAndAddTools(toolGroupId, tools); +} + +function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.tools' + ); + const tools = createTools(utilityModule); + tools.disabled.push( + { + toolName: utilityModule.exports.toolNames.Crosshairs, + configuration: { + viewportIndicators: false, + autoPan: { + enabled: false, + panSize: 10, + }, + }, + }, + { toolName: utilityModule.exports.toolNames.ReferenceLines } + ); + toolGroupService.createToolGroupAndAddTools('mpr', tools); +} + +function initToolGroups(extensionManager, toolGroupService, commandsManager) { + initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default'); + initMPRToolGroup(extensionManager, toolGroupService, commandsManager); +} + +export default initToolGroups; diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts new file mode 100644 index 00000000000..b17a33deae1 --- /dev/null +++ b/modes/segmentation/src/toolbarButtons.ts @@ -0,0 +1,356 @@ +import { + // ExpandableToolbarButton, + // ListMenu, + WindowLevelMenuItem, +} from '@ohif/ui'; +import { defaults } from '@ohif/core'; + +const { windowLevelPresets } = defaults; +/** + * + * @param {*} type - 'tool' | 'action' | 'toggle' + * @param {*} id + * @param {*} icon + * @param {*} label + */ +function _createButton(type, id, icon, label, commands, tooltip, uiType) { + return { + id, + icon, + label, + type, + commands, + tooltip, + uiType, + }; +} + +const _createActionButton = _createButton.bind(null, 'action'); +const _createToggleButton = _createButton.bind(null, 'toggle'); +const _createToolButton = _createButton.bind(null, 'tool'); + +/** + * + * @param {*} preset - preset number (from above import) + * @param {*} title + * @param {*} subtitle + */ +function _createWwwcPreset(preset, title, subtitle) { + return { + id: preset.toString(), + title, + subtitle, + type: 'action', + commands: [ + { + commandName: 'setWindowLevel', + commandOptions: { + ...windowLevelPresets[preset], + }, + context: 'CORNERSTONE', + }, + ], + }; +} + +const toolGroupIds = ['default', 'mpr', 'SRToolGroup']; + +/** + * Creates an array of 'setToolActive' commands for the given toolName - one for + * each toolGroupId specified in toolGroupIds. + * @param {string} toolName + * @returns {Array} an array of 'setToolActive' commands + */ +function _createSetToolActiveCommands(toolName) { + const temp = toolGroupIds.map(toolGroupId => ({ + commandName: 'setToolActive', + commandOptions: { + toolGroupId, + toolName, + }, + context: 'CORNERSTONE', + })); + return temp; +} + +const toolbarButtons = [ + // Zoom.. + { + id: 'Zoom', + type: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-zoom', + label: 'Zoom', + commands: _createSetToolActiveCommands('Zoom'), + }, + }, + // Window Level + Presets... + { + id: 'WindowLevel', + type: 'ohif.splitButton', + props: { + groupId: 'WindowLevel', + primary: _createToolButton( + 'WindowLevel', + 'tool-window-level', + 'Window Level', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'WindowLevel', + }, + context: 'CORNERSTONE', + }, + ], + 'Window Level' + ), + secondary: { + icon: 'chevron-down', + label: 'W/L Manual', + isActive: true, + tooltip: 'W/L Presets', + }, + isAction: true, // ? + renderer: WindowLevelMenuItem, + items: [ + _createWwwcPreset(1, 'Soft tissue', '400 / 40'), + _createWwwcPreset(2, 'Lung', '1500 / -600'), + _createWwwcPreset(3, 'Liver', '150 / 90'), + _createWwwcPreset(4, 'Bone', '2500 / 480'), + _createWwwcPreset(5, 'Brain', '80 / 40'), + ], + }, + }, + // Pan... + { + id: 'Pan', + type: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-move', + label: 'Pan', + commands: _createSetToolActiveCommands('Pan'), + }, + }, + { + id: 'Capture', + type: 'ohif.action', + props: { + icon: 'tool-capture', + label: 'Capture', + type: 'action', + commands: [ + { + commandName: 'showDownloadViewportModal', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + }, + }, + { + id: 'Layout', + type: 'ohif.layoutSelector', + props: { + rows: 3, + columns: 3, + }, + }, + { + id: 'MPR', + type: 'ohif.action', + props: { + type: 'toggle', + icon: 'icon-mpr', + label: 'MPR', + commands: [ + { + commandName: 'toggleHangingProtocol', + commandOptions: { + protocolId: 'mpr', + }, + context: 'DEFAULT', + }, + ], + }, + }, + { + id: 'Crosshairs', + type: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-crosshair', + label: 'Crosshairs', + commands: [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'Crosshairs', + toolGroupId: 'mpr', + }, + context: 'CORNERSTONE', + }, + ], + }, + }, + // More... + { + id: 'MoreTools', + type: 'ohif.splitButton', + props: { + isRadio: true, // ? + groupId: 'MoreTools', + primary: _createActionButton( + 'Reset', + 'tool-reset', + 'Reset View', + [ + { + commandName: 'resetViewport', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + 'Reset' + ), + secondary: { + icon: 'chevron-down', + label: '', + isActive: true, + tooltip: 'More Tools', + }, + items: [ + _createActionButton( + 'Reset', + 'tool-reset', + 'Reset View', + [ + { + commandName: 'resetViewport', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + 'Reset' + ), + _createActionButton( + 'rotate-right', + 'tool-rotate-right', + 'Rotate Right', + [ + { + commandName: 'rotateViewportCW', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + 'Rotate +90' + ), + _createActionButton( + 'flip-horizontal', + 'tool-flip-horizontal', + 'Flip Horizontally', + [ + { + commandName: 'flipViewportHorizontal', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + 'Flip Horizontal' + ), + _createToggleButton('StackImageSync', 'link', 'Stack Image Sync', [ + { + commandName: 'toggleStackImageSync', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ]), + _createToggleButton( + 'ReferenceLines', + 'tool-referenceLines', // change this with the new icon + 'Reference Lines', + [ + { + commandName: 'toggleReferenceLines', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ] + ), + _createToolButton( + 'StackScroll', + 'tool-stack-scroll', + 'Stack Scroll', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'StackScroll', + }, + context: 'CORNERSTONE', + }, + ], + 'Stack Scroll' + ), + _createActionButton( + 'invert', + 'tool-invert', + 'Invert', + [ + { + commandName: 'invertViewport', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + 'Invert Colors' + ), + _createToggleButton( + 'cine', + 'tool-cine', + 'Cine', + [ + { + commandName: 'toggleCine', + context: 'CORNERSTONE', + }, + ], + 'Cine' + ), + _createToolButton( + 'Magnify', + 'tool-magnify', + 'Magnify', + [ + { + commandName: 'setToolActive', + commandOptions: { + toolName: 'Magnify', + }, + context: 'CORNERSTONE', + }, + ], + 'Magnify' + ), + _createActionButton( + 'TagBrowser', + 'list-bullets', + 'Dicom Tag Browser', + [ + { + commandName: 'openDICOMTagViewer', + commandOptions: {}, + context: 'DEFAULT', + }, + ], + 'Dicom Tag Browser' + ), + ], + }, + }, +]; + +export default toolbarButtons; diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index 38e620a1801..110fb0fb753 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -58,7 +58,6 @@ function modeFactory({ modeConfiguration }) { const setWindowLevelActive = () => { toolbarService.recordInteraction({ groupId: 'WindowLevel', - itemId: 'WindowLevel', interactionType: 'tool', commands: [ { diff --git a/modes/tmtv/src/initToolGroups.js b/modes/tmtv/src/initToolGroups.js index 549a6052fe7..a8f4fcf31b7 100644 --- a/modes/tmtv/src/initToolGroups.js +++ b/modes/tmtv/src/initToolGroups.js @@ -26,7 +26,24 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { ], passive: [ { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, + { + toolName: toolNames.ArrowAnnotate, + configuration: { + getTextCallback: (callback, eventDetails) => { + commandsManager.runCommand('arrowTextCallback', { + callback, + eventDetails, + }); + }, + + changeTextCallback: (data, eventDetails, callback) => + commandsManager.runCommand('arrowTextCallback', { + callback, + data, + eventDetails, + }), + }, + }, { toolName: toolNames.Bidirectional }, { toolName: toolNames.DragProbe }, { toolName: toolNames.Probe }, @@ -38,138 +55,54 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { { toolName: toolNames.Magnify }, ], enabled: [{ toolName: toolNames.SegmentationDisplay }], - disabled: [{ toolName: toolNames.Crosshairs }], - }; - - const toolsConfig = { - [toolNames.Crosshairs]: { - viewportIndicators: false, - autoPan: { - enabled: false, - panSize: 10, - }, - }, - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => { - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }); + disabled: [ + { + toolName: toolNames.Crosshairs, + configuration: { + viewportIndicators: false, + autoPan: { + enabled: false, + panSize: 10, + }, + }, }, - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, + ], }; - toolGroupService.createToolGroupAndAddTools(toolGroupIds.CT, tools, toolsConfig); - toolGroupService.createToolGroupAndAddTools( - toolGroupIds.PT, - { - active: tools.active, - passive: [...tools.passive, { toolName: 'RectangleROIStartEndThreshold' }], - enabled: tools.enabled, - disabled: tools.disabled, - }, - toolsConfig - ); - toolGroupService.createToolGroupAndAddTools(toolGroupIds.Fusion, tools, toolsConfig); - toolGroupService.createToolGroupAndAddTools(toolGroupIds.default, tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupIds.CT, tools); + toolGroupService.createToolGroupAndAddTools(toolGroupIds.PT, { + active: tools.active, + passive: [...tools.passive, { toolName: 'RectangleROIStartEndThreshold' }], + enabled: tools.enabled, + disabled: tools.disabled, + }); + toolGroupService.createToolGroupAndAddTools(toolGroupIds.Fusion, tools); + toolGroupService.createToolGroupAndAddTools(toolGroupIds.default, tools); const mipTools = { active: [ { toolName: toolNames.VolumeRotateMouseWheel, + configuration: { + rotateIncrementDegrees: 0.1, + }, }, { toolName: toolNames.MipJumpToClick, + configuration: { + toolGroupId: toolGroupIds.PT, + }, bindings: [{ mouseButton: Enums.MouseBindings.Primary }], }, ], enabled: [{ toolName: toolNames.SegmentationDisplay }], }; - const mipToolsConfig = { - [toolNames.VolumeRotateMouseWheel]: { - rotateIncrementDegrees: 0.1, - }, - [toolNames.MipJumpToClick]: { - toolGroupId: toolGroupIds.PT, - }, - }; - - toolGroupService.createToolGroupAndAddTools(toolGroupIds.MIP, mipTools, mipToolsConfig); -} - -function initMPRToolGroup(toolNames, Enums, toolGroupService, commandsManager) { - const tools = { - active: [ - { - toolName: toolNames.WindowLevel, - bindings: [{ mouseButton: Enums.MouseBindings.Primary }], - }, - { - toolName: toolNames.Pan, - bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }], - }, - { - toolName: toolNames.Zoom, - bindings: [{ mouseButton: Enums.MouseBindings.Secondary }], - }, - { toolName: toolNames.StackScrollMouseWheel, bindings: [] }, - ], - passive: [ - { toolName: toolNames.Length }, - { toolName: toolNames.ArrowAnnotate }, - { toolName: toolNames.Bidirectional }, - { toolName: toolNames.DragProbe }, - { toolName: toolNames.EllipticalROI }, - { toolName: toolNames.RectangleROI }, - { toolName: toolNames.StackScroll }, - { toolName: toolNames.Angle }, - { toolName: toolNames.CobbAngle }, - { toolName: toolNames.SegmentationDisplay }, - ], - disabled: [{ toolName: toolNames.Crosshairs }], - - // enabled - // disabled - }; - - const toolsConfig = { - [toolNames.Crosshairs]: { - viewportIndicators: false, - autoPan: { - enabled: false, - panSize: 10, - }, - }, - [toolNames.ArrowAnnotate]: { - getTextCallback: (callback, eventDetails) => - commandsManager.runCommand('arrowTextCallback', { - callback, - eventDetails, - }), - - changeTextCallback: (data, eventDetails, callback) => - commandsManager.runCommand('arrowTextCallback', { - callback, - data, - eventDetails, - }), - }, - }; - - toolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig); + toolGroupService.createToolGroupAndAddTools(toolGroupIds.MIP, mipTools); } function initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { _initToolGroups(toolNames, Enums, toolGroupService, commandsManager); - // initMPRToolGroup(toolNames, Enums, toolGroupService, commandsManager); } export default initToolGroups; diff --git a/platform/app/cypress.config.ts b/platform/app/cypress.config.ts index 7cf5ff77f98..c575e7c56cd 100644 --- a/platform/app/cypress.config.ts +++ b/platform/app/cypress.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ responseTimeout: 10000, specPattern: 'cypress/integration/**/*.spec.[jt]s', projectId: '4oe38f', - video: false, + video: true, reporter: 'junit', reporterOptions: { mochaFile: 'cypress/results/test-output.xml', diff --git a/platform/app/cypress/integration/MultiStudy.spec.js b/platform/app/cypress/integration/MultiStudy.spec.js index 8a6bd81b01e..48d60ac0000 100644 --- a/platform/app/cypress/integration/MultiStudy.spec.js +++ b/platform/app/cypress/integration/MultiStudy.spec.js @@ -7,6 +7,7 @@ describe('OHIF Multi Study', () => { cy.expectMinimumThumbnails(4); cy.initCornerstoneToolsAliases(); cy.initCommonElementsAliases(); + cy.waitDicomImage(); }; it('Should display 2 comparison up', () => { diff --git a/platform/app/cypress/integration/OHIFPdfDisplay.spec.js b/platform/app/cypress/integration/OHIFPdfDisplay.spec.js index 05be6e8de62..2ef2f020efe 100644 --- a/platform/app/cypress/integration/OHIFPdfDisplay.spec.js +++ b/platform/app/cypress/integration/OHIFPdfDisplay.spec.js @@ -1,8 +1,6 @@ describe('OHIF PDF Display', function () { beforeEach(function () { cy.openStudyInViewer('2.25.317377619501274872606137091638706705333'); - - cy.resetViewport().wait(50); }); it('checks if series thumbnails are being displayed', function () { diff --git a/platform/app/cypress/integration/OHIFVideoDisplay.spec.js b/platform/app/cypress/integration/OHIFVideoDisplay.spec.js index 1b3565b8af2..ff877034aff 100644 --- a/platform/app/cypress/integration/OHIFVideoDisplay.spec.js +++ b/platform/app/cypress/integration/OHIFVideoDisplay.spec.js @@ -1,7 +1,6 @@ describe('OHIF Video Display', function () { beforeEach(function () { cy.openStudyInViewer('2.25.96975534054447904995905761963464388233'); - cy.resetViewport().wait(50); }); it('checks if series thumbnails are being displayed', function () { diff --git a/platform/app/cypress/integration/customization/HangingProtocol.spec.js b/platform/app/cypress/integration/customization/HangingProtocol.spec.js index 87de457af05..4f55e620b59 100644 --- a/platform/app/cypress/integration/customization/HangingProtocol.spec.js +++ b/platform/app/cypress/integration/customization/HangingProtocol.spec.js @@ -1,5 +1,5 @@ describe('OHIF HP', () => { - const beforeSetup = () => { + beforeEach(() => { cy.checkStudyRouteInViewer( '1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1', '&hangingProtocolId=@ohif/mnGrid' @@ -7,17 +7,14 @@ describe('OHIF HP', () => { cy.expectMinimumThumbnails(3); cy.initCornerstoneToolsAliases(); cy.initCommonElementsAliases(); - }; + cy.waitDicomImage(); + }); it('Should display 3 up', () => { - beforeSetup(); - cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 3); }); it('Should navigate next/previous stage', () => { - beforeSetup(); - cy.get('body').type(','); cy.wait(250); cy.get('[data-cy="viewport-pane"]').its('length').should('be.eq', 4); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js index a06201f90e7..5f2ba6f5a2b 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js @@ -5,7 +5,7 @@ describe('OHIF Context Menu', function () { cy.expectMinimumThumbnails(3); cy.initCommonElementsAliases(); cy.initCornerstoneToolsAliases(); - cy.resetViewport().wait(50); + cy.waitDicomImage(); }); it('checks context menu customization', function () { diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneHotkeys.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneHotkeys.spec.js index ea0a016b134..984b038bc0e 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneHotkeys.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneHotkeys.spec.js @@ -13,10 +13,10 @@ describe('OHIF Cornerstone Hotkeys', () => { cy.expectMinimumThumbnails(3); cy.initCornerstoneToolsAliases(); cy.initCommonElementsAliases(); + cy.waitDicomImage(); }); it('checks if hotkeys "R" and "L" can rotate the image', () => { - // Hotkey R cy.get('body').type('R'); cy.get('@viewportInfoMidLeft').should('contains.text', 'P'); cy.get('@viewportInfoMidTop').should('contains.text', 'R'); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js index 829fd1d9295..15fe14babc1 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js @@ -9,6 +9,7 @@ describe('OHIF Cornerstone Toolbar', () => { //const expectedText = 'Ser: 1'; //cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText); + cy.waitDicomImage(); }); it('checks if all primary buttons are being displayed', () => { @@ -66,13 +67,12 @@ describe('OHIF Cornerstone Toolbar', () => { // }); it('checks if Levels tool will change the window width and center of an image', () => { - //Click on button and verify if icon is active on toolbar - cy.waitDicomImage(); - cy.get('@wwwcBtnPrimary') - .click() - .then($wwwcBtn => { - cy.wrap($wwwcBtn).should('have.class', 'active'); - }); + // Wait for the DICOM image to load + + // Assign an alias to the button element + cy.get('@wwwcBtnPrimary').as('wwwcButton'); + cy.get('@wwwcButton').click(); + cy.get('@wwwcButton').should('have.class', 'active'); //drags the mouse inside the viewport to be able to interact with series cy.get('@viewport') @@ -90,13 +90,16 @@ describe('OHIF Cornerstone Toolbar', () => { }); it('checks if Pan tool will move the image inside the viewport', () => { - //Click on button and verify if icon is active on toolbar - cy.get('@panBtn') - .click() - .then($panBtn => { - cy.wrap($panBtn).should('have.class', 'active'); - }); + // Assign an alias to the button element + cy.get('@panBtn').as('panButton'); + + // Click on the button + cy.get('@panButton').click(); + + // Assert that the button has the 'active' class + cy.get('@panButton').should('have.class', 'active'); + // Trigger the pan actions on the viewport cy.get('@viewport') .trigger('mousedown', 'center', { buttons: 1 }) .trigger('mousemove', 'bottom', { buttons: 1 }) diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js index 45ff0511a07..2d69ae06bf3 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFMeasurementPanel.spec.js @@ -5,7 +5,6 @@ describe('OHIF Measurement Panel', function () { cy.expectMinimumThumbnails(3); cy.initCommonElementsAliases(); cy.initCornerstoneToolsAliases(); - cy.resetViewport().wait(50); cy.waitDicomImage(); }); diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js index 6bcd8e0f0f8..e92e75e5c0f 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js @@ -5,7 +5,6 @@ describe('OHIF Study Viewer Page', function () { cy.expectMinimumThumbnails(3); cy.initCommonElementsAliases(); cy.initCornerstoneToolsAliases(); - cy.resetViewport().wait(50); }); it('checks if series thumbnails are being displayed', function () { diff --git a/platform/app/cypress/support/commands.js b/platform/app/cypress/support/commands.js index 04e438c5735..d6bbbc41413 100644 --- a/platform/app/cypress/support/commands.js +++ b/platform/app/cypress/support/commands.js @@ -144,18 +144,19 @@ Cypress.Commands.add('drag', { prevSubject: 'element' }, (...args) => * @param {number[]} secondClick - Click position [x, y] */ Cypress.Commands.add('addLine', (viewport, firstClick, secondClick) => { - cy.get(viewport).then($viewport => { - const [x1, y1] = firstClick; - const [x2, y2] = secondClick; + cy.get(viewport).as('viewportAlias'); + const [x1, y1] = firstClick; + const [x2, y2] = secondClick; - // The wait is necessary because of double click testing - cy.wrap($viewport) - .click(x1, y1) - .wait(250) - .trigger('mousemove', { clientX: x2, clientY: y2 }) - .click(x2, y2) - .wait(250); - }); + // First click + cy.get('@viewportAlias').click(x1, y1, { force: true, multiple: true }).wait(250); + + // Move the mouse and then click again + cy.get('@viewportAlias') + .trigger('mousemove', { clientX: x2, clientY: y2 }) + .get('@viewportAlias') + .click(x2, y2, { force: true, multiple: true }) + .wait(250); }); /** @@ -183,8 +184,10 @@ Cypress.Commands.add('addAngle', (viewport, firstClick, secondClick, thirdClick) }); Cypress.Commands.add('expectMinimumThumbnails', (seriesToWait = 1) => { - cy.get('[data-cy="study-browser-thumbnail"]', { timeout: 50000 }).should($itemList => { - expect($itemList.length >= seriesToWait).to.be.true; + cy.get('[data-cy="study-browser-thumbnail"]', { timeout: 50000 }).as('thumbnails'); + + cy.get('@thumbnails').should($itemList => { + expect($itemList.length).to.be.gte(seriesToWait); }); }); @@ -211,11 +214,13 @@ Cypress.Commands.add('waitDicomImage', (mode = '/basic-test', timeout = 50000) = //Command to reset and clear all the changes made to the viewport Cypress.Commands.add('resetViewport', () => { - //Click on More button + // Assign an alias to the More button cy.get('[data-cy="MoreTools-split-button-primary"]') .should('have.attr', 'data-tool', 'Reset') - .as('moreBtn') - .click(); + .as('moreBtn'); + + // Use the alias to click on the More button + cy.get('@moreBtn').click(); }); Cypress.Commands.add('imageZoomIn', () => { @@ -266,12 +271,13 @@ Cypress.Commands.add('initStudyListAliasesOnDesktop', () => { Cypress.Commands.add( 'addLengthMeasurement', (firstClick = [150, 100], secondClick = [130, 170]) => { - cy.get('@measurementToolsBtnPrimary') - .should('have.attr', 'data-tool', 'Length') - .click() - .then($lengthBtn => { - cy.wrap($lengthBtn).should('have.class', 'active'); - }); + // Assign an alias to the button element + cy.get('@measurementToolsBtnPrimary').as('lengthButton'); + + cy.get('@lengthButton').should('have.attr', 'data-tool', 'Length'); + cy.get('@lengthButton').click(); + + cy.get('@lengthButton').should('have.class', 'active'); cy.addLine('.viewport-element', firstClick, secondClick); } @@ -463,7 +469,11 @@ Cypress.Commands.add('closePreferences', () => { Cypress.Commands.add('selectPreferencesTab', tabAlias => { cy.initPreferencesModalAliases(); - cy.get(tabAlias).click().should('have.class', 'active'); + + cy.get(tabAlias).as('selectedTab'); + cy.get('@selectedTab').click(); + cy.get('@selectedTab').should('have.class', 'active'); + initPreferencesModalFooterBtnAliases(); }); diff --git a/platform/app/package.json b/platform/app/package.json index f401c079628..f7d635e5e43 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -94,7 +94,7 @@ "devDependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@percy/cypress": "^3.1.1", - "cypress": "^12.6.0", + "cypress": "^13.2.0", "cypress-file-upload": "^3.5.3", "glob": "^8.0.3", "identity-obj-proxy": "3.0.x", diff --git a/platform/app/pluginConfig.json b/platform/app/pluginConfig.json index 2f892db6c86..08a42deb0f2 100644 --- a/platform/app/pluginConfig.json +++ b/platform/app/pluginConfig.json @@ -57,6 +57,9 @@ { "packageName": "@ohif/mode-longitudinal" }, + { + "packageName": "@ohif/mode-segmentation" + }, { "packageName": "@ohif/mode-tmtv" }, diff --git a/platform/app/public/config/aws.js b/platform/app/public/config/aws.js index f09e37fc0b1..f7dd9a06987 100644 --- a/platform/app/public/config/aws.js +++ b/platform/app/public/config/aws.js @@ -23,7 +23,6 @@ window.config = { qidoRoot: 'https://myserver.com/dicomweb', wadoRoot: 'https://myserver.com/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 09c0a233f0b..00013529ea8 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -3,10 +3,7 @@ window.config = { // whiteLabeling: {}, extensions: [], modes: [], - customizationService: { - // Shows a custom route -access via http://localhost:3000/custom - // helloPage: '@ohif/extension-default.customizationModule.helloPage', - }, + customizationService: {}, showStudyList: true, // some windows systems have issues with more than 3 web workers maxNumberOfWebWorkers: 3, @@ -45,7 +42,6 @@ window.config = { qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/public/config/dicomweb_relative.js b/platform/app/public/config/dicomweb_relative.js index 476b2d55e08..bfa51c34aec 100644 --- a/platform/app/public/config/dicomweb_relative.js +++ b/platform/app/public/config/dicomweb_relative.js @@ -22,7 +22,6 @@ window.config = { qidoRoot: '/dicomweb', wadoRoot: '/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index af48a84cbe6..8b5f174a1e8 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -23,7 +23,6 @@ window.config = { qidoRoot: '/viewer-testdata', wadoRoot: '/viewer-testdata', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, @@ -63,7 +62,6 @@ window.config = { qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/public/config/local_static.js b/platform/app/public/config/local_static.js index 8d1e4f66302..89f205d0115 100644 --- a/platform/app/public/config/local_static.js +++ b/platform/app/public/config/local_static.js @@ -22,7 +22,6 @@ window.config = { qidoRoot: '/dicomweb', wadoRoot: '/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/public/config/multiple.js b/platform/app/public/config/multiple.js index 145b16ffc2e..57684a9ef50 100644 --- a/platform/app/public/config/multiple.js +++ b/platform/app/public/config/multiple.js @@ -59,7 +59,6 @@ window.config = { qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, @@ -78,7 +77,6 @@ window.config = { qidoRoot: 'https://dd32w2rfebxel.cloudfront.net/dicomweb', wadoRoot: 'https://dd32w2rfebxel.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, supportsStow: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', @@ -99,7 +97,6 @@ window.config = { qidoRoot: '/viewer-testdata', wadoRoot: '/viewer-testdata', qidoSupportsIncludeField: false, - supportsReject: false, supportsStow: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index bbb24da90fb..a729d1c8d50 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -22,7 +22,6 @@ window.config = { wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, - supportsReject: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index 236add7e30d..e6e1c239470 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -382,7 +382,7 @@ function _getViewportComponent(displaySets, viewportComponents, uiNotificationSe console.log("Can't show displaySet", SOPClassHandlerId, displaySets[0]); uiNotificationService.show({ title: 'Viewport Not Supported Yet', - message: `Cannot display SOPClassId of ${displaySets[0].SOPClassUID} yet`, + message: `Cannot display SOPClassUID of ${displaySets[0].SOPClassUID} yet`, type: 'error', }); diff --git a/platform/cli/templates/mode/src/index.tsx b/platform/cli/templates/mode/src/index.tsx index 3a3f53b8916..2d63d106182 100644 --- a/platform/cli/templates/mode/src/index.tsx +++ b/platform/cli/templates/mode/src/index.tsx @@ -53,7 +53,6 @@ function modeFactory({ modeConfiguration }) { const activateTool = () => { toolbarService.recordInteraction({ groupId: 'WindowLevel', - itemId: 'WindowLevel', interactionType: 'tool', commands: [ { diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.ts b/platform/core/src/services/DisplaySetService/DisplaySetService.ts index d7312b81cf8..8d01a28dca7 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.ts +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.ts @@ -178,8 +178,15 @@ export default class DisplaySetService extends PubSubService { * @param {string} displaySetInstanceUID * @returns {object} displaySet */ - public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => - displaySetCache.get(displaySetInstanceUid); + public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => { + if (typeof displaySetInstanceUid !== 'string') { + throw new Error( + `getDisplaySetByUID: displaySetInstanceUid must be a string, you passed ${displaySetInstanceUid}` + ); + } + + return displaySetCache.get(displaySetInstanceUid); + }; /** * diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index b6f4bc3b69f..01fdd8f6ef0 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -523,22 +523,32 @@ export default class HangingProtocolService extends PubSubService { stage.viewports.push({ viewportOptions: { ...defaultViewportOptions, - viewportId: uuidv4(), + // Use 'default' for the first viewport, and UUIDs for the rest. + viewportId: i === 0 ? 'default' : uuidv4(), }, displaySets: [], }); } } else { // Clone each viewport to ensure independent objects - stage.viewports = stage.viewports.map(viewport => ({ - ...viewport, - viewportOptions: { - ...(viewport.viewportOptions || defaultViewportOptions), - viewportId: viewport.viewportOptions?.viewportId || uuidv4(), - }, - displaySets: viewport.displaySets || [], - })); + stage.viewports = stage.viewports.map((viewport, index) => { + const existingViewportId = viewport.viewportOptions?.viewportId; + return { + ...viewport, + viewportOptions: { + ...(viewport.viewportOptions || defaultViewportOptions), + // use provided viewportId when available, otherwise use default for first viewport + // and uuid for the rest + viewportId: existingViewportId + ? existingViewportId + : index === 0 + ? 'default' + : uuidv4(), + }, + displaySets: viewport.displaySets || [], + }; + }); stage.viewports.forEach(viewport => { viewport.displaySets.forEach(displaySet => { displaySet.options = displaySet.options || {}; diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.js b/platform/core/src/services/HangingProtocolService/lib/validator.js index ef30551f806..72c0a9549ca 100644 --- a/platform/core/src/services/HangingProtocolService/lib/validator.js +++ b/platform/core/src/services/HangingProtocolService/lib/validator.js @@ -77,7 +77,7 @@ validate.validators.doesNotEqual = function (value, options, key) { } } } else if (testValue === dicomArrayValue[0]) { - console.debug(dicomArrayValue, testValue); + console.log(dicomArrayValue, testValue); return `${key} must not equal to ${testValue}`; } }; diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index e79808296fa..919ee946355 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -94,8 +94,10 @@ export default class ToolbarService extends PubSubService { commandsManager.runCommand(commandName, commandOptions, context); }); - // only set the primary tool if no error was thrown - this.state.primaryToolId = itemId; + // only set the primary tool if no error was thrown. + // if the itemId is not undefined use it; otherwise, set the first tool in + // the commands as the primary tool + this.state.primaryToolId = itemId || commands[0].commandOptions?.toolName; } catch (error) { console.warn(error); } @@ -164,7 +166,7 @@ export default class ToolbarService extends PubSubService { this.state.groups[groupId] = itemId; } - this._broadcastEvent(this.EVENTS.TOOL_BAR_STATE_MODIFIED, {}); + this._broadcastEvent(this.EVENTS.TOOL_BAR_STATE_MODIFIED, { ...this.state }); } getButtons() { @@ -293,6 +295,10 @@ export default class ToolbarService extends PubSubService { * @param {*} props - Props set by the Viewer layer */ _mapButtonToDisplay(btn, btnSection, metadata, props) { + if (!btn) { + return; + } + const { id, type, component } = btn; const buttonType = this._buttonTypes()[type]; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index f6e3904efcb..ee430ccacbe 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -74,6 +74,11 @@ class ViewportGridService extends PubSubService { return this.serviceImplementation._getState(); } + public getActiveViewportId() { + const state = this.getState(); + return state.activeViewportId; + } + public setDisplaySetsForViewport(props) { // Just update a single viewport, but use the multi-viewport update for it. this.setDisplaySetsForViewports([props]); diff --git a/platform/docs/docs/deployment/build-for-production.md b/platform/docs/docs/deployment/build-for-production.md index 7c061a89eb9..217f38da28a 100644 --- a/platform/docs/docs/deployment/build-for-production.md +++ b/platform/docs/docs/deployment/build-for-production.md @@ -116,6 +116,26 @@ In the video below notice that there is `platform/viewer` which has been renamed +### Build for non-root path + +If you would like to access the viewer from a non-root path (e.g., `/my-awesome-viewer` instead of `/`), +You can achieve so by using the `PUBLIC_URL` environment variable AND the `routerBasename` configuration option. + +1. use a config (e.g. config/myConfig.js) file that is using the `routerBasename` of your choice `/my-awesome-viewer` (note there is only one / - it is not /my-awesome-viewer/). +2. build the viewer with `PUBLIC_URL=/my-awesome-viewer/ APP_CONFIG=config/myConfig.js yarn build` (note there are two / - it is not /my-awesome-viewer). + + +:::tip +The PUBLIC_URL tells the application where to find the static assets and the routerBasename will tell the application how to handle the routes +::: + +:::tip +Testing, you can use `npx http-server` to serve the files in the generated `dist` folder and access the viewer from `http://localhost:8080/my-awesome-viewer`. To achieve +so, you should first rename the `dist` folder to `my-awesome-viewer` and then change the working directory +to the `platform/app` folder and run `npx http-server ./`. Then on the browser, you can access the viewer from `http://localhost:8080/my-awesome-viewer` +::: + + ### Automating Builds and Deployments If you found setting up your environment and running all of these steps to be a diff --git a/platform/docs/docs/deployment/iframe.md b/platform/docs/docs/deployment/iframe.md index 2deac3fdb8b..c1c0fb87068 100644 --- a/platform/docs/docs/deployment/iframe.md +++ b/platform/docs/docs/deployment/iframe.md @@ -28,7 +28,7 @@ It is also required that the PUBLIC_URL environment variable is set to the same `