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:
Joe Boccanfuso 2026-03-17 08:59:47 -04:00 committed by GitHub
parent 69d8ad63f3
commit 1d4802c2a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 220 additions and 160 deletions

5
.gitignore vendored
View File

@ -66,3 +66,8 @@ libs/
# Backup files
*~
# cornerstone3D local linking
libs/
link-cs3d.js
unlink-cs3d.js

View File

@ -2,9 +2,8 @@ import dcmjs from 'dcmjs';
import { classes, Types, utils } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
import { DicomMetadataStore } from '@ohif/core';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
@ -29,11 +28,11 @@ const {
},
} = adaptersRT;
const { downloadDICOMData } = helpers;
const commandsModule = ({
servicesManager,
extensionManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const { segmentationService, displaySetService, viewportGridService } =
servicesManager.services as AppTypes.Services;
@ -194,8 +193,11 @@ const commandsModule = ({
const generatedSegmentation = actions.generateSegmentation({
segmentationId,
});
downloadDICOMData(generatedSegmentation.dataset, `${segmentationInOHIF.label}`);
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: 'download',
defaultFileName: `${segmentationInOHIF.label}.dcm`,
});
storeFn(generatedSegmentation.dataset);
},
/**
* Stores a segmentation based on the provided segmentationId into a specified data source.
@ -217,11 +219,10 @@ const commandsModule = ({
}
const { label, predecessorImageId } = segmentation;
const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource()[0];
const {
value: reportName,
dataSourceName: selectedDataSource,
dataSourceName,
series,
priorSeriesNumber,
action,
@ -231,50 +232,58 @@ const commandsModule = ({
predecessorImageId,
title: 'Store Segmentation',
modality,
enableDownload: true,
});
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
try {
const selectedDataSourceConfig = selectedDataSource
? extensionManager.getDataSources(selectedDataSource)[0]
: defaultDataSource;
if (action !== PROMPT_RESPONSES.CREATE_REPORT) {
return;
}
const args = {
segmentationId,
options: {
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
predecessorImageId: series,
},
};
const generatedDataAsync =
(modality === 'SEG' && actions.generateSegmentation(args)) ||
(modality === 'RTSTRUCT' && actions.generateContour(args));
const generatedData = await generatedDataAsync;
const defaultFileName =
modality === 'RTSTRUCT'
? `rtss-${segmentationId}.dcm`
: `${label || 'segmentation'}.dcm`;
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: dataSourceName,
defaultFileName,
});
const { dataset: naturalizedReport } = generatedData;
if (!storeFn) {
throw new Error(`No valid store for dataSource: ${dataSourceName}`);
}
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
if (naturalizedReport.StudyID === 'No Study ID') {
naturalizedReport.StudyID = '';
}
try {
const args = {
segmentationId,
options: {
SeriesDescription: series ? undefined : reportName || label || 'Contour Series',
SeriesNumber: series ? undefined : 1 + priorSeriesNumber,
predecessorImageId: series,
},
};
const generatedDataAsync =
(modality === 'SEG' && actions.generateSegmentation(args)) ||
(modality === 'RTSTRUCT' && actions.generateContour(args));
const generatedData = await generatedDataAsync;
await selectedDataSourceConfig.store.dicom(naturalizedReport);
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.debug('Error storing segmentation:', error);
throw error;
if (!generatedData?.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
if (naturalizedReport.StudyID === 'No Study ID') {
naturalizedReport.StudyID = '';
}
await storeFn(naturalizedReport, {});
return naturalizedReport;
} catch (error) {
console.debug('Error storing segmentation:', error);
throw error;
}
},
@ -307,14 +316,11 @@ const commandsModule = ({
downloadRTSS: async args => {
const { dataset } = await actions.generateContour(args);
const { InstanceNumber: instanceNumber = 1, SeriesInstanceUID: seriesUID } = dataset;
try {
//Create a URL for the binary.
const filename = `rtss-${seriesUID}-${instanceNumber}.dcm`;
downloadDICOMData(dataset, filename);
} catch (e) {
console.warn(e);
}
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource: 'download',
defaultFileName: `rtss-${seriesUID}-${instanceNumber}.dcm`,
});
await storeFn(dataset);
},
toggleActiveSegmentationUtility: ({ itemId: buttonId }) => {

View File

@ -1,14 +1,11 @@
import { metaData } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore, utils } from '@ohif/core';
import dcmjs from 'dcmjs';
import OHIF from '@ohif/core';
import { adaptersSR } from '@cornerstonejs/adapters';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
const { downloadBlob } = utils;
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF;
@ -75,24 +72,8 @@ const commandsModule = (props: withAppTypes) => {
/**
*
* @param measurementData An array of measurements from the measurements service
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* as opposed to Finding Sites.
* that you wish to serialize.
*/
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
const srDataset = _generateReport(measurementData, additionalFindingTypes, options);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
downloadBlob(reportBlob, { filename: 'dicom-sr.dcm' });
},
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param dataSource The dataSource that you wish to use to persist the data.
* @param dataSource The data source name ('download', 'copyToClipboard', or a named data source).
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
* @return The naturalized report
@ -103,23 +84,30 @@ const commandsModule = (props: withAppTypes) => {
additionalFindingTypes,
options = {},
}) => {
// Use the @cornerstonejs adapter for converting to/from DICOM
// But it is good enough for now whilst we only have cornerstone as a datasource.
log.info('[DICOMSR] storeMeasurements');
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
const storeFn = commandsManager.runCommand('createStoreFunction', {
dataSource,
defaultFileName: 'dicom-sr.dcm',
});
if (!storeFn) {
log.error('[DICOMSR] No valid store for dataSource:', dataSource);
return Promise.reject({});
}
try {
const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, options);
const naturalizedReport = _generateReport(
measurementData,
additionalFindingTypes,
options
);
const { StudyInstanceUID, ContentSequence } = naturalizedReport;
const { ContentSequence } = naturalizedReport;
// The content sequence has 5 or more elements, of which
// the `[4]` element contains the annotation data, so this is
// checking that there is some annotation data present.
if (!ContentSequence?.[4].ContentSequence?.length) {
if (!ContentSequence?.[4]?.ContentSequence?.length) {
console.log('naturalizedReport missing imaging content', naturalizedReport);
throw new Error('Invalid report, no content');
}
@ -128,22 +116,12 @@ const commandsModule = (props: withAppTypes) => {
}
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
let dicomDict;
if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
}
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
await storeFn(naturalizedReport, { measurementData, dicomDict });
return naturalizedReport;
} catch (error) {
@ -166,7 +144,6 @@ const commandsModule = (props: withAppTypes) => {
};
const definitions = {
downloadReport: actions.downloadReport,
storeMeasurements: actions.storeMeasurements,
hydrateStructuredReport: actions.hydrateStructuredReport,
};

View File

@ -637,16 +637,35 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
NUMContentItems.forEach(item => {
const { ConceptNameCodeSequence, ContentSequence, MeasuredValueSequence } = item;
// Handle spatial reference ONLY if ContentSequence exists
// Handle spatial reference ONLY if ContentSequence exists.
// ContentSequence may be a scalar SCOORD or an array when additional named
// SCOORDs (e.g. control points) are nested alongside the primary geometry.
// Pick the primary geometry entry: prefer the SCOORD without a
// ConceptNameCodeSequence (plain polyline), falling back to the first SCOORD.
if (ContentSequence) {
const { ValueType } = ContentSequence;
const scoordItem = Array.isArray(ContentSequence)
? (ContentSequence.find(
cs =>
(cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D') &&
!cs.ConceptNameCodeSequence
) ?? ContentSequence.find(cs => cs.ValueType === 'SCOORD' || cs.ValueType === 'SCOORD3D'))
: ContentSequence;
if (!scoordItem) {
console.warn(
'ContentSequence array contains no SCOORD or SCOORD3D entry, skipping annotation.'
);
return;
}
const { ValueType } = scoordItem;
if (ValueType !== 'SCOORD' && ValueType !== 'SCOORD3D') {
console.warn(`Graphic ${ValueType} not currently supported, skipping annotation.`);
return;
}
const coords = _getCoordsFromSCOORDOrSCOORD3D(ContentSequence);
const coords = _getCoordsFromSCOORDOrSCOORD3D(scoordItem);
if (coords) {
measurement.coords.push(coords);

View File

@ -5,9 +5,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
Icons,
} from '@ohif/ui-next';
@ -19,8 +17,6 @@ interface ExportSegmentationSubMenuItemProps {
allowExport: boolean;
actions: {
storeSegmentation: (segmentationId: string, modality?: string) => Promise<unknown>;
onSegmentationDownloadRTSS: (segmentationId: string) => void;
onSegmentationDownload: (segmentationId: string) => void;
downloadCSVSegmentationReport: (segmentationId: string) => void;
};
}
@ -37,14 +33,10 @@ export const ExportSegmentationSubMenuItem: React.FC<ExportSegmentationSubMenuIt
<DropdownMenuSub>
<DropdownMenuSubTrigger className="pl-1">
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Download & Export')}</span>
<span className="pl-2">{t('Export')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Download className="h-5 w-5" />
<span className="pl-1">{t('Download')}</span>
</DropdownMenuLabel>
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
<DropdownMenuItem
onClick={e => {
@ -56,31 +48,6 @@ export const ExportSegmentationSubMenuItem: React.FC<ExportSegmentationSubMenuIt
{t('CSV Report')}
</DropdownMenuItem>
)}
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownload(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM SEG')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={e => {
e.preventDefault();
actions.onSegmentationDownloadRTSS(segmentationId);
}}
disabled={!allowExport}
>
{t('DICOM RTSS')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center pl-0">
<Icons.Export className="h-5 w-5" />
<span className="pl-1 pt-1">{t('Export')}</span>
</DropdownMenuLabel>
{segmentationRepresentationType === SegmentationRepresentations.Labelmap && (
<DropdownMenuItem
onClick={e => {

View File

@ -64,12 +64,6 @@ export const CustomDropdownMenuContent = () => {
context: 'CORNERSTONE',
});
},
onSegmentationDownloadRTSS: segmentationId => {
commandsManager.run('downloadRTSS', { segmentationId });
},
onSegmentationDownload: segmentationId => {
commandsManager.run('downloadSegmentation', { segmentationId });
},
downloadCSVSegmentationReport: segmentationId => {
commandsManager.run('downloadCSVSegmentationReport', { segmentationId });
},

View File

@ -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',
});

View File

@ -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,

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 {

View File

@ -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>

View File

@ -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
}

View File

@ -137,8 +137,9 @@ function TrackedMeasurementsContextProvider(
});
},
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
if (evt.data.createdDisplaySetInstanceUIDs.length > 0) {
const StructuredReportDisplaySetInstanceUID = evt.data.createdDisplaySetInstanceUIDs[0];
const uids = evt.data?.createdDisplaySetInstanceUIDs;
if (uids?.length > 0) {
const StructuredReportDisplaySetInstanceUID = uids[0];
viewportGridService.setDisplaySetsForViewport({
viewportId: evt.data.viewportId,

@ -1 +1 @@
Subproject commit 3f85fe843c9a1ffaccb826845f317dace9c06c45
Subproject commit bd743ed76b403e6d476e1af0bbfe7479d96b563d