fix(SR): Added support for spline and live wire SR items. (#5870)

* fix(SR): Added support for spline and live wire SR items.

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Add a script to checkout a worktree for test builds

* fix: Allow download for testing sr validator

* Remove script that wasn't intended to be included

* Bump CS3D version.

* PR comments - simplify code and use single codepath for download

* Allow both download and save buttons for SEG and RTSTRUCT

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-03-17 08:59:47 -04:00
1 parent 69d8ad63f3
commit 1d4802c2a3
13 files changed
+220 -160

No files matched your search

@@ -1,5 +1,3 @@
import { DicomMetadataStore } from '@ohif/core';
/**
*
* @param {*} servicesManager
@@ -7,7 +5,8 @@ import { DicomMetadataStore } from '@ohif/core';
async function createReportAsync({
servicesManager,
getReport,
reportType = 'measurement',
reportType = 'Measurements',
successMessage,
}: withAppTypes) {
const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services;
@@ -18,18 +17,15 @@ async function createReportAsync({
return;
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
// addInstances is called by the store command (storeMeasurements/storeSegmentation),
// so the display set should already exist at this point.
const displaySet = displaySetService.getMostRecentDisplaySet();
const displaySetInstanceUID = displaySet.displaySetInstanceUID;
uiNotificationService.show({
title: 'Create Report',
message: `${reportType} saved successfully`,
message: successMessage ?? `${reportType} saved successfully`,
type: 'success',
});
@@ -30,6 +30,7 @@ export default function CreateReportDialogPrompt({
predecessorImageId,
extensionManager,
servicesManager,
enableDownload = false,
}): Promise<{
value: string;
dataSourceName: string;
@@ -59,6 +60,7 @@ export default function CreateReportDialogPrompt({
predecessorImageId,
minSeriesNumber,
modality,
enableDownload,
onSave: async ({
reportName,
dataSource: selectedDataSource,
+66 -1
View File
@@ -1,4 +1,7 @@
import { Types, DicomMetadataStore } from '@ohif/core';
import { Types, DicomMetadataStore, utils } from '@ohif/core';
import dcmjs from 'dcmjs';
const { downloadBlob } = utils;
import { ContextMenuController } from './CustomizableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
@@ -768,6 +771,67 @@ const commandsModule = ({
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
},
/**
* Creates a store function based on the data source type.
* @param dataSource - 'download', 'copyToClipboard', or a named data source
* @param defaultFileName - Default filename for download/clipboard
* @param defaultContentType - Default content type for clipboard
* @returns A store function, or null if no valid store exists
*/
createStoreFunction: ({ dataSource, defaultFileName, defaultContentType }) => {
if (dataSource === 'download') {
return async dicom => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
DicomMetadataStore.addInstances(instances, true);
if (instances.length !== 1) {
throw new Error('Download only supports a single DICOM instance');
}
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
downloadBlob(reportBlob, { filename: defaultFileName || 'dicom.dcm' });
};
}
if (dataSource === 'copyToClipboard') {
return async dicom => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
DicomMetadataStore.addInstances(instances, true);
if (instances.length !== 1) {
throw new Error('Copy to clipboard only supports a single DICOM instance');
}
const reportBlob = dcmjs.data.datasetToBlob(instances[0]);
const type = defaultContentType || 'application/dicom';
await navigator.clipboard.write([
new ClipboardItem({ [type]: new Blob([reportBlob], { type }) }),
]);
};
}
// DICOM STOW path — resolve the named data source
const dataSources = extensionManager.getDataSources(dataSource);
const resolvedDataSource = dataSources?.[0];
if (!resolvedDataSource?.store?.dicom) {
return null;
}
return async (dicom, { dicomDict } = {}) => {
const instances = Array.isArray(dicom) ? dicom : [dicom];
const config = resolvedDataSource.getConfig?.();
if (config?.wadoRoot) {
instances.forEach(instance => {
instance.wadoRoot = config.wadoRoot;
});
}
DicomMetadataStore.addInstances(instances, true);
for (const instance of instances) {
await resolvedDataSource.store.dicom(instance, null, dicomDict);
}
const studyUIDs = new Set(instances.map(i => i.StudyInstanceUID).filter(Boolean));
for (const uid of studyUIDs) {
resolvedDataSource.deleteStudyMetadataPromise(uid);
}
};
},
};
const definitions = {
@@ -796,6 +860,7 @@ const commandsModule = ({
scrollActiveThumbnailIntoView: actions.scrollActiveThumbnailIntoView,
addDisplaySetAsLayer: actions.addDisplaySetAsLayer,
removeDisplaySetLayer: actions.removeDisplaySetLayer,
createStoreFunction: actions.createStoreFunction,
};
return {
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InputDialog } from '@ohif/ui-next';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
@@ -21,6 +22,7 @@ type ReportDialogProps = {
priorSeriesNumber: number;
}) => void;
onCancel: () => void;
enableDownload?: boolean;
};
function ReportDialog({
@@ -31,8 +33,11 @@ function ReportDialog({
hide,
onSave,
onCancel,
enableDownload = false,
}: ReportDialogProps) {
const { t } = useTranslation('Buttons');
const { servicesManager } = useSystem();
const actionTakenRef = useRef(false);
const [selectedDataSource, setSelectedDataSource] = useState<string | null>(
dataSources?.[0]?.value ?? null
);
@@ -72,6 +77,7 @@ function ReportDialog({
}, [selectedSeries, seriesOptions]);
const handleSave = useCallback(() => {
actionTakenRef.current = true;
onSave({
reportName,
dataSource: selectedDataSource,
@@ -82,11 +88,33 @@ function ReportDialog({
}, [selectedDataSource, selectedSeries, reportName, hide, onSave]);
const handleCancel = useCallback(() => {
actionTakenRef.current = true;
onCancel();
hide();
}, [onCancel, hide]);
const handleDownload = useCallback(() => {
actionTakenRef.current = true;
onSave({
reportName,
dataSource: 'download',
priorSeriesNumber: Math.max(...seriesOptions.map(it => it.seriesNumber)),
series: selectedSeries,
});
hide();
}, [selectedDataSource, selectedSeries, reportName, hide, onSave]);
// Handles the close dialog button/external close as a cancel
useEffect(() => {
return () => {
if (!actionTakenRef.current) {
onCancel();
}
};
}, [onCancel]);
const showDataSourceSelect = dataSources?.length > 1;
const showDownloadButton = enableDownload;
return (
<div className="text-foreground flex min-w-[400px] max-w-md flex-col">
@@ -181,9 +209,11 @@ function ReportDialog({
<div className="flex justify-end gap-2">
<InputDialog>
<InputDialog.Actions>
<InputDialog.ActionsSecondary onClick={handleCancel}>
Cancel
</InputDialog.ActionsSecondary>
{showDownloadButton && (
<InputDialog.ActionsSecondary onClick={handleDownload}>
{t('Download')}
</InputDialog.ActionsSecondary>
)}
<InputDialog.ActionsPrimary onClick={handleSave}>Save</InputDialog.ActionsPrimary>
</InputDialog.Actions>
</InputDialog>
@@ -35,21 +35,19 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
minSeriesNumber: 3000,
extensionManager,
servicesManager,
enableDownload: true,
});
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources(promptResult.dataSourceName);
const dataSource = dataSources[0];
const { series, priorSeriesNumber, value: reportName } = promptResult;
const { series, priorSeriesNumber, value: reportName, dataSourceName } = promptResult;
const SeriesDescription = reportName || defaultSaveTitle;
const getReport = async () => {
return commandsManager.runCommand(
const getReport = async () =>
commandsManager.runCommand(
'storeMeasurements',
{
measurementData,
dataSource,
dataSource: dataSourceName,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription,
@@ -59,12 +57,12 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport,
});
} else if (promptResult.action === RESPONSE.CANCEL) {
} else if (promptResult.action === PROMPT_RESPONSES.CANCEL) {
// Do nothing
}