feat: state sync service and hanging protocol updates to preserve state (#3131)
* feat: Add state sync and use it to remember viewport grid info fix: Version updates Fixes for toggling MPR mode Fix the display when the interleaved load module fails Fix the memory of the state to restore correctly PR fixes for the state sync service PR fixes PR fixes PR fixes Added a hack warning to remove volumeDeactivate Fixes for TMTV colormap setting Fix the casing Missed renames fix: tests not running due to variance in ordering Reverting some fixes to change case PR changes - mostly comments and minor improvements fix: All display sets were being updated on drag and drop PR fixes - mostly renames PR fixes Test support for OHIF, for HP branch test: Add at least a minimal set of automated tests for hanging protocols Docs PR fixes Merge fixes DOCS updates Add an example of the mn hanging protocol PR fixes PR fixes PR fixes * Fix the drag and drop PR fixes * PR changes - update default keys for next/previous stage * fix: Was storing the custom viewport grid too aggressively Caused by a PR change misspelling a variable
This commit is contained in:
1 parent
b7fff77e17
commit
803f638401
87 files changed
+3413
-1919
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
utilities as csUtils,
|
||||
CONSTANTS,
|
||||
} from '@cornerstonejs/core';
|
||||
import { Services } from '@ohif/core';
|
||||
|
||||
import { setEnabledElement } from '../state';
|
||||
|
||||
@@ -22,6 +23,9 @@ import {
|
||||
import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
||||
import { CinePlayer, useCine, useViewportGrid } from '@ohif/ui';
|
||||
|
||||
import { CornerstoneViewportService } from '../services/ViewportService/CornerstoneViewportService';
|
||||
import Presentation from '../types/Presentation';
|
||||
|
||||
const STACK = 'stack';
|
||||
|
||||
function areEqual(prevProps, nextProps) {
|
||||
@@ -128,6 +132,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
|
||||
cornerstoneViewportService,
|
||||
cornerstoneCacheService,
|
||||
viewportGridService,
|
||||
stateSyncService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const cineHandler = () => {
|
||||
@@ -211,6 +216,21 @@ const OHIFCornerstoneViewport = React.memo(props => {
|
||||
}
|
||||
}, [elementRef]);
|
||||
|
||||
const storePresentation = () => {
|
||||
const currentPresentation = cornerstoneViewportService.getPresentation(
|
||||
viewportIndex
|
||||
);
|
||||
const { presentationSync } = stateSyncService.getState();
|
||||
if (currentPresentation) {
|
||||
stateSyncService.store({
|
||||
presentationSync: {
|
||||
...presentationSync,
|
||||
[currentPresentation.id]: currentPresentation,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cleanUpServices = useCallback(() => {
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex(
|
||||
viewportIndex
|
||||
@@ -288,6 +308,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
|
||||
setImageScrollBarHeight();
|
||||
|
||||
return () => {
|
||||
storePresentation();
|
||||
|
||||
cleanUpServices();
|
||||
|
||||
cornerstoneViewportService.disableElement(viewportIndex);
|
||||
@@ -360,11 +382,20 @@ const OHIFCornerstoneViewport = React.memo(props => {
|
||||
initialImageIndex
|
||||
);
|
||||
|
||||
storePresentation();
|
||||
|
||||
const { presentationSync } = stateSyncService.getState();
|
||||
const { presentationId } = viewportOptions;
|
||||
const presentation = presentationId
|
||||
? (presentationSync[presentationId] as Presentation)
|
||||
: null;
|
||||
|
||||
cornerstoneViewportService.setViewportData(
|
||||
viewportIndex,
|
||||
viewportData,
|
||||
viewportOptions,
|
||||
displaySetOptions
|
||||
displaySetOptions,
|
||||
presentation
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ function ViewportOrientationMarkers({
|
||||
viewportIndex
|
||||
);
|
||||
|
||||
if (!ohifViewport) {
|
||||
console.log('ViewportOrientationMarkers::No viewport');
|
||||
return null;
|
||||
}
|
||||
const backgroundColor = ohifViewport.getViewportOptions().background;
|
||||
|
||||
// Todo: probably this can be done in a better way in which we identify bright
|
||||
|
||||
@@ -15,11 +15,14 @@ import { ServicesManager } from '@ohif/core';
|
||||
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
|
||||
import callInputDialog from './utils/callInputDialog';
|
||||
import { setColormap } from './utils/colormap/transferFunctionHelpers';
|
||||
import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol';
|
||||
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
|
||||
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
||||
|
||||
const commandsModule = ({ servicesManager }) => {
|
||||
const commandsModule = ({
|
||||
servicesManager,
|
||||
}: {
|
||||
servicesManager: ServicesManager;
|
||||
}): React.FunctionComponent => {
|
||||
const {
|
||||
viewportGridService,
|
||||
toolGroupService,
|
||||
@@ -27,9 +30,8 @@ const commandsModule = ({ servicesManager }) => {
|
||||
toolbarService,
|
||||
uiDialogService,
|
||||
cornerstoneViewportService,
|
||||
hangingProtocolService,
|
||||
uiNotificationService,
|
||||
} = (servicesManager as ServicesManager).services;
|
||||
} = servicesManager.services;
|
||||
|
||||
function _getActiveViewportEnabledElement() {
|
||||
return getActiveViewportEnabledElement(viewportGridService);
|
||||
@@ -128,6 +130,14 @@ const commandsModule = ({ servicesManager }) => {
|
||||
});
|
||||
viewport.render();
|
||||
},
|
||||
|
||||
// Just call the toolbar service record interaction - allows
|
||||
// executing a toolbar command as a full toolbar command with side affects
|
||||
// coming from the ToolbarService itself.
|
||||
toolbarServiceRecordInteraction: props => {
|
||||
toolbarService.recordInteraction(props);
|
||||
},
|
||||
|
||||
setToolActive: ({ toolName, toolGroupId = null }) => {
|
||||
if (toolName === 'Crosshairs') {
|
||||
const activeViewportToolGroup = _getToolGroup(null);
|
||||
@@ -150,7 +160,7 @@ const commandsModule = ({ servicesManager }) => {
|
||||
};
|
||||
|
||||
const toolGroup = _getToolGroup(toolGroupId);
|
||||
const toolGroupViewportIds = toolGroup.getViewportIds();
|
||||
const toolGroupViewportIds = toolGroup?.getViewportIds?.();
|
||||
|
||||
// if toolGroup has been destroyed, or its viewports have been removed
|
||||
if (!toolGroupViewportIds || !toolGroupViewportIds.length) {
|
||||
@@ -404,16 +414,6 @@ const commandsModule = ({ servicesManager }) => {
|
||||
(activeViewportIndex - 1 + viewports.length) % viewports.length;
|
||||
viewportGridService.setActiveViewportIndex(nextViewportIndex);
|
||||
},
|
||||
setHangingProtocol: ({ protocolId }) => {
|
||||
hangingProtocolService.setProtocol(protocolId);
|
||||
},
|
||||
toggleMPR: ({ toggledState }) => {
|
||||
toggleMPRHangingProtocol({
|
||||
toggledState,
|
||||
servicesManager,
|
||||
getToolGroup: _getToolGroup,
|
||||
});
|
||||
},
|
||||
toggleStackImageSync: ({ toggledState }) => {
|
||||
toggleStackImageSync({
|
||||
getEnabledElement,
|
||||
@@ -451,6 +451,11 @@ const commandsModule = ({ servicesManager }) => {
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
toolbarServiceRecordInteraction: {
|
||||
commandFn: actions.toolbarServiceRecordInteraction,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setToolActive: {
|
||||
commandFn: actions.setToolActive,
|
||||
storeContexts: [],
|
||||
@@ -554,16 +559,6 @@ const commandsModule = ({ servicesManager }) => {
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setHangingProtocol: {
|
||||
commandFn: actions.setHangingProtocol,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
toggleMPR: {
|
||||
commandFn: actions.toggleMPR,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
toggleStackImageSync: {
|
||||
commandFn: actions.toggleStackImageSync,
|
||||
storeContexts: [],
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
const mpr = {
|
||||
id: 'mpr',
|
||||
import { Types } from '@ohif/core';
|
||||
|
||||
const mpr: Types.HangingProtocol.Protocol = {
|
||||
locked: true,
|
||||
hasUpdatedPriorsInformation: false,
|
||||
name: 'mpr',
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
modifiedDate: '2022-10-04T19:22:08.894Z',
|
||||
modifiedDate: '2023-02-17',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
// Unknown number of priors referenced - so just match any study
|
||||
numberOfPriorsReferenced: 0,
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'nth',
|
||||
// imageLoadStrategy: 'nth',
|
||||
callbacks: {
|
||||
// Switches out of MPR mode when the layout change button is used
|
||||
onLayoutChange: [
|
||||
{
|
||||
commandName: 'toggleHangingProtocol',
|
||||
commandOptions: { protocolId: 'mpr' },
|
||||
context: 'DEFAULT',
|
||||
},
|
||||
],
|
||||
// Turns off crosshairs when switching out of MPR mode
|
||||
onProtocolExit: [
|
||||
{
|
||||
commandName: 'toolbarServiceRecordInteraction',
|
||||
commandOptions: {
|
||||
interactionType: 'tool',
|
||||
commands: [
|
||||
{
|
||||
commandOptions: {
|
||||
toolName: 'WindowLevel',
|
||||
},
|
||||
context: 'CORNERSTONE',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySetSelectors: {
|
||||
mprDisplaySet: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
@@ -27,8 +57,7 @@ const mpr = {
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'mpr3Stage',
|
||||
name: 'mpr',
|
||||
name: 'MPR 1x3',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
@@ -76,7 +105,7 @@ const mpr = {
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'mprDisplaySet',
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -99,7 +128,7 @@ const mpr = {
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'mprDisplaySet',
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -122,7 +151,7 @@ const mpr = {
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'mprDisplaySet',
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import { registerColormap } from './utils/colormap/transferFunctionHelpers';
|
||||
import { id } from './id';
|
||||
import * as csWADOImageLoader from './initWADOImageLoader.js';
|
||||
import { measurementMappingUtils } from './utils/measurementServiceMappings';
|
||||
import { PublicViewportOptions } from './services/ViewportService/Viewport';
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
return import(
|
||||
@@ -94,12 +95,12 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
// const onNewImageHandler = jumpData => {
|
||||
// commandsManager.runCommand('jumpToImage', jumpData);
|
||||
// };
|
||||
const { ToolbarService } = servicesManager.services;
|
||||
const { toolbarService } = (servicesManager as ServicesManager).services;
|
||||
|
||||
return (
|
||||
<OHIFCornerstoneViewport
|
||||
{...props}
|
||||
ToolbarService={ToolbarService}
|
||||
ToolbarService={toolbarService}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
/>
|
||||
@@ -151,4 +152,4 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
};
|
||||
|
||||
export default cornerstoneExtension;
|
||||
export { measurementMappingUtils };
|
||||
export { measurementMappingUtils, PublicViewportOptions };
|
||||
@@ -74,6 +74,7 @@ export default async function init({
|
||||
hangingProtocolService,
|
||||
toolGroupService,
|
||||
viewportGridService,
|
||||
stateSyncService,
|
||||
} = servicesManager.services;
|
||||
|
||||
window.services = servicesManager.services;
|
||||
@@ -97,6 +98,10 @@ export default async function init({
|
||||
_showCPURenderingModal(uiModalService, hangingProtocolService);
|
||||
}
|
||||
|
||||
// Stores a map from `presentationId` to a Presentation object so that
|
||||
// an OHIFCornerstoneViewport can be redisplayed with the same attributes
|
||||
stateSyncService.register('presentationSync', { clearOnModeExit: true });
|
||||
|
||||
const labelmapRepresentation =
|
||||
cornerstoneTools.Enums.SegmentationRepresentations.Labelmap;
|
||||
|
||||
@@ -369,8 +374,8 @@ export default async function init({
|
||||
|
||||
viewportGridService.subscribe(
|
||||
viewportGridService.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED,
|
||||
({ viewportIndex }) => {
|
||||
const viewportId = `viewport-${viewportIndex}`;
|
||||
({ viewportIndex, viewportId }) => {
|
||||
viewportId = viewportId || `viewport-${viewportIndex}`;
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
|
||||
if (!toolGroup || !toolGroup._toolInstances?.['ReferenceLines']) {
|
||||
@@ -427,9 +432,9 @@ function _showCPURenderingModal(uiModalService, hangingProtocolService) {
|
||||
};
|
||||
|
||||
const { unsubscribe } = hangingProtocolService.subscribe(
|
||||
hangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT,
|
||||
({ progress }) => {
|
||||
const done = callback(progress);
|
||||
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
|
||||
() => {
|
||||
const done = callback(100);
|
||||
|
||||
if (done) {
|
||||
unsubscribe();
|
||||
|
||||
+130
-68
@@ -1,4 +1,4 @@
|
||||
import { pubSubServiceInterface } from '@ohif/core';
|
||||
import { PubSubService } from '@ohif/core';
|
||||
import {
|
||||
RenderingEngine,
|
||||
StackViewport,
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
StackViewportData,
|
||||
VolumeViewportData,
|
||||
} from '../../types/CornerstoneCacheService';
|
||||
import {
|
||||
Presentation,
|
||||
StackPresentation,
|
||||
VolumePresentation,
|
||||
} from '../../types/Presentation';
|
||||
import {
|
||||
setColormap,
|
||||
setLowerUpperColorTransferFunction,
|
||||
@@ -37,18 +42,14 @@ const EVENTS = {
|
||||
* Handles cornerstone viewport logic including enabling, disabling, and
|
||||
* updating the viewport.
|
||||
*/
|
||||
class CornerstoneViewportService implements IViewportService {
|
||||
class CornerstoneViewportService extends PubSubService
|
||||
implements IViewportService {
|
||||
renderingEngine: Types.IRenderingEngine | null;
|
||||
viewportsInfo: Map<number, ViewportInfo>;
|
||||
viewportsInfo: Map<number, ViewportInfo> = new Map();
|
||||
viewportsById: Map<string, ViewportInfo> = new Map();
|
||||
viewportGridResizeObserver: ResizeObserver | null;
|
||||
viewportsDisplaySets: Map<string, string[]> = new Map();
|
||||
|
||||
/**
|
||||
* Service-specific
|
||||
*/
|
||||
EVENTS: { [key: string]: string };
|
||||
listeners: { [key: string]: Array<(...args: any[]) => void> };
|
||||
_broadcastEvent: unknown; // we should be able to extend the PubSub class to get this
|
||||
// Some configs
|
||||
enableResizeDetector: true;
|
||||
resizeRefreshRateMs: 200;
|
||||
@@ -56,15 +57,10 @@ class CornerstoneViewportService implements IViewportService {
|
||||
servicesManager = null;
|
||||
|
||||
constructor(servicesManager) {
|
||||
super(EVENTS);
|
||||
this.renderingEngine = null;
|
||||
this.viewportGridResizeObserver = null;
|
||||
this.viewportsInfo = new Map();
|
||||
//
|
||||
this.listeners = {};
|
||||
this.EVENTS = EVENTS;
|
||||
this.servicesManager = servicesManager;
|
||||
Object.assign(this, pubSubServiceInterface);
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,12 +73,23 @@ class CornerstoneViewportService implements IViewportService {
|
||||
viewportOptions: PublicViewportOptions,
|
||||
elementRef: HTMLDivElement
|
||||
) {
|
||||
const viewportInfo = new ViewportInfo(
|
||||
viewportIndex,
|
||||
this.getViewportId(viewportIndex)
|
||||
);
|
||||
// Use the provided viewportId
|
||||
// Not providing a viewportId is frowned upon because it does weird things
|
||||
// on moving them around, but it does mostly work.
|
||||
if (!viewportOptions.viewportId) {
|
||||
console.warn('Should provide viewport id externally', viewportOptions);
|
||||
viewportOptions.viewportId =
|
||||
this.getViewportId(viewportIndex) || `viewport-${viewportIndex}`;
|
||||
}
|
||||
const { viewportId } = viewportOptions;
|
||||
const viewportInfo = new ViewportInfo(viewportIndex, viewportId);
|
||||
if (!viewportInfo.viewportId) {
|
||||
throw new Error('Should have viewport ID afterwards');
|
||||
}
|
||||
|
||||
viewportInfo.setElement(elementRef);
|
||||
this.viewportsInfo.set(viewportIndex, viewportInfo);
|
||||
this.viewportsById.set(viewportId, viewportInfo);
|
||||
}
|
||||
|
||||
public getViewportIds(): string[] {
|
||||
@@ -96,7 +103,7 @@ class CornerstoneViewportService implements IViewportService {
|
||||
}
|
||||
|
||||
public getViewportId(viewportIndex: number): string {
|
||||
return `viewport-${viewportIndex}`;
|
||||
return this.viewportsInfo[viewportIndex]?.viewportId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +170,32 @@ class CornerstoneViewportService implements IViewportService {
|
||||
|
||||
this.viewportsInfo.get(viewportIndex).destroy();
|
||||
this.viewportsInfo.delete(viewportIndex);
|
||||
this.viewportsById.delete(viewportId);
|
||||
}
|
||||
|
||||
public getPresentation(viewportIndex: number): Presentation {
|
||||
const viewportInfo = this.viewportsInfo.get(viewportIndex);
|
||||
if (!viewportInfo) return;
|
||||
const {
|
||||
presentationId: id,
|
||||
viewportType,
|
||||
} = viewportInfo.getViewportOptions();
|
||||
if (!id) return;
|
||||
|
||||
const csViewport = this.getCornerstoneViewportByIndex(viewportIndex);
|
||||
if (!csViewport) return;
|
||||
|
||||
const properties = csViewport.getProperties();
|
||||
const initialImageIndex = csViewport.getCurrentImageIdIndex();
|
||||
const camera = csViewport.getCamera();
|
||||
return {
|
||||
id,
|
||||
viewportType:
|
||||
!viewportType || viewportType === 'stack' ? 'stack' : 'volume',
|
||||
properties,
|
||||
initialImageIndex,
|
||||
camera,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,29 +210,27 @@ class CornerstoneViewportService implements IViewportService {
|
||||
viewportIndex: number,
|
||||
viewportData: StackViewportData | VolumeViewportData,
|
||||
publicViewportOptions: PublicViewportOptions,
|
||||
publicDisplaySetOptions: DisplaySetOptions[]
|
||||
publicDisplaySetOptions: DisplaySetOptions[],
|
||||
presentation?: Presentation
|
||||
): void {
|
||||
const renderingEngine = this.getRenderingEngine();
|
||||
const viewportInfo = this.viewportsInfo.get(viewportIndex);
|
||||
|
||||
if (!publicViewportOptions.viewportId) {
|
||||
publicViewportOptions.viewportId = this.getViewportId(viewportIndex);
|
||||
const viewportId =
|
||||
publicViewportOptions.viewportId || this.getViewportId(viewportIndex);
|
||||
if (!viewportId) {
|
||||
throw new Error('Must define viewportId externally');
|
||||
}
|
||||
|
||||
let viewportId = viewportInfo.getViewportId();
|
||||
const viewportInfo = this.viewportsById.get(viewportId);
|
||||
|
||||
// if currently there is a viewport with the viewportId, but it is not the same
|
||||
// as the one we are trying to set, we need to disable the old one
|
||||
// and enable the new one, we could ideally change the name of the viewportId
|
||||
// but the viewportId is an integral part in renderers map, tools svg cache
|
||||
// etc. which would require a lot of refactoring, for now we will just disable
|
||||
// the old one and enable the new one at the end of this function
|
||||
let newViewportId = null;
|
||||
if (publicViewportOptions?.viewportId !== viewportId) {
|
||||
newViewportId = publicViewportOptions.viewportId;
|
||||
viewportInfo.setViewportId(newViewportId);
|
||||
if (!viewportInfo) {
|
||||
throw new Error('Viewport info not defined');
|
||||
}
|
||||
|
||||
renderingEngine.disableElement(viewportId);
|
||||
// If the viewport has moved index, then record the new index
|
||||
if (viewportInfo.viewportIndex !== viewportIndex) {
|
||||
this.viewportsInfo.delete(viewportInfo.viewportIndex);
|
||||
this.viewportsInfo.set(viewportIndex, viewportInfo);
|
||||
viewportInfo.viewportIndex = viewportIndex;
|
||||
}
|
||||
|
||||
viewportInfo.setRenderingEngineId(renderingEngine.id);
|
||||
@@ -220,9 +251,9 @@ class CornerstoneViewportService implements IViewportService {
|
||||
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
|
||||
viewportData,
|
||||
viewportIndex,
|
||||
viewportId,
|
||||
});
|
||||
|
||||
viewportId = viewportInfo.getViewportId();
|
||||
const element = viewportInfo.getElement();
|
||||
const type = viewportInfo.getViewportType();
|
||||
const background = viewportInfo.getBackground();
|
||||
@@ -245,7 +276,7 @@ class CornerstoneViewportService implements IViewportService {
|
||||
renderingEngine.enableElement(viewportInput);
|
||||
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
this._setDisplaySets(viewport, viewportData, viewportInfo);
|
||||
this._setDisplaySets(viewport, viewportData, viewportInfo, presentation);
|
||||
}
|
||||
|
||||
public getCornerstoneViewport(
|
||||
@@ -308,8 +339,9 @@ class CornerstoneViewportService implements IViewportService {
|
||||
_setStackViewport(
|
||||
viewport: Types.IStackViewport,
|
||||
viewportData: StackViewportData,
|
||||
viewportInfo: ViewportInfo
|
||||
) {
|
||||
viewportInfo: ViewportInfo,
|
||||
presentation?: StackPresentation
|
||||
): void {
|
||||
const displaySetOptions = viewportInfo.getDisplaySetOptions();
|
||||
|
||||
const {
|
||||
@@ -320,29 +352,40 @@ class CornerstoneViewportService implements IViewportService {
|
||||
|
||||
this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]);
|
||||
|
||||
let initialImageIndexToUse = initialImageIndex;
|
||||
let initialImageIndexToUse =
|
||||
presentation?.initialImageIndex ?? initialImageIndex;
|
||||
|
||||
if (!initialImageIndexToUse) {
|
||||
if (
|
||||
initialImageIndexToUse === undefined ||
|
||||
initialImageIndexToUse === null
|
||||
) {
|
||||
initialImageIndexToUse =
|
||||
this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0;
|
||||
}
|
||||
|
||||
const { voi, voiInverted } = displaySetOptions[0];
|
||||
const properties = {};
|
||||
if (voi && (voi.windowWidth || voi.windowCenter)) {
|
||||
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
|
||||
voi.windowWidth,
|
||||
voi.windowCenter
|
||||
);
|
||||
properties.voiRange = { lower, upper };
|
||||
const properties = presentation?.properties || {};
|
||||
if (!presentation?.properties) {
|
||||
const { voi, voiInverted } = displaySetOptions[0];
|
||||
if (voi && (voi.windowWidth || voi.windowCenter)) {
|
||||
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
|
||||
voi.windowWidth,
|
||||
voi.windowCenter
|
||||
);
|
||||
properties.voiRange = { lower, upper };
|
||||
}
|
||||
|
||||
if (voiInverted !== undefined) {
|
||||
properties.invert = voiInverted;
|
||||
}
|
||||
}
|
||||
|
||||
if (voiInverted !== undefined) {
|
||||
properties.invert = voiInverted;
|
||||
}
|
||||
|
||||
viewport.setStack(imageIds, initialImageIndexToUse).then(() => {
|
||||
// There is a bug in CS3D that the setStack does not
|
||||
// navigate to the desired image.
|
||||
viewport.setStack(imageIds, 0).then(() => {
|
||||
// The scroll, however, works fine in CS3D
|
||||
viewport.scroll(initialImageIndexToUse);
|
||||
viewport.setProperties(properties);
|
||||
if (presentation?.camera) viewport.setCamera(presentation.camera);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,7 +440,8 @@ class CornerstoneViewportService implements IViewportService {
|
||||
async _setVolumeViewport(
|
||||
viewport: Types.IVolumeViewport,
|
||||
viewportData: VolumeViewportData,
|
||||
viewportInfo: ViewportInfo
|
||||
viewportInfo: ViewportInfo,
|
||||
presentation: VolumePresentation
|
||||
): Promise<void> {
|
||||
// TODO: We need to overhaul the way data sources work so requests can be made
|
||||
// async. I think we should follow the image loader pattern which is async and
|
||||
@@ -423,6 +467,7 @@ class CornerstoneViewportService implements IViewportService {
|
||||
displaySetInstanceUIDs.push(displaySetInstanceUID);
|
||||
|
||||
if (!volume) {
|
||||
console.log('Volume display set not found');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -453,10 +498,15 @@ class CornerstoneViewportService implements IViewportService {
|
||||
!hangingProtocolService.customImageLoadPerformed
|
||||
) {
|
||||
// delegate the volume loading to the hanging protocol service if it has a custom image load strategy
|
||||
return hangingProtocolService.runImageLoadStrategy({
|
||||
viewportId: viewport.id,
|
||||
volumeInputArray,
|
||||
});
|
||||
if (
|
||||
hangingProtocolService.runImageLoadStrategy({
|
||||
viewportId: viewport.id,
|
||||
volumeInputArray,
|
||||
})
|
||||
) {
|
||||
// Fallback to the default strategy if the custom one fails
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
volumeToLoad.forEach(volume => {
|
||||
@@ -464,10 +514,10 @@ class CornerstoneViewportService implements IViewportService {
|
||||
});
|
||||
|
||||
// This returns the async continuation only
|
||||
return this.setVolumesForViewport(viewport, volumeInputArray);
|
||||
return this.setVolumesForViewport(viewport, volumeInputArray, presentation);
|
||||
}
|
||||
|
||||
public async setVolumesForViewport(viewport, volumeInputArray) {
|
||||
public async setVolumesForViewport(viewport, volumeInputArray, presentation) {
|
||||
const {
|
||||
displaySetService,
|
||||
segmentationService,
|
||||
@@ -475,6 +525,9 @@ class CornerstoneViewportService implements IViewportService {
|
||||
} = this.servicesManager.services;
|
||||
|
||||
await viewport.setVolumes(volumeInputArray);
|
||||
const { properties, camera } = presentation || {};
|
||||
if (properties) viewport.setProperties(properties);
|
||||
if (camera) viewport.setCamera(camera);
|
||||
|
||||
// load any secondary displaySets
|
||||
const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id);
|
||||
@@ -589,7 +642,11 @@ class CornerstoneViewportService implements IViewportService {
|
||||
|
||||
// Todo: keepCamera is an interim solution until we have a better solution for
|
||||
// keeping the camera position when the viewport data is changed
|
||||
public updateViewport(viewportIndex, viewportData, keepCamera = false) {
|
||||
public updateViewport(
|
||||
viewportIndex: number,
|
||||
viewportData,
|
||||
keepCamera = false
|
||||
) {
|
||||
const viewportInfo = this.getViewportInfoByIndex(viewportIndex);
|
||||
|
||||
const viewportId = viewportInfo.getViewportId();
|
||||
@@ -645,19 +702,22 @@ class CornerstoneViewportService implements IViewportService {
|
||||
_setDisplaySets(
|
||||
viewport: StackViewport | VolumeViewport,
|
||||
viewportData: StackViewportData | VolumeViewportData,
|
||||
viewportInfo: ViewportInfo
|
||||
viewportInfo: ViewportInfo,
|
||||
presentation?: Presentation
|
||||
): void {
|
||||
if (viewport instanceof StackViewport) {
|
||||
this._setStackViewport(
|
||||
viewport,
|
||||
viewportData as StackViewportData,
|
||||
viewportInfo
|
||||
viewportInfo,
|
||||
presentation as StackPresentation
|
||||
);
|
||||
} else if (viewport instanceof VolumeViewport) {
|
||||
this._setVolumeViewport(
|
||||
viewport,
|
||||
viewportData as VolumeViewportData,
|
||||
viewportInfo
|
||||
viewportInfo,
|
||||
presentation as VolumePresentation
|
||||
);
|
||||
} else {
|
||||
throw new Error('Unknown viewport type');
|
||||
@@ -758,7 +818,7 @@ class CornerstoneViewportService implements IViewportService {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ExtendedCornerstoneViewportService(serviceManager) {
|
||||
export default function CornerstoneViewportServiceRegistration(serviceManager) {
|
||||
return {
|
||||
name: 'cornerstoneViewportService',
|
||||
altName: 'CornerstoneViewportService',
|
||||
@@ -767,3 +827,5 @@ export default function ExtendedCornerstoneViewportService(serviceManager) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { CornerstoneViewportService, CornerstoneViewportServiceRegistration };
|
||||
@@ -18,6 +18,8 @@ export type ViewportOptions = {
|
||||
viewportType: Enums.ViewportType;
|
||||
toolGroupId: string;
|
||||
viewportId: string;
|
||||
// Presentation ID to store/load presentation state from
|
||||
presentationId?: string;
|
||||
orientation?: Types.Orientation;
|
||||
background?: Types.Point3;
|
||||
syncGroups?: SyncGroup[];
|
||||
@@ -33,6 +35,7 @@ export type ViewportOptions = {
|
||||
export type PublicViewportOptions = {
|
||||
viewportType?: string;
|
||||
toolGroupId?: string;
|
||||
presentationId?: string;
|
||||
viewportId?: string;
|
||||
orientation?: string;
|
||||
background?: Types.Point3;
|
||||
@@ -42,6 +45,11 @@ export type PublicViewportOptions = {
|
||||
allowUnmatchedView?: boolean;
|
||||
};
|
||||
|
||||
export type DisplaySetSelector = {
|
||||
id?: string;
|
||||
options?: PublicDisplaySetOptions;
|
||||
};
|
||||
|
||||
export type PublicDisplaySetOptions = {
|
||||
voi?: VOI;
|
||||
voiInverted?: boolean;
|
||||
@@ -136,7 +144,7 @@ class ViewportInfo {
|
||||
}
|
||||
|
||||
public setPublicDisplaySetOptions(
|
||||
publicDisplaySetOptions: Array<PublicDisplaySetOptions>
|
||||
publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[]
|
||||
): void {
|
||||
// map the displaySetOptions and check if they are undefined then set them to default values
|
||||
const displaySetOptions = this.mapDisplaySetOptions(
|
||||
@@ -167,7 +175,10 @@ class ViewportInfo {
|
||||
viewportOptionsEntry: PublicViewportOptions
|
||||
): void {
|
||||
let viewportType = viewportOptionsEntry.viewportType;
|
||||
let toolGroupId = viewportOptionsEntry.toolGroupId;
|
||||
const {
|
||||
toolGroupId = DEFAULT_TOOLGROUP_ID,
|
||||
presentationId,
|
||||
} = viewportOptionsEntry;
|
||||
let orientation;
|
||||
|
||||
if (!viewportType) {
|
||||
@@ -185,16 +196,13 @@ class ViewportInfo {
|
||||
orientation = Enums.OrientationAxis.AXIAL;
|
||||
}
|
||||
|
||||
if (!toolGroupId) {
|
||||
toolGroupId = DEFAULT_TOOLGROUP_ID;
|
||||
}
|
||||
|
||||
this.setViewportOptions({
|
||||
...viewportOptionsEntry,
|
||||
viewportId: this.viewportId,
|
||||
viewportType: viewportType as Enums.ViewportType,
|
||||
orientation,
|
||||
toolGroupId,
|
||||
presentationId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,12 +248,15 @@ class ViewportInfo {
|
||||
return this.viewportOptions.initialImageOptions;
|
||||
}
|
||||
|
||||
// Handle incoming public display set options or a display set select
|
||||
// with a contained options.
|
||||
private mapDisplaySetOptions(
|
||||
publicDisplaySetOptions: Array<PublicDisplaySetOptions>
|
||||
options: PublicDisplaySetOptions[] | DisplaySetSelector[] = [{}]
|
||||
): Array<DisplaySetOptions> {
|
||||
const displaySetOptions: Array<DisplaySetOptions> = [];
|
||||
|
||||
publicDisplaySetOptions.forEach(option => {
|
||||
options.forEach(item => {
|
||||
let option = item?.options || item;
|
||||
if (!option) {
|
||||
option = {
|
||||
blendMode: undefined,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Store presentation data for either stack viewports or volume viewports */
|
||||
import { Types } from '@cornerstonejs/core';
|
||||
|
||||
export interface BasePresentation {
|
||||
id: string;
|
||||
properties: Record<string, unknown>;
|
||||
initialImageIndex?: number;
|
||||
camera: Types.ICamera;
|
||||
}
|
||||
|
||||
export interface StackPresentation extends BasePresentation {
|
||||
viewportType: 'stack';
|
||||
}
|
||||
|
||||
export interface VolumePresentation extends BasePresentation {
|
||||
viewportType: 'volume';
|
||||
}
|
||||
|
||||
// Currently it seems like the entire presentation state can be shared between
|
||||
// Stack and Volume, but is setup to allow differences
|
||||
export type Presentation = StackPresentation | VolumePresentation;
|
||||
|
||||
export default Presentation;
|
||||
@@ -2,15 +2,17 @@ import { Enums } from '@cornerstonejs/core';
|
||||
|
||||
const STACK = 'stack';
|
||||
const VOLUME = 'volume';
|
||||
const ORTHOGRAPHIC = 'orthographic';
|
||||
|
||||
export default function getCornerstoneViewportType(
|
||||
viewportType: string
|
||||
): Enums.ViewportType {
|
||||
if (viewportType.toLowerCase() === STACK) {
|
||||
const lowerViewportType = viewportType.toLowerCase();
|
||||
if (lowerViewportType === STACK) {
|
||||
return Enums.ViewportType.STACK;
|
||||
}
|
||||
|
||||
if (viewportType.toLowerCase() === VOLUME) {
|
||||
if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) {
|
||||
return Enums.ViewportType.ORTHOGRAPHIC;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import removeToolGroupSegmentationRepresentations from '../removeToolGroupSegmentationRepresentations';
|
||||
|
||||
const MPR_TOOLGROUP_ID = 'mpr';
|
||||
|
||||
const cachedState = {
|
||||
protocol: null,
|
||||
stage: null,
|
||||
viewportMatchDetails: null,
|
||||
viewportStructure: null,
|
||||
toolOptions: null,
|
||||
};
|
||||
|
||||
const setCachedState = (
|
||||
protocol,
|
||||
stage,
|
||||
viewportMatchDetails,
|
||||
viewportStructure,
|
||||
toolOptions
|
||||
) => {
|
||||
cachedState.protocol = protocol;
|
||||
cachedState.stage = stage;
|
||||
cachedState.viewportMatchDetails = viewportMatchDetails;
|
||||
cachedState.viewportStructure = viewportStructure;
|
||||
cachedState.toolOptions = JSON.parse(JSON.stringify(toolOptions));
|
||||
};
|
||||
|
||||
const resetCachedState = () => {
|
||||
cachedState.protocol = null;
|
||||
cachedState.stage = null;
|
||||
cachedState.viewportMatchDetails = null;
|
||||
cachedState.viewportStructure = null;
|
||||
cachedState.toolOptions = null;
|
||||
};
|
||||
|
||||
export default function toggleMPRHangingProtocol({
|
||||
toggledState,
|
||||
servicesManager,
|
||||
getToolGroup,
|
||||
}) {
|
||||
const {
|
||||
uiNotificationService,
|
||||
hangingProtocolService,
|
||||
viewportGridService,
|
||||
toolbarService,
|
||||
} = servicesManager.services;
|
||||
|
||||
// TODO Introduce a service to persist the state of the current hanging protocol/app.
|
||||
// So all of the code to persist the state here will no longer be needed. Perhaps
|
||||
// just the id of the current hanging protocol to toggle MPR off is needed.
|
||||
|
||||
const {
|
||||
activeViewportIndex,
|
||||
viewports,
|
||||
numRows,
|
||||
numCols,
|
||||
} = viewportGridService.getState();
|
||||
const viewportDisplaySetInstanceUIDs =
|
||||
viewports[activeViewportIndex].displaySetInstanceUIDs;
|
||||
|
||||
// What is the current active protocol and stage number to restore later
|
||||
const { protocol, stage } = hangingProtocolService.getActiveProtocol();
|
||||
|
||||
const restoreErrorCallback = error => {
|
||||
console.error(error);
|
||||
uiNotificationService.show({
|
||||
title: 'Multiplanar reconstruction (MPR) ',
|
||||
message:
|
||||
'Something went wrong while trying to restore the previous layout.',
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
if (toggledState) {
|
||||
resetCachedState();
|
||||
|
||||
const {
|
||||
viewportMatchDetails,
|
||||
viewportStructure,
|
||||
toolOptions,
|
||||
} = _getViewportsInfo({
|
||||
protocol,
|
||||
stage,
|
||||
viewports,
|
||||
servicesManager,
|
||||
});
|
||||
|
||||
setCachedState(
|
||||
protocol,
|
||||
stage,
|
||||
viewportMatchDetails,
|
||||
viewportStructure,
|
||||
toolOptions
|
||||
);
|
||||
|
||||
const matchDetails = {
|
||||
displaySetInstanceUIDs: viewportDisplaySetInstanceUIDs,
|
||||
};
|
||||
|
||||
_disableCrosshairs(
|
||||
toolOptions.map(({ toolGroupId }) => toolGroupId),
|
||||
getToolGroup
|
||||
);
|
||||
|
||||
const errorCallback = error => {
|
||||
// Unable to create MPR, so be sure to return to the cached/original protocol.
|
||||
hangingProtocolService.setProtocol(
|
||||
cachedState.protocol.id,
|
||||
viewportMatchDetails,
|
||||
restoreErrorCallback
|
||||
);
|
||||
|
||||
uiNotificationService.show({
|
||||
title: 'Multiplanar reconstruction (MPR) ',
|
||||
message:
|
||||
'Cannot create MPR for this DisplaySet since it is not reconstructable.',
|
||||
type: 'info',
|
||||
duration: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
hangingProtocolService.setProtocol(
|
||||
MPR_TOOLGROUP_ID,
|
||||
matchDetails,
|
||||
errorCallback
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_disableCrosshairs([MPR_TOOLGROUP_ID], getToolGroup);
|
||||
|
||||
const { layoutType, properties } = cachedState.viewportStructure;
|
||||
const { viewportMatchDetails } = cachedState;
|
||||
|
||||
// The reason we split the flow here is that we don't allow viewport grid
|
||||
// change in the non default hanging protocol, so we can just apply the
|
||||
// cached protocol and stage. However, for the default protocol, we need
|
||||
// to also apply the layout type and properties.
|
||||
if (cachedState.protocol.id !== 'default') {
|
||||
hangingProtocolService.setProtocol(
|
||||
cachedState.protocol.id,
|
||||
viewportMatchDetails,
|
||||
restoreErrorCallback
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
hangingProtocolService.setProtocol(
|
||||
'default',
|
||||
viewportMatchDetails,
|
||||
restoreErrorCallback
|
||||
);
|
||||
|
||||
if (numRows !== properties.rows || numCols !== properties.columns) {
|
||||
viewportGridService.setLayout({
|
||||
numRows: properties.rows,
|
||||
numCols: properties.columns,
|
||||
layoutType,
|
||||
layoutOptions: properties.layoutOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const numViewports =
|
||||
properties.layoutOptions.length || properties.rows * properties.columns;
|
||||
|
||||
// loop inside viewportMatchDetails map
|
||||
// and set the viewportOptions for each viewport
|
||||
[...Array(numViewports).keys()].forEach(viewportIndex => {
|
||||
const viewportMatchDetailsForViewport = viewportMatchDetails.get(
|
||||
viewportIndex
|
||||
);
|
||||
|
||||
if (viewportMatchDetailsForViewport) {
|
||||
const {
|
||||
viewportOptions,
|
||||
displaySetsInfo,
|
||||
} = viewportMatchDetailsForViewport;
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportIndex,
|
||||
displaySetInstanceUIDs: displaySetsInfo.map(
|
||||
displaySetInfo => displaySetInfo.displaySetInstanceUID
|
||||
),
|
||||
viewportOptions,
|
||||
});
|
||||
} else {
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportIndex,
|
||||
displaySetInstanceUIDs: [],
|
||||
viewportOptions: {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
toolbarService.recordInteraction({
|
||||
groupId: 'WindowLevel',
|
||||
itemId: 'WindowLevel',
|
||||
interactionType: 'tool',
|
||||
commands: [
|
||||
{
|
||||
commandName: 'setToolActive',
|
||||
commandOptions: {
|
||||
toolName: 'WindowLevel',
|
||||
},
|
||||
context: 'CORNERSTONE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
//clear segmentations if they exist
|
||||
removeToolGroupSegmentationRepresentations(MPR_TOOLGROUP_ID);
|
||||
}
|
||||
|
||||
function _disableCrosshairs(toolGroupIds, getToolGroup) {
|
||||
toolGroupIds.forEach(toolGroupId => {
|
||||
const toolGroup = getToolGroup(toolGroupId);
|
||||
if (
|
||||
toolGroup.getToolInstance('Crosshairs')?.mode === Enums.ToolModes.Active
|
||||
) {
|
||||
toolGroup.setToolDisabled('Crosshairs');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _getViewportsInfo({ protocol, stage, viewports, servicesManager }) {
|
||||
// here we need to use the viewports and try to map it into the
|
||||
// viewportMatchDetails and displaySetMatch that hangingProtocolService
|
||||
// expects
|
||||
const {
|
||||
viewportGridService,
|
||||
hangingProtocolService,
|
||||
toolGroupService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const { numRows, numCols } = viewportGridService.getState();
|
||||
|
||||
let viewportMatchDetails = new Map();
|
||||
|
||||
const viewportStructure = {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: numRows,
|
||||
columns: numCols,
|
||||
layoutOptions: [],
|
||||
},
|
||||
};
|
||||
|
||||
viewports.forEach((viewport, viewportIndex) => {
|
||||
viewportStructure.properties.layoutOptions.push({
|
||||
x: viewport.x,
|
||||
y: viewport.y,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
});
|
||||
|
||||
if (protocol.id === 'default') {
|
||||
viewports.forEach((viewport, viewportIndex) => {
|
||||
if (viewport.displaySetInstanceUIDs) {
|
||||
viewportMatchDetails.set(viewportIndex, {
|
||||
displaySetsInfo: viewport.displaySetInstanceUIDs.map(
|
||||
displaySetInstanceUID => {
|
||||
return { displaySetInstanceUID };
|
||||
}
|
||||
),
|
||||
viewportOptions: viewport.viewportOptions,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
({ viewportMatchDetails } = hangingProtocolService.getMatchDetails());
|
||||
}
|
||||
|
||||
// get the toolGroup state for viewports
|
||||
let toolOptions = [];
|
||||
const viewportIds = viewports
|
||||
.map(
|
||||
viewport =>
|
||||
viewport.displaySetInstanceUIDs &&
|
||||
viewport.displaySetInstanceUIDs.length > 0 &&
|
||||
viewport.viewportOptions?.viewportId
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (viewportIds.length) {
|
||||
toolOptions = viewportIds
|
||||
.map(viewportId => {
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
return toolGroup
|
||||
? {
|
||||
toolGroupId: toolGroup.id,
|
||||
toolOptions: toolGroup.toolOptions,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return { viewportMatchDetails, viewportStructure, toolOptions };
|
||||
}
|
||||
Reference in new issue
Block a user