diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts new file mode 100644 index 0000000000..70de70e81f --- /dev/null +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -0,0 +1,407 @@ +import type { Types, VolumeViewport3D } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + setVolumesForViewports, + volumeLoader, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + setCtTransferFunctionForVolumeActor, + addDropdownToToolbar, + getLocalUrl, + addToggleButtonToToolbar, +} from '../../../../utils/demo/helpers'; + +import * as cornerstoneTools from '@cornerstonejs/tools'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + VolumeCroppingTool, + VolumeCroppingControlTool, + TrackballRotateTool, + ZoomTool, + PanTool, + OrientationMarkerTool, + StackScrollTool, + CrosshairsTool, +} = cornerstoneTools; + +const { MouseBindings } = csToolsEnums; +const { ViewportType } = Enums; + +// Define a unique id for the volume +const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id +const toolGroupId = 'MY_TOOLGROUP_ID'; +const toolGroupIdVRT = 'MY_TOOLGROUP_VRT_ID'; + +const viewportId1 = 'CT_AXIAL'; +const viewportId2 = 'CT_CORONAL'; +const viewportId3 = 'CT_SAGITTAL'; +const viewportId4 = 'CT_3D_VOLUME'; // New 3D volume viewport +const viewportIds = [viewportId1, viewportId2, viewportId3, viewportId4]; + +// Add dropdown to toolbar to select number of orthographic viewports (reloads page with URL param) +addDropdownToToolbar({ + labelText: 'Number of Orthographic Viewports', + options: { + values: [1, 2, 3], + defaultValue: getNumViewportsFromUrl(), + }, + onSelectedValueChange: (selectedValue) => { + const url = new URL(window.location.href); + url.searchParams.set('numViewports', selectedValue); + window.location.href = url.toString(); + }, +}); +const renderingEngineId = 'myRenderingEngine'; + +///////////////////////////////////////// +// ======== Set up page ======== // +setTitleAndDescription( + 'Volume Cropping', + 'Here we demonstrate how to crop a 3D volume with 6 clipping planes aligned on the x,y and z axes.' +); + +const size = '400px'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; +viewportGrid.style.width = '100%'; +viewportGrid.style.height = '800px'; + +// Create elements for the viewports +const element1 = document.createElement('div'); // Axial +const element2 = document.createElement('div'); // Sagittal +const element3 = document.createElement('div'); // Coronal +const element4 = document.createElement('div'); // 3D Volume + +// Create a container for the right side viewports +const rightViewportsContainer = document.createElement('div'); +rightViewportsContainer.style.display = 'flex'; +rightViewportsContainer.style.flexDirection = 'column'; +rightViewportsContainer.style.width = '20%'; +rightViewportsContainer.style.height = '100%'; + +// Set styles for the 2D viewports (stacked vertically on the right) +element1.style.width = '100%'; +element1.style.height = '33.33%'; +element1.style.minHeight = '200px'; + +element2.style.width = '100%'; +element2.style.height = '33.33%'; +element2.style.minHeight = '200px'; + +element3.style.width = '100%'; +element3.style.height = '33.33%'; +element3.style.minHeight = '200px'; + +// Set styles for the 3D viewport (on the left) +element4.style.width = '75%'; +element4.style.height = '100%'; +element4.style.minHeight = '600px'; +element4.style.position = 'relative'; + +// Disable right click context menu so we can have right click tools +element1.oncontextmenu = (e) => e.preventDefault(); +element2.oncontextmenu = (e) => e.preventDefault(); +element3.oncontextmenu = (e) => e.preventDefault(); +element4.oncontextmenu = (e) => e.preventDefault(); + +// Add elements to the viewport grid +// First add the 3D viewport on the left +viewportGrid.appendChild(element4); + +// Add the 2D viewports stacked vertically on the right +rightViewportsContainer.appendChild(element1); +rightViewportsContainer.appendChild(element2); +rightViewportsContainer.appendChild(element3); +viewportGrid.appendChild(rightViewportsContainer); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Basic controls: + - Click/Drag the spheres in VRT or reference lines in the orthographic viewports. + - Rotate , pan or zoom the 3D viewport using the mouse. + - Use the scroll wheel to scroll through the slices in the orthographic viewports. + `; + +content.append(instructions); + +addToggleButtonToToolbar({ + title: 'Toggle 3D handles', + defaultToggle: false, + onClick: (toggle) => { + // Get the tool group for the 3D viewport + const toolGroupVRT = + cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupIdVRT); + // Get the VolumeCroppingTool instance from the tool group + const croppingTool = toolGroupVRT.getToolInstance('VolumeCropping'); + // Call setHandlesVisible on the tool instance + if (croppingTool && typeof croppingTool.setHandlesVisible === 'function') { + croppingTool.setHandlesVisible(!croppingTool.getHandlesVisible()); + } + }, +}); + +addToggleButtonToToolbar({ + title: 'Toggle Cropping Planes', + defaultToggle: false, + onClick: (toggle) => { + // Get the tool group for the 3D viewport + const toolGroupVRT = + cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupIdVRT); + // Get the VolumeCroppingTool instance from the tool group + const croppingTool = toolGroupVRT.getToolInstance('VolumeCropping'); + // Call setClippingPlanesVisible on the tool instance + if ( + croppingTool && + typeof croppingTool.setClippingPlanesVisible === 'function' + ) { + croppingTool.setClippingPlanesVisible( + !croppingTool.getClippingPlanesVisible() + ); + } + }, +}); + +const viewportColors = { + [viewportId1]: 'rgb(200, 0, 0)', + [viewportId2]: 'rgb(0, 200, 0)', + [viewportId3]: 'rgb(200, 200, 0)', + [viewportId4]: 'rgb(0, 200, 200)', +}; + +function getReferenceLineColor(viewportId) { + return viewportColors[viewportId]; +} + +const viewportReferenceLineControllable = [ + viewportId1, + viewportId2, + viewportId3, +]; + +/** + * Get the number of orthographic viewports from the URL (?numViewports=1|2|3) + */ +function getNumViewportsFromUrl() { + const params = new URLSearchParams(window.location.search); + const value = params.get('numViewports'); + const num = Number(value); + if ([1, 2, 3].includes(num)) { + return num; + } + return 3; // default +} + +/** + * Runs the demo with a configurable number of orthographic viewports + */ +async function run(numViewports = getNumViewportsFromUrl()) { + await initDemo(); + + cornerstoneTools.addTool(VolumeCroppingTool); + cornerstoneTools.addTool(VolumeCroppingControlTool); + cornerstoneTools.addTool(TrackballRotateTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(OrientationMarkerTool); + cornerstoneTools.addTool(StackScrollTool); + cornerstoneTools.addTool(CrosshairsTool); + + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: + getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Only include the requested number of orthographic viewports + const orthographicViewports = [ + { + viewportId: viewportId1, + type: ViewportType.ORTHOGRAPHIC, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId2, + type: ViewportType.ORTHOGRAPHIC, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0, 0, 0], + }, + }, + ].slice(0, numViewports); + + // Show/hide orthographic viewport elements based on numViewports + [element1, element2, element3].forEach((el, idx) => { + if (idx < numViewports) { + el.style.display = 'block'; + el.style.height = `${100 / numViewports}%`; + } else { + el.style.display = 'none'; + } + }); + + // Always set viewport4 (3D viewport) orientation to CORONAL + const viewportInputArray = [ + ...orthographicViewports, + { + viewportId: viewportId4, + type: ViewportType.VOLUME_3D, + element: element4, + defaultOptions: { + background: [0, 0, 0], + orientation: Enums.OrientationAxis.CORONAL, + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + volume.load(); + + // Only set volumes for the active viewport IDs + const activeViewportIds = [ + ...orthographicViewports.map((vp) => vp.viewportId), + viewportId4, + ]; + await setVolumesForViewports( + renderingEngine, + [ + { + volumeId, + callback: setCtTransferFunctionForVolumeActor, + }, + ], + activeViewportIds + ); + + // Tool group for orthographic viewports + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + orthographicViewports.forEach((vp) => { + toolGroup.addViewport(vp.viewportId, renderingEngineId); + }); + + toolGroup.addTool(VolumeCroppingControlTool.toolName, { + getReferenceLineColor, + viewportIndicators: true, + }); + toolGroup.setToolActive(VolumeCroppingControlTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, + }, + ], + }); + toolGroup.addTool(StackScrollTool.toolName, { + viewportIndicators: true, + }); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Wheel, + }, + { + mouseButton: MouseBindings.Secondary, + }, + ], + }); + + // Tool group for 3D viewport + const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); + toolGroupVRT.addTool(ZoomTool.toolName); + toolGroupVRT.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, + }, + ], + }); + toolGroupVRT.addTool(PanTool.toolName); + toolGroupVRT.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, + }, + ], + }); + toolGroupVRT.addTool(OrientationMarkerTool.toolName, { + overlayMarkerType: + OrientationMarkerTool.OVERLAY_MARKER_TYPES.ANNOTATED_CUBE, + }); + // toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); + + const isMobile = window.matchMedia('(any-pointer:coarse)').matches; + const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; + renderingEngine.renderViewports(activeViewportIds); + await setVolumesForViewports( + renderingEngine, + [{ volumeId }], + [viewportId4] + ).then(() => { + viewport.setProperties({ + preset: 'CT-Bone', + }); + toolGroupVRT.addViewport(viewportId4, renderingEngineId); + toolGroupVRT.addTool(VolumeCroppingTool.toolName, { + sphereRadius: 7, + sphereColors: { + x: [1, 1, 0], + y: [0, 1, 0], + z: [1, 0, 0], + corners: [0, 0, 1], + }, + showCornerSpheres: true, + initialCropFactor: 0.2, + }); + toolGroupVRT.setToolActive(VolumeCroppingTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, + }, + ], + }); + viewport.setZoom(1.2); + viewport.render(); + }); +} + +run(); diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index 6fce4fd7c2..f32a532921 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -35,6 +35,10 @@ enum Events { CROSSHAIR_TOOL_CENTER_CHANGED = 'CORNERSTONE_TOOLS_CROSSHAIR_TOOL_CENTER_CHANGED', + VOLUMECROPPINGCONTROL_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPINGCONTROL_TOOL_CHANGED', + + VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', + /////////////////////////////////////// // Annotations /////////////////////////////////////// diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index fb449d1e54..670f962cff 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -28,6 +28,8 @@ import { AnnotationDisplayTool, PanTool, TrackballRotateTool, + VolumeCroppingTool, + VolumeCroppingControlTool, DragProbeTool, WindowLevelTool, ZoomTool, @@ -106,6 +108,8 @@ export { PanTool, SegmentBidirectionalTool, TrackballRotateTool, + VolumeCroppingTool, + VolumeCroppingControlTool, DragProbeTool, WindowLevelTool, WindowLevelRegionTool, diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts new file mode 100644 index 0000000000..379c8818b0 --- /dev/null +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -0,0 +1,1937 @@ +import { vec2, vec3 } from 'gl-matrix'; +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; + +import { AnnotationTool } from './base'; + +import type { Types } from '@cornerstonejs/core'; +import { + getRenderingEngine, + getEnabledElementByIds, + getEnabledElement, + utilities as csUtils, + Enums, + CONSTANTS, + triggerEvent, + eventTarget, +} from '@cornerstonejs/core'; + +import { + getToolGroup, + getToolGroupForViewport, +} from '../store/ToolGroupManager'; + +import { + addAnnotation, + getAnnotations, + removeAnnotation, +} from '../stateManagement/annotation/annotationState'; + +import { + drawCircle as drawCircleSvg, + drawLine as drawLineSvg, +} from '../drawingSvg'; +import { state } from '../store/state'; +import { Events } from '../enums'; +import { getViewportIdsWithToolToRender } from '../utilities/viewportFilters'; +import { + resetElementCursor, + hideElementCursor, +} from '../cursors/elementCursor'; +import liangBarksyClip from '../utilities/math/vec2/liangBarksyClip'; + +import * as lineSegment from '../utilities/math/line'; +import type { + Annotation, + Annotations, + EventTypes, + ToolHandle, + PublicToolProps, + ToolProps, + InteractionTypes, + SVGDrawingHelper, +} from '../types'; +import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; +import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; + +const { RENDERING_DEFAULTS } = CONSTANTS; + +type ReferenceLine = [ + viewport: { + id: string; + canvas?: HTMLCanvasElement; + canvasToWorld?: (...args: unknown[]) => Types.Point3; + }, + startPoint: Types.Point2, + endPoint: Types.Point2, + type: 'min' | 'max' +]; + +interface VolumeCroppingAnnotation extends Annotation { + data: { + handles: { + activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles + toolCenter: Types.Point3; + toolCenterMin: Types.Point3; + toolCenterMax: Types.Point3; + }; + activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated + viewportId: string; + referenceLines: ReferenceLine[]; // set in renderAnnotation + clippingPlanes?: vtkPlane[]; // clipping planes for the viewport + clippingPlaneReferenceLines?: ReferenceLine[]; + orientation?: string; // AXIAL, CORONAL, SAGITTAL + }; + isVirtual?: boolean; + virtualNormal?: Types.Point3; +} + +function defaultReferenceLineColor() { + return 'rgb(0, 200, 0)'; +} + +function defaultReferenceLineControllable() { + return true; +} + +const OPERATION = { + DRAG: 1, + ROTATE: 2, + SLAB: 3, +}; + +/** + * VolumeCroppingControlTool provides interactive reference lines to modify the cropping planes + * of the VolumeCroppingTool. It renders reference lines across 1 to 3 orthographic viewports and allows + * users to drag these lines to adjust volume cropping boundaries in real-time. + * + * @remarks + * This tool has no standalone functionality and must be used in conjunction with a VolumeCroppingTool that will be receiving volume. + * Messaging between this tool and the main cropping tool is handled through Cornerstone events that are validated by the series instance UID of the volume. + * Therefore the tool does not need to be in the same tool group as the volume cropping tool and + * multiple cropping & control instances can be used on different series in the same display. + * + * @example + * ```typescript + * // Basic setup + * const toolGroup = ToolGroupManager.createToolGroup('myToolGroup'); + * toolGroup.addTool(VolumeCroppingControlTool.toolName); + * toolGroup.addTool(VolumeCroppingTool.toolName); + * + * // Configure with custom colors and settings + * toolGroup.setToolConfiguration(VolumeCroppingControlTool.toolName, { + * lineColors: { + * AXIAL: [1.0, 0.0, 0.0], // Red for axial views + * CORONAL: [0.0, 1.0, 0.0], // Green for coronal views + * SAGITTAL: [1.0, 1.0, 0.0], // Yellow for sagittal views + * }, + * lineWidth: 2.0, + * extendReferenceLines: true, + * viewportIndicators: true + * }); + * + * // Activate the tool + * toolGroup.setToolActive(VolumeCroppingControlTool.toolName); + * ``` + * + * @public + * @class VolumeCroppingControlTool + * @extends AnnotationTool + * + * @property {string} seriesInstanceUID - Frame of reference for the tool + * @property {VolumeCroppingAnnotation[]} _virtualAnnotations - Store virtual annotations for missing viewport orientations (e.g., CT_CORONAL when only axial and sagittal are present) + * @property {string} toolName - Static tool identifier: 'VolumeCroppingControl' + * @property {Array} sphereStates - Array of sphere state objects for 3D volume manipulation handles + * @property {number|null} draggingSphereIndex - Index of currently dragged sphere, null when not dragging + * @property {Types.Point3} toolCenter - Center point of the cropping volume in world coordinates [x, y, z] + * @property {Types.Point3} toolCenterMin - Minimum bounds of the cropping volume in world coordinates [xMin, yMin, zMin] + * @property {Types.Point3} toolCenterMax - Maximum bounds of the cropping volume in world coordinates [xMax, yMax, zMax] + * @property {Function} _getReferenceLineColor - Optional callback to determine reference line color per viewport + * @property {Function} _getReferenceLineControllable - Optional callback to determine if reference lines are interactive per viewport + * + * @configuration + * @property {boolean} viewportIndicators - Whether to show colored circle indicators in viewport corners (default: false) + * @property {Object} viewportIndicatorsConfig - Configuration for viewport indicators + * @property {number} viewportIndicatorsConfig.radius - Radius of indicator circles in pixels (default: 5) + * @property {number|null} viewportIndicatorsConfig.x - X position offset, null for auto-positioning + * @property {number|null} viewportIndicatorsConfig.y - Y position offset, null for auto-positioning + * @property {number} viewportIndicatorsConfig.xOffset - X position as fraction of viewport width (default: 0.95) + * @property {number} viewportIndicatorsConfig.yOffset - Y position as fraction of viewport height (default: 0.05) + * @property {number} viewportIndicatorsConfig.circleRadius - Circle radius as fraction of diagonal length + * @property {boolean} extendReferenceLines - Whether to extend reference lines beyond intersection points with dashed lines (default: true) + * @property {number} initialCropFactor - Initial cropping factor as percentage of volume bounds (default: 0.2) + * @property {Object} mobile - Mobile-specific configuration + * @property {boolean} mobile.enabled - Enable mobile touch interactions (default: false) + * @property {number} mobile.opacity - Opacity for mobile interactions (default: 0.8) + * @property {Object} lineColors - Color configuration for different viewport orientations + * @property {number[]} lineColors.AXIAL - RGB color array for axial viewport lines [r, g, b] (default: [1.0, 0.0, 0.0]) + * @property {number[]} lineColors.CORONAL - RGB color array for coronal viewport lines [r, g, b] (default: [0.0, 1.0, 0.0]) + * @property {number[]} lineColors.SAGITTAL - RGB color array for sagittal viewport lines [r, g, b] (default: [1.0, 1.0, 0.0]) + * @property {number[]} lineColors.UNKNOWN - RGB color array for unknown orientation lines [r, g, b] (default: [0.0, 0.0, 1.0]) + * @property {number} lineWidth - Default width of reference lines in pixels (default: 1.5) + * @property {number} lineWidthActive - Width of reference lines when actively dragging in pixels (default: 2.5) + * @property {number} activeLineWidth - Alias for lineWidthActive for backward compatibility + + * @events + * @event VOLUMECROPPINGCONTROL_TOOL_CHANGED - Fired when reference lines are dragged or tool state changes + * @event VOLUMECROPPING_TOOL_CHANGED - Listens for changes from the main VolumeCroppingTool to synchronize state + * + * + * @limitations + * - Does not function independently without VolumeCroppingTool + * - Requires volume data to be loaded before activation + * - Limited to orthogonal viewport orientations (axial, coronal, sagittal)l + */ +class VolumeCroppingControlTool extends AnnotationTool { + // Store virtual annotations (e.g., for missing orientations like CT_CORONAL) + _virtualAnnotations: VolumeCroppingAnnotation[] = []; + static toolName; + seriesInstanceUID?: string; + sphereStates: { + point: Types.Point3; + axis: string; + uid: string; + sphereSource; + sphereActor; + }[] = []; + draggingSphereIndex: number | null = null; + toolCenter: Types.Point3 = [0, 0, 0]; + toolCenterMin: Types.Point3 = [0, 0, 0]; + toolCenterMax: Types.Point3 = [0, 0, 0]; + _getReferenceLineColor?: (viewportId: string) => string; + _getReferenceLineControllable?: (viewportId: string) => boolean; + constructor( + toolProps: PublicToolProps = {}, + defaultToolProps: ToolProps = { + supportedInteractionTypes: ['Mouse'], + configuration: { + // renders a colored circle on top right of the viewports whose color + // matches the color of the reference line + viewportIndicators: false, + viewportIndicatorsConfig: { + radius: 5, + x: null, + y: null, + }, + extendReferenceLines: true, + initialCropFactor: 0.2, + mobile: { + enabled: false, + opacity: 0.8, + }, + lineColors: { + AXIAL: [1.0, 0.0, 0.0], // Red for axial + CORONAL: [0.0, 1.0, 0.0], // Green for coronal + SAGITTAL: [1.0, 1.0, 0.0], // Yellow for sagittal + UNKNOWN: [0.0, 0.0, 1.0], // Blue for unknown + }, + lineWidth: 1.5, + lineWidthActive: 2.5, + }, + } + ) { + super(toolProps, defaultToolProps); + + this._getReferenceLineColor = + toolProps.configuration?.getReferenceLineColor || + defaultReferenceLineColor; + this._getReferenceLineControllable = + toolProps.configuration?.getReferenceLineControllable || + defaultReferenceLineControllable; + + const viewportsInfo = getToolGroup(this.toolGroupId)?.viewportsInfo; + + eventTarget.addEventListener( + Events.VOLUMECROPPING_TOOL_CHANGED, + this._onSphereMoved + ); + + if (viewportsInfo && viewportsInfo.length > 0) { + const { viewportId, renderingEngineId } = viewportsInfo[0]; + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId); + const volumeActors = viewport.getActors(); + if (!volumeActors || !volumeActors.length) { + console.warn( + `VolumeCroppingControlTool: No volume actors found in viewport ${viewportId}.` + ); + return; + } + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (imageData) { + const dimensions = imageData.getDimensions(); + const spacing = imageData.getSpacing(); + const origin = imageData.getOrigin(); + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; + const cropFactor = this.configuration.initialCropFactor ?? 0.2; + this.toolCenter = [ + origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0], + origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1], + origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2], + ]; + const maxCropFactor = 1 - cropFactor; + this.toolCenterMin = [ + origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0], + origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1], + origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2], + ]; + this.toolCenterMax = [ + origin[0] + maxCropFactor * (dimensions[0] - 1) * spacing[0], + origin[1] + maxCropFactor * (dimensions[1] - 1) * spacing[1], + origin[2] + maxCropFactor * (dimensions[2] - 1) * spacing[2], + ]; + } + } + } + + _updateToolCentersFromViewport(viewport) { + const volumeActors = viewport.getActors(); + if (!volumeActors || !volumeActors.length) { + return; + } + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (!imageData) { + return; + } + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; + const dimensions = imageData.getDimensions(); + const spacing = imageData.getSpacing(); + const origin = imageData.getOrigin(); + const cropFactor = this.configuration.initialCropFactor ?? 0.2; + const cropStart = cropFactor / 2; + const cropEnd = 1 - cropFactor / 2; + this.toolCenter = [ + origin[0] + + ((cropStart + cropEnd) / 2) * (dimensions[0] - 1) * spacing[0], + origin[1] + + ((cropStart + cropEnd) / 2) * (dimensions[1] - 1) * spacing[1], + origin[2] + + ((cropStart + cropEnd) / 2) * (dimensions[2] - 1) * spacing[2], + ]; + this.toolCenterMin = [ + origin[0] + cropStart * (dimensions[0] - 1) * spacing[0], + origin[1] + cropStart * (dimensions[1] - 1) * spacing[1], + origin[2] + cropStart * (dimensions[2] - 1) * spacing[2], + ]; + this.toolCenterMax = [ + origin[0] + cropEnd * (dimensions[0] - 1) * spacing[0], + origin[1] + cropEnd * (dimensions[1] - 1) * spacing[1], + origin[2] + cropEnd * (dimensions[2] - 1) * spacing[2], + ]; + } + /** + * Gets the camera from the viewport, and adds annotation for the viewport + * to the annotationManager. If any annotation is found in the annotationManager, it + * overwrites it. + * @param viewportInfo - The viewportInfo for the viewport + * @returns viewPlaneNormal and center of viewport canvas in world space + */ + initializeViewport = ({ + renderingEngineId, + viewportId, + }: Types.IViewportId): { + normal: Types.Point3; + point: Types.Point3; + } => { + if (!renderingEngineId || !viewportId) { + console.warn( + 'VolumeCroppingControlTool: Missing renderingEngineId or viewportId' + ); + return; + } + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!enabledElement) { + return; + } + + const { viewport } = enabledElement; + this._updateToolCentersFromViewport(viewport); + const { element } = viewport; + const { position, focalPoint, viewPlaneNormal } = viewport.getCamera(); + + // Check if there is already annotation for this viewport + let annotations = this._getAnnotations(enabledElement); + annotations = this.filterInteractableAnnotationsForElement( + element, + annotations + ); + + if (annotations?.length) { + // If found, it will override it by removing the annotation and adding it later + removeAnnotation(annotations[0].annotationUID); + } + + // Determine orientation from camera normal, fallback to viewportId string + const orientation = this._getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + + const annotation = { + highlighted: false, + metadata: { + cameraPosition: [...position], + cameraFocalPoint: [...focalPoint], + toolName: this.getToolName(), + }, + data: { + handles: { + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeOperation: null, // 0 translation, 1 rotation handles, 2 slab thickness handles + activeViewportIds: [], // a list of the viewport ids connected to the reference lines being translated + viewportId, + referenceLines: [], // set in renderAnnotation + orientation, + }, + }; + + addAnnotation(annotation, element); + return { + normal: viewPlaneNormal, + point: viewport.canvasToWorld([100, 100]), + }; + }; + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + return viewports; + }; + + onSetToolInactive() { + console.debug( + `VolumeCroppingControlTool: onSetToolInactive called for tool ${this.getToolName()}` + ); + } + + onSetToolActive() { + // console.debug( + // `VolumeCroppingControlTool: onSetToolActive called for tool ${this.getToolName()}` + // ); + const viewportsInfo = this._getViewportsInfo(); + + // Check if any annotation exists before proceeding + let anyAnnotationExists = false; + for (const vpInfo of viewportsInfo) { + const enabledElement = getEnabledElementByIds( + vpInfo.viewportId, + vpInfo.renderingEngineId + ); + const annotations = this._getAnnotations(enabledElement); + if (annotations && annotations.length > 0) { + anyAnnotationExists = true; + break; + } + } + if (!anyAnnotationExists) { + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + this._subscribeToViewportNewVolumeSet(viewportsInfo); + // Request the volume cropping tool to send current planes + this._computeToolCenter(viewportsInfo); + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + toolGroupId: this.toolGroupId, + viewportsInfo: viewportsInfo, + seriesInstanceUID: this.seriesInstanceUID, + }); + } else { + // Turn off visibility of existing annotations + for (const vpInfo of viewportsInfo) { + const enabledElement = getEnabledElementByIds( + vpInfo.viewportId, + vpInfo.renderingEngineId + ); + + if (!enabledElement) { + continue; + } + + const annotations = this._getAnnotations(enabledElement); + if (annotations && annotations.length > 0) { + annotations.forEach((annotation) => { + removeAnnotation(annotation.annotationUID); + }); + } + + // Render after removing annotations to clear reference lines + enabledElement.viewport.render(); + } + } + } + + onSetToolEnabled() { + console.debug( + `VolumeCroppingControlTool: onSetToolEnabled called for tool ${this.getToolName()}` + ); + const viewportsInfo = this._getViewportsInfo(); + + //this._computeToolCenter(viewportsInfo); + } + + onSetToolDisabled() { + console.debug( + `VolumeCroppingControlTool: onSetToolDisabled called for tool ${this.getToolName()}` + ); + const viewportsInfo = this._getViewportsInfo(); + + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + + // has no value when the tool is disabled + // since viewports can change (zoom, pan, scroll) + // between disabled and enabled/active states. + // so we just remove the annotations from the state + viewportsInfo.forEach(({ renderingEngineId, viewportId }) => { + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + + if (!enabledElement) { + return; + } + + const annotations = this._getAnnotations(enabledElement); + if (annotations?.length) { + annotations.forEach((annotation) => { + removeAnnotation(annotation.annotationUID); + }); + } + }); + } + + resetCroppingSpheres = () => { + const viewportsInfo = this._getViewportsInfo(); + for (const viewportInfo of viewportsInfo) { + const { viewportId, renderingEngineId } = viewportInfo; + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const viewport = enabledElement.viewport as Types.IVolumeViewport; + const resetPan = true; + const resetZoom = true; + const resetToCenter = true; + const resetRotation = true; + const suppressEvents = true; + viewport.resetCamera({ + resetPan, + resetZoom, + resetToCenter, + resetRotation, + suppressEvents, + }); + (viewport as Types.IVolumeViewport).resetSlabThickness(); + const { element } = viewport; + let annotations = this._getAnnotations(enabledElement); + annotations = this.filterInteractableAnnotationsForElement( + element, + annotations + ); + if (annotations.length) { + removeAnnotation(annotations[0].annotationUID); + } + viewport.render(); + } + + this._computeToolCenter(viewportsInfo); + }; + + computeToolCenter = () => { + const viewportsInfo = this._getViewportsInfo(); + }; + + _computeToolCenter = (viewportsInfo): void => { + if (!viewportsInfo || !viewportsInfo[0]) { + console.warn( + ' _computeToolCenter : No valid viewportsInfo for computeToolCenter.' + ); + return; + } + // Support any missing orientation + const orientationIds = ['AXIAL', 'CORONAL', 'SAGITTAL']; + // Get present orientations from viewportsInfo + const presentOrientations = viewportsInfo + .map((vp) => { + if (vp.renderingEngineId) { + const renderingEngine = getRenderingEngine(vp.renderingEngineId); + const viewport = renderingEngine.getViewport(vp.viewportId); + if (viewport && viewport.getCamera) { + const orientation = this._getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + if (orientation) { + return orientation; + } + } + } + return null; + }) + .filter(Boolean); + + const missingOrientation = orientationIds.find( + (id) => !presentOrientations.includes(id) + ); + + // Initialize present viewports + + const presentNormals: Types.Point3[] = []; + const presentCenters: Types.Point3[] = []; + // Find present viewport infos by matching orientation, not viewportId + const presentViewportInfos = viewportsInfo.filter((vp) => { + let orientation = null; + if (vp.renderingEngineId) { + const renderingEngine = getRenderingEngine(vp.renderingEngineId); + const viewport = renderingEngine.getViewport(vp.viewportId); + if (viewport && viewport.getCamera) { + orientation = this._getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + } + } + return orientation && orientationIds.includes(orientation); + }); + presentViewportInfos.forEach((vpInfo) => { + const { normal, point } = this.initializeViewport(vpInfo); + presentNormals.push(normal); + presentCenters.push(point); + }); + + // If all three orientations are present, nothing to synthesize + if (presentViewportInfos.length === 2 && missingOrientation) { + // Synthesize virtual annotation for the missing orientation + const virtualNormal: Types.Point3 = [0, 0, 0]; + vec3.cross(virtualNormal, presentNormals[0], presentNormals[1]); + vec3.normalize(virtualNormal, virtualNormal); + const virtualCenter: Types.Point3 = [ + (presentCenters[0][0] + presentCenters[1][0]) / 2, + (presentCenters[0][1] + presentCenters[1][1]) / 2, + (presentCenters[0][2] + presentCenters[1][2]) / 2, + ]; + const orientation = null; + const virtualAnnotation: VolumeCroppingAnnotation = { + highlighted: false, + metadata: { + cameraPosition: [...virtualCenter], + cameraFocalPoint: [...virtualCenter], + toolName: this.getToolName(), + }, + data: { + handles: { + activeOperation: null, + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeViewportIds: [], + viewportId: missingOrientation, + referenceLines: [], + orientation, + }, + isVirtual: true, + virtualNormal, + }; + this._virtualAnnotations = [virtualAnnotation]; + } else if (presentViewportInfos.length === 1) { + // Synthesize two virtual annotations for the two missing orientations + // Get present orientation from camera normal + let presentOrientation = null; + const vpInfo = presentViewportInfos[0]; + if (vpInfo.renderingEngineId) { + const renderingEngine = getRenderingEngine(vpInfo.renderingEngineId); + const viewport = renderingEngine.getViewport(vpInfo.viewportId); + if (viewport && viewport.getCamera) { + presentOrientation = this._getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + } + } + const presentCenter = presentCenters[0]; + // Map canonical normals to orientation strings + const canonicalNormals = { + AXIAL: [0, 0, 1], + CORONAL: [0, 1, 0], + SAGITTAL: [1, 0, 0], + }; + // missingIds: AXIAL, CORONAL, SAGITTAL + const missingIds = orientationIds.filter( + (id) => id !== presentOrientation + ); + const virtualAnnotations: VolumeCroppingAnnotation[] = missingIds.map( + (orientation) => { + // Use orientation string to get canonical normal + const normal = canonicalNormals[orientation]; + const virtualAnnotation = { + highlighted: false, + metadata: { + cameraPosition: [...presentCenter], + cameraFocalPoint: [...presentCenter], + toolName: this.getToolName(), + }, + data: { + handles: { + activeOperation: null, + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeViewportIds: [], + viewportId: orientation, // Use orientation string for virtual annotation + referenceLines: [], + orientation, + }, + isVirtual: true, + virtualNormal: normal, + }; + + return virtualAnnotation; + } + ); + this._virtualAnnotations = virtualAnnotations; + } + + if (viewportsInfo && viewportsInfo.length) { + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + } + }; + /** + * Utility function to map a camera normal to an orientation string. + * Returns 'AXIAL', 'CORONAL', 'SAGITTAL', or null if not matched. + */ + _getOrientationFromNormal(normal: Types.Point3): string | null { + if (!normal) { + return null; + } + // Canonical normals + const canonical = { + AXIAL: [0, 0, 1], + CORONAL: [0, 1, 0], + SAGITTAL: [1, 0, 0], + }; + // Use a tolerance for floating point comparison + const tol = 1e-2; + for (const [key, value] of Object.entries(canonical)) { + if ( + Math.abs(normal[0] - value[0]) < tol && + Math.abs(normal[1] - value[1]) < tol && + Math.abs(normal[2] - value[2]) < tol + ) { + return key; + } + // Also check negative direction + if ( + Math.abs(normal[0] + value[0]) < tol && + Math.abs(normal[1] + value[1]) < tol && + Math.abs(normal[2] + value[2]) < tol + ) { + return key; + } + } + return null; + } + _syncWithVolumeCroppingTool(originalClippingPlanes) { + // Sync our tool centers with the clipping plane bounds + const planes = originalClippingPlanes; + if (planes.length >= 6) { + this.toolCenterMin = [ + planes[0].origin[0], // XMIN + planes[2].origin[1], // YMIN + planes[4].origin[2], // ZMIN + ]; + this.toolCenterMax = [ + planes[1].origin[0], // XMAX + planes[3].origin[1], // YMAX + planes[5].origin[2], // ZMAX + ]; + this.toolCenter = [ + (this.toolCenterMin[0] + this.toolCenterMax[0]) / 2, + (this.toolCenterMin[1] + this.toolCenterMax[1]) / 2, + (this.toolCenterMin[2] + this.toolCenterMax[2]) / 2, + ]; + + // Update annotations based on their specific orientation + const viewportsInfo = this._getViewportsInfo(); + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (enabledElement) { + const annotations = this._getAnnotations(enabledElement); + annotations.forEach((annotation) => { + if ( + annotation.data && + annotation.data.handles && + annotation.data.orientation + ) { + const orientation = annotation.data.orientation; + + // Update tool centers based on the specific orientation + if (orientation === 'AXIAL') { + // Axial views see X and Y clipping planes + annotation.data.handles.toolCenterMin = [ + planes[0].origin[0], // XMIN + planes[2].origin[1], // YMIN + annotation.data.handles.toolCenterMin[2], // Keep existing Z + ]; + annotation.data.handles.toolCenterMax = [ + planes[1].origin[0], // XMAX + planes[3].origin[1], // YMAX + annotation.data.handles.toolCenterMax[2], // Keep existing Z + ]; + } else if (orientation === 'CORONAL') { + // Coronal views see X and Z clipping planes + annotation.data.handles.toolCenterMin = [ + planes[0].origin[0], // XMIN + annotation.data.handles.toolCenterMin[1], // Keep existing Y + planes[4].origin[2], // ZMIN + ]; + annotation.data.handles.toolCenterMax = [ + planes[1].origin[0], // XMAX + annotation.data.handles.toolCenterMax[1], // Keep existing Y + planes[5].origin[2], // ZMAX + ]; + } else if (orientation === 'SAGITTAL') { + // Sagittal views see Y and Z clipping planes + annotation.data.handles.toolCenterMin = [ + annotation.data.handles.toolCenterMin[0], // Keep existing X + planes[2].origin[1], // YMIN + planes[4].origin[2], // ZMIN + ]; + annotation.data.handles.toolCenterMax = [ + annotation.data.handles.toolCenterMax[0], // Keep existing X + planes[3].origin[1], // YMAX + planes[5].origin[2], // ZMAX + ]; + } + + // Update the tool center as midpoint + annotation.data.handles.toolCenter = [ + (annotation.data.handles.toolCenterMin[0] + + annotation.data.handles.toolCenterMax[0]) / + 2, + (annotation.data.handles.toolCenterMin[1] + + annotation.data.handles.toolCenterMax[1]) / + 2, + (annotation.data.handles.toolCenterMin[2] + + annotation.data.handles.toolCenterMax[2]) / + 2, + ]; + } + }); + } + }); + + // Update virtual annotations as well + if (this._virtualAnnotations && this._virtualAnnotations.length > 0) { + this._virtualAnnotations.forEach((annotation) => { + if ( + annotation.data && + annotation.data.handles && + annotation.data.orientation + ) { + const orientation = annotation.data.orientation.toUpperCase(); + + // Apply the same orientation-specific logic to virtual annotations + if (orientation === 'AXIAL') { + annotation.data.handles.toolCenterMin = [ + planes[0].origin[0], // XMIN + planes[2].origin[1], // YMIN + annotation.data.handles.toolCenterMin[2], + ]; + annotation.data.handles.toolCenterMax = [ + planes[1].origin[0], // XMAX + planes[3].origin[1], // YMAX + annotation.data.handles.toolCenterMax[2], + ]; + } else if (orientation === 'CORONAL') { + annotation.data.handles.toolCenterMin = [ + planes[0].origin[0], // XMIN + annotation.data.handles.toolCenterMin[1], + planes[4].origin[2], // ZMIN + ]; + annotation.data.handles.toolCenterMax = [ + planes[1].origin[0], // XMAX + annotation.data.handles.toolCenterMax[1], + planes[5].origin[2], // ZMAX + ]; + } else if (orientation === 'SAGITTAL') { + annotation.data.handles.toolCenterMin = [ + annotation.data.handles.toolCenterMin[0], + planes[2].origin[1], // YMIN + planes[4].origin[2], // ZMIN + ]; + annotation.data.handles.toolCenterMax = [ + annotation.data.handles.toolCenterMax[0], + planes[3].origin[1], // YMAX + planes[5].origin[2], // ZMAX + ]; + } + + annotation.data.handles.toolCenter = [ + (annotation.data.handles.toolCenterMin[0] + + annotation.data.handles.toolCenterMax[0]) / + 2, + (annotation.data.handles.toolCenterMin[1] + + annotation.data.handles.toolCenterMax[1]) / + 2, + (annotation.data.handles.toolCenterMin[2] + + annotation.data.handles.toolCenterMax[2]) / + 2, + ]; + } + }); + } + + // Trigger re-render to show updated reference lines + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + } + } + + setToolCenter(toolCenter: Types.Point3, handleType): void { + if (handleType === 'min') { + this.toolCenterMin = [...toolCenter]; + } else if (handleType === 'max') { + this.toolCenterMax = [...toolCenter]; + } + const viewportsInfo = this._getViewportsInfo(); + + // assuming all viewports are in the same rendering engine + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + } + + /** + * addNewAnnotation is called when the user clicks on the image. + * It does not store the annotation in the stateManager though. + * + * @param evt - The mouse event + * @param interactionType - The type of interaction (e.g., mouse, touch, etc.) + * @returns annotation + */ + + addNewAnnotation( + evt: EventTypes.InteractionEventType + ): VolumeCroppingAnnotation { + const eventDetail = evt.detail; + const { element } = eventDetail; + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); + + // Guard clause: if no interactable annotation, return null + if ( + !filteredAnnotations || + filteredAnnotations.length === 0 || + !filteredAnnotations[0] + ) { + return null; + } + + const { data } = filteredAnnotations[0]; + + const viewportIdArray = []; + // put all the draggable reference lines in the viewportIdArray + + const referenceLines = data.referenceLines || []; + for (let i = 0; i < referenceLines.length; ++i) { + const otherViewport = referenceLines[i][0]; + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + if (!viewportControllable) { + continue; + } + viewportIdArray.push(otherViewport.id); + i++; + } + + data.activeViewportIds = [...viewportIdArray]; + // set translation operation + data.handles.activeOperation = OPERATION.DRAG; + + evt.preventDefault(); + + hideElementCursor(element); + + this._activateModify(element); + return filteredAnnotations[0]; + } + + cancel = () => { + console.log('Not implemented yet'); + }; + + /** + * It returns if the canvas point is near the provided volume cropping annotation in the + * provided element or not. A proximity is passed to the function to determine the + * proximity of the point to the annotation in number of pixels. + * + * @param element - HTML Element + * @param annotation - Annotation + * @param canvasCoords - Canvas coordinates + * @param proximity - Proximity to tool to consider + * @returns Boolean, whether the canvas point is near tool + */ + isPointNearTool = ( + element: HTMLDivElement, + annotation: VolumeCroppingAnnotation, + canvasCoords: Types.Point2, + proximity: number + ): boolean => { + if (this._pointNearTool(element, annotation, canvasCoords, 6)) { + return true; + } + + return false; + }; + + toolSelectedCallback = ( + evt: EventTypes.InteractionEventType, + annotation: Annotation, + interactionType: InteractionTypes + ): void => { + const eventDetail = evt.detail; + const { element } = eventDetail; + annotation.highlighted = true; + this._activateModify(element); + + hideElementCursor(element); + + evt.preventDefault(); + }; + + handleSelectedCallback( + evt: EventTypes.InteractionEventType, + annotation: Annotation, + handle: ToolHandle, + interactionType: InteractionTypes + ): void { + // You can customize this logic as needed + // For now, just call toolSelectedCallback if you want default behavior + this.toolSelectedCallback(evt, annotation, interactionType); + } + + onResetCamera = (evt) => { + this.resetCroppingSpheres(); + }; + + mouseMoveCallback = ( + evt: EventTypes.MouseMoveEventType, + filteredToolAnnotations: Annotations + ): boolean => { + if (!filteredToolAnnotations) { + return; + } + const { element, currentPoints } = evt.detail; + const canvasCoords = currentPoints.canvas; + let imageNeedsUpdate = false; + + for (let i = 0; i < filteredToolAnnotations.length; i++) { + const annotation = filteredToolAnnotations[i] as VolumeCroppingAnnotation; + + if (isAnnotationLocked(annotation.annotationUID)) { + continue; + } + + const { data, highlighted } = annotation; + if (!data.handles) { + continue; + } + + const previousActiveOperation = data.handles.activeOperation; + const previousActiveViewportIds = + data.activeViewportIds && data.activeViewportIds.length > 0 + ? [...data.activeViewportIds] + : []; + + // This init are necessary, because when we move the mouse they are not cleaned by _endCallback + data.activeViewportIds = []; + let near = false; + near = this._pointNearTool(element, annotation, canvasCoords, 6); + + const nearToolAndNotMarkedActive = near && !highlighted; + const notNearToolAndMarkedActive = !near && highlighted; + if (nearToolAndNotMarkedActive || notNearToolAndMarkedActive) { + annotation.highlighted = !highlighted; + imageNeedsUpdate = true; + } + } + + return imageNeedsUpdate; + }; + + filterInteractableAnnotationsForElement = (element, annotations) => { + if (!annotations || !annotations.length) { + return []; + } + + const enabledElement = getEnabledElement(element); + // Use orientation property for matching + let orientation = null; + if (enabledElement.viewport && enabledElement.viewport.getCamera) { + orientation = this._getOrientationFromNormal( + enabledElement.viewport.getCamera().viewPlaneNormal + ); + } + + // Filter annotations for this orientation, including virtual annotations + const filtered = annotations.filter((annotation) => { + // Always include virtual annotations for reference line rendering + if (annotation.isVirtual) { + return true; + } + // Match by orientation property + if ( + annotation.data.orientation && + orientation && + annotation.data.orientation === orientation + ) { + return true; + } + return false; + }); + + return filtered; + }; + + /** + * renders the volume cropping lines and handles in the requestAnimationFrame callback + * + * @param enabledElement - The Cornerstone's enabledElement. + * @param svgDrawingHelper - The svgDrawingHelper providing the context for drawing. + */ + renderAnnotation = ( + enabledElement: Types.IEnabledElement, + svgDrawingHelper: SVGDrawingHelper + ): boolean => { + function lineIntersection2D(p1, p2, q1, q2) { + const s1_x = p2[0] - p1[0]; + const s1_y = p2[1] - p1[1]; + const s2_x = q2[0] - q1[0]; + const s2_y = q2[1] - q1[1]; + const denom = -s2_x * s1_y + s1_x * s2_y; + if (Math.abs(denom) < 1e-8) { + return null; + } // Parallel + const s = (-s1_y * (p1[0] - q1[0]) + s1_x * (p1[1] - q1[1])) / denom; + const t = (s2_x * (p1[1] - q1[1]) - s2_y * (p1[0] - q1[0])) / denom; + if (s >= 0 && s <= 1 && t >= 0 && t <= 1) { + return [p1[0] + t * s1_x, p1[1] + t * s1_y]; + } + return null; + } + const viewportsInfo = this._getViewportsInfo(); + if (!viewportsInfo || viewportsInfo.length === 0) { + // No viewports available + return false; + } + let renderStatus = false; + const { viewport, renderingEngine } = enabledElement; + const { element } = viewport; + let annotations = this._getAnnotations(enabledElement); + // If we have virtual annotations , always include them + if (this._virtualAnnotations && this._virtualAnnotations.length) { + annotations = annotations.concat(this._virtualAnnotations); + } + const camera = viewport.getCamera(); + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); + + // viewport Annotation: use the first annotation for the current viewport + const viewportAnnotation = filteredToolAnnotations[0]; + if (!viewportAnnotation || !viewportAnnotation.data) { + // No annotation for this viewport + return renderStatus; + } + + const annotationUID = viewportAnnotation.annotationUID; + + // Get cameras/canvases for each of these. + // -- Get two world positions for this canvas in this line (e.g. the diagonal) + // -- Convert these world positions to this canvas. + // -- Extend/confine this line to fit in this canvas. + // -- Render this line. + const { clientWidth, clientHeight } = viewport.canvas; + const canvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + + const data = viewportAnnotation.data; + // Get all other annotations except the current viewport's + const otherViewportAnnotations = annotations; + + const volumeCroppingCenterCanvasMin = viewport.worldToCanvas( + this.toolCenterMin + ); + const volumeCroppingCenterCanvasMax = viewport.worldToCanvas( + this.toolCenterMax + ); + + const referenceLines = []; + + // get canvas information for points and lines (canvas box, canvas horizontal distances) + const canvasBox = [0, 0, clientWidth, clientHeight]; + + otherViewportAnnotations.forEach((annotation) => { + const data = annotation.data; + // Type guard for isVirtual property + const isVirtual = + 'isVirtual' in annotation && + (annotation as { isVirtual?: boolean }).isVirtual === true; + data.handles.toolCenter = this.toolCenter; + let otherViewport, + otherCamera, + clientWidth, + clientHeight, + otherCanvasDiagonalLength, + otherCanvasCenter, + otherViewportCenterWorld; + if (isVirtual) { + // Synthesize a virtual viewport/camera for any missing orientation + const realViewports = viewportsInfo.filter( + (vp) => vp.viewportId !== data.viewportId + ); + if (realViewports.length === 2) { + const vp1 = renderingEngine.getViewport(realViewports[0].viewportId); + const vp2 = renderingEngine.getViewport(realViewports[1].viewportId); + const normal1 = vp1.getCamera().viewPlaneNormal; + const normal2 = vp2.getCamera().viewPlaneNormal; + const virtualNormal = vec3.create(); + vec3.cross(virtualNormal, normal1, normal2); + vec3.normalize(virtualNormal, virtualNormal); + otherCamera = { + viewPlaneNormal: virtualNormal, + position: data.handles.toolCenter, + focalPoint: data.handles.toolCenter, + viewUp: [0, 1, 0], + }; + clientWidth = viewport.canvas.clientWidth; + clientHeight = viewport.canvas.clientHeight; + otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + otherCanvasCenter = [clientWidth * 0.5, clientHeight * 0.5]; + otherViewportCenterWorld = data.handles.toolCenter; + otherViewport = { + id: data.viewportId, + canvas: viewport.canvas, + canvasToWorld: () => data.handles.toolCenter, + }; + } else { + // Only one real viewport: use canonical normal from virtual annotation + const virtualNormal = (annotation as VolumeCroppingAnnotation) + .virtualNormal ?? [0, 0, 1]; + otherCamera = { + viewPlaneNormal: virtualNormal, + position: data.handles.toolCenter, + focalPoint: data.handles.toolCenter, + viewUp: [0, 1, 0], + }; + clientWidth = viewport.canvas.clientWidth; + clientHeight = viewport.canvas.clientHeight; + otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + otherCanvasCenter = [clientWidth * 0.5, clientHeight * 0.5]; + otherViewportCenterWorld = data.handles.toolCenter; + otherViewport = { + id: data.viewportId, + canvas: viewport.canvas, + canvasToWorld: () => data.handles.toolCenter, + }; + } + } else { + otherViewport = renderingEngine.getViewport(data.viewportId as string); + otherCamera = otherViewport.getCamera(); + clientWidth = otherViewport.canvas.clientWidth; + clientHeight = otherViewport.canvas.clientHeight; + otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + otherCanvasCenter = [clientWidth * 0.5, clientHeight * 0.5]; + otherViewportCenterWorld = + otherViewport.canvasToWorld(otherCanvasCenter); + } + + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + const direction = [0, 0, 0]; + vtkMath.cross( + camera.viewPlaneNormal as [number, number, number], + otherCamera.viewPlaneNormal as [number, number, number], + direction as [number, number, number] + ); + vtkMath.normalize(direction as [number, number, number]); + vtkMath.multiplyScalar( + direction as [number, number, number], + otherCanvasDiagonalLength + ); + + const pointWorld0: [number, number, number] = [0, 0, 0]; + vtkMath.add( + otherViewportCenterWorld as [number, number, number], + direction as [number, number, number], + pointWorld0 + ); + const pointWorld1: [number, number, number] = [0, 0, 0]; + vtkMath.subtract( + otherViewportCenterWorld as [number, number, number], + direction as [number, number, number], + pointWorld1 + ); + + const pointCanvas0 = viewport.worldToCanvas(pointWorld0 as Types.Point3); + const otherViewportCenterCanvas = viewport.worldToCanvas([ + otherViewportCenterWorld[0] ?? 0, + otherViewportCenterWorld[1] ?? 0, + otherViewportCenterWorld[2] ?? 0, + ] as [number, number, number] as Types.Point3); + + const canvasUnitVectorFromCenter = vec2.create(); + vec2.subtract( + canvasUnitVectorFromCenter, + pointCanvas0, + otherViewportCenterCanvas + ); + vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); + + const canvasVectorFromCenterLong = vec2.create(); + vec2.scale( + canvasVectorFromCenterLong, + canvasUnitVectorFromCenter, + canvasDiagonalLength * 100 + ); + + // For min + const refLinesCenterMin = otherViewportControllable + ? vec2.clone(volumeCroppingCenterCanvasMin) + : vec2.clone(otherViewportCenterCanvas); + const refLinePointMinOne = vec2.create(); + const refLinePointMinTwo = vec2.create(); + vec2.add( + refLinePointMinOne, + refLinesCenterMin, + canvasVectorFromCenterLong + ); + vec2.subtract( + refLinePointMinTwo, + refLinesCenterMin, + canvasVectorFromCenterLong + ); + liangBarksyClip(refLinePointMinOne, refLinePointMinTwo, canvasBox); + referenceLines.push([ + otherViewport, + refLinePointMinOne, + refLinePointMinTwo, + 'min', + ]); + + // For max center + const refLinesCenterMax = otherViewportControllable + ? vec2.clone(volumeCroppingCenterCanvasMax) + : vec2.clone(otherViewportCenterCanvas); + const refLinePointMaxOne = vec2.create(); + const refLinePointMaxTwo = vec2.create(); + vec2.add( + refLinePointMaxOne, + refLinesCenterMax, + canvasVectorFromCenterLong + ); + vec2.subtract( + refLinePointMaxTwo, + refLinesCenterMax, + canvasVectorFromCenterLong + ); + liangBarksyClip(refLinePointMaxOne, refLinePointMaxTwo, canvasBox); + referenceLines.push([ + otherViewport, + refLinePointMaxOne, + refLinePointMaxTwo, + 'max', + ]); + }); + + data.referenceLines = referenceLines; + + const viewportColor = this._getReferenceLineColor(viewport.id); + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + + referenceLines.forEach((line, lineIndex) => { + // Calculate intersections with other lines in this viewport + const intersections = []; + for (let j = 0; j < referenceLines.length; ++j) { + if (j === lineIndex) { + continue; + } + const otherLine = referenceLines[j]; + const intersection = lineIntersection2D( + line[1], + line[2], + otherLine[1], + otherLine[2] + ); + if (intersection) { + intersections.push({ + with: otherLine[3], // 'min' or 'max' + point: intersection, + }); + } + } + + // get color for the reference line using orientation + const otherViewport = line[0]; + let orientation = null; + // Try to get orientation from annotation data or viewportId + if (otherViewport && otherViewport.id) { + // Try to get from annotation if available + const annotationForViewport = annotations.find( + (a) => a.data.viewportId === otherViewport.id + ); + if (annotationForViewport && annotationForViewport.data.orientation) { + orientation = String( + annotationForViewport.data.orientation + ).toUpperCase(); + } else { + // Fallback: try to infer from viewportId + const idUpper = otherViewport.id.toUpperCase(); + if (idUpper.includes('AXIAL')) { + orientation = 'AXIAL'; + } else if (idUpper.includes('CORONAL')) { + orientation = 'CORONAL'; + } else if (idUpper.includes('SAGITTAL')) { + orientation = 'SAGITTAL'; + } + } + } + // Use lineColors from configuration + const lineColors = this.configuration.lineColors || {}; + const colorArr = lineColors[orientation] || + lineColors.unknown || [1.0, 0.0, 0.0]; // fallback to red + // Convert [r,g,b] to rgb string if needed + const color = Array.isArray(colorArr) + ? `rgb(${colorArr.map((v) => Math.round(v * 255)).join(',')})` + : colorArr; + + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const selectedViewportId = data.activeViewportIds.find( + (id) => id === otherViewport.id + ); + + let lineWidth = this.configuration.lineWidth ?? 1.5; + const lineActive = + data.handles.activeOperation !== null && + data.handles.activeOperation === OPERATION.DRAG && + selectedViewportId; + if (lineActive) { + lineWidth = this.configuration.activeLineWidth ?? 2.5; + } + + const lineUID = `${lineIndex}`; + if (viewportControllable) { + if (intersections.length === 2) { + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + intersections[0].point, + intersections[1].point, + { + color, + lineWidth, + } + ); + } + if ( + this.configuration.extendReferenceLines && + intersections.length === 2 + ) { + if ( + this.configuration.extendReferenceLines && + intersections.length === 2 + ) { + // Sort intersections by distance from line start + const sortedIntersections = intersections + .map((intersection) => ({ + ...intersection, + distance: vec2.distance(line[1], intersection.point), + })) + .sort((a, b) => a.distance - b.distance); + + // Draw dashed lines in correct order + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID + '_dashed_before', + line[1], + sortedIntersections[0].point, + { color, lineWidth, lineDash: [4, 4] } + ); + + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID + '_dashed_after', + sortedIntersections[1].point, + line[2], + { color, lineWidth, lineDash: [4, 4] } + ); + } + } + } + }); + + renderStatus = true; + + if (this.configuration.viewportIndicators) { + const { viewportIndicatorsConfig } = this.configuration; + const xOffset = viewportIndicatorsConfig?.xOffset || 0.95; + const yOffset = viewportIndicatorsConfig?.yOffset || 0.05; + const referenceColorCoordinates = [ + clientWidth * xOffset, + clientHeight * yOffset, + ]; + + const circleRadius = + viewportIndicatorsConfig?.circleRadius || canvasDiagonalLength * 0.01; + + const circleUID = '0'; + drawCircleSvg( + svgDrawingHelper, + annotationUID, + circleUID, + referenceColorCoordinates as Types.Point2, + circleRadius, + { color, fill: color } + ); + } + + return renderStatus; + }; + + _getAnnotations = (enabledElement: Types.IEnabledElement) => { + const { viewport } = enabledElement; + const annotations = + getAnnotations(this.getToolName(), viewport.element) || []; + const viewportIds = this._getViewportsInfo().map( + ({ viewportId }) => viewportId + ); + + // filter the annotations to only keep that are for this toolGroup + const toolGroupAnnotations = annotations.filter((annotation) => { + const { data } = annotation; + return viewportIds.includes(data.viewportId); + }); + + return toolGroupAnnotations; + }; + + _onSphereMoved = (evt) => { + if (evt.detail.originalClippingPlanes) { + this._syncWithVolumeCroppingTool(evt.detail.originalClippingPlanes); + } else { + if (evt.detail.seriesInstanceUID !== this.seriesInstanceUID) { + return; + } + // This is called when a sphere is moved + const { draggingSphereIndex, toolCenter } = evt.detail; + const newMin: [number, number, number] = [...this.toolCenterMin]; + const newMax: [number, number, number] = [...this.toolCenterMax]; + // face spheres + if (draggingSphereIndex >= 0 && draggingSphereIndex <= 5) { + const axis = Math.floor(draggingSphereIndex / 2); + const isMin = draggingSphereIndex % 2 === 0; + (isMin ? newMin : newMax)[axis] = toolCenter[axis]; + this.setToolCenter(newMin, 'min'); + this.setToolCenter(newMax, 'max'); + return; + } + // corner spheres + if (draggingSphereIndex >= 6 && draggingSphereIndex <= 13) { + const idx = draggingSphereIndex; + if (idx < 10) { + newMin[0] = toolCenter[0]; + } else { + newMax[0] = toolCenter[0]; + } + if ([6, 7, 10, 11].includes(idx)) { + newMin[1] = toolCenter[1]; + } else { + newMax[1] = toolCenter[1]; + } + if (idx % 2 === 0) { + newMin[2] = toolCenter[2]; + } else { + newMax[2] = toolCenter[2]; + } + this.setToolCenter(newMin, 'min'); + this.setToolCenter(newMax, 'max'); + } + } + }; + + _onNewVolume = () => { + const viewportsInfo = this._getViewportsInfo(); + if (viewportsInfo && viewportsInfo.length > 0) { + const { viewportId, renderingEngineId } = viewportsInfo[0]; + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId); + const volumeActors = viewport.getActors(); + if (volumeActors.length > 0) { + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (imageData) { + this.seriesInstanceUID = imageData.seriesInstanceUID; + this._updateToolCentersFromViewport(viewport); + // Update all annotations' handles.toolCenter + const annotations = + getAnnotations(this.getToolName(), viewportId) || []; + annotations.forEach((annotation) => { + if (annotation.data && annotation.data.handles) { + annotation.data.handles.toolCenter = [...this.toolCenter]; + } + }); + } + } + } + this._computeToolCenter(viewportsInfo); + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + toolGroupId: this.toolGroupId, + viewportsInfo: viewportsInfo, + seriesInstanceUID: this.seriesInstanceUID, + }); + }; + + _unsubscribeToViewportNewVolumeSet(viewportsInfo) { + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const { element } = viewport; + + element.removeEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + this._onNewVolume + ); + }); + } + + _subscribeToViewportNewVolumeSet(viewports) { + viewports.forEach(({ viewportId, renderingEngineId }) => { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const { element } = viewport; + + element.addEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + this._onNewVolume + ); + }); + } + + _getAnnotationsForViewportsWithDifferentCameras = ( + enabledElement, + annotations + ) => { + const { viewportId, renderingEngine, viewport } = enabledElement; + + const otherViewportAnnotations = annotations.filter( + (annotation) => annotation.data.viewportId !== viewportId + ); + + if (!otherViewportAnnotations || !otherViewportAnnotations.length) { + return []; + } + + const camera = viewport.getCamera(); + const { viewPlaneNormal, position } = camera; + + const viewportsWithDifferentCameras = otherViewportAnnotations.filter( + (annotation) => { + const { viewportId } = annotation.data; + const targetViewport = renderingEngine.getViewport(viewportId); + const cameraOfTarget = targetViewport.getCamera(); + + return !( + csUtils.isEqual( + cameraOfTarget.viewPlaneNormal, + viewPlaneNormal, + 1e-2 + ) && csUtils.isEqual(cameraOfTarget.position, position, 1) + ); + } + ); + + return viewportsWithDifferentCameras; + }; + + _filterViewportWithSameOrientation = ( + enabledElement, + referenceAnnotation, + annotations + ) => { + const { renderingEngine } = enabledElement; + const { data } = referenceAnnotation; + const viewport = renderingEngine.getViewport(data.viewportId); + + const linkedViewportAnnotations = annotations.filter((annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return otherViewportControllable === true; + }); + + if (!linkedViewportAnnotations || !linkedViewportAnnotations.length) { + return []; + } + + const camera = viewport.getCamera(); + const viewPlaneNormal = camera.viewPlaneNormal; + vtkMath.normalize(viewPlaneNormal); + + const otherViewportsAnnotationsWithSameCameraDirection = + linkedViewportAnnotations.filter((annotation) => { + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + return ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) && + csUtils.isEqual(camera.viewUp, otherCamera.viewUp, 1e-2) + ); + }); + + return otherViewportsAnnotationsWithSameCameraDirection; + }; + + _activateModify = (element) => { + // mobile sometimes has lingering interaction even when touchEnd triggers + // this check allows for multiple handles to be active which doesn't affect + // tool usage. + state.isInteractingWithTool = !this.configuration.mobile?.enabled; + + element.addEventListener(Events.MOUSE_UP, this._endCallback); + element.addEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.addEventListener(Events.MOUSE_CLICK, this._endCallback); + + element.addEventListener(Events.TOUCH_END, this._endCallback); + element.addEventListener(Events.TOUCH_DRAG, this._dragCallback); + element.addEventListener(Events.TOUCH_TAP, this._endCallback); + }; + + _deactivateModify = (element) => { + state.isInteractingWithTool = false; + + element.removeEventListener(Events.MOUSE_UP, this._endCallback); + element.removeEventListener(Events.MOUSE_DRAG, this._dragCallback); + element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); + + element.removeEventListener(Events.TOUCH_END, this._endCallback); + element.removeEventListener(Events.TOUCH_DRAG, this._dragCallback); + element.removeEventListener(Events.TOUCH_TAP, this._endCallback); + }; + + _endCallback = (evt: EventTypes.InteractionEventType) => { + const eventDetail = evt.detail; + const { element } = eventDetail; + + this.editData.annotation.data.handles.activeOperation = null; + this.editData.annotation.data.activeViewportIds = []; + + this._deactivateModify(element); + + resetElementCursor(element); + + this.editData = null; + + const requireSameOrientation = false; + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName(), + requireSameOrientation + ); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + }; + + _dragCallback = (evt: EventTypes.InteractionEventType) => { + const eventDetail = evt.detail; + const delta = eventDetail.deltaPoints.world; + + if ( + Math.abs(delta[0]) < 1e-3 && + Math.abs(delta[1]) < 1e-3 && + Math.abs(delta[2]) < 1e-3 + ) { + return; + } + + const { element } = eventDetail; + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + if (viewport.type === Enums.ViewportType.VOLUME_3D) { + return; + } + const annotations = this._getAnnotations( + enabledElement + ) as VolumeCroppingAnnotation[]; + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); + + // viewport Annotation + const viewportAnnotation = filteredToolAnnotations[0]; + if (!viewportAnnotation) { + return; + } + + const { handles } = viewportAnnotation.data; + + if (handles.activeOperation === OPERATION.DRAG) { + if (handles.activeType === 'min') { + this.toolCenterMin[0] += delta[0]; + this.toolCenterMin[1] += delta[1]; + this.toolCenterMin[2] += delta[2]; + } else if (handles.activeType === 'max') { + this.toolCenterMax[0] += delta[0]; + this.toolCenterMax[1] += delta[1]; + this.toolCenterMax[2] += delta[2]; + } else { + this.toolCenter[0] += delta[0]; + this.toolCenter[1] += delta[1]; + this.toolCenter[2] += delta[2]; + } + const viewportsInfo = this._getViewportsInfo(); + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + handleType: handles.activeType, + viewportOrientation: [], + seriesInstanceUID: this.seriesInstanceUID, + }); + } + }; + + _applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ) { + // update camera for the other viewports. + // NOTE1: The lines then are rendered by the onCameraModified + viewportsAnnotationsToUpdate.forEach((annotation) => { + this._applyDeltaShiftToViewportCamera(renderingEngine, annotation, delta); + }); + } + + _applyDeltaShiftToViewportCamera( + renderingEngine: Types.IRenderingEngine, + annotation, + delta + ) { + const { data } = annotation; + + const viewport = renderingEngine.getViewport(data.viewportId); + const camera = viewport.getCamera(); + const normal = camera.viewPlaneNormal; + + // Project delta over camera normal + // (we don't need to pan, we need only to scroll the camera as in the wheel stack scroll tool) + const dotProd = vtkMath.dot(delta, normal); + const projectedDelta: Types.Point3 = [...normal]; + vtkMath.multiplyScalar(projectedDelta, dotProd); + + if ( + Math.abs(projectedDelta[0]) > 1e-3 || + Math.abs(projectedDelta[1]) > 1e-3 || + Math.abs(projectedDelta[2]) > 1e-3 + ) { + const newFocalPoint: Types.Point3 = [0, 0, 0]; + const newPosition: Types.Point3 = [0, 0, 0]; + + vtkMath.add(camera.focalPoint, projectedDelta, newFocalPoint); + vtkMath.add(camera.position, projectedDelta, newPosition); + + viewport.setCamera({ + focalPoint: newFocalPoint, + position: newPosition, + }); + viewport.render(); + } + } + + _pointNearTool(element, annotation, canvasCoords, proximity) { + const { data } = annotation; + + // You must have referenceLines available in annotation.data. + // If not, you can recompute them here or store them in renderAnnotation. + // For this example, let's assume you store them as data.referenceLines. + const referenceLines = data.referenceLines; + + const viewportIdArray = []; + + if (referenceLines) { + for (let i = 0; i < referenceLines.length; ++i) { + // Each line: [otherViewport, refLinePointOne, refLinePointMinOne, ...] + const otherViewport = referenceLines[i][0]; + // First segment + const start1 = referenceLines[i][1]; + const end1 = referenceLines[i][2]; + const type = referenceLines[i][3]; // 'min' or 'max' + + const distance1 = lineSegment.distanceToPoint(start1, end1, [ + canvasCoords[0], + canvasCoords[1], + ]); + + if (distance1 <= proximity) { + viewportIdArray.push(otherViewport.id); + data.handles.activeOperation = 1; // DRAG + data.handles.activeType = type; + } + } + } + + data.activeViewportIds = [...viewportIdArray]; + + this.editData = { + annotation, + }; + return data.handles.activeOperation === 1 ? true : false; + } +} + +VolumeCroppingControlTool.toolName = 'VolumeCroppingControl'; +export default VolumeCroppingControlTool; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts new file mode 100644 index 0000000000..a6bac4d188 --- /dev/null +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -0,0 +1,1726 @@ +import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; +import vtkPoints from '@kitware/vtk.js/Common/Core/Points'; +import vtkCellArray from '@kitware/vtk.js/Common/Core/CellArray'; +import { mat3, mat4, vec3 } from 'gl-matrix'; +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; +import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; + +import { BaseTool } from './base'; + +import type { Types } from '@cornerstonejs/core'; +import { + getRenderingEngine, + getEnabledElementByIds, + getEnabledElement, + Enums, + triggerEvent, + eventTarget, +} from '@cornerstonejs/core'; + +import { getToolGroup } from '../store/ToolGroupManager'; +import { Events } from '../enums'; + +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; + +const PLANEINDEX = { + XMIN: 0, + XMAX: 1, + YMIN: 2, + YMAX: 3, + ZMIN: 4, + ZMAX: 5, +}; +const SPHEREINDEX = { + // cube faces + XMIN: 0, + XMAX: 1, + YMIN: 2, + YMAX: 3, + ZMIN: 4, + ZMAX: 5, + // cube corners + XMIN_YMIN_ZMIN: 6, + XMIN_YMIN_ZMAX: 7, + XMIN_YMAX_ZMIN: 8, + XMIN_YMAX_ZMAX: 9, + XMAX_YMIN_ZMIN: 10, + XMAX_YMIN_ZMAX: 11, + XMAX_YMAX_ZMIN: 12, + XMAX_YMAX_ZMAX: 13, +}; + +/** + * VolumeCroppingTool provides manipulatable spheres and real-time volume cropping capabilities. + * It renders interactive handles (spheres) at face centers and corners of a cropping box, allowing users to precisely adjust volume boundaries through direct manipulation in 3D space. + * + * @remarks + * This tool creates a complete 3D cropping interface with: + * - 6 face spheres for individual axis cropping + * - 8 corner spheres for multi-axis cropping + * - 12 edge lines connecting corner spheres + * - Real-time clipping plane updates + * - Synchronization with VolumeCroppingControlTool working on the same series instance UID for cross-viewport interaction + * + * + * @example + * ```typescript + * // Basic setup + * const toolGroup = ToolGroupManager.createToolGroup('volume3D'); + * toolGroup.addTool(VolumeCroppingTool.toolName); + * + * // Configure with custom settings + * toolGroup.setToolConfiguration(VolumeCroppingTool.toolName, { + * showCornerSpheres: true, + * showHandles: true, + * initialCropFactor: 0.1, + * sphereColors: { + * SAGITTAL: [1.0, 1.0, 0.0], // Yellow for sagittal (X-axis) spheres + * CORONAL: [0.0, 1.0, 0.0], // Green for coronal (Y-axis) spheres + * AXIAL: [1.0, 0.0, 0.0], // Red for axial (Z-axis) spheres + * CORNERS: [0.0, 0.0, 1.0] // Blue for corner spheres + * }, + * sphereRadius: 10, + * grabSpherePixelDistance: 25 + * }); + * + * // Activate the tool + * toolGroup.setToolActive(VolumeCroppingTool.toolName); + * + * // Programmatically control visibility + * const tool = toolGroup.getToolInstance(VolumeCroppingTool.toolName); + * tool.setHandlesVisible(true); + * tool.setClippingPlanesVisible(true); + * + * // Toggle visibility for interactive UI + * function toggleCroppingInterface() { + * const handlesVisible = tool.getHandlesVisible(); + * const planesVisible = tool.getClippingPlanesVisible(); + * + * // Toggle handles (spheres and edge lines) + * tool.setHandlesVisible(!handlesVisible); + * + * // Toggle clipping effect + * tool.setClippingPlanesVisible(!planesVisible); + * + * console.log(`Handles: ${!handlesVisible ? 'shown' : 'hidden'}`); + * console.log(`Cropping: ${!planesVisible ? 'active' : 'disabled'}`); + * } + * + * // Common UI scenarios + * // Show handles but disable cropping (for positioning) + * tool.setHandlesVisible(true); + * tool.setClippingPlanesVisible(false); + * + * // Hide handles but keep cropping active (for clean view) + * tool.setHandlesVisible(false); + * tool.setClippingPlanesVisible(true); + * ``` + * + * @public + * @class VolumeCroppingTool + * @extends BaseTool + * + * @property {string} toolName - Static tool identifier: 'VolumeCropping' + * @property {string} seriesInstanceUID - Frame of reference for the tool + * @property {Function} touchDragCallback - Touch drag event handler for mobile interactions + * @property {Function} mouseDragCallback - Mouse drag event handler for desktop interactions + * @property {Function} cleanUp - Cleanup function for resetting tool state after interactions + * @property {Map} _resizeObservers - Map of ResizeObserver instances for viewport resize handling + * @property {Function} _viewportAddedListener - Event listener for new viewport additions + * @property {boolean} _hasResolutionChanged - Flag tracking if rendering resolution has been modified during interaction + * @property {Array} originalClippingPlanes - Array of clipping plane objects with origin and normal vectors + * @property {number|null} draggingSphereIndex - Index of currently dragged sphere, null when not dragging + * @property {Types.Point3} toolCenter - Center point of the cropping volume in world coordinates [x, y, z] + * @property {number[]|null} cornerDragOffset - 3D offset vector for corner sphere dragging [dx, dy, dz] + * @property {number|null} faceDragOffset - 1D offset value for face sphere dragging along single axis + * @property {Array} sphereStates - Array of sphere state objects containing position, VTK actors, and metadata + * @property {Object} edgeLines - Dictionary of edge line actors connecting corner spheres for wireframe visualization + * + * @typedef {Object} SphereState + * @property {Types.Point3} point - World coordinates of sphere center [x, y, z] + * @property {string} axis - Axis identifier ('x', 'y', 'z', or 'corner') + * @property {string} uid - Unique identifier for the sphere (e.g., 'x_min', 'corner_XMIN_YMIN_ZMIN') + * @property {vtkSphereSource} sphereSource - VTK sphere geometry source + * @property {vtkActor} sphereActor - VTK actor for rendering the sphere + * @property {boolean} isCorner - Flag indicating if sphere is a corner (true) or face (false) sphere + * @property {number[]} color - RGB color array [r, g, b] for sphere rendering + * + * @typedef {Object} EdgeLine + * @property {vtkActor} actor - VTK actor for rendering the edge line + * @property {vtkPolyData} source - VTK polydata source containing line geometry + * @property {string} key1 - First corner identifier (e.g., 'XMIN_YMIN_ZMIN') + * @property {string} key2 - Second corner identifier (e.g., 'XMAX_YMIN_ZMIN') + * + * @configuration + * @property {boolean} showCornerSpheres - Whether to show corner manipulation spheres (default: true) + * @property {boolean} showHandles - Whether to show all manipulation handles (spheres and edges) (default: true) + * @property {boolean} showClippingPlanes - Whether to apply clipping planes to volume rendering (default: true) + * @property {Object} mobile - Mobile device interaction settings + * @property {boolean} mobile.enabled - Enable touch-based interactions on mobile devices (default: false) + * @property {number} mobile.opacity - Opacity for mobile interaction feedback (default: 0.8) + * @property {number} initialCropFactor - Initial cropping factor as fraction of volume bounds (default: 0.08) + * @property {Object} sphereColors - Color configuration for different sphere types + * @property {number[]} sphereColors.SAGITTAL - RGB color for sagittal (X-axis) face spheres [r, g, b] (default: [1.0, 1.0, 0.0]) + * @property {number[]} sphereColors.CORONAL - RGB color for coronal (Y-axis) face spheres [r, g, b] (default: [0.0, 1.0, 0.0]) + * @property {number[]} sphereColors.AXIAL - RGB color for axial (Z-axis) face spheres [r, g, b] (default: [1.0, 0.0, 0.0]) + * @property {number[]} sphereColors.CORNERS - RGB color for corner spheres [r, g, b] (default: [0.0, 0.0, 1.0]) + * @property {number} sphereRadius - Radius of manipulation spheres in world units (default: 8) + * @property {number} grabSpherePixelDistance - Pixel distance threshold for sphere selection (default: 20) + * @property {number} rotateIncrementDegrees - Rotation increment for camera rotation (default: 2) + * @property {number} rotateSampleDistanceFactor - Sample distance multiplier during rotation for performance (default: 2) + * + * @events + * @event VOLUMECROPPING_TOOL_CHANGED - Fired when sphere positions change or clipping planes are updated + * @event VOLUMECROPPINGCONTROL_TOOL_CHANGED - Listens for changes from VolumeCroppingControlTool + * @event VOLUME_VIEWPORT_NEW_VOLUME - Listens for new volume loading to reinitialize cropping bounds + * @event TOOLGROUP_VIEWPORT_ADDED - Listens for new viewport additions to extend resize observation + * + * @methods + * - **setHandlesVisible(visible: boolean)**: Show/hide manipulation spheres and edge lines + * - **setClippingPlanesVisible(visible: boolean)**: Enable/disable volume clipping planes + * - **getHandlesVisible()**: Get current handle visibility state + * - **getClippingPlanesVisible()**: Get current clipping plane visibility state + * + * + * @see {@link VolumeCroppingControlTool} - Companion tool for 2D viewport reference lines + * @see {@link BaseTool} - Base class providing core tool functionality + * + */ +class VolumeCroppingTool extends BaseTool { + static toolName; + seriesInstanceUID?: string; + touchDragCallback: (evt: EventTypes.InteractionEventType) => void; + mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; + cleanUp: () => void; + _resizeObservers = new Map(); + _viewportAddedListener: (evt) => void; + _hasResolutionChanged = false; + originalClippingPlanes: { origin: number[]; normal: number[] }[] = []; + draggingSphereIndex: number | null = null; + toolCenter: Types.Point3 = [0, 0, 0]; + cornerDragOffset: [number, number, number] | null = null; + faceDragOffset: number | null = null; + // Store spheres for show/hide actor. + sphereStates: { + point: Types.Point3; + axis: string; + uid: string; + sphereSource; + sphereActor; + isCorner: boolean; + color: number[]; // [r, g, b] color for the sphere + }[] = []; + // Store 2D edge lines between corner spheres for show/hide actor. + edgeLines: { + [uid: string]: { + actor: vtkActor; + source: vtkPolyData; + key1: string; // key1,key2: Corner identifiers such as XMIN_YMIN_ZMIN to XMAX_YMIN_ZMIN + key2: string; + }; + } = {}; + + constructor( + toolProps: PublicToolProps = {}, + defaultToolProps: ToolProps = { + configuration: { + showCornerSpheres: true, + showHandles: true, + showClippingPlanes: true, + mobile: { + enabled: false, + opacity: 0.8, + }, + initialCropFactor: 0.08, + sphereColors: { + SAGITTAL: [1.0, 1.0, 0.0], // Yellow for sagittal (X-axis) + CORONAL: [0.0, 1.0, 0.0], // Green for coronal (Y-axis) + AXIAL: [1.0, 0.0, 0.0], // Red for axial (Z-axis) + CORNERS: [0.0, 0.0, 1.0], // Blue for corners + }, + sphereRadius: 8, + grabSpherePixelDistance: 20, //pixels threshold for closeness to the sphere being grabbed + rotateIncrementDegrees: 2, + rotateSampleDistanceFactor: 2, // Factor to increase sample distance (lower resolution) when rotating + }, + } + ) { + super(toolProps, defaultToolProps); + this.touchDragCallback = this._dragCallback.bind(this); + this.mouseDragCallback = this._dragCallback.bind(this); + } + + onSetToolActive() { + // console.debug('Setting tool active: volumeCropping', this.sphereStates); + if (this.sphereStates && this.sphereStates.length > 0) { + if (this.configuration.showHandles) { + this.setHandlesVisible(false); + this.setClippingPlanesVisible(false); + // console.debug('Setting tool active: hiding controls'); + } else { + this.setHandlesVisible(true); + this.setClippingPlanesVisible(true); + // console.debug('Setting tool active: showing controls'); + } + } else { + const viewportsInfo = this._getViewportsInfo(); + const subscribeToElementResize = () => { + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + if (!this._resizeObservers.has(viewportId)) { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ) || { viewport: null }; + if (!viewport) { + return; + } + const { element } = viewport; + const resizeObserver = new ResizeObserver(() => { + const element = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!element) { + return; + } + const { viewport } = element; + const viewPresentation = viewport.getViewPresentation(); + viewport.resetCamera(); + viewport.setViewPresentation(viewPresentation); + viewport.render(); + }); + resizeObserver.observe(element); + this._resizeObservers.set(viewportId, resizeObserver); + } + }); + }; + + subscribeToElementResize(); + + this._viewportAddedListener = (evt) => { + if (evt.detail.toolGroupId === this.toolGroupId) { + subscribeToElementResize(); + } + }; + + eventTarget.addEventListener( + Events.TOOLGROUP_VIEWPORT_ADDED, + this._viewportAddedListener + ); + + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + this._subscribeToViewportNewVolumeSet(viewportsInfo); + this._initialize3DViewports(viewportsInfo); + + if (this.sphereStates && this.sphereStates.length > 0) { + this.setHandlesVisible(true); + } else { + //console.warn('Setting tool active: no spheres! trying to initialize'); + this.originalClippingPlanes = []; + this._initialize3DViewports(viewportsInfo); + } + } + } + + onSetToolConfiguration = (): void => { + console.debug('Setting tool settoolconfiguration : volumeCropping'); + //this._init(); + }; + + onSetToolEnabled = (): void => { + console.debug('Setting tool enabled: volumeCropping'); + }; + + onSetToolDisabled() { + // console.debug('Setting tool disabled: volumeCropping'); + // Disconnect all resize observers + this._resizeObservers.forEach((resizeObserver, viewportId) => { + resizeObserver.disconnect(); + this._resizeObservers.delete(viewportId); + }); + + if (this._viewportAddedListener) { + eventTarget.removeEventListener( + Events.TOOLGROUP_VIEWPORT_ADDED, + this._viewportAddedListener + ); + this._viewportAddedListener = null; // Clear the reference to the listener + } + + const viewportsInfo = this._getViewportsInfo(); + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + } + + onCameraModified = (evt) => { + const { element } = evt.currentTarget + ? { element: evt.currentTarget } + : evt.detail; + const enabledElement = getEnabledElement(element); + this._updateClippingPlanes(enabledElement.viewport); + enabledElement.viewport.render(); + }; + + preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + //console.debug('VolumeCroppingTool.preMouseDownCallback called'); + const eventDetail = evt.detail; + const { element } = eventDetail; + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + const actorEntry = viewport.getDefaultActor(); + const actor = actorEntry.actor as Types.VolumeActor; + const mapper = actor.getMapper(); + + const mouseCanvas: [number, number] = [ + evt.detail.currentPoints.canvas[0], + evt.detail.currentPoints.canvas[1], + ]; + // Find the sphere under the mouse + this.draggingSphereIndex = null; + this.cornerDragOffset = null; + this.faceDragOffset = null; + for (let i = 0; i < this.sphereStates.length; ++i) { + const sphereCanvas = viewport.worldToCanvas(this.sphereStates[i].point); + const dist = Math.sqrt( + Math.pow(mouseCanvas[0] - sphereCanvas[0], 2) + + Math.pow(mouseCanvas[1] - sphereCanvas[1], 2) + ); + if (dist < this.configuration.grabSpherePixelDistance) { + this.draggingSphereIndex = i; + element.style.cursor = 'grabbing'; + + // --- Store offset for corners --- + const sphereState = this.sphereStates[i]; + const mouseWorld = viewport.canvasToWorld(mouseCanvas); + if (sphereState.isCorner) { + this.cornerDragOffset = [ + sphereState.point[0] - mouseWorld[0], + sphereState.point[1] - mouseWorld[1], + sphereState.point[2] - mouseWorld[2], + ]; + this.faceDragOffset = null; + } else { + // For face spheres, only store the offset along the axis of movement + const axisIdx = { x: 0, y: 1, z: 2 }[sphereState.axis]; + this.faceDragOffset = + sphereState.point[axisIdx] - mouseWorld[axisIdx]; + this.cornerDragOffset = null; + } + + return true; + } + } + + const hasSampleDistance = + 'getSampleDistance' in mapper || 'getCurrentSampleDistance' in mapper; + + if (!hasSampleDistance) { + return true; + } + + const originalSampleDistance = mapper.getSampleDistance(); + + if (!this._hasResolutionChanged) { + const { rotateSampleDistanceFactor } = this.configuration; + mapper.setSampleDistance( + originalSampleDistance * rotateSampleDistanceFactor + ); + this._hasResolutionChanged = true; + + if (this.cleanUp !== null) { + // Clean up previous event listener + document.removeEventListener('mouseup', this.cleanUp); + } + + this.cleanUp = () => { + mapper.setSampleDistance(originalSampleDistance); + + // Reset cursor style + (evt.target as HTMLElement).style.cursor = ''; + if (this.draggingSphereIndex !== null) { + const sphereState = this.sphereStates[this.draggingSphereIndex]; + const [viewport3D] = this._getViewportsInfo(); + const renderingEngine = getRenderingEngine( + viewport3D.renderingEngineId + ); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + + if (sphereState.isCorner) { + this._updateCornerSpheres(); + this._updateFaceSpheresFromCorners(); + this._updateClippingPlanesFromFaceSpheres(viewport); + } + } + this.draggingSphereIndex = null; + this.cornerDragOffset = null; + this.faceDragOffset = null; + + viewport.render(); + this._hasResolutionChanged = false; + }; + + document.addEventListener('mouseup', this.cleanUp, { once: true }); + } + + return true; + }; + + /** + * Sets the visibility of the cropping handles (spheres and edge lines). + * + * When handles are being shown, this method automatically synchronizes the sphere positions + * with the current clipping plane positions to ensure visual consistency. This includes + * updating face spheres, corner spheres, and edge lines to match the current crop bounds. + * + * @param visible - Whether to show or hide the cropping handles + * + * @example + * ```typescript + * // Hide all cropping handles + * volumeCroppingTool.setHandlesVisible(false); + * + * // Show handles and sync with current crop state + * volumeCroppingTool.setHandlesVisible(true); + * ``` + * + */ + setHandlesVisible(visible: boolean) { + this.configuration.showHandles = visible; + // Before showing, update sphere positions to match clipping planes + if (visible) { + // Update face spheres from the current clipping planes + this.sphereStates[SPHEREINDEX.XMIN].point[0] = + this.originalClippingPlanes[PLANEINDEX.XMIN].origin[0]; + this.sphereStates[SPHEREINDEX.XMAX].point[0] = + this.originalClippingPlanes[PLANEINDEX.XMAX].origin[0]; + this.sphereStates[SPHEREINDEX.YMIN].point[1] = + this.originalClippingPlanes[PLANEINDEX.YMIN].origin[1]; + this.sphereStates[SPHEREINDEX.YMAX].point[1] = + this.originalClippingPlanes[PLANEINDEX.YMAX].origin[1]; + this.sphereStates[SPHEREINDEX.ZMIN].point[2] = + this.originalClippingPlanes[PLANEINDEX.ZMIN].origin[2]; + this.sphereStates[SPHEREINDEX.ZMAX].point[2] = + this.originalClippingPlanes[PLANEINDEX.ZMAX].origin[2]; + + // Update all sphere actors' positions + [ + SPHEREINDEX.XMIN, + SPHEREINDEX.XMAX, + SPHEREINDEX.YMIN, + SPHEREINDEX.YMAX, + SPHEREINDEX.ZMIN, + SPHEREINDEX.ZMAX, + ].forEach((idx) => { + const s = this.sphereStates[idx]; + s.sphereSource.setCenter(...s.point); + s.sphereSource.modified(); + }); + + // Update corners and edges as well + this._updateCornerSpheres(); + } + + // Show/hide actors + this._updateHandlesVisibility(); + + // Render + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + viewport.render(); + } + + /** + * Gets the current visibility state of the cropping handles. + * + * @returns Whether the cropping handles (spheres and edge lines) are currently visible + * + * @example + * ```typescript + * // Check if handles are currently visible + * const handlesVisible = volumeCroppingTool.getHandlesVisible(); + * if (handlesVisible) { + * console.log('Cropping handles are currently shown'); + * } else { + * console.log('Cropping handles are currently hidden'); + * } + * ``` + * + * @remarks + * This method returns the configuration state, which controls the visibility of: + * - Face spheres (6 spheres for individual axis cropping) + * - Corner spheres (8 spheres for multi-axis cropping) + * - Edge lines connecting the corner spheres + */ + getHandlesVisible() { + return this.configuration.showHandles; + } + + /** + * Gets the current visibility state of the clipping planes. + * + * @returns Whether the clipping planes are currently visible and actively cropping the volume + * + * @example + * ```typescript + * // Check if clipping planes are currently active + * const planesVisible = volumeCroppingTool.getClippingPlanesVisible(); + * if (planesVisible) { + * console.log('Volume is currently being cropped'); + * } else { + * console.log('Volume is displayed in full'); + * } + * ``` + * + * @remarks + * This method returns the configuration state that controls whether: + * - The volume rendering respects the current clipping plane boundaries + * - Parts of the volume outside the crop bounds are hidden from view + * - The cropping effect is applied to the 3D volume visualization + */ + getClippingPlanesVisible() { + return this.configuration.showClippingPlanes; + } + + /** + * Sets the visibility of the clipping planes to enable or disable volume cropping. + * + * When clipping planes are visible, the volume rendering is cropped according to the + * current sphere positions. When disabled, the full volume is displayed without cropping. + * The viewport is automatically re-rendered after the change. + * + * @param visible - Whether to enable (true) or disable (false) volume clipping + * + * @example + * ```typescript + * // Enable volume cropping + * volumeCroppingTool.setClippingPlanesVisible(true); + * + * // Disable volume cropping to show full volume + * volumeCroppingTool.setClippingPlanesVisible(false); + * ``` + * + * @remarks + * - When enabled, parts of the volume outside the crop bounds are hidden + * - When disabled, all clipping planes are removed from the volume mapper + * - The cropping bounds are determined by the current sphere positions + * - The viewport is automatically re-rendered to reflect the change + * - This method updates the internal configuration and applies changes immediately + */ + setClippingPlanesVisible(visible: boolean) { + this.configuration.showClippingPlanes = visible; + const viewport = this._getViewport(); + this._updateClippingPlanes(viewport); + viewport.render(); + } + + _dragCallback(evt: EventTypes.InteractionEventType): void { + const { element, currentPoints, lastPoints } = evt.detail; + + if (this.draggingSphereIndex !== null) { + // crop handles + this._onMouseMoveSphere(evt); + } else { + // rotate + const currentPointsCanvas = currentPoints.canvas; + const lastPointsCanvas = lastPoints.canvas; + const { rotateIncrementDegrees } = this.configuration; + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + + const camera = viewport.getCamera(); + const width = element.clientWidth; + const height = element.clientHeight; + + const normalizedPosition = [ + currentPointsCanvas[0] / width, + currentPointsCanvas[1] / height, + ]; + + const normalizedPreviousPosition = [ + lastPointsCanvas[0] / width, + lastPointsCanvas[1] / height, + ]; + + const center: Types.Point2 = [width * 0.5, height * 0.5]; + // NOTE: centerWorld corresponds to the focal point in cornerstone3D + const centerWorld = viewport.canvasToWorld(center); + const normalizedCenter = [0.5, 0.5]; + + const radsq = (1.0 + Math.abs(normalizedCenter[0])) ** 2.0; + const op = [normalizedPreviousPosition[0], 0, 0]; + const oe = [normalizedPosition[0], 0, 0]; + + const opsq = op[0] ** 2; + const oesq = oe[0] ** 2; + + const lop = opsq > radsq ? 0 : Math.sqrt(radsq - opsq); + const loe = oesq > radsq ? 0 : Math.sqrt(radsq - oesq); + + const nop: Types.Point3 = [op[0], 0, lop]; + vtkMath.normalize(nop); + const noe: Types.Point3 = [oe[0], 0, loe]; + vtkMath.normalize(noe); + + const dot = vtkMath.dot(nop, noe); + if (Math.abs(dot) > 0.0001) { + const angleX = + -2 * + Math.acos(vtkMath.clampValue(dot, -1.0, 1.0)) * + Math.sign(normalizedPosition[0] - normalizedPreviousPosition[0]) * + rotateIncrementDegrees; + + const upVec = camera.viewUp; + const atV = camera.viewPlaneNormal; + const rightV: Types.Point3 = [0, 0, 0]; + const forwardV: Types.Point3 = [0, 0, 0]; + + vtkMath.cross(upVec, atV, rightV); + vtkMath.normalize(rightV); + + vtkMath.cross(atV, rightV, forwardV); + vtkMath.normalize(forwardV); + vtkMath.normalize(upVec); + + this._rotateCamera(viewport, centerWorld, forwardV, angleX); + + const angleY = + (normalizedPreviousPosition[1] - normalizedPosition[1]) * + rotateIncrementDegrees; + + this._rotateCamera(viewport, centerWorld, rightV, angleY); + } + + viewport.render(); + } + } + + _onMouseMoveSphere = (evt) => { + if (this.draggingSphereIndex === null) { + return false; + } + + const sphereState = this.sphereStates[this.draggingSphereIndex]; + if (!sphereState) { + return false; + } + + // Get viewport and world coordinates + const { viewport, world } = this._getViewportAndWorldCoords(evt); + if (!viewport || !world) { + return false; + } + + // Handle sphere movement based on type WITHOUT updating clipping planes yet + if (sphereState.isCorner) { + // Calculate and update just the dragged corner position + const newCorner = this._calculateNewCornerPosition(world); + this._updateSpherePosition(sphereState, newCorner); + + // Update related corners + const axisFlags = this._parseCornerKey(sphereState.uid); + this._updateRelatedCorners(sphereState, newCorner, axisFlags); + + // Update face spheres and corners + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(); + } else { + // Update face sphere position + const axisIdx = { x: 0, y: 1, z: 2 }[sphereState.axis]; + let newValue = world[axisIdx]; + if (this.faceDragOffset !== null) { + newValue += this.faceDragOffset; + } + sphereState.point[axisIdx] = newValue; + sphereState.sphereSource.setCenter(...sphereState.point); + sphereState.sphereSource.modified(); + + // Update corners from face spheres + this._updateCornerSpheresFromFaces(); + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(); + } + + // // THEN update clipping planes + this._updateClippingPlanesFromFaceSpheres(viewport); + + // Final render and event trigger + viewport.render(); + + this._triggerToolChangedEvent(sphereState); + + return true; + }; + + _onControlToolChange = (evt) => { + const viewport = this._getViewport(); + if (!evt.detail.toolCenter) { + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + originalClippingPlanes: this.originalClippingPlanes, + viewportId: viewport.id, + renderingEngineId: viewport.renderingEngineId, + seriesInstanceUID: this.seriesInstanceUID, + }); + } else { + if (evt.detail.seriesInstanceUID !== this.seriesInstanceUID) { + return; + } + const isMin = evt.detail.handleType === 'min'; + const toolCenter = isMin + ? evt.detail.toolCenterMin + : evt.detail.toolCenterMax; + const normals = isMin + ? [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] + : [ + [-1, 0, 0], + [0, -1, 0], + [0, 0, -1], + ]; + const planeIndices = isMin + ? [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMIN] + : [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMAX]; + const sphereIndices = isMin + ? [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN] + : [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX]; + const axes = ['x', 'y', 'z']; + const orientationAxes = [ + Enums.OrientationAxis.SAGITTAL, + Enums.OrientationAxis.CORONAL, + Enums.OrientationAxis.AXIAL, + ]; + + // Update planes and spheres for each axis + for (let i = 0; i < 3; ++i) { + const origin: [number, number, number] = [0, 0, 0]; + origin[i] = toolCenter[i]; + const plane = vtkPlane.newInstance({ + origin, + normal: normals[i] as [number, number, number], + }); + this.originalClippingPlanes[planeIndices[i]].origin = plane.getOrigin(); + + // Update face sphere + this.sphereStates[sphereIndices[i]].point[i] = plane.getOrigin()[i]; + this.sphereStates[sphereIndices[i]].sphereSource.setCenter( + ...this.sphereStates[sphereIndices[i]].point + ); + this.sphereStates[sphereIndices[i]].sphereSource.modified(); + + // Update center for other face spheres (not on this axis) + const otherSphere = this.sphereStates.find( + (s, idx) => s.axis === axes[i] && idx !== sphereIndices[i] + ); + const newCenter = (otherSphere.point[i] + plane.getOrigin()[i]) / 2; + this.sphereStates.forEach((state) => { + if ( + !state.isCorner && + state.axis !== axes[i] && + !evt.detail.viewportOrientation.includes(orientationAxes[i]) + ) { + state.point[i] = newCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + + // Update vtk clipping plane origin + const volumeActor = viewport.getDefaultActor()?.actor; + if (volumeActor) { + const mapper = volumeActor.getMapper() as vtkVolumeMapper; + const clippingPlanes = mapper.getClippingPlanes(); + if (clippingPlanes) { + clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + } + } + } + viewport.render(); + } + }; + _updateClippingPlanes(viewport) { + // Get the actor and transformation matrix + const actorEntry = viewport.getDefaultActor(); + if (!actorEntry || !actorEntry.actor) { + // Only warn once per session for missing actor + if (!viewport._missingActorWarned) { + console.warn( + 'VolumeCroppingTool._updateClippingPlanes: No default actor found in viewport.' + ); + viewport._missingActorWarned = true; + } + return; + } + const actor = actorEntry.actor; + const mapper = actor.getMapper(); + const matrix = actor.getMatrix(); + + // Only update if clipping planes are visible + if (!this.configuration.showClippingPlanes) { + mapper.removeAllClippingPlanes(); + return; + } + + // Extract rotation part for normals + const rot = mat3.create(); + mat3.fromMat4(rot, matrix); + // Compute inverse transpose for normal transformation + const normalMatrix = mat3.create(); + mat3.invert(normalMatrix, rot); + mat3.transpose(normalMatrix, normalMatrix); + + // Cache transformed origins/normals to avoid repeated work + const originalPlanes = this.originalClippingPlanes; + if (!originalPlanes || !originalPlanes.length) { + return; + } + + // Only remove/add if the number of planes has changed or matrix has changed + // (Assume matrix changes frequently, so always update for now) + mapper.removeAllClippingPlanes(); + + // Preallocate arrays for transformed origins/normals + const transformedOrigins: Types.Point3[] = []; + const transformedNormals: Types.Point3[] = []; + + for (let i = 0; i < originalPlanes.length; ++i) { + const plane = originalPlanes[i]; + // Transform origin (full 4x4) + const oVec = vec3.create(); + vec3.transformMat4(oVec, new Float32Array(plane.origin), matrix); + const o: [number, number, number] = [oVec[0], oVec[1], oVec[2]]; + // Transform normal (rotation only) + const nVec = vec3.create(); + vec3.transformMat3(nVec, new Float32Array(plane.normal), normalMatrix); + vec3.normalize(nVec, nVec); + const n: [number, number, number] = [nVec[0], nVec[1], nVec[2]]; + transformedOrigins.push(o); + transformedNormals.push(n); + } + + // Create and add planes in a single loop + for (let i = 0; i < transformedOrigins.length; ++i) { + // Use cached transformed values + const planeInstance = vtkPlane.newInstance({ + origin: transformedOrigins[i], + normal: transformedNormals[i], + }); + mapper.addClippingPlane(planeInstance); + } + } + + _updateHandlesVisibility() { + // Spheres + this.sphereStates.forEach((state) => { + if (state.sphereActor) { + state.sphereActor.setVisibility(this.configuration.showHandles); + } + }); + + // Edge lines (box edges) + Object.values(this.edgeLines).forEach(({ actor }) => { + if (actor) { + actor.setVisibility(this.configuration.showHandles); + } + }); + } + + _addLine3DBetweenPoints( + viewport, + point1, + point2, + color: [number, number, number] = [0.7, 0.7, 0.7], + uid = '' + ) { + // Avoid creating a line if the points are the same + if ( + point1[0] === point2[0] && + point1[1] === point2[1] && + point1[2] === point2[2] + ) { + return { actor: null, source: null }; + } + const points = vtkPoints.newInstance(); + points.setNumberOfPoints(2); + points.setPoint(0, point1[0], point1[1], point1[2]); + points.setPoint(1, point2[0], point2[1], point2[2]); + + const lines = vtkCellArray.newInstance({ values: [2, 0, 1] }); + const polyData = vtkPolyData.newInstance(); + polyData.setPoints(points); + polyData.setLines(lines); + + const mapper = vtkMapper.newInstance(); + mapper.setInputData(polyData); + const actor = vtkActor.newInstance(); + actor.setMapper(mapper); + actor.getProperty().setColor(...color); + actor.getProperty().setLineWidth(0.5); // Thinner line + actor.getProperty().setOpacity(1.0); + actor.getProperty().setInterpolationToFlat(); // No shading + actor.getProperty().setAmbient(1.0); // Full ambient + actor.getProperty().setDiffuse(0.0); // No diffuse + actor.getProperty().setSpecular(0.0); // No specular + actor.setVisibility(this.configuration.showHandles); + viewport.addActor({ actor, uid }); + return { actor, source: polyData }; + } + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + return viewports; + }; + + _addSphere( + viewport, + point, + axis, + position, + cornerKey = null, + adaptiveRadius + ) { + // Generate a unique UID for each sphere based on its axis and position + // Use cornerKey for corners, otherwise axis+position for faces + const uid = cornerKey ? `corner_${cornerKey}` : `${axis}_${position}`; + const sphereState = this.sphereStates.find((s) => s.uid === uid); + if (sphereState) { + return; + } + const sphereSource = vtkSphereSource.newInstance(); + sphereSource.setCenter(point); + sphereSource.setRadius(adaptiveRadius); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + const sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); + let color: [number, number, number] = [0.0, 1.0, 0.0]; // Default green + const sphereColors = this.configuration.sphereColors || {}; + + if (cornerKey) { + color = sphereColors.CORNERS || [0.0, 0.0, 1.0]; // Use corners color from config, fallback to blue + } else if (axis === 'z') { + color = sphereColors.AXIAL || [1.0, 0.0, 0.0]; // Z-axis = AXIAL planes + } else if (axis === 'x') { + color = sphereColors.SAGITTAL || [1.0, 1.0, 0.0]; // X-axis = SAGITTAL planes + } else if (axis === 'y') { + color = sphereColors.CORONAL || [0.0, 1.0, 0.0]; // Y-axis = CORONAL planes + } + // Store or update the sphere position + const idx = this.sphereStates.findIndex((s) => s.uid === uid); + if (idx === -1) { + this.sphereStates.push({ + point: point.slice(), + axis, + uid, + sphereSource, + sphereActor, + isCorner: !!cornerKey, + color, + }); + } else { + this.sphereStates[idx].point = point.slice(); + this.sphereStates[idx].sphereSource = sphereSource; + } + + // Remove existing actor with this UID if present + const existingActors = viewport.getActors(); + const existing = existingActors.find((a) => a.uid === uid); + if (existing) { + //viewport.removeActor(uid); + return; + } + sphereActor.getProperty().setColor(color); + sphereActor.setVisibility(this.configuration.showHandles); + viewport.addActor({ actor: sphereActor, uid: uid }); + } + /** + * Calculate an adaptive sphere radius based on the diagonal of the volume. + * This allows the sphere size to scale with the volume size. + * @param diagonal The diagonal length of the volume in world coordinates. + * @returns The calculated adaptive radius, clamped between min and max limits. + */ + _calculateAdaptiveSphereRadius(diagonal): number { + // Get base radius from configuration (acts as a scaling factor) + const baseRadius = + this.configuration.sphereRadius !== undefined + ? this.configuration.sphereRadius + : 8; + + // Scale radius as a percentage of diagonal (adjustable factor) + const scaleFactor = this.configuration.sphereRadiusScale || 0.01; // 1% of diagonal by default + const adaptiveRadius = diagonal * scaleFactor; + + // Apply min/max limits to prevent too small or too large spheres + const minRadius = this.configuration.minSphereRadius || 2; + const maxRadius = this.configuration.maxSphereRadius || 50; + + return Math.max(minRadius, Math.min(maxRadius, adaptiveRadius)); + } + + _initialize3DViewports = (viewportsInfo): void => { + if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { + console.warn( + 'VolumeCroppingTool: No viewportsInfo available for initialization of volumecroppingtool.' + ); + return; + } + const viewport = this._getViewport(); + const volumeActors = viewport.getActors(); + if (!volumeActors || volumeActors.length === 0) { + console.warn( + 'VolumeCroppingTool: No volume actors found in the viewport.' + ); + return; + } + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (!imageData) { + console.warn('VolumeCroppingTool: No image data found for volume actor.'); + return; + } + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; + const worldBounds = imageData.getBounds(); // Already in world coordinates + const cropFactor = this.configuration.initialCropFactor || 0.1; + + // Calculate cropping bounds using world bounds + const xRange = worldBounds[1] - worldBounds[0]; + const yRange = worldBounds[3] - worldBounds[2]; + const zRange = worldBounds[5] - worldBounds[4]; + + const xMin = worldBounds[0] + cropFactor * xRange; + const xMax = worldBounds[1] - cropFactor * xRange; + const yMin = worldBounds[2] + cropFactor * yRange; + const yMax = worldBounds[3] - cropFactor * yRange; + const zMin = worldBounds[4] + cropFactor * zRange; + const zMax = worldBounds[5] - cropFactor * zRange; + + const planes: vtkPlane[] = []; + + // X min plane (cuts everything left of xMin) + const planeXmin = vtkPlane.newInstance({ + origin: [xMin, 0, 0], + normal: [1, 0, 0], + }); + const planeXmax = vtkPlane.newInstance({ + origin: [xMax, 0, 0], + normal: [-1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, yMin, 0], + normal: [0, 1, 0], + }); + const planeYmax = vtkPlane.newInstance({ + origin: [0, yMax, 0], + normal: [0, -1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, zMin], + normal: [0, 0, 1], + }); + const planeZmax = vtkPlane.newInstance({ + origin: [0, 0, zMax], + normal: [0, 0, -1], + }); + const mapper = viewport + .getDefaultActor() + .actor.getMapper() as vtkVolumeMapper; + planes.push(planeXmin); + planes.push(planeXmax); + planes.push(planeYmin); + planes.push(planeYmax); + planes.push(planeZmin); + planes.push(planeZmax); + + const originalPlanes = planes.map((plane) => ({ + origin: [...plane.getOrigin()], + normal: [...plane.getNormal()], + })); + + this.originalClippingPlanes = originalPlanes; + const sphereXminPoint = [xMin, (yMax + yMin) / 2, (zMax + zMin) / 2]; + const sphereXmaxPoint = [xMax, (yMax + yMin) / 2, (zMax + zMin) / 2]; + const sphereYminPoint = [(xMax + xMin) / 2, yMin, (zMax + zMin) / 2]; + const sphereYmaxPoint = [(xMax + xMin) / 2, yMax, (zMax + zMin) / 2]; + const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMin]; + const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax]; + const adaptiveRadius = this._calculateAdaptiveSphereRadius( + Math.sqrt(xRange * xRange + yRange * yRange + zRange * zRange) + ); + this._addSphere( + viewport, + sphereXminPoint, + 'x', + 'min', + null, + adaptiveRadius + ); + this._addSphere( + viewport, + sphereXmaxPoint, + 'x', + 'max', + null, + adaptiveRadius + ); + this._addSphere( + viewport, + sphereYminPoint, + 'y', + 'min', + null, + adaptiveRadius + ); + this._addSphere( + viewport, + sphereYmaxPoint, + 'y', + 'max', + null, + adaptiveRadius + ); + this._addSphere( + viewport, + sphereZminPoint, + 'z', + 'min', + null, + adaptiveRadius + ); + this._addSphere( + viewport, + sphereZmaxPoint, + 'z', + 'max', + null, + adaptiveRadius + ); + + const corners = [ + [xMin, yMin, zMin], + [xMin, yMin, zMax], + [xMin, yMax, zMin], + [xMin, yMax, zMax], + [xMax, yMin, zMin], + [xMax, yMin, zMax], + [xMax, yMax, zMin], + [xMax, yMax, zMax], + ]; + + const cornerKeys = [ + 'XMIN_YMIN_ZMIN', + 'XMIN_YMIN_ZMAX', + 'XMIN_YMAX_ZMIN', + 'XMIN_YMAX_ZMAX', + 'XMAX_YMIN_ZMIN', + 'XMAX_YMIN_ZMAX', + 'XMAX_YMAX_ZMIN', + 'XMAX_YMAX_ZMAX', + ]; + + for (let i = 0; i < corners.length; i++) { + this._addSphere( + viewport, + corners[i], + 'corner', + null, + cornerKeys[i], + adaptiveRadius + ); + } + + // draw the lines between corners + // All 12 edges as pairs of corner keys + const edgeCornerPairs = [ + // X edges + ['XMIN_YMIN_ZMIN', 'XMAX_YMIN_ZMIN'], + ['XMIN_YMIN_ZMAX', 'XMAX_YMIN_ZMAX'], + ['XMIN_YMAX_ZMIN', 'XMAX_YMAX_ZMIN'], + ['XMIN_YMAX_ZMAX', 'XMAX_YMAX_ZMAX'], + // Y edges + ['XMIN_YMIN_ZMIN', 'XMIN_YMAX_ZMIN'], + ['XMIN_YMIN_ZMAX', 'XMIN_YMAX_ZMAX'], + ['XMAX_YMIN_ZMIN', 'XMAX_YMAX_ZMIN'], + ['XMAX_YMIN_ZMAX', 'XMAX_YMAX_ZMAX'], + // Z edges + ['XMIN_YMIN_ZMIN', 'XMIN_YMIN_ZMAX'], + ['XMIN_YMAX_ZMIN', 'XMIN_YMAX_ZMAX'], + ['XMAX_YMIN_ZMIN', 'XMAX_YMIN_ZMAX'], + ['XMAX_YMAX_ZMIN', 'XMAX_YMAX_ZMAX'], + ]; + + edgeCornerPairs.forEach(([key1, key2], i) => { + const state1 = this.sphereStates.find((s) => s.uid === `corner_${key1}`); + const state2 = this.sphereStates.find((s) => s.uid === `corner_${key2}`); + if (state1 && state2) { + const uid = `edge_${key1}_${key2}`; + const { actor, source } = this._addLine3DBetweenPoints( + viewport, + state1.point, + state2.point, + [0.7, 0.7, 0.7], + uid + ); + this.edgeLines[uid] = { actor, source, key1, key2 }; + } + }); + + mapper.addClippingPlane(planeXmin); + mapper.addClippingPlane(planeXmax); + mapper.addClippingPlane(planeYmin); + mapper.addClippingPlane(planeYmax); + mapper.addClippingPlane(planeZmin); + mapper.addClippingPlane(planeZmax); + + eventTarget.addEventListener( + Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, + (evt) => { + this._onControlToolChange(evt); + } + ); + viewport.render(); + }; + + // Helper method to get viewport and world coordinates + _getViewportAndWorldCoords = (evt) => { + const viewport = this._getViewport(); + const x = evt.detail.currentPoints.canvas[0]; + const y = evt.detail.currentPoints.canvas[1]; + const world = viewport.canvasToWorld([x, y]); + + return { viewport, world }; + }; + + _getViewport = () => { + const [viewport3D] = this._getViewportsInfo(); + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + return renderingEngine.getViewport(viewport3D.viewportId); + }; + + // Handle corner sphere movement + _handleCornerSphereMovement = (sphereState, world, viewport) => { + // Calculate new position with offset + const newCorner = this._calculateNewCornerPosition(world); + + // Update the dragged corner + this._updateSpherePosition(sphereState, newCorner); + + // Parse corner key to determine axes + const axisFlags = this._parseCornerKey(sphereState.uid); + + // Update related corners efficiently + this._updateRelatedCorners(sphereState, newCorner, axisFlags); + + // Update dependent elements + this._updateAfterCornerMovement(viewport); + }; + + // Handle face sphere movement + _handleFaceSphereMovement = (sphereState, world, viewport) => { + const axisIdx = { x: 0, y: 1, z: 2 }[sphereState.axis]; + let newValue = world[axisIdx]; + + if (this.faceDragOffset !== null) { + newValue += this.faceDragOffset; + } + + // Update only the relevant axis + sphereState.point[axisIdx] = newValue; + sphereState.sphereSource.setCenter(...sphereState.point); + sphereState.sphereSource.modified(); + + // Update dependent elements + this._updateAfterFaceMovement(viewport); + }; + + // Calculate new corner position with offset + _calculateNewCornerPosition = (world) => { + let newCorner = [world[0], world[1], world[2]]; + + if (this.cornerDragOffset) { + newCorner = [ + world[0] + this.cornerDragOffset[0], + world[1] + this.cornerDragOffset[1], + world[2] + this.cornerDragOffset[2], + ]; + } + + return newCorner; + }; + + // Parse corner key to determine which axes are min/max + _parseCornerKey = (uid) => { + const cornerKey = uid.replace('corner_', ''); + return { + isXMin: cornerKey.includes('XMIN'), + isXMax: cornerKey.includes('XMAX'), + isYMin: cornerKey.includes('YMIN'), + isYMax: cornerKey.includes('YMAX'), + isZMin: cornerKey.includes('ZMIN'), + isZMax: cornerKey.includes('ZMAX'), + }; + }; + + // Update sphere position + _updateSpherePosition = (sphereState, newPosition) => { + sphereState.point = newPosition; + sphereState.sphereSource.setCenter(...newPosition); + sphereState.sphereSource.modified(); + }; + + // Update related corners that share axes + _updateRelatedCorners = (draggedSphere, newCorner, axisFlags) => { + this.sphereStates.forEach((state) => { + if (!state.isCorner || state === draggedSphere) { + return; + } + + const key = state.uid.replace('corner_', ''); + const shouldUpdate = this._shouldUpdateCorner(key, axisFlags); + + if (shouldUpdate) { + this._updateCornerCoordinates(state, newCorner, key, axisFlags); + } + }); + }; + + // Determine if a corner should be updated + _shouldUpdateCorner = (cornerKey, axisFlags) => { + return ( + (axisFlags.isXMin && cornerKey.includes('XMIN')) || + (axisFlags.isXMax && cornerKey.includes('XMAX')) || + (axisFlags.isYMin && cornerKey.includes('YMIN')) || + (axisFlags.isYMax && cornerKey.includes('YMAX')) || + (axisFlags.isZMin && cornerKey.includes('ZMIN')) || + (axisFlags.isZMax && cornerKey.includes('ZMAX')) + ); + }; + + // Update individual corner coordinates + _updateCornerCoordinates = (state, newCorner, cornerKey, axisFlags) => { + // X axis updates + if ( + (axisFlags.isXMin && cornerKey.includes('XMIN')) || + (axisFlags.isXMax && cornerKey.includes('XMAX')) + ) { + state.point[0] = newCorner[0]; + } + + // Y axis updates + if ( + (axisFlags.isYMin && cornerKey.includes('YMIN')) || + (axisFlags.isYMax && cornerKey.includes('YMAX')) + ) { + state.point[1] = newCorner[1]; + } + + // Z axis updates + if ( + (axisFlags.isZMin && cornerKey.includes('ZMIN')) || + (axisFlags.isZMax && cornerKey.includes('ZMAX')) + ) { + state.point[2] = newCorner[2]; + } + + // Apply changes + state.sphereSource.setCenter(...state.point); + state.sphereSource.modified(); + }; + + // Update after corner movement + _updateAfterCornerMovement = (viewport) => { + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(); + this._updateClippingPlanesFromFaceSpheres(viewport); + }; + + // Update after face movement + _updateAfterFaceMovement = (viewport) => { + this._updateCornerSpheresFromFaces(); + // this._updateFaceSpheresFromCorners(); + // this._updateCornerSpheres(); + this._updateClippingPlanesFromFaceSpheres(viewport); + }; + + // Trigger tool changed event + _triggerToolChangedEvent = (sphereState) => { + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + toolCenter: sphereState.point, + axis: sphereState.isCorner ? 'corner' : sphereState.axis, + draggingSphereIndex: this.draggingSphereIndex, + seriesInstanceUID: this.seriesInstanceUID, + }); + }; + + _updateClippingPlanesFromFaceSpheres(viewport) { + const mapper = viewport.getDefaultActor().actor.getMapper(); + // Update origins in originalClippingPlanes + this.originalClippingPlanes[0].origin = [ + ...this.sphereStates[SPHEREINDEX.XMIN].point, + ]; + this.originalClippingPlanes[1].origin = [ + ...this.sphereStates[SPHEREINDEX.XMAX].point, + ]; + this.originalClippingPlanes[2].origin = [ + ...this.sphereStates[SPHEREINDEX.YMIN].point, + ]; + this.originalClippingPlanes[3].origin = [ + ...this.sphereStates[SPHEREINDEX.YMAX].point, + ]; + this.originalClippingPlanes[4].origin = [ + ...this.sphereStates[SPHEREINDEX.ZMIN].point, + ]; + this.originalClippingPlanes[5].origin = [ + ...this.sphereStates[SPHEREINDEX.ZMAX].point, + ]; + + mapper.removeAllClippingPlanes(); + for (let i = 0; i < 6; ++i) { + const origin = this.originalClippingPlanes[i].origin as [ + number, + number, + number + ]; + const normal = this.originalClippingPlanes[i].normal as [ + number, + number, + number + ]; + const plane = vtkPlane.newInstance({ + origin, + normal, + }); + mapper.addClippingPlane(plane); + } + } + + _updateCornerSpheresFromFaces() { + // Get face sphere positions + const xMin = this.sphereStates[SPHEREINDEX.XMIN].point[0]; + const xMax = this.sphereStates[SPHEREINDEX.XMAX].point[0]; + const yMin = this.sphereStates[SPHEREINDEX.YMIN].point[1]; + const yMax = this.sphereStates[SPHEREINDEX.YMAX].point[1]; + const zMin = this.sphereStates[SPHEREINDEX.ZMIN].point[2]; + const zMax = this.sphereStates[SPHEREINDEX.ZMAX].point[2]; + + const corners = [ + { key: 'XMIN_YMIN_ZMIN', pos: [xMin, yMin, zMin] }, + { key: 'XMIN_YMIN_ZMAX', pos: [xMin, yMin, zMax] }, + { key: 'XMIN_YMAX_ZMIN', pos: [xMin, yMax, zMin] }, + { key: 'XMIN_YMAX_ZMAX', pos: [xMin, yMax, zMax] }, + { key: 'XMAX_YMIN_ZMIN', pos: [xMax, yMin, zMin] }, + { key: 'XMAX_YMIN_ZMAX', pos: [xMax, yMin, zMax] }, + { key: 'XMAX_YMAX_ZMIN', pos: [xMax, yMax, zMin] }, + { key: 'XMAX_YMAX_ZMAX', pos: [xMax, yMax, zMax] }, + ]; + + for (const corner of corners) { + const state = this.sphereStates.find( + (s) => s.uid === `corner_${corner.key}` + ); + if (state) { + state.point[0] = corner.pos[0]; + state.point[1] = corner.pos[1]; + state.point[2] = corner.pos[2]; + state.sphereSource.setCenter(...state.point); + state.sphereSource.modified(); + } + } + } + _updateFaceSpheresFromCorners() { + // Get all corner points + const corners = [ + this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMIN].point, + this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMAX].point, + this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMIN].point, + this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMAX].point, + this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMIN].point, + this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMAX].point, + this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMIN].point, + this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMAX].point, + ]; + + const xs = corners.map((p) => p[0]); + const ys = corners.map((p) => p[1]); + const zs = corners.map((p) => p[2]); + + const xMin = Math.min(...xs), + xMax = Math.max(...xs); + const yMin = Math.min(...ys), + yMax = Math.max(...ys); + const zMin = Math.min(...zs), + zMax = Math.max(...zs); + + // Face spheres should always be at the center of their face + this.sphereStates[SPHEREINDEX.XMIN].point = [ + xMin, + (yMin + yMax) / 2, + (zMin + zMax) / 2, + ]; + this.sphereStates[SPHEREINDEX.XMAX].point = [ + xMax, + (yMin + yMax) / 2, + (zMin + zMax) / 2, + ]; + this.sphereStates[SPHEREINDEX.YMIN].point = [ + (xMin + xMax) / 2, + yMin, + (zMin + zMax) / 2, + ]; + this.sphereStates[SPHEREINDEX.YMAX].point = [ + (xMin + xMax) / 2, + yMax, + (zMin + zMax) / 2, + ]; + this.sphereStates[SPHEREINDEX.ZMIN].point = [ + (xMin + xMax) / 2, + (yMin + yMax) / 2, + zMin, + ]; + this.sphereStates[SPHEREINDEX.ZMAX].point = [ + (xMin + xMax) / 2, + (yMin + yMax) / 2, + zMax, + ]; + + [ + SPHEREINDEX.XMIN, + SPHEREINDEX.XMAX, + SPHEREINDEX.YMIN, + SPHEREINDEX.YMAX, + SPHEREINDEX.ZMIN, + SPHEREINDEX.ZMAX, + ].forEach((idx) => { + const s = this.sphereStates[idx]; + s.sphereSource.setCenter(...s.point); + s.sphereSource.modified(); + }); + } + + _updateCornerSpheres() { + // Get face sphere positions + const xMin = this.sphereStates[SPHEREINDEX.XMIN].point[0]; + const xMax = this.sphereStates[SPHEREINDEX.XMAX].point[0]; + const yMin = this.sphereStates[SPHEREINDEX.YMIN].point[1]; + const yMax = this.sphereStates[SPHEREINDEX.YMAX].point[1]; + const zMin = this.sphereStates[SPHEREINDEX.ZMIN].point[2]; + const zMax = this.sphereStates[SPHEREINDEX.ZMAX].point[2]; + + // Define all 8 corners from face sphere positions + const corners = [ + { key: 'XMIN_YMIN_ZMIN', pos: [xMin, yMin, zMin] }, + { key: 'XMIN_YMIN_ZMAX', pos: [xMin, yMin, zMax] }, + { key: 'XMIN_YMAX_ZMIN', pos: [xMin, yMax, zMin] }, + { key: 'XMIN_YMAX_ZMAX', pos: [xMin, yMax, zMax] }, + { key: 'XMAX_YMIN_ZMIN', pos: [xMax, yMin, zMin] }, + { key: 'XMAX_YMIN_ZMAX', pos: [xMax, yMin, zMax] }, + { key: 'XMAX_YMAX_ZMIN', pos: [xMax, yMax, zMin] }, + { key: 'XMAX_YMAX_ZMAX', pos: [xMax, yMax, zMax] }, + ]; + + // Update corner spheres + for (const corner of corners) { + const state = this.sphereStates.find( + (s) => s.uid === `corner_${corner.key}` + ); + if (state) { + state.point[0] = corner.pos[0]; + state.point[1] = corner.pos[1]; + state.point[2] = corner.pos[2]; + state.sphereSource.setCenter(...state.point); + state.sphereSource.modified(); + } + } + + // Update edge lines to follow the corner spheres + Object.values(this.edgeLines).forEach(({ source, key1, key2 }) => { + const state1 = this.sphereStates.find((s) => s.uid === `corner_${key1}`); + const state2 = this.sphereStates.find((s) => s.uid === `corner_${key2}`); + if (state1 && state2) { + const points = source.getPoints(); + points.setPoint(0, state1.point[0], state1.point[1], state1.point[2]); + points.setPoint(1, state2.point[0], state2.point[1], state2.point[2]); + points.modified(); + source.modified(); + } + }); + } + + _onNewVolume = () => { + const viewportsInfo = this._getViewportsInfo(); + this.originalClippingPlanes = []; + this.sphereStates = []; + this.edgeLines = {}; + this._initialize3DViewports(viewportsInfo); + }; + + _unsubscribeToViewportNewVolumeSet(viewportsInfo) { + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const { element } = viewport; + + element.removeEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + this._onNewVolume + ); + }); + } + + _subscribeToViewportNewVolumeSet(viewports) { + viewports.forEach(({ viewportId, renderingEngineId }) => { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const { element } = viewport; + + element.addEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + this._onNewVolume + ); + }); + } + + _rotateCamera = (viewport, centerWorld, axis, angle) => { + const vtkCamera = viewport.getVtkActiveCamera(); + const viewUp = vtkCamera.getViewUp(); + const focalPoint = vtkCamera.getFocalPoint(); + const position = vtkCamera.getPosition(); + + const newPosition: Types.Point3 = [0, 0, 0]; + const newFocalPoint: Types.Point3 = [0, 0, 0]; + const newViewUp: Types.Point3 = [0, 0, 0]; + + const transform = mat4.identity(new Float32Array(16)); + mat4.translate(transform, transform, centerWorld); + mat4.rotate(transform, transform, angle, axis); + mat4.translate(transform, transform, [ + -centerWorld[0], + -centerWorld[1], + -centerWorld[2], + ]); + vec3.transformMat4(newPosition, position, transform); + vec3.transformMat4(newFocalPoint, focalPoint, transform); + + mat4.identity(transform); + mat4.rotate(transform, transform, angle, axis); + vec3.transformMat4(newViewUp, viewUp, transform); + + viewport.setCamera({ + position: newPosition, + viewUp: newViewUp, + focalPoint: newFocalPoint, + }); + }; +} + +VolumeCroppingTool.toolName = 'VolumeCropping'; +export default VolumeCroppingTool; diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts index 35393622f9..186d2c7d76 100644 --- a/packages/tools/src/tools/index.ts +++ b/packages/tools/src/tools/index.ts @@ -1,6 +1,8 @@ import { BaseTool, AnnotationTool, AnnotationDisplayTool } from './base'; import PanTool from './PanTool'; import TrackballRotateTool from './TrackballRotateTool'; +import VolumeCroppingTool from './VolumeCroppingTool'; +import VolumeCroppingControlTool from './VolumeCroppingControlTool'; import WindowLevelTool from './WindowLevelTool'; import WindowLevelRegionTool from './WindowLevelRegionTool'; import StackScrollTool from './StackScrollTool'; @@ -72,6 +74,8 @@ export { // Manipulation Tools PanTool, TrackballRotateTool, + VolumeCroppingTool, + VolumeCroppingControlTool, DragProbeTool, WindowLevelTool, WindowLevelRegionTool, diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 5e8b400798..cc28841d9b 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -163,6 +163,10 @@ "name": "3D Volume Picking", "description": "Demonstrates how to use the VTK.js vtkCellPicker object to pick 3D point in volume rendering scene. Also shows how to synchronize between 3D and 2D viewports." }, + "volumeCroppingTool": { + "name": "3D Volume Cropping", + "description": "Demonstrates how to use the VolumeCropping and VolumeCroppingControl tools." + }, "multipleToolGroups": { "name": "Multiple Tool Groups", "description": "Demonstrates the usage of multiple tool groups for a set of viewports."