Segmentation UI for VTKjs (#1685)

* Add single viewport configuration

* Multiple viewport configuration

* Improve performance by using independent set methods

* Add jump to slice command

* Add context configuration

* Cache panel visibility

* Fix sync between vtk and cornerstone

* Remove apis index

* Add approach

* Add loading to update volumes

* Fix broken configuration

* Bump vtk version

* Use loading label

* Update cy tests after vtk loading label changed

* Remove loading for segs
This commit is contained in:
Igor Octaviano 2020-05-06 14:17:44 -03:00 committed by GitHub
parent 19a8e71317
commit 42c22df1b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 516 additions and 173 deletions

View File

@ -38,6 +38,7 @@
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "^7.5.5", "@babel/runtime": "^7.5.5",
"gl-matrix": "^3.3.0",
"react-select": "^3.0.8" "react-select": "^3.0.8"
} }
} }

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { TableListItem, Icon } from '@ohif/ui'; import { TableListItem, Icon } from '@ohif/ui';
@ -19,6 +19,11 @@ ColoredCircle.propTypes = {
const SegmentItem = ({ index, label, onClick, itemClass, color, visible = true, onVisibilityChange }) => { const SegmentItem = ({ index, label, onClick, itemClass, color, visible = true, onVisibilityChange }) => {
const [isVisible, setIsVisible] = useState(visible); const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
setIsVisible(visible);
}, [visible]);
return ( return (
<div className="dcmseg-segment-item"> <div className="dcmseg-segment-item">
<TableListItem <TableListItem

View File

@ -32,7 +32,11 @@ const refreshViewport = () => {
* @param {Array} props.viewports - Viewports data (viewportSpecificData) * @param {Array} props.viewports - Viewports data (viewportSpecificData)
* @param {number} props.activeIndex - Active viewport index * @param {number} props.activeIndex - Active viewport index
* @param {boolean} props.isOpen - Boolean that indicates if the panel is expanded * @param {boolean} props.isOpen - Boolean that indicates if the panel is expanded
* @param {Function} props.onSegItemClick - Segment click handler * @param {Function} props.onSegmentItemClick - Segment click handler
* @param {Function} props.onSegmentVisibilityChange - Segment visibiliy change handler
* @param {Function} props.onConfigurationChange - Configuration change handler
* @param {Function} props.activeContexts - List of active application contexts
* @param {Function} props.contexts - List of available application contexts
* @returns component * @returns component
*/ */
const SegmentationPanel = ({ const SegmentationPanel = ({
@ -40,25 +44,40 @@ const SegmentationPanel = ({
viewports, viewports,
activeIndex, activeIndex,
isOpen, isOpen,
onSegItemClick, onSegmentItemClick,
UINotificationService, onSegmentVisibilityChange,
onConfigurationChange,
onDisplaySetLoadFailure,
onSelectedSegmentationChange,
activeContexts,
contexts,
}) => { }) => {
const isVTK = () => activeContexts.includes(contexts.VTK);
const isCornerstone = () => activeContexts.includes(contexts.CORNERSTONE);
/* /*
* TODO: wrap get/set interactions with the cornerstoneTools * TODO: wrap get/set interactions with the cornerstoneTools
* store with context to make these kind of things less blurry. * store with context to make these kind of things less blurry.
*/ */
const { configuration } = cornerstoneTools.getModule('segmentation'); const { configuration } = cornerstoneTools.getModule('segmentation');
const DEFAULT_BRUSH_RADIUS = configuration.radius || 10; const DEFAULT_BRUSH_RADIUS = configuration.radius || 10;
/*
* TODO: We shouldn't hardcode brushColor color, in the future
* the SEG may set the colorLUT to whatever it wants.
*/
const [state, setState] = useState({ const [state, setState] = useState({
brushRadius: DEFAULT_BRUSH_RADIUS, brushRadius: DEFAULT_BRUSH_RADIUS,
brushColor: brushColor:
'rgba(221, 85, 85, 1)' /* TODO: We shouldn't hardcode this color, in the future the SEG may set the colorLUT to whatever it wants. */, 'rgba(221, 85, 85, 1)',
selectedSegment: null, selectedSegment: null,
selectedSegmentation: null, selectedSegmentation: null,
showSegSettings: false, showSegmentationSettings: false,
brushStackState: null, brushStackState: null,
labelmapList: [], labelmapList: [],
segmentList: [], segmentList: [],
cachedSegmentsProperties: [],
isLoading: false
}); });
useEffect(() => { useEffect(() => {
@ -75,6 +94,25 @@ const SegmentationPanel = ({
updateState('brushStackState', module.state.series[firstImageId]); updateState('brushStackState', module.state.series[firstImageId]);
}; };
/*
* TODO: Improve the way we notify parts of the app that depends on segs to be loaded.
*
* Currently we are using a non-ideal implementation through a custom event to notify the segmentation panel
* or other components that could rely on loaded segmentations that
* the segments were loaded so that e.g. when the user opens the panel
* before the segments are fully loaded, the panel can subscribe to this custom event
* and update itself with the new segments.
*
* This limitation is due to the fact that the cs segmentation module is an object (which will be
* updated after the segments are loaded) that React its not aware of its changes
* because the module object its not passed in to the panel component as prop but accessed externally.
*
* Improving this event approach to something reactive that can be tracked inside the react lifecycle,
* allows us to easily watch the module or the segmentations loading process in any other component
* without subscribing to external events.
*/
document.addEventListener('extensiondicomsegmentationsegloaded', refreshSegmentations);
/* /*
* These are specific to each element; * These are specific to each element;
* Need to iterate cornerstone-tools tracked enabled elements? * Need to iterate cornerstone-tools tracked enabled elements?
@ -88,6 +126,7 @@ const SegmentationPanel = ({
); );
return () => { return () => {
document.removeEventListener('extensiondicomsegmentationsegloaded', refreshSegmentations);
cornerstoneTools.store.state.enabledElements.forEach(enabledElement => cornerstoneTools.store.state.enabledElements.forEach(enabledElement =>
enabledElement.removeEventListener( enabledElement.removeEventListener(
'cornerstonetoolslabelmapmodified', 'cornerstonetoolslabelmapmodified',
@ -95,9 +134,9 @@ const SegmentationPanel = ({
) )
); );
}; };
}); }, [activeIndex, viewports]);
useEffect(() => { const refreshSegmentations = useCallback(() => {
const module = cornerstoneTools.getModule('segmentation'); const module = cornerstoneTools.getModule('segmentation');
const activeViewport = viewports[activeIndex]; const activeViewport = viewports[activeIndex];
const studyMetadata = studyMetadataManager.get( const studyMetadata = studyMetadataManager.get(
@ -107,7 +146,6 @@ const SegmentationPanel = ({
activeViewport.displaySetInstanceUID activeViewport.displaySetInstanceUID
); );
const brushStackState = module.state.series[firstImageId]; const brushStackState = module.state.series[firstImageId];
if (brushStackState) { if (brushStackState) {
const labelmap3D = const labelmap3D =
brushStackState.labelmaps3D[brushStackState.activeLabelmapIndex]; brushStackState.labelmaps3D[brushStackState.activeLabelmapIndex];
@ -136,19 +174,20 @@ const SegmentationPanel = ({
})); }));
} }
}, [ }, [
studies,
viewports, viewports,
activeIndex, activeIndex,
getLabelmapList, state.isLoading
getSegmentList,
state.selectedSegmentation,
]); ]);
useEffect(() => {
refreshSegmentations();
}, [viewports, activeIndex, state.selectedSegmentation, activeContexts, state.isLoading]);
/* Handle open/closed panel behaviour */ /* Handle open/closed panel behaviour */
useEffect(() => { useEffect(() => {
setState(state => ({ setState(state => ({
...state, ...state,
showSegSettings: state.showSegSettings && !isOpen, showSegmentationSettings: state.showSegmentationSettings && !isOpen,
})); }));
}, [isOpen]); }, [isOpen]);
@ -183,7 +222,8 @@ const SegmentationPanel = ({
displaySet, displaySet,
firstImageId, firstImageId,
brushStackState.activeLabelmapIndex, brushStackState.activeLabelmapIndex,
UINotificationService () => onSelectedSegmentationChange(),
onDisplaySetLoadFailure
); );
updateState('selectedSegmentation', activatedLabelmapIndex); updateState('selectedSegmentation', activatedLabelmapIndex);
}, },
@ -262,65 +302,104 @@ const SegmentationPanel = ({
: prev; : prev;
}); });
const enabledElements = cornerstone.getEnabledElements(); if (isCornerstone()) {
const element = enabledElements[activeIndex].element; const enabledElements = cornerstone.getEnabledElements();
const toolState = cornerstoneTools.getToolState(element, 'stack'); const element = enabledElements[activeIndex].element;
const toolState = cornerstoneTools.getToolState(element, 'stack');
if (!toolState) { if (!toolState) {
return; return;
}
const imageIds = toolState.data[0].imageIds;
const imageId = imageIds[closest];
const frameIndex = imageIds.indexOf(imageId);
const SOPInstanceUID = cornerstone.metaData.get(
'SOPInstanceUID',
imageId
);
const StudyInstanceUID = cornerstone.metaData.get(
'StudyInstanceUID',
imageId
);
onSegmentItemClick({
StudyInstanceUID,
SOPInstanceUID,
frameIndex,
activeViewportIndex: activeIndex,
});
} }
const imageIds = toolState.data[0].imageIds; if (isVTK()) {
const imageId = imageIds[closest]; const activeViewport = viewports[activeIndex];
const frameIndex = imageIds.indexOf(imageId); const studyMetadata = studyMetadataManager.get(
activeViewport.StudyInstanceUID
);
const allDisplaySets = studyMetadata.getDisplaySets();
const currentDisplaySet = allDisplaySets.find(
displaySet =>
displaySet.displaySetInstanceUID ===
activeViewport.displaySetInstanceUID
);
const SOPInstanceUID = cornerstone.metaData.get( const frame = labelmap3D.labelmaps2D[closest];
'SOPInstanceUID',
imageId
);
const StudyInstanceUID = cornerstone.metaData.get(
'StudyInstanceUID',
imageId
);
onSegItemClick({ onSegmentItemClick({
StudyInstanceUID, studies,
SOPInstanceUID, StudyInstanceUID: currentDisplaySet.StudyInstanceUID,
frameIndex, displaySetInstanceUID: currentDisplaySet.displaySetInstanceUID,
activeViewportIndex: activeIndex, SOPClassUID: viewports[activeIndex].sopClassUIDs[0],
}); SOPInstanceUID: currentDisplaySet.SOPInstanceUID,
segmentNumber,
frameIndex: closest,
frame,
});
}
}; };
const enabledElements = cornerstone.getEnabledElements(); const isSegmentVisible = () => {
const enabledElementViewport = enabledElements[activeIndex]; return !labelmap3D.segmentsHidden[segmentIndex];
};
let isVisible = true; const toggleSegmentVisibility = () => {
if (enabledElementViewport) { const segmentsHidden = labelmap3D.segmentsHidden;
const element = enabledElementViewport.element; segmentsHidden[segmentIndex] = !segmentsHidden[segmentIndex];
const module = cornerstoneTools.getModule('segmentation'); return !segmentsHidden[segmentIndex];
isVisible = module.getters.isSegmentVisible( };
element,
segmentNumber, const cachedSegmentProperties = state.cachedSegmentsProperties[segmentNumber];
brushStackState.activeLabelmapIndex let visible = isSegmentVisible();
); if (cachedSegmentProperties && cachedSegmentProperties.visible !== visible) {
toggleSegmentVisibility();
} }
segmentList.push( segmentList.push(
<SegmentItem <SegmentItem
key={segmentNumber} key={segmentNumber}
itemClass={`segment-item ${sameSegment && 'selected'}`} itemClass={`segment-item ${sameSegment && 'selected'}`}
onClick={setCurrentSelectedSegment} onClick={() => setCurrentSelectedSegment()}
label={segmentLabel} label={segmentLabel}
index={segmentNumber} index={segmentNumber}
color={color} color={color}
visible={isVisible} visible={visible}
onVisibilityChange={() => { onVisibilityChange={newVisibility => {
const element = enabledElements[activeIndex].element; if (isCornerstone()) {
module.setters.toggleSegmentVisibility( const enabledElements = cornerstone.getEnabledElements();
element, const element = enabledElements[activeIndex].element;
segmentNumber, module.setters.toggleSegmentVisibility(
brushStackState.activeLabelmapIndex element,
); segmentNumber,
brushStackState.activeLabelmapIndex
);
}
if (isVTK()) {
onSegmentVisibilityChange(segmentNumber, newVisibility);
}
updateCachedSegmentsProperties(segmentNumber, { visible: newVisibility });
refreshViewport(); refreshViewport();
}} }}
/> />
@ -336,9 +415,25 @@ const SegmentationPanel = ({
* Show default name * Show default name
*/ */
}, },
[activeIndex, onSegItemClick, state.selectedSegment] [activeIndex, onSegmentItemClick, state.selectedSegment, state.isLoading]
); );
const updateCachedSegmentsProperties = (segmentNumber, properties) => {
const segmentsProperties = state.cachedSegmentsProperties;
const segmentProperties = state.cachedSegmentsProperties[segmentNumber];
segmentsProperties[segmentNumber] =
segmentProperties ?
{ ...segmentProperties, ...properties } :
properties;
updateState('cachedSegmentsProperties', segmentsProperties);
};
useEffect(() => {
updateState('cachedSegmentsProperties', []);
}, [activeContexts]);
const updateState = (field, value) => { const updateState = (field, value) => {
setState(state => ({ ...state, [field]: value })); setState(state => ({ ...state, [field]: value }));
}; };
@ -387,7 +482,6 @@ const SegmentationPanel = ({
}; };
const updateConfiguration = newConfiguration => { const updateConfiguration = newConfiguration => {
/* Supported configuration */
configuration.renderFill = newConfiguration.renderFill; configuration.renderFill = newConfiguration.renderFill;
configuration.renderOutline = newConfiguration.renderOutline; configuration.renderOutline = newConfiguration.renderOutline;
configuration.shouldRenderInactiveLabelmaps = configuration.shouldRenderInactiveLabelmaps =
@ -397,14 +491,17 @@ const SegmentationPanel = ({
configuration.outlineWidth = newConfiguration.outlineWidth; configuration.outlineWidth = newConfiguration.outlineWidth;
configuration.fillAlphaInactive = newConfiguration.fillAlphaInactive; configuration.fillAlphaInactive = newConfiguration.fillAlphaInactive;
configuration.outlineAlphaInactive = newConfiguration.outlineAlphaInactive; configuration.outlineAlphaInactive = newConfiguration.outlineAlphaInactive;
onConfigurationChange(newConfiguration);
refreshViewport(); refreshViewport();
}; };
if (state.showSegSettings) { const disabledConfigurationFields = ['outlineAlpha', 'shouldRenderInactiveLabelmaps'];
if (state.showSegmentationSettings) {
return ( return (
<SegmentationSettings <SegmentationSettings
disabledFields={isVTK() ? disabledConfigurationFields : []}
configuration={configuration} configuration={configuration}
onBack={() => updateState('showSegSettings', false)} onBack={() => updateState('showSegmentationSettings', false)}
onChange={updateConfiguration} onChange={updateConfiguration}
/> />
); );
@ -416,7 +513,7 @@ const SegmentationPanel = ({
name="cog" name="cog"
width="25px" width="25px"
height="25px" height="25px"
onClick={() => updateState('showSegSettings', true)} onClick={() => updateState('showSegmentationSettings', true)}
/> />
{false && ( {false && (
<form className="selector-form"> <form className="selector-form">
@ -525,7 +622,8 @@ const _setActiveLabelmap = async (
displaySet, displaySet,
firstImageId, firstImageId,
activeLabelmapIndex, activeLabelmapIndex,
UINotificationService callback = () => { },
onDisplaySetLoadFailure
) => { ) => {
if (displaySet.labelmapIndex === activeLabelmapIndex) { if (displaySet.labelmapIndex === activeLabelmapIndex) {
log.warn(`${activeLabelmapIndex} is already the active labelmap`); log.warn(`${activeLabelmapIndex} is already the active labelmap`);
@ -539,12 +637,7 @@ const _setActiveLabelmap = async (
const loadPromise = displaySet.load(viewportSpecificData, studies); const loadPromise = displaySet.load(viewportSpecificData, studies);
loadPromise.catch(error => { loadPromise.catch(error => {
UINotificationService.show({ onDisplaySetLoadFailure(error);
title: 'DICOM Segmentation Loader',
message: error.message,
type: 'error',
autoClose: false,
});
// Return old index. // Return old index.
return activeLabelmapIndex; return activeLabelmapIndex;
@ -559,6 +652,8 @@ const _setActiveLabelmap = async (
refreshViewport(); refreshViewport();
callback();
return displaySet.labelmapIndex; return displaySet.labelmapIndex;
}; };

View File

@ -4,7 +4,7 @@ import { Range } from '@ohif/ui';
import './SegmentationSettings.css'; import './SegmentationSettings.css';
const SegmentationSettings = ({ configuration, onBack, onChange }) => { const SegmentationSettings = ({ configuration, onBack, onChange, disabledFields = [] }) => {
const [state, setState] = useState({ const [state, setState] = useState({
renderFill: configuration.renderFill, renderFill: configuration.renderFill,
renderOutline: configuration.renderOutline, renderOutline: configuration.renderOutline,
@ -70,28 +70,32 @@ const SegmentationSettings = ({ configuration, onBack, onChange }) => {
/> />
{state.renderOutline && ( {state.renderOutline && (
<> <>
<CustomRange {!disabledFields.includes('outlineAlpha') && (
value={state.outlineAlpha * 100} <CustomRange
label="Opacity" value={state.outlineAlpha * 100}
showPercentage label="Opacity"
step={1} showPercentage
min={0} step={1}
max={100} min={0}
onChange={event => save('outlineAlpha', toFloat(event.target.value))} max={100}
/> onChange={event => save('outlineAlpha', toFloat(event.target.value))}
<CustomRange />
value={state.outlineWidth} )}
label="Width" {!disabledFields.includes('outlineWidth') && (
showValue <CustomRange
step={1} value={state.outlineWidth}
min={0} label="Width"
max={5} showValue
onChange={event => save('outlineWidth', parseInt(event.target.value))} step={1}
/> min={0}
max={5}
onChange={event => save('outlineWidth', parseInt(event.target.value))}
/>
)}
</> </>
)} )}
</div> </div>
{(state.renderFill || state.renderOutline) && ( {(state.renderFill || state.renderOutline) && !disabledFields.includes('shouldRenderInactiveLabelmaps') && (
<div <div
className="settings-group" className="settings-group"
style={{ marginBottom: state.shouldRenderInactiveLabelmaps ? 15 : 0 }} style={{ marginBottom: state.shouldRenderInactiveLabelmaps ? 15 : 0 }}
@ -103,7 +107,7 @@ const SegmentationSettings = ({ configuration, onBack, onChange }) => {
/> />
{state.shouldRenderInactiveLabelmaps && ( {state.shouldRenderInactiveLabelmaps && (
<> <>
{state.renderFill && ( {state.renderFill && !disabledFields.includes('fillAlphaInactive') && (
<CustomRange <CustomRange
label="Fill Opacity" label="Fill Opacity"
showPercentage showPercentage
@ -114,7 +118,7 @@ const SegmentationSettings = ({ configuration, onBack, onChange }) => {
onChange={event => save('fillAlphaInactive', toFloat(event.target.value))} onChange={event => save('fillAlphaInactive', toFloat(event.target.value))}
/> />
)} )}
{state.renderOutline && ( {state.renderOutline && !disabledFields.includes('outlineAlphaInactive') && (
<CustomRange <CustomRange
label="Outline Opacity" label="Outline Opacity"
showPercentage showPercentage

View File

@ -23,19 +23,56 @@ export default {
getToolbarModule({ servicesManager }) { getToolbarModule({ servicesManager }) {
return toolbarModule; return toolbarModule;
}, },
getPanelModule({ commandsManager, servicesManager }) { getPanelModule({ commandsManager, api, servicesManager }) {
const { UINotificationService } = servicesManager;
const ExtendedSegmentationPanel = props => { const ExtendedSegmentationPanel = props => {
const segItemClickHandler = segData => { const { activeContexts } = api.hooks.useAppContext();
commandsManager.runCommand('jumpToImage', segData);
const onDisplaySetLoadFailureHandler = error => {
UINotificationService.show({
title: 'DICOM Segmentation Loader',
message: error.message,
type: 'error',
autoClose: false,
});
}; };
const { UINotificationService } = servicesManager.services; const segmentItemClickHandler = data => {
commandsManager.runCommand('jumpToImage', data);
commandsManager.runCommand('jumpToSlice', data);
};
const onSegmentVisibilityChangeHandler = (segmentNumber, visible) => {
commandsManager.runCommand('setSegmentConfiguration', {
segmentNumber,
visible
});
};
const onConfigurationChangeHandler = configuration => {
commandsManager.runCommand('setSegmentationConfiguration', {
globalOpacity: configuration.fillAlpha,
outlineThickness: configuration.outlineWidth,
renderOutline: configuration.renderOutline,
visible: configuration.renderFill
});
};
const onSelectedSegmentationChangeHandler = () => {
commandsManager.runCommand('requestNewSegmentation');
};
return ( return (
<SegmentationPanel <SegmentationPanel
{...props} {...props}
onSegItemClick={segItemClickHandler} activeContexts={activeContexts}
UINotificationService={UINotificationService} contexts={api.contexts}
onSegmentItemClick={segmentItemClickHandler}
onSegmentVisibilityChange={onSegmentVisibilityChangeHandler}
onConfigurationChange={onConfigurationChangeHandler}
onSelectedSegmentationChange={onSelectedSegmentationChangeHandler}
onDisplaySetLoadFailure={onDisplaySetLoadFailureHandler}
/> />
); );
}; };

View File

@ -63,6 +63,27 @@ export default async function loadSegmentation(
segDisplaySet.labelmapIndex = labelmapIndex; segDisplaySet.labelmapIndex = labelmapIndex;
/*
* TODO: Improve the way we notify parts of the app that depends on segs to be loaded.
*
* Currently we are using a non-ideal implementation through a custom event to notify the segmentation panel
* or other components that could rely on loaded segmentations that
* the segments were loaded so that e.g. when the user opens the panel
* before the segments are fully loaded, the panel can subscribe to this custom event
* and update itself with the new segments.
*
* This limitation is due to the fact that the cs segmentation module is an object (which will be
* updated after the segments are loaded) that React its not aware of its changes
* because the module object its not passed in to the panel component as prop but accessed externally.
*
* Improving this event approach to something reactive that can be tracked inside the react lifecycle,
* allows us to easily watch the module or the segmentations loading process in any other component
* without subscribing to external events.
*/
console.log('Segmentation loaded.');
const event = new CustomEvent('extensiondicomsegmentationsegloaded');
document.dispatchEvent(event);
resolve(labelmapIndex); resolve(labelmapIndex);
}); });
} }

View File

@ -50,7 +50,7 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.5.5", "@babel/runtime": "^7.5.5",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"react-vtkjs-viewport": "^0.8.3" "react-vtkjs-viewport": "^0.9.0"
}, },
"devDependencies": { "devDependencies": {
"@ohif/core": "^2.9.3", "@ohif/core": "^2.9.3",

View File

@ -32,16 +32,16 @@ class LoadingIndicator extends PureComponent {
</div> </div>
</div> </div>
) : ( ) : (
<div className="imageViewerLoadingIndicator loadingIndicator"> <div className="imageViewerLoadingIndicator loadingIndicator">
<div className="indicatorContents"> <div className="indicatorContents">
<p> <p>
{this.props.t('Reformatting')}... {this.props.t('Loading...')}
<i className="fa fa-spin fa-circle-o-notch fa-fw" /> <i className="fa fa-spin fa-circle-o-notch fa-fw" />
{percComplete} {percComplete}
</p> </p>
</div>
</div> </div>
</div> )}
)}
</React.Fragment> </React.Fragment>
); );
} }

View File

@ -49,7 +49,7 @@ class OHIFVTKViewport extends Component {
state = { state = {
volumes: null, volumes: null,
paintFilterLabelMapImageData: null, paintFilterLabelMapImageData: null,
paintFilterBackgroundImageData: null, paintFilterBackgroundImageData: null
}; };
static propTypes = { static propTypes = {
@ -69,7 +69,7 @@ class OHIFVTKViewport extends Component {
}; };
static defaultProps = { static defaultProps = {
onScroll: () => {}, onScroll: () => { },
}; };
static id = 'OHIFVTKViewport'; static id = 'OHIFVTKViewport';
@ -156,6 +156,10 @@ class OHIFVTKViewport extends Component {
const { activeLabelmapIndex } = brushStackState; const { activeLabelmapIndex } = brushStackState;
const labelmap3D = brushStackState.labelmaps3D[activeLabelmapIndex]; const labelmap3D = brushStackState.labelmaps3D[activeLabelmapIndex];
this.segmentsDefaultProperties = labelmap3D.segmentsHidden.map(isHidden => {
return { visible: !isHidden };
});
const vtkLabelmapID = `${firstImageId}_${activeLabelmapIndex}`; const vtkLabelmapID = `${firstImageId}_${activeLabelmapIndex}`;
if (labelmapCache[vtkLabelmapID]) { if (labelmapCache[vtkLabelmapID]) {
@ -339,13 +343,13 @@ class OHIFVTKViewport extends Component {
this.setStateFromProps(); this.setStateFromProps();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps, prevState) {
const { displaySet } = this.props.viewportData; const { displaySet } = this.props.viewportData;
const prevDisplaySet = prevProps.viewportData.displaySet; const prevDisplaySet = prevProps.viewportData.displaySet;
if ( if (
displaySet.displaySetInstanceUID !== displaySet.displaySetInstanceUID !==
prevDisplaySet.displaySetInstanceUID || prevDisplaySet.displaySetInstanceUID ||
displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID || displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID ||
displaySet.frameIndex !== prevDisplaySet.frameIndex displaySet.frameIndex !== prevDisplaySet.frameIndex
) { ) {
@ -405,10 +409,6 @@ class OHIFVTKViewport extends Component {
const style = { width: '100%', height: '100%', position: 'relative' }; const style = { width: '100%', height: '100%', position: 'relative' };
const visible = configuration.renderFill || configuration.renderOutline;
const opacity = configuration.fillAlpha;
const outlineThickness = configuration.outlineThickness;
return ( return (
<> <>
<div style={style}> <div style={style}>
@ -428,10 +428,14 @@ class OHIFVTKViewport extends Component {
dataDetails={this.state.dataDetails} dataDetails={this.state.dataDetails}
labelmapRenderingOptions={{ labelmapRenderingOptions={{
colorLUT: this.state.labelmapColorLUT, colorLUT: this.state.labelmapColorLUT,
globalOpacity: opacity, globalOpacity: configuration.fillAlpha,
visible, visible: configuration.renderFill,
outlineThickness, outlineThickness: configuration.outlineWidth,
renderOutline: true, renderOutline: configuration.renderOutline,
segmentsDefaultProperties: this.segmentsDefaultProperties,
onNewSegmentationRequested: () => {
this.setStateFromProps();
}
}} }}
onScroll={this.props.onScroll} onScroll={this.props.onScroll}
/> />

View File

@ -5,10 +5,13 @@ import {
vtkInteractorStyleMPRRotate, vtkInteractorStyleMPRRotate,
vtkSVGCrosshairsWidget, vtkSVGCrosshairsWidget,
} from 'react-vtkjs-viewport'; } from 'react-vtkjs-viewport';
import { getImageData } from 'react-vtkjs-viewport';
import { vec3 } from 'gl-matrix';
import setMPRLayout from './utils/setMPRLayout.js'; import setMPRLayout from './utils/setMPRLayout.js';
import setViewportToVTK from './utils/setViewportToVTK.js'; import setViewportToVTK from './utils/setViewportToVTK.js';
import Constants from 'vtk.js/Sources/Rendering/Core/VolumeMapper/Constants.js'; import Constants from 'vtk.js/Sources/Rendering/Core/VolumeMapper/Constants.js';
import OHIFVTKViewport from './OHIFVTKViewport';
import vtkCoordinate from 'vtk.js/Sources/Rendering/Core/Coordinate';
const { BlendMode } = Constants; const { BlendMode } = Constants;
@ -34,7 +37,6 @@ const commandsModule = ({ commandsManager }) => {
} }
const displaySet = viewportSpecificData[activeViewportIndex]; const displaySet = viewportSpecificData[activeViewportIndex];
let api; let api;
if (!api) { if (!api) {
try { try {
@ -99,6 +101,20 @@ const commandsModule = ({ commandsManager }) => {
}); });
} }
const _convertModelToWorldSpace = (position, vtkImageData) => {
const indexToWorld = vtkImageData.getIndexToWorld();
const pos = vec3.create();
position[0] += 0.5; /* Move to the centre of the voxel. */
position[1] += 0.5; /* Move to the centre of the voxel. */
position[2] += 0.5; /* Move to the centre of the voxel. */
vec3.set(pos, position[0], position[1], position[2]);
vec3.transformMat4(pos, pos, indexToWorld);
return pos;
};
const actions = { const actions = {
getVtkApis: ({ index }) => { getVtkApis: ({ index }) => {
return apis[index]; return apis[index];
@ -124,6 +140,119 @@ const commandsModule = ({ commandsManager }) => {
_setView(api, [0, 1, 0], [0, 0, 1]); _setView(api, [0, 1, 0], [0, 0, 1]);
}, },
requestNewSegmentation: async ({ viewports }) => {
const allViewports = Object.values(viewports.viewportSpecificData);
const promises = allViewports.map(async (viewport, viewportIndex) => {
let api = apis[viewportIndex];
if (!api) {
api = await _getActiveViewportVTKApi(viewports);
apis[viewportIndex] = api;
}
api.requestNewSegmentation();
api.updateImage();
});
await Promise.all(promises);
},
jumpToSlice: async ({
viewports,
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPClassUID,
SOPInstanceUID,
segmentNumber,
frameIndex,
frame,
done = () => { }
}) => {
let api = apis[viewports.activeViewportIndex];
if (!api) {
api = await _getActiveViewportVTKApi(viewports);
apis[viewports.activeViewportIndex] = api;
}
const stack = OHIFVTKViewport.getCornerstoneStack(
studies,
StudyInstanceUID,
displaySetInstanceUID,
SOPClassUID,
SOPInstanceUID,
frameIndex,
);
const imageDataObject = getImageData(stack.imageIds, displaySetInstanceUID);
let pixelIndex = 0;
let x = 0;
let y = 0;
let count = 0;
const rows = imageDataObject.dimensions[1];
const cols = imageDataObject.dimensions[0];
for (let j = 0; j < rows; j++) {
for (let i = 0; i < cols; i++) {
// [i, j] =
const pixel = frame.pixelData[pixelIndex];
if (pixel === segmentNumber) {
x += i;
y += j;
count++;
}
pixelIndex++;
}
}
x /= count;
y /= count;
const position = [x, y, frameIndex];
const worldPos = _convertModelToWorldSpace(position, imageDataObject.vtkImageData);
api.svgWidgets.crosshairsWidget.moveCrosshairs(worldPos, apis);
done();
},
setSegmentationConfiguration: async ({
viewports,
globalOpacity,
visible,
renderOutline,
outlineThickness,
}) => {
const allViewports = Object.values(viewports.viewportSpecificData);
const promises = allViewports.map(async (viewport, viewportIndex) => {
let api = apis[viewportIndex];
if (!api) {
api = await _getActiveViewportVTKApi(viewports);
apis[viewportIndex] = api;
}
api.setGlobalOpacity(globalOpacity);
api.setVisibility(visible);
api.setOutlineThickness(outlineThickness);
api.setOutlineRendering(renderOutline);
api.updateImage();
});
await Promise.all(promises);
},
setSegmentConfiguration: async ({ viewports, visible, segmentNumber }) => {
const allViewports = Object.values(viewports.viewportSpecificData);
const promises = allViewports.map(async (viewport, viewportIndex) => {
let api = apis[viewportIndex];
if (!api) {
api = await _getActiveViewportVTKApi(viewports);
apis[viewportIndex] = api;
}
api.setSegmentVisibility(segmentNumber, visible);
api.updateImage();
});
await Promise.all(promises);
},
enableRotateTool: () => { enableRotateTool: () => {
apis.forEach(api => { apis.forEach(api => {
const istyle = vtkInteractorStyleMPRRotate.newInstance(); const istyle = vtkInteractorStyleMPRRotate.newInstance();
@ -280,6 +409,26 @@ const commandsModule = ({ commandsManager }) => {
window.vtkActions = actions; window.vtkActions = actions;
const definitions = { const definitions = {
requestNewSegmentation: {
commandFn: actions.requestNewSegmentation,
storeContexts: ['viewports'],
options: {},
},
jumpToSlice: {
commandFn: actions.jumpToSlice,
storeContexts: ['viewports'],
options: {},
},
setSegmentationConfiguration: {
commandFn: actions.setSegmentationConfiguration,
storeContexts: ['viewports'],
options: {},
},
setSegmentConfiguration: {
commandFn: actions.setSegmentConfiguration,
storeContexts: ['viewports'],
options: {},
},
axial: { axial: {
commandFn: actions.axial, commandFn: actions.axial,
storeContexts: ['viewports'], storeContexts: ['viewports'],

View File

@ -79,7 +79,7 @@ export class StudyMetadata extends Metadata {
Object.defineProperty(this, 'studyInstanceUID', { Object.defineProperty(this, 'studyInstanceUID', {
configurable: false, configurable: false,
enumerable: false, enumerable: false,
get: function() { get: function () {
return this.getStudyInstanceUID(); return this.getStudyInstanceUID();
}, },
}); });

View File

@ -2,7 +2,7 @@ import MODULE_TYPES from './MODULE_TYPES.js';
import log from './../log.js'; import log from './../log.js';
export default class ExtensionManager { export default class ExtensionManager {
constructor({ commandsManager, servicesManager, appConfig = {} }) { constructor({ commandsManager, servicesManager, api, appConfig = {} }) {
this.modules = {}; this.modules = {};
this.registeredExtensionIds = []; this.registeredExtensionIds = [];
this.moduleTypeNames = Object.values(MODULE_TYPES); this.moduleTypeNames = Object.values(MODULE_TYPES);
@ -10,6 +10,7 @@ export default class ExtensionManager {
this._commandsManager = commandsManager; this._commandsManager = commandsManager;
this._servicesManager = servicesManager; this._servicesManager = servicesManager;
this._appConfig = appConfig; this._appConfig = appConfig;
this._api = api;
this.moduleTypeNames.forEach(moduleType => { this.moduleTypeNames.forEach(moduleType => {
this.modules[moduleType] = []; this.modules[moduleType] = [];
@ -119,6 +120,7 @@ export default class ExtensionManager {
commandsManager: this._commandsManager, commandsManager: this._commandsManager,
appConfig: this._appConfig, appConfig: this._appConfig,
configuration, configuration,
api: this._api
}); });
if (!extensionModule) { if (!extensionModule) {

View File

@ -21,8 +21,8 @@ describe('OHIF VTK Extension', () => {
//Select 2D MPR button //Select 2D MPR button
cy.get('[data-cy="2d mpr"]').click(); cy.get('[data-cy="2d mpr"]').click();
//Wait Reformatting Images //Wait waitVTKLoading Images
cy.waitVTKReformatting(); cy.waitVTKLoading();
}); });
beforeEach(() => { beforeEach(() => {

View File

@ -18,8 +18,8 @@ describe('Visual Regression - OHIF VTK Extension', () => {
//Select 2D MPR button //Select 2D MPR button
cy.get('[data-cy="2d mpr"]').click(); cy.get('[data-cy="2d mpr"]').click();
//Wait Reformatting Images //Wait waitVTKLoading Images
cy.waitVTKReformatting(); cy.waitVTKwaitVTKLoading();
}); });
beforeEach(() => { beforeEach(() => {

View File

@ -107,15 +107,15 @@ Cypress.Commands.add('waitStudyList', () => {
}); });
}); });
Cypress.Commands.add('waitVTKReformatting', () => { Cypress.Commands.add('waitVTKLoading', () => {
// Wait for start reformatting // Wait for start loading
cy.get('[data-cy="viewprt-grid"]', { timeout: 10000 }).should($grid => { cy.get('[data-cy="viewprt-grid"]', { timeout: 10000 }).should($grid => {
expect($grid).to.contain.text('Reform'); expect($grid).to.contain.text('Loading');
}); });
// Wait for finish reformatting // Wait for finish loading
cy.get('[data-cy="viewprt-grid"]', { timeout: 30000 }).should($grid => { cy.get('[data-cy="viewprt-grid"]', { timeout: 30000 }).should($grid => {
expect($grid).not.to.contain.text('Reform'); expect($grid).not.to.contain.text('Loading');
}); });
}); });

View File

@ -53,7 +53,7 @@ import store from './store';
/** Contexts */ /** Contexts */
import WhiteLabelingContext from './context/WhiteLabelingContext'; import WhiteLabelingContext from './context/WhiteLabelingContext';
import UserManagerContext from './context/UserManagerContext'; import UserManagerContext from './context/UserManagerContext';
import AppContext from './context/AppContext'; import { AppProvider, useAppContext, CONTEXTS } from './context/AppContext';
/** ~~~~~~~~~~~~~ Application Setup */ /** ~~~~~~~~~~~~~ Application Setup */
const commandsManagerConfig = { const commandsManagerConfig = {
@ -159,8 +159,8 @@ class App extends Component {
if (this._userManager) { if (this._userManager) {
return ( return (
<AppContext.Provider value={{ appConfig: this._appConfig }}> <Provider store={store}>
<Provider store={store}> <AppProvider config={this._appConfig}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<OidcProvider store={store} userManager={this._userManager}> <OidcProvider store={store} userManager={this._userManager}>
<UserManagerContext.Provider value={this._userManager}> <UserManagerContext.Provider value={this._userManager}>
@ -183,14 +183,14 @@ class App extends Component {
</UserManagerContext.Provider> </UserManagerContext.Provider>
</OidcProvider> </OidcProvider>
</I18nextProvider> </I18nextProvider>
</Provider> </AppProvider>
</AppContext.Provider> </Provider>
); );
} }
return ( return (
<AppContext.Provider value={{ appConfig: this._appConfig }}> <Provider store={store}>
<Provider store={store}> <AppProvider config={this._appConfig}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Router basename={routerBasename}> <Router basename={routerBasename}>
<WhiteLabelingContext.Provider value={whiteLabeling}> <WhiteLabelingContext.Provider value={whiteLabeling}>
@ -204,8 +204,8 @@ class App extends Component {
</WhiteLabelingContext.Provider> </WhiteLabelingContext.Provider>
</Router> </Router>
</I18nextProvider> </I18nextProvider>
</Provider> </AppProvider>
</AppContext.Provider> </Provider>
); );
} }
@ -255,6 +255,12 @@ function _initExtensions(extensions, cornerstoneExtensionConfig, appConfig) {
commandsManager, commandsManager,
servicesManager, servicesManager,
appConfig, appConfig,
api: {
contexts: CONTEXTS,
hooks: {
useAppContext
}
}
}); });
const requiredExtensions = [ const requiredExtensions = [

View File

@ -191,8 +191,8 @@ class OHIFStandaloneViewer extends Component {
{match === null ? ( {match === null ? (
<></> <></>
) : ( ) : (
<Component match={match} location={this.props.location} /> <Component match={match} location={this.props.location} />
)} )}
</CSSTransition> </CSSTransition>
)} )}
</Route> </Route>

View File

@ -1,15 +0,0 @@
// TODO: REPLACE THIS WITH A CONTEXT PROVIDER
// EVERYTHING IN `VIEWER.JS` COULD USE THIS FOR APPROPRIATE CONTEXT
import ToolbarRow from './ToolbarRow';
import { connect } from 'react-redux';
import { getActiveContexts } from './../store/layout/selectors.js';
const mapStateToProps = state => {
return {
activeContexts: getActiveContexts(state),
};
};
const ConnectedToolbarRow = connect(mapStateToProps)(ToolbarRow);
export default ConnectedToolbarRow;

View File

@ -16,6 +16,7 @@ import { commandsManager, extensionManager } from './../App.js';
import ConnectedCineDialog from './ConnectedCineDialog'; import ConnectedCineDialog from './ConnectedCineDialog';
import ConnectedLayoutButton from './ConnectedLayoutButton'; import ConnectedLayoutButton from './ConnectedLayoutButton';
import { withAppContext } from '../context/AppContext';
class ToolbarRow extends Component { class ToolbarRow extends Component {
// TODO: Simplify these? isOpen can be computed if we say "any" value for selected, // TODO: Simplify these? isOpen can be computed if we say "any" value for selected,
@ -381,5 +382,5 @@ function _handleBuiltIn(button) {
} }
export default withTranslation(['Common', 'ViewportDownloadForm'])( export default withTranslation(['Common', 'ViewportDownloadForm'])(
withModal(withDialog(ToolbarRow)) withModal(withDialog(withAppContext(ToolbarRow)))
); );

View File

@ -7,7 +7,7 @@ import OHIF, { DICOMSR } from '@ohif/core';
import { withDialog } from '@ohif/ui'; import { withDialog } from '@ohif/ui';
import moment from 'moment'; import moment from 'moment';
import ConnectedHeader from './ConnectedHeader.js'; import ConnectedHeader from './ConnectedHeader.js';
import ConnectedToolbarRow from './ConnectedToolbarRow.js'; import ToolbarRow from './ToolbarRow.js';
import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; import ConnectedStudyBrowser from './ConnectedStudyBrowser.js';
import ConnectedViewerMain from './ConnectedViewerMain.js'; import ConnectedViewerMain from './ConnectedViewerMain.js';
import SidePanel from './../components/SidePanel.js'; import SidePanel from './../components/SidePanel.js';
@ -256,7 +256,7 @@ class Viewer extends Component {
</WhiteLabelingContext.Consumer> </WhiteLabelingContext.Consumer>
{/* TOOLBAR */} {/* TOOLBAR */}
<ConnectedToolbarRow <ToolbarRow
isLeftSidePanelOpen={this.state.isLeftSidePanelOpen} isLeftSidePanelOpen={this.state.isLeftSidePanelOpen}
isRightSidePanelOpen={this.state.isRightSidePanelOpen} isRightSidePanelOpen={this.state.isRightSidePanelOpen}
selectedLeftSidePanel={ selectedLeftSidePanel={
@ -307,11 +307,11 @@ class Viewer extends Component {
activeIndex={this.props.activeViewportIndex} activeIndex={this.props.activeViewportIndex}
/> />
) : ( ) : (
<ConnectedStudyBrowser <ConnectedStudyBrowser
studies={this.state.thumbnails} studies={this.state.thumbnails}
studyMetadata={this.props.studies} studyMetadata={this.props.studies}
/> />
)} )}
</SidePanel> </SidePanel>
{/* MAIN */} {/* MAIN */}
@ -349,7 +349,7 @@ export default withDialog(Viewer);
* @param {Study[]} studies * @param {Study[]} studies
* @param {DisplaySet[]} studies[].displaySets * @param {DisplaySet[]} studies[].displaySets
*/ */
const _mapStudiesToThumbnails = function(studies) { const _mapStudiesToThumbnails = function (studies) {
return studies.map(study => { return studies.map(study => {
const { StudyInstanceUID } = study; const { StudyInstanceUID } = study;

View File

@ -1,5 +1,33 @@
import React from 'react'; import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { getActiveContexts } from '../store/layout/selectors.js';
let AppContext = React.createContext({}); let AppContext = React.createContext({});
export const CONTEXTS = {
CORNERSTONE: 'ACTIVE_VIEWPORT::CORNERSTONE',
VTK: 'ACTIVE_VIEWPORT::VTK'
};
export const useAppContext = () => useContext(AppContext);
export const AppProvider = ({ children, config }) => {
const activeContexts = useSelector(state => getActiveContexts(state));
return (
<AppContext.Provider value={{ appConfig: config, activeContexts }}>
{children}
</AppContext.Provider>
);
};
export const withAppContext = Component => {
return function WrappedComponent(props) {
const { appConfig, activeContexts } = useAppContext();
return (
<Component {...props} appConfig={appConfig} activeContexts={activeContexts} />
);
};
};
export default AppContext; export default AppContext;

View File

@ -9110,6 +9110,11 @@ gl-matrix@^3.1.0:
resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.1.0.tgz#f5b2de17d8fed95a79e5025b10cded0ab9ccbed0" resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.1.0.tgz#f5b2de17d8fed95a79e5025b10cded0ab9ccbed0"
integrity sha512-526NA+3EA+ztAQi0IZpSWiM0fyQXIp7IbRvfJ4wS/TjjQD0uv0fVybXwwqqSOlq33UckivI0yMDlVtboWm3k7A== integrity sha512-526NA+3EA+ztAQi0IZpSWiM0fyQXIp7IbRvfJ4wS/TjjQD0uv0fVybXwwqqSOlq33UckivI0yMDlVtboWm3k7A==
gl-matrix@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.3.0.tgz#232eef60b1c8b30a28cbbe75b2caf6c48fd6358b"
integrity sha512-COb7LDz+SXaHtl/h4LeaFcNdJdAQSDeVqjiIihSXNrkWObZLhDI4hIkZC11Aeqp7bcE72clzB0BnDXr2SmslRA==
gl-preserve-state@^1.0.0: gl-preserve-state@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/gl-preserve-state/-/gl-preserve-state-1.0.0.tgz#4ef710d62873f1470ed015c6546c37dacddd4198" resolved "https://registry.yarnpkg.com/gl-preserve-state/-/gl-preserve-state-1.0.0.tgz#4ef710d62873f1470ed015c6546c37dacddd4198"
@ -15598,7 +15603,7 @@ react-codemirror2@^6.0.0:
resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-6.0.0.tgz#180065df57a64026026cde569a9708fdf7656525" resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-6.0.0.tgz#180065df57a64026026cde569a9708fdf7656525"
integrity sha512-D7y9qZ05FbUh9blqECaJMdDwKluQiO3A9xB+fssd5jKM7YAXucRuEOlX32mJQumUvHUkHRHqXIPBjm6g0FW0Ag== integrity sha512-D7y9qZ05FbUh9blqECaJMdDwKluQiO3A9xB+fssd5jKM7YAXucRuEOlX32mJQumUvHUkHRHqXIPBjm6g0FW0Ag==
react-cornerstone-viewport@^2.3.8: react-cornerstone-viewport@2.3.8:
version "2.3.8" version "2.3.8"
resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-2.3.8.tgz#7af8360f29bca986ae4e36b4e503269b88ddc52f" resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-2.3.8.tgz#7af8360f29bca986ae4e36b4e503269b88ddc52f"
integrity sha512-aiG2uVNrDY6SQx4t/HBxIA3zsMsCwT+6TpcXK9qSSoXhs+X6OTmYEKncWUqL0jtxU1yfh6JTUz8ARTg03gtF+A== integrity sha512-aiG2uVNrDY6SQx4t/HBxIA3zsMsCwT+6TpcXK9qSSoXhs+X6OTmYEKncWUqL0jtxU1yfh6JTUz8ARTg03gtF+A==
@ -16024,10 +16029,10 @@ react-transition-group@^4.1.1:
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.6.2" prop-types "^15.6.2"
react-vtkjs-viewport@^0.8.3: react-vtkjs-viewport@^0.9.0:
version "0.8.4" version "0.9.0"
resolved "https://registry.yarnpkg.com/react-vtkjs-viewport/-/react-vtkjs-viewport-0.8.4.tgz#6bd9837d7762b845e77f053fb8fbf4469428d594" resolved "https://registry.yarnpkg.com/react-vtkjs-viewport/-/react-vtkjs-viewport-0.9.0.tgz#5a7890643e511946f960db6b05142318be2dfe80"
integrity sha512-b90ENJzmStiahxVox+7mMwZij3NVUu//jE07PV2Qtp6E7q/eLXn6ZUwcEt9w5YHN36xLZVcx9b53ztLcXVgh/Q== integrity sha512-kjpNr1n+eW0nU736HH07IsSYQXphxfn/RBvKtpBer9tNsl+FyLH8h+uY8KWdv8kVBHkEoaH6/srxB0JBa8l+jA==
dependencies: dependencies:
date-fns "^2.2.1" date-fns "^2.2.1"
gl-matrix "^3.1.0" gl-matrix "^3.1.0"