From fab32ed24d8fb91ac81d39cc844fd4a6d5e382b3 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 16 Apr 2025 08:43:01 +0200 Subject: [PATCH 001/132] feat(volumeCropping): Add 3D Volume Cropping tool example --- .../examples/volumeCroppingTool/index.ts | 174 +++++++++++++++ .../tools/src/tools/VolumeCroppingTool.ts | 202 ++++++++++++++++++ utils/ExampleRunner/example-info.json | 4 + 3 files changed, 380 insertions(+) create mode 100644 packages/tools/examples/volumeCroppingTool/index.ts create mode 100644 packages/tools/src/tools/VolumeCroppingTool.ts diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts new file mode 100644 index 0000000000..09f2a777ab --- /dev/null +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -0,0 +1,174 @@ +import type { Types } from '@cornerstonejs/core'; +import { + CONSTANTS, + Enums, + getRenderingEngine, + RenderingEngine, + setVolumesForViewports, + volumeLoader, +} from '@cornerstonejs/core'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +import { + addButtonToToolbar, + addDropdownToToolbar, + addManipulationBindings, + createImageIdsAndCacheMetaData, + initDemo, + setTitleAndDescription, +} from '../../../../utils/demo/helpers'; + +import VolumeCroppingTool from '../../src/tools/VolumeCroppingTool'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { ToolGroupManager, Enums: csToolsEnums } = cornerstoneTools; + +const { ViewportType } = Enums; +const { MouseBindings } = csToolsEnums; + +// Define a unique id for the volume +let renderingEngine; +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 renderingEngineId = 'myRenderingEngine'; +const viewportId = '3D_VIEWPORT'; + +// ======== Set up page ======== // +setTitleAndDescription( + '3D Volume Cropping', + 'Here we demonstrate how to crop a 3D volume.' +); + +const size = '500px'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; + +const element1 = document.createElement('div'); +element1.oncontextmenu = () => false; + +element1.style.width = size; +element1.style.height = size; + +viewportGrid.appendChild(element1); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = 'Click the image to rotate it.'; + +content.append(instructions); + +addButtonToToolbar({ + title: 'Apply random rotation', + onClick: () => { + // Get the rendering engine + const renderingEngine = getRenderingEngine(renderingEngineId); + + // Get the volume viewport + const viewport = renderingEngine.getViewport( + viewportId + ) as Types.IVolumeViewport; + + // Apply the rotation to the camera of the viewport + viewport.setViewPresentation({ rotation: Math.random() * 360 }); + viewport.render(); + }, +}); + +addDropdownToToolbar({ + options: { + values: CONSTANTS.VIEWPORT_PRESETS.map((preset) => preset.name), + defaultValue: 'CT-Bone', + }, + onSelectedValueChange: (presetName) => { + viewport.setProperties({ preset: presetName }); + viewport.render(); + }, +}); + +// ============================= // + +let viewport; + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + const toolGroupId = 'TOOL_GROUP_ID'; + + // Define a tool group, which defines how mouse events map to tool commands for + // Any viewport using the group + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Add the tools to the tool group and specify which volume they are pointing at + addManipulationBindings(toolGroup, { + is3DViewport: true, + }); + cornerstoneTools.addTool(VolumeCroppingTool); + toolGroup.addTool(VolumeCroppingTool.toolName); + toolGroup.setToolActive(VolumeCroppingTool.toolName); + + // Get Cornerstone imageIds and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.367700692008930469189923116409', + wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + // Instantiate a rendering engine + renderingEngine = new RenderingEngine(renderingEngineId); + + // Create the viewports + + const viewportInputArray = [ + { + viewportId: viewportId, + type: ViewportType.VOLUME_3D, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + // background: CONSTANTS.BACKGROUND_COLORS.slicer3D, + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + // Set the tool group on the viewports + toolGroup.addViewport(viewportId, renderingEngineId); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + // Set the volume to load + volume.load(); + viewport = renderingEngine.getViewport(viewportId); + + await setVolumesForViewports( + renderingEngine, + [{ volumeId }], + [viewportId] + ).then(() => { + viewport.setProperties({ + preset: 'CT-Bone', + }); + viewport.render(); + }); +} + +run(); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts new file mode 100644 index 0000000000..5811d252b0 --- /dev/null +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -0,0 +1,202 @@ +// https://github.com/Kitware/vtk-js/blob/d15d50f8ba87704865b725be870c3316da2a7078/Sources/Widgets/Widgets3D/ImageCroppingWidget/index.js#L195 + +import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; +import type { + ImageCroppingWidgetState, + vtkImageCroppingViewWidget, +} from '@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget'; +import vtkImageCroppingWidget from '@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget'; + +import { BaseTool } from './base'; +import { + Enums, + eventTarget, + getEnabledElementByIds, + getRenderingEngines, +} from '@cornerstonejs/core'; + +import { filterViewportsWithToolEnabled } from '../utilities/viewportFilters'; +import { getToolGroup } from '../store/ToolGroupManager'; +import { Events } from '../enums'; + +class VolumeCroppingTool extends BaseTool { + static toolName; + constructor( + toolProps = {}, + defaultToolProps = { + configuration: {}, + } + ) { + super(toolProps, defaultToolProps); + } + + onSetToolEnabled = (): void => { + console.debug('VolumeCroppingTool: onSetToolEnabled', this.toolGroupId); + this.initViewports(); + this._subscribeToViewportEvents(); + }; + + onSetToolActive = (): void => { + console.debug('VolumeCroppingTool: onSetToolActive', this.toolGroupId); + this.initViewports(); + this._subscribeToViewportEvents(); + }; + + onSetToolDisabled = (): void => { + // this.cleanUpData(); + // this._unsubscribeToViewportNewVolumeSet(); + }; + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + return viewports; + }; + + _subscribeToViewportEvents() { + const subscribeToElementResize = () => { + const viewportsInfo = this._getViewportsInfo(); + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + const { viewport } = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + const { element } = viewport; + this.initViewports(); + + element.addEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + this.initViewports.bind(this) + ); + /* + const resizeObserver = new ResizeObserver(() => { + // Todo: i wish there was a better way to do this + setTimeout(() => { + const element = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!element) { + return; + } + const { viewport } = element; + this.resize(viewportId); + viewport.render(); + }, 100); + }); + + resizeObserver.observe(element); + + this._resizeObservers.set(viewportId, resizeObserver); +*/ + }); + }; + + subscribeToElementResize(); + + eventTarget.addEventListener(Events.TOOLGROUP_VIEWPORT_ADDED, (evt) => { + if (evt.detail.toolGroupId !== this.toolGroupId) { + return; + } + + subscribeToElementResize(); + this.initViewports(); + }); + } + + private initViewports() { + const renderingEngines = getRenderingEngines(); + const renderingEngine = renderingEngines[0]; + + if (!renderingEngine) { + return; + } + + let viewports = renderingEngine.getViewports(); + viewports = filterViewportsWithToolEnabled(viewports, this.getToolName()); + + viewports.forEach((viewport) => { + const renderer = viewport.getRenderer(); + const renderWindow = viewport + .getRenderingEngine() + .offscreenMultiRenderWindow.getRenderWindow(); + const widget = viewport.getWidget(this.getToolName()); + // testing if widget has been deleted + if (!widget || widget.isDeleted()) { + this.addCropToolInViewport(viewport); + renderer.resetCamera(); + renderer.resetCameraClippingRange(); + } + + renderWindow.render(); + // viewport.getRenderingEngine().render(); + }); + } + + async addCropToolInViewport(viewport) { + //const viewportId = viewport.id; + // const renderer = viewport.getRenderer(); + //const renderWindow = viewport + // .getRenderingEngine() + // .offscreenMultiRenderWindow.getRenderWindow(); + const widgetManager = vtkWidgetManager.newInstance(); + + widgetManager.setRenderer(viewport.getRenderer()); + + const cropWidget = vtkImageCroppingWidget.newInstance(); + const state = cropWidget.getWidgetState() as ImageCroppingWidgetState; + cropWidget.setEdgeHandlesEnabled(false); + cropWidget.setFaceHandlesEnabled(true); + cropWidget.setCornerHandlesEnabled(true); + const widget = widgetManager.addWidget( + cropWidget + ) as vtkImageCroppingViewWidget; + console.debug('VolumeCroppingTool state:', state); + /* Initial widget register + //widgetRegistration(); + //const action = e ? e.currentTarget.dataset.action : 'addWidget'; + const viewWidget = widgetManager['addWidget'](cropWidget); + if (viewWidget) { + viewWidget.setDisplayCallback((coords) => { + if (coords) { + console.debug('VolumeCroppingTool: coords', coords); + const [w, h] = apiRenderWindow.getSize(); + } + }); + } +*/ + widgetManager.enablePicking(); + + console.debug('VolumeCroppingTool: widget created'); + } +} + +function widgetRegistration(e) { + const action = e ? e.currentTarget.dataset.action : 'addWidget'; + const viewWidget = widgetManager[action](widget); + if (viewWidget) { + viewWidget.setDisplayCallback((coords) => { + overlay.style.left = '-100px'; + if (coords) { + const [w, h] = apiRenderWindow.getSize(); + overlay.style.left = `${Math.round( + (coords[0][0] / w) * window.innerWidth - + overlaySize * 0.5 - + overlayBorder + )}px`; + overlay.style.top = `${Math.round( + ((h - coords[0][1]) / h) * window.innerHeight - + overlaySize * 0.5 - + overlayBorder + )}px`; + } + }); + + renderer.resetCamera(); + renderer.resetCameraClippingRange(); + } + widgetManager.enablePicking(); + renderWindow.render(); +} + +VolumeCroppingTool.toolName = 'VolumeCropping'; +export default VolumeCroppingTool; diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 626200b4db..f8701a2474 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 VTK.js vtkImageCroppingWidget." + }, "multipleToolGroups": { "name": "Multiple Tool Groups", "description": "Demonstrates the usage of multiple tool groups for a set of viewports." From 386e24d5b3d57955d8d97b79db6b5ada551af987 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:51:31 +0200 Subject: [PATCH 002/132] feat(volumeCropping): Enhance 3D Volume Cropping with Trackball rotation and clipping planes --- .../examples/volumeCroppingTool/index.ts | 194 +++++++++++++++--- .../tools/src/tools/TrackballRotateTool.ts | 83 ++++++++ 2 files changed, 245 insertions(+), 32 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 09f2a777ab..40cecbbc3f 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -12,12 +12,14 @@ import { addButtonToToolbar, addDropdownToToolbar, addManipulationBindings, + addSliderToToolbar, createImageIdsAndCacheMetaData, initDemo, setTitleAndDescription, } from '../../../../utils/demo/helpers'; -import VolumeCroppingTool from '../../src/tools/VolumeCroppingTool'; +import TrackballRotateTool from '../../src/tools/TrackballRotateTool'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; // This is for debugging purposes console.warn( @@ -40,7 +42,7 @@ const viewportId = '3D_VIEWPORT'; // ======== Set up page ======== // setTitleAndDescription( '3D Volume Cropping', - 'Here we demonstrate how to crop a 3D volume.' + 'Here we demonstrate how to crop a 3D volume along 6 planes controlled by sliders.' ); const size = '500px'; @@ -66,36 +68,18 @@ instructions.innerText = 'Click the image to rotate it.'; content.append(instructions); -addButtonToToolbar({ - title: 'Apply random rotation', - onClick: () => { - // Get the rendering engine - const renderingEngine = getRenderingEngine(renderingEngineId); +function setClippingPlane(planeIndex, newDisplayThreshold, axis, dimensions) { + const mapper = viewport.getDefaultActor().actor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + const origin = [0, 0, 0]; + origin[axis] = newDisplayThreshold; + // if TrackballRotateTool is in use, rotate the plane. - // Get the volume viewport - const viewport = renderingEngine.getViewport( - viewportId - ) as Types.IVolumeViewport; - - // Apply the rotation to the camera of the viewport - viewport.setViewPresentation({ rotation: Math.random() * 360 }); - viewport.render(); - }, -}); - -addDropdownToToolbar({ - options: { - values: CONSTANTS.VIEWPORT_PRESETS.map((preset) => preset.name), - defaultValue: 'CT-Bone', - }, - onSelectedValueChange: (presetName) => { - viewport.setProperties({ preset: presetName }); - viewport.render(); - }, -}); + clippingPlanes[planeIndex].setOrigin(origin); + viewport.render(); +} // ============================= // - let viewport; /** @@ -115,9 +99,9 @@ async function run() { addManipulationBindings(toolGroup, { is3DViewport: true, }); - cornerstoneTools.addTool(VolumeCroppingTool); - toolGroup.addTool(VolumeCroppingTool.toolName); - toolGroup.setToolActive(VolumeCroppingTool.toolName); + cornerstoneTools.addTool(cornerstoneTools.TrackballRotateTool); + toolGroup.addTool(TrackballRotateTool.toolName); + toolGroup.setToolActive(TrackballRotateTool.toolName); // Get Cornerstone imageIds and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ @@ -154,6 +138,19 @@ async function run() { const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds, }); + // Get the vtkImageData from the volume + const imageData = volume.imageData; + const dimensions = imageData.getDimensions(); // [xDim, yDim, zDim] + console.log('Volume dimensions:', dimensions); + + // Log the world dimensions (physical size) + const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] + const worldDimensions = [ + Math.round(dimensions[0] * spacing[0]), + Math.round(dimensions[1] * spacing[1]), + Math.round(dimensions[2] * spacing[2]), + ]; + console.log('Volume world dimensions (mm):', worldDimensions); // Set the volume to load volume.load(); @@ -167,6 +164,139 @@ async function run() { viewport.setProperties({ preset: 'CT-Bone', }); + const mapper = viewport.getDefaultActor().actor.getMapper(); + const xMin = worldDimensions[0] * -0.5; + const xMax = worldDimensions[0] * 0.5; + const yMin = worldDimensions[1] * -0.5; + const yMax = worldDimensions[1] * 0.5; + const zMin = -worldDimensions[2]; + const zMax = 0; + const planes: vtkPlane[] = []; + + // X min plane (cuts everything left of xMin) + const planeXmin = vtkPlane.newInstance({ + origin: [xMin, 0, 0], + normal: [1, 0, 0], + }); + mapper.addClippingPlane(planeXmin); + addSliderToToolbar({ + title: ' x-min: ' + xMin, + range: [xMin, xMax], + defaultValue: xMin, + updateLabelOnChange(value, label) { + label.innerText = ` x-min: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(0, newDisplayThreshold, 0, dimensions); + }, + }); + planes.push(planeXmin); + + // X max plane (cuts everything right of xMax) + const planeXmax = vtkPlane.newInstance({ + origin: [xMax, 0, 0], + normal: [-1, 0, 0], + }); + mapper.addClippingPlane(planeXmax); + addSliderToToolbar({ + title: ' x-max: ' + xMax, + range: [xMin, xMax], + defaultValue: xMax, + updateLabelOnChange(value, label) { + label.innerText = ` x-max: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(1, newDisplayThreshold, 0, dimensions); + }, + }); + planes.push(planeXmax); + + // Y min plane + const planeYmin = vtkPlane.newInstance({ + origin: [0, yMin, 0], + normal: [0, 1, 0], + }); + addSliderToToolbar({ + title: ' y-min: ' + yMin, + range: [yMin, yMax], + defaultValue: yMin, + updateLabelOnChange(value, label) { + label.innerText = ` y-min: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(2, newDisplayThreshold, 1, dimensions); + }, + }); + mapper.addClippingPlane(planeYmin); + planes.push(planeYmin); + + // Y max plane + const planeYmax = vtkPlane.newInstance({ + origin: [0, yMax, 0], + normal: [0, -1, 0], + }); + mapper.addClippingPlane(planeYmax); + addSliderToToolbar({ + title: ' y-max: ' + yMax, + range: [yMin, yMax], + defaultValue: yMax, + updateLabelOnChange(value, label) { + label.innerText = ` y-max: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(3, newDisplayThreshold, 1, dimensions); + }, + }); + planes.push(planeYmax); + + // Z min plane + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, zMin], + normal: [0, 0, 1], + }); + mapper.addClippingPlane(planeZmin); + addSliderToToolbar({ + title: ' z-min: ' + zMin, + range: [zMin, zMax], + defaultValue: zMin, + updateLabelOnChange(value, label) { + label.innerText = ` z-min: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(4, newDisplayThreshold, 2, dimensions); + }, + }); + planes.push(planeZmin); + + // Z max plane + const planeZmax = vtkPlane.newInstance({ + origin: [0, 0, zMax], + normal: [0, 0, -1], + }); + mapper.addClippingPlane(planeZmax); + addSliderToToolbar({ + title: ' z-max: ' + zMax, + range: [zMin, zMax], + defaultValue: zMax, + updateLabelOnChange(value, label) { + label.innerText = ` z-max: ${value} `; + }, + onSelectedValueChange: (newDisplayThreshold) => { + setClippingPlane(5, newDisplayThreshold, 2, dimensions); + }, + }); + planes.push(planeZmax); + + const originalPlanes = planes.map((plane) => ({ + origin: [...plane.getOrigin()], + normal: [...plane.getNormal()], + })); + const trackballRotateTool = toolGroup.getToolInstance( + TrackballRotateTool.toolName + ); + trackballRotateTool.setOriginalClippingPlanes(viewport, originalPlanes); + + console.debug('index planes: ', originalPlanes); viewport.render(); }); } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 07b2bb9da3..9b1f2e2ebc 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -1,4 +1,5 @@ import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import { Events } from '../enums'; import { eventTarget, @@ -20,6 +21,8 @@ class TrackballRotateTool extends BaseTool { // eslint-disable-next-line @typescript-eslint/no-explicit-any _viewportAddedListener: (evt: any) => void; _hasResolutionChanged = false; + // Store original (axis-aligned) clipping planes for each viewport + _originalClippingPlanes = new Map(); constructor( toolProps: PublicToolProps = {}, @@ -34,7 +37,15 @@ class TrackballRotateTool extends BaseTool { this.touchDragCallback = this._dragCallback.bind(this); this.mouseDragCallback = this._dragCallback.bind(this); } + // Set the original planes for a viewport + setOriginalClippingPlanes(viewport, planes) { + this._originalClippingPlanes = planes; + } + // Set the original planes for a viewport + getOriginalClippingPlanes(viewport) { + return this._originalClippingPlanes; + } preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -148,6 +159,74 @@ class TrackballRotateTool extends BaseTool { } }; + // Helper to transform a normal by a 3x3 matrix + _transformNormal(normal, mat) { + return [ + mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], + mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], + mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], + ]; + } + + // Update all clipping planes after rotation + _updateClippingPlanes(viewport) { + const actorEntry = viewport.getDefaultActor(); + const actor = actorEntry.actor as Types.VolumeActor; + const mapper = actor.getMapper(); + const matrix = actor.getMatrix(); + //console.debug('_updateClippingPlanes: ', mapper.getClippingPlanes()); + // Extract rotation part for normals + const rot = [ + matrix[0], + matrix[1], + matrix[2], + matrix[4], + matrix[5], + matrix[6], + matrix[8], + matrix[9], + matrix[10], + ]; + + let originalPlanes = this.getOriginalClippingPlanes(viewport.id); + if (!originalPlanes || originalPlanes.length === 0) { + originalPlanes = planes.map((plane) => ({ + origin: [...plane.getOrigin()], + normal: [...plane.getNormal()], + })); + + // this.setOriginalClippingPlanes(viewport.id, mapper.getClippingPlanes()); + } + + console.log('Updating clipping planes for viewport:', viewport.id); + mapper.removeAllClippingPlanes(); + //originalPlanes = this._originalClippingPlanes.get(viewport.id); + //originalPlanes = this.getOriginalClippingPlanes(viewport.id); + originalPlanes.forEach(({ origin, normal }) => { + // Transform origin (full 4x4) + const o = [ + matrix[0] * origin[0] + + matrix[4] * origin[1] + + matrix[8] * origin[2] + + matrix[12], + matrix[1] * origin[0] + + matrix[5] * origin[1] + + matrix[9] * origin[2] + + matrix[13], + matrix[2] * origin[0] + + matrix[6] * origin[1] + + matrix[10] * origin[2] + + matrix[14], + ]; + // Transform normal (rotation only) + const n = this._transformNormal(normal, rot); + const plane = vtkPlane.newInstance({ origin: o, normal: n }); + console.debug('Adding plane:', origin, plane); + mapper.addClippingPlane(plane); + }); + const _planesAfter = mapper.getClippingPlanes(); + } + rotateCamera = (viewport, centerWorld, axis, angle) => { const vtkCamera = viewport.getVtkActiveCamera(); const viewUp = vtkCamera.getViewUp(); @@ -178,6 +257,10 @@ class TrackballRotateTool extends BaseTool { viewUp: newViewUp, focalPoint: newFocalPoint, }); + console.debug('Rotating camera', viewport); + + // Update clipping planes after rotation + this._updateClippingPlanes(viewport); }; _dragCallback(evt: EventTypes.InteractionEventType): void { From b47fa33843c5cfea2682749f6d3ce75a3c016cea Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:36:52 +0200 Subject: [PATCH 003/132] feat(volumeCropping): Implement original clipping planes management in VolumeViewport3D and update ZoomTool to handle clipping planes during zoom --- .../src/RenderingEngine/VolumeViewport3D.ts | 20 +++++- .../examples/volumeCroppingTool/index.ts | 29 ++++----- packages/tools/src/tools/ZoomTool.ts | 64 +++++++++++++++++++ 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index da477d981b..5317e21b1e 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -10,7 +10,7 @@ import type vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import type { ViewportInput } from '../types/IViewport'; import type { ImageActor } from '../types/IActor'; import BaseVolumeViewport from './BaseVolumeViewport'; - +import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; /** * An object representing a 3-dimensional volume viewport. VolumeViewport3Ds are used to render * 3D volumes in their entirety, and not just load a single slice at a time. @@ -19,6 +19,9 @@ import BaseVolumeViewport from './BaseVolumeViewport'; * which will add volumes to the specified viewports. */ class VolumeViewport3D extends BaseVolumeViewport { + // Store original (axis-aligned) clipping planes for each viewport + _originalClippingPlanes: vtkPlane[] = []; + constructor(props: ViewportInput) { super(props); @@ -35,6 +38,21 @@ class VolumeViewport3D extends BaseVolumeViewport { } } + // Set the original planes for a viewport + public setOriginalClippingPlanes(planes) { + this._originalClippingPlanes = planes; + } + + // Set the original planes for a viewport + public setOriginalClippingPlane(index, origin) { + this._originalClippingPlanes[index].origin = origin; + } + + // Set the original planes for a viewport + public getOriginalClippingPlanes() { + return this._originalClippingPlanes; + } + public getNumberOfSlices = (): number => { return 1; }; diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 40cecbbc3f..17fbe6a245 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -41,8 +41,8 @@ const viewportId = '3D_VIEWPORT'; // ======== Set up page ======== // setTitleAndDescription( - '3D Volume Cropping', - 'Here we demonstrate how to crop a 3D volume along 6 planes controlled by sliders.' + 'Volume Cropping', + 'Here we demonstrate how to crop a 3D volume along 6 planes.' ); const size = '500px'; @@ -68,14 +68,11 @@ instructions.innerText = 'Click the image to rotate it.'; content.append(instructions); -function setClippingPlane(planeIndex, newDisplayThreshold, axis, dimensions) { +function setClippingPlane(planeIndex, origin) { const mapper = viewport.getDefaultActor().actor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); - const origin = [0, 0, 0]; - origin[axis] = newDisplayThreshold; - // if TrackballRotateTool is in use, rotate the plane. - clippingPlanes[planeIndex].setOrigin(origin); + viewport.setOriginalClippingPlane(planeIndex, origin); viewport.render(); } @@ -187,7 +184,7 @@ async function run() { label.innerText = ` x-min: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(0, newDisplayThreshold, 0, dimensions); + setClippingPlane(0, [newDisplayThreshold, 0, 0]); }, }); planes.push(planeXmin); @@ -206,7 +203,7 @@ async function run() { label.innerText = ` x-max: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(1, newDisplayThreshold, 0, dimensions); + setClippingPlane(1, [newDisplayThreshold, 0, 0]); }, }); planes.push(planeXmax); @@ -224,7 +221,7 @@ async function run() { label.innerText = ` y-min: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(2, newDisplayThreshold, 1, dimensions); + setClippingPlane(2, [0, newDisplayThreshold, 0]); }, }); mapper.addClippingPlane(planeYmin); @@ -244,7 +241,7 @@ async function run() { label.innerText = ` y-max: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(3, newDisplayThreshold, 1, dimensions); + setClippingPlane(3, [0, newDisplayThreshold, 0]); }, }); planes.push(planeYmax); @@ -263,7 +260,7 @@ async function run() { label.innerText = ` z-min: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(4, newDisplayThreshold, 2, dimensions); + setClippingPlane(4, [0, 0, newDisplayThreshold]); }, }); planes.push(planeZmin); @@ -282,7 +279,7 @@ async function run() { label.innerText = ` z-max: ${value} `; }, onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(5, newDisplayThreshold, 2, dimensions); + setClippingPlane(5, [0, 0, newDisplayThreshold]); }, }); planes.push(planeZmax); @@ -291,12 +288,8 @@ async function run() { origin: [...plane.getOrigin()], normal: [...plane.getNormal()], })); - const trackballRotateTool = toolGroup.getToolInstance( - TrackballRotateTool.toolName - ); - trackballRotateTool.setOriginalClippingPlanes(viewport, originalPlanes); - console.debug('index planes: ', originalPlanes); + viewport.setOriginalClippingPlanes(originalPlanes); viewport.render(); }); } diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 513fb3f32b..51317f33ca 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -1,5 +1,6 @@ import { vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import type { Types } from '@cornerstonejs/core'; import { getEnabledElement } from '@cornerstonejs/core'; import { BaseTool } from './base'; @@ -45,6 +46,64 @@ class ZoomTool extends BaseTool { } this.mouseDragCallback = this._dragCallback.bind(this); } + // Helper to transform a normal by a 3x3 matrix + _transformNormal(normal, mat) { + return [ + mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], + mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], + mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], + ]; + } + + _updateClippingPlanes(viewport) { + // Get the actor and transformation matrix + const actorEntry = viewport.getDefaultActor(); + const actor = actorEntry.actor; + const mapper = actor.getMapper(); + const matrix = actor.getMatrix(); + + // Extract rotation part for normals + const rot = [ + matrix[0], + matrix[1], + matrix[2], + matrix[4], + matrix[5], + matrix[6], + matrix[8], + matrix[9], + matrix[10], + ]; + + // Get original planes from the viewport (VolumeViewport3D) + const originalPlanes = viewport.getOriginalClippingPlanes?.(); + if (!originalPlanes || !originalPlanes.length) { + return; + } + + mapper.removeAllClippingPlanes(); + originalPlanes.forEach(({ origin, normal }) => { + // Transform origin (full 4x4) + const o = [ + matrix[0] * origin[0] + + matrix[4] * origin[1] + + matrix[8] * origin[2] + + matrix[12], + matrix[1] * origin[0] + + matrix[5] * origin[1] + + matrix[9] * origin[2] + + matrix[13], + matrix[2] * origin[0] + + matrix[6] * origin[1] + + matrix[10] * origin[2] + + matrix[14], + ]; + // Transform normal (rotation only) + const n = this._transformNormal(normal, rot); + const plane = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(plane); + }); + } preMouseDownCallback = (evt: EventTypes.InteractionEventType): boolean => { const eventData = evt.detail; @@ -110,6 +169,9 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera, true); } + // Update clipping planes after zoom + this._updateClippingPlanes(viewport); + viewport.render(); } @@ -131,6 +193,8 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera); } + // Update clipping planes after zoom + this._updateClippingPlanes(viewport); viewport.render(); } From 54f585f0d3fea8a5c3e54a8bb6714a5cab8aef86 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:46:36 +0200 Subject: [PATCH 004/132] feat(volumeCropping): Enhance VolumeCroppingTool with mouse and touch drag callbacks for improved interaction --- packages/tools/src/tools/PanTool.ts | 59 ++++ .../tools/src/tools/TrackballRotateTool.ts | 21 +- .../tools/src/tools/VolumeCroppingTool.ts | 324 ++++++++++++------ 3 files changed, 273 insertions(+), 131 deletions(-) diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index ff96b39c4f..00c09e7c5b 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -1,6 +1,7 @@ import { BaseTool } from './base'; import { getEnabledElement } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; @@ -25,6 +26,62 @@ class PanTool extends BaseTool { mouseDragCallback(evt: EventTypes.InteractionEventType) { this._dragCallback(evt); } + _transformNormal(normal, mat) { + return [ + mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], + mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], + mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], + ]; + } + + _updateClippingPlanes(viewport) { + const actorEntry = viewport.getDefaultActor(); + const actor = actorEntry.actor; + const mapper = actor.getMapper(); + const matrix = actor.getMatrix(); + + // Extract rotation part for normals + const rot = [ + matrix[0], + matrix[1], + matrix[2], + matrix[4], + matrix[5], + matrix[6], + matrix[8], + matrix[9], + matrix[10], + ]; + + // Get original planes from the viewport (VolumeViewport3D) + const originalPlanes = viewport.getOriginalClippingPlanes?.(); + if (!originalPlanes || !originalPlanes.length) { + return; + } + + mapper.removeAllClippingPlanes(); + originalPlanes.forEach(({ origin, normal }) => { + // Transform origin (full 4x4) + const o = [ + matrix[0] * origin[0] + + matrix[4] * origin[1] + + matrix[8] * origin[2] + + matrix[12], + matrix[1] * origin[0] + + matrix[5] * origin[1] + + matrix[9] * origin[2] + + matrix[13], + matrix[2] * origin[0] + + matrix[6] * origin[1] + + matrix[10] * origin[2] + + matrix[14], + ]; + // Transform normal (rotation only) + const n = this._transformNormal(normal, rot); + const plane = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(plane); + }); + } _dragCallback(evt: EventTypes.InteractionEventType) { const { element, deltaPoints } = evt.detail; @@ -58,6 +115,8 @@ class PanTool extends BaseTool { focalPoint: updatedFocalPoint, position: updatedPosition, }); + // Update clipping planes after pan + this._updateClippingPlanes(enabledElement.viewport); enabledElement.viewport.render(); } } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 9b1f2e2ebc..dd537d4502 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -21,8 +21,6 @@ class TrackballRotateTool extends BaseTool { // eslint-disable-next-line @typescript-eslint/no-explicit-any _viewportAddedListener: (evt: any) => void; _hasResolutionChanged = false; - // Store original (axis-aligned) clipping planes for each viewport - _originalClippingPlanes = new Map(); constructor( toolProps: PublicToolProps = {}, @@ -37,15 +35,7 @@ class TrackballRotateTool extends BaseTool { this.touchDragCallback = this._dragCallback.bind(this); this.mouseDragCallback = this._dragCallback.bind(this); } - // Set the original planes for a viewport - setOriginalClippingPlanes(viewport, planes) { - this._originalClippingPlanes = planes; - } - // Set the original planes for a viewport - getOriginalClippingPlanes(viewport) { - return this._originalClippingPlanes; - } preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -174,7 +164,6 @@ class TrackballRotateTool extends BaseTool { const actor = actorEntry.actor as Types.VolumeActor; const mapper = actor.getMapper(); const matrix = actor.getMatrix(); - //console.debug('_updateClippingPlanes: ', mapper.getClippingPlanes()); // Extract rotation part for normals const rot = [ matrix[0], @@ -188,20 +177,15 @@ class TrackballRotateTool extends BaseTool { matrix[10], ]; - let originalPlanes = this.getOriginalClippingPlanes(viewport.id); + let originalPlanes = viewport.getOriginalClippingPlanes(); if (!originalPlanes || originalPlanes.length === 0) { originalPlanes = planes.map((plane) => ({ origin: [...plane.getOrigin()], normal: [...plane.getNormal()], })); - - // this.setOriginalClippingPlanes(viewport.id, mapper.getClippingPlanes()); } - console.log('Updating clipping planes for viewport:', viewport.id); mapper.removeAllClippingPlanes(); - //originalPlanes = this._originalClippingPlanes.get(viewport.id); - //originalPlanes = this.getOriginalClippingPlanes(viewport.id); originalPlanes.forEach(({ origin, normal }) => { // Transform origin (full 4x4) const o = [ @@ -221,10 +205,8 @@ class TrackballRotateTool extends BaseTool { // Transform normal (rotation only) const n = this._transformNormal(normal, rot); const plane = vtkPlane.newInstance({ origin: o, normal: n }); - console.debug('Adding plane:', origin, plane); mapper.addClippingPlane(plane); }); - const _planesAfter = mapper.getClippingPlanes(); } rotateCamera = (viewport, centerWorld, axis, angle) => { @@ -257,7 +239,6 @@ class TrackballRotateTool extends BaseTool { viewUp: newViewUp, focalPoint: newFocalPoint, }); - console.debug('Rotating camera', viewport); // Update clipping planes after rotation this._updateClippingPlanes(viewport); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 5811d252b0..bb41168ea7 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,50 +1,91 @@ // https://github.com/Kitware/vtk-js/blob/d15d50f8ba87704865b725be870c3316da2a7078/Sources/Widgets/Widgets3D/ImageCroppingWidget/index.js#L195 import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; -import type { +import vtkImageCroppingWidget, { ImageCroppingWidgetState, vtkImageCroppingViewWidget, } from '@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget'; -import vtkImageCroppingWidget from '@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget'; -import { BaseTool } from './base'; +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import { Events } from '../enums'; import { - Enums, eventTarget, + getEnabledElement, getEnabledElementByIds, - getRenderingEngines, } from '@cornerstonejs/core'; - -import { filterViewportsWithToolEnabled } from '../utilities/viewportFilters'; +import type { Types } from '@cornerstonejs/core'; +import { mat4, vec3 } from 'gl-matrix'; +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; +import { BaseTool } from './base'; import { getToolGroup } from '../store/ToolGroupManager'; -import { Events } from '../enums'; class VolumeCroppingTool extends BaseTool { static toolName; + touchDragCallback: (evt: EventTypes.InteractionEventType) => void; + mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; + cleanUp: () => void; + _resizeObservers = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _viewportAddedListener: (evt: any) => void; + _hasResolutionChanged = false; + constructor( - toolProps = {}, - defaultToolProps = { - configuration: {}, + toolProps: PublicToolProps = {}, + defaultToolProps: ToolProps = { + supportedInteractionTypes: ['Mouse', 'Touch'], + configuration: { + rotateIncrementDegrees: 2, + }, } ) { super(toolProps, defaultToolProps); + this.touchDragCallback = this._dragCallback.bind(this); + this.mouseDragCallback = this._dragCallback.bind(this); } - onSetToolEnabled = (): void => { - console.debug('VolumeCroppingTool: onSetToolEnabled', this.toolGroupId); - this.initViewports(); - this._subscribeToViewportEvents(); - }; + preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + 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(); - onSetToolActive = (): void => { - console.debug('VolumeCroppingTool: onSetToolActive', this.toolGroupId); - this.initViewports(); - this._subscribeToViewportEvents(); - }; + if (evt.detail.event.altKey) { + // volume cropping + const croppingWidget = vtkImageCroppingWidget.newInstance({}); + return; + } else { + // 3D rotation + const hasSampleDistance = + 'getSampleDistance' in mapper || 'getCurrentSampleDistance' in mapper; + + if (!hasSampleDistance) { + return true; + } + + const originalSampleDistance = mapper.getSampleDistance(); + + if (!this._hasResolutionChanged) { + mapper.setSampleDistance(originalSampleDistance * 4); + this._hasResolutionChanged = true; + + if (this.cleanUp !== null) { + // Clean up previous event listener + document.removeEventListener('mouseup', this.cleanUp); + } + + this.cleanUp = () => { + mapper.setSampleDistance(originalSampleDistance); + viewport.render(); + this._hasResolutionChanged = false; + }; - onSetToolDisabled = (): void => { - // this.cleanUpData(); - // this._unsubscribeToViewportNewVolumeSet(); + document.addEventListener('mouseup', this.cleanUp, { once: true }); + } + return true; + } }; _getViewportsInfo = () => { @@ -52,25 +93,23 @@ class VolumeCroppingTool extends BaseTool { return viewports; }; - _subscribeToViewportEvents() { + onSetToolActive = () => { const subscribeToElementResize = () => { const viewportsInfo = this._getViewportsInfo(); viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { - const { viewport } = getEnabledElementByIds( - viewportId, - renderingEngineId - ); - const { element } = viewport; - this.initViewports(); - - element.addEventListener( - Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, - this.initViewports.bind(this) - ); - /* - const resizeObserver = new ResizeObserver(() => { - // Todo: i wish there was a better way to do this - setTimeout(() => { + 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 @@ -79,97 +118,159 @@ class VolumeCroppingTool extends BaseTool { return; } const { viewport } = element; - this.resize(viewportId); - viewport.render(); - }, 100); - }); - resizeObserver.observe(element); + const viewPresentation = viewport.getViewPresentation(); - this._resizeObservers.set(viewportId, resizeObserver); -*/ + viewport.resetCamera(); + + viewport.setViewPresentation(viewPresentation); + viewport.render(); + }); + + resizeObserver.observe(element); + this._resizeObservers.set(viewportId, resizeObserver); + } }); }; subscribeToElementResize(); - eventTarget.addEventListener(Events.TOOLGROUP_VIEWPORT_ADDED, (evt) => { - if (evt.detail.toolGroupId !== this.toolGroupId) { - return; + this._viewportAddedListener = (evt) => { + if (evt.detail.toolGroupId === this.toolGroupId) { + subscribeToElementResize(); } + }; - subscribeToElementResize(); - this.initViewports(); - }); - } + eventTarget.addEventListener( + Events.TOOLGROUP_VIEWPORT_ADDED, + this._viewportAddedListener + ); + }; - private initViewports() { - const renderingEngines = getRenderingEngines(); - const renderingEngine = renderingEngines[0]; + onSetToolDisabled = () => { + // Disconnect all resize observers + this._resizeObservers.forEach((resizeObserver, viewportId) => { + resizeObserver.disconnect(); + this._resizeObservers.delete(viewportId); + }); - if (!renderingEngine) { - return; + if (this._viewportAddedListener) { + eventTarget.removeEventListener( + Events.TOOLGROUP_VIEWPORT_ADDED, + this._viewportAddedListener + ); + this._viewportAddedListener = null; // Clear the reference to the listener } + }; - let viewports = renderingEngine.getViewports(); - viewports = filterViewportsWithToolEnabled(viewports, this.getToolName()); - - viewports.forEach((viewport) => { - const renderer = viewport.getRenderer(); - const renderWindow = viewport - .getRenderingEngine() - .offscreenMultiRenderWindow.getRenderWindow(); - const widget = viewport.getWidget(this.getToolName()); - // testing if widget has been deleted - if (!widget || widget.isDeleted()) { - this.addCropToolInViewport(viewport); - renderer.resetCamera(); - renderer.resetCameraClippingRange(); - } + rotateCamera = (viewport, centerWorld, axis, angle) => { + const vtkCamera = viewport.getVtkActiveCamera(); + const viewUp = vtkCamera.getViewUp(); + const focalPoint = vtkCamera.getFocalPoint(); + const position = vtkCamera.getPosition(); - renderWindow.render(); - // viewport.getRenderingEngine().render(); + 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, }); - } + }; - async addCropToolInViewport(viewport) { - //const viewportId = viewport.id; - // const renderer = viewport.getRenderer(); - //const renderWindow = viewport - // .getRenderingEngine() - // .offscreenMultiRenderWindow.getRenderWindow(); - const widgetManager = vtkWidgetManager.newInstance(); - - widgetManager.setRenderer(viewport.getRenderer()); - - const cropWidget = vtkImageCroppingWidget.newInstance(); - const state = cropWidget.getWidgetState() as ImageCroppingWidgetState; - cropWidget.setEdgeHandlesEnabled(false); - cropWidget.setFaceHandlesEnabled(true); - cropWidget.setCornerHandlesEnabled(true); - const widget = widgetManager.addWidget( - cropWidget - ) as vtkImageCroppingViewWidget; - console.debug('VolumeCroppingTool state:', state); - /* Initial widget register - //widgetRegistration(); - //const action = e ? e.currentTarget.dataset.action : 'addWidget'; - const viewWidget = widgetManager['addWidget'](cropWidget); - if (viewWidget) { - viewWidget.setDisplayCallback((coords) => { - if (coords) { - console.debug('VolumeCroppingTool: coords', coords); - const [w, h] = apiRenderWindow.getSize(); - } - }); - } -*/ - widgetManager.enablePicking(); + _dragCallback(evt: EventTypes.InteractionEventType): void { + const { element, currentPoints, lastPoints } = evt.detail; + 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, + ]; - console.debug('VolumeCroppingTool: widget created'); + 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(); + } } } +/* function widgetRegistration(e) { const action = e ? e.currentTarget.dataset.action : 'addWidget'; const viewWidget = widgetManager[action](widget); @@ -197,6 +298,7 @@ function widgetRegistration(e) { widgetManager.enablePicking(); renderWindow.render(); } +*/ VolumeCroppingTool.toolName = 'VolumeCropping'; export default VolumeCroppingTool; From 4e3d725031286e2c28436102cf308501f2d214ff Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:10:21 +0200 Subject: [PATCH 005/132] refactor(volumeCropping): Clean up TrackballRotateTool integration and improve clipping planes handling --- packages/tools/examples/volumeCroppingTool/index.ts | 4 ---- packages/tools/src/tools/TrackballRotateTool.ts | 8 +++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 17fbe6a245..38da2e2dff 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -18,7 +18,6 @@ import { setTitleAndDescription, } from '../../../../utils/demo/helpers'; -import TrackballRotateTool from '../../src/tools/TrackballRotateTool'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; // This is for debugging purposes @@ -96,9 +95,6 @@ async function run() { addManipulationBindings(toolGroup, { is3DViewport: true, }); - cornerstoneTools.addTool(cornerstoneTools.TrackballRotateTool); - toolGroup.addTool(TrackballRotateTool.toolName); - toolGroup.setToolActive(TrackballRotateTool.toolName); // Get Cornerstone imageIds and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index dd537d4502..14465d6370 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -177,12 +177,9 @@ class TrackballRotateTool extends BaseTool { matrix[10], ]; - let originalPlanes = viewport.getOriginalClippingPlanes(); + const originalPlanes = viewport.getOriginalClippingPlanes(); if (!originalPlanes || originalPlanes.length === 0) { - originalPlanes = planes.map((plane) => ({ - origin: [...plane.getOrigin()], - normal: [...plane.getNormal()], - })); + return; } mapper.removeAllClippingPlanes(); @@ -204,6 +201,7 @@ class TrackballRotateTool extends BaseTool { ]; // Transform normal (rotation only) const n = this._transformNormal(normal, rot); + // const plane = vtkPlane.newInstance({ origin: o, normal: n }); const plane = vtkPlane.newInstance({ origin: o, normal: n }); mapper.addClippingPlane(plane); }); From b946aedacf1a42d386c8b9620cea3ad9dd4c289a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:43:09 +0200 Subject: [PATCH 006/132] Add VolumeCroppingTool to tools index - Imported VolumeCroppingTool in the tools index file. - Exported VolumeCroppingTool alongside other manipulation tools. --- .../examples/volumeCroppingTool/index.ts | 734 +++-- packages/tools/src/index.ts | 2 + .../tools/src/tools/VolumeCroppingTool.ts | 2910 +++++++++++++++-- packages/tools/src/tools/index.ts | 2 + 4 files changed, 3240 insertions(+), 408 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 38da2e2dff..4d7fe1426a 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -1,23 +1,34 @@ -import type { Types } from '@cornerstonejs/core'; +import type { + BaseVolumeViewport, + Types, + VolumeViewport3D, +} from '@cornerstonejs/core'; import { - CONSTANTS, - Enums, - getRenderingEngine, RenderingEngine, + Enums, setVolumesForViewports, volumeLoader, + getRenderingEngine, + eventTarget, } from '@cornerstonejs/core'; -import * as cornerstoneTools from '@cornerstonejs/tools'; +import { Enums as toolsEnums } from '@cornerstonejs/tools'; import { - addButtonToToolbar, - addDropdownToToolbar, - addManipulationBindings, - addSliderToToolbar, - createImageIdsAndCacheMetaData, initDemo, + createImageIdsAndCacheMetaData, setTitleAndDescription, + setCtTransferFunctionForVolumeActor, + addDropdownToToolbar, + addManipulationBindings, + getLocalUrl, + addToggleButtonToToolbar, + addButtonToToolbar, } from '../../../../utils/demo/helpers'; +import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; +import * as cornerstoneTools from '@cornerstonejs/tools'; +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'; // This is for debugging purposes @@ -25,58 +36,324 @@ console.warn( 'Click on index.ts to open source code for this example --------->' ); -const { ToolGroupManager, Enums: csToolsEnums } = cornerstoneTools; +const { + ToolGroupManager, + Enums: csToolsEnums, + VolumeCroppingTool, + synchronizers, + TrackballRotateTool, + ZoomTool, +} = cornerstoneTools; + +const { createSlabThicknessSynchronizer } = synchronizers; -const { ViewportType } = Enums; const { MouseBindings } = csToolsEnums; +const { ViewportType } = Enums; + +let sphereActor = undefined; // Define a unique id for the volume -let renderingEngine; 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 viewportId1 = 'CT_AXIAL'; +const viewportId2 = 'CT_SAGITTAL'; +const viewportId3 = 'CT_CORONAL'; +const viewportId4 = 'CT_3D_VOLUME'; // New 3D volume viewport +const viewportIds = [viewportId1, viewportId2, viewportId3, viewportId4]; const renderingEngineId = 'myRenderingEngine'; -const viewportId = '3D_VIEWPORT'; +const synchronizerId = 'SLAB_THICKNESS_SYNCHRONIZER_ID'; +/////////////////////////////////////// +const newToolGroupId = 'NEW_TOOL_GROUP_ID'; +///////////////////////////////////////// // ======== Set up page ======== // setTitleAndDescription( 'Volume Cropping', - 'Here we demonstrate how to crop a 3D volume along 6 planes.' + 'Here we demonstrate how to crop a 3D volume along 6 clipping planes aligned on the axises.' ); -const size = '500px'; +const size = '400px'; const content = document.getElementById('content'); const viewportGrid = document.createElement('div'); -viewportGrid.style.display = 'flex'; viewportGrid.style.display = 'flex'; viewportGrid.style.flexDirection = 'row'; - -const element1 = document.createElement('div'); -element1.oncontextmenu = () => false; - -element1.style.width = size; -element1.style.height = size; - -viewportGrid.appendChild(element1); +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 = '50%'; +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 = '50%'; +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 = 'Click the image to rotate it.'; +instructions.innerText = ` + Basic controls: + - Click/Drag anywhere in the viewport to move the center of the crosshairs. + - Drag a reference line to move it, scrolling the other views. + + Advanced controls: Hover over a line and find the following two handles: + - Square (closest to center): Drag these to change the thickness of the MIP slab in that plane. + - Circle (further from center): Drag these to rotate the axes. + `; content.append(instructions); -function setClippingPlane(planeIndex, origin) { - const mapper = viewport.getDefaultActor().actor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[planeIndex].setOrigin(origin); - viewport.setOriginalClippingPlane(planeIndex, origin); +addButtonToToolbar({ + title: 'Reset Camera', + onClick: () => { + const renderingEngine = getRenderingEngine(renderingEngineId); + + viewportIds.forEach((viewportId) => { + const viewport = renderingEngine.getViewport(viewportId); + viewport.resetCamera(); + viewport.render(); + }); + }, +}); + +// ============================= // +function addTemporaryPickedPositionLabel( + x: number, + y: number, + pickedPoint: Types.Point3 +) { + // Create a temporary div to show the coordinates + const coordDiv = document.createElement('div'); + coordDiv.style.position = 'absolute'; + coordDiv.style.top = `${y + 10}px`; + coordDiv.style.left = `${x + 10}px`; + coordDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; + coordDiv.style.color = 'white'; + coordDiv.style.padding = '5px'; + coordDiv.style.borderRadius = '3px'; + coordDiv.style.zIndex = '1000'; + coordDiv.style.pointerEvents = 'none'; + coordDiv.textContent = `X: ${pickedPoint[0].toFixed( + 2 + )}, Y: ${pickedPoint[1].toFixed(2)}, Z: ${pickedPoint[2].toFixed(2)}`; + + element4.appendChild(coordDiv); + + // Remove the div after a few seconds + setTimeout(() => { + if (element4.contains(coordDiv)) { + element4.removeChild(coordDiv); + } + }, 3000); +} + +function setCrossHairPosition(pickedPoint) { + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + const crosshairTool = toolGroup.getToolInstance(VolumeCroppingTool.toolName); + crosshairTool.setToolCenter(pickedPoint, true); +} + +function addSphere(viewport, point) { + if (!sphereActor) { + // Generate a random string for the sphere UID + const randomUID = 'sphere_' + Math.random().toString(36).substring(2, 15); + + const sphereSource = vtkSphereSource.newInstance(); + sphereSource.setCenter(point); + sphereSource.setRadius(5); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); + sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + viewport.addActor({ actor: sphereActor, uid: randomUID }); + } else { + sphereActor.getMapper().getInputConnection().filter.setCenter(point); + } + console.debug('actors:', viewport.getActors()); viewport.render(); } -// ============================= // -let viewport; +const viewportColors = { + [viewportId1]: 'rgb(200, 0, 0)', + [viewportId2]: 'rgb(200, 200, 0)', + [viewportId3]: 'rgb(0, 200, 0)', + [viewportId4]: 'rgb(0, 200, 200)', +}; + +let synchronizer; + +const viewportReferenceLineControllable = [ + viewportId1, + viewportId2, + viewportId3, + viewportId4, +]; + +const viewportReferenceLineDraggableRotatable = [ + viewportId1, + viewportId2, + viewportId3, + viewportId4, +]; + +const viewportReferenceLineSlabThicknessControlsOn = [ + viewportId1, + viewportId2, + viewportId3, + viewportId4, +]; + +function getReferenceLineColor(viewportId) { + return viewportColors[viewportId]; +} + +function getReferenceLineControllable(viewportId) { + const index = viewportReferenceLineControllable.indexOf(viewportId); + return index !== -1; +} + +function getReferenceLineDraggableRotatable(viewportId) { + const index = viewportReferenceLineDraggableRotatable.indexOf(viewportId); + return index !== -1; +} + +function getReferenceLineSlabThicknessControlsOn(viewportId) { + const index = + viewportReferenceLineSlabThicknessControlsOn.indexOf(viewportId); + return index !== -1; +} + +const blendModeOptions = { + MIP: 'Maximum Intensity Projection', + MINIP: 'Minimum Intensity Projection', + AIP: 'Average Intensity Projection', +}; + +addDropdownToToolbar({ + options: { + values: [ + 'Maximum Intensity Projection', + 'Minimum Intensity Projection', + 'Average Intensity Projection', + ], + defaultValue: 'Maximum Intensity Projection', + }, + onSelectedValueChange: (selectedValue) => { + let blendModeToUse; + switch (selectedValue) { + case blendModeOptions.MIP: + blendModeToUse = Enums.BlendModes.MAXIMUM_INTENSITY_BLEND; + break; + case blendModeOptions.MINIP: + blendModeToUse = Enums.BlendModes.MINIMUM_INTENSITY_BLEND; + break; + case blendModeOptions.AIP: + blendModeToUse = Enums.BlendModes.AVERAGE_INTENSITY_BLEND; + break; + default: + throw new Error('undefined orientation option'); + } + + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + const crosshairsInstance = toolGroup.getToolInstance( + VolumeCroppingTool.toolName + ); + const oldConfiguration = crosshairsInstance.configuration; + + crosshairsInstance.configuration = { + ...oldConfiguration, + slabThicknessBlendMode: blendModeToUse, + }; + + // Update the blendMode for actors to instantly reflect the change + toolGroup.viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport( + viewportId + ) as Types.IVolumeViewport; + + viewport.setBlendMode(blendModeToUse); + viewport.render(); + }); + + // Also update the 3D volume viewport + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport3D = renderingEngine.getViewport( + viewportId4 + ) as Types.IVolumeViewport; + viewport3D.setBlendMode(blendModeToUse); + viewport3D.render(); + }, +}); + +addToggleButtonToToolbar({ + id: 'syncSlabThickness', + title: 'Sync Slab Thickness', + defaultToggle: false, + onClick: (toggle) => { + synchronizer.setEnabled(toggle); + }, +}); + +function setUpSynchronizers() { + synchronizer = createSlabThicknessSynchronizer(synchronizerId); + + // Add viewports to VOI synchronizers + [viewportId1, viewportId2, viewportId3, viewportId4].forEach((viewportId) => { + synchronizer.add({ + renderingEngineId, + viewportId, + }); + }); + // Normally this would be left on, but here we are starting the demo in the + // default state, which is to not have a synchronizer enabled. + synchronizer.setEnabled(false); +} /** * Runs the demo @@ -85,78 +362,169 @@ async function run() { // Init Cornerstone and related libraries await initDemo(); - const toolGroupId = 'TOOL_GROUP_ID'; - - // Define a tool group, which defines how mouse events map to tool commands for - // Any viewport using the group - const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); - - // Add the tools to the tool group and specify which volume they are pointing at - addManipulationBindings(toolGroup, { - is3DViewport: true, + // Add tools to Cornerstone3D + cornerstoneTools.addTool(VolumeCroppingTool); + cornerstoneTools.addTool(TrackballRotateTool); + const newToolGroup = ToolGroupManager.createToolGroup(newToolGroupId); + newToolGroup.addTool(TrackballRotateTool.toolName); + newToolGroup.setToolActive(TrackballRotateTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); + newToolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, // Left Click + }, + ], }); - // Get Cornerstone imageIds and fetch metadata into RAM + // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: - '1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339', + '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.367700692008930469189923116409', - wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: + getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + volume.load(); + // Instantiate a rendering engine - renderingEngine = new RenderingEngine(renderingEngineId); + const renderingEngine = new RenderingEngine(renderingEngineId); // Create the viewports - const viewportInputArray = [ { - viewportId: viewportId, - type: ViewportType.VOLUME_3D, + 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.SAGITTAL, - // background: CONSTANTS.BACKGROUND_COLORS.slicer3D, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId4, + type: ViewportType.VOLUME_3D, + element: element4, + defaultOptions: { + background: [0, 0, 0], + orientation: Enums.OrientationAxis.CORONAL, }, }, ]; renderingEngine.setViewports(viewportInputArray); - // Set the tool group on the viewports - toolGroup.addViewport(viewportId, renderingEngineId); + // Set the volume to load + volume.load(); - // Define a volume in memory - const volume = await volumeLoader.createAndCacheVolume(volumeId, { - imageIds, + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [ + { + volumeId, + callback: setCtTransferFunctionForVolumeActor, + }, + ], + viewportIds + ); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + addManipulationBindings(toolGroup); + + // For the crosshairs to operate, the viewports must currently be + // added ahead of setting the tool active. This will be improved in the future. + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + newToolGroup.addViewport(viewportId4, renderingEngineId); + + // Manipulation Tools + // Add Crosshairs tool and configure it to link the three viewports + // These viewports could use different tool groups. See the PET-CT example + // for a more complicated used case. + + const isMobile = window.matchMedia('(any-pointer:coarse)').matches; + + toolGroup.addTool(VolumeCroppingTool.toolName, { + getReferenceLineColor, + getReferenceLineControllable, + getReferenceLineDraggableRotatable, + getReferenceLineSlabThicknessControlsOn, + mobile: { + enabled: isMobile, + opacity: 0.8, + handleRadius: 9, + }, }); - // Get the vtkImageData from the volume - const imageData = volume.imageData; - const dimensions = imageData.getDimensions(); // [xDim, yDim, zDim] - console.log('Volume dimensions:', dimensions); - - // Log the world dimensions (physical size) - const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const worldDimensions = [ - Math.round(dimensions[0] * spacing[0]), - Math.round(dimensions[1] * spacing[1]), - Math.round(dimensions[2] * spacing[2]), - ]; - console.log('Volume world dimensions (mm):', worldDimensions); - // Set the volume to load - volume.load(); - viewport = renderingEngine.getViewport(viewportId); + toolGroup.setToolActive(VolumeCroppingTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + setUpSynchronizers(); + const picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); + picker.setPickFromList(1); + picker.setTolerance(0); + picker.initializePickList(); + // Render the image + const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; + renderingEngine.renderViewports(viewportIds); await setVolumesForViewports( renderingEngine, [{ volumeId }], - [viewportId] + [viewportId4] ).then(() => { viewport.setProperties({ preset: 'CT-Bone', }); + const defaultActor = viewport.getDefaultActor(); + if (defaultActor?.actor) { + // Cast to any to avoid type errors with different actor types + picker.addPickList(defaultActor.actor as any); + prepareImageDataForPicking(viewport); + } + // Get the vtkImageData from the volume + const imageData = volume.imageData; + const dimensions = imageData.getDimensions(); // [xDim, yDim, zDim] + const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] + const worldDimensions = [ + Math.round(dimensions[0] * spacing[0]), + Math.round(dimensions[1] * spacing[1]), + Math.round(dimensions[2] * spacing[2]), + ]; + //const mapper = defaultActor.actor.getMapper(); const mapper = viewport.getDefaultActor().actor.getMapper(); const xMin = worldDimensions[0] * -0.5; const xMax = worldDimensions[0] * 0.5; @@ -172,121 +540,119 @@ async function run() { normal: [1, 0, 0], }); mapper.addClippingPlane(planeXmin); - addSliderToToolbar({ - title: ' x-min: ' + xMin, - range: [xMin, xMax], - defaultValue: xMin, - updateLabelOnChange(value, label) { - label.innerText = ` x-min: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(0, [newDisplayThreshold, 0, 0]); - }, - }); - planes.push(planeXmin); + //planes.push(planeXmin); + // addSphere(viewport, [xMin, 0, -(zMax - zMin) / 2]); - // X max plane (cuts everything right of xMax) - const planeXmax = vtkPlane.newInstance({ - origin: [xMax, 0, 0], - normal: [-1, 0, 0], - }); - mapper.addClippingPlane(planeXmax); - addSliderToToolbar({ - title: ' x-max: ' + xMax, - range: [xMin, xMax], - defaultValue: xMax, - updateLabelOnChange(value, label) { - label.innerText = ` x-max: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(1, [newDisplayThreshold, 0, 0]); - }, - }); - planes.push(planeXmax); - - // Y min plane - const planeYmin = vtkPlane.newInstance({ - origin: [0, yMin, 0], - normal: [0, 1, 0], - }); - addSliderToToolbar({ - title: ' y-min: ' + yMin, - range: [yMin, yMax], - defaultValue: yMin, - updateLabelOnChange(value, label) { - label.innerText = ` y-min: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(2, [0, newDisplayThreshold, 0]); - }, - }); - mapper.addClippingPlane(planeYmin); - planes.push(planeYmin); - - // Y max plane - const planeYmax = vtkPlane.newInstance({ - origin: [0, yMax, 0], - normal: [0, -1, 0], - }); - mapper.addClippingPlane(planeYmax); - addSliderToToolbar({ - title: ' y-max: ' + yMax, - range: [yMin, yMax], - defaultValue: yMax, - updateLabelOnChange(value, label) { - label.innerText = ` y-max: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(3, [0, newDisplayThreshold, 0]); - }, - }); - planes.push(planeYmax); - - // Z min plane - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, zMin], - normal: [0, 0, 1], - }); - mapper.addClippingPlane(planeZmin); - addSliderToToolbar({ - title: ' z-min: ' + zMin, - range: [zMin, zMax], - defaultValue: zMin, - updateLabelOnChange(value, label) { - label.innerText = ` z-min: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(4, [0, 0, newDisplayThreshold]); - }, - }); - planes.push(planeZmin); + viewport.render(); + }); - // Z max plane - const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, zMax], - normal: [0, 0, -1], - }); - mapper.addClippingPlane(planeZmax); - addSliderToToolbar({ - title: ' z-max: ' + zMax, - range: [zMin, zMax], - defaultValue: zMax, - updateLabelOnChange(value, label) { - label.innerText = ` z-max: ${value} `; - }, - onSelectedValueChange: (newDisplayThreshold) => { - setClippingPlane(5, [0, 0, newDisplayThreshold]); - }, - }); - planes.push(planeZmax); + function setClippingPlane(viewport, planeIndex, origin) { + const mapper = viewport.getDefaultActor().actor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + console.debug('clippingPlanes before setOrigin:', clippingPlanes); + clippingPlanes[planeIndex].setOrigin(origin); + // viewport.setOriginalClippingPlane(planeIndex, origin); + viewport.render(); + } + + // Add right-click event handler to element4 for picking coordinates + element4.addEventListener('mousedown', (evt) => { + // Check if it's a right-click (button 2) + if (evt.button === 2) { + evt.preventDefault(); + evt.stopPropagation(); + + // Get the rendering engine and viewport + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport( + viewportId4 + ) as VolumeViewport3D; + + // Get canvas coordinates relative to the element + const rect = element4.getBoundingClientRect(); + const x = evt.clientX - rect.left; + const y = evt.clientY - rect.top; + + const displayCoords = viewport.getVtkDisplayCoords([x, y]); + // Use the picker to get the 3D coordinates + picker.pick( + [displayCoords[0], displayCoords[1], 0], + viewport.getRenderer() + ); + + // Get the picked position + const pickedPositions = picker.getPickedPositions(); + const actors = picker.getActors(); + if (actors.length > 0) { + const pickedPoint = pickedPositions[0]; + if (pickedPoint) { + console.log('Picked point coordinates:', pickedPoint); + addSphere(viewport, pickedPoint); + addTemporaryPickedPositionLabel(x, y, pickedPoint); + setCrossHairPosition(pickedPoint); + setClippingPlane(viewport, 0, [0, 0, -500]); + } + } + } + }); +} - const originalPlanes = planes.map((plane) => ({ - origin: [...plane.getOrigin()], - normal: [...plane.getNormal()], - })); +eventTarget.addEventListener( + toolsEnums.Events.CROSSHAIR_TOOL_CENTER_CHANGED, + (evt) => { + const { toolCenter } = evt.detail; + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport( + viewportId4 + ) as VolumeViewport3D; + if (sphereActor) { + addSphere(viewport, toolCenter); + } + } +); - viewport.setOriginalClippingPlanes(originalPlanes); - viewport.render(); +/** + * Creates the minimum infrastructure needed to pick a point in the 3D volume + * with VTK.js + * @remarks + * Is this the right place to put this function? + * @param viewport + * @returns + */ +function prepareImageDataForPicking(viewport: BaseVolumeViewport) { + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + return; + } + // Get the imageData from the volumeActor + const imageData = volumeActor.getMapper().getInputData(); + + if (!imageData) { + console.error('No imageData found in the volumeActor'); + return null; + } + + // Get the voxelManager from the imageData + const { voxelManager } = imageData.get('voxelManager'); + + if (!voxelManager) { + console.error('No voxelManager found in the imageData'); + return imageData; + } + + // Create a fake scalar object to expose the scalar data to VTK.js + const fakeScalars = { + getData: () => { + return voxelManager.getCompleteScalarDataArray(); + }, + getNumberOfComponents: () => voxelManager.numberOfComponents, + getDataType: () => + voxelManager.getCompleteScalarDataArray().constructor.name, + }; + + // Set the point data to return the fakeScalars + imageData.setPointData({ + getScalars: () => fakeScalars, }); } diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index a0ee885294..9c6a93bb7d 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -27,6 +27,7 @@ import { AnnotationDisplayTool, PanTool, TrackballRotateTool, + VolumeCroppingTool, DragProbeTool, WindowLevelTool, ZoomTool, @@ -103,6 +104,7 @@ export { PanTool, SegmentBidirectionalTool, TrackballRotateTool, + VolumeCroppingTool, DragProbeTool, WindowLevelTool, WindowLevelRegionTool, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index bb41168ea7..cb16775a92 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,304 +1,2766 @@ -// https://github.com/Kitware/vtk-js/blob/d15d50f8ba87704865b725be870c3316da2a7078/Sources/Widgets/Widgets3D/ImageCroppingWidget/index.js#L195 +import { vec2, vec3 } from 'gl-matrix'; +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; -import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; -import vtkImageCroppingWidget, { - ImageCroppingWidgetState, - vtkImageCroppingViewWidget, -} from '@kitware/vtk.js/Widgets/Widgets3D/ImageCroppingWidget'; +import { AnnotationTool } from './base'; -import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import { Events } from '../enums'; +import type { Types } from '@cornerstonejs/core'; import { - eventTarget, - getEnabledElement, getEnabledElementByIds, + getEnabledElement, + utilities as csUtils, + Enums, + CONSTANTS, + triggerEvent, + eventTarget, } from '@cornerstonejs/core'; -import type { Types } from '@cornerstonejs/core'; -import { mat4, vec3 } from 'gl-matrix'; -import type { EventTypes, PublicToolProps, ToolProps } from '../types'; -import { BaseTool } from './base'; -import { getToolGroup } from '../store/ToolGroupManager'; -class VolumeCroppingTool extends BaseTool { +import { + getToolGroup, + getToolGroupForViewport, +} from '../store/ToolGroupManager'; + +import { + addAnnotation, + getAnnotations, + removeAnnotation, +} from '../stateManagement/annotation/annotationState'; + +import { + drawCircle as drawCircleSvg, + drawHandles as drawHandlesSvg, + 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; + +interface CrosshairsAnnotation extends Annotation { + data: { + handles: { + rotationPoints: Types.Point3[]; // rotation handles, used for rotation interactions + slabThicknessPoints: Types.Point3[]; // slab thickness handles, used for setting the slab thickness + activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles + toolCenter: Types.Point3; + }; + activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated + viewportId: string; + }; +} + +function defaultReferenceLineColor() { + return 'rgb(0, 200, 0)'; +} + +function defaultReferenceLineControllable() { + return true; +} + +function defaultReferenceLineDraggableRotatable() { + return true; +} + +function defaultReferenceLineSlabThicknessControlsOn() { + return true; +} + +const OPERATION = { + DRAG: 1, + ROTATE: 2, + SLAB: 3, +}; + +const EPSILON = 1e-3; + +/** + * VolumeCroppingTool is a tool that provides reference lines between different viewports + * of a toolGroup. Using crosshairs, you can jump to a specific location in one + * viewport and the rest of the viewports in the toolGroup will be aligned to that location. + * Crosshairs have grababble handles that can be used to rotate and translate the + * reference lines. They can also be used to set the slab thickness of the viewports + * by modifying the slab thickness handles. + * + */ +class VolumeCroppingTool extends AnnotationTool { static toolName; - touchDragCallback: (evt: EventTypes.InteractionEventType) => void; - mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; - cleanUp: () => void; - _resizeObservers = new Map(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _viewportAddedListener: (evt: any) => void; - _hasResolutionChanged = false; + + toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. + // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle + _getReferenceLineColor?: (viewportId: string) => string; + _getReferenceLineControllable?: (viewportId: string) => boolean; + _getReferenceLineDraggableRotatable?: (viewportId: string) => boolean; + _getReferenceLineSlabThicknessControlsOn?: (viewportId: string) => boolean; constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse', 'Touch'], + supportedInteractionTypes: ['Mouse'], configuration: { - rotateIncrementDegrees: 2, + shadow: true, + // 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, + }, + // Auto pan is a configuration which will update pan + // other viewports in the toolGroup if the center of the crosshairs + // is outside of the viewport. This might be useful for the case + // when the user is scrolling through an image (usually in the zoomed view) + // and the crosshairs will eventually get outside of the viewport for + // the other viewports. + autoPan: { + enabled: false, + panSize: 10, + }, + handleRadius: 3, + // Enable HDPI rendering for handles using devicePixelRatio + enableHDPIHandles: false, + // radius of the area around the intersection of the planes, in which + // the reference lines will not be rendered. This is only used when + // having 3 viewports in the toolGroup. + referenceLinesCenterGapRadius: 20, + // actorUIDs for slabThickness application, if not defined, the slab thickness + // will be applied to all actors of the viewport + filterActorUIDsToSetSlabThickness: [], + // blend mode for slabThickness modifications + slabThicknessBlendMode: Enums.BlendModes.MAXIMUM_INTENSITY_BLEND, + mobile: { + enabled: false, + opacity: 0.8, + handleRadius: 9, + }, }, } ) { super(toolProps, defaultToolProps); - this.touchDragCallback = this._dragCallback.bind(this); - this.mouseDragCallback = this._dragCallback.bind(this); + + this._getReferenceLineColor = + toolProps.configuration?.getReferenceLineColor || + defaultReferenceLineColor; + this._getReferenceLineControllable = + toolProps.configuration?.getReferenceLineControllable || + defaultReferenceLineControllable; + this._getReferenceLineDraggableRotatable = + toolProps.configuration?.getReferenceLineDraggableRotatable || + defaultReferenceLineDraggableRotatable; + this._getReferenceLineSlabThicknessControlsOn = + toolProps.configuration?.getReferenceLineSlabThicknessControlsOn || + defaultReferenceLineSlabThicknessControlsOn; + } + + /** + * Gets the camera from the viewport, and adds crosshairs 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 to add the crosshairs + * @returns viewPlaneNormal and center of viewport canvas in world space + */ + initializeViewport = ({ + renderingEngineId, + viewportId, + }: Types.IViewportId): { + normal: Types.Point3; + point: Types.Point3; + } => { + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!enabledElement) { + return; + } + const { FrameOfReferenceUID, viewport } = enabledElement; + 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); + } + + const annotation = { + highlighted: false, + metadata: { + cameraPosition: [...position], + cameraFocalPoint: [...focalPoint], + FrameOfReferenceUID, + toolName: this.getToolName(), + }, + data: { + handles: { + rotationPoints: [], // rotation handles, used for rotation interactions + slabThicknessPoints: [], // slab thickness handles, used for setting the slab thickness + toolCenter: this.toolCenter, + }, + 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, + }, + }; + + addAnnotation(annotation, element); + + return { + normal: viewPlaneNormal, + point: viewport.canvasToWorld([ + viewport.canvas.clientWidth / 2, + viewport.canvas.clientHeight / 2, + ]), + }; + }; + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + + return viewports; + }; + + onSetToolActive() { + const viewportsInfo = this._getViewportsInfo(); + + // Upon new setVolumes on viewports we need to update the crosshairs + // reference points in the new space, so we subscribe to the event + // and update the reference points accordingly. + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + this._subscribeToViewportNewVolumeSet(viewportsInfo); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolPassive() { + const viewportsInfo = this._getViewportsInfo(); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolEnabled() { + const viewportsInfo = this._getViewportsInfo(); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolDisabled() { + const viewportsInfo = this._getViewportsInfo(); + + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + + // Crosshairs annotations in the state + // 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); + }); + } + }); + } + + resetCrosshairs = () => { + 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(); + this._computeToolCenter(viewportsInfo); + }; + + /** + * When activated, it initializes the crosshairs. It begins by computing + * the intersection of viewports associated with the crosshairs instance. + * When all three views are accessible, the intersection (e.g., crosshairs tool centre) + * will be an exact point in space; however, with two viewports, because the + * intersection of two planes is a line, it assumes the last view is between the centre + * of the two rendering viewports. + * @param viewportsInfo Array of viewportInputs which each item containing `{viewportId, renderingEngineId}` + */ + _computeToolCenter = (viewportsInfo): void => { + if (!viewportsInfo.length || viewportsInfo.length === 1) { + console.warn( + 'For crosshairs to operate, at least two viewports must be given.' + ); + return; + } + + // Todo: handle two same view viewport, or more than 3 viewports + const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; + + // Initialize first viewport + const { normal: normal1, point: point1 } = + this.initializeViewport(firstViewport); + + // Initialize second viewport + const { normal: normal2, point: point2 } = + this.initializeViewport(secondViewport); + + let normal3 = [0, 0, 0]; + let point3 = vec3.create(); + + // If there are three viewports + if (thirdViewport) { + ({ normal: normal3, point: point3 } = + this.initializeViewport(thirdViewport)); + } else { + // If there are only two views (viewport) associated with the crosshairs: + // In this situation, we don't have a third information to find the + // exact intersection, and we "assume" the third view is looking at + // a location in between the first and second view centers + vec3.add(point3, point1, point2); + vec3.scale(point3, point3, 0.5); + vec3.cross(normal3, normal1, normal2); + } + + // Planes of each viewport + const firstPlane = csUtils.planar.planeEquation(normal1, point1); + const secondPlane = csUtils.planar.planeEquation(normal2, point2); + const thirdPlane = csUtils.planar.planeEquation(normal3, point3); + + // Calculating the intersection of 3 planes + // prettier-ignore + + const toolCenter = csUtils.planar.threePlaneIntersection(firstPlane, secondPlane, thirdPlane); + this.setToolCenter(toolCenter); + }; + + setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { + // prettier-ignore + this.toolCenter = toolCenter; + const viewportsInfo = this._getViewportsInfo(); + + // assuming all viewports are in the same rendering engine + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + if (!suppressEvents) { + triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + }); + } } - preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + /** + * addNewAnnotation acts as jump for the crosshairs tool. It 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 Crosshairs annotation + */ + addNewAnnotation = ( + evt: EventTypes.InteractionEventType + ): CrosshairsAnnotation => { const eventDetail = evt.detail; const { element } = eventDetail; + + const { currentPoints } = eventDetail; + const jumpWorld = currentPoints.world; + const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - const actorEntry = viewport.getDefaultActor(); - const actor = actorEntry.actor as Types.VolumeActor; - const mapper = actor.getMapper(); + this._jump(enabledElement, jumpWorld); - if (evt.detail.event.altKey) { - // volume cropping - const croppingWidget = vtkImageCroppingWidget.newInstance({}); - return; - } else { - // 3D rotation - const hasSampleDistance = - 'getSampleDistance' in mapper || 'getCurrentSampleDistance' in mapper; + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); - if (!hasSampleDistance) { - return true; + // viewport Annotation + const { data } = filteredAnnotations[0]; + + const { rotationPoints } = data.handles; + const viewportIdArray = []; + // put all the draggable reference lines in the viewportIdArray + for (let i = 0; i < rotationPoints.length - 1; ++i) { + const otherViewport = rotationPoints[i][1]; + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const viewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + if (!viewportControllable || !viewportDraggableRotatable) { + continue; } + viewportIdArray.push(otherViewport.id); + // rotation handles are two per viewport + i++; + } - const originalSampleDistance = mapper.getSampleDistance(); + data.activeViewportIds = [...viewportIdArray]; + // set translation operation + data.handles.activeOperation = OPERATION.DRAG; - if (!this._hasResolutionChanged) { - mapper.setSampleDistance(originalSampleDistance * 4); - this._hasResolutionChanged = true; + evt.preventDefault(); - if (this.cleanUp !== null) { - // Clean up previous event listener - document.removeEventListener('mouseup', this.cleanUp); - } + hideElementCursor(element); - this.cleanUp = () => { - mapper.setSampleDistance(originalSampleDistance); - viewport.render(); - this._hasResolutionChanged = false; - }; + this._activateModify(element); + return filteredAnnotations[0]; + }; - document.addEventListener('mouseup', this.cleanUp, { once: true }); - } + cancel = () => { + console.log('Not implemented yet'); + }; + + /** + * It checks if the mouse click is near crosshairs handles, if yes + * it returns the handle location. If the mouse click is not near any + * of the handles, it does not return anything. + * + * @param element - The element that the tool is attached to. + * @param annotation - The annotation object associated with the annotation + * @param canvasCoords - The coordinates of the mouse click on canvas + * @param proximity - The distance from the mouse cursor to the point + * that is considered "near". + * @returns The handle that is closest to the cursor, or null if the cursor + * is not near any of the handles. + */ + getHandleNearImagePoint( + element: HTMLDivElement, + annotation: Annotation, + canvasCoords: Types.Point2, + proximity: number + ): ToolHandle | undefined { + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + + let point = this._getRotationHandleNearImagePoint( + viewport, + annotation, + canvasCoords, + proximity + ); + + if (point !== null) { + return point; + } + + point = this._getSlabThicknessHandleNearImagePoint( + viewport, + annotation, + canvasCoords, + proximity + ); + + if (point !== null) { + return point; + } + } + + handleSelectedCallback = ( + evt: EventTypes.InteractionEventType, + annotation: Annotation + ): void => { + const eventDetail = evt.detail; + const { element } = eventDetail; + annotation.highlighted = true; + + // NOTE: handle index or coordinates are not used when dragging. + // This because the handle points are actually generated in the renderTool and they are a derivative + // from the camera variables of the viewports and of the slab thickness variable. + // Remember that the translation and rotation operations operate on the camera + // variables and not really on the handles. Similar for the slab thickness. + this._activateModify(element); + + hideElementCursor(element); + + evt.preventDefault(); + }; + + /** + * It returns if the canvas point is near the provided crosshairs 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: CrosshairsAnnotation, + canvasCoords: Types.Point2, + proximity: number + ): boolean => { + if (this._pointNearTool(element, annotation, canvasCoords, 6)) { return true; } + + return false; }; - _getViewportsInfo = () => { - const viewports = getToolGroup(this.toolGroupId).viewportsInfo; - return viewports; + 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(); }; - onSetToolActive = () => { - const subscribeToElementResize = () => { - const viewportsInfo = this._getViewportsInfo(); - viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { - if (!this._resizeObservers.has(viewportId)) { - const { viewport } = getEnabledElementByIds( - viewportId, - renderingEngineId - ) || { viewport: null }; - - if (!viewport) { - return; - } + onCameraModified = (evt) => { + const eventDetail = evt.detail; + const { element } = eventDetail; + const enabledElement = getEnabledElement(element); + const { renderingEngine } = enabledElement; + const viewport = enabledElement.viewport as Types.IVolumeViewport; - const { element } = viewport; + const annotations = this._getAnnotations(enabledElement); + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); - const resizeObserver = new ResizeObserver(() => { - const element = getEnabledElementByIds( - viewportId, - renderingEngineId - ); - if (!element) { - return; - } - const { viewport } = element; + // viewport that the camera modified is originating from + const viewportAnnotation = + filteredToolAnnotations[0] as CrosshairsAnnotation; + + if (!viewportAnnotation) { + return; + } - const viewPresentation = viewport.getViewPresentation(); + // -- Update the camera of other linked viewports containing the same volumeId that + // have the same camera in case of translation + // -- Update the crosshair center in world coordinates in annotation. + // This is necessary because other tools can modify the position of the slices, + // e.g. stackScroll tool at wheel scroll. So we update the coordinates of the center always here. + // NOTE: rotation and slab thickness handles are created/updated in renderTool. + const currentCamera = viewport.getCamera(); + const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; + const deltaCameraPosition: Types.Point3 = [0, 0, 0]; + vtkMath.subtract( + currentCamera.position, + oldCameraPosition, + deltaCameraPosition + ); - viewport.resetCamera(); + const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; + const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; + vtkMath.subtract( + currentCamera.focalPoint, + oldCameraFocalPoint, + deltaCameraFocalPoint + ); - viewport.setViewPresentation(viewPresentation); - viewport.render(); - }); + // updated cached "previous" camera position and focal point + viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; + viewportAnnotation.metadata.cameraFocalPoint = [ + ...currentCamera.focalPoint, + ]; - resizeObserver.observe(element); - this._resizeObservers.set(viewportId, resizeObserver); - } - }); - }; + const viewportControllable = this._getReferenceLineControllable( + viewport.id + ); + const viewportDraggableRotatable = this._getReferenceLineDraggableRotatable( + viewport.id + ); + if ( + !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && + viewportControllable && + viewportDraggableRotatable + ) { + // Is camera Modified a TRANSLATION or ROTATION? + let isRotation = false; - subscribeToElementResize(); + // This is guaranteed to be the same diff for both position and focal point + // if the camera is modified by pan, zoom, or scroll BUT for rotation of + // crosshairs handles it will be different. + const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( + deltaCameraPosition, + deltaCameraFocalPoint, + 1e-3 + ); - this._viewportAddedListener = (evt) => { - if (evt.detail.toolGroupId === this.toolGroupId) { - subscribeToElementResize(); + // NOTE: it is a translation if the the focal point and camera position shifts are the same + if (!cameraModifiedSameForPosAndFocalPoint) { + isRotation = true; } - }; - eventTarget.addEventListener( - Events.TOOLGROUP_VIEWPORT_ADDED, - this._viewportAddedListener - ); - }; + const cameraModifiedInPlane = + Math.abs( + vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) + ) < 1e-2; - onSetToolDisabled = () => { - // Disconnect all resize observers - this._resizeObservers.forEach((resizeObserver, viewportId) => { - resizeObserver.disconnect(); - this._resizeObservers.delete(viewportId); - }); + // TRANSLATION + // NOTE1: if the camera modified is a result of a pan or zoom don't update the crosshair center + // NOTE2: rotation handles are updates in renderTool + if (!isRotation && !cameraModifiedInPlane) { + this.toolCenter[0] += deltaCameraPosition[0]; + this.toolCenter[1] += deltaCameraPosition[1]; + this.toolCenter[2] += deltaCameraPosition[2]; + + triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + }); + } + } - if (this._viewportAddedListener) { - eventTarget.removeEventListener( - Events.TOOLGROUP_VIEWPORT_ADDED, - this._viewportAddedListener + // AutoPan modification + if (this.configuration.autoPan?.enabled) { + const toolGroup = getToolGroupForViewport( + viewport.id, + renderingEngine.id ); - this._viewportAddedListener = null; // Clear the reference to the listener + + const otherViewportIds = toolGroup + .getViewportIds() + .filter((id) => id !== viewport.id); + + otherViewportIds.forEach((viewportId) => { + this._autoPanViewportIfNecessary(viewportId, renderingEngine); + }); } + + const requireSameOrientation = false; + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName(), + requireSameOrientation + ); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; - 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); + onResetCamera = (evt) => { + this.resetCrosshairs(); + }; - viewport.setCamera({ - position: newPosition, - viewUp: newViewUp, - focalPoint: newFocalPoint, - }); + mouseMoveCallback = ( + evt: EventTypes.MouseMoveEventType, + filteredToolAnnotations: Annotations + ): boolean => { + 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 CrosshairsAnnotation; + + 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 = []; + data.handles.activeOperation = null; + + const handleNearImagePoint = this.getHandleNearImagePoint( + element, + annotation, + canvasCoords, + 6 + ); + + let near = false; + if (handleNearImagePoint) { + near = true; + } else { + near = this._pointNearTool(element, annotation, canvasCoords, 6); + } + + const nearToolAndNotMarkedActive = near && !highlighted; + const notNearToolAndMarkedActive = !near && highlighted; + if (nearToolAndNotMarkedActive || notNearToolAndMarkedActive) { + annotation.highlighted = !highlighted; + imageNeedsUpdate = true; + } else if ( + data.handles.activeOperation !== previousActiveOperation || + !this._areViewportIdArraysEqual( + data.activeViewportIds, + previousActiveViewportIds + ) + ) { + imageNeedsUpdate = true; + } + } + + return imageNeedsUpdate; }; - _dragCallback(evt: EventTypes.InteractionEventType): void { - const { element, currentPoints, lastPoints } = evt.detail; - const currentPointsCanvas = currentPoints.canvas; - const lastPointsCanvas = lastPoints.canvas; - const { rotateIncrementDegrees } = this.configuration; + filterInteractableAnnotationsForElement = (element, annotations) => { + if (!annotations || !annotations.length) { + return []; + } + const enabledElement = getEnabledElement(element); - const { viewport } = enabledElement; + const { viewportId } = enabledElement; + + const viewportUIDSpecificCrosshairs = annotations.filter( + (annotation) => annotation.data.viewportId === viewportId + ); + + return viewportUIDSpecificCrosshairs; + }; + /** + * 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 => { + let renderStatus = false; + const { viewport, renderingEngine } = enabledElement; + const { element } = viewport; + const annotations = this._getAnnotations(enabledElement); const camera = viewport.getCamera(); - const width = element.clientWidth; - const height = element.clientHeight; + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); - const normalizedPosition = [ - currentPointsCanvas[0] / width, - currentPointsCanvas[1] / height, - ]; + // viewport Annotation + const viewportAnnotation = filteredToolAnnotations[0]; + if (!annotations?.length || !viewportAnnotation?.data) { + // No annotations yet, and didn't just create it as we likely don't have a FrameOfReference/any data loaded yet. + return renderStatus; + } - const normalizedPreviousPosition = [ - lastPointsCanvas[0] / width, - lastPointsCanvas[1] / height, - ]; + const annotationUID = viewportAnnotation.annotationUID; - 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]; + // 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 canvasMinDimensionLength = Math.min(clientWidth, clientHeight); + + const data = viewportAnnotation.data; + const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); + + const otherViewportAnnotations = + this._filterAnnotationsByUniqueViewportOrientations( + enabledElement, + annotations + ); - const radsq = (1.0 + Math.abs(normalizedCenter[0])) ** 2.0; - const op = [normalizedPreviousPosition[0], 0, 0]; - const oe = [normalizedPosition[0], 0, 0]; + const referenceLines = []; - const opsq = op[0] ** 2; - const oesq = oe[0] ** 2; + // get canvas information for points and lines (canvas box, canvas horizontal distances) + const canvasBox = [0, 0, clientWidth, clientHeight]; - const lop = opsq > radsq ? 0 : Math.sqrt(radsq - opsq); - const loe = oesq > radsq ? 0 : Math.sqrt(radsq - oesq); + otherViewportAnnotations.forEach((annotation) => { + const { data } = annotation; - const nop: Types.Point3 = [op[0], 0, lop]; - vtkMath.normalize(nop); - const noe: Types.Point3 = [oe[0], 0, loe]; - vtkMath.normalize(noe); + data.handles.toolCenter = this.toolCenter; - 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 otherViewport = renderingEngine.getViewport( + data.viewportId + ) as Types.IVolumeViewport; - const upVec = camera.viewUp; - const atV = camera.viewPlaneNormal; - const rightV: Types.Point3 = [0, 0, 0]; - const forwardV: Types.Point3 = [0, 0, 0]; + const otherCamera = otherViewport.getCamera(); - vtkMath.cross(upVec, atV, rightV); - vtkMath.normalize(rightV); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const otherViewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + const otherViewportSlabThicknessControlsOn = + this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); - vtkMath.cross(atV, rightV, forwardV); - vtkMath.normalize(forwardV); - vtkMath.normalize(upVec); + // get coordinates for the reference line + const { clientWidth, clientHeight } = otherViewport.canvas; + const otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + const otherCanvasCenter: Types.Point2 = [ + clientWidth * 0.5, + clientHeight * 0.5, + ]; + const otherViewportCenterWorld = + otherViewport.canvasToWorld(otherCanvasCenter); - this.rotateCamera(viewport, centerWorld, forwardV, angleX); + const direction: Types.Point3 = [0, 0, 0]; + vtkMath.cross( + camera.viewPlaneNormal, + otherCamera.viewPlaneNormal, + direction + ); + vtkMath.normalize(direction); + vtkMath.multiplyScalar( + direction, + otherCanvasDiagonalLength + ); - const angleY = - (normalizedPreviousPosition[1] - normalizedPosition[1]) * - rotateIncrementDegrees; + const pointWorld0: Types.Point3 = [0, 0, 0]; + vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); - this.rotateCamera(viewport, centerWorld, rightV, angleY); + const pointWorld1: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); - viewport.render(); - } - } -} + const pointCanvas0 = viewport.worldToCanvas(pointWorld0); -/* -function widgetRegistration(e) { - const action = e ? e.currentTarget.dataset.action : 'addWidget'; - const viewWidget = widgetManager[action](widget); - if (viewWidget) { - viewWidget.setDisplayCallback((coords) => { - overlay.style.left = '-100px'; - if (coords) { - const [w, h] = apiRenderWindow.getSize(); - overlay.style.left = `${Math.round( - (coords[0][0] / w) * window.innerWidth - - overlaySize * 0.5 - - overlayBorder - )}px`; - overlay.style.top = `${Math.round( - ((h - coords[0][1]) / h) * window.innerHeight - - overlaySize * 0.5 - - overlayBorder - )}px`; + const otherViewportCenterCanvas = viewport.worldToCanvas( + otherViewportCenterWorld + ); + + const canvasUnitVectorFromCenter = vec2.create(); + vec2.subtract( + canvasUnitVectorFromCenter, + pointCanvas0, + otherViewportCenterCanvas + ); + vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); + + // Graphic: + // Mid -> SlabThickness handle + // Short -> Rotation handle + // Long + // | + // | + // | + // Mid + // | + // | + // | + // Short + // | + // | + // | + // Long --- Mid--- Short--- Center --- Short --- Mid --- Long + // | + // | + // | + // Short + // | + // | + // | + // Mid + // | + // | + // | + // Long + const canvasVectorFromCenterLong = vec2.create(); + + vec2.scale( + canvasVectorFromCenterLong, + canvasUnitVectorFromCenter, + canvasDiagonalLength * 100 + ); + const canvasVectorFromCenterMid = vec2.create(); + vec2.scale( + canvasVectorFromCenterMid, + canvasUnitVectorFromCenter, + // to maximize the visibility of the controls, they need to be + // placed at most at half the length of the shortest side of the canvas. + // Chosen 0.4 to have some margin to the edge. + canvasMinDimensionLength * 0.4 + ); + const canvasVectorFromCenterShort = vec2.create(); + vec2.scale( + canvasVectorFromCenterShort, + canvasUnitVectorFromCenter, + // Chosen 0.2 because is half of 0.4. + canvasMinDimensionLength * 0.2 + ); + const canvasVectorFromCenterStart = vec2.create(); + const centerGap = this.configuration.referenceLinesCenterGapRadius; + vec2.scale( + canvasVectorFromCenterStart, + canvasUnitVectorFromCenter, + // Don't put a gap if the the third view is missing + otherViewportAnnotations.length === 2 ? centerGap : 0 + ); + + // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) + const refLinePointOne = vec2.create(); + const refLinePointTwo = vec2.create(); + const refLinePointThree = vec2.create(); + const refLinePointFour = vec2.create(); + + let refLinesCenter = vec2.clone(crosshairCenterCanvas); + if (!otherViewportDraggableRotatable || !otherViewportControllable) { + refLinesCenter = vec2.clone(otherViewportCenterCanvas); } - }); - renderer.resetCamera(); - renderer.resetCameraClippingRange(); + vec2.add(refLinePointOne, refLinesCenter, canvasVectorFromCenterStart); + vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); + vec2.subtract( + refLinePointThree, + refLinesCenter, + canvasVectorFromCenterStart + ); + vec2.subtract( + refLinePointFour, + refLinesCenter, + canvasVectorFromCenterLong + ); + + // Clipping lines to be only included in a box (canvas), we don't want + // the lines goes beyond canvas + liangBarksyClip(refLinePointOne, refLinePointTwo, canvasBox); + liangBarksyClip(refLinePointThree, refLinePointFour, canvasBox); + + // Computing rotation handle positions + const rotHandleOne = vec2.create(); + vec2.subtract( + rotHandleOne, + crosshairCenterCanvas, + canvasVectorFromCenterMid + ); + + const rotHandleTwo = vec2.create(); + vec2.add(rotHandleTwo, crosshairCenterCanvas, canvasVectorFromCenterMid); + + // Computing SlabThickness (st below) position + + // SlabThickness center in canvas + let stHandlesCenterCanvas = vec2.clone(crosshairCenterCanvas); + if ( + !otherViewportDraggableRotatable && + otherViewportSlabThicknessControlsOn + ) { + stHandlesCenterCanvas = vec2.clone(otherViewportCenterCanvas); + } + + // SlabThickness center in world + let stHandlesCenterWorld: Types.Point3 = [...this.toolCenter]; + if ( + !otherViewportDraggableRotatable && + otherViewportSlabThicknessControlsOn + ) { + stHandlesCenterWorld = [...otherViewportCenterWorld]; + } + + const worldUnitVectorFromCenter: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(pointWorld0, pointWorld1, worldUnitVectorFromCenter); + vtkMath.normalize(worldUnitVectorFromCenter); + + const { viewPlaneNormal } = camera; + // @ts-ignore // Todo: fix after vtk pr merged + const { matrix } = vtkMatrixBuilder + .buildFromDegree() + // @ts-ignore fix after vtk pr merged + .rotate(90, viewPlaneNormal); + + const worldUnitOrthoVectorFromCenter: Types.Point3 = [0, 0, 0]; + vec3.transformMat4( + worldUnitOrthoVectorFromCenter, + worldUnitVectorFromCenter, + matrix + ); + + const slabThicknessValue = otherViewport.getSlabThickness(); + const worldOrthoVectorFromCenter: Types.Point3 = [ + ...worldUnitOrthoVectorFromCenter, + ]; + vtkMath.multiplyScalar(worldOrthoVectorFromCenter, slabThicknessValue); + + const worldVerticalRefPoint: Types.Point3 = [0, 0, 0]; + vtkMath.add( + stHandlesCenterWorld, + worldOrthoVectorFromCenter, + worldVerticalRefPoint + ); + + // convert vertical world distances in canvas coordinates + const canvasVerticalRefPoint = viewport.worldToCanvas( + worldVerticalRefPoint + ); + + // points for slab thickness lines + const canvasOrthoVectorFromCenter = vec2.create(); + vec2.subtract( + canvasOrthoVectorFromCenter, + stHandlesCenterCanvas, + canvasVerticalRefPoint + ); + + const stLinePointOne = vec2.create(); + vec2.subtract( + stLinePointOne, + stHandlesCenterCanvas, + canvasVectorFromCenterLong + ); + vec2.add(stLinePointOne, stLinePointOne, canvasOrthoVectorFromCenter); + + const stLinePointTwo = vec2.create(); + vec2.add( + stLinePointTwo, + stHandlesCenterCanvas, + canvasVectorFromCenterLong + ); + vec2.add(stLinePointTwo, stLinePointTwo, canvasOrthoVectorFromCenter); + + liangBarksyClip(stLinePointOne, stLinePointTwo, canvasBox); + + const stLinePointThree = vec2.create(); + vec2.add( + stLinePointThree, + stHandlesCenterCanvas, + canvasVectorFromCenterLong + ); + vec2.subtract( + stLinePointThree, + stLinePointThree, + canvasOrthoVectorFromCenter + ); + + const stLinePointFour = vec2.create(); + vec2.subtract( + stLinePointFour, + stHandlesCenterCanvas, + canvasVectorFromCenterLong + ); + vec2.subtract( + stLinePointFour, + stLinePointFour, + canvasOrthoVectorFromCenter + ); + + liangBarksyClip(stLinePointThree, stLinePointFour, canvasBox); + + // points for slab thickness handles + const stHandleOne = vec2.create(); + const stHandleTwo = vec2.create(); + const stHandleThree = vec2.create(); + const stHandleFour = vec2.create(); + + vec2.subtract( + stHandleOne, + stHandlesCenterCanvas, + canvasVectorFromCenterShort + ); + vec2.add(stHandleOne, stHandleOne, canvasOrthoVectorFromCenter); + vec2.add(stHandleTwo, stHandlesCenterCanvas, canvasVectorFromCenterShort); + vec2.add(stHandleTwo, stHandleTwo, canvasOrthoVectorFromCenter); + vec2.subtract( + stHandleThree, + stHandlesCenterCanvas, + canvasVectorFromCenterShort + ); + vec2.subtract(stHandleThree, stHandleThree, canvasOrthoVectorFromCenter); + vec2.add( + stHandleFour, + stHandlesCenterCanvas, + canvasVectorFromCenterShort + ); + vec2.subtract(stHandleFour, stHandleFour, canvasOrthoVectorFromCenter); + + referenceLines.push([ + otherViewport, + refLinePointOne, + refLinePointTwo, + refLinePointThree, + refLinePointFour, + stLinePointOne, + stLinePointTwo, + stLinePointThree, + stLinePointFour, + rotHandleOne, + rotHandleTwo, + stHandleOne, + stHandleTwo, + stHandleThree, + stHandleFour, + ]); + }); + + const newRtpoints = []; + const newStpoints = []; + const viewportColor = this._getReferenceLineColor(viewport.id); + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + + referenceLines.forEach((line, lineIndex) => { + // get color for the reference line + const otherViewport = line[0]; + const viewportColor = this._getReferenceLineColor(otherViewport.id); + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const viewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id) || + this.configuration.mobile?.enabled; + const viewportSlabThicknessControlsOn = + this._getReferenceLineSlabThicknessControlsOn(otherViewport.id) || + this.configuration.mobile?.enabled; + const selectedViewportId = data.activeViewportIds.find( + (id) => id === otherViewport.id + ); + + let color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + + let lineWidth = 1; + + const lineActive = + data.handles.activeOperation !== null && + data.handles.activeOperation === OPERATION.DRAG && + selectedViewportId; + + if (lineActive) { + lineWidth = 2.5; + } + + let lineUID = `${lineIndex}`; + if (viewportControllable && viewportDraggableRotatable) { + lineUID = `${lineIndex}One`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[1], + line[2], + { + color, + lineWidth, + } + ); + + lineUID = `${lineIndex}Two`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[3], + line[4], + { + color, + lineWidth, + } + ); + } else { + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[2], + line[4], + { + color, + lineWidth, + } + ); + } + + if (viewportControllable) { + color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + + const rotHandlesActive = + data.handles.activeOperation === OPERATION.ROTATE; + const rotationHandles = [line[9], line[10]]; + + const rotHandleWorldOne = [ + viewport.canvasToWorld(line[9]), + otherViewport, + line[1], + line[2], + ]; + const rotHandleWorldTwo = [ + viewport.canvasToWorld(line[10]), + otherViewport, + line[3], + line[4], + ]; + newRtpoints.push(rotHandleWorldOne, rotHandleWorldTwo); + + const slabThicknessHandlesActive = + data.handles.activeOperation === OPERATION.SLAB; + const slabThicknessHandles = [line[11], line[12], line[13], line[14]]; + + const slabThicknessHandleWorldOne = [ + viewport.canvasToWorld(line[11]), + otherViewport, + line[5], + line[6], + ]; + const slabThicknessHandleWorldTwo = [ + viewport.canvasToWorld(line[12]), + otherViewport, + line[5], + line[6], + ]; + const slabThicknessHandleWorldThree = [ + viewport.canvasToWorld(line[13]), + otherViewport, + line[7], + line[8], + ]; + const slabThicknessHandleWorldFour = [ + viewport.canvasToWorld(line[14]), + otherViewport, + line[7], + line[8], + ]; + newStpoints.push( + slabThicknessHandleWorldOne, + slabThicknessHandleWorldTwo, + slabThicknessHandleWorldThree, + slabThicknessHandleWorldFour + ); + + let handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); + let opacity = 1; + if (this.configuration.mobile?.enabled) { + handleRadius = this.configuration.mobile.handleRadius; + opacity = this.configuration.mobile.opacity; + } + + if ( + (lineActive || this.configuration.mobile?.enabled) && + !rotHandlesActive && + !slabThicknessHandlesActive && + viewportDraggableRotatable && + viewportSlabThicknessControlsOn + ) { + // draw all handles inactive (rotation and slab thickness) + let handleUID = `${lineIndex}One`; + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + handleUID, + rotationHandles, + { + color, + handleRadius, + opacity, + type: 'circle', + } + ); + handleUID = `${lineIndex}Two`; + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + handleUID, + slabThicknessHandles, + { + color, + handleRadius, + opacity, + type: 'rect', + } + ); + } else if ( + lineActive && + !rotHandlesActive && + !slabThicknessHandlesActive && + viewportDraggableRotatable + ) { + const handleUID = `${lineIndex}`; + // draw rotation handles inactive + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + handleUID, + rotationHandles, + { + color, + handleRadius, + opacity, + type: 'circle', + } + ); + } else if ( + selectedViewportId && + !rotHandlesActive && + !slabThicknessHandlesActive && + viewportSlabThicknessControlsOn + ) { + const handleUID = `${lineIndex}`; + // draw slab thickness handles inactive + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + handleUID, + slabThicknessHandles, + { + color, + handleRadius, + opacity, + type: 'rect', + } + ); + } else if (rotHandlesActive && viewportDraggableRotatable) { + const handleUID = `${lineIndex}`; + const handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles + ? window.devicePixelRatio + : 1); + // draw all rotation handles as active + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + handleUID, + rotationHandles, + { + color, + handleRadius, + fill: color, + type: 'circle', + } + ); + } else if ( + slabThicknessHandlesActive && + selectedViewportId && + viewportSlabThicknessControlsOn + ) { + const handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles + ? window.devicePixelRatio + : 1); + // draw only the slab thickness handles for the active viewport as active + drawHandlesSvg( + svgDrawingHelper, + annotationUID, + lineUID, + slabThicknessHandles, + { + color, + handleRadius, + fill: color, + type: 'rect', + } + ); + } + const slabThicknessValue = otherViewport.getSlabThickness(); + if (slabThicknessValue > 0.5 && viewportSlabThicknessControlsOn) { + // draw slab thickness reference lines + lineUID = `${lineIndex}STOne`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[5], + line[6], + { + color, + width: 1, + lineDash: [2, 3], + } + ); + + lineUID = `${lineIndex}STTwo`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[7], + line[8], + { + color, + width: line, + lineDash: [2, 3], + } + ); + } + } + }); + + renderStatus = true; + + // Save new handles points in annotation + data.handles.rotationPoints = newRtpoints; + data.handles.slabThicknessPoints = newStpoints; + + 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; + }; + + _onNewVolume = () => { + const viewportsInfo = this._getViewportsInfo(); + this._computeToolCenter(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 + ); + }); + } + + _autoPanViewportIfNecessary( + viewportId: string, + renderingEngine: Types.IRenderingEngine + ): void { + // 1. Check if the toolCenter is outside the viewport + // 2. If it is outside, pan the viewport to fit in the toolCenter + + const viewport = renderingEngine.getViewport(viewportId); + const { clientWidth, clientHeight } = viewport.canvas; + + const toolCenterCanvas = viewport.worldToCanvas(this.toolCenter); + + // pan the viewport to fit the toolCenter in the direction + // that is out of bounds + const pan = this.configuration.autoPan.panSize; + + const visiblePointCanvas = [ + toolCenterCanvas[0], + toolCenterCanvas[1], + ]; + + if (toolCenterCanvas[0] < 0) { + visiblePointCanvas[0] = pan; + } else if (toolCenterCanvas[0] > clientWidth) { + visiblePointCanvas[0] = clientWidth - pan; + } + + if (toolCenterCanvas[1] < 0) { + visiblePointCanvas[1] = pan; + } else if (toolCenterCanvas[1] > clientHeight) { + visiblePointCanvas[1] = clientHeight - pan; + } + + if ( + visiblePointCanvas[0] === toolCenterCanvas[0] && + visiblePointCanvas[1] === toolCenterCanvas[1] + ) { + return; + } + + const visiblePointWorld = viewport.canvasToWorld(visiblePointCanvas); + + const deltaPointsWorld = [ + visiblePointWorld[0] - this.toolCenter[0], + visiblePointWorld[1] - this.toolCenter[1], + visiblePointWorld[2] - this.toolCenter[2], + ]; + + const camera = viewport.getCamera(); + const { focalPoint, position } = camera; + + const updatedPosition = [ + position[0] - deltaPointsWorld[0], + position[1] - deltaPointsWorld[1], + position[2] - deltaPointsWorld[2], + ]; + + const updatedFocalPoint = [ + focalPoint[0] - deltaPointsWorld[0], + focalPoint[1] - deltaPointsWorld[1], + focalPoint[2] - deltaPointsWorld[2], + ]; + + viewport.setCamera({ + focalPoint: updatedFocalPoint, + position: updatedPosition, + }); + + viewport.render(); + } + + _areViewportIdArraysEqual = (viewportIdArrayOne, viewportIdArrayTwo) => { + if (viewportIdArrayOne.length !== viewportIdArrayTwo.length) { + return false; + } + + viewportIdArrayOne.forEach((id) => { + let itemFound = false; + for (let i = 0; i < viewportIdArrayTwo.length; ++i) { + if (id === viewportIdArrayTwo[i]) { + itemFound = true; + break; + } + } + if (itemFound === false) { + return false; + } + }); + + return true; + }; + + // It filters the viewports with crosshairs and only return viewports + // that have different camera. + _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; + }; + + _filterAnnotationsByUniqueViewportOrientations = ( + enabledElement, + annotations + ) => { + const { renderingEngine, viewport } = enabledElement; + const camera = viewport.getCamera(); + const viewPlaneNormal = camera.viewPlaneNormal; + vtkMath.normalize(viewPlaneNormal); + + const otherLinkedViewportAnnotationsFromSameScene = annotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return ( + viewport !== otherViewport && + // scene === otherScene && + otherViewportControllable === true + ); + } + ); + + const otherViewportsAnnotationsWithUniqueCameras = []; + // Iterate first on other viewport from the same scene linked + for ( + let i = 0; + i < otherLinkedViewportAnnotationsFromSameScene.length; + ++i + ) { + const annotation = otherLinkedViewportAnnotationsFromSameScene[i]; + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + const otherNonLinkedViewportAnnotationsFromSameScene = annotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return ( + viewport !== otherViewport && + // scene === otherScene && + otherViewportControllable !== true + ); + } + ); + + // Iterate second on other viewport from the same scene non linked + for ( + let i = 0; + i < otherNonLinkedViewportAnnotationsFromSameScene.length; + ++i + ) { + const annotation = otherNonLinkedViewportAnnotationsFromSameScene[i]; + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + // Iterate on all the viewport + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + for (let i = 0; i < otherViewportAnnotations.length; ++i) { + const annotation = otherViewportAnnotations[i]; + if ( + otherViewportsAnnotationsWithUniqueCameras.some( + (element) => element === annotation + ) + ) { + continue; + } + + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + return otherViewportsAnnotationsWithUniqueCameras; + }; + + _checkIfViewportsRenderingSameScene = (viewport, otherViewport) => { + const volumeIds = viewport.getAllVolumeIds(); + const otherVolumeIds = otherViewport.getAllVolumeIds(); + + return ( + volumeIds.length === otherVolumeIds.length && + volumeIds.every((id) => otherVolumeIds.includes(id)) + ); + }; + + _jump = (enabledElement, jumpWorld) => { + state.isInteractingWithTool = true; + const { viewport, renderingEngine } = enabledElement; + + const annotations = this._getAnnotations(enabledElement); + + const delta: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(jumpWorld, this.toolCenter, delta); + + // TRANSLATION + // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + + const sameScene = this._checkIfViewportsRenderingSameScene( + viewport, + otherViewport + ); + + return ( + this._getReferenceLineControllable(otherViewport.id) && + this._getReferenceLineDraggableRotatable(otherViewport.id) && + sameScene + ); + } + ); + + if (viewportsAnnotationsToUpdate.length === 0) { + state.isInteractingWithTool = false; + return false; + } + + this._applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ); + + state.isInteractingWithTool = false; + + return true; + }; + + _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 { renderingEngine, viewport } = enabledElement; + const annotations = this._getAnnotations( + enabledElement + ) as CrosshairsAnnotation[]; + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); + + // viewport Annotation + const viewportAnnotation = filteredToolAnnotations[0]; + if (!viewportAnnotation) { + return; + } + + const { handles } = viewportAnnotation.data; + const { currentPoints } = evt.detail; + const canvasCoords = currentPoints.canvas; + + if (handles.activeOperation === OPERATION.DRAG) { + // TRANSLATION + // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const otherViewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + + return ( + otherViewportControllable === true && + otherViewportDraggableRotatable === true && + viewportAnnotation.data.activeViewportIds.find( + (id) => id === otherViewport.id + ) + ); + } + ); + + this._applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ); + } else if (handles.activeOperation === OPERATION.ROTATE) { + // ROTATION + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const otherViewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + + return ( + otherViewportControllable === true && + otherViewportDraggableRotatable === true + ); + } + ); + + const dir1 = vec2.create(); + const dir2 = vec2.create(); + + const center: Types.Point3 = [ + this.toolCenter[0], + this.toolCenter[1], + this.toolCenter[2], + ]; + + const centerCanvas = viewport.worldToCanvas(center); + + const finalPointCanvas = eventDetail.currentPoints.canvas; + const originalPointCanvas = vec2.create(); + vec2.sub( + originalPointCanvas, + finalPointCanvas, + eventDetail.deltaPoints.canvas + ); + vec2.sub(dir1, originalPointCanvas, centerCanvas); + vec2.sub(dir2, finalPointCanvas, centerCanvas); + + let angle = vec2.angle(dir1, dir2); + + if ( + this._isClockWise(centerCanvas, originalPointCanvas, finalPointCanvas) + ) { + angle *= -1; + } + + // Rounding the angle to allow rotated handles to be undone + // If we don't round and rotate handles clockwise by 0.0131233 radians, + // there's no assurance that the counter-clockwise rotation occurs at + // precisely -0.0131233, resulting in the drawn annotations being lost. + angle = Math.round(angle * 100) / 100; + + const rotationAxis = viewport.getCamera().viewPlaneNormal; + // @ts-ignore : vtkjs incorrect typing + const { matrix } = vtkMatrixBuilder + .buildFromRadian() + .translate(center[0], center[1], center[2]) + // @ts-ignore + .rotate(angle, rotationAxis) //todo: why we are passing + .translate(-center[0], -center[1], -center[2]); + + const otherViewportsIds = []; + // update camera for the other viewports. + // NOTE: The lines then are rendered by the onCameraModified + viewportsAnnotationsToUpdate.forEach((annotation) => { + const { data } = annotation; + data.handles.toolCenter = center; + + const otherViewport = renderingEngine.getViewport(data.viewportId); + const camera = otherViewport.getCamera(); + const { viewUp, position, focalPoint } = camera; + + viewUp[0] += position[0]; + viewUp[1] += position[1]; + viewUp[2] += position[2]; + + vec3.transformMat4(focalPoint, focalPoint, matrix); + vec3.transformMat4(position, position, matrix); + vec3.transformMat4(viewUp, viewUp, matrix); + + viewUp[0] -= position[0]; + viewUp[1] -= position[1]; + viewUp[2] -= position[2]; + + otherViewport.setCamera({ + position, + viewUp, + focalPoint, + }); + otherViewportsIds.push(otherViewport.id); + }); + renderingEngine.renderViewports(otherViewportsIds); + } else if (handles.activeOperation === OPERATION.SLAB) { + // SLAB THICKNESS + // this should be just the active one under the mouse, + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const referenceAnnotations = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const otherViewportSlabThicknessControlsOn = + this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); + + return ( + otherViewportControllable === true && + otherViewportSlabThicknessControlsOn === true && + viewportAnnotation.data.activeViewportIds.find( + (id) => id === otherViewport.id + ) + ); + } + ); + + if (referenceAnnotations.length === 0) { + return; + } + const viewportsAnnotationsToUpdate = + this._filterViewportWithSameOrientation( + enabledElement, + referenceAnnotations[0], + annotations + ); + + const viewportsIds = []; + viewportsIds.push(viewport.id); + viewportsAnnotationsToUpdate.forEach( + (annotation: CrosshairsAnnotation) => { + const { data } = annotation; + + const otherViewport = renderingEngine.getViewport( + data.viewportId + ) as Types.IVolumeViewport; + const camera = otherViewport.getCamera(); + const normal = camera.viewPlaneNormal; + + 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 mod = Math.sqrt( + projectedDelta[0] * projectedDelta[0] + + projectedDelta[1] * projectedDelta[1] + + projectedDelta[2] * projectedDelta[2] + ); + + const currentPoint = eventDetail.lastPoints.world; + const direction: Types.Point3 = [0, 0, 0]; + + const currentCenter: Types.Point3 = [ + this.toolCenter[0], + this.toolCenter[1], + this.toolCenter[2], + ]; + + // use this.toolCenter only if viewportDraggableRotatable + const viewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + if (!viewportDraggableRotatable) { + const { rotationPoints } = this.editData.annotation.data.handles; + // Todo: what is a point uid? + // @ts-expect-error + const otherViewportRotationPoints = rotationPoints.filter( + (point) => point[1].uid === otherViewport.id + ); + if (otherViewportRotationPoints.length === 2) { + const point1 = viewport.canvasToWorld( + otherViewportRotationPoints[0][3] + ); + const point2 = viewport.canvasToWorld( + otherViewportRotationPoints[1][3] + ); + vtkMath.add(point1, point2, currentCenter); + vtkMath.multiplyScalar(currentCenter, 0.5); + } + } + + vtkMath.subtract(currentPoint, currentCenter, direction); + const dotProdDirection = vtkMath.dot(direction, normal); + const projectedDirection: Types.Point3 = [...normal]; + vtkMath.multiplyScalar(projectedDirection, dotProdDirection); + const normalizedProjectedDirection: Types.Point3 = [ + projectedDirection[0], + projectedDirection[1], + projectedDirection[2], + ]; + vec3.normalize( + normalizedProjectedDirection, + normalizedProjectedDirection + ); + const normalizedProjectedDelta: Types.Point3 = [ + projectedDelta[0], + projectedDelta[1], + projectedDelta[2], + ]; + vec3.normalize(normalizedProjectedDelta, normalizedProjectedDelta); + + let slabThicknessValue = otherViewport.getSlabThickness(); + if ( + csUtils.isOpposite( + normalizedProjectedDirection, + normalizedProjectedDelta, + 1e-3 + ) + ) { + slabThicknessValue -= mod; + } else { + slabThicknessValue += mod; + } + + slabThicknessValue = Math.abs(slabThicknessValue); + slabThicknessValue = Math.max( + RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS, + slabThicknessValue + ); + + const near = this._pointNearReferenceLine( + viewportAnnotation, + canvasCoords, + 6, + otherViewport + ); + + if (near) { + slabThicknessValue = RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS; + } + + // We want to set the slabThickness for the viewport's actors but + // since the crosshairs tool instance has configuration regarding which + // actorUIDs (in case of volume -> actorUID = volumeIds) to set the + // slabThickness for, we need to delegate the slabThickness setting + // to the crosshairs tool instance of the toolGroup since configurations + // exist on the toolInstance and each toolGroup has its own crosshairs + // tool instance (Otherwise, we would need to set this filterActorUIDsToSetSlabThickness at + // the viewport level which makes tool and viewport state convoluted). + const toolGroup = getToolGroupForViewport( + otherViewport.id, + renderingEngine.id + ); + const crosshairsInstance = toolGroup.getToolInstance( + this.getToolName() + ); + crosshairsInstance.setSlabThickness( + otherViewport, + slabThicknessValue + ); + + viewportsIds.push(otherViewport.id); + } + } + ); + renderingEngine.renderViewports(viewportsIds); + } + }; + + setSlabThickness(viewport, slabThickness) { + let actorUIDs; + const { filterActorUIDsToSetSlabThickness } = this.configuration; + if ( + filterActorUIDsToSetSlabThickness && + filterActorUIDsToSetSlabThickness.length > 0 + ) { + actorUIDs = filterActorUIDsToSetSlabThickness; + } + + let blendModeToUse = this.configuration.slabThicknessBlendMode; + if (slabThickness === RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS) { + blendModeToUse = Enums.BlendModes.COMPOSITE; + } + + const immediate = false; + viewport.setBlendMode(blendModeToUse, actorUIDs, immediate); + viewport.setSlabThickness(slabThickness, actorUIDs); + } + + _isClockWise(a, b, c) { + // return true if the rotation is clockwise + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) > 0; + } + + _applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ) { + // update camera for the other viewports. + // NOTE1: The lines then are rendered by the onCameraModified + // NOTE2: crosshair center are automatically updated in the onCameraModified event + viewportsAnnotationsToUpdate.forEach((annotation) => { + this._applyDeltaShiftToViewportCamera(renderingEngine, annotation, delta); + }); + } + + _applyDeltaShiftToViewportCamera( + renderingEngine: Types.IRenderingEngine, + annotation, + delta + ) { + // update camera for the other viewports. + // NOTE1: The lines then are rendered by the onCameraModified + // NOTE2: crosshair center are automatically updated in the onCameraModified event + 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(); + } + } + + _pointNearReferenceLine = ( + annotation, + canvasCoords, + proximity, + lineViewport + ) => { + const { data } = annotation; + const { rotationPoints } = data.handles; + + for (let i = 0; i < rotationPoints.length - 1; ++i) { + const otherViewport = rotationPoints[i][1]; + if (otherViewport.id !== lineViewport.id) { + continue; + } + + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + if (!viewportControllable) { + continue; + } + + const lineSegment1 = { + start: { + x: rotationPoints[i][2][0], + y: rotationPoints[i][2][1], + }, + end: { + x: rotationPoints[i][3][0], + y: rotationPoints[i][3][1], + }, + }; + + const distanceToPoint1 = lineSegment.distanceToPoint( + [lineSegment1.start.x, lineSegment1.start.y], + [lineSegment1.end.x, lineSegment1.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + const lineSegment2 = { + start: { + x: rotationPoints[i + 1][2][0], + y: rotationPoints[i + 1][2][1], + }, + end: { + x: rotationPoints[i + 1][3][0], + y: rotationPoints[i + 1][3][1], + }, + }; + + const distanceToPoint2 = lineSegment.distanceToPoint( + [lineSegment2.start.x, lineSegment2.start.y], + [lineSegment2.end.x, lineSegment2.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { + return true; + } + + // rotation handles are two for viewport + i++; + } + + return false; + }; + + _getRotationHandleNearImagePoint( + viewport, + annotation, + canvasCoords, + proximity + ) { + const { data } = annotation; + const { rotationPoints } = data.handles; + + for (let i = 0; i < rotationPoints.length; i++) { + const point = rotationPoints[i][0]; + const otherViewport = rotationPoints[i][1]; + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + if (!viewportControllable) { + continue; + } + + const viewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + if (!viewportDraggableRotatable) { + continue; + } + + const annotationCanvasCoordinate = viewport.worldToCanvas(point); + if (vec2.distance(canvasCoords, annotationCanvasCoordinate) < proximity) { + data.handles.activeOperation = OPERATION.ROTATE; + + this.editData = { + annotation, + }; + + return point; + } + } + + return null; + } + + _getSlabThicknessHandleNearImagePoint( + viewport, + annotation, + canvasCoords, + proximity + ) { + const { data } = annotation; + const { slabThicknessPoints } = data.handles; + + for (let i = 0; i < slabThicknessPoints.length; i++) { + const point = slabThicknessPoints[i][0]; + const otherViewport = slabThicknessPoints[i][1]; + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + if (!viewportControllable) { + continue; + } + + const viewportSlabThicknessControlsOn = + this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); + if (!viewportSlabThicknessControlsOn) { + continue; + } + + const annotationCanvasCoordinate = viewport.worldToCanvas(point); + if (vec2.distance(canvasCoords, annotationCanvasCoordinate) < proximity) { + data.handles.activeOperation = OPERATION.SLAB; + + data.activeViewportIds = [otherViewport.id]; + + this.editData = { + annotation, + }; + + return point; + } + } + + return null; + } + + _pointNearTool(element, annotation, canvasCoords, proximity) { + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + const { clientWidth, clientHeight } = viewport.canvas; + const canvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + const { data } = annotation; + + const { rotationPoints } = data.handles; + const { slabThicknessPoints } = data.handles; + const viewportIdArray = []; + + for (let i = 0; i < rotationPoints.length - 1; ++i) { + const otherViewport = rotationPoints[i][1]; + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const viewportDraggableRotatable = + this._getReferenceLineDraggableRotatable(otherViewport.id); + + if (!viewportControllable || !viewportDraggableRotatable) { + continue; + } + + const lineSegment1 = { + start: { + x: rotationPoints[i][2][0], + y: rotationPoints[i][2][1], + }, + end: { + x: rotationPoints[i][3][0], + y: rotationPoints[i][3][1], + }, + }; + + const distanceToPoint1 = lineSegment.distanceToPoint( + [lineSegment1.start.x, lineSegment1.start.y], + [lineSegment1.end.x, lineSegment1.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + const lineSegment2 = { + start: { + x: rotationPoints[i + 1][2][0], + y: rotationPoints[i + 1][2][1], + }, + end: { + x: rotationPoints[i + 1][3][0], + y: rotationPoints[i + 1][3][1], + }, + }; + + const distanceToPoint2 = lineSegment.distanceToPoint( + [lineSegment2.start.x, lineSegment2.start.y], + [lineSegment2.end.x, lineSegment2.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { + viewportIdArray.push(otherViewport.id); + data.handles.activeOperation = OPERATION.DRAG; + } + + // rotation handles are two for viewport + i++; + } + + for (let i = 0; i < slabThicknessPoints.length - 1; ++i) { + const otherViewport = slabThicknessPoints[i][1]; + if (viewportIdArray.find((id) => id === otherViewport.id)) { + continue; + } + + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const viewportSlabThicknessControlsOn = + this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); + + if (!viewportControllable || !viewportSlabThicknessControlsOn) { + continue; + } + + const stPointLineCanvas1 = slabThicknessPoints[i][2]; + const stPointLineCanvas2 = slabThicknessPoints[i][3]; + + const centerCanvas = vec2.create(); + vec2.add(centerCanvas, stPointLineCanvas1, stPointLineCanvas2); + vec2.scale(centerCanvas, centerCanvas, 0.5); + + const canvasUnitVectorFromCenter = vec2.create(); + vec2.subtract( + canvasUnitVectorFromCenter, + stPointLineCanvas1, + centerCanvas + ); + vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); + + const canvasVectorFromCenterStart = vec2.create(); + vec2.scale( + canvasVectorFromCenterStart, + canvasUnitVectorFromCenter, + canvasDiagonalLength * 0.05 + ); + + const stPointLineCanvas1Start = vec2.create(); + const stPointLineCanvas2Start = vec2.create(); + vec2.add( + stPointLineCanvas1Start, + centerCanvas, + canvasVectorFromCenterStart + ); + vec2.subtract( + stPointLineCanvas2Start, + centerCanvas, + canvasVectorFromCenterStart + ); + + const lineSegment1 = { + start: { + x: stPointLineCanvas1Start[0], + y: stPointLineCanvas1Start[1], + }, + end: { + x: stPointLineCanvas1[0], + y: stPointLineCanvas1[1], + }, + }; + + const distanceToPoint1 = lineSegment.distanceToPoint( + [lineSegment1.start.x, lineSegment1.start.y], + [lineSegment1.end.x, lineSegment1.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + const lineSegment2 = { + start: { + x: stPointLineCanvas2Start[0], + y: stPointLineCanvas2Start[1], + }, + end: { + x: stPointLineCanvas2[0], + y: stPointLineCanvas2[1], + }, + }; + + const distanceToPoint2 = lineSegment.distanceToPoint( + [lineSegment2.start.x, lineSegment2.start.y], + [lineSegment2.end.x, lineSegment2.end.y], + [canvasCoords[0], canvasCoords[1]] + ); + + if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { + viewportIdArray.push(otherViewport.id); // we still need this to draw inactive slab thickness handles + data.handles.activeOperation = null; // no operation + } + + // slab thickness handles are in couples + i++; + } + + data.activeViewportIds = [...viewportIdArray]; + + this.editData = { + annotation, + }; + + return data.handles.activeOperation === OPERATION.DRAG ? true : false; } - widgetManager.enablePicking(); - renderWindow.render(); } -*/ -VolumeCroppingTool.toolName = 'VolumeCropping'; +VolumeCroppingTool.toolName = 'Crosshairs'; export default VolumeCroppingTool; diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts index 605030eb7f..de2a20f481 100644 --- a/packages/tools/src/tools/index.ts +++ b/packages/tools/src/tools/index.ts @@ -1,6 +1,7 @@ import { BaseTool, AnnotationTool, AnnotationDisplayTool } from './base'; import PanTool from './PanTool'; import TrackballRotateTool from './TrackballRotateTool'; +import VolumeCroppingTool from './VolumeCroppingTool'; import WindowLevelTool from './WindowLevelTool'; import WindowLevelRegionTool from './WindowLevelRegionTool'; import StackScrollTool from './StackScrollTool'; @@ -70,6 +71,7 @@ export { // Manipulation Tools PanTool, TrackballRotateTool, + VolumeCroppingTool, DragProbeTool, WindowLevelTool, WindowLevelRegionTool, From 7b648655544bef9dbee1fc1ee0777caa2ec4735f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:03:19 +0200 Subject: [PATCH 007/132] feat: Restrict TrackballRotateTool to VOLUME_3D viewports and refactor VolumeCroppingTool - Added a check in TrackballRotateTool to allow rotation only for VOLUME_3D viewports. - Refactored VolumeCroppingTool to remove slab thickness handling and rotation points. - Introduced reference lines for better annotation management. - Updated annotation interfaces and removed unused methods related to slab thickness. - Enhanced handle selection logic and improved code readability. --- .../examples/volumeCroppingTool/index.ts | 125 +- .../tools/src/tools/TrackballRotateTool.ts | 6 +- .../tools/src/tools/VolumeCroppingTool.ts | 1126 +++-------------- 3 files changed, 240 insertions(+), 1017 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 4d7fe1426a..042d12e326 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -30,6 +30,7 @@ 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 ToolGroup from 'tools/src/store/ToolGroupManager/ToolGroup'; // This is for debugging purposes console.warn( @@ -43,6 +44,7 @@ const { synchronizers, TrackballRotateTool, ZoomTool, + OrientationMarkerTool, } = cornerstoneTools; const { createSlabThicknessSynchronizer } = synchronizers; @@ -71,7 +73,7 @@ const newToolGroupId = 'NEW_TOOL_GROUP_ID'; // ======== Set up page ======== // setTitleAndDescription( 'Volume Cropping', - 'Here we demonstrate how to crop a 3D volume along 6 clipping planes aligned on the axises.' + 'Here we demonstrate how to crop a 3D volume with 6 clipping planes aligned on the x,y and z axes.' ); const size = '400px'; @@ -138,24 +140,15 @@ instructions.innerText = ` Basic controls: - Click/Drag anywhere in the viewport to move the center of the crosshairs. - Drag a reference line to move it, scrolling the other views. - - Advanced controls: Hover over a line and find the following two handles: - - Square (closest to center): Drag these to change the thickness of the MIP slab in that plane. - - Circle (further from center): Drag these to rotate the axes. `; content.append(instructions); -addButtonToToolbar({ - title: 'Reset Camera', - onClick: () => { - const renderingEngine = getRenderingEngine(renderingEngineId); - - viewportIds.forEach((viewportId) => { - const viewport = renderingEngine.getViewport(viewportId); - viewport.resetCamera(); - viewport.render(); - }); +addToggleButtonToToolbar({ + title: 'Toggle 3D handles', + defaultToggle: false, + onClick: (toggle) => { + // resetViewports(toggle); }, }); @@ -267,79 +260,6 @@ function getReferenceLineSlabThicknessControlsOn(viewportId) { return index !== -1; } -const blendModeOptions = { - MIP: 'Maximum Intensity Projection', - MINIP: 'Minimum Intensity Projection', - AIP: 'Average Intensity Projection', -}; - -addDropdownToToolbar({ - options: { - values: [ - 'Maximum Intensity Projection', - 'Minimum Intensity Projection', - 'Average Intensity Projection', - ], - defaultValue: 'Maximum Intensity Projection', - }, - onSelectedValueChange: (selectedValue) => { - let blendModeToUse; - switch (selectedValue) { - case blendModeOptions.MIP: - blendModeToUse = Enums.BlendModes.MAXIMUM_INTENSITY_BLEND; - break; - case blendModeOptions.MINIP: - blendModeToUse = Enums.BlendModes.MINIMUM_INTENSITY_BLEND; - break; - case blendModeOptions.AIP: - blendModeToUse = Enums.BlendModes.AVERAGE_INTENSITY_BLEND; - break; - default: - throw new Error('undefined orientation option'); - } - - const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); - - const crosshairsInstance = toolGroup.getToolInstance( - VolumeCroppingTool.toolName - ); - const oldConfiguration = crosshairsInstance.configuration; - - crosshairsInstance.configuration = { - ...oldConfiguration, - slabThicknessBlendMode: blendModeToUse, - }; - - // Update the blendMode for actors to instantly reflect the change - toolGroup.viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport = renderingEngine.getViewport( - viewportId - ) as Types.IVolumeViewport; - - viewport.setBlendMode(blendModeToUse); - viewport.render(); - }); - - // Also update the 3D volume viewport - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport3D = renderingEngine.getViewport( - viewportId4 - ) as Types.IVolumeViewport; - viewport3D.setBlendMode(blendModeToUse); - viewport3D.render(); - }, -}); - -addToggleButtonToToolbar({ - id: 'syncSlabThickness', - title: 'Sync Slab Thickness', - defaultToggle: false, - onClick: (toggle) => { - synchronizer.setEnabled(toggle); - }, -}); - function setUpSynchronizers() { synchronizer = createSlabThicknessSynchronizer(synchronizerId); @@ -365,8 +285,16 @@ async function run() { // Add tools to Cornerstone3D cornerstoneTools.addTool(VolumeCroppingTool); cornerstoneTools.addTool(TrackballRotateTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(OrientationMarkerTool); + + /* const newToolGroup = ToolGroupManager.createToolGroup(newToolGroupId); + newToolGroup.addTool(OrientationMarkerTool.toolName); + newToolGroup.addTool(ZoomTool.toolName); newToolGroup.addTool(TrackballRotateTool.toolName); + newToolGroup.addTool(VolumeCroppingTool.toolName); + newToolGroup.setToolActive(OrientationMarkerTool.toolName); newToolGroup.setToolActive(TrackballRotateTool.toolName, { bindings: [ { @@ -377,11 +305,11 @@ async function run() { newToolGroup.setToolActive(ZoomTool.toolName, { bindings: [ { - mouseButton: MouseBindings.Secondary, // Left Click + mouseButton: MouseBindings.Secondary, }, ], }); - +*/ // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: @@ -461,13 +389,22 @@ async function run() { // Define tool groups to add the segmentation display tool to const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); addManipulationBindings(toolGroup); + toolGroup.addTool(TrackballRotateTool.toolName); + toolGroup.setToolActive(TrackballRotateTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Left Click + }, + ], + }); // For the crosshairs to operate, the viewports must currently be // added ahead of setting the tool active. This will be improved in the future. toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); - newToolGroup.addViewport(viewportId4, renderingEngineId); + toolGroup.addViewport(viewportId4, renderingEngineId); + //newToolGroup.addViewport(viewportId4, renderingEngineId); // Manipulation Tools // Add Crosshairs tool and configure it to link the three viewports @@ -587,10 +524,10 @@ async function run() { const pickedPoint = pickedPositions[0]; if (pickedPoint) { console.log('Picked point coordinates:', pickedPoint); - addSphere(viewport, pickedPoint); + // addSphere(viewport, pickedPoint); addTemporaryPickedPositionLabel(x, y, pickedPoint); setCrossHairPosition(pickedPoint); - setClippingPlane(viewport, 0, [0, 0, -500]); + // setClippingPlane(viewport, 0, [0, 0, -500]); } } } @@ -606,7 +543,7 @@ eventTarget.addEventListener( viewportId4 ) as VolumeViewport3D; if (sphereActor) { - addSphere(viewport, toolCenter); + // addSphere(viewport, toolCenter); } } ); diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 14465d6370..c2914411f0 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -5,6 +5,7 @@ import { eventTarget, getEnabledElement, getEnabledElementByIds, + Enums, } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import { mat4, vec3 } from 'gl-matrix'; @@ -249,7 +250,10 @@ class TrackballRotateTool extends BaseTool { const { rotateIncrementDegrees } = this.configuration; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - + if (viewport.type !== Enums.ViewportType.VOLUME_3D) { + // Only allow rotation for VOLUME_3D viewports + return; + } const camera = viewport.getCamera(); const width = element.clientWidth; const height = element.clientHeight; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index cb16775a92..ef2992a654 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,11 +1,18 @@ import { vec2, vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; +import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; +//import * as cornerstoneTools from '@cornerstonejs/tools'; +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 { AnnotationTool } from './base'; import type { Types } from '@cornerstonejs/core'; import { + getRenderingEngine, getEnabledElementByIds, getEnabledElement, utilities as csUtils, @@ -28,7 +35,6 @@ import { import { drawCircle as drawCircleSvg, - drawHandles as drawHandlesSvg, drawLine as drawLineSvg, } from '../drawingSvg'; import { state } from '../store/state'; @@ -56,16 +62,19 @@ import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotatio const { RENDERING_DEFAULTS } = CONSTANTS; -interface CrosshairsAnnotation extends Annotation { +//interface VolumeCroppingAnnotation extends Annotation { +interface VolumeCroppingAnnotation extends Annotation { data: { handles: { - rotationPoints: Types.Point3[]; // rotation handles, used for rotation interactions - slabThicknessPoints: Types.Point3[]; // slab thickness handles, used for setting the slab thickness + // rotationPoints: Types.Point3[]; // rotation handles, used for rotation interactions + // slabThicknessPoints: Types.Point3[]; // slab thickness handles, used for setting the slab thickness activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles toolCenter: Types.Point3; }; activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated viewportId: string; + // referenceLines: []; // set in renderAnnotation + clippingPlanes?: vtkPlane[]; // clipping planes for the viewport }; } @@ -81,10 +90,6 @@ function defaultReferenceLineDraggableRotatable() { return true; } -function defaultReferenceLineSlabThicknessControlsOn() { - return true; -} - const OPERATION = { DRAG: 1, ROTATE: 2, @@ -97,9 +102,6 @@ const EPSILON = 1e-3; * VolumeCroppingTool is a tool that provides reference lines between different viewports * of a toolGroup. Using crosshairs, you can jump to a specific location in one * viewport and the rest of the viewports in the toolGroup will be aligned to that location. - * Crosshairs have grababble handles that can be used to rotate and translate the - * reference lines. They can also be used to set the slab thickness of the viewports - * by modifying the slab thickness handles. * */ class VolumeCroppingTool extends AnnotationTool { @@ -110,7 +112,6 @@ class VolumeCroppingTool extends AnnotationTool { _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; _getReferenceLineDraggableRotatable?: (viewportId: string) => boolean; - _getReferenceLineSlabThicknessControlsOn?: (viewportId: string) => boolean; constructor( toolProps: PublicToolProps = {}, @@ -144,11 +145,7 @@ class VolumeCroppingTool extends AnnotationTool { // the reference lines will not be rendered. This is only used when // having 3 viewports in the toolGroup. referenceLinesCenterGapRadius: 20, - // actorUIDs for slabThickness application, if not defined, the slab thickness - // will be applied to all actors of the viewport - filterActorUIDsToSetSlabThickness: [], - // blend mode for slabThickness modifications - slabThicknessBlendMode: Enums.BlendModes.MAXIMUM_INTENSITY_BLEND, + mobile: { enabled: false, opacity: 0.8, @@ -168,9 +165,9 @@ class VolumeCroppingTool extends AnnotationTool { this._getReferenceLineDraggableRotatable = toolProps.configuration?.getReferenceLineDraggableRotatable || defaultReferenceLineDraggableRotatable; - this._getReferenceLineSlabThicknessControlsOn = - toolProps.configuration?.getReferenceLineSlabThicknessControlsOn || - defaultReferenceLineSlabThicknessControlsOn; + // this._getReferenceLineSlabThicknessControlsOn = + // toolProps.configuration?.getReferenceLineSlabThicknessControlsOn || + // defaultReferenceLineSlabThicknessControlsOn; } /** @@ -220,13 +217,14 @@ class VolumeCroppingTool extends AnnotationTool { }, data: { handles: { - rotationPoints: [], // rotation handles, used for rotation interactions - slabThicknessPoints: [], // slab thickness handles, used for setting the slab thickness + // rotationPoints: [], // rotation handles, used for rotation interactions + // slabThicknessPoints: [], // slab thickness handles, used for setting the slab thickness toolCenter: this.toolCenter, }, 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 }, }; @@ -338,6 +336,27 @@ class VolumeCroppingTool extends AnnotationTool { this._computeToolCenter(viewportsInfo); }; + addSphere(viewport, point) { + // if (!sphereActor) { + // Generate a random string for the sphere UID + const randomUID = 'sphere_' + Math.random().toString(36).substring(2, 15); + + const sphereSource = vtkSphereSource.newInstance(); + sphereSource.setCenter(point); + sphereSource.setRadius(15); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + const sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); + sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + viewport.addActor({ actor: sphereActor, uid: randomUID }); + // } else { + // sphereActor.getMapper().getInputConnection().filter.setCenter(point); + // } + // console.debug('actors:', viewport.getActors()); + viewport.render(); + } + computeToolCenter = () => { const viewportsInfo = this._getViewportsInfo(); this._computeToolCenter(viewportsInfo); @@ -353,15 +372,9 @@ class VolumeCroppingTool extends AnnotationTool { * @param viewportsInfo Array of viewportInputs which each item containing `{viewportId, renderingEngineId}` */ _computeToolCenter = (viewportsInfo): void => { - if (!viewportsInfo.length || viewportsInfo.length === 1) { - console.warn( - 'For crosshairs to operate, at least two viewports must be given.' - ); - return; - } - // Todo: handle two same view viewport, or more than 3 viewports - const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; + const [firstViewport, secondViewport, thirdViewport, viewport3D] = + viewportsInfo; // Initialize first viewport const { normal: normal1, point: point1 } = @@ -393,10 +406,108 @@ class VolumeCroppingTool extends AnnotationTool { const secondPlane = csUtils.planar.planeEquation(normal2, point2); const thirdPlane = csUtils.planar.planeEquation(normal3, point3); - // Calculating the intersection of 3 planes - // prettier-ignore + // add clipping planes + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const volumeActors = viewport.getActors(); + const imageData = volumeActors[0].actor.getMapper().getInputData(); + const dimensions = imageData.getDimensions(); + const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] + const worldDimensions = [ + Math.round(dimensions[0] * spacing[0]), + Math.round(dimensions[1] * spacing[1]), + Math.round(dimensions[2] * spacing[2]), + ]; + console.debug('worldDimensions', worldDimensions); + //const xMin = 1500 * -0.5; + const xMin = worldDimensions[0] * -0.5; + const xMax = worldDimensions[0] * 0.5; + const yMin = worldDimensions[1] * -0.5; + const yMax = worldDimensions[1] * 0.5; + const zMin = worldDimensions[2] * -0.5; + const zMax = 0; + const planes: vtkPlane[] = []; + const cropFactor = 0.2; + // X min plane (cuts everything left of xMin) + const planeXmin = vtkPlane.newInstance({ + origin: [xMin + worldDimensions[0] * cropFactor, 0, 0], + normal: [1, 0, 0], + }); + const planeXmax = vtkPlane.newInstance({ + origin: [xMax - worldDimensions[0] * cropFactor, 0, 0], + normal: [-1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, yMin + worldDimensions[1] * cropFactor, 0], + normal: [0, 1, 0], + }); + const planeYmax = vtkPlane.newInstance({ + origin: [0, yMax - worldDimensions[1] * cropFactor, 0], + normal: [0, -1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, zMin + worldDimensions[2] * cropFactor], + normal: [0, 0, 1], + }); + const planeZmax = vtkPlane.newInstance({ + origin: [0, 0, zMax - worldDimensions[2] * cropFactor], + normal: [0, 0, -1], + }); + + const mapper = viewport.getDefaultActor().actor.getMapper(); + + 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()], + })); + + viewport.setOriginalClippingPlanes(originalPlanes); + + // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); + this.addSphere(viewport, [ + xMin + worldDimensions[0] * cropFactor, + (yMax + yMin) / 2, + (zMax + zMin) / 4, + ]); + this.addSphere(viewport, [ + xMax - worldDimensions[0] * cropFactor, + (yMax + yMin) / 2, + (zMax + zMin) / 2, + ]); + this.addSphere(viewport, [ + (xMax + xMin) / 2, + yMin + worldDimensions[0] * cropFactor, + (zMax + zMin) / 2, + ]); + + this.addSphere(viewport, [ + (xMax + xMin) / 2, + yMax - worldDimensions[0] * cropFactor, + (zMax + zMin) / 2, + ]); + this.addSphere(viewport, [ + (xMax + xMin) / 2, + (yMax + yMin) / 2, + zMin - worldDimensions[0] * cropFactor, + ]); + + // this.addSphere(viewport, [0, 0, 0]); + // this.addSphere(viewport, [xMin, yMin, zMin]); + // this.addSphere(viewport, [xMax, yMax, zMax]); + //this.addSphere(viewport, [0, 0, 0]); + viewport.render(); - const toolCenter = csUtils.planar.threePlaneIntersection(firstPlane, secondPlane, thirdPlane); + const toolCenter = csUtils.planar.threePlaneIntersection( + firstPlane, + secondPlane, + thirdPlane + ); this.setToolCenter(toolCenter); }; @@ -425,9 +536,10 @@ class VolumeCroppingTool extends AnnotationTool { * @param interactionType - The type of interaction (e.g., mouse, touch, etc.) * @returns Crosshairs annotation */ + /* addNewAnnotation = ( evt: EventTypes.InteractionEventType - ): CrosshairsAnnotation => { + ): VolumeCroppingAnnotation => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -475,8 +587,9 @@ class VolumeCroppingTool extends AnnotationTool { this._activateModify(element); return filteredAnnotations[0]; - }; + }; + */ cancel = () => { console.log('Not implemented yet'); }; @@ -494,6 +607,7 @@ class VolumeCroppingTool extends AnnotationTool { * @returns The handle that is closest to the cursor, or null if the cursor * is not near any of the handles. */ + /* getHandleNearImagePoint( element: HTMLDivElement, annotation: Annotation, @@ -503,18 +617,7 @@ class VolumeCroppingTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - let point = this._getRotationHandleNearImagePoint( - viewport, - annotation, - canvasCoords, - proximity - ); - - if (point !== null) { - return point; - } - - point = this._getSlabThicknessHandleNearImagePoint( + const point = this._getRotationHandleNearImagePoint( viewport, annotation, canvasCoords, @@ -525,6 +628,7 @@ class VolumeCroppingTool extends AnnotationTool { return point; } } + */ handleSelectedCallback = ( evt: EventTypes.InteractionEventType, @@ -559,7 +663,7 @@ class VolumeCroppingTool extends AnnotationTool { */ isPointNearTool = ( element: HTMLDivElement, - annotation: CrosshairsAnnotation, + annotation: VolumeCroppingAnnotation, canvasCoords: Types.Point2, proximity: number ): boolean => { @@ -598,7 +702,7 @@ class VolumeCroppingTool extends AnnotationTool { // viewport that the camera modified is originating from const viewportAnnotation = - filteredToolAnnotations[0] as CrosshairsAnnotation; + filteredToolAnnotations[0] as VolumeCroppingAnnotation; if (!viewportAnnotation) { return; @@ -715,12 +819,15 @@ class VolumeCroppingTool extends AnnotationTool { 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 CrosshairsAnnotation; + const annotation = filteredToolAnnotations[i] as VolumeCroppingAnnotation; if (isAnnotationLocked(annotation.annotationUID)) { continue; @@ -739,6 +846,8 @@ class VolumeCroppingTool extends AnnotationTool { // This init are necessary, because when we move the mouse they are not cleaned by _endCallback data.activeViewportIds = []; + + /* data.handles.activeOperation = null; const handleNearImagePoint = this.getHandleNearImagePoint( @@ -747,27 +856,19 @@ class VolumeCroppingTool extends AnnotationTool { canvasCoords, 6 ); - +*/ let near = false; - if (handleNearImagePoint) { - near = true; - } else { - near = this._pointNearTool(element, annotation, canvasCoords, 6); - } + // if (handleNearImagePoint) { + // near = true; + // } else { + near = this._pointNearTool(element, annotation, canvasCoords, 6); + // } const nearToolAndNotMarkedActive = near && !highlighted; const notNearToolAndMarkedActive = !near && highlighted; if (nearToolAndNotMarkedActive || notNearToolAndMarkedActive) { annotation.highlighted = !highlighted; imageNeedsUpdate = true; - } else if ( - data.handles.activeOperation !== previousActiveOperation || - !this._areViewportIdArraysEqual( - data.activeViewportIds, - previousActiveViewportIds - ) - ) { - imageNeedsUpdate = true; } } @@ -857,8 +958,6 @@ class VolumeCroppingTool extends AnnotationTool { ); const otherViewportDraggableRotatable = this._getReferenceLineDraggableRotatable(otherViewport.id); - const otherViewportSlabThicknessControlsOn = - this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); // get coordinates for the reference line const { clientWidth, clientHeight } = otherViewport.canvas; @@ -992,174 +1091,15 @@ class VolumeCroppingTool extends AnnotationTool { // the lines goes beyond canvas liangBarksyClip(refLinePointOne, refLinePointTwo, canvasBox); liangBarksyClip(refLinePointThree, refLinePointFour, canvasBox); - - // Computing rotation handle positions - const rotHandleOne = vec2.create(); - vec2.subtract( - rotHandleOne, - crosshairCenterCanvas, - canvasVectorFromCenterMid - ); - - const rotHandleTwo = vec2.create(); - vec2.add(rotHandleTwo, crosshairCenterCanvas, canvasVectorFromCenterMid); - - // Computing SlabThickness (st below) position - - // SlabThickness center in canvas - let stHandlesCenterCanvas = vec2.clone(crosshairCenterCanvas); - if ( - !otherViewportDraggableRotatable && - otherViewportSlabThicknessControlsOn - ) { - stHandlesCenterCanvas = vec2.clone(otherViewportCenterCanvas); - } - - // SlabThickness center in world - let stHandlesCenterWorld: Types.Point3 = [...this.toolCenter]; - if ( - !otherViewportDraggableRotatable && - otherViewportSlabThicknessControlsOn - ) { - stHandlesCenterWorld = [...otherViewportCenterWorld]; - } - - const worldUnitVectorFromCenter: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(pointWorld0, pointWorld1, worldUnitVectorFromCenter); - vtkMath.normalize(worldUnitVectorFromCenter); - - const { viewPlaneNormal } = camera; - // @ts-ignore // Todo: fix after vtk pr merged - const { matrix } = vtkMatrixBuilder - .buildFromDegree() - // @ts-ignore fix after vtk pr merged - .rotate(90, viewPlaneNormal); - - const worldUnitOrthoVectorFromCenter: Types.Point3 = [0, 0, 0]; - vec3.transformMat4( - worldUnitOrthoVectorFromCenter, - worldUnitVectorFromCenter, - matrix - ); - - const slabThicknessValue = otherViewport.getSlabThickness(); - const worldOrthoVectorFromCenter: Types.Point3 = [ - ...worldUnitOrthoVectorFromCenter, - ]; - vtkMath.multiplyScalar(worldOrthoVectorFromCenter, slabThicknessValue); - - const worldVerticalRefPoint: Types.Point3 = [0, 0, 0]; - vtkMath.add( - stHandlesCenterWorld, - worldOrthoVectorFromCenter, - worldVerticalRefPoint - ); - - // convert vertical world distances in canvas coordinates - const canvasVerticalRefPoint = viewport.worldToCanvas( - worldVerticalRefPoint - ); - - // points for slab thickness lines - const canvasOrthoVectorFromCenter = vec2.create(); - vec2.subtract( - canvasOrthoVectorFromCenter, - stHandlesCenterCanvas, - canvasVerticalRefPoint - ); - - const stLinePointOne = vec2.create(); - vec2.subtract( - stLinePointOne, - stHandlesCenterCanvas, - canvasVectorFromCenterLong - ); - vec2.add(stLinePointOne, stLinePointOne, canvasOrthoVectorFromCenter); - - const stLinePointTwo = vec2.create(); - vec2.add( - stLinePointTwo, - stHandlesCenterCanvas, - canvasVectorFromCenterLong - ); - vec2.add(stLinePointTwo, stLinePointTwo, canvasOrthoVectorFromCenter); - - liangBarksyClip(stLinePointOne, stLinePointTwo, canvasBox); - - const stLinePointThree = vec2.create(); - vec2.add( - stLinePointThree, - stHandlesCenterCanvas, - canvasVectorFromCenterLong - ); - vec2.subtract( - stLinePointThree, - stLinePointThree, - canvasOrthoVectorFromCenter - ); - - const stLinePointFour = vec2.create(); - vec2.subtract( - stLinePointFour, - stHandlesCenterCanvas, - canvasVectorFromCenterLong - ); - vec2.subtract( - stLinePointFour, - stLinePointFour, - canvasOrthoVectorFromCenter - ); - - liangBarksyClip(stLinePointThree, stLinePointFour, canvasBox); - - // points for slab thickness handles - const stHandleOne = vec2.create(); - const stHandleTwo = vec2.create(); - const stHandleThree = vec2.create(); - const stHandleFour = vec2.create(); - - vec2.subtract( - stHandleOne, - stHandlesCenterCanvas, - canvasVectorFromCenterShort - ); - vec2.add(stHandleOne, stHandleOne, canvasOrthoVectorFromCenter); - vec2.add(stHandleTwo, stHandlesCenterCanvas, canvasVectorFromCenterShort); - vec2.add(stHandleTwo, stHandleTwo, canvasOrthoVectorFromCenter); - vec2.subtract( - stHandleThree, - stHandlesCenterCanvas, - canvasVectorFromCenterShort - ); - vec2.subtract(stHandleThree, stHandleThree, canvasOrthoVectorFromCenter); - vec2.add( - stHandleFour, - stHandlesCenterCanvas, - canvasVectorFromCenterShort - ); - vec2.subtract(stHandleFour, stHandleFour, canvasOrthoVectorFromCenter); - referenceLines.push([ otherViewport, refLinePointOne, refLinePointTwo, refLinePointThree, refLinePointFour, - stLinePointOne, - stLinePointTwo, - stLinePointThree, - stLinePointFour, - rotHandleOne, - rotHandleTwo, - stHandleOne, - stHandleTwo, - stHandleThree, - stHandleFour, ]); }); - - const newRtpoints = []; - const newStpoints = []; + data.referenceLines = referenceLines; const viewportColor = this._getReferenceLineColor(viewport.id); const color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; @@ -1174,14 +1114,11 @@ class VolumeCroppingTool extends AnnotationTool { const viewportDraggableRotatable = this._getReferenceLineDraggableRotatable(otherViewport.id) || this.configuration.mobile?.enabled; - const viewportSlabThicknessControlsOn = - this._getReferenceLineSlabThicknessControlsOn(otherViewport.id) || - this.configuration.mobile?.enabled; const selectedViewportId = data.activeViewportIds.find( (id) => id === otherViewport.id ); - let color = + const color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; let lineWidth = 1; @@ -1239,60 +1176,6 @@ class VolumeCroppingTool extends AnnotationTool { if (viewportControllable) { color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - - const rotHandlesActive = - data.handles.activeOperation === OPERATION.ROTATE; - const rotationHandles = [line[9], line[10]]; - - const rotHandleWorldOne = [ - viewport.canvasToWorld(line[9]), - otherViewport, - line[1], - line[2], - ]; - const rotHandleWorldTwo = [ - viewport.canvasToWorld(line[10]), - otherViewport, - line[3], - line[4], - ]; - newRtpoints.push(rotHandleWorldOne, rotHandleWorldTwo); - - const slabThicknessHandlesActive = - data.handles.activeOperation === OPERATION.SLAB; - const slabThicknessHandles = [line[11], line[12], line[13], line[14]]; - - const slabThicknessHandleWorldOne = [ - viewport.canvasToWorld(line[11]), - otherViewport, - line[5], - line[6], - ]; - const slabThicknessHandleWorldTwo = [ - viewport.canvasToWorld(line[12]), - otherViewport, - line[5], - line[6], - ]; - const slabThicknessHandleWorldThree = [ - viewport.canvasToWorld(line[13]), - otherViewport, - line[7], - line[8], - ]; - const slabThicknessHandleWorldFour = [ - viewport.canvasToWorld(line[14]), - otherViewport, - line[7], - line[8], - ]; - newStpoints.push( - slabThicknessHandleWorldOne, - slabThicknessHandleWorldTwo, - slabThicknessHandleWorldThree, - slabThicknessHandleWorldFour - ); - let handleRadius = this.configuration.handleRadius * (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); @@ -1301,165 +1184,21 @@ class VolumeCroppingTool extends AnnotationTool { handleRadius = this.configuration.mobile.handleRadius; opacity = this.configuration.mobile.opacity; } - - if ( - (lineActive || this.configuration.mobile?.enabled) && - !rotHandlesActive && - !slabThicknessHandlesActive && - viewportDraggableRotatable && - viewportSlabThicknessControlsOn - ) { - // draw all handles inactive (rotation and slab thickness) - let handleUID = `${lineIndex}One`; - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - handleUID, - rotationHandles, - { - color, - handleRadius, - opacity, - type: 'circle', - } - ); - handleUID = `${lineIndex}Two`; - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - handleUID, - slabThicknessHandles, - { - color, - handleRadius, - opacity, - type: 'rect', - } - ); - } else if ( - lineActive && - !rotHandlesActive && - !slabThicknessHandlesActive && - viewportDraggableRotatable - ) { - const handleUID = `${lineIndex}`; - // draw rotation handles inactive - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - handleUID, - rotationHandles, - { - color, - handleRadius, - opacity, - type: 'circle', - } - ); - } else if ( - selectedViewportId && - !rotHandlesActive && - !slabThicknessHandlesActive && - viewportSlabThicknessControlsOn - ) { + if (lineActive && viewportDraggableRotatable) { const handleUID = `${lineIndex}`; - // draw slab thickness handles inactive - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - handleUID, - slabThicknessHandles, - { - color, - handleRadius, - opacity, - type: 'rect', - } - ); - } else if (rotHandlesActive && viewportDraggableRotatable) { + } else if (viewportDraggableRotatable) { const handleUID = `${lineIndex}`; const handleRadius = this.configuration.handleRadius * (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); - // draw all rotation handles as active - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - handleUID, - rotationHandles, - { - color, - handleRadius, - fill: color, - type: 'circle', - } - ); - } else if ( - slabThicknessHandlesActive && - selectedViewportId && - viewportSlabThicknessControlsOn - ) { - const handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles - ? window.devicePixelRatio - : 1); - // draw only the slab thickness handles for the active viewport as active - drawHandlesSvg( - svgDrawingHelper, - annotationUID, - lineUID, - slabThicknessHandles, - { - color, - handleRadius, - fill: color, - type: 'rect', - } - ); - } - const slabThicknessValue = otherViewport.getSlabThickness(); - if (slabThicknessValue > 0.5 && viewportSlabThicknessControlsOn) { - // draw slab thickness reference lines - lineUID = `${lineIndex}STOne`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[5], - line[6], - { - color, - width: 1, - lineDash: [2, 3], - } - ); - - lineUID = `${lineIndex}STTwo`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[7], - line[8], - { - color, - width: line, - lineDash: [2, 3], - } - ); } } }); renderStatus = true; - // Save new handles points in annotation - data.handles.rotationPoints = newRtpoints; - data.handles.slabThicknessPoints = newStpoints; - if (this.configuration.viewportIndicators) { const { viewportIndicatorsConfig } = this.configuration; @@ -2041,7 +1780,7 @@ class VolumeCroppingTool extends AnnotationTool { const { renderingEngine, viewport } = enabledElement; const annotations = this._getAnnotations( enabledElement - ) as CrosshairsAnnotation[]; + ) as VolumeCroppingAnnotation[]; const filteredToolAnnotations = this.filterInteractableAnnotationsForElement(element, annotations); @@ -2089,306 +1828,9 @@ class VolumeCroppingTool extends AnnotationTool { viewportsAnnotationsToUpdate, delta ); - } else if (handles.activeOperation === OPERATION.ROTATE) { - // ROTATION - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const otherViewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - - return ( - otherViewportControllable === true && - otherViewportDraggableRotatable === true - ); - } - ); - - const dir1 = vec2.create(); - const dir2 = vec2.create(); - - const center: Types.Point3 = [ - this.toolCenter[0], - this.toolCenter[1], - this.toolCenter[2], - ]; - - const centerCanvas = viewport.worldToCanvas(center); - - const finalPointCanvas = eventDetail.currentPoints.canvas; - const originalPointCanvas = vec2.create(); - vec2.sub( - originalPointCanvas, - finalPointCanvas, - eventDetail.deltaPoints.canvas - ); - vec2.sub(dir1, originalPointCanvas, centerCanvas); - vec2.sub(dir2, finalPointCanvas, centerCanvas); - - let angle = vec2.angle(dir1, dir2); - - if ( - this._isClockWise(centerCanvas, originalPointCanvas, finalPointCanvas) - ) { - angle *= -1; - } - - // Rounding the angle to allow rotated handles to be undone - // If we don't round and rotate handles clockwise by 0.0131233 radians, - // there's no assurance that the counter-clockwise rotation occurs at - // precisely -0.0131233, resulting in the drawn annotations being lost. - angle = Math.round(angle * 100) / 100; - - const rotationAxis = viewport.getCamera().viewPlaneNormal; - // @ts-ignore : vtkjs incorrect typing - const { matrix } = vtkMatrixBuilder - .buildFromRadian() - .translate(center[0], center[1], center[2]) - // @ts-ignore - .rotate(angle, rotationAxis) //todo: why we are passing - .translate(-center[0], -center[1], -center[2]); - - const otherViewportsIds = []; - // update camera for the other viewports. - // NOTE: The lines then are rendered by the onCameraModified - viewportsAnnotationsToUpdate.forEach((annotation) => { - const { data } = annotation; - data.handles.toolCenter = center; - - const otherViewport = renderingEngine.getViewport(data.viewportId); - const camera = otherViewport.getCamera(); - const { viewUp, position, focalPoint } = camera; - - viewUp[0] += position[0]; - viewUp[1] += position[1]; - viewUp[2] += position[2]; - - vec3.transformMat4(focalPoint, focalPoint, matrix); - vec3.transformMat4(position, position, matrix); - vec3.transformMat4(viewUp, viewUp, matrix); - - viewUp[0] -= position[0]; - viewUp[1] -= position[1]; - viewUp[2] -= position[2]; - - otherViewport.setCamera({ - position, - viewUp, - focalPoint, - }); - otherViewportsIds.push(otherViewport.id); - }); - renderingEngine.renderViewports(otherViewportsIds); - } else if (handles.activeOperation === OPERATION.SLAB) { - // SLAB THICKNESS - // this should be just the active one under the mouse, - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const referenceAnnotations = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const otherViewportSlabThicknessControlsOn = - this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); - - return ( - otherViewportControllable === true && - otherViewportSlabThicknessControlsOn === true && - viewportAnnotation.data.activeViewportIds.find( - (id) => id === otherViewport.id - ) - ); - } - ); - - if (referenceAnnotations.length === 0) { - return; - } - const viewportsAnnotationsToUpdate = - this._filterViewportWithSameOrientation( - enabledElement, - referenceAnnotations[0], - annotations - ); - - const viewportsIds = []; - viewportsIds.push(viewport.id); - viewportsAnnotationsToUpdate.forEach( - (annotation: CrosshairsAnnotation) => { - const { data } = annotation; - - const otherViewport = renderingEngine.getViewport( - data.viewportId - ) as Types.IVolumeViewport; - const camera = otherViewport.getCamera(); - const normal = camera.viewPlaneNormal; - - 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 mod = Math.sqrt( - projectedDelta[0] * projectedDelta[0] + - projectedDelta[1] * projectedDelta[1] + - projectedDelta[2] * projectedDelta[2] - ); - - const currentPoint = eventDetail.lastPoints.world; - const direction: Types.Point3 = [0, 0, 0]; - - const currentCenter: Types.Point3 = [ - this.toolCenter[0], - this.toolCenter[1], - this.toolCenter[2], - ]; - - // use this.toolCenter only if viewportDraggableRotatable - const viewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - if (!viewportDraggableRotatable) { - const { rotationPoints } = this.editData.annotation.data.handles; - // Todo: what is a point uid? - // @ts-expect-error - const otherViewportRotationPoints = rotationPoints.filter( - (point) => point[1].uid === otherViewport.id - ); - if (otherViewportRotationPoints.length === 2) { - const point1 = viewport.canvasToWorld( - otherViewportRotationPoints[0][3] - ); - const point2 = viewport.canvasToWorld( - otherViewportRotationPoints[1][3] - ); - vtkMath.add(point1, point2, currentCenter); - vtkMath.multiplyScalar(currentCenter, 0.5); - } - } - - vtkMath.subtract(currentPoint, currentCenter, direction); - const dotProdDirection = vtkMath.dot(direction, normal); - const projectedDirection: Types.Point3 = [...normal]; - vtkMath.multiplyScalar(projectedDirection, dotProdDirection); - const normalizedProjectedDirection: Types.Point3 = [ - projectedDirection[0], - projectedDirection[1], - projectedDirection[2], - ]; - vec3.normalize( - normalizedProjectedDirection, - normalizedProjectedDirection - ); - const normalizedProjectedDelta: Types.Point3 = [ - projectedDelta[0], - projectedDelta[1], - projectedDelta[2], - ]; - vec3.normalize(normalizedProjectedDelta, normalizedProjectedDelta); - - let slabThicknessValue = otherViewport.getSlabThickness(); - if ( - csUtils.isOpposite( - normalizedProjectedDirection, - normalizedProjectedDelta, - 1e-3 - ) - ) { - slabThicknessValue -= mod; - } else { - slabThicknessValue += mod; - } - - slabThicknessValue = Math.abs(slabThicknessValue); - slabThicknessValue = Math.max( - RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS, - slabThicknessValue - ); - - const near = this._pointNearReferenceLine( - viewportAnnotation, - canvasCoords, - 6, - otherViewport - ); - - if (near) { - slabThicknessValue = RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS; - } - - // We want to set the slabThickness for the viewport's actors but - // since the crosshairs tool instance has configuration regarding which - // actorUIDs (in case of volume -> actorUID = volumeIds) to set the - // slabThickness for, we need to delegate the slabThickness setting - // to the crosshairs tool instance of the toolGroup since configurations - // exist on the toolInstance and each toolGroup has its own crosshairs - // tool instance (Otherwise, we would need to set this filterActorUIDsToSetSlabThickness at - // the viewport level which makes tool and viewport state convoluted). - const toolGroup = getToolGroupForViewport( - otherViewport.id, - renderingEngine.id - ); - const crosshairsInstance = toolGroup.getToolInstance( - this.getToolName() - ); - crosshairsInstance.setSlabThickness( - otherViewport, - slabThicknessValue - ); - - viewportsIds.push(otherViewport.id); - } - } - ); - renderingEngine.renderViewports(viewportsIds); } }; - setSlabThickness(viewport, slabThickness) { - let actorUIDs; - const { filterActorUIDsToSetSlabThickness } = this.configuration; - if ( - filterActorUIDsToSetSlabThickness && - filterActorUIDsToSetSlabThickness.length > 0 - ) { - actorUIDs = filterActorUIDsToSetSlabThickness; - } - - let blendModeToUse = this.configuration.slabThicknessBlendMode; - if (slabThickness === RENDERING_DEFAULTS.MINIMUM_SLAB_THICKNESS) { - blendModeToUse = Enums.BlendModes.COMPOSITE; - } - - const immediate = false; - viewport.setBlendMode(blendModeToUse, actorUIDs, immediate); - viewport.setSlabThickness(slabThickness, actorUIDs); - } - - _isClockWise(a, b, c) { - // return true if the rotation is clockwise - return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) > 0; - } - _applyDeltaShiftToSelectedViewportCameras( renderingEngine, viewportsAnnotationsToUpdate, @@ -2548,208 +1990,48 @@ class VolumeCroppingTool extends AnnotationTool { return null; } - _getSlabThicknessHandleNearImagePoint( - viewport, - annotation, - canvasCoords, - proximity - ) { - const { data } = annotation; - const { slabThicknessPoints } = data.handles; - - for (let i = 0; i < slabThicknessPoints.length; i++) { - const point = slabThicknessPoints[i][0]; - const otherViewport = slabThicknessPoints[i][1]; - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - if (!viewportControllable) { - continue; - } - - const viewportSlabThicknessControlsOn = - this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); - if (!viewportSlabThicknessControlsOn) { - continue; - } - - const annotationCanvasCoordinate = viewport.worldToCanvas(point); - if (vec2.distance(canvasCoords, annotationCanvasCoordinate) < proximity) { - data.handles.activeOperation = OPERATION.SLAB; - - data.activeViewportIds = [otherViewport.id]; - - this.editData = { - annotation, - }; - - return point; - } - } - - return null; - } - _pointNearTool(element, annotation, canvasCoords, proximity) { - const enabledElement = getEnabledElement(element); + /* const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; const { clientWidth, clientHeight } = viewport.canvas; const canvasDiagonalLength = Math.sqrt( clientWidth * clientWidth + clientHeight * clientHeight ); + */ const { data } = annotation; - const { rotationPoints } = data.handles; - const { slabThicknessPoints } = data.handles; - const viewportIdArray = []; + // 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; - for (let i = 0; i < rotationPoints.length - 1; ++i) { - const otherViewport = rotationPoints[i][1]; - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const viewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - - if (!viewportControllable || !viewportDraggableRotatable) { - continue; - } - - const lineSegment1 = { - start: { - x: rotationPoints[i][2][0], - y: rotationPoints[i][2][1], - }, - end: { - x: rotationPoints[i][3][0], - y: rotationPoints[i][3][1], - }, - }; - - const distanceToPoint1 = lineSegment.distanceToPoint( - [lineSegment1.start.x, lineSegment1.start.y], - [lineSegment1.end.x, lineSegment1.end.y], - [canvasCoords[0], canvasCoords[1]] - ); - - const lineSegment2 = { - start: { - x: rotationPoints[i + 1][2][0], - y: rotationPoints[i + 1][2][1], - }, - end: { - x: rotationPoints[i + 1][3][0], - y: rotationPoints[i + 1][3][1], - }, - }; - - const distanceToPoint2 = lineSegment.distanceToPoint( - [lineSegment2.start.x, lineSegment2.start.y], - [lineSegment2.end.x, lineSegment2.end.y], - [canvasCoords[0], canvasCoords[1]] - ); - - if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { - viewportIdArray.push(otherViewport.id); - data.handles.activeOperation = OPERATION.DRAG; - } - - // rotation handles are two for viewport - i++; - } - - for (let i = 0; i < slabThicknessPoints.length - 1; ++i) { - const otherViewport = slabThicknessPoints[i][1]; - if (viewportIdArray.find((id) => id === otherViewport.id)) { - continue; - } - - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const viewportSlabThicknessControlsOn = - this._getReferenceLineSlabThicknessControlsOn(otherViewport.id); - - if (!viewportControllable || !viewportSlabThicknessControlsOn) { - continue; - } - - const stPointLineCanvas1 = slabThicknessPoints[i][2]; - const stPointLineCanvas2 = slabThicknessPoints[i][3]; - - const centerCanvas = vec2.create(); - vec2.add(centerCanvas, stPointLineCanvas1, stPointLineCanvas2); - vec2.scale(centerCanvas, centerCanvas, 0.5); - - const canvasUnitVectorFromCenter = vec2.create(); - vec2.subtract( - canvasUnitVectorFromCenter, - stPointLineCanvas1, - centerCanvas - ); - vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); - - const canvasVectorFromCenterStart = vec2.create(); - vec2.scale( - canvasVectorFromCenterStart, - canvasUnitVectorFromCenter, - canvasDiagonalLength * 0.05 - ); - - const stPointLineCanvas1Start = vec2.create(); - const stPointLineCanvas2Start = vec2.create(); - vec2.add( - stPointLineCanvas1Start, - centerCanvas, - canvasVectorFromCenterStart - ); - vec2.subtract( - stPointLineCanvas2Start, - centerCanvas, - canvasVectorFromCenterStart - ); - - const lineSegment1 = { - start: { - x: stPointLineCanvas1Start[0], - y: stPointLineCanvas1Start[1], - }, - end: { - x: stPointLineCanvas1[0], - y: stPointLineCanvas1[1], - }, - }; - - const distanceToPoint1 = lineSegment.distanceToPoint( - [lineSegment1.start.x, lineSegment1.start.y], - [lineSegment1.end.x, lineSegment1.end.y], - [canvasCoords[0], canvasCoords[1]] - ); - - const lineSegment2 = { - start: { - x: stPointLineCanvas2Start[0], - y: stPointLineCanvas2Start[1], - }, - end: { - x: stPointLineCanvas2[0], - y: stPointLineCanvas2[1], - }, - }; - - const distanceToPoint2 = lineSegment.distanceToPoint( - [lineSegment2.start.x, lineSegment2.start.y], - [lineSegment2.end.x, lineSegment2.end.y], - [canvasCoords[0], canvasCoords[1]] - ); + const viewportIdArray = []; - if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { - viewportIdArray.push(otherViewport.id); // we still need this to draw inactive slab thickness handles - data.handles.activeOperation = null; // no operation + if (referenceLines) { + for (let i = 0; i < referenceLines.length; ++i) { + // Each line: [otherViewport, refLinePointOne, refLinePointTwo, refLinePointThree, refLinePointFour, ...] + const otherViewport = referenceLines[i][0]; + // First segment + const start1 = referenceLines[i][1]; + const end1 = referenceLines[i][2]; + // Second segment + const start2 = referenceLines[i][3]; + const end2 = referenceLines[i][4]; + + const distance1 = lineSegment.distanceToPoint(start1, end1, [ + canvasCoords[0], + canvasCoords[1], + ]); + const distance2 = lineSegment.distanceToPoint(start2, end2, [ + canvasCoords[0], + canvasCoords[1], + ]); + + if (distance1 <= proximity || distance2 <= proximity) { + viewportIdArray.push(otherViewport.id); + data.handles.activeOperation = 1; // DRAG + } } - - // slab thickness handles are in couples - i++; } data.activeViewportIds = [...viewportIdArray]; @@ -2757,10 +2039,10 @@ class VolumeCroppingTool extends AnnotationTool { this.editData = { annotation, }; - - return data.handles.activeOperation === OPERATION.DRAG ? true : false; + //console.debug(data.handles.activeOperation); + return data.handles.activeOperation === 1 ? true : false; } } -VolumeCroppingTool.toolName = 'Crosshairs'; +VolumeCroppingTool.toolName = 'VolumeCroppingTool'; export default VolumeCroppingTool; From 9338ca198ff5db1bc5412618b6be4f59ef9c38aa Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:02:54 +0200 Subject: [PATCH 008/132] feat(volumeCropping): Add axis parameter to addSphere method and update sphere color based on axis --- .../tools/src/tools/VolumeCroppingTool.ts | 168 ++++++------------ 1 file changed, 54 insertions(+), 114 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index ef2992a654..2f3ea242d9 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -151,6 +151,7 @@ class VolumeCroppingTool extends AnnotationTool { opacity: 0.8, handleRadius: 9, }, + initialCropFactor: 0.2, }, } ) { @@ -336,7 +337,7 @@ class VolumeCroppingTool extends AnnotationTool { this._computeToolCenter(viewportsInfo); }; - addSphere(viewport, point) { + addSphere(viewport, point, axis) { // if (!sphereActor) { // Generate a random string for the sphere UID const randomUID = 'sphere_' + Math.random().toString(36).substring(2, 15); @@ -348,12 +349,20 @@ class VolumeCroppingTool extends AnnotationTool { sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); sphereActor.setMapper(sphereMapper); - sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + + let color = [1.0, 1.0, 0.0]; // Yellow + if (axis === 'z') { + color = [1.0, 0.0, 0.0]; + } else if (axis === 'x') { + color = [0.0, 1.0, 0.0]; + } + color = [0.0, 0.0, 1.0]; + sphereActor.getProperty().setColor(...color); + //sphereActor.getProperty().setColor(0.0, 0.0, 1.0); viewport.addActor({ actor: sphereActor, uid: randomUID }); - // } else { - // sphereActor.getMapper().getInputConnection().filter.setCenter(point); - // } - // console.debug('actors:', viewport.getActors()); + + console.debug('sphere:', point); + viewport.render(); } @@ -412,20 +421,21 @@ class VolumeCroppingTool extends AnnotationTool { const volumeActors = viewport.getActors(); const imageData = volumeActors[0].actor.getMapper().getInputData(); const dimensions = imageData.getDimensions(); + console.debug('dimensions', dimensions); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] const worldDimensions = [ Math.round(dimensions[0] * spacing[0]), Math.round(dimensions[1] * spacing[1]), Math.round(dimensions[2] * spacing[2]), ]; + //const worldDimensions = dimensions; console.debug('worldDimensions', worldDimensions); - //const xMin = 1500 * -0.5; const xMin = worldDimensions[0] * -0.5; const xMax = worldDimensions[0] * 0.5; const yMin = worldDimensions[1] * -0.5; const yMax = worldDimensions[1] * 0.5; const zMin = worldDimensions[2] * -0.5; - const zMax = 0; + const zMax = worldDimensions[2]; const planes: vtkPlane[] = []; const cropFactor = 0.2; // X min plane (cuts everything left of xMin) @@ -446,11 +456,11 @@ class VolumeCroppingTool extends AnnotationTool { normal: [0, -1, 0], }); const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, zMin + worldDimensions[2] * cropFactor], + origin: [0, 0, -zMax], normal: [0, 0, 1], }); const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, zMax - worldDimensions[2] * cropFactor], + origin: [0, 0, zMax], normal: [0, 0, -1], }); @@ -460,8 +470,8 @@ class VolumeCroppingTool extends AnnotationTool { planes.push(planeXmax); planes.push(planeYmin); planes.push(planeYmax); - // planes.push(planeZmin); - // planes.push(planeZmax); + planes.push(planeZmin); + planes.push(planeZmax); const originalPlanes = planes.map((plane) => ({ origin: [...plane.getOrigin()], normal: [...plane.getNormal()], @@ -470,32 +480,38 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlanes(originalPlanes); // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); - this.addSphere(viewport, [ - xMin + worldDimensions[0] * cropFactor, - (yMax + yMin) / 2, - (zMax + zMin) / 4, - ]); - this.addSphere(viewport, [ - xMax - worldDimensions[0] * cropFactor, - (yMax + yMin) / 2, - (zMax + zMin) / 2, - ]); - this.addSphere(viewport, [ - (xMax + xMin) / 2, - yMin + worldDimensions[0] * cropFactor, - (zMax + zMin) / 2, - ]); - - this.addSphere(viewport, [ - (xMax + xMin) / 2, - yMax - worldDimensions[0] * cropFactor, - (zMax + zMin) / 2, - ]); - this.addSphere(viewport, [ - (xMax + xMin) / 2, - (yMax + yMin) / 2, - zMin - worldDimensions[0] * cropFactor, - ]); + this.addSphere( + viewport, + [xMin + worldDimensions[0] * cropFactor, (yMax + yMin) / 2, -220], + 'x' + ); + this.addSphere( + viewport, + [xMax - worldDimensions[0] * cropFactor, (yMax + yMin) / 2, -220], + 'x' + ); + this.addSphere( + viewport, + [(xMax + xMin) / 2, yMin + worldDimensions[0] * cropFactor, -220], + 'y' + ); + + this.addSphere( + viewport, + [(xMax + xMin) / 2, yMax - worldDimensions[0] * cropFactor, -220], + 'y' + ); + + this.addSphere( + viewport, + [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax], + 'z' + ); + this.addSphere( + viewport, + [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4], + 'z' + ); // this.addSphere(viewport, [0, 0, 0]); // this.addSphere(viewport, [xMin, yMin, zMin]); @@ -594,42 +610,6 @@ class VolumeCroppingTool extends AnnotationTool { console.log('Not implemented yet'); }; - /** - * It checks if the mouse click is near crosshairs handles, if yes - * it returns the handle location. If the mouse click is not near any - * of the handles, it does not return anything. - * - * @param element - The element that the tool is attached to. - * @param annotation - The annotation object associated with the annotation - * @param canvasCoords - The coordinates of the mouse click on canvas - * @param proximity - The distance from the mouse cursor to the point - * that is considered "near". - * @returns The handle that is closest to the cursor, or null if the cursor - * is not near any of the handles. - */ - /* - getHandleNearImagePoint( - element: HTMLDivElement, - annotation: Annotation, - canvasCoords: Types.Point2, - proximity: number - ): ToolHandle | undefined { - const enabledElement = getEnabledElement(element); - const { viewport } = enabledElement; - - const point = this._getRotationHandleNearImagePoint( - viewport, - annotation, - canvasCoords, - proximity - ); - - if (point !== null) { - return point; - } - } - */ - handleSelectedCallback = ( evt: EventTypes.InteractionEventType, annotation: Annotation @@ -1950,46 +1930,6 @@ class VolumeCroppingTool extends AnnotationTool { return false; }; - _getRotationHandleNearImagePoint( - viewport, - annotation, - canvasCoords, - proximity - ) { - const { data } = annotation; - const { rotationPoints } = data.handles; - - for (let i = 0; i < rotationPoints.length; i++) { - const point = rotationPoints[i][0]; - const otherViewport = rotationPoints[i][1]; - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - if (!viewportControllable) { - continue; - } - - const viewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - if (!viewportDraggableRotatable) { - continue; - } - - const annotationCanvasCoordinate = viewport.worldToCanvas(point); - if (vec2.distance(canvasCoords, annotationCanvasCoordinate) < proximity) { - data.handles.activeOperation = OPERATION.ROTATE; - - this.editData = { - annotation, - }; - - return point; - } - } - - return null; - } - _pointNearTool(element, annotation, canvasCoords, proximity) { /* const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; From b5f4021a6361fb4d25ccd07c35583b7d20908631 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 17 Jun 2025 08:04:58 +0200 Subject: [PATCH 009/132] feat(volumeCropping): Enhance sphere management with dragging functionality and update clipping plane references --- .../tools/src/tools/VolumeCroppingTool.ts | 333 ++++++++---------- 1 file changed, 155 insertions(+), 178 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 2f3ea242d9..95d56a43ed 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -75,6 +75,7 @@ interface VolumeCroppingAnnotation extends Annotation { viewportId: string; // referenceLines: []; // set in renderAnnotation clippingPlanes?: vtkPlane[]; // clipping planes for the viewport + clippingPlaneReferenceLines?: []; }; } @@ -96,8 +97,6 @@ const OPERATION = { SLAB: 3, }; -const EPSILON = 1e-3; - /** * VolumeCroppingTool is a tool that provides reference lines between different viewports * of a toolGroup. Using crosshairs, you can jump to a specific location in one @@ -106,7 +105,8 @@ const EPSILON = 1e-3; */ class VolumeCroppingTool extends AnnotationTool { static toolName; - + sphereStates: { point: Types.Point3; axis: string; uid: string }[] = []; + draggingSphereIndex: number | null = null; toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; @@ -163,12 +163,6 @@ class VolumeCroppingTool extends AnnotationTool { this._getReferenceLineControllable = toolProps.configuration?.getReferenceLineControllable || defaultReferenceLineControllable; - this._getReferenceLineDraggableRotatable = - toolProps.configuration?.getReferenceLineDraggableRotatable || - defaultReferenceLineDraggableRotatable; - // this._getReferenceLineSlabThicknessControlsOn = - // toolProps.configuration?.getReferenceLineSlabThicknessControlsOn || - // defaultReferenceLineSlabThicknessControlsOn; } /** @@ -226,6 +220,8 @@ class VolumeCroppingTool extends AnnotationTool { activeViewportIds: [], // a list of the viewport ids connected to the reference lines being translated viewportId, referenceLines: [], // set in renderAnnotation + clippingPlanes: [], // clipping planes for the viewport + clippingPlaneReferenceLines: [], // clipping planes for the viewport }, }; @@ -338,13 +334,36 @@ class VolumeCroppingTool extends AnnotationTool { }; addSphere(viewport, point, axis) { - // if (!sphereActor) { - // Generate a random string for the sphere UID - const randomUID = 'sphere_' + Math.random().toString(36).substring(2, 15); - + // Generate a unique UID for each sphere based on its axis and position + const uid = `${axis}_${point.map((v) => Math.round(v)).join('_')}`; + const sphereState = this.sphereStates.find((s) => s.uid === uid); + // if (!sphereState) { const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); sphereSource.setRadius(15); + + // 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, + }); + } 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) { + //return; + // viewport.removeActor(uid); + } +*/ const sphereMapper = vtkMapper.newInstance(); sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); @@ -357,13 +376,21 @@ class VolumeCroppingTool extends AnnotationTool { color = [0.0, 1.0, 0.0]; } color = [0.0, 0.0, 1.0]; - sphereActor.getProperty().setColor(...color); - //sphereActor.getProperty().setColor(0.0, 0.0, 1.0); - viewport.addActor({ actor: sphereActor, uid: randomUID }); - - console.debug('sphere:', point); - + // sphereActor.getProperty().setColor(...color); + sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + viewport.addActor({ actor: sphereActor, uid: uid }); + console.debug('added sphere: ', uid, viewport.getActors()); viewport.render(); + /* } else { + // Only update the position and source, do not create new actor/source + sphereState.point = point.slice(); + if (sphereState.sphereSource) { + // sphereState.sphereSource.setCenter(point); + console.debug('updated sphere: ', uid); + viewport.render(); + } + } + */ } computeToolCenter = () => { @@ -513,12 +540,11 @@ class VolumeCroppingTool extends AnnotationTool { 'z' ); - // this.addSphere(viewport, [0, 0, 0]); - // this.addSphere(viewport, [xMin, yMin, zMin]); - // this.addSphere(viewport, [xMax, yMax, zMax]); - //this.addSphere(viewport, [0, 0, 0]); + const element = viewport.canvas || viewport.element; + element.addEventListener('mousedown', this._onMouseDownSphere); + element.addEventListener('mousemove', this._onMouseMoveSphere); + element.addEventListener('mouseup', this._onMouseUpSphere); viewport.render(); - const toolCenter = csUtils.planar.threePlaneIntersection( firstPlane, secondPlane, @@ -527,6 +553,64 @@ class VolumeCroppingTool extends AnnotationTool { this.setToolCenter(toolCenter); }; + _onMouseDownSphere = (evt) => { + const element = evt.currentTarget; + const viewportsInfo = this._getViewportsInfo(); + const [firstViewport, secondViewport, thirdViewport, viewport3D] = + viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const mouseCanvas = [evt.offsetX, evt.offsetY]; + // Find the sphere under the mouse + 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 < 20) { + // 20 pixels threshold + this.draggingSphereIndex = i; + element.style.cursor = 'grabbing'; + console.debug('grabbing', i); + return; + } + } + this.draggingSphereIndex = null; + }; + + _onMouseMoveSphere = (evt) => { + if (this.draggingSphereIndex === null) { + return; + } + + const element = evt.currentTarget; + const viewportsInfo = this._getViewportsInfo(); + const [firstViewport, secondViewport, thirdViewport, viewport3D] = + viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const mouseCanvas = [evt.offsetX, evt.offsetY]; + + this.sphereStates[this.draggingSphereIndex].point = + viewport.canvasToWorld(mouseCanvas); + + // Update the sphere position in state + const sphereState = this.sphereStates[this.draggingSphereIndex]; + + sphereState.point = viewport.canvasToWorld(mouseCanvas); + sphereState.sphereSource.setCenter(sphereState.point); + //console.debug('3D mouse move'); + viewport.render(); + }; + + _onMouseUpSphere = (evt) => { + if (this.draggingSphereIndex !== null) { + evt.currentTarget.style.cursor = ''; + } + this.draggingSphereIndex = null; + }; + setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { // prettier-ignore this.toolCenter = toolCenter; @@ -552,7 +636,7 @@ class VolumeCroppingTool extends AnnotationTool { * @param interactionType - The type of interaction (e.g., mouse, touch, etc.) * @returns Crosshairs annotation */ - /* + addNewAnnotation = ( evt: EventTypes.InteractionEventType ): VolumeCroppingAnnotation => { @@ -575,24 +659,23 @@ class VolumeCroppingTool extends AnnotationTool { // viewport Annotation const { data } = filteredAnnotations[0]; - const { rotationPoints } = data.handles; const viewportIdArray = []; // put all the draggable reference lines in the viewportIdArray + /* for (let i = 0; i < rotationPoints.length - 1; ++i) { const otherViewport = rotationPoints[i][1]; const viewportControllable = this._getReferenceLineControllable( otherViewport.id ); - const viewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - if (!viewportControllable || !viewportDraggableRotatable) { + + if (!viewportControllable) { continue; } viewportIdArray.push(otherViewport.id); // rotation handles are two per viewport i++; } - +*/ data.activeViewportIds = [...viewportIdArray]; // set translation operation data.handles.activeOperation = OPERATION.DRAG; @@ -603,33 +686,12 @@ class VolumeCroppingTool extends AnnotationTool { this._activateModify(element); return filteredAnnotations[0]; - }; - */ + cancel = () => { console.log('Not implemented yet'); }; - handleSelectedCallback = ( - evt: EventTypes.InteractionEventType, - annotation: Annotation - ): void => { - const eventDetail = evt.detail; - const { element } = eventDetail; - annotation.highlighted = true; - - // NOTE: handle index or coordinates are not used when dragging. - // This because the handle points are actually generated in the renderTool and they are a derivative - // from the camera variables of the viewports and of the slab thickness variable. - // Remember that the translation and rotation operations operate on the camera - // variables and not really on the handles. Similar for the slab thickness. - this._activateModify(element); - - hideElementCursor(element); - - evt.preventDefault(); - }; - /** * It returns if the canvas point is near the provided crosshairs annotation in the * provided element or not. A proximity is passed to the function to determine the @@ -720,13 +782,10 @@ class VolumeCroppingTool extends AnnotationTool { const viewportControllable = this._getReferenceLineControllable( viewport.id ); - const viewportDraggableRotatable = this._getReferenceLineDraggableRotatable( - viewport.id - ); + if ( !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && - viewportControllable && - viewportDraggableRotatable + viewportControllable ) { // Is camera Modified a TRANSLATION or ROTATION? let isRotation = false; @@ -936,8 +995,6 @@ class VolumeCroppingTool extends AnnotationTool { const otherViewportControllable = this._getReferenceLineControllable( otherViewport.id ); - const otherViewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); // get coordinates for the reference line const { clientWidth, clientHeight } = otherViewport.canvas; @@ -1050,7 +1107,7 @@ class VolumeCroppingTool extends AnnotationTool { const refLinePointFour = vec2.create(); let refLinesCenter = vec2.clone(crosshairCenterCanvas); - if (!otherViewportDraggableRotatable || !otherViewportControllable) { + if (!otherViewportControllable) { refLinesCenter = vec2.clone(otherViewportCenterCanvas); } @@ -1091,9 +1148,6 @@ class VolumeCroppingTool extends AnnotationTool { const viewportControllable = this._getReferenceLineControllable( otherViewport.id ); - const viewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id) || - this.configuration.mobile?.enabled; const selectedViewportId = data.activeViewportIds.find( (id) => id === otherViewport.id ); @@ -1113,7 +1167,7 @@ class VolumeCroppingTool extends AnnotationTool { } let lineUID = `${lineIndex}`; - if (viewportControllable && viewportDraggableRotatable) { + if (viewportControllable) { lineUID = `${lineIndex}One`; drawLineSvg( svgDrawingHelper, @@ -1164,15 +1218,8 @@ class VolumeCroppingTool extends AnnotationTool { handleRadius = this.configuration.mobile.handleRadius; opacity = this.configuration.mobile.opacity; } - if (lineActive && viewportDraggableRotatable) { - const handleUID = `${lineIndex}`; - } else if (viewportDraggableRotatable) { + if (lineActive) { const handleUID = `${lineIndex}`; - const handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles - ? window.devicePixelRatio - : 1); } } }); @@ -1723,7 +1770,6 @@ class VolumeCroppingTool extends AnnotationTool { _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; - this.editData.annotation.data.handles.activeOperation = null; this.editData.annotation.data.activeViewportIds = []; @@ -1775,40 +1821,45 @@ class VolumeCroppingTool extends AnnotationTool { const canvasCoords = currentPoints.canvas; if (handles.activeOperation === OPERATION.DRAG) { - // TRANSLATION - // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const otherViewportDraggableRotatable = - this._getReferenceLineDraggableRotatable(otherViewport.id); - - return ( - otherViewportControllable === true && - otherViewportDraggableRotatable === true && - viewportAnnotation.data.activeViewportIds.find( - (id) => id === otherViewport.id - ) - ); - } + this.toolCenter[0] += delta[0]; + this.toolCenter[1] += delta[1]; + this.toolCenter[2] += delta[2]; + const viewportsInfo = this._getViewportsInfo(); + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) ); + } - this._applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta + // TRANSLATION + // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations ); - } + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return ( + otherViewportControllable === true && + viewportAnnotation.data.activeViewportIds.find( + (id) => id === otherViewport.id + ) + ); + } + ); + + this._applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ); }; _applyDeltaShiftToSelectedViewportCameras( @@ -1863,81 +1914,7 @@ class VolumeCroppingTool extends AnnotationTool { } } - _pointNearReferenceLine = ( - annotation, - canvasCoords, - proximity, - lineViewport - ) => { - const { data } = annotation; - const { rotationPoints } = data.handles; - - for (let i = 0; i < rotationPoints.length - 1; ++i) { - const otherViewport = rotationPoints[i][1]; - if (otherViewport.id !== lineViewport.id) { - continue; - } - - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - if (!viewportControllable) { - continue; - } - - const lineSegment1 = { - start: { - x: rotationPoints[i][2][0], - y: rotationPoints[i][2][1], - }, - end: { - x: rotationPoints[i][3][0], - y: rotationPoints[i][3][1], - }, - }; - - const distanceToPoint1 = lineSegment.distanceToPoint( - [lineSegment1.start.x, lineSegment1.start.y], - [lineSegment1.end.x, lineSegment1.end.y], - [canvasCoords[0], canvasCoords[1]] - ); - - const lineSegment2 = { - start: { - x: rotationPoints[i + 1][2][0], - y: rotationPoints[i + 1][2][1], - }, - end: { - x: rotationPoints[i + 1][3][0], - y: rotationPoints[i + 1][3][1], - }, - }; - - const distanceToPoint2 = lineSegment.distanceToPoint( - [lineSegment2.start.x, lineSegment2.start.y], - [lineSegment2.end.x, lineSegment2.end.y], - [canvasCoords[0], canvasCoords[1]] - ); - - if (distanceToPoint1 <= proximity || distanceToPoint2 <= proximity) { - return true; - } - - // rotation handles are two for viewport - i++; - } - - return false; - }; - _pointNearTool(element, annotation, canvasCoords, proximity) { - /* const enabledElement = getEnabledElement(element); - const { viewport } = enabledElement; - const { clientWidth, clientHeight } = viewport.canvas; - const canvasDiagonalLength = Math.sqrt( - clientWidth * clientWidth + clientHeight * clientHeight - ); - */ const { data } = annotation; // You must have referenceLines available in annotation.data. From 4b719f7e07dc9498a072bb5d6a8ad2879abd8a79 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:11:46 +0200 Subject: [PATCH 010/132] feat(volumeCropping): Refactor VolumeCroppingTool by removing unused functions and enhancing sphere state management --- .../examples/volumeCroppingTool/index.ts | 27 +- .../tools/src/tools/VolumeCroppingTool.ts | 342 +++++++++++------- 2 files changed, 223 insertions(+), 146 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 042d12e326..940bbd8420 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -249,17 +249,6 @@ function getReferenceLineControllable(viewportId) { return index !== -1; } -function getReferenceLineDraggableRotatable(viewportId) { - const index = viewportReferenceLineDraggableRotatable.indexOf(viewportId); - return index !== -1; -} - -function getReferenceLineSlabThicknessControlsOn(viewportId) { - const index = - viewportReferenceLineSlabThicknessControlsOn.indexOf(viewportId); - return index !== -1; -} - function setUpSynchronizers() { synchronizer = createSlabThicknessSynchronizer(synchronizerId); @@ -390,6 +379,7 @@ async function run() { const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); addManipulationBindings(toolGroup); toolGroup.addTool(TrackballRotateTool.toolName); + /* toolGroup.setToolActive(TrackballRotateTool.toolName, { bindings: [ { @@ -397,9 +387,8 @@ async function run() { }, ], }); +*/ - // For the crosshairs to operate, the viewports must currently be - // added ahead of setting the tool active. This will be improved in the future. toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); @@ -416,8 +405,6 @@ async function run() { toolGroup.addTool(VolumeCroppingTool.toolName, { getReferenceLineColor, getReferenceLineControllable, - getReferenceLineDraggableRotatable, - getReferenceLineSlabThicknessControlsOn, mobile: { enabled: isMobile, opacity: 0.8, @@ -461,7 +448,6 @@ async function run() { Math.round(dimensions[1] * spacing[1]), Math.round(dimensions[2] * spacing[2]), ]; - //const mapper = defaultActor.actor.getMapper(); const mapper = viewport.getDefaultActor().actor.getMapper(); const xMin = worldDimensions[0] * -0.5; const xMax = worldDimensions[0] * 0.5; @@ -471,15 +457,11 @@ async function run() { const zMax = 0; const planes: vtkPlane[] = []; - // X min plane (cuts everything left of xMin) const planeXmin = vtkPlane.newInstance({ origin: [xMin, 0, 0], normal: [1, 0, 0], }); - mapper.addClippingPlane(planeXmin); - //planes.push(planeXmin); - // addSphere(viewport, [xMin, 0, -(zMax - zMin) / 2]); - + planes.push(planeXmin); viewport.render(); }); @@ -488,7 +470,7 @@ async function run() { const clippingPlanes = mapper.getClippingPlanes(); console.debug('clippingPlanes before setOrigin:', clippingPlanes); clippingPlanes[planeIndex].setOrigin(origin); - // viewport.setOriginalClippingPlane(planeIndex, origin); + viewport.setOriginalClippingPlane(planeIndex, origin); viewport.render(); } @@ -552,7 +534,6 @@ eventTarget.addEventListener( * Creates the minimum infrastructure needed to pick a point in the 3D volume * with VTK.js * @remarks - * Is this the right place to put this function? * @param viewport * @returns */ diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 95d56a43ed..04d419cd71 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -87,10 +87,6 @@ function defaultReferenceLineControllable() { return true; } -function defaultReferenceLineDraggableRotatable() { - return true; -} - const OPERATION = { DRAG: 1, ROTATE: 2, @@ -105,14 +101,20 @@ const OPERATION = { */ class VolumeCroppingTool extends AnnotationTool { static toolName; - sphereStates: { point: Types.Point3; axis: string; uid: string }[] = []; + sphereStates: { + point: Types.Point3; + axis: string; + uid: string; + sphereSource; + sphereActor; + }[] = []; draggingSphereIndex: number | null = null; toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; _getReferenceLineDraggableRotatable?: (viewportId: string) => boolean; - + picker: vtkCellPicker; constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { @@ -163,6 +165,10 @@ class VolumeCroppingTool extends AnnotationTool { this._getReferenceLineControllable = toolProps.configuration?.getReferenceLineControllable || defaultReferenceLineControllable; + this.picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); + this.picker.setPickFromList(1); + this.picker.setTolerance(0); + this.picker.initializePickList(); } /** @@ -263,7 +269,7 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolEnabled() { const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); + // this._computeToolCenter(viewportsInfo); } onSetToolDisabled() { @@ -341,6 +347,10 @@ class VolumeCroppingTool extends AnnotationTool { const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); sphereSource.setRadius(15); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputConnection(sphereSource.getOutputPort()); + const sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); // Store or update the sphere position const idx = this.sphereStates.findIndex((s) => s.uid === uid); @@ -350,6 +360,7 @@ class VolumeCroppingTool extends AnnotationTool { axis, uid, sphereSource, + sphereActor, }); } else { this.sphereStates[idx].point = point.slice(); @@ -364,10 +375,6 @@ class VolumeCroppingTool extends AnnotationTool { // viewport.removeActor(uid); } */ - const sphereMapper = vtkMapper.newInstance(); - sphereMapper.setInputConnection(sphereSource.getOutputPort()); - const sphereActor = vtkActor.newInstance(); - sphereActor.setMapper(sphereMapper); let color = [1.0, 1.0, 0.0]; // Yellow if (axis === 'z') { @@ -378,6 +385,7 @@ class VolumeCroppingTool extends AnnotationTool { color = [0.0, 0.0, 1.0]; // sphereActor.getProperty().setColor(...color); sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); console.debug('added sphere: ', uid, viewport.getActors()); viewport.render(); @@ -442,9 +450,11 @@ class VolumeCroppingTool extends AnnotationTool { const secondPlane = csUtils.planar.planeEquation(normal2, point2); const thirdPlane = csUtils.planar.planeEquation(normal3, point3); + this.initializeViewport(viewport3D); // add clipping planes const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const volumeActors = viewport.getActors(); const imageData = volumeActors[0].actor.getMapper().getInputData(); const dimensions = imageData.getDimensions(); @@ -539,11 +549,17 @@ class VolumeCroppingTool extends AnnotationTool { [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4], 'z' ); + const defaultActor = viewport.getDefaultActor(); + if (defaultActor?.actor) { + // Cast to any to avoid type errors with different actor types + this.picker.addPickList(defaultActor.actor); + this._prepareImageDataForPicking(viewport); + } const element = viewport.canvas || viewport.element; - element.addEventListener('mousedown', this._onMouseDownSphere); - element.addEventListener('mousemove', this._onMouseMoveSphere); - element.addEventListener('mouseup', this._onMouseUpSphere); + // element.addEventListener('mousedown', this._onMouseDownSphere); + // element.addEventListener('mousemove', this._onMouseMoveSphere); + // element.addEventListener('mouseup', this._onMouseUpSphere); viewport.render(); const toolCenter = csUtils.planar.threePlaneIntersection( firstPlane, @@ -553,11 +569,56 @@ class VolumeCroppingTool extends AnnotationTool { this.setToolCenter(toolCenter); }; + /** + * Creates the minimum infrastructure needed to pick a point in the 3D volume + * with VTK.js + * @remarks + * @param viewport + * @returns + */ + _prepareImageDataForPicking = (viewport) => { + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + return; + } + // Get the imageData from the volumeActor + const imageData = volumeActor.getMapper().getInputData(); + + if (!imageData) { + console.error('No imageData found in the volumeActor'); + return null; + } + + // Get the voxelManager from the imageData + const { voxelManager } = imageData.get('voxelManager'); + + if (!voxelManager) { + console.error('No voxelManager found in the imageData'); + return imageData; + } + + // Create a fake scalar object to expose the scalar data to VTK.js + const fakeScalars = { + getData: () => { + return voxelManager.getCompleteScalarDataArray(); + }, + getNumberOfComponents: () => voxelManager.numberOfComponents, + getDataType: () => + voxelManager.getCompleteScalarDataArray().constructor.name, + }; + + // Set the point data to return the fakeScalars + imageData.setPointData({ + getScalars: () => fakeScalars, + }); + }; + _onMouseDownSphere = (evt) => { const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); const [firstViewport, secondViewport, thirdViewport, viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); const mouseCanvas = [evt.offsetX, evt.offsetY]; @@ -590,18 +651,37 @@ class VolumeCroppingTool extends AnnotationTool { viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const mouseCanvas = [evt.offsetX, evt.offsetY]; - this.sphereStates[this.draggingSphereIndex].point = - viewport.canvasToWorld(mouseCanvas); + // Use vtkCellPicker to get world coordinates - // Update the sphere position in state - const sphereState = this.sphereStates[this.draggingSphereIndex]; + const rect = element.getBoundingClientRect(); + const x = evt.clientX - rect.left; + const y = evt.clientY - rect.top; - sphereState.point = viewport.canvasToWorld(mouseCanvas); - sphereState.sphereSource.setCenter(sphereState.point); - //console.debug('3D mouse move'); - viewport.render(); + const displayCoords = viewport.getVtkDisplayCoords([x, y]); + // Use the picker to get the 3D coordinates + this.picker.pick( + [displayCoords[0], displayCoords[1], 0], + viewport.getRenderer() + ); + const pickedPositions = this.picker.getPickedPositions(); + if (pickedPositions.length > 0) { + const pickedPoint = pickedPositions[0]; + const sphereState = this.sphereStates[this.draggingSphereIndex]; + // Restrict movement to the sphere's axis only + const newPoint = [...sphereState.point]; + if (sphereState.axis === 'x') { + newPoint[0] = pickedPoint[0]; + } else if (sphereState.axis === 'y') { + newPoint[1] = pickedPoint[1]; + } else if (sphereState.axis === 'z') { + newPoint[2] = pickedPoint[2]; + } + + sphereState.point = newPoint; + sphereState.sphereSource.setCenter(pickedPoint); + viewport.render(); + } }; _onMouseUpSphere = (evt) => { @@ -648,20 +728,30 @@ class VolumeCroppingTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - this._jump(enabledElement, jumpWorld); + if (viewport.type === Enums.ViewportType.VOLUME_3D) { + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); - const annotations = this._getAnnotations(enabledElement); - const filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); + const { data } = filteredAnnotations[0]; + data.handles.activeOperation = OPERATION.DRAG; + return filteredAnnotations[0]; + } else { + this._jump(enabledElement, jumpWorld); - // viewport Annotation - const { data } = filteredAnnotations[0]; + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); - const viewportIdArray = []; - // put all the draggable reference lines in the viewportIdArray - /* + const { data } = filteredAnnotations[0]; + + const viewportIdArray = []; + // put all the draggable reference lines in the viewportIdArray + /* for (let i = 0; i < rotationPoints.length - 1; ++i) { const otherViewport = rotationPoints[i][1]; const viewportControllable = this._getReferenceLineControllable( @@ -676,16 +766,17 @@ class VolumeCroppingTool extends AnnotationTool { i++; } */ - data.activeViewportIds = [...viewportIdArray]; - // set translation operation - data.handles.activeOperation = OPERATION.DRAG; + data.activeViewportIds = [...viewportIdArray]; + // set translation operation + data.handles.activeOperation = OPERATION.DRAG; - evt.preventDefault(); + evt.preventDefault(); - hideElementCursor(element); + hideElementCursor(element); - this._activateModify(element); - return filteredAnnotations[0]; + this._activateModify(element); + return filteredAnnotations[0]; + } }; cancel = () => { @@ -921,7 +1012,7 @@ class VolumeCroppingTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewportId } = enabledElement; - + // console.log(annotations); const viewportUIDSpecificCrosshairs = annotations.filter( (annotation) => annotation.data.viewportId === viewportId ); @@ -1136,94 +1227,98 @@ class VolumeCroppingTool extends AnnotationTool { refLinePointFour, ]); }); - data.referenceLines = referenceLines; - const viewportColor = this._getReferenceLineColor(viewport.id); - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - - referenceLines.forEach((line, lineIndex) => { - // get color for the reference line - const otherViewport = line[0]; - const viewportColor = this._getReferenceLineColor(otherViewport.id); - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const selectedViewportId = data.activeViewportIds.find( - (id) => id === otherViewport.id - ); + if (viewport.type !== Enums.ViewportType.VOLUME_3D) { + data.referenceLines = referenceLines; + const viewportColor = this._getReferenceLineColor(viewport.id); const color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let lineWidth = 1; - - const lineActive = - data.handles.activeOperation !== null && - data.handles.activeOperation === OPERATION.DRAG && - selectedViewportId; - - if (lineActive) { - lineWidth = 2.5; - } - - let lineUID = `${lineIndex}`; - if (viewportControllable) { - lineUID = `${lineIndex}One`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[1], - line[2], - { - color, - lineWidth, - } - ); - - lineUID = `${lineIndex}Two`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[3], - line[4], - { - color, - lineWidth, - } + referenceLines.forEach((line, lineIndex) => { + // get color for the reference line + const otherViewport = line[0]; + const viewportColor = this._getReferenceLineColor(otherViewport.id); + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id ); - } else { - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[2], - line[4], - { - color, - lineWidth, - } + const selectedViewportId = data.activeViewportIds.find( + (id) => id === otherViewport.id ); - } - if (viewportControllable) { - color = + const color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); - let opacity = 1; - if (this.configuration.mobile?.enabled) { - handleRadius = this.configuration.mobile.handleRadius; - opacity = this.configuration.mobile.opacity; - } + + let lineWidth = 1; + + const lineActive = + data.handles.activeOperation !== null && + data.handles.activeOperation === OPERATION.DRAG && + selectedViewportId; + if (lineActive) { - const handleUID = `${lineIndex}`; + lineWidth = 2.5; + } + + let lineUID = `${lineIndex}`; + if (viewportControllable) { + lineUID = `${lineIndex}One`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[1], + line[2], + { + color, + lineWidth, + } + ); + + lineUID = `${lineIndex}Two`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[3], + line[4], + { + color, + lineWidth, + } + ); + } else { + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[2], + line[4], + { + color, + lineWidth, + } + ); } - } - }); + if (viewportControllable) { + color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + let handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles + ? window.devicePixelRatio + : 1); + let opacity = 1; + if (this.configuration.mobile?.enabled) { + handleRadius = this.configuration.mobile.handleRadius; + opacity = this.configuration.mobile.opacity; + } + if (lineActive) { + const handleUID = `${lineIndex}`; + } + } + }); + } renderStatus = true; if (this.configuration.viewportIndicators) { @@ -1717,9 +1812,7 @@ class VolumeCroppingTool extends AnnotationTool { ); return ( - this._getReferenceLineControllable(otherViewport.id) && - this._getReferenceLineDraggableRotatable(otherViewport.id) && - sameScene + this._getReferenceLineControllable(otherViewport.id) && sameScene ); } ); @@ -1770,6 +1863,7 @@ class VolumeCroppingTool extends AnnotationTool { _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; const { element } = eventDetail; + this.editData.annotation.data.handles.activeOperation = null; this.editData.annotation.data.activeViewportIds = []; @@ -1804,6 +1898,9 @@ class VolumeCroppingTool extends AnnotationTool { const { element } = eventDetail; const enabledElement = getEnabledElement(element); const { renderingEngine, viewport } = enabledElement; + if (viewport.type === Enums.ViewportType.VOLUME_3D) { + return; + } const annotations = this._getAnnotations( enabledElement ) as VolumeCroppingAnnotation[]; @@ -1956,7 +2053,6 @@ class VolumeCroppingTool extends AnnotationTool { this.editData = { annotation, }; - //console.debug(data.handles.activeOperation); return data.handles.activeOperation === 1 ? true : false; } } From 4f88edb89cede5155e27f96a6fc656e21293e9cf Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:55:31 +0200 Subject: [PATCH 011/132] refactor(volumeCropping): Remove unused functions and variables to streamline code --- .../examples/volumeCroppingTool/index.ts | 243 +----------------- 1 file changed, 2 insertions(+), 241 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 940bbd8420..d5c822fa2d 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -24,13 +24,7 @@ import { addButtonToToolbar, } from '../../../../utils/demo/helpers'; -import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import * as cornerstoneTools from '@cornerstonejs/tools'; -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 ToolGroup from 'tools/src/store/ToolGroupManager/ToolGroup'; // This is for debugging purposes console.warn( @@ -47,12 +41,10 @@ const { OrientationMarkerTool, } = cornerstoneTools; -const { createSlabThicknessSynchronizer } = synchronizers; - const { MouseBindings } = csToolsEnums; const { ViewportType } = Enums; -let sphereActor = undefined; +const sphereActor = undefined; // Define a unique id for the volume const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix @@ -65,9 +57,6 @@ const viewportId3 = 'CT_CORONAL'; const viewportId4 = 'CT_3D_VOLUME'; // New 3D volume viewport const viewportIds = [viewportId1, viewportId2, viewportId3, viewportId4]; const renderingEngineId = 'myRenderingEngine'; -const synchronizerId = 'SLAB_THICKNESS_SYNCHRONIZER_ID'; -/////////////////////////////////////// -const newToolGroupId = 'NEW_TOOL_GROUP_ID'; ///////////////////////////////////////// // ======== Set up page ======== // @@ -152,64 +141,6 @@ addToggleButtonToToolbar({ }, }); -// ============================= // -function addTemporaryPickedPositionLabel( - x: number, - y: number, - pickedPoint: Types.Point3 -) { - // Create a temporary div to show the coordinates - const coordDiv = document.createElement('div'); - coordDiv.style.position = 'absolute'; - coordDiv.style.top = `${y + 10}px`; - coordDiv.style.left = `${x + 10}px`; - coordDiv.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; - coordDiv.style.color = 'white'; - coordDiv.style.padding = '5px'; - coordDiv.style.borderRadius = '3px'; - coordDiv.style.zIndex = '1000'; - coordDiv.style.pointerEvents = 'none'; - coordDiv.textContent = `X: ${pickedPoint[0].toFixed( - 2 - )}, Y: ${pickedPoint[1].toFixed(2)}, Z: ${pickedPoint[2].toFixed(2)}`; - - element4.appendChild(coordDiv); - - // Remove the div after a few seconds - setTimeout(() => { - if (element4.contains(coordDiv)) { - element4.removeChild(coordDiv); - } - }, 3000); -} - -function setCrossHairPosition(pickedPoint) { - const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); - const crosshairTool = toolGroup.getToolInstance(VolumeCroppingTool.toolName); - crosshairTool.setToolCenter(pickedPoint, true); -} - -function addSphere(viewport, point) { - if (!sphereActor) { - // Generate a random string for the sphere UID - const randomUID = 'sphere_' + Math.random().toString(36).substring(2, 15); - - const sphereSource = vtkSphereSource.newInstance(); - sphereSource.setCenter(point); - sphereSource.setRadius(5); - const sphereMapper = vtkMapper.newInstance(); - sphereMapper.setInputConnection(sphereSource.getOutputPort()); - sphereActor = vtkActor.newInstance(); - sphereActor.setMapper(sphereMapper); - sphereActor.getProperty().setColor(0.0, 0.0, 1.0); - viewport.addActor({ actor: sphereActor, uid: randomUID }); - } else { - sphereActor.getMapper().getInputConnection().filter.setCenter(point); - } - console.debug('actors:', viewport.getActors()); - viewport.render(); -} - const viewportColors = { [viewportId1]: 'rgb(200, 0, 0)', [viewportId2]: 'rgb(200, 200, 0)', @@ -217,8 +148,6 @@ const viewportColors = { [viewportId4]: 'rgb(0, 200, 200)', }; -let synchronizer; - const viewportReferenceLineControllable = [ viewportId1, viewportId2, @@ -249,21 +178,6 @@ function getReferenceLineControllable(viewportId) { return index !== -1; } -function setUpSynchronizers() { - synchronizer = createSlabThicknessSynchronizer(synchronizerId); - - // Add viewports to VOI synchronizers - [viewportId1, viewportId2, viewportId3, viewportId4].forEach((viewportId) => { - synchronizer.add({ - renderingEngineId, - viewportId, - }); - }); - // Normally this would be left on, but here we are starting the demo in the - // default state, which is to not have a synchronizer enabled. - synchronizer.setEnabled(false); -} - /** * Runs the demo */ @@ -277,28 +191,6 @@ async function run() { cornerstoneTools.addTool(ZoomTool); cornerstoneTools.addTool(OrientationMarkerTool); - /* - const newToolGroup = ToolGroupManager.createToolGroup(newToolGroupId); - newToolGroup.addTool(OrientationMarkerTool.toolName); - newToolGroup.addTool(ZoomTool.toolName); - newToolGroup.addTool(TrackballRotateTool.toolName); - newToolGroup.addTool(VolumeCroppingTool.toolName); - newToolGroup.setToolActive(OrientationMarkerTool.toolName); - newToolGroup.setToolActive(TrackballRotateTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Primary, // Left Click - }, - ], - }); - newToolGroup.setToolActive(ZoomTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Secondary, - }, - ], - }); -*/ // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: @@ -379,7 +271,7 @@ async function run() { const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); addManipulationBindings(toolGroup); toolGroup.addTool(TrackballRotateTool.toolName); - /* + toolGroup.setToolActive(TrackballRotateTool.toolName, { bindings: [ { @@ -387,13 +279,11 @@ async function run() { }, ], }); -*/ toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); toolGroup.addViewport(viewportId4, renderingEngineId); - //newToolGroup.addViewport(viewportId4, renderingEngineId); // Manipulation Tools // Add Crosshairs tool and configure it to link the three viewports @@ -416,12 +306,6 @@ async function run() { bindings: [{ mouseButton: MouseBindings.Primary }], }); - setUpSynchronizers(); - - const picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); - picker.setPickFromList(1); - picker.setTolerance(0); - picker.initializePickList(); // Render the image const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; renderingEngine.renderViewports(viewportIds); @@ -433,86 +317,7 @@ async function run() { viewport.setProperties({ preset: 'CT-Bone', }); - const defaultActor = viewport.getDefaultActor(); - if (defaultActor?.actor) { - // Cast to any to avoid type errors with different actor types - picker.addPickList(defaultActor.actor as any); - prepareImageDataForPicking(viewport); - } - // Get the vtkImageData from the volume - const imageData = volume.imageData; - const dimensions = imageData.getDimensions(); // [xDim, yDim, zDim] - const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const worldDimensions = [ - Math.round(dimensions[0] * spacing[0]), - Math.round(dimensions[1] * spacing[1]), - Math.round(dimensions[2] * spacing[2]), - ]; - const mapper = viewport.getDefaultActor().actor.getMapper(); - const xMin = worldDimensions[0] * -0.5; - const xMax = worldDimensions[0] * 0.5; - const yMin = worldDimensions[1] * -0.5; - const yMax = worldDimensions[1] * 0.5; - const zMin = -worldDimensions[2]; - const zMax = 0; - const planes: vtkPlane[] = []; - - const planeXmin = vtkPlane.newInstance({ - origin: [xMin, 0, 0], - normal: [1, 0, 0], - }); - planes.push(planeXmin); - viewport.render(); - }); - - function setClippingPlane(viewport, planeIndex, origin) { - const mapper = viewport.getDefaultActor().actor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - console.debug('clippingPlanes before setOrigin:', clippingPlanes); - clippingPlanes[planeIndex].setOrigin(origin); - viewport.setOriginalClippingPlane(planeIndex, origin); viewport.render(); - } - - // Add right-click event handler to element4 for picking coordinates - element4.addEventListener('mousedown', (evt) => { - // Check if it's a right-click (button 2) - if (evt.button === 2) { - evt.preventDefault(); - evt.stopPropagation(); - - // Get the rendering engine and viewport - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport = renderingEngine.getViewport( - viewportId4 - ) as VolumeViewport3D; - - // Get canvas coordinates relative to the element - const rect = element4.getBoundingClientRect(); - const x = evt.clientX - rect.left; - const y = evt.clientY - rect.top; - - const displayCoords = viewport.getVtkDisplayCoords([x, y]); - // Use the picker to get the 3D coordinates - picker.pick( - [displayCoords[0], displayCoords[1], 0], - viewport.getRenderer() - ); - - // Get the picked position - const pickedPositions = picker.getPickedPositions(); - const actors = picker.getActors(); - if (actors.length > 0) { - const pickedPoint = pickedPositions[0]; - if (pickedPoint) { - console.log('Picked point coordinates:', pickedPoint); - // addSphere(viewport, pickedPoint); - addTemporaryPickedPositionLabel(x, y, pickedPoint); - setCrossHairPosition(pickedPoint); - // setClippingPlane(viewport, 0, [0, 0, -500]); - } - } - } }); } @@ -530,48 +335,4 @@ eventTarget.addEventListener( } ); -/** - * Creates the minimum infrastructure needed to pick a point in the 3D volume - * with VTK.js - * @remarks - * @param viewport - * @returns - */ -function prepareImageDataForPicking(viewport: BaseVolumeViewport) { - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - return; - } - // Get the imageData from the volumeActor - const imageData = volumeActor.getMapper().getInputData(); - - if (!imageData) { - console.error('No imageData found in the volumeActor'); - return null; - } - - // Get the voxelManager from the imageData - const { voxelManager } = imageData.get('voxelManager'); - - if (!voxelManager) { - console.error('No voxelManager found in the imageData'); - return imageData; - } - - // Create a fake scalar object to expose the scalar data to VTK.js - const fakeScalars = { - getData: () => { - return voxelManager.getCompleteScalarDataArray(); - }, - getNumberOfComponents: () => voxelManager.numberOfComponents, - getDataType: () => - voxelManager.getCompleteScalarDataArray().constructor.name, - }; - - // Set the point data to return the fakeScalars - imageData.setPointData({ - getScalars: () => fakeScalars, - }); -} - run(); From c503f57e6158e7422b9238700378d7f38154a8d2 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 18 Jun 2025 07:11:31 +0200 Subject: [PATCH 012/132] feat(volumeCropping): Add sphere color configuration and improve annotation handling --- .../examples/volumeCroppingTool/index.ts | 11 ++- .../tools/src/tools/VolumeCroppingTool.ts | 93 ++++++++++++++----- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index d5c822fa2d..cc3c8d0892 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -300,6 +300,13 @@ async function run() { opacity: 0.8, handleRadius: 9, }, + // Add sphere color configuration + sphereColors: { + x: [0.0, 1.0, 0.0], // Green for X + y: [1.0, 1.0, 0.0], // Yellow for Y + z: [1.0, 0.0, 0.0], // Red for Z + default: [0.0, 0.0, 1.0], // Blue as fallback + }, }); toolGroup.setToolActive(VolumeCroppingTool.toolName, { @@ -320,7 +327,7 @@ async function run() { viewport.render(); }); } - +/* eventTarget.addEventListener( toolsEnums.Events.CROSSHAIR_TOOL_CENTER_CHANGED, (evt) => { @@ -334,5 +341,5 @@ eventTarget.addEventListener( } } ); - +*/ run(); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 04d419cd71..caabfc19d0 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -73,7 +73,7 @@ interface VolumeCroppingAnnotation extends Annotation { }; activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated viewportId: string; - // referenceLines: []; // set in renderAnnotation + referenceLines: []; // set in renderAnnotation clippingPlanes?: vtkPlane[]; // clipping planes for the viewport clippingPlaneReferenceLines?: []; }; @@ -172,7 +172,7 @@ class VolumeCroppingTool extends AnnotationTool { } /** - * Gets the camera from the viewport, and adds crosshairs annotation for the viewport + * 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 to add the crosshairs @@ -230,16 +230,18 @@ class VolumeCroppingTool extends AnnotationTool { clippingPlaneReferenceLines: [], // clipping planes for the viewport }, }; - - addAnnotation(annotation, element); - - return { - normal: viewPlaneNormal, - point: viewport.canvasToWorld([ - viewport.canvas.clientWidth / 2, - viewport.canvas.clientHeight / 2, - ]), - }; + if (viewport.type !== Enums.ViewportType.VOLUME_3D) { + addAnnotation(annotation, element); + return { + normal: viewPlaneNormal, + point: viewport.canvasToWorld([ + viewport.canvas.clientWidth / 2, + viewport.canvas.clientHeight / 2, + ]), + }; + } else { + return; + } }; _getViewportsInfo = () => { @@ -376,15 +378,23 @@ class VolumeCroppingTool extends AnnotationTool { } */ - let color = [1.0, 1.0, 0.0]; // Yellow + let color = [0.0, 1.0, 0.0]; if (axis === 'z') { color = [1.0, 0.0, 0.0]; } else if (axis === 'x') { - color = [0.0, 1.0, 0.0]; + color = [1.0, 1.0, 0.0]; } - color = [0.0, 0.0, 1.0]; - // sphereActor.getProperty().setColor(...color); - sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + + //const color = [0.0, 0.0, 1.0]; + + sphereActor.getProperty().setColor(color); + //sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + /* + const sphereColors = this.configuration.sphereColors || {}; + const color = sphereColors[sphereState.axis] || + sphereColors.default || [0.0, 0.0, 1.0]; + sphereActor.getProperty().setColor(...color); +*/ sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); console.debug('added sphere: ', uid, viewport.getActors()); @@ -504,11 +514,17 @@ class VolumeCroppingTool extends AnnotationTool { const mapper = viewport.getDefaultActor().actor.getMapper(); planes.push(planeXmin); + mapper.addClippingPlane(planeXmin); planes.push(planeXmax); + mapper.addClippingPlane(planeXmax); planes.push(planeYmin); + mapper.addClippingPlane(planeYmin); planes.push(planeYmax); + mapper.addClippingPlane(planeYmax); planes.push(planeZmin); + mapper.addClippingPlane(planeZmin); planes.push(planeZmax); + mapper.addClippingPlane(planeZmax); const originalPlanes = planes.map((plane) => ({ origin: [...plane.getOrigin()], normal: [...plane.getNormal()], @@ -557,9 +573,12 @@ class VolumeCroppingTool extends AnnotationTool { } const element = viewport.canvas || viewport.element; - // element.addEventListener('mousedown', this._onMouseDownSphere); - // element.addEventListener('mousemove', this._onMouseMoveSphere); - // element.addEventListener('mouseup', this._onMouseUpSphere); + element.addEventListener('mousedown', this._onMouseDownSphere); + element.addEventListener('mousemove', this._onMouseMoveSphere); + element.addEventListener('mouseup', this._onMouseUpSphere); + // mapper.modified(); + // viewport.getDefaultActor().actor.modified(); + viewport.render(); const toolCenter = csUtils.planar.threePlaneIntersection( firstPlane, @@ -644,6 +663,8 @@ class VolumeCroppingTool extends AnnotationTool { if (this.draggingSphereIndex === null) { return; } + evt.stopPropagation(); + evt.preventDefault(); const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); @@ -670,6 +691,7 @@ class VolumeCroppingTool extends AnnotationTool { const sphereState = this.sphereStates[this.draggingSphereIndex]; // Restrict movement to the sphere's axis only const newPoint = [...sphereState.point]; + if (sphereState.axis === 'x') { newPoint[0] = pickedPoint[0]; } else if (sphereState.axis === 'y') { @@ -679,15 +701,32 @@ class VolumeCroppingTool extends AnnotationTool { } sphereState.point = newPoint; - sphereState.sphereSource.setCenter(pickedPoint); + sphereState.sphereSource.setCenter(newPoint); + sphereState.sphereSource.modified(); + + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + + const clippingPlanes = mapper.getClippingPlanes(); + //console.debug('clippingPlanes before setOrigin:', clippingPlanes); + //clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); + viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + mapper.modified(); + viewport.getDefaultActor().actor.modified(); viewport.render(); } }; _onMouseUpSphere = (evt) => { - if (this.draggingSphereIndex !== null) { - evt.currentTarget.style.cursor = ''; - } + //evt.stopPropagation(); + // evt.preventDefault(); + // if (this.draggingSphereIndex !== null) { + evt.currentTarget.style.cursor = ''; + // } this.draggingSphereIndex = null; }; @@ -1262,6 +1301,7 @@ class VolumeCroppingTool extends AnnotationTool { let lineUID = `${lineIndex}`; if (viewportControllable) { lineUID = `${lineIndex}One`; + console.debug(lineUID); drawLineSvg( svgDrawingHelper, annotationUID, @@ -1590,6 +1630,10 @@ class VolumeCroppingTool extends AnnotationTool { const otherViewportControllable = this._getReferenceLineControllable( otherViewport.id ); + // Filter out 3D viewports + if (otherViewport.type === Enums.ViewportType.VOLUME_3D) { + return false; + } return ( viewport !== otherViewport && @@ -1862,6 +1906,7 @@ class VolumeCroppingTool extends AnnotationTool { _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; + console.debug(eventDetail); const { element } = eventDetail; this.editData.annotation.data.handles.activeOperation = null; From 057d367df040d05bd624645687ef599ea6e6b78f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:10:02 +0200 Subject: [PATCH 013/132] working example before split in 2 tools. --- .../examples/volumeCroppingTool/index.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index cc3c8d0892..8933ecdc85 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -51,6 +51,8 @@ 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_SAGITTAL'; const viewportId3 = 'CT_CORONAL'; @@ -269,13 +271,26 @@ async function run() { // Define tool groups to add the segmentation display tool to const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); - addManipulationBindings(toolGroup); + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + // toolGroup.addTool(CrosshairsTool.toolName); + + // toolGroup.setToolActive(VolumeCroppingTool.toolName, { + // bindings: [{ mouseButton: MouseBindings.Primary }], + // }); + + const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); + + toolGroup.addViewport(viewportId4, renderingEngineId); + + //addManipulationBindings(toolGroup); toolGroup.addTool(TrackballRotateTool.toolName); toolGroup.setToolActive(TrackballRotateTool.toolName, { bindings: [ { - mouseButton: MouseBindings.Primary, // Left Click + mouseButton: MouseBindings.Secondary, }, ], }); @@ -293,6 +308,7 @@ async function run() { const isMobile = window.matchMedia('(any-pointer:coarse)').matches; toolGroup.addTool(VolumeCroppingTool.toolName, { + toolGroupId, getReferenceLineColor, getReferenceLineControllable, mobile: { @@ -307,12 +323,14 @@ async function run() { z: [1.0, 0.0, 0.0], // Red for Z default: [0.0, 0.0, 1.0], // Blue as fallback }, + sphereRadius: 10, }); toolGroup.setToolActive(VolumeCroppingTool.toolName, { bindings: [{ mouseButton: MouseBindings.Primary }], }); + const isMobile = window.matchMedia('(any-pointer:coarse)').matches; // Render the image const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; renderingEngine.renderViewports(viewportIds); @@ -327,19 +345,5 @@ async function run() { viewport.render(); }); } -/* -eventTarget.addEventListener( - toolsEnums.Events.CROSSHAIR_TOOL_CENTER_CHANGED, - (evt) => { - const { toolCenter } = evt.detail; - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport = renderingEngine.getViewport( - viewportId4 - ) as VolumeViewport3D; - if (sphereActor) { - // addSphere(viewport, toolCenter); - } - } -); -*/ + run(); From 5c9dc6f24bfc8d2c395e2abd007804442cdf97f8 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 20 Jun 2025 05:37:52 +0200 Subject: [PATCH 014/132] Add VolumeCroppingControlTool to tools index export --- .../examples/volumeCroppingTool/index.ts | 107 +- packages/tools/src/enums/Events.ts | 2 + packages/tools/src/index.ts | 2 + .../src/tools/VolumeCroppingControlTool.ts | 1750 +++++++++++++++++ .../tools/src/tools/VolumeCroppingTool.ts | 1501 ++------------ packages/tools/src/tools/index.ts | 2 + 6 files changed, 1942 insertions(+), 1422 deletions(-) create mode 100644 packages/tools/src/tools/VolumeCroppingControlTool.ts diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 8933ecdc85..f076703324 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -18,7 +18,6 @@ import { setTitleAndDescription, setCtTransferFunctionForVolumeActor, addDropdownToToolbar, - addManipulationBindings, getLocalUrl, addToggleButtonToToolbar, addButtonToToolbar, @@ -35,17 +34,16 @@ const { ToolGroupManager, Enums: csToolsEnums, VolumeCroppingTool, - synchronizers, + VolumeCroppingControlTool, TrackballRotateTool, ZoomTool, + PanTool, OrientationMarkerTool, } = cornerstoneTools; const { MouseBindings } = csToolsEnums; const { ViewportType } = Enums; -const sphereActor = undefined; - // 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 @@ -147,34 +145,17 @@ const viewportColors = { [viewportId1]: 'rgb(200, 0, 0)', [viewportId2]: 'rgb(200, 200, 0)', [viewportId3]: 'rgb(0, 200, 0)', - [viewportId4]: 'rgb(0, 200, 200)', }; -const viewportReferenceLineControllable = [ - viewportId1, - viewportId2, - viewportId3, - viewportId4, -]; - -const viewportReferenceLineDraggableRotatable = [ - viewportId1, - viewportId2, - viewportId3, - viewportId4, -]; +const getReferenceLineColor = (viewportId) => + viewportColors[viewportId] || 'rgb(0, 200, 0)'; -const viewportReferenceLineSlabThicknessControlsOn = [ +const viewportReferenceLineControllable = [ viewportId1, viewportId2, viewportId3, - viewportId4, ]; -function getReferenceLineColor(viewportId) { - return viewportColors[viewportId]; -} - function getReferenceLineControllable(viewportId) { const index = viewportReferenceLineControllable.indexOf(viewportId); return index !== -1; @@ -189,8 +170,10 @@ async function run() { // Add tools to Cornerstone3D cornerstoneTools.addTool(VolumeCroppingTool); + cornerstoneTools.addTool(VolumeCroppingControlTool); cornerstoneTools.addTool(TrackballRotateTool); cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(PanTool); cornerstoneTools.addTool(OrientationMarkerTool); // Get Cornerstone imageIds for the source data and fetch metadata into RAM @@ -274,62 +257,50 @@ async function run() { toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); - // toolGroup.addTool(CrosshairsTool.toolName); - - // toolGroup.setToolActive(VolumeCroppingTool.toolName, { - // bindings: [{ mouseButton: MouseBindings.Primary }], - // }); + toolGroup.addTool(VolumeCroppingControlTool.toolName, { + configuration: { + getReferenceLineColor, + }, + }); + toolGroup.setToolActive(VolumeCroppingControlTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, + }, + ], + }); const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); - toolGroup.addViewport(viewportId4, renderingEngineId); + toolGroupVRT.addViewport(viewportId4, renderingEngineId); - //addManipulationBindings(toolGroup); - toolGroup.addTool(TrackballRotateTool.toolName); + toolGroupVRT.addTool(VolumeCroppingTool.toolName); + toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); - toolGroup.setToolActive(TrackballRotateTool.toolName, { + toolGroupVRT.addTool(TrackballRotateTool.toolName); + toolGroupVRT.setToolActive(TrackballRotateTool.toolName, { bindings: [ { - mouseButton: MouseBindings.Secondary, + mouseButton: MouseBindings.Primary, }, ], }); - - toolGroup.addViewport(viewportId1, renderingEngineId); - toolGroup.addViewport(viewportId2, renderingEngineId); - toolGroup.addViewport(viewportId3, renderingEngineId); - toolGroup.addViewport(viewportId4, renderingEngineId); - - // Manipulation Tools - // Add Crosshairs tool and configure it to link the three viewports - // These viewports could use different tool groups. See the PET-CT example - // for a more complicated used case. - - const isMobile = window.matchMedia('(any-pointer:coarse)').matches; - - toolGroup.addTool(VolumeCroppingTool.toolName, { - toolGroupId, - getReferenceLineColor, - getReferenceLineControllable, - mobile: { - enabled: isMobile, - opacity: 0.8, - handleRadius: 9, - }, - // Add sphere color configuration - sphereColors: { - x: [0.0, 1.0, 0.0], // Green for X - y: [1.0, 1.0, 0.0], // Yellow for Y - z: [1.0, 0.0, 0.0], // Red for Z - default: [0.0, 0.0, 1.0], // Blue as fallback - }, - sphereRadius: 10, + toolGroupVRT.addTool(ZoomTool.toolName); + toolGroupVRT.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, + }, + ], }); - - toolGroup.setToolActive(VolumeCroppingTool.toolName, { - bindings: [{ mouseButton: MouseBindings.Primary }], + toolGroupVRT.addTool(PanTool.toolName); + toolGroupVRT.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, + }, + ], }); - const isMobile = window.matchMedia('(any-pointer:coarse)').matches; // Render the image const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index 6fce4fd7c2..4c722823bb 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -35,6 +35,8 @@ enum Events { CROSSHAIR_TOOL_CENTER_CHANGED = 'CORNERSTONE_TOOLS_CROSSHAIR_TOOL_CENTER_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 18e5571084..670f962cff 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -29,6 +29,7 @@ import { PanTool, TrackballRotateTool, VolumeCroppingTool, + VolumeCroppingControlTool, DragProbeTool, WindowLevelTool, ZoomTool, @@ -108,6 +109,7 @@ export { 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..f50bb14fef --- /dev/null +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -0,0 +1,1750 @@ +import { vec2, vec3 } from 'gl-matrix'; +import vtkMath from '@kitware/vtk.js/Common/Core/Math'; +import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; +import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; +//import * as cornerstoneTools from '@cornerstonejs/tools'; +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 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; + +interface VolumeCroppingAnnotation extends Annotation { + data: { + handles: { + activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles + toolCenter: Types.Point3; + }; + activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated + viewportId: string; + referenceLines: []; // set in renderAnnotation + clippingPlanes?: vtkPlane[]; // clipping planes for the viewport + clippingPlaneReferenceLines?: []; + }; +} + +function defaultReferenceLineColor() { + return 'rgb(0, 200, 0)'; +} + +function defaultReferenceLineControllable() { + return true; +} + +const OPERATION = { + DRAG: 1, + ROTATE: 2, + SLAB: 3, +}; + +/** + * VolumeCroppingControlTool is a tool that provides reference lines between different viewports + * of a toolGroup. Using crosshairs, you can jump to a specific location in one + * viewport and the rest of the viewports in the toolGroup will be aligned to that location. + * + */ +class VolumeCroppingControlTool extends AnnotationTool { + static toolName; + sphereStates: { + point: Types.Point3; + axis: string; + uid: string; + sphereSource; + sphereActor; + }[] = []; + draggingSphereIndex: number | null = null; + toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. + // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle + _getReferenceLineColor?: (viewportId: string) => string; + _getReferenceLineControllable?: (viewportId: string) => boolean; + picker: vtkCellPicker; + 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, + }, + // Auto pan is a configuration which will update pan + // other viewports in the toolGroup if the center of the crosshairs + // is outside of the viewport. This might be useful for the case + // when the user is scrolling through an image (usually in the zoomed view) + // and the crosshairs will eventually get outside of the viewport for + // the other viewports. + autoPan: { + enabled: false, + panSize: 10, + }, + handleRadius: 3, + // Enable HDPI rendering for handles using devicePixelRatio + enableHDPIHandles: false, + // radius of the area around the intersection of the planes, in which + // the reference lines will not be rendered. This is only used when + // having 3 viewports in the toolGroup. + referenceLinesCenterGapRadius: 20, + + mobile: { + enabled: false, + opacity: 0.8, + handleRadius: 9, + }, + }, + } + ) { + super(toolProps, defaultToolProps); + + this._getReferenceLineColor = + toolProps.configuration?.getReferenceLineColor || + defaultReferenceLineColor; + this._getReferenceLineControllable = + toolProps.configuration?.getReferenceLineControllable || + defaultReferenceLineControllable; + this.picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); + this.picker.setPickFromList(1); + this.picker.setTolerance(0); + this.picker.initializePickList(); + } + + /** + * 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 to add the crosshairs + * @returns viewPlaneNormal and center of viewport canvas in world space + */ + initializeViewport = ({ + renderingEngineId, + viewportId, + }: Types.IViewportId): { + normal: Types.Point3; + point: Types.Point3; + } => { + const enabledElement = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!enabledElement) { + return; + } + const { FrameOfReferenceUID, viewport } = enabledElement; + 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); + } + + const annotation = { + highlighted: false, + metadata: { + cameraPosition: [...position], + cameraFocalPoint: [...focalPoint], + FrameOfReferenceUID, + toolName: this.getToolName(), + }, + data: { + handles: { + toolCenter: this.toolCenter, + }, + 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 + }, + }; + + addAnnotation(annotation, element); + return { + normal: viewPlaneNormal, + point: viewport.canvasToWorld([ + viewport.canvas.clientWidth / 2, + viewport.canvas.clientHeight / 2, + ]), + }; + }; + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + + return viewports; + }; + + onSetToolActive() { + const viewportsInfo = this._getViewportsInfo(); + + // Upon new setVolumes on viewports we need to update the crosshairs + // reference points in the new space, so we subscribe to the event + // and update the reference points accordingly. + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + this._subscribeToViewportNewVolumeSet(viewportsInfo); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolPassive() { + const viewportsInfo = this._getViewportsInfo(); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolEnabled() { + const viewportsInfo = this._getViewportsInfo(); + + this._computeToolCenter(viewportsInfo); + } + + onSetToolDisabled() { + const viewportsInfo = this._getViewportsInfo(); + + this._unsubscribeToViewportNewVolumeSet(viewportsInfo); + + // Crosshairs annotations in the state + // 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); + }); + } + }); + } + + resetCrosshairs = () => { + 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(); + this._computeToolCenter(viewportsInfo); + }; + + /** + * When activated, it initializes the crosshairs. It begins by computing + * the intersection of viewports associated with the crosshairs instance. + * When all three views are accessible, the intersection (e.g., crosshairs tool centre) + * will be an exact point in space; however, with two viewports, because the + * intersection of two planes is a line, it assumes the last view is between the centre + * of the two rendering viewports. + * @param viewportsInfo Array of viewportInputs which each item containing `{viewportId, renderingEngineId}` + */ + _computeToolCenter = (viewportsInfo): void => { + // Todo: handle two same view viewport, or more than 3 viewports + const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; + + // Initialize first viewport + const { normal: normal1, point: point1 } = + this.initializeViewport(firstViewport); + + // Initialize second viewport + const { normal: normal2, point: point2 } = + this.initializeViewport(secondViewport); + + let normal3 = [0, 0, 0]; + let point3 = vec3.create(); + + // If there are three viewports + if (thirdViewport) { + ({ normal: normal3, point: point3 } = + this.initializeViewport(thirdViewport)); + } else { + // If there are only two views (viewport) associated with the crosshairs: + // In this situation, we don't have a third information to find the + // exact intersection, and we "assume" the third view is looking at + // a location in between the first and second view centers + vec3.add(point3, point1, point2); + vec3.scale(point3, point3, 0.5); + vec3.cross(normal3, normal1, normal2); + } + + // Planes of each viewport + const firstPlane = csUtils.planar.planeEquation(normal1, point1); + const secondPlane = csUtils.planar.planeEquation(normal2, point2); + const thirdPlane = csUtils.planar.planeEquation(normal3, point3); + + //viewport.render(); + const toolCenter = csUtils.planar.threePlaneIntersection( + firstPlane, + secondPlane, + thirdPlane + ); + this.setToolCenter(toolCenter); + }; + + setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { + // prettier-ignore + this.toolCenter = toolCenter; + const viewportsInfo = this._getViewportsInfo(); + + // assuming all viewports are in the same rendering engine + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + if (!suppressEvents) { + console.log('event sent: ', Events.CROSSHAIR_TOOL_CENTER_CHANGED); + triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + }); + } + } + + /** + * addNewAnnotation acts as jump for the crosshairs tool. It 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 Crosshairs annotation + */ + + addNewAnnotation = ( + evt: EventTypes.InteractionEventType + ): VolumeCroppingAnnotation => { + const eventDetail = evt.detail; + + // check if we are close to a sphere + + console.debug('addNewAnnotation: ', eventDetail); + const { element } = eventDetail; + + const { currentPoints } = eventDetail; + const jumpWorld = currentPoints.world; + + const enabledElement = getEnabledElement(element); + const { viewport } = enabledElement; + if (viewport.type === Enums.ViewportType.VOLUME_3D) { + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); + + if (!filteredAnnotations.length) { + this.initializeViewport({ + renderingEngineId: viewport.renderingEngineId, + viewportId: viewport.id, + }); + // Re-fetch after creation + annotations = this._getAnnotations(enabledElement); + filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); + if (!filteredAnnotations.length) { + return; + } + } + + const { data } = filteredAnnotations[0]; + data.handles.activeOperation = OPERATION.DRAG; + // console.debug('addanotation', filteredAnnotations); + + return filteredAnnotations[0]; + // here should be the nearPoint checker and update handler + } else { + this._jump(enabledElement, jumpWorld); + + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); + + 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 crosshairs 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(); + }; + + onCameraModified = (evt) => { + const eventDetail = evt.detail; + const { element } = eventDetail; + const enabledElement = getEnabledElement(element); + const { renderingEngine } = enabledElement; + const viewport = enabledElement.viewport as Types.IVolumeViewport; + + const annotations = this._getAnnotations(enabledElement); + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); + + // viewport that the camera modified is originating from + const viewportAnnotation = + filteredToolAnnotations[0] as VolumeCroppingAnnotation; + + if (!viewportAnnotation) { + return; + } + + // -- Update the camera of other linked viewports containing the same volumeId that + // have the same camera in case of translation + // -- Update the crosshair center in world coordinates in annotation. + // This is necessary because other tools can modify the position of the slices, + // e.g. stackScroll tool at wheel scroll. So we update the coordinates of the center always here. + // NOTE: rotation and slab thickness handles are created/updated in renderTool. + const currentCamera = viewport.getCamera(); + const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; + const deltaCameraPosition: Types.Point3 = [0, 0, 0]; + vtkMath.subtract( + currentCamera.position, + oldCameraPosition, + deltaCameraPosition + ); + + const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; + const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; + vtkMath.subtract( + currentCamera.focalPoint, + oldCameraFocalPoint, + deltaCameraFocalPoint + ); + + // updated cached "previous" camera position and focal point + viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; + viewportAnnotation.metadata.cameraFocalPoint = [ + ...currentCamera.focalPoint, + ]; + + const viewportControllable = this._getReferenceLineControllable( + viewport.id + ); + + if ( + !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && + viewportControllable + ) { + // Is camera Modified a TRANSLATION or ROTATION? + let isRotation = false; + + // This is guaranteed to be the same diff for both position and focal point + // if the camera is modified by pan, zoom, or scroll BUT for rotation of + // crosshairs handles it will be different. + const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( + deltaCameraPosition, + deltaCameraFocalPoint, + 1e-3 + ); + + // NOTE: it is a translation if the the focal point and camera position shifts are the same + if (!cameraModifiedSameForPosAndFocalPoint) { + isRotation = true; + } + + const cameraModifiedInPlane = + Math.abs( + vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) + ) < 1e-2; + + // TRANSLATION + // NOTE1: if the camera modified is a result of a pan or zoom don't update the crosshair center + // NOTE2: rotation handles are updates in renderTool + if (!isRotation && !cameraModifiedInPlane) { + this.toolCenter[0] += deltaCameraPosition[0]; + this.toolCenter[1] += deltaCameraPosition[1]; + this.toolCenter[2] += deltaCameraPosition[2]; + triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + }); + } + } + + // AutoPan modification + if (this.configuration.autoPan?.enabled) { + const toolGroup = getToolGroupForViewport( + viewport.id, + renderingEngine.id + ); + + const otherViewportIds = toolGroup + .getViewportIds() + .filter((id) => id !== viewport.id); + + otherViewportIds.forEach((viewportId) => { + this._autoPanViewportIfNecessary(viewportId, renderingEngine); + }); + } + + const requireSameOrientation = false; + const viewportIdsToRender = getViewportIdsWithToolToRender( + element, + this.getToolName(), + requireSameOrientation + ); + + triggerAnnotationRenderForViewportIds(viewportIdsToRender); + }; + + onResetCamera = (evt) => { + this.resetCrosshairs(); + }; + + 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); + const { viewportId } = enabledElement; + // console.log(annotations); + const viewportUIDSpecificCrosshairs = annotations.filter( + (annotation) => annotation.data.viewportId === viewportId + ); + + return viewportUIDSpecificCrosshairs; + }; + + /** + * 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 => { + let renderStatus = false; + const { viewport, renderingEngine } = enabledElement; + const { element } = viewport; + const annotations = this._getAnnotations(enabledElement); + const camera = viewport.getCamera(); + const filteredToolAnnotations = + this.filterInteractableAnnotationsForElement(element, annotations); + + // viewport Annotation + const viewportAnnotation = filteredToolAnnotations[0]; + if (!annotations?.length || !viewportAnnotation?.data) { + // No annotations yet, and didn't just create it as we likely don't have a FrameOfReference/any data loaded yet. + 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 canvasMinDimensionLength = Math.min(clientWidth, clientHeight); + + const data = viewportAnnotation.data; + console.debug('annotation data: ', data); + + if (viewport.type === Enums.ViewportType.VOLUME_3D) { + // console.debug('annotation data for 3D: ', data); + } else { + const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); + + const otherViewportAnnotations = + this._filterAnnotationsByUniqueViewportOrientations( + enabledElement, + annotations + ); + + 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.handles.toolCenter = this.toolCenter; + + const otherViewport = renderingEngine.getViewport( + data.viewportId + ) as Types.IVolumeViewport; + + const otherCamera = otherViewport.getCamera(); + + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + // get coordinates for the reference line + const { clientWidth, clientHeight } = otherViewport.canvas; + const otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + const otherCanvasCenter: Types.Point2 = [ + clientWidth * 0.5, + clientHeight * 0.5, + ]; + const otherViewportCenterWorld = + otherViewport.canvasToWorld(otherCanvasCenter); + + const direction: Types.Point3 = [0, 0, 0]; + vtkMath.cross( + camera.viewPlaneNormal, + otherCamera.viewPlaneNormal, + direction + ); + vtkMath.normalize(direction); + vtkMath.multiplyScalar( + direction, + otherCanvasDiagonalLength + ); + + const pointWorld0: Types.Point3 = [0, 0, 0]; + vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); + + const pointWorld1: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); + + const pointCanvas0 = viewport.worldToCanvas(pointWorld0); + + const otherViewportCenterCanvas = viewport.worldToCanvas( + otherViewportCenterWorld + ); + + const canvasUnitVectorFromCenter = vec2.create(); + vec2.subtract( + canvasUnitVectorFromCenter, + pointCanvas0, + otherViewportCenterCanvas + ); + vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); + + const canvasVectorFromCenterLong = vec2.create(); + + vec2.scale( + canvasVectorFromCenterLong, + canvasUnitVectorFromCenter, + canvasDiagonalLength * 100 + ); + const canvasVectorFromCenterMid = vec2.create(); + vec2.scale( + canvasVectorFromCenterMid, + canvasUnitVectorFromCenter, + // to maximize the visibility of the controls, they need to be + // placed at most at half the length of the shortest side of the canvas. + // Chosen 0.4 to have some margin to the edge. + canvasMinDimensionLength * 0.4 + ); + const canvasVectorFromCenterShort = vec2.create(); + vec2.scale( + canvasVectorFromCenterShort, + canvasUnitVectorFromCenter, + // Chosen 0.2 because is half of 0.4. + canvasMinDimensionLength * 0.2 + ); + const canvasVectorFromCenterStart = vec2.create(); + const centerGap = this.configuration.referenceLinesCenterGapRadius; + vec2.scale( + canvasVectorFromCenterStart, + canvasUnitVectorFromCenter, + // Don't put a gap if the the third view is missing + otherViewportAnnotations.length === 2 ? centerGap : 0 + ); + + // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) + const refLinePointOne = vec2.create(); + const refLinePointTwo = vec2.create(); + const refLinePointThree = vec2.create(); + const refLinePointFour = vec2.create(); + + let refLinesCenter = vec2.clone(crosshairCenterCanvas); + if (!otherViewportControllable) { + refLinesCenter = vec2.clone(otherViewportCenterCanvas); + } + + vec2.add(refLinePointOne, refLinesCenter, canvasVectorFromCenterStart); + vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); + vec2.subtract( + refLinePointThree, + refLinesCenter, + canvasVectorFromCenterStart + ); + vec2.subtract( + refLinePointFour, + refLinesCenter, + canvasVectorFromCenterLong + ); + + // Clipping lines to be only included in a box (canvas), we don't want + // the lines goes beyond canvas + liangBarksyClip(refLinePointOne, refLinePointTwo, canvasBox); + liangBarksyClip(refLinePointThree, refLinePointFour, canvasBox); + referenceLines.push([ + otherViewport, + refLinePointOne, + refLinePointTwo, + refLinePointThree, + refLinePointFour, + ]); + }); + + /// create new reference lines here + + data.referenceLines = referenceLines; + const viewportColor = this._getReferenceLineColor(viewport.id); + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + console.debug(color); + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + referenceLines, + }); + referenceLines.forEach((line, lineIndex) => { + // get color for the reference line + const otherViewport = line[0]; + const viewportColor = this._getReferenceLineColor(otherViewport.id); + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const selectedViewportId = data.activeViewportIds.find( + (id) => id === otherViewport.id + ); + + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + + let lineWidth = 1; + + const lineActive = + data.handles.activeOperation !== null && + data.handles.activeOperation === OPERATION.DRAG && + selectedViewportId; + + if (lineActive) { + lineWidth = 2.5; + } + + let lineUID = `${lineIndex}`; + if (viewportControllable) { + lineUID = `${lineIndex}One`; + // console.debug(lineUID); + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[1], + line[2], + { + color, + lineWidth, + } + ); + + lineUID = `${lineIndex}Two`; + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[3], + line[4], + { + color, + lineWidth, + } + ); + } else { + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[2], + line[4], + { + color, + lineWidth, + } + ); + } + + if (viewportControllable) { + color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + let handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles + ? window.devicePixelRatio + : 1); + let opacity = 1; + if (this.configuration.mobile?.enabled) { + handleRadius = this.configuration.mobile.handleRadius; + opacity = this.configuration.mobile.opacity; + } + if (lineActive) { + const handleUID = `${lineIndex}`; + } + } + }); + + 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; + }; + + _onNewVolume = () => { + const viewportsInfo = this._getViewportsInfo(); + this._computeToolCenter(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 + ); + }); + } + + _autoPanViewportIfNecessary( + viewportId: string, + renderingEngine: Types.IRenderingEngine + ): void { + // 1. Check if the toolCenter is outside the viewport + // 2. If it is outside, pan the viewport to fit in the toolCenter + + const viewport = renderingEngine.getViewport(viewportId); + const { clientWidth, clientHeight } = viewport.canvas; + + const toolCenterCanvas = viewport.worldToCanvas(this.toolCenter); + + // pan the viewport to fit the toolCenter in the direction + // that is out of bounds + const pan = this.configuration.autoPan.panSize; + + const visiblePointCanvas = [ + toolCenterCanvas[0], + toolCenterCanvas[1], + ]; + + if (toolCenterCanvas[0] < 0) { + visiblePointCanvas[0] = pan; + } else if (toolCenterCanvas[0] > clientWidth) { + visiblePointCanvas[0] = clientWidth - pan; + } + + if (toolCenterCanvas[1] < 0) { + visiblePointCanvas[1] = pan; + } else if (toolCenterCanvas[1] > clientHeight) { + visiblePointCanvas[1] = clientHeight - pan; + } + + if ( + visiblePointCanvas[0] === toolCenterCanvas[0] && + visiblePointCanvas[1] === toolCenterCanvas[1] + ) { + return; + } + + const visiblePointWorld = viewport.canvasToWorld(visiblePointCanvas); + + const deltaPointsWorld = [ + visiblePointWorld[0] - this.toolCenter[0], + visiblePointWorld[1] - this.toolCenter[1], + visiblePointWorld[2] - this.toolCenter[2], + ]; + + const camera = viewport.getCamera(); + const { focalPoint, position } = camera; + + const updatedPosition = [ + position[0] - deltaPointsWorld[0], + position[1] - deltaPointsWorld[1], + position[2] - deltaPointsWorld[2], + ]; + + const updatedFocalPoint = [ + focalPoint[0] - deltaPointsWorld[0], + focalPoint[1] - deltaPointsWorld[1], + focalPoint[2] - deltaPointsWorld[2], + ]; + + viewport.setCamera({ + focalPoint: updatedFocalPoint, + position: updatedPosition, + }); + + viewport.render(); + } + + _areViewportIdArraysEqual = (viewportIdArrayOne, viewportIdArrayTwo) => { + if (viewportIdArrayOne.length !== viewportIdArrayTwo.length) { + return false; + } + + viewportIdArrayOne.forEach((id) => { + let itemFound = false; + for (let i = 0; i < viewportIdArrayTwo.length; ++i) { + if (id === viewportIdArrayTwo[i]) { + itemFound = true; + break; + } + } + if (itemFound === false) { + return false; + } + }); + + return true; + }; + + // It filters the viewports with crosshairs and only return viewports + // that have different camera. + _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; + }; + + _filterAnnotationsByUniqueViewportOrientations = ( + enabledElement, + annotations + ) => { + const { renderingEngine, viewport } = enabledElement; + const camera = viewport.getCamera(); + const viewPlaneNormal = camera.viewPlaneNormal; + vtkMath.normalize(viewPlaneNormal); + + const otherLinkedViewportAnnotationsFromSameScene = annotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + // Filter out 3D viewports + if (otherViewport.type === Enums.ViewportType.VOLUME_3D) { + return false; + } + + return ( + viewport !== otherViewport && + // scene === otherScene && + otherViewportControllable === true + ); + } + ); + + const otherViewportsAnnotationsWithUniqueCameras = []; + // Iterate first on other viewport from the same scene linked + for ( + let i = 0; + i < otherLinkedViewportAnnotationsFromSameScene.length; + ++i + ) { + const annotation = otherLinkedViewportAnnotationsFromSameScene[i]; + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + const otherNonLinkedViewportAnnotationsFromSameScene = annotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return ( + viewport !== otherViewport && + // scene === otherScene && + otherViewportControllable !== true + ); + } + ); + + // Iterate second on other viewport from the same scene non linked + for ( + let i = 0; + i < otherNonLinkedViewportAnnotationsFromSameScene.length; + ++i + ) { + const annotation = otherNonLinkedViewportAnnotationsFromSameScene[i]; + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + // Iterate on all the viewport + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + for (let i = 0; i < otherViewportAnnotations.length; ++i) { + const annotation = otherViewportAnnotations[i]; + if ( + otherViewportsAnnotationsWithUniqueCameras.some( + (element) => element === annotation + ) + ) { + continue; + } + + const { viewportId } = annotation.data; + const otherViewport = renderingEngine.getViewport(viewportId); + const otherCamera = otherViewport.getCamera(); + const otherViewPlaneNormal = otherCamera.viewPlaneNormal; + vtkMath.normalize(otherViewPlaneNormal); + + if ( + csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || + csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) + ) { + continue; + } + + let cameraFound = false; + for ( + let jj = 0; + jj < otherViewportsAnnotationsWithUniqueCameras.length; + ++jj + ) { + const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; + const { viewportId } = annotation.data; + const stockedViewport = renderingEngine.getViewport(viewportId); + const cameraOfStocked = stockedViewport.getCamera(); + + if ( + csUtils.isEqual( + cameraOfStocked.viewPlaneNormal, + otherCamera.viewPlaneNormal, + 1e-2 + ) && + csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) + ) { + cameraFound = true; + } + } + + if (!cameraFound) { + otherViewportsAnnotationsWithUniqueCameras.push(annotation); + } + } + + return otherViewportsAnnotationsWithUniqueCameras; + }; + + _checkIfViewportsRenderingSameScene = (viewport, otherViewport) => { + const volumeIds = viewport.getAllVolumeIds(); + const otherVolumeIds = otherViewport.getAllVolumeIds(); + + return ( + volumeIds.length === otherVolumeIds.length && + volumeIds.every((id) => otherVolumeIds.includes(id)) + ); + }; + + _jump = (enabledElement, jumpWorld) => { + state.isInteractingWithTool = true; + const { viewport, renderingEngine } = enabledElement; + + const annotations = this._getAnnotations(enabledElement); + + const delta: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(jumpWorld, this.toolCenter, delta); + + // TRANSLATION + // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + + const sameScene = this._checkIfViewportsRenderingSameScene( + viewport, + otherViewport + ); + + return ( + this._getReferenceLineControllable(otherViewport.id) && sameScene + ); + } + ); + + if (viewportsAnnotationsToUpdate.length === 0) { + state.isInteractingWithTool = false; + return false; + } + + this._applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ); + + state.isInteractingWithTool = false; + + return true; + }; + + _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; + console.debug(eventDetail); + 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 { renderingEngine, 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; + const { currentPoints } = evt.detail; + const canvasCoords = currentPoints.canvas; + + if (handles.activeOperation === OPERATION.DRAG) { + this.toolCenter[0] += delta[0]; + this.toolCenter[1] += delta[1]; + this.toolCenter[2] += delta[2]; + const viewportsInfo = this._getViewportsInfo(); + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + } + + // TRANSLATION + // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene + const otherViewportAnnotations = + this._getAnnotationsForViewportsWithDifferentCameras( + enabledElement, + annotations + ); + + const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( + (annotation) => { + const { data } = annotation; + const otherViewport = renderingEngine.getViewport(data.viewportId); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + + return ( + otherViewportControllable === true && + viewportAnnotation.data.activeViewportIds.find( + (id) => id === otherViewport.id + ) + ); + } + ); + + this._applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ); + }; + + _applyDeltaShiftToSelectedViewportCameras( + renderingEngine, + viewportsAnnotationsToUpdate, + delta + ) { + // update camera for the other viewports. + // NOTE1: The lines then are rendered by the onCameraModified + // NOTE2: crosshair center are automatically updated in the onCameraModified event + viewportsAnnotationsToUpdate.forEach((annotation) => { + this._applyDeltaShiftToViewportCamera(renderingEngine, annotation, delta); + }); + } + + _applyDeltaShiftToViewportCamera( + renderingEngine: Types.IRenderingEngine, + annotation, + delta + ) { + // update camera for the other viewports. + // NOTE1: The lines then are rendered by the onCameraModified + // NOTE2: crosshair center are automatically updated in the onCameraModified event + 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, refLinePointTwo, refLinePointThree, refLinePointFour, ...] + const otherViewport = referenceLines[i][0]; + // First segment + const start1 = referenceLines[i][1]; + const end1 = referenceLines[i][2]; + // Second segment + const start2 = referenceLines[i][3]; + const end2 = referenceLines[i][4]; + + const distance1 = lineSegment.distanceToPoint(start1, end1, [ + canvasCoords[0], + canvasCoords[1], + ]); + const distance2 = lineSegment.distanceToPoint(start2, end2, [ + canvasCoords[0], + canvasCoords[1], + ]); + + if (distance1 <= proximity || distance2 <= proximity) { + viewportIdArray.push(otherViewport.id); + data.handles.activeOperation = 1; // DRAG + } + } + } + + data.activeViewportIds = [...viewportIdArray]; + + this.editData = { + annotation, + }; + return data.handles.activeOperation === 1 ? true : false; + } +} + +VolumeCroppingControlTool.toolName = 'VolumeCroppingControlTool'; +export default VolumeCroppingControlTool; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index caabfc19d0..ed2be551f3 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -21,22 +21,13 @@ import { triggerEvent, eventTarget, } from '@cornerstonejs/core'; +import { Enums as toolsEnums } from '@cornerstonejs/tools'; 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'; @@ -44,7 +35,6 @@ import { resetElementCursor, hideElementCursor, } from '../cursors/elementCursor'; -import liangBarksyClip from '../utilities/math/vec2/liangBarksyClip'; import * as lineSegment from '../utilities/math/line'; import type { @@ -55,7 +45,6 @@ import type { PublicToolProps, ToolProps, InteractionTypes, - SVGDrawingHelper, } from '../types'; import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; @@ -120,11 +109,9 @@ class VolumeCroppingTool extends AnnotationTool { defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse'], configuration: { - shadow: true, // 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, @@ -154,6 +141,13 @@ class VolumeCroppingTool extends AnnotationTool { handleRadius: 9, }, initialCropFactor: 0.2, + sphereColors: { + x: [0.0, 1.0, 0.0], // Green for X + y: [1.0, 1.0, 0.0], // Yellow for Y + z: [1.0, 0.0, 0.0], // Red for Z + default: [0.0, 0.0, 1.0], // Blue as fallback + }, + sphereRadius: 10, }, } ) { @@ -171,82 +165,8 @@ class VolumeCroppingTool extends AnnotationTool { this.picker.initializePickList(); } - /** - * 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 to add the crosshairs - * @returns viewPlaneNormal and center of viewport canvas in world space - */ - initializeViewport = ({ - renderingEngineId, - viewportId, - }: Types.IViewportId): { - normal: Types.Point3; - point: Types.Point3; - } => { - const enabledElement = getEnabledElementByIds( - viewportId, - renderingEngineId - ); - if (!enabledElement) { - return; - } - const { FrameOfReferenceUID, viewport } = enabledElement; - 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); - } - - const annotation = { - highlighted: false, - metadata: { - cameraPosition: [...position], - cameraFocalPoint: [...focalPoint], - FrameOfReferenceUID, - toolName: this.getToolName(), - }, - data: { - handles: { - // rotationPoints: [], // rotation handles, used for rotation interactions - // slabThicknessPoints: [], // slab thickness handles, used for setting the slab thickness - toolCenter: this.toolCenter, - }, - 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 - clippingPlanes: [], // clipping planes for the viewport - clippingPlaneReferenceLines: [], // clipping planes for the viewport - }, - }; - if (viewport.type !== Enums.ViewportType.VOLUME_3D) { - addAnnotation(annotation, element); - return { - normal: viewPlaneNormal, - point: viewport.canvasToWorld([ - viewport.canvas.clientWidth / 2, - viewport.canvas.clientHeight / 2, - ]), - }; - } else { - return; - } - }; - _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; - return viewports; }; @@ -258,94 +178,29 @@ class VolumeCroppingTool extends AnnotationTool { // and update the reference points accordingly. this._unsubscribeToViewportNewVolumeSet(viewportsInfo); this._subscribeToViewportNewVolumeSet(viewportsInfo); - - this._computeToolCenter(viewportsInfo); + this._initialize3DViewports(viewportsInfo); } onSetToolPassive() { const viewportsInfo = this._getViewportsInfo(); - - this._computeToolCenter(viewportsInfo); + this._initialize3DViewports(viewportsInfo); } onSetToolEnabled() { const viewportsInfo = this._getViewportsInfo(); - - // this._computeToolCenter(viewportsInfo); + this._initialize3DViewports(viewportsInfo); } onSetToolDisabled() { const viewportsInfo = this._getViewportsInfo(); - this._unsubscribeToViewportNewVolumeSet(viewportsInfo); - - // Crosshairs annotations in the state - // 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); - }); - } - }); } - resetCrosshairs = () => { - 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); - }; - addSphere(viewport, point, axis) { // Generate a unique UID for each sphere based on its axis and position const uid = `${axis}_${point.map((v) => Math.round(v)).join('_')}`; const sphereState = this.sphereStates.find((s) => s.uid === uid); - // if (!sphereState) { + // if (!sphereState) { const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); sphereSource.setRadius(15); @@ -368,15 +223,14 @@ class VolumeCroppingTool extends AnnotationTool { 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) { - //return; - // viewport.removeActor(uid); - } -*/ + + // 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; + } let color = [0.0, 1.0, 0.0]; if (axis === 'z') { @@ -384,22 +238,20 @@ class VolumeCroppingTool extends AnnotationTool { } else if (axis === 'x') { color = [1.0, 1.0, 0.0]; } - //const color = [0.0, 0.0, 1.0]; - sphereActor.getProperty().setColor(color); - //sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + /* const sphereColors = this.configuration.sphereColors || {}; - const color = sphereColors[sphereState.axis] || + const color = sphereColors[this.sphereStates[idx].axis] || sphereColors.default || [0.0, 0.0, 1.0]; - sphereActor.getProperty().setColor(...color); + sphereActor.getProperty().setColor(color); */ sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); console.debug('added sphere: ', uid, viewport.getActors()); viewport.render(); - /* } else { + /* } else { // Only update the position and source, do not create new actor/source sphereState.point = point.slice(); if (sphereState.sphereSource) { @@ -411,64 +263,17 @@ class VolumeCroppingTool extends AnnotationTool { */ } - computeToolCenter = () => { - const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); - }; - - /** - * When activated, it initializes the crosshairs. It begins by computing - * the intersection of viewports associated with the crosshairs instance. - * When all three views are accessible, the intersection (e.g., crosshairs tool centre) - * will be an exact point in space; however, with two viewports, because the - * intersection of two planes is a line, it assumes the last view is between the centre - * of the two rendering viewports. - * @param viewportsInfo Array of viewportInputs which each item containing `{viewportId, renderingEngineId}` - */ - _computeToolCenter = (viewportsInfo): void => { - // Todo: handle two same view viewport, or more than 3 viewports - const [firstViewport, secondViewport, thirdViewport, viewport3D] = - viewportsInfo; - - // Initialize first viewport - const { normal: normal1, point: point1 } = - this.initializeViewport(firstViewport); - - // Initialize second viewport - const { normal: normal2, point: point2 } = - this.initializeViewport(secondViewport); - - let normal3 = [0, 0, 0]; - let point3 = vec3.create(); - - // If there are three viewports - if (thirdViewport) { - ({ normal: normal3, point: point3 } = - this.initializeViewport(thirdViewport)); - } else { - // If there are only two views (viewport) associated with the crosshairs: - // In this situation, we don't have a third information to find the - // exact intersection, and we "assume" the third view is looking at - // a location in between the first and second view centers - vec3.add(point3, point1, point2); - vec3.scale(point3, point3, 0.5); - vec3.cross(normal3, normal1, normal2); - } - - // Planes of each viewport - const firstPlane = csUtils.planar.planeEquation(normal1, point1); - const secondPlane = csUtils.planar.planeEquation(normal2, point2); - const thirdPlane = csUtils.planar.planeEquation(normal3, point3); + _initialize3DViewports = (viewportsInfo): void => { + const [viewport3D] = viewportsInfo; - this.initializeViewport(viewport3D); - // add clipping planes const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); const volumeActors = viewport.getActors(); const imageData = volumeActors[0].actor.getMapper().getInputData(); const dimensions = imageData.getDimensions(); - console.debug('dimensions', dimensions); + const origin = imageData.getOrigin(); + //console.debug('dimensions', dimensions); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] const worldDimensions = [ Math.round(dimensions[0] * spacing[0]), @@ -476,13 +281,24 @@ class VolumeCroppingTool extends AnnotationTool { Math.round(dimensions[2] * spacing[2]), ]; //const worldDimensions = dimensions; - console.debug('worldDimensions', worldDimensions); + console.debug('worldDimensions', worldDimensions, origin); + // /* const xMin = worldDimensions[0] * -0.5; const xMax = worldDimensions[0] * 0.5; const yMin = worldDimensions[1] * -0.5; const yMax = worldDimensions[1] * 0.5; const zMin = worldDimensions[2] * -0.5; const zMax = worldDimensions[2]; + // */ + + /* + const xMin = origin[0] - worldDimensions[0] / 2; + const xMax = origin[0] + worldDimensions[0] / 2; + const yMin = origin[1] - worldDimensions[1] / 2; + const yMax = origin[1] + worldDimensions[1] / 2; + const zMin = origin[2] - (spacing[2] * (dimensions[2] - 1)) / 2; + const zMax = origin[2] + (spacing[2] * (dimensions[2] - 1)) / 2; +*/ const planes: vtkPlane[] = []; const cropFactor = 0.2; // X min plane (cuts everything left of xMin) @@ -502,12 +318,16 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, yMax - worldDimensions[1] * cropFactor, 0], normal: [0, -1, 0], }); + + // const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax]; + // const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4]; + const planeZmin = vtkPlane.newInstance({ origin: [0, 0, -zMax], normal: [0, 0, 1], }); const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, zMax], + origin: [0, 0, zMax / 4], normal: [0, 0, -1], }); @@ -532,39 +352,48 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlanes(originalPlanes); - // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); - this.addSphere( - viewport, - [xMin + worldDimensions[0] * cropFactor, (yMax + yMin) / 2, -220], - 'x' - ); - this.addSphere( - viewport, - [xMax - worldDimensions[0] * cropFactor, (yMax + yMin) / 2, -220], - 'x' - ); - this.addSphere( - viewport, - [(xMax + xMin) / 2, yMin + worldDimensions[0] * cropFactor, -220], - 'y' - ); - - this.addSphere( - viewport, - [(xMax + xMin) / 2, yMax - worldDimensions[0] * cropFactor, -220], - 'y' - ); + const sphereXminPoint = [ + xMin + worldDimensions[0] * cropFactor, + (yMax + yMin) / 2, + -220, + ]; + const sphereXmaxPoint = [ + xMax - worldDimensions[0] * cropFactor, + (yMax + yMin) / 2, + -220, + ]; + const sphereYminPoint = [ + (xMax + xMin) / 2, + yMin + worldDimensions[1] * cropFactor, + -220, + ]; + const sphereYmaxPoint = [ + (xMax + xMin) / 2, + yMax - worldDimensions[1] * cropFactor, + -220, + ]; + /* + const sphereZminPoint = [ + (xMax + xMin) / 2, + (yMax + yMin) / 2, + -300, //zMin + worldDimensions[2] * cropFactor, + ]; + const sphereZmaxPoint = [ + (xMax + xMin) / 2, + (yMax + yMin) / 2, + 100, //zMax - worldDimensions[2] * cropFactor, + ]; +*/ + const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax]; + const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4]; - this.addSphere( - viewport, - [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax], - 'z' - ); - this.addSphere( - viewport, - [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4], - 'z' - ); + // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); + this.addSphere(viewport, sphereXminPoint, 'x'); + this.addSphere(viewport, sphereXmaxPoint, 'x'); + this.addSphere(viewport, sphereYminPoint, 'y'); + this.addSphere(viewport, sphereYmaxPoint, 'y'); + this.addSphere(viewport, sphereZminPoint, 'z'); + this.addSphere(viewport, sphereZmaxPoint, 'z'); const defaultActor = viewport.getDefaultActor(); if (defaultActor?.actor) { // Cast to any to avoid type errors with different actor types @@ -576,16 +405,38 @@ class VolumeCroppingTool extends AnnotationTool { element.addEventListener('mousedown', this._onMouseDownSphere); element.addEventListener('mousemove', this._onMouseMoveSphere); element.addEventListener('mouseup', this._onMouseUpSphere); - // mapper.modified(); - // viewport.getDefaultActor().actor.modified(); + mapper.modified(); + viewport.getDefaultActor().actor.modified(); + + eventTarget.addEventListener( + Events.CROSSHAIR_TOOL_CENTER_CHANGED, + (evt) => { + console.debug('CROSSHAIR_TOOL_CENTER_CHANGED', evt); + viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + + const renderingEngine = getRenderingEngine( + viewport3D.renderingEngineId + ); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); - viewport.render(); - const toolCenter = csUtils.planar.threePlaneIntersection( - firstPlane, - secondPlane, - thirdPlane + const { toolCenter } = evt.detail; + viewport.setCamera({ + focalPoint: toolCenter, + }); + } ); - this.setToolCenter(toolCenter); + + eventTarget.addEventListener(Events.VOLUMECROPPING_TOOL_CHANGED, (evt) => { + console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.data); + viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + }); + + viewport.render(); }; /** @@ -635,8 +486,7 @@ class VolumeCroppingTool extends AnnotationTool { _onMouseDownSphere = (evt) => { const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); - const [firstViewport, secondViewport, thirdViewport, viewport3D] = - viewportsInfo; + const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); @@ -652,7 +502,7 @@ class VolumeCroppingTool extends AnnotationTool { // 20 pixels threshold this.draggingSphereIndex = i; element.style.cursor = 'grabbing'; - console.debug('grabbing', i); + console.debug('grabbing sphere index: ', i); return; } } @@ -668,8 +518,7 @@ class VolumeCroppingTool extends AnnotationTool { const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); - const [firstViewport, secondViewport, thirdViewport, viewport3D] = - viewportsInfo; + const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); @@ -713,7 +562,7 @@ class VolumeCroppingTool extends AnnotationTool { const clippingPlanes = mapper.getClippingPlanes(); //console.debug('clippingPlanes before setOrigin:', clippingPlanes); - //clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); + clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); mapper.modified(); viewport.getDefaultActor().actor.modified(); @@ -730,98 +579,6 @@ class VolumeCroppingTool extends AnnotationTool { this.draggingSphereIndex = null; }; - setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { - // prettier-ignore - this.toolCenter = toolCenter; - const viewportsInfo = this._getViewportsInfo(); - - // assuming all viewports are in the same rendering engine - triggerAnnotationRenderForViewportIds( - viewportsInfo.map(({ viewportId }) => viewportId) - ); - if (!suppressEvents) { - triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - }); - } - } - - /** - * addNewAnnotation acts as jump for the crosshairs tool. It 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 Crosshairs annotation - */ - - addNewAnnotation = ( - evt: EventTypes.InteractionEventType - ): VolumeCroppingAnnotation => { - const eventDetail = evt.detail; - const { element } = eventDetail; - - const { currentPoints } = eventDetail; - const jumpWorld = currentPoints.world; - - const enabledElement = getEnabledElement(element); - const { viewport } = enabledElement; - if (viewport.type === Enums.ViewportType.VOLUME_3D) { - const annotations = this._getAnnotations(enabledElement); - const filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); - - const { data } = filteredAnnotations[0]; - data.handles.activeOperation = OPERATION.DRAG; - return filteredAnnotations[0]; - } else { - this._jump(enabledElement, jumpWorld); - - const annotations = this._getAnnotations(enabledElement); - const filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); - - const { data } = filteredAnnotations[0]; - - const viewportIdArray = []; - // put all the draggable reference lines in the viewportIdArray - /* - for (let i = 0; i < rotationPoints.length - 1; ++i) { - const otherViewport = rotationPoints[i][1]; - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - if (!viewportControllable) { - continue; - } - viewportIdArray.push(otherViewport.id); - // rotation handles are two per viewport - 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 crosshairs annotation in the * provided element or not. A proximity is passed to the function to determine the @@ -846,142 +603,27 @@ class VolumeCroppingTool extends AnnotationTool { 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(); - }; - onCameraModified = (evt) => { - const eventDetail = evt.detail; - const { element } = eventDetail; + const { element } = evt.currentTarget + ? { element: evt.currentTarget } + : evt.detail; const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - const viewport = enabledElement.viewport as Types.IVolumeViewport; - - const annotations = this._getAnnotations(enabledElement); - const filteredToolAnnotations = - this.filterInteractableAnnotationsForElement(element, annotations); - - // viewport that the camera modified is originating from - const viewportAnnotation = - filteredToolAnnotations[0] as VolumeCroppingAnnotation; - - if (!viewportAnnotation) { + const { viewport } = enabledElement; + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); return; } + const mapper = volumeActor.getMapper(); - // -- Update the camera of other linked viewports containing the same volumeId that - // have the same camera in case of translation - // -- Update the crosshair center in world coordinates in annotation. - // This is necessary because other tools can modify the position of the slices, - // e.g. stackScroll tool at wheel scroll. So we update the coordinates of the center always here. - // NOTE: rotation and slab thickness handles are created/updated in renderTool. - const currentCamera = viewport.getCamera(); - const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; - const deltaCameraPosition: Types.Point3 = [0, 0, 0]; - vtkMath.subtract( - currentCamera.position, - oldCameraPosition, - deltaCameraPosition - ); - - const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; - const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; - vtkMath.subtract( - currentCamera.focalPoint, - oldCameraFocalPoint, - deltaCameraFocalPoint - ); - - // updated cached "previous" camera position and focal point - viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; - viewportAnnotation.metadata.cameraFocalPoint = [ - ...currentCamera.focalPoint, - ]; - - const viewportControllable = this._getReferenceLineControllable( - viewport.id - ); - - if ( - !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && - viewportControllable - ) { - // Is camera Modified a TRANSLATION or ROTATION? - let isRotation = false; - - // This is guaranteed to be the same diff for both position and focal point - // if the camera is modified by pan, zoom, or scroll BUT for rotation of - // crosshairs handles it will be different. - const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( - deltaCameraPosition, - deltaCameraFocalPoint, - 1e-3 - ); - - // NOTE: it is a translation if the the focal point and camera position shifts are the same - if (!cameraModifiedSameForPosAndFocalPoint) { - isRotation = true; - } - - const cameraModifiedInPlane = - Math.abs( - vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) - ) < 1e-2; - - // TRANSLATION - // NOTE1: if the camera modified is a result of a pan or zoom don't update the crosshair center - // NOTE2: rotation handles are updates in renderTool - if (!isRotation && !cameraModifiedInPlane) { - this.toolCenter[0] += deltaCameraPosition[0]; - this.toolCenter[1] += deltaCameraPosition[1]; - this.toolCenter[2] += deltaCameraPosition[2]; - - triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - }); - } - } + const clippingPlanes = mapper.getClippingPlanes(); - // AutoPan modification - if (this.configuration.autoPan?.enabled) { - const toolGroup = getToolGroupForViewport( - viewport.id, - renderingEngine.id - ); - - const otherViewportIds = toolGroup - .getViewportIds() - .filter((id) => id !== viewport.id); - - otherViewportIds.forEach((viewportId) => { - this._autoPanViewportIfNecessary(viewportId, renderingEngine); - }); - } - - const requireSameOrientation = false; - const viewportIdsToRender = getViewportIdsWithToolToRender( - element, - this.getToolName(), - requireSameOrientation - ); - - triggerAnnotationRenderForViewportIds(viewportIdsToRender); + // console.debug('on camera modified', viewport.getActors(), clippingPlanes); + enabledElement.viewport.render(); }; onResetCamera = (evt) => { - this.resetCrosshairs(); + console.debug('on reset camera'); }; mouseMoveCallback = ( @@ -1015,23 +657,8 @@ class VolumeCroppingTool extends AnnotationTool { // This init are necessary, because when we move the mouse they are not cleaned by _endCallback data.activeViewportIds = []; - - /* - data.handles.activeOperation = null; - - const handleNearImagePoint = this.getHandleNearImagePoint( - element, - annotation, - canvasCoords, - 6 - ); -*/ let near = false; - // if (handleNearImagePoint) { - // near = true; - // } else { near = this._pointNearTool(element, annotation, canvasCoords, 6); - // } const nearToolAndNotMarkedActive = near && !highlighted; const notNearToolAndMarkedActive = !near && highlighted; @@ -1044,370 +671,24 @@ class VolumeCroppingTool extends AnnotationTool { return imageNeedsUpdate; }; - filterInteractableAnnotationsForElement = (element, annotations) => { - if (!annotations || !annotations.length) { - return []; - } - - const enabledElement = getEnabledElement(element); - const { viewportId } = enabledElement; - // console.log(annotations); - const viewportUIDSpecificCrosshairs = annotations.filter( - (annotation) => annotation.data.viewportId === viewportId - ); + _onNewVolume = () => { + const viewportsInfo = this._getViewportsInfo(); + this._initialize3DViewports(viewportsInfo); - return viewportUIDSpecificCrosshairs; - }; + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); - /** - * 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 => { - let renderStatus = false; - const { viewport, renderingEngine } = enabledElement; - const { element } = viewport; - const annotations = this._getAnnotations(enabledElement); const camera = viewport.getCamera(); - const filteredToolAnnotations = - this.filterInteractableAnnotationsForElement(element, annotations); - - // viewport Annotation - const viewportAnnotation = filteredToolAnnotations[0]; - if (!annotations?.length || !viewportAnnotation?.data) { - // No annotations yet, and didn't just create it as we likely don't have a FrameOfReference/any data loaded yet. - 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 canvasMinDimensionLength = Math.min(clientWidth, clientHeight); - - const data = viewportAnnotation.data; - const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); - - const otherViewportAnnotations = - this._filterAnnotationsByUniqueViewportOrientations( - enabledElement, - annotations - ); - - 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.handles.toolCenter = this.toolCenter; - - const otherViewport = renderingEngine.getViewport( - data.viewportId - ) as Types.IVolumeViewport; - - const otherCamera = otherViewport.getCamera(); - - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - // get coordinates for the reference line - const { clientWidth, clientHeight } = otherViewport.canvas; - const otherCanvasDiagonalLength = Math.sqrt( - clientWidth * clientWidth + clientHeight * clientHeight - ); - const otherCanvasCenter: Types.Point2 = [ - clientWidth * 0.5, - clientHeight * 0.5, - ]; - const otherViewportCenterWorld = - otherViewport.canvasToWorld(otherCanvasCenter); - - const direction: Types.Point3 = [0, 0, 0]; - vtkMath.cross( - camera.viewPlaneNormal, - otherCamera.viewPlaneNormal, - direction - ); - vtkMath.normalize(direction); - vtkMath.multiplyScalar( - direction, - otherCanvasDiagonalLength - ); - - const pointWorld0: Types.Point3 = [0, 0, 0]; - vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); - - const pointWorld1: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); - - const pointCanvas0 = viewport.worldToCanvas(pointWorld0); - - const otherViewportCenterCanvas = viewport.worldToCanvas( - otherViewportCenterWorld - ); - - const canvasUnitVectorFromCenter = vec2.create(); - vec2.subtract( - canvasUnitVectorFromCenter, - pointCanvas0, - otherViewportCenterCanvas - ); - vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); - - // Graphic: - // Mid -> SlabThickness handle - // Short -> Rotation handle - // Long - // | - // | - // | - // Mid - // | - // | - // | - // Short - // | - // | - // | - // Long --- Mid--- Short--- Center --- Short --- Mid --- Long - // | - // | - // | - // Short - // | - // | - // | - // Mid - // | - // | - // | - // Long - const canvasVectorFromCenterLong = vec2.create(); - - vec2.scale( - canvasVectorFromCenterLong, - canvasUnitVectorFromCenter, - canvasDiagonalLength * 100 - ); - const canvasVectorFromCenterMid = vec2.create(); - vec2.scale( - canvasVectorFromCenterMid, - canvasUnitVectorFromCenter, - // to maximize the visibility of the controls, they need to be - // placed at most at half the length of the shortest side of the canvas. - // Chosen 0.4 to have some margin to the edge. - canvasMinDimensionLength * 0.4 - ); - const canvasVectorFromCenterShort = vec2.create(); - vec2.scale( - canvasVectorFromCenterShort, - canvasUnitVectorFromCenter, - // Chosen 0.2 because is half of 0.4. - canvasMinDimensionLength * 0.2 - ); - const canvasVectorFromCenterStart = vec2.create(); - const centerGap = this.configuration.referenceLinesCenterGapRadius; - vec2.scale( - canvasVectorFromCenterStart, - canvasUnitVectorFromCenter, - // Don't put a gap if the the third view is missing - otherViewportAnnotations.length === 2 ? centerGap : 0 - ); - - // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) - const refLinePointOne = vec2.create(); - const refLinePointTwo = vec2.create(); - const refLinePointThree = vec2.create(); - const refLinePointFour = vec2.create(); - - let refLinesCenter = vec2.clone(crosshairCenterCanvas); - if (!otherViewportControllable) { - refLinesCenter = vec2.clone(otherViewportCenterCanvas); - } - - vec2.add(refLinePointOne, refLinesCenter, canvasVectorFromCenterStart); - vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); - vec2.subtract( - refLinePointThree, - refLinesCenter, - canvasVectorFromCenterStart - ); - vec2.subtract( - refLinePointFour, - refLinesCenter, - canvasVectorFromCenterLong - ); - - // Clipping lines to be only included in a box (canvas), we don't want - // the lines goes beyond canvas - liangBarksyClip(refLinePointOne, refLinePointTwo, canvasBox); - liangBarksyClip(refLinePointThree, refLinePointFour, canvasBox); - referenceLines.push([ - otherViewport, - refLinePointOne, - refLinePointTwo, - refLinePointThree, - refLinePointFour, - ]); - }); - - if (viewport.type !== Enums.ViewportType.VOLUME_3D) { - data.referenceLines = referenceLines; - const viewportColor = this._getReferenceLineColor(viewport.id); - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - - referenceLines.forEach((line, lineIndex) => { - // get color for the reference line - const otherViewport = line[0]; - const viewportColor = this._getReferenceLineColor(otherViewport.id); - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const selectedViewportId = data.activeViewportIds.find( - (id) => id === otherViewport.id - ); - - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - - let lineWidth = 1; - - const lineActive = - data.handles.activeOperation !== null && - data.handles.activeOperation === OPERATION.DRAG && - selectedViewportId; - - if (lineActive) { - lineWidth = 2.5; - } - - let lineUID = `${lineIndex}`; - if (viewportControllable) { - lineUID = `${lineIndex}One`; - console.debug(lineUID); - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[1], - line[2], - { - color, - lineWidth, - } - ); - - lineUID = `${lineIndex}Two`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[3], - line[4], - { - color, - lineWidth, - } - ); - } else { - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[2], - line[4], - { - color, - lineWidth, - } - ); - } - - if (viewportControllable) { - color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles - ? window.devicePixelRatio - : 1); - let opacity = 1; - if (this.configuration.mobile?.enabled) { - handleRadius = this.configuration.mobile.handleRadius; - opacity = this.configuration.mobile.opacity; - } - if (lineActive) { - const handleUID = `${lineIndex}`; - } - } - }); - } - 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); + viewport.setCamera({ + focalPoint: camera.focalPoint, + position: [ + camera.position[0], + camera.position[1], + camera.position[2] + 1, + ], }); - - return toolGroupAnnotations; - }; - - _onNewVolume = () => { - const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); + viewport.render(); }; _unsubscribeToViewportNewVolumeSet(viewportsInfo) { @@ -1440,443 +721,6 @@ class VolumeCroppingTool extends AnnotationTool { }); } - _autoPanViewportIfNecessary( - viewportId: string, - renderingEngine: Types.IRenderingEngine - ): void { - // 1. Check if the toolCenter is outside the viewport - // 2. If it is outside, pan the viewport to fit in the toolCenter - - const viewport = renderingEngine.getViewport(viewportId); - const { clientWidth, clientHeight } = viewport.canvas; - - const toolCenterCanvas = viewport.worldToCanvas(this.toolCenter); - - // pan the viewport to fit the toolCenter in the direction - // that is out of bounds - const pan = this.configuration.autoPan.panSize; - - const visiblePointCanvas = [ - toolCenterCanvas[0], - toolCenterCanvas[1], - ]; - - if (toolCenterCanvas[0] < 0) { - visiblePointCanvas[0] = pan; - } else if (toolCenterCanvas[0] > clientWidth) { - visiblePointCanvas[0] = clientWidth - pan; - } - - if (toolCenterCanvas[1] < 0) { - visiblePointCanvas[1] = pan; - } else if (toolCenterCanvas[1] > clientHeight) { - visiblePointCanvas[1] = clientHeight - pan; - } - - if ( - visiblePointCanvas[0] === toolCenterCanvas[0] && - visiblePointCanvas[1] === toolCenterCanvas[1] - ) { - return; - } - - const visiblePointWorld = viewport.canvasToWorld(visiblePointCanvas); - - const deltaPointsWorld = [ - visiblePointWorld[0] - this.toolCenter[0], - visiblePointWorld[1] - this.toolCenter[1], - visiblePointWorld[2] - this.toolCenter[2], - ]; - - const camera = viewport.getCamera(); - const { focalPoint, position } = camera; - - const updatedPosition = [ - position[0] - deltaPointsWorld[0], - position[1] - deltaPointsWorld[1], - position[2] - deltaPointsWorld[2], - ]; - - const updatedFocalPoint = [ - focalPoint[0] - deltaPointsWorld[0], - focalPoint[1] - deltaPointsWorld[1], - focalPoint[2] - deltaPointsWorld[2], - ]; - - viewport.setCamera({ - focalPoint: updatedFocalPoint, - position: updatedPosition, - }); - - viewport.render(); - } - - _areViewportIdArraysEqual = (viewportIdArrayOne, viewportIdArrayTwo) => { - if (viewportIdArrayOne.length !== viewportIdArrayTwo.length) { - return false; - } - - viewportIdArrayOne.forEach((id) => { - let itemFound = false; - for (let i = 0; i < viewportIdArrayTwo.length; ++i) { - if (id === viewportIdArrayTwo[i]) { - itemFound = true; - break; - } - } - if (itemFound === false) { - return false; - } - }); - - return true; - }; - - // It filters the viewports with crosshairs and only return viewports - // that have different camera. - _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; - }; - - _filterAnnotationsByUniqueViewportOrientations = ( - enabledElement, - annotations - ) => { - const { renderingEngine, viewport } = enabledElement; - const camera = viewport.getCamera(); - const viewPlaneNormal = camera.viewPlaneNormal; - vtkMath.normalize(viewPlaneNormal); - - const otherLinkedViewportAnnotationsFromSameScene = annotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - // Filter out 3D viewports - if (otherViewport.type === Enums.ViewportType.VOLUME_3D) { - return false; - } - - return ( - viewport !== otherViewport && - // scene === otherScene && - otherViewportControllable === true - ); - } - ); - - const otherViewportsAnnotationsWithUniqueCameras = []; - // Iterate first on other viewport from the same scene linked - for ( - let i = 0; - i < otherLinkedViewportAnnotationsFromSameScene.length; - ++i - ) { - const annotation = otherLinkedViewportAnnotationsFromSameScene[i]; - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - const otherNonLinkedViewportAnnotationsFromSameScene = annotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - return ( - viewport !== otherViewport && - // scene === otherScene && - otherViewportControllable !== true - ); - } - ); - - // Iterate second on other viewport from the same scene non linked - for ( - let i = 0; - i < otherNonLinkedViewportAnnotationsFromSameScene.length; - ++i - ) { - const annotation = otherNonLinkedViewportAnnotationsFromSameScene[i]; - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - // Iterate on all the viewport - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - for (let i = 0; i < otherViewportAnnotations.length; ++i) { - const annotation = otherViewportAnnotations[i]; - if ( - otherViewportsAnnotationsWithUniqueCameras.some( - (element) => element === annotation - ) - ) { - continue; - } - - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - return otherViewportsAnnotationsWithUniqueCameras; - }; - - _checkIfViewportsRenderingSameScene = (viewport, otherViewport) => { - const volumeIds = viewport.getAllVolumeIds(); - const otherVolumeIds = otherViewport.getAllVolumeIds(); - - return ( - volumeIds.length === otherVolumeIds.length && - volumeIds.every((id) => otherVolumeIds.includes(id)) - ); - }; - - _jump = (enabledElement, jumpWorld) => { - state.isInteractingWithTool = true; - const { viewport, renderingEngine } = enabledElement; - - const annotations = this._getAnnotations(enabledElement); - - const delta: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(jumpWorld, this.toolCenter, delta); - - // TRANSLATION - // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - - const sameScene = this._checkIfViewportsRenderingSameScene( - viewport, - otherViewport - ); - - return ( - this._getReferenceLineControllable(otherViewport.id) && sameScene - ); - } - ); - - if (viewportsAnnotationsToUpdate.length === 0) { - state.isInteractingWithTool = false; - return false; - } - - this._applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta - ); - - state.isInteractingWithTool = false; - - return true; - }; - _activateModify = (element) => { // mobile sometimes has lingering interaction even when touchEnd triggers // this check allows for multiple handles to be active which doesn't affect @@ -1970,6 +814,7 @@ class VolumeCroppingTool extends AnnotationTool { triggerAnnotationRenderForViewportIds( viewportsInfo.map(({ viewportId }) => viewportId) ); + viewport.render; } // TRANSLATION @@ -2004,58 +849,6 @@ class VolumeCroppingTool extends AnnotationTool { ); }; - _applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta - ) { - // update camera for the other viewports. - // NOTE1: The lines then are rendered by the onCameraModified - // NOTE2: crosshair center are automatically updated in the onCameraModified event - viewportsAnnotationsToUpdate.forEach((annotation) => { - this._applyDeltaShiftToViewportCamera(renderingEngine, annotation, delta); - }); - } - - _applyDeltaShiftToViewportCamera( - renderingEngine: Types.IRenderingEngine, - annotation, - delta - ) { - // update camera for the other viewports. - // NOTE1: The lines then are rendered by the onCameraModified - // NOTE2: crosshair center are automatically updated in the onCameraModified event - 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; diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts index 31a8e224a0..186d2c7d76 100644 --- a/packages/tools/src/tools/index.ts +++ b/packages/tools/src/tools/index.ts @@ -2,6 +2,7 @@ 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'; @@ -74,6 +75,7 @@ export { PanTool, TrackballRotateTool, VolumeCroppingTool, + VolumeCroppingControlTool, DragProbeTool, WindowLevelTool, WindowLevelRegionTool, From e796bfb5c0bd4db5651cb1b1ecd906bc934bc5bd Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 20 Jun 2025 14:08:19 +0200 Subject: [PATCH 015/132] feat(volumeCropping): Add StackScrollTool and enhance viewport handling in VolumeCroppingControlTool --- .../examples/volumeCroppingTool/index.ts | 30 +- .../src/tools/VolumeCroppingControlTool.ts | 508 +++++++++--------- .../tools/src/tools/VolumeCroppingTool.ts | 143 ++--- 3 files changed, 366 insertions(+), 315 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index f076703324..40c24a0f9f 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -39,6 +39,7 @@ const { ZoomTool, PanTool, OrientationMarkerTool, + StackScrollTool, } = cornerstoneTools; const { MouseBindings } = csToolsEnums; @@ -145,10 +146,12 @@ const viewportColors = { [viewportId1]: 'rgb(200, 0, 0)', [viewportId2]: 'rgb(200, 200, 0)', [viewportId3]: 'rgb(0, 200, 0)', + [viewportId4]: 'rgb(0, 200, 200)', }; -const getReferenceLineColor = (viewportId) => - viewportColors[viewportId] || 'rgb(0, 200, 0)'; +function getReferenceLineColor(viewportId) { + return viewportColors[viewportId]; +} const viewportReferenceLineControllable = [ viewportId1, @@ -175,6 +178,7 @@ async function run() { cornerstoneTools.addTool(ZoomTool); cornerstoneTools.addTool(PanTool); cornerstoneTools.addTool(OrientationMarkerTool); + cornerstoneTools.addTool(StackScrollTool); // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ @@ -258,9 +262,7 @@ async function run() { toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); toolGroup.addTool(VolumeCroppingControlTool.toolName, { - configuration: { - getReferenceLineColor, - }, + getReferenceLineColor, }); toolGroup.setToolActive(VolumeCroppingControlTool.toolName, { bindings: [ @@ -269,14 +271,19 @@ async function run() { }, ], }); + toolGroup.addTool(StackScrollTool.toolName, { + viewportIndicators: true, + }); + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Wheel, + }, + ], + }); const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); - toolGroupVRT.addViewport(viewportId4, renderingEngineId); - - toolGroupVRT.addTool(VolumeCroppingTool.toolName); - toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); - toolGroupVRT.addTool(TrackballRotateTool.toolName); toolGroupVRT.setToolActive(TrackballRotateTool.toolName, { bindings: [ @@ -313,6 +320,9 @@ async function run() { viewport.setProperties({ preset: 'CT-Bone', }); + toolGroupVRT.addViewport(viewportId4, renderingEngineId); + toolGroupVRT.addTool(VolumeCroppingTool.toolName); + toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); viewport.render(); }); } diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f50bb14fef..599296562a 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -162,6 +162,30 @@ class VolumeCroppingControlTool extends AnnotationTool { this.picker.setPickFromList(1); this.picker.setTolerance(0); this.picker.initializePickList(); + const viewportsInfo = getToolGroup(this.toolGroupId)?.viewportsInfo; + 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(); + const imageData = volumeActors[0].actor.getMapper().getInputData(); + + // const imageData = enabledElement?.viewport?.getImageData?.(); + if (imageData) { + const dimensions = imageData.getDimensions(); + const spacing = imageData.getSpacing(); + const origin = imageData.getOrigin(); + this.toolCenter = [ + origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], + origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], + origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], + ]; + } + } } /** @@ -223,10 +247,7 @@ class VolumeCroppingControlTool extends AnnotationTool { addAnnotation(annotation, element); return { normal: viewPlaneNormal, - point: viewport.canvasToWorld([ - viewport.canvas.clientWidth / 2, - viewport.canvas.clientHeight / 2, - ]), + point: viewport.canvasToWorld([100, 100]), }; }; @@ -381,7 +402,8 @@ class VolumeCroppingControlTool extends AnnotationTool { secondPlane, thirdPlane ); - this.setToolCenter(toolCenter); + + // this.setToolCenter(toolCenter); }; setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { @@ -399,6 +421,12 @@ class VolumeCroppingControlTool extends AnnotationTool { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, }); + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + // orientation: viewport.defaultOptions.orientation, + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + // viewportId: data.viewportId, + }); } } @@ -624,7 +652,11 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolCenter[0] += deltaCameraPosition[0]; this.toolCenter[1] += deltaCameraPosition[1]; this.toolCenter[2] += deltaCameraPosition[2]; - triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + // triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + // toolGroupId: this.toolGroupId, + // toolCenter: this.toolCenter, + // }); + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, }); @@ -745,6 +777,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // No annotations yet, and didn't just create it as we likely don't have a FrameOfReference/any data loaded yet. return renderStatus; } + //console.debug(viewportAnnotation); const annotationUID = viewportAnnotation.annotationUID; @@ -760,276 +793,243 @@ class VolumeCroppingControlTool extends AnnotationTool { const canvasMinDimensionLength = Math.min(clientWidth, clientHeight); const data = viewportAnnotation.data; - console.debug('annotation data: ', data); + // console.debug('annotation data: ', data.viewportId); - if (viewport.type === Enums.ViewportType.VOLUME_3D) { - // console.debug('annotation data for 3D: ', data); - } else { - const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); + const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); - const otherViewportAnnotations = - this._filterAnnotationsByUniqueViewportOrientations( - enabledElement, - annotations - ); + const otherViewportAnnotations = + this._filterAnnotationsByUniqueViewportOrientations( + enabledElement, + annotations + ); - const referenceLines = []; + const referenceLines = []; - // get canvas information for points and lines (canvas box, canvas horizontal distances) - const canvasBox = [0, 0, clientWidth, clientHeight]; + // get canvas information for points and lines (canvas box, canvas horizontal distances) + const canvasBox = [0, 0, clientWidth, clientHeight]; - otherViewportAnnotations.forEach((annotation) => { - const { data } = annotation; + otherViewportAnnotations.forEach((annotation) => { + const { data } = annotation; - data.handles.toolCenter = this.toolCenter; + data.handles.toolCenter = this.toolCenter; - const otherViewport = renderingEngine.getViewport( - data.viewportId - ) as Types.IVolumeViewport; + const otherViewport = renderingEngine.getViewport( + data.viewportId + ) as Types.IVolumeViewport; - const otherCamera = otherViewport.getCamera(); + const otherCamera = otherViewport.getCamera(); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); + const otherViewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); - // get coordinates for the reference line - const { clientWidth, clientHeight } = otherViewport.canvas; - const otherCanvasDiagonalLength = Math.sqrt( - clientWidth * clientWidth + clientHeight * clientHeight - ); - const otherCanvasCenter: Types.Point2 = [ - clientWidth * 0.5, - clientHeight * 0.5, - ]; - const otherViewportCenterWorld = - otherViewport.canvasToWorld(otherCanvasCenter); - - const direction: Types.Point3 = [0, 0, 0]; - vtkMath.cross( - camera.viewPlaneNormal, - otherCamera.viewPlaneNormal, - direction - ); - vtkMath.normalize(direction); - vtkMath.multiplyScalar( - direction, - otherCanvasDiagonalLength - ); + // get coordinates for the reference line + const { clientWidth, clientHeight } = otherViewport.canvas; + const otherCanvasDiagonalLength = Math.sqrt( + clientWidth * clientWidth + clientHeight * clientHeight + ); + const otherCanvasCenter: Types.Point2 = [ + clientWidth * 0.5, + clientHeight * 0.5, + ]; + const otherViewportCenterWorld = + otherViewport.canvasToWorld(otherCanvasCenter); + + const direction: Types.Point3 = [0, 0, 0]; + vtkMath.cross( + camera.viewPlaneNormal, + otherCamera.viewPlaneNormal, + direction + ); + vtkMath.normalize(direction); + vtkMath.multiplyScalar( + direction, + otherCanvasDiagonalLength + ); - const pointWorld0: Types.Point3 = [0, 0, 0]; - vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); + const pointWorld0: Types.Point3 = [0, 0, 0]; + vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); - const pointWorld1: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); + const pointWorld1: Types.Point3 = [0, 0, 0]; + vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); - const pointCanvas0 = viewport.worldToCanvas(pointWorld0); + const pointCanvas0 = viewport.worldToCanvas(pointWorld0); - const otherViewportCenterCanvas = viewport.worldToCanvas( - otherViewportCenterWorld - ); + const otherViewportCenterCanvas = viewport.worldToCanvas( + otherViewportCenterWorld + ); - const canvasUnitVectorFromCenter = vec2.create(); - vec2.subtract( - canvasUnitVectorFromCenter, - pointCanvas0, - otherViewportCenterCanvas - ); - vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); + const canvasUnitVectorFromCenter = vec2.create(); + vec2.subtract( + canvasUnitVectorFromCenter, + pointCanvas0, + otherViewportCenterCanvas + ); + vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); - const canvasVectorFromCenterLong = vec2.create(); + const canvasVectorFromCenterLong = vec2.create(); - vec2.scale( - canvasVectorFromCenterLong, - canvasUnitVectorFromCenter, - canvasDiagonalLength * 100 - ); - const canvasVectorFromCenterMid = vec2.create(); - vec2.scale( - canvasVectorFromCenterMid, - canvasUnitVectorFromCenter, - // to maximize the visibility of the controls, they need to be - // placed at most at half the length of the shortest side of the canvas. - // Chosen 0.4 to have some margin to the edge. - canvasMinDimensionLength * 0.4 - ); - const canvasVectorFromCenterShort = vec2.create(); - vec2.scale( - canvasVectorFromCenterShort, - canvasUnitVectorFromCenter, - // Chosen 0.2 because is half of 0.4. - canvasMinDimensionLength * 0.2 - ); - const canvasVectorFromCenterStart = vec2.create(); - const centerGap = this.configuration.referenceLinesCenterGapRadius; - vec2.scale( - canvasVectorFromCenterStart, - canvasUnitVectorFromCenter, - // Don't put a gap if the the third view is missing - otherViewportAnnotations.length === 2 ? centerGap : 0 - ); + vec2.scale( + canvasVectorFromCenterLong, + canvasUnitVectorFromCenter, + canvasDiagonalLength * 100 + ); + const canvasVectorFromCenterStart = vec2.create(); + const centerGap = this.configuration.referenceLinesCenterGapRadius; + vec2.scale( + canvasVectorFromCenterStart, + canvasUnitVectorFromCenter, + // Don't put a gap if the the third view is missing + otherViewportAnnotations.length === 2 ? centerGap : 0 + ); - // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) - const refLinePointOne = vec2.create(); - const refLinePointTwo = vec2.create(); - const refLinePointThree = vec2.create(); - const refLinePointFour = vec2.create(); + // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) + const refLinePointTwo = vec2.create(); + const refLinePointFour = vec2.create(); - let refLinesCenter = vec2.clone(crosshairCenterCanvas); - if (!otherViewportControllable) { - refLinesCenter = vec2.clone(otherViewportCenterCanvas); - } + let refLinesCenter = vec2.clone(crosshairCenterCanvas); + if (!otherViewportControllable) { + refLinesCenter = vec2.clone(otherViewportCenterCanvas); + } + vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); + vec2.subtract( + refLinePointFour, + refLinesCenter, + canvasVectorFromCenterLong + ); - vec2.add(refLinePointOne, refLinesCenter, canvasVectorFromCenterStart); - vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); - vec2.subtract( - refLinePointThree, - refLinesCenter, - canvasVectorFromCenterStart - ); - vec2.subtract( - refLinePointFour, - refLinesCenter, - canvasVectorFromCenterLong - ); + // Clipping lines to be only included in a box (canvas), we don't want + // the lines goes beyond canvas + liangBarksyClip(refLinePointTwo, refLinePointFour, canvasBox); + referenceLines.push([ + otherViewport, + refLinePointTwo, + refLinePointTwo, + refLinePointFour, + refLinePointFour, + ]); + //console.debug(refLinePointTwo, refLinePointFour); + }); - // Clipping lines to be only included in a box (canvas), we don't want - // the lines goes beyond canvas - liangBarksyClip(refLinePointOne, refLinePointTwo, canvasBox); - liangBarksyClip(refLinePointThree, refLinePointFour, canvasBox); - referenceLines.push([ - otherViewport, - refLinePointOne, - refLinePointTwo, - refLinePointThree, - refLinePointFour, - ]); - }); + /// create new reference lines here - /// create new reference lines here + data.referenceLines = referenceLines; - data.referenceLines = referenceLines; - const viewportColor = this._getReferenceLineColor(viewport.id); - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - console.debug(color); - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - referenceLines, - }); - referenceLines.forEach((line, lineIndex) => { - // get color for the reference line - const otherViewport = line[0]; - const viewportColor = this._getReferenceLineColor(otherViewport.id); - const viewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - const selectedViewportId = data.activeViewportIds.find( - (id) => id === otherViewport.id - ); + const viewportColor = this._getReferenceLineColor(viewport.id); + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + referenceLines.forEach((line, lineIndex) => { + // get color for the reference line + const otherViewport = line[0]; + const viewportColor = this._getReferenceLineColor(otherViewport.id); + const viewportControllable = this._getReferenceLineControllable( + otherViewport.id + ); + const selectedViewportId = data.activeViewportIds.find( + (id) => id === otherViewport.id + ); - let lineWidth = 1; + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - const lineActive = - data.handles.activeOperation !== null && - data.handles.activeOperation === OPERATION.DRAG && - selectedViewportId; + let lineWidth = 1; - if (lineActive) { - lineWidth = 2.5; - } + const lineActive = + data.handles.activeOperation !== null && + data.handles.activeOperation === OPERATION.DRAG && + selectedViewportId; - let lineUID = `${lineIndex}`; - if (viewportControllable) { - lineUID = `${lineIndex}One`; - // console.debug(lineUID); - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[1], - line[2], - { - color, - lineWidth, - } - ); - - lineUID = `${lineIndex}Two`; - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[3], - line[4], - { - color, - lineWidth, - } - ); - } else { - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[2], - line[4], - { - color, - lineWidth, - } - ); - } + if (lineActive) { + lineWidth = 2.5; + } - if (viewportControllable) { - color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles - ? window.devicePixelRatio - : 1); - let opacity = 1; - if (this.configuration.mobile?.enabled) { - handleRadius = this.configuration.mobile.handleRadius; - opacity = this.configuration.mobile.opacity; - } - if (lineActive) { - const handleUID = `${lineIndex}`; + let lineUID = `${lineIndex}`; + if (viewportControllable) { + lineUID = `${lineIndex}One`; + /* + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[1], + line[2], + { + color, + lineWidth, } - } - }); - - 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( + ); +*/ + lineUID = `${lineIndex}Two`; + drawLineSvg( svgDrawingHelper, annotationUID, - circleUID, - referenceColorCoordinates as Types.Point2, - circleRadius, - { color, fill: color } + lineUID, + line[2], + line[4], + { + color, + lineWidth, + } ); + } else { + /* drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + line[2], + line[4], + { + color, + lineWidth, + } + ); */ } - return renderStatus; + if (viewportControllable) { + color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + let handleRadius = + this.configuration.handleRadius * + (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); + let opacity = 1; + if (this.configuration.mobile?.enabled) { + handleRadius = this.configuration.mobile.handleRadius; + opacity = this.configuration.mobile.opacity; + } + if (lineActive) { + const handleUID = `${lineIndex}`; + } + } + }); + + 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) => { @@ -1051,6 +1051,32 @@ class VolumeCroppingControlTool extends AnnotationTool { _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) { + const dimensions = imageData.getDimensions(); + const spacing = imageData.getSpacing(); + const origin = imageData.getOrigin(); + this.toolCenter = [ + origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], + origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], + origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], + ]; + // Update all annotations' handles.toolCenter + const annotations = getAnnotations(this.getToolName()) || []; + annotations.forEach((annotation) => { + if (annotation.data && annotation.data.handles) { + annotation.data.handles.toolCenter = [...this.toolCenter]; + } + }); + } + } + } this._computeToolCenter(viewportsInfo); }; @@ -1550,7 +1576,7 @@ class VolumeCroppingControlTool extends AnnotationTool { _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; - console.debug(eventDetail); + // console.debug(eventDetail); const { element } = eventDetail; this.editData.annotation.data.handles.activeOperation = null; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index ed2be551f3..bbc0d29d05 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -183,7 +183,7 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolPassive() { const viewportsInfo = this._getViewportsInfo(); - this._initialize3DViewports(viewportsInfo); + // this._initialize3DViewports(viewportsInfo); } onSetToolEnabled() { @@ -265,15 +265,13 @@ class VolumeCroppingTool extends AnnotationTool { _initialize3DViewports = (viewportsInfo): void => { const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const volumeActors = viewport.getActors(); const imageData = volumeActors[0].actor.getMapper().getInputData(); const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); - //console.debug('dimensions', dimensions); + console.debug('initialized 3d', dimensions); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] const worldDimensions = [ Math.round(dimensions[0] * spacing[0]), @@ -281,58 +279,58 @@ class VolumeCroppingTool extends AnnotationTool { Math.round(dimensions[2] * spacing[2]), ]; //const worldDimensions = dimensions; - console.debug('worldDimensions', worldDimensions, origin); - // /* + //console.debug('worldDimensions', worldDimensions, origin); + /* const xMin = worldDimensions[0] * -0.5; const xMax = worldDimensions[0] * 0.5; const yMin = worldDimensions[1] * -0.5; const yMax = worldDimensions[1] * 0.5; const zMin = worldDimensions[2] * -0.5; const zMax = worldDimensions[2]; - // */ + */ - /* - const xMin = origin[0] - worldDimensions[0] / 2; - const xMax = origin[0] + worldDimensions[0] / 2; - const yMin = origin[1] - worldDimensions[1] / 2; - const yMax = origin[1] + worldDimensions[1] / 2; - const zMin = origin[2] - (spacing[2] * (dimensions[2] - 1)) / 2; - const zMax = origin[2] + (spacing[2] * (dimensions[2] - 1)) / 2; + /* this.toolCenter = [ + origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], + origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], + origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], + ]; */ + const xMin = origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0]; + const xMax = origin[0] + 0.8 * (dimensions[0] - 1) * spacing[0]; + const yMin = origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1]; + const yMax = origin[1] + 0.8 * (dimensions[1] - 1) * spacing[1]; + const zMin = origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2]; + const zMax = origin[2] + 0.8 * (dimensions[2] - 1) * spacing[2]; + const planes: vtkPlane[] = []; const cropFactor = 0.2; // X min plane (cuts everything left of xMin) const planeXmin = vtkPlane.newInstance({ - origin: [xMin + worldDimensions[0] * cropFactor, 0, 0], + origin: [xMin, 0, 0], normal: [1, 0, 0], }); const planeXmax = vtkPlane.newInstance({ - origin: [xMax - worldDimensions[0] * cropFactor, 0, 0], + origin: [xMax, 0, 0], normal: [-1, 0, 0], }); const planeYmin = vtkPlane.newInstance({ - origin: [0, yMin + worldDimensions[1] * cropFactor, 0], + origin: [0, yMin, 0], normal: [0, 1, 0], }); const planeYmax = vtkPlane.newInstance({ - origin: [0, yMax - worldDimensions[1] * cropFactor, 0], + origin: [0, yMax, 0], normal: [0, -1, 0], }); - - // const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax]; - // const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4]; - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, -zMax], + origin: [0, 0, zMin], normal: [0, 0, 1], }); const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, zMax / 4], + origin: [0, 0, zMax], normal: [0, 0, -1], }); const mapper = viewport.getDefaultActor().actor.getMapper(); - planes.push(planeXmin); mapper.addClippingPlane(planeXmin); planes.push(planeXmax); @@ -352,40 +350,12 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlanes(originalPlanes); - const sphereXminPoint = [ - xMin + worldDimensions[0] * cropFactor, - (yMax + yMin) / 2, - -220, - ]; - const sphereXmaxPoint = [ - xMax - worldDimensions[0] * cropFactor, - (yMax + yMin) / 2, - -220, - ]; - const sphereYminPoint = [ - (xMax + xMin) / 2, - yMin + worldDimensions[1] * cropFactor, - -220, - ]; - const sphereYmaxPoint = [ - (xMax + xMin) / 2, - yMax - worldDimensions[1] * cropFactor, - -220, - ]; - /* - const sphereZminPoint = [ - (xMax + xMin) / 2, - (yMax + yMin) / 2, - -300, //zMin + worldDimensions[2] * cropFactor, - ]; - const sphereZmaxPoint = [ - (xMax + xMin) / 2, - (yMax + yMin) / 2, - 100, //zMax - worldDimensions[2] * cropFactor, - ]; -*/ - const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, -zMax]; - const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax / 4]; + 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]; // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); this.addSphere(viewport, sphereXminPoint, 'x'); @@ -405,9 +375,15 @@ class VolumeCroppingTool extends AnnotationTool { element.addEventListener('mousedown', this._onMouseDownSphere); element.addEventListener('mousemove', this._onMouseMoveSphere); element.addEventListener('mouseup', this._onMouseUpSphere); + mapper.modified(); viewport.getDefaultActor().actor.modified(); - + volumeActors.forEach((actorObj) => { + if (actorObj.actor && typeof actorObj.actor.modified === 'function') { + actorObj.actor.modified(); + } + }); + viewport.render(); eventTarget.addEventListener( Events.CROSSHAIR_TOOL_CENTER_CHANGED, (evt) => { @@ -428,15 +404,52 @@ class VolumeCroppingTool extends AnnotationTool { ); eventTarget.addEventListener(Events.VOLUMECROPPING_TOOL_CHANGED, (evt) => { - console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.data); + // coronal is x axis in green + // sagittal is y axis in yellow + // axial is z axis in red + console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.detail.toolCenter); + viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - }); + const toolMin = evt.detail.toolCenter; + const planeXmin = vtkPlane.newInstance({ + origin: [toolMin[0], 0, 0], + normal: [1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, toolMin[1], 0], + normal: [0, 1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, toolMin[2]], + normal: [0, 0, 1], + }); + viewport.setOriginalClippingPlane(0, planeXmin.getOrigin()); + viewport.setOriginalClippingPlane(2, planeYmin.getOrigin()); + viewport.setOriginalClippingPlane(4, planeZmin.getOrigin()); - viewport.render(); + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + + const clippingPlanes = mapper.getClippingPlanes(); + //console.debug('clippingPlanes before setOrigin:', clippingPlanes); + clippingPlanes[0].setOrigin(planeXmin.getOrigin()); + clippingPlanes[2].setOrigin(planeYmin.getOrigin()); + clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + //viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + mapper.modified(); + + viewport.getDefaultActor().actor.modified(); + volumeActor.modified(); + viewport.render(); + }); }; /** @@ -565,7 +578,9 @@ class VolumeCroppingTool extends AnnotationTool { clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); mapper.modified(); + viewport.getDefaultActor().actor.modified(); + volumeActor.modified(); viewport.render(); } }; @@ -673,7 +688,6 @@ class VolumeCroppingTool extends AnnotationTool { _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); - this._initialize3DViewports(viewportsInfo); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); @@ -688,6 +702,7 @@ class VolumeCroppingTool extends AnnotationTool { camera.position[2] + 1, ], }); + this._initialize3DViewports(viewportsInfo); viewport.render(); }; From b512865098fa7055dc0924fe872576bda61c3d1c Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 20 Jun 2025 16:51:13 +0200 Subject: [PATCH 016/132] feat(volumeCropping): Add viewport indicators and sphere moved event handling in VolumeCropping tools --- .../examples/volumeCroppingTool/index.ts | 3 + packages/tools/src/enums/Events.ts | 2 + .../src/tools/VolumeCroppingControlTool.ts | 23 +++++- .../tools/src/tools/VolumeCroppingTool.ts | 74 ++++++++++++------- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 40c24a0f9f..efc3e6db12 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -263,6 +263,7 @@ async function run() { toolGroup.addViewport(viewportId3, renderingEngineId); toolGroup.addTool(VolumeCroppingControlTool.toolName, { getReferenceLineColor, + viewportIndicators: true, }); toolGroup.setToolActive(VolumeCroppingControlTool.toolName, { bindings: [ @@ -323,6 +324,8 @@ async function run() { toolGroupVRT.addViewport(viewportId4, renderingEngineId); toolGroupVRT.addTool(VolumeCroppingTool.toolName); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); + // Set zoom to 1.5x + viewport.setZoom(1.5); viewport.render(); }); } diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index 4c722823bb..9326cdd51e 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -37,6 +37,8 @@ enum Events { VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', + VOLUMECROPPING_SPHERE_MOVED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_SPHERE_MOVED', + /////////////////////////////////////// // Annotations /////////////////////////////////////// diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 599296562a..17b5ce223b 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -163,6 +163,12 @@ class VolumeCroppingControlTool extends AnnotationTool { this.picker.setTolerance(0); this.picker.initializePickList(); const viewportsInfo = getToolGroup(this.toolGroupId)?.viewportsInfo; + + eventTarget.addEventListener( + Events.VOLUMECROPPING_SPHERE_MOVED, + this._onSphereMoved + ); + if (viewportsInfo && viewportsInfo.length > 0) { const { viewportId, renderingEngineId } = viewportsInfo[0]; const enabledElement = getEnabledElementByIds( @@ -484,7 +490,7 @@ class VolumeCroppingControlTool extends AnnotationTool { return filteredAnnotations[0]; // here should be the nearPoint checker and update handler } else { - this._jump(enabledElement, jumpWorld); + //this._jump(enabledElement, jumpWorld); const annotations = this._getAnnotations(enabledElement); const filteredAnnotations = this.filterInteractableAnnotationsForElement( @@ -1049,6 +1055,21 @@ class VolumeCroppingControlTool extends AnnotationTool { return toolGroupAnnotations; }; + _onSphereMoved = (evt) => { + const eventCenter = evt.detail.toolCenter; + console.debug('Sphere moved event received', eventCenter); + let newCenter = [0, 0, 0]; + if (evt.detail.axis === 'x') { + newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'y') { + newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'z') { + newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + } + this.setToolCenter(newCenter, true); + // this.toolCenter = evt.detail.toolCenter; + }; + _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); if (viewportsInfo && viewportsInfo.length > 0) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index bbc0d29d05..e2be015743 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -271,30 +271,7 @@ class VolumeCroppingTool extends AnnotationTool { const imageData = volumeActors[0].actor.getMapper().getInputData(); const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); - console.debug('initialized 3d', dimensions); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const worldDimensions = [ - Math.round(dimensions[0] * spacing[0]), - Math.round(dimensions[1] * spacing[1]), - Math.round(dimensions[2] * spacing[2]), - ]; - //const worldDimensions = dimensions; - //console.debug('worldDimensions', worldDimensions, origin); - /* - const xMin = worldDimensions[0] * -0.5; - const xMax = worldDimensions[0] * 0.5; - const yMin = worldDimensions[1] * -0.5; - const yMax = worldDimensions[1] * 0.5; - const zMin = worldDimensions[2] * -0.5; - const zMax = worldDimensions[2]; - */ - - /* this.toolCenter = [ - origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], - origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], - origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], - ]; -*/ const xMin = origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0]; const xMax = origin[0] + 0.8 * (dimensions[0] - 1) * spacing[0]; const yMin = origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1]; @@ -437,13 +414,39 @@ class VolumeCroppingTool extends AnnotationTool { return; } const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - //console.debug('clippingPlanes before setOrigin:', clippingPlanes); clippingPlanes[0].setOrigin(planeXmin.getOrigin()); clippingPlanes[2].setOrigin(planeYmin.getOrigin()); clippingPlanes[4].setOrigin(planeZmin.getOrigin()); - //viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + + const currentPoint = this.sphereStates[0].point; + const newPointXMin = [ + planeXmin.getOrigin()[0], + currentPoint[1], + currentPoint[2], + ]; + this.sphereStates[0].point = newPointXMin; + this.sphereStates[0].sphereSource.setCenter(newPointXMin); + this.sphereStates[0].sphereSource.modified(); + /* + const newPointYMin = [ + currentPoint[0], + planeYmin.getOrigin()[1], + currentPoint[2], + ]; + this.sphereStates[2].point = newPointYMin; + this.sphereStates[2].sphereSource.setCenter(newPointYMin); + this.sphereStates[2].sphereSource.modified(); + const newPointZmin = [ + currentPoint[0], + currentPoint[1], + planeZmin.getOrigin()[2], + ]; + this.sphereStates[4].point = newPointZmin; + this.sphereStates[4].sphereSource.setCenter(newPointZmin); + this.sphereStates[4].sphereSource.modified(); + + */ mapper.modified(); viewport.getDefaultActor().actor.modified(); @@ -574,7 +577,7 @@ class VolumeCroppingTool extends AnnotationTool { const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); - //console.debug('clippingPlanes before setOrigin:', clippingPlanes); + clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); mapper.modified(); @@ -582,6 +585,23 @@ class VolumeCroppingTool extends AnnotationTool { viewport.getDefaultActor().actor.modified(); volumeActor.modified(); viewport.render(); + const sphereMoved = newPoint; + console.debug(clippingPlanes[0].getOrigin()); + if (sphereState.axis === 'x') { + sphereMoved[0] = pickedPoint[0]; + sphereMoved[1] = clippingPlanes[0].getOrigin()[1]; + sphereMoved[2] = clippingPlanes[0].getOrigin()[2]; + } else if (sphereState.axis === 'y') { + sphereMoved[1] = pickedPoint[1]; + } else if (sphereState.axis === 'z') { + sphereMoved[2] = pickedPoint[2]; + } + + /// Send event with the new point + triggerEvent(eventTarget, Events.VOLUMECROPPING_SPHERE_MOVED, { + toolCenter: sphereMoved, + axis: sphereState.axis, + }); } }; From f247511efa7dbdb0ce552c4cc5f09c7cdd1f44ba Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 21 Jun 2025 10:51:11 +0200 Subject: [PATCH 017/132] feat(volumeCropping): Enhance VolumeCroppingTool with additional mouse bindings and improve annotation handling --- .../examples/volumeCroppingTool/index.ts | 3 + .../src/tools/VolumeCroppingControlTool.ts | 40 +------- .../tools/src/tools/VolumeCroppingTool.ts | 91 +++++++------------ 3 files changed, 39 insertions(+), 95 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index efc3e6db12..999128883b 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -280,6 +280,9 @@ async function run() { { mouseButton: MouseBindings.Wheel, }, + { + mouseButton: MouseBindings.Secondary, + }, ], }); diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 17b5ce223b..f4b9aea0b4 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1,11 +1,5 @@ import { vec2, vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; -import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; -//import * as cornerstoneTools from '@cornerstonejs/tools'; -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 type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import { AnnotationTool } from './base'; @@ -110,7 +104,6 @@ class VolumeCroppingControlTool extends AnnotationTool { // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; - picker: vtkCellPicker; constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { @@ -158,10 +151,7 @@ class VolumeCroppingControlTool extends AnnotationTool { this._getReferenceLineControllable = toolProps.configuration?.getReferenceLineControllable || defaultReferenceLineControllable; - this.picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); - this.picker.setPickFromList(1); - this.picker.setTolerance(0); - this.picker.initializePickList(); + const viewportsInfo = getToolGroup(this.toolGroupId)?.viewportsInfo; eventTarget.addEventListener( @@ -751,7 +741,6 @@ class VolumeCroppingControlTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewportId } = enabledElement; - // console.log(annotations); const viewportUIDSpecificCrosshairs = annotations.filter( (annotation) => annotation.data.viewportId === viewportId ); @@ -953,19 +942,6 @@ class VolumeCroppingControlTool extends AnnotationTool { let lineUID = `${lineIndex}`; if (viewportControllable) { lineUID = `${lineIndex}One`; - /* - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[1], - line[2], - { - color, - lineWidth, - } - ); -*/ lineUID = `${lineIndex}Two`; drawLineSvg( svgDrawingHelper, @@ -978,18 +954,6 @@ class VolumeCroppingControlTool extends AnnotationTool { lineWidth, } ); - } else { - /* drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - line[2], - line[4], - { - color, - lineWidth, - } - ); */ } if (viewportControllable) { @@ -1057,7 +1021,6 @@ class VolumeCroppingControlTool extends AnnotationTool { _onSphereMoved = (evt) => { const eventCenter = evt.detail.toolCenter; - console.debug('Sphere moved event received', eventCenter); let newCenter = [0, 0, 0]; if (evt.detail.axis === 'x') { newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; @@ -1067,7 +1030,6 @@ class VolumeCroppingControlTool extends AnnotationTool { newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; } this.setToolCenter(newCenter, true); - // this.toolCenter = evt.detail.toolCenter; }; _onNewVolume = () => { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index e2be015743..991a62ec17 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -2,7 +2,6 @@ import { vec2, vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; -//import * as cornerstoneTools from '@cornerstonejs/tools'; 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'; @@ -200,7 +199,6 @@ class VolumeCroppingTool extends AnnotationTool { // Generate a unique UID for each sphere based on its axis and position const uid = `${axis}_${point.map((v) => Math.round(v)).join('_')}`; const sphereState = this.sphereStates.find((s) => s.uid === uid); - // if (!sphereState) { const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); sphereSource.setRadius(15); @@ -238,7 +236,6 @@ class VolumeCroppingTool extends AnnotationTool { } else if (axis === 'x') { color = [1.0, 1.0, 0.0]; } - //const color = [0.0, 0.0, 1.0]; sphereActor.getProperty().setColor(color); /* @@ -249,18 +246,8 @@ class VolumeCroppingTool extends AnnotationTool { */ sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); - console.debug('added sphere: ', uid, viewport.getActors()); + // console.debug('added sphere: ', uid, viewport.getActors()); viewport.render(); - /* } else { - // Only update the position and source, do not create new actor/source - sphereState.point = point.slice(); - if (sphereState.sphereSource) { - // sphereState.sphereSource.setCenter(point); - console.debug('updated sphere: ', uid); - viewport.render(); - } - } - */ } _initialize3DViewports = (viewportsInfo): void => { @@ -272,15 +259,18 @@ class VolumeCroppingTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const xMin = origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0]; - const xMax = origin[0] + 0.8 * (dimensions[0] - 1) * spacing[0]; - const yMin = origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1]; - const yMax = origin[1] + 0.8 * (dimensions[1] - 1) * spacing[1]; - const zMin = origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2]; + const cropFactor = 0.2; + const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; + const xMax = + origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; + const yMin = origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1]; + const yMax = + origin[1] + (1 - cropFactor) * (dimensions[1] - 1) * spacing[1]; + const zMin = origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2]; const zMax = origin[2] + 0.8 * (dimensions[2] - 1) * spacing[2]; const planes: vtkPlane[] = []; - const cropFactor = 0.2; + // X min plane (cuts everything left of xMin) const planeXmin = vtkPlane.newInstance({ origin: [xMin, 0, 0], @@ -334,7 +324,6 @@ class VolumeCroppingTool extends AnnotationTool { const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMin]; const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax]; - // this.addSphere(viewport, [xMin + worldDimensions[0] * cropFactor, 0, 0]); this.addSphere(viewport, sphereXminPoint, 'x'); this.addSphere(viewport, sphereXmaxPoint, 'x'); this.addSphere(viewport, sphereYminPoint, 'y'); @@ -384,7 +373,7 @@ class VolumeCroppingTool extends AnnotationTool { // coronal is x axis in green // sagittal is y axis in yellow // axial is z axis in red - console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.detail.toolCenter); + //console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.detail.toolCenter); viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; @@ -416,41 +405,23 @@ class VolumeCroppingTool extends AnnotationTool { const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[0].setOrigin(planeXmin.getOrigin()); - clippingPlanes[2].setOrigin(planeYmin.getOrigin()); - clippingPlanes[4].setOrigin(planeZmin.getOrigin()); - - const currentPoint = this.sphereStates[0].point; - const newPointXMin = [ + this.sphereStates[0].sphereSource.setCenter( planeXmin.getOrigin()[0], - currentPoint[1], - currentPoint[2], - ]; - this.sphereStates[0].point = newPointXMin; - this.sphereStates[0].sphereSource.setCenter(newPointXMin); - this.sphereStates[0].sphereSource.modified(); - /* - const newPointYMin = [ - currentPoint[0], + this.sphereStates[0].point[1], + this.sphereStates[0].point[2] + ); + clippingPlanes[2].setOrigin(planeYmin.getOrigin()); + this.sphereStates[2].sphereSource.setCenter( + this.sphereStates[2].point[0], planeYmin.getOrigin()[1], - currentPoint[2], - ]; - this.sphereStates[2].point = newPointYMin; - this.sphereStates[2].sphereSource.setCenter(newPointYMin); - this.sphereStates[2].sphereSource.modified(); - const newPointZmin = [ - currentPoint[0], - currentPoint[1], - planeZmin.getOrigin()[2], - ]; - this.sphereStates[4].point = newPointZmin; - this.sphereStates[4].sphereSource.setCenter(newPointZmin); - this.sphereStates[4].sphereSource.modified(); - - */ - mapper.modified(); - - viewport.getDefaultActor().actor.modified(); - volumeActor.modified(); + this.sphereStates[2].point[2] + ); + clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + this.sphereStates[4].sphereSource.setCenter( + this.sphereStates[4].point[0], + this.sphereStates[4].point[1], + planeZmin.getOrigin()[2] + ); viewport.render(); }); }; @@ -546,10 +517,19 @@ class VolumeCroppingTool extends AnnotationTool { const displayCoords = viewport.getVtkDisplayCoords([x, y]); // Use the picker to get the 3D coordinates + + // --- Remove clipping planes before picking otherwise we cannot back out of the volume + const mapper = viewport.getDefaultActor().actor.getMapper(); + const originalClippingPlanes = mapper.getClippingPlanes().slice(); + mapper.removeAllClippingPlanes(); this.picker.pick( [displayCoords[0], displayCoords[1], 0], viewport.getRenderer() ); + // --- Restore clipping planes after picking --- + originalClippingPlanes.forEach((plane) => { + mapper.addClippingPlane(plane); + }); const pickedPositions = this.picker.getPickedPositions(); if (pickedPositions.length > 0) { const pickedPoint = pickedPositions[0]; @@ -586,7 +566,6 @@ class VolumeCroppingTool extends AnnotationTool { volumeActor.modified(); viewport.render(); const sphereMoved = newPoint; - console.debug(clippingPlanes[0].getOrigin()); if (sphereState.axis === 'x') { sphereMoved[0] = pickedPoint[0]; sphereMoved[1] = clippingPlanes[0].getOrigin()[1]; From 1ea4504ab0252a7af3df49447175d0492ea0c3b2 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 21 Jun 2025 11:22:04 +0200 Subject: [PATCH 018/132] fix(volumeCropping): Update event names for VolumeCroppingControlTool and adjust zoom level in example --- .../examples/volumeCroppingTool/index.ts | 7 +- .../src/tools/VolumeCroppingControlTool.ts | 4 +- .../tools/src/tools/VolumeCroppingTool.ts | 138 ++++++++---------- 3 files changed, 68 insertions(+), 81 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 999128883b..e2acac90cc 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -312,6 +312,9 @@ async function run() { }, ], }); + //toolGroupVRT.addTool(OrientationMarkerTool.toolName); + // toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); + const isMobile = window.matchMedia('(any-pointer:coarse)').matches; // Render the image const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; @@ -327,8 +330,8 @@ async function run() { toolGroupVRT.addViewport(viewportId4, renderingEngineId); toolGroupVRT.addTool(VolumeCroppingTool.toolName); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); - // Set zoom to 1.5x - viewport.setZoom(1.5); + // Set zoom to 1.3x + viewport.setZoom(1.3); viewport.render(); }); } diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f4b9aea0b4..ef31086913 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -417,7 +417,7 @@ class VolumeCroppingControlTool extends AnnotationTool { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, }); - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { // orientation: viewport.defaultOptions.orientation, toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, @@ -652,7 +652,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // toolGroupId: this.toolGroupId, // toolCenter: this.toolCenter, // }); - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, }); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 991a62ec17..e1925bd882 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,6 +1,4 @@ import { vec2, vec3 } from 'gl-matrix'; -import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; @@ -369,61 +367,66 @@ class VolumeCroppingTool extends AnnotationTool { } ); - eventTarget.addEventListener(Events.VOLUMECROPPING_TOOL_CHANGED, (evt) => { - // coronal is x axis in green - // sagittal is y axis in yellow - // axial is z axis in red - //console.debug('VOLUMECROPPING_TOOL_CHANGED', evt.detail.toolCenter); - - viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const toolMin = evt.detail.toolCenter; - const planeXmin = vtkPlane.newInstance({ - origin: [toolMin[0], 0, 0], - normal: [1, 0, 0], - }); - const planeYmin = vtkPlane.newInstance({ - origin: [0, toolMin[1], 0], - normal: [0, 1, 0], - }); - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, toolMin[2]], - normal: [0, 0, 1], - }); - viewport.setOriginalClippingPlane(0, planeXmin.getOrigin()); - viewport.setOriginalClippingPlane(2, planeYmin.getOrigin()); - viewport.setOriginalClippingPlane(4, planeZmin.getOrigin()); + eventTarget.addEventListener( + Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, + (evt) => { + // coronal is x axis in green + // sagittal is y axis in yellow + // axial is z axis in red + //console.debug('VOLUMECROPPINGCONTROL_TOOL_CHANGED', evt.detail.toolCenter); - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; + viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + + const renderingEngine = getRenderingEngine( + viewport3D.renderingEngineId + ); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const toolMin = evt.detail.toolCenter; + const planeXmin = vtkPlane.newInstance({ + origin: [toolMin[0], 0, 0], + normal: [1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, toolMin[1], 0], + normal: [0, 1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, toolMin[2]], + normal: [0, 0, 1], + }); + viewport.setOriginalClippingPlane(0, planeXmin.getOrigin()); + viewport.setOriginalClippingPlane(2, planeYmin.getOrigin()); + viewport.setOriginalClippingPlane(4, planeZmin.getOrigin()); + + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[0].setOrigin(planeXmin.getOrigin()); + this.sphereStates[0].sphereSource.setCenter( + planeXmin.getOrigin()[0], + this.sphereStates[0].point[1], + this.sphereStates[0].point[2] + ); + clippingPlanes[2].setOrigin(planeYmin.getOrigin()); + this.sphereStates[2].sphereSource.setCenter( + this.sphereStates[2].point[0], + planeYmin.getOrigin()[1], + this.sphereStates[2].point[2] + ); + clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + this.sphereStates[4].sphereSource.setCenter( + this.sphereStates[4].point[0], + this.sphereStates[4].point[1], + planeZmin.getOrigin()[2] + ); + viewport.render(); } - const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[0].setOrigin(planeXmin.getOrigin()); - this.sphereStates[0].sphereSource.setCenter( - planeXmin.getOrigin()[0], - this.sphereStates[0].point[1], - this.sphereStates[0].point[2] - ); - clippingPlanes[2].setOrigin(planeYmin.getOrigin()); - this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point[0], - planeYmin.getOrigin()[1], - this.sphereStates[2].point[2] - ); - clippingPlanes[4].setOrigin(planeZmin.getOrigin()); - this.sphereStates[4].sphereSource.setCenter( - this.sphereStates[4].point[0], - this.sphereStates[4].point[1], - planeZmin.getOrigin()[2] - ); - viewport.render(); - }); + ); }; /** @@ -534,9 +537,8 @@ class VolumeCroppingTool extends AnnotationTool { if (pickedPositions.length > 0) { const pickedPoint = pickedPositions[0]; const sphereState = this.sphereStates[this.draggingSphereIndex]; - // Restrict movement to the sphere's axis only const newPoint = [...sphereState.point]; - + // Restrict movement to the sphere's axis only if (sphereState.axis === 'x') { newPoint[0] = pickedPoint[0]; } else if (sphereState.axis === 'y') { @@ -548,37 +550,19 @@ class VolumeCroppingTool extends AnnotationTool { sphereState.point = newPoint; sphereState.sphereSource.setCenter(newPoint); sphereState.sphereSource.modified(); - const volumeActor = viewport.getDefaultActor()?.actor; if (!volumeActor) { console.warn('No volume actor found'); return; } const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); - mapper.modified(); - - viewport.getDefaultActor().actor.modified(); - volumeActor.modified(); viewport.render(); - const sphereMoved = newPoint; - if (sphereState.axis === 'x') { - sphereMoved[0] = pickedPoint[0]; - sphereMoved[1] = clippingPlanes[0].getOrigin()[1]; - sphereMoved[2] = clippingPlanes[0].getOrigin()[2]; - } else if (sphereState.axis === 'y') { - sphereMoved[1] = pickedPoint[1]; - } else if (sphereState.axis === 'z') { - sphereMoved[2] = pickedPoint[2]; - } - /// Send event with the new point - triggerEvent(eventTarget, Events.VOLUMECROPPING_SPHERE_MOVED, { - toolCenter: sphereMoved, + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + toolCenter: newPoint, axis: sphereState.axis, }); } From 6d8f2799156ad0df8c632ef7d3ba895cfde090ea Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 21 Jun 2025 16:20:30 +0200 Subject: [PATCH 019/132] feat(volumeCropping): Update VolumeCroppingTool and VolumeCroppingControlTool to enhance sphere handling and event management --- .../examples/volumeCroppingTool/index.ts | 4 +- packages/tools/src/enums/Events.ts | 4 +- .../src/tools/VolumeCroppingControlTool.ts | 23 ++++++----- .../tools/src/tools/VolumeCroppingTool.ts | 40 ++++++++++++++++++- 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index e2acac90cc..7a8d154cb1 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -328,7 +328,9 @@ async function run() { preset: 'CT-Bone', }); toolGroupVRT.addViewport(viewportId4, renderingEngineId); - toolGroupVRT.addTool(VolumeCroppingTool.toolName); + toolGroupVRT.addTool(VolumeCroppingTool.toolName, { + sphereRadius: 10, + }); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); // Set zoom to 1.3x viewport.setZoom(1.3); diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index 9326cdd51e..f32a532921 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -35,9 +35,9 @@ enum Events { CROSSHAIR_TOOL_CENTER_CHANGED = 'CORNERSTONE_TOOLS_CROSSHAIR_TOOL_CENTER_CHANGED', - VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', + VOLUMECROPPINGCONTROL_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPINGCONTROL_TOOL_CHANGED', - VOLUMECROPPING_SPHERE_MOVED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_SPHERE_MOVED', + VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', /////////////////////////////////////// // Annotations diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index ef31086913..d6b80d8cda 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -155,7 +155,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const viewportsInfo = getToolGroup(this.toolGroupId)?.viewportsInfo; eventTarget.addEventListener( - Events.VOLUMECROPPING_SPHERE_MOVED, + Events.VOLUMECROPPING_TOOL_CHANGED, this._onSphereMoved ); @@ -1020,16 +1020,19 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _onSphereMoved = (evt) => { - const eventCenter = evt.detail.toolCenter; - let newCenter = [0, 0, 0]; - if (evt.detail.axis === 'x') { - newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; - } else if (evt.detail.axis === 'y') { - newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; - } else if (evt.detail.axis === 'z') { - newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { + // only update for min spheres + let newCenter = [0, 0, 0]; + const eventCenter = evt.detail.toolCenter; + if (evt.detail.axis === 'x') { + newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'y') { + newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'z') { + newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + } + this.setToolCenter(newCenter, true); } - this.setToolCenter(newCenter, true); }; _onNewVolume = () => { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index e1925bd882..66d0eeed7a 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -199,7 +199,11 @@ class VolumeCroppingTool extends AnnotationTool { const sphereState = this.sphereStates.find((s) => s.uid === uid); const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); - sphereSource.setRadius(15); + const sphereRadius = + this.configuration.sphereRadius !== undefined + ? this.configuration.sphereRadius + : 15; + sphereSource.setRadius(sphereRadius); const sphereMapper = vtkMapper.newInstance(); sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); @@ -541,10 +545,43 @@ class VolumeCroppingTool extends AnnotationTool { // Restrict movement to the sphere's axis only if (sphereState.axis === 'x') { newPoint[0] = pickedPoint[0]; + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex + ); + const newXCenter = (otherXSphere.point[0] + pickedPoint[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'x') { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); } else if (sphereState.axis === 'y') { newPoint[1] = pickedPoint[1]; + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex + ); + const newYCenter = (otherYSphere.point[1] + pickedPoint[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'y') { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); } else if (sphereState.axis === 'z') { newPoint[2] = pickedPoint[2]; + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex + ); + const newZCenter = (otherZSphere.point[2] + pickedPoint[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'z') { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); } sphereState.point = newPoint; @@ -564,6 +601,7 @@ class VolumeCroppingTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { toolCenter: newPoint, axis: sphereState.axis, + draggingSphereIndex: this.draggingSphereIndex, }); } }; From a5bc9f230ec652688021b76aa06409d1edb644a8 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 22 Jun 2025 13:41:45 +0200 Subject: [PATCH 020/132] other sphere movement --- .../tools/src/tools/VolumeCroppingTool.ts | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 66d0eeed7a..9ce508b21c 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -411,23 +411,58 @@ class VolumeCroppingTool extends AnnotationTool { const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[0].setOrigin(planeXmin.getOrigin()); + clippingPlanes[2].setOrigin(planeYmin.getOrigin()); + clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + this.sphereStates[0].sphereSource.setCenter( planeXmin.getOrigin()[0], this.sphereStates[0].point[1], this.sphereStates[0].point[2] ); - clippingPlanes[2].setOrigin(planeYmin.getOrigin()); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== 0 + ); + const newXCenter = + (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'x') { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + } + }); this.sphereStates[2].sphereSource.setCenter( this.sphereStates[2].point[0], planeYmin.getOrigin()[1], this.sphereStates[2].point[2] ); - clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== 2 + ); + const newYCenter = + (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'y') { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + } + }); this.sphereStates[4].sphereSource.setCenter( this.sphereStates[4].point[0], this.sphereStates[4].point[1], planeZmin.getOrigin()[2] ); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== 4 + ); + const newZCenter = + (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'z') { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + } + }); + viewport.render(); } ); @@ -540,6 +575,7 @@ class VolumeCroppingTool extends AnnotationTool { const pickedPositions = this.picker.getPickedPositions(); if (pickedPositions.length > 0) { const pickedPoint = pickedPositions[0]; + const sphereState = this.sphereStates[this.draggingSphereIndex]; const newPoint = [...sphereState.point]; // Restrict movement to the sphere's axis only @@ -717,11 +753,7 @@ class VolumeCroppingTool extends AnnotationTool { const camera = viewport.getCamera(); viewport.setCamera({ focalPoint: camera.focalPoint, - position: [ - camera.position[0], - camera.position[1], - camera.position[2] + 1, - ], + position: [camera.position[0], camera.position[1], camera.position[2]], }); this._initialize3DViewports(viewportsInfo); viewport.render(); From c1a69b1a17b43c1fb31c6c2b89ca2a8ac3ab4e56 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:45:07 +0200 Subject: [PATCH 021/132] feat(volumeCropping): Add sphere color customization and viewport orientation handling in VolumeCropping tools --- .../examples/volumeCroppingTool/index.ts | 6 ++++ .../src/tools/VolumeCroppingControlTool.ts | 5 ++++ .../tools/src/tools/VolumeCroppingTool.ts | 29 ++++++++++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 7a8d154cb1..36507790b3 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -330,6 +330,12 @@ async function run() { toolGroupVRT.addViewport(viewportId4, renderingEngineId); toolGroupVRT.addTool(VolumeCroppingTool.toolName, { sphereRadius: 10, + sphereColor: { + x: [1, 0, 0], // Red for X axis + y: [0, 1, 0], // Green for Y axis + z: [0, 0, 1], // Blue for Z axis + corner: [1, 1, 0], // Yellow for others (optional) + }, }); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); // Set zoom to 1.3x diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index d6b80d8cda..d8a2293138 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -652,9 +652,14 @@ class VolumeCroppingControlTool extends AnnotationTool { // toolGroupId: this.toolGroupId, // toolCenter: this.toolCenter, // }); + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, + viewportOrientation: [ + viewportAnnotation.data.referenceLines[0][0].options.orientation, + viewportAnnotation.data.referenceLines[1][0].options.orientation, + ], }); } } diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 9ce508b21c..771494c76d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -79,6 +79,15 @@ const OPERATION = { SLAB: 3, }; +const PLANEINDEX = { + XMIN: 0, + XMAX: 1, + YMIN: 2, + YMAX: 3, + ZMIN: 4, + ZMAX: 5, +}; + /** * VolumeCroppingTool is a tool that provides reference lines between different viewports * of a toolGroup. Using crosshairs, you can jump to a specific location in one @@ -246,9 +255,9 @@ class VolumeCroppingTool extends AnnotationTool { sphereColors.default || [0.0, 0.0, 1.0]; sphereActor.getProperty().setColor(color); */ + sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); - // console.debug('added sphere: ', uid, viewport.getActors()); viewport.render(); } @@ -425,11 +434,16 @@ class VolumeCroppingTool extends AnnotationTool { const newXCenter = (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'x') { + if ( + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') + ) { state.point[0] = newXCenter; state.sphereSource.setCenter(state.point); } }); + + // y this.sphereStates[2].sphereSource.setCenter( this.sphereStates[2].point[0], planeYmin.getOrigin()[1], @@ -441,11 +455,15 @@ class VolumeCroppingTool extends AnnotationTool { const newYCenter = (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'y') { + if ( + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') + ) { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); } }); + // z this.sphereStates[4].sphereSource.setCenter( this.sphereStates[4].point[0], this.sphereStates[4].point[1], @@ -457,7 +475,10 @@ class VolumeCroppingTool extends AnnotationTool { const newZCenter = (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'z') { + if ( + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') + ) { state.point[2] = newZCenter; state.sphereSource.setCenter(state.point); } From 946df82721d9e191037e2b35da2ea8a670b14b27 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:16:45 +0200 Subject: [PATCH 022/132] refactor(volumeCropping): Clean up commented code and add debug logging for sphere colors --- packages/core/src/RenderingEngine/VolumeViewport3D.ts | 7 ++++++- packages/tools/src/tools/VolumeCroppingTool.ts | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 5317e21b1e..80d7e4b553 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -20,7 +20,8 @@ import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; */ class VolumeViewport3D extends BaseVolumeViewport { // Store original (axis-aligned) clipping planes for each viewport - _originalClippingPlanes: vtkPlane[] = []; + // _originalClippingPlanes: vtkPlane[] = []; + _originalClippingPlanes: []; constructor(props: ViewportInput) { super(props); @@ -41,11 +42,15 @@ class VolumeViewport3D extends BaseVolumeViewport { // Set the original planes for a viewport public setOriginalClippingPlanes(planes) { this._originalClippingPlanes = planes; + // this.render(); } // Set the original planes for a viewport public setOriginalClippingPlane(index, origin) { this._originalClippingPlanes[index].origin = origin; + //this._originalClippingPlanes[index].setOrigin(origin); + // console.debug('Updated original clipping plane', this._originalClippingPlanes[index]); + // this.render(); } // Set the original planes for a viewport diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 771494c76d..0ffbf9f0ae 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -249,8 +249,9 @@ class VolumeCroppingTool extends AnnotationTool { } sphereActor.getProperty().setColor(color); - /* const sphereColors = this.configuration.sphereColors || {}; + console.debug('sphereColors', sphereColors); + /* const color = sphereColors[this.sphereStates[idx].axis] || sphereColors.default || [0.0, 0.0, 1.0]; sphereActor.getProperty().setColor(color); From 14d8844613401168e135e5045b66653a7b03bfeb Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:32:46 +0200 Subject: [PATCH 023/132] fix(volumeViewport3D): Initialize _originalClippingPlanes in constructor --- packages/core/src/RenderingEngine/VolumeViewport3D.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 80d7e4b553..cb0c66b716 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -25,7 +25,7 @@ class VolumeViewport3D extends BaseVolumeViewport { constructor(props: ViewportInput) { super(props); - + const _originalClippingPlanes = []; const { parallelProjection, orientation } = this.options; const activeCamera = this.getVtkActiveCamera(); @@ -50,7 +50,7 @@ class VolumeViewport3D extends BaseVolumeViewport { this._originalClippingPlanes[index].origin = origin; //this._originalClippingPlanes[index].setOrigin(origin); // console.debug('Updated original clipping plane', this._originalClippingPlanes[index]); - // this.render(); + //this.render(); } // Set the original planes for a viewport From fe2d823cdb66fdb6028397adc5213cb36afdf06b Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:51:49 +0200 Subject: [PATCH 024/132] feat(volumeCropping): Integrate CrosshairsTool and enhance sphere handling in VolumeCroppingTool --- .../examples/volumeCroppingTool/index.ts | 18 ++- .../src/tools/VolumeCroppingControlTool.ts | 135 +++++------------- .../tools/src/tools/VolumeCroppingTool.ts | 63 +++++--- 3 files changed, 97 insertions(+), 119 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 36507790b3..a85e32bafa 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -40,6 +40,7 @@ const { PanTool, OrientationMarkerTool, StackScrollTool, + CrosshairsTool, } = cornerstoneTools; const { MouseBindings } = csToolsEnums; @@ -179,6 +180,7 @@ async function run() { cornerstoneTools.addTool(PanTool); cornerstoneTools.addTool(OrientationMarkerTool); cornerstoneTools.addTool(StackScrollTool); + cornerstoneTools.addTool(CrosshairsTool); // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ @@ -261,6 +263,16 @@ async function run() { toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); + /* + toolGroup.addTool(CrosshairsTool.toolName); + toolGroup.setToolActive(CrosshairsTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, + }, + ], + }); + */ toolGroup.addTool(VolumeCroppingControlTool.toolName, { getReferenceLineColor, viewportIndicators: true, @@ -312,8 +324,10 @@ async function run() { }, ], }); - //toolGroupVRT.addTool(OrientationMarkerTool.toolName); - // toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); + toolGroupVRT.addTool(OrientationMarkerTool.toolName, { + overlayMarkerType: OrientationMarkerTool.OVERLAY_MARKER_TYPES.AXES, + }); + toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); const isMobile = window.matchMedia('(any-pointer:coarse)').matches; // Render the image diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index d8a2293138..4bbdca18f9 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -117,17 +117,6 @@ class VolumeCroppingControlTool extends AnnotationTool { x: null, y: null, }, - // Auto pan is a configuration which will update pan - // other viewports in the toolGroup if the center of the crosshairs - // is outside of the viewport. This might be useful for the case - // when the user is scrolling through an image (usually in the zoomed view) - // and the crosshairs will eventually get outside of the viewport for - // the other viewports. - autoPan: { - enabled: false, - panSize: 10, - }, - handleRadius: 3, // Enable HDPI rendering for handles using devicePixelRatio enableHDPIHandles: false, // radius of the area around the intersection of the planes, in which @@ -138,7 +127,6 @@ class VolumeCroppingControlTool extends AnnotationTool { mobile: { enabled: false, opacity: 0.8, - handleRadius: 9, }, }, } @@ -421,18 +409,19 @@ class VolumeCroppingControlTool extends AnnotationTool { // orientation: viewport.defaultOptions.orientation, toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, + toolMin: this.toolCenter, // viewportId: data.viewportId, }); } } /** - * addNewAnnotation acts as jump for the crosshairs tool. It is called when - * the user clicks on the image. It does not store the annotation in the stateManager though. + * 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 Crosshairs annotation + * @returns annotation */ addNewAnnotation = ( @@ -440,8 +429,6 @@ class VolumeCroppingControlTool extends AnnotationTool { ): VolumeCroppingAnnotation => { const eventDetail = evt.detail; - // check if we are close to a sphere - console.debug('addNewAnnotation: ', eventDetail); const { element } = eventDetail; @@ -450,74 +437,44 @@ class VolumeCroppingControlTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - if (viewport.type === Enums.ViewportType.VOLUME_3D) { - const annotations = this._getAnnotations(enabledElement); - const filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); - - if (!filteredAnnotations.length) { - this.initializeViewport({ - renderingEngineId: viewport.renderingEngineId, - viewportId: viewport.id, - }); - // Re-fetch after creation - annotations = this._getAnnotations(enabledElement); - filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); - if (!filteredAnnotations.length) { - return; - } - } - const { data } = filteredAnnotations[0]; - data.handles.activeOperation = OPERATION.DRAG; - // console.debug('addanotation', filteredAnnotations); + //this._jump(enabledElement, jumpWorld); - return filteredAnnotations[0]; - // here should be the nearPoint checker and update handler - } else { - //this._jump(enabledElement, jumpWorld); - - const annotations = this._getAnnotations(enabledElement); - const filteredAnnotations = this.filterInteractableAnnotationsForElement( - viewport.element, - annotations - ); + const annotations = this._getAnnotations(enabledElement); + const filteredAnnotations = this.filterInteractableAnnotationsForElement( + viewport.element, + annotations + ); - const { data } = filteredAnnotations[0]; + const { data } = filteredAnnotations[0]; - const viewportIdArray = []; - // put all the draggable reference lines in the viewportIdArray + 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 - ); + 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++; + if (!viewportControllable) { + continue; } + viewportIdArray.push(otherViewport.id); + i++; + } - data.activeViewportIds = [...viewportIdArray]; - // set translation operation - data.handles.activeOperation = OPERATION.DRAG; + data.activeViewportIds = [...viewportIdArray]; + // set translation operation + data.handles.activeOperation = OPERATION.DRAG; - evt.preventDefault(); + evt.preventDefault(); - hideElementCursor(element); + hideElementCursor(element); - this._activateModify(element); - return filteredAnnotations[0]; - } + this._activateModify(element); + return filteredAnnotations[0]; }; cancel = () => { @@ -652,16 +609,15 @@ class VolumeCroppingControlTool extends AnnotationTool { // toolGroupId: this.toolGroupId, // toolCenter: this.toolCenter, // }); - - triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - viewportOrientation: [ - viewportAnnotation.data.referenceLines[0][0].options.orientation, - viewportAnnotation.data.referenceLines[1][0].options.orientation, - ], - }); } + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + toolGroupId: this.toolGroupId, + toolCenter: this.toolCenter, + viewportOrientation: [ + viewportAnnotation.data.referenceLines[0][0].options.orientation, + viewportAnnotation.data.referenceLines[1][0].options.orientation, + ], + }); } // AutoPan modification @@ -964,14 +920,6 @@ class VolumeCroppingControlTool extends AnnotationTool { if (viewportControllable) { color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let handleRadius = - this.configuration.handleRadius * - (this.configuration.enableHDPIHandles ? window.devicePixelRatio : 1); - let opacity = 1; - if (this.configuration.mobile?.enabled) { - handleRadius = this.configuration.mobile.handleRadius; - opacity = this.configuration.mobile.opacity; - } if (lineActive) { const handleUID = `${lineIndex}`; } @@ -982,7 +930,6 @@ class VolumeCroppingControlTool extends AnnotationTool { if (this.configuration.viewportIndicators) { const { viewportIndicatorsConfig } = this.configuration; - const xOffset = viewportIndicatorsConfig?.xOffset || 0.95; const yOffset = viewportIndicatorsConfig?.yOffset || 0.05; const referenceColorCoordinates = [ @@ -1113,10 +1060,6 @@ class VolumeCroppingControlTool extends AnnotationTool { const toolCenterCanvas = viewport.worldToCanvas(this.toolCenter); - // pan the viewport to fit the toolCenter in the direction - // that is out of bounds - const pan = this.configuration.autoPan.panSize; - const visiblePointCanvas = [ toolCenterCanvas[0], toolCenterCanvas[1], diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 0ffbf9f0ae..9863e2c1b6 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -202,10 +202,13 @@ class VolumeCroppingTool extends AnnotationTool { this._unsubscribeToViewportNewVolumeSet(viewportsInfo); } - addSphere(viewport, point, axis) { + addSphere(viewport, point, axis, position) { // Generate a unique UID for each sphere based on its axis and position - const uid = `${axis}_${point.map((v) => Math.round(v)).join('_')}`; + const uid = `${axis}_${position}`; const sphereState = this.sphereStates.find((s) => s.uid === uid); + if (sphereState) { + return; + } const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); const sphereRadius = @@ -336,12 +339,12 @@ class VolumeCroppingTool extends AnnotationTool { const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMin]; const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax]; - this.addSphere(viewport, sphereXminPoint, 'x'); - this.addSphere(viewport, sphereXmaxPoint, 'x'); - this.addSphere(viewport, sphereYminPoint, 'y'); - this.addSphere(viewport, sphereYmaxPoint, 'y'); - this.addSphere(viewport, sphereZminPoint, 'z'); - this.addSphere(viewport, sphereZmaxPoint, 'z'); + this.addSphere(viewport, sphereXminPoint, 'x', 'min'); + this.addSphere(viewport, sphereXmaxPoint, 'x', 'max'); + this.addSphere(viewport, sphereYminPoint, 'y', 'min'); + this.addSphere(viewport, sphereYmaxPoint, 'y', 'max'); + this.addSphere(viewport, sphereZminPoint, 'z', 'min'); + this.addSphere(viewport, sphereZmaxPoint, 'z', 'max'); const defaultActor = viewport.getDefaultActor(); if (defaultActor?.actor) { // Cast to any to avoid type errors with different actor types @@ -387,11 +390,8 @@ class VolumeCroppingTool extends AnnotationTool { // coronal is x axis in green // sagittal is y axis in yellow // axial is z axis in red - //console.debug('VOLUMECROPPINGCONTROL_TOOL_CHANGED', evt.detail.toolCenter); - viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine( viewport3D.renderingEngineId ); @@ -409,9 +409,18 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, toolMin[2]], normal: [0, 0, 1], }); - viewport.setOriginalClippingPlane(0, planeXmin.getOrigin()); - viewport.setOriginalClippingPlane(2, planeYmin.getOrigin()); - viewport.setOriginalClippingPlane(4, planeZmin.getOrigin()); + viewport.setOriginalClippingPlane( + PLANEINDEX.XMIN, + planeXmin.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.YMIN, + planeYmin.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.ZMIN, + planeZmin.getOrigin() + ); const volumeActor = viewport.getDefaultActor()?.actor; if (!volumeActor) { @@ -420,15 +429,20 @@ class VolumeCroppingTool extends AnnotationTool { } const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[0].setOrigin(planeXmin.getOrigin()); - clippingPlanes[2].setOrigin(planeYmin.getOrigin()); - clippingPlanes[4].setOrigin(planeZmin.getOrigin()); + clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); + clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); + clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); this.sphereStates[0].sphereSource.setCenter( planeXmin.getOrigin()[0], this.sphereStates[0].point[1], this.sphereStates[0].point[2] ); + console.debug( + 'update xmin with : ', + planeXmin.getOrigin()[0], + toolMin[0] + ); const otherXSphere = this.sphereStates.find( (s, i) => s.axis === 'x' && i !== 0 ); @@ -437,7 +451,7 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates.forEach((state, idx) => { if ( state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') + !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow ) { state.point[0] = newXCenter; state.sphereSource.setCenter(state.point); @@ -450,6 +464,11 @@ class VolumeCroppingTool extends AnnotationTool { planeYmin.getOrigin()[1], this.sphereStates[2].point[2] ); + console.debug( + 'update ymin with : ', + planeYmin.getOrigin()[1], + toolMin[1] + ); const otherYSphere = this.sphereStates.find( (s, i) => s.axis === 'y' && i !== 2 ); @@ -458,10 +477,11 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates.forEach((state, idx) => { if ( state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') + !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green ) { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); + console.debug('updating for y change: ', state); } }); // z @@ -478,7 +498,7 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates.forEach((state, idx) => { if ( state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') + !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red ) { state.point[2] = newZCenter; state.sphereSource.setCenter(state.point); @@ -773,11 +793,12 @@ class VolumeCroppingTool extends AnnotationTool { const viewport = renderingEngine.getViewport(viewport3D.viewportId); const camera = viewport.getCamera(); + + this._initialize3DViewports(viewportsInfo); viewport.setCamera({ focalPoint: camera.focalPoint, position: [camera.position[0], camera.position[1], camera.position[2]], }); - this._initialize3DViewports(viewportsInfo); viewport.render(); }; From 549f047bff8bf11566cd2e9325b40bfa3ed62654 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 24 Jun 2025 18:03:35 +0200 Subject: [PATCH 025/132] refactor(volumeCropping): Replace hardcoded crop factor with configurable initialCropFactor and clean up reference line handling --- .../src/tools/VolumeCroppingControlTool.ts | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 4bbdca18f9..560f015627 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -117,13 +117,8 @@ class VolumeCroppingControlTool extends AnnotationTool { x: null, y: null, }, - // Enable HDPI rendering for handles using devicePixelRatio - enableHDPIHandles: false, - // radius of the area around the intersection of the planes, in which - // the reference lines will not be rendered. This is only used when - // having 3 viewports in the toolGroup. referenceLinesCenterGapRadius: 20, - + initialCropFactor: 0.2, mobile: { enabled: false, opacity: 0.8, @@ -164,9 +159,18 @@ class VolumeCroppingControlTool extends AnnotationTool { const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); this.toolCenter = [ - origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], - origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], - origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], + origin[0] + + this.configuration.initialCropFactor * + (dimensions[0] - 1) * + spacing[0], + origin[1] + + this.configuration.initialCropFactor * + (dimensions[1] - 1) * + spacing[1], + origin[2] + + this.configuration.initialCropFactor * + (dimensions[2] - 1) * + spacing[2], ]; } } @@ -860,9 +864,9 @@ class VolumeCroppingControlTool extends AnnotationTool { referenceLines.push([ otherViewport, refLinePointTwo, - refLinePointTwo, - refLinePointFour, + // refLinePointTwo, refLinePointFour, + // refLinePointFour, ]); //console.debug(refLinePointTwo, refLinePointFour); }); @@ -908,8 +912,8 @@ class VolumeCroppingControlTool extends AnnotationTool { svgDrawingHelper, annotationUID, lineUID, + line[1], line[2], - line[4], { color, lineWidth, @@ -1001,9 +1005,18 @@ class VolumeCroppingControlTool extends AnnotationTool { const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); this.toolCenter = [ - origin[0] + 0.2 * (dimensions[0] - 1) * spacing[0], - origin[1] + 0.2 * (dimensions[1] - 1) * spacing[1], - origin[2] + 0.2 * (dimensions[2] - 1) * spacing[2], + origin[0] + + this.configuration.initialCropFactor * + (dimensions[0] - 1) * + spacing[0], + origin[1] + + this.configuration.initialCropFactor * + (dimensions[1] - 1) * + spacing[1], + origin[2] + + this.configuration.initialCropFactor * + (dimensions[2] - 1) * + spacing[2], ]; // Update all annotations' handles.toolCenter const annotations = getAnnotations(this.getToolName()) || []; @@ -1672,25 +1685,18 @@ class VolumeCroppingControlTool extends AnnotationTool { if (referenceLines) { for (let i = 0; i < referenceLines.length; ++i) { - // Each line: [otherViewport, refLinePointOne, refLinePointTwo, refLinePointThree, refLinePointFour, ...] + // Each line: [otherViewport, refLinePointOne, refLinePointTwo, ...] const otherViewport = referenceLines[i][0]; // First segment const start1 = referenceLines[i][1]; const end1 = referenceLines[i][2]; - // Second segment - const start2 = referenceLines[i][3]; - const end2 = referenceLines[i][4]; const distance1 = lineSegment.distanceToPoint(start1, end1, [ canvasCoords[0], canvasCoords[1], ]); - const distance2 = lineSegment.distanceToPoint(start2, end2, [ - canvasCoords[0], - canvasCoords[1], - ]); - if (distance1 <= proximity || distance2 <= proximity) { + if (distance1 <= proximity) { viewportIdArray.push(otherViewport.id); data.handles.activeOperation = 1; // DRAG } From ceed202b35f154e5404aad37ffb113a08b3fce97 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 24 Jun 2025 19:18:49 +0200 Subject: [PATCH 026/132] feat(volumeCropping): Add toolCenterMin and toolCenterMax properties for enhanced cropping control --- .../src/tools/VolumeCroppingControlTool.ts | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 560f015627..3022c85d93 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -61,6 +61,8 @@ interface VolumeCroppingAnnotation extends Annotation { 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; @@ -101,6 +103,8 @@ class VolumeCroppingControlTool extends AnnotationTool { }[] = []; draggingSphereIndex: number | null = null; toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. + toolCenterMin: Types.Point3 = [0, 0, 0]; + toolCenterMax: Types.Point3 = [0, 0, 0]; // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; @@ -158,19 +162,22 @@ class VolumeCroppingControlTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); + const cropFactor = this.configuration.initialCropFactor ?? 0.2; this.toolCenter = [ - origin[0] + - this.configuration.initialCropFactor * - (dimensions[0] - 1) * - spacing[0], - origin[1] + - this.configuration.initialCropFactor * - (dimensions[1] - 1) * - spacing[1], - origin[2] + - this.configuration.initialCropFactor * - (dimensions[2] - 1) * - spacing[2], + 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], ]; } } @@ -224,6 +231,8 @@ class VolumeCroppingControlTool extends AnnotationTool { 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 @@ -614,6 +623,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // toolCenter: this.toolCenter, // }); } + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, @@ -834,41 +844,29 @@ class VolumeCroppingControlTool extends AnnotationTool { canvasUnitVectorFromCenter, canvasDiagonalLength * 100 ); - const canvasVectorFromCenterStart = vec2.create(); - const centerGap = this.configuration.referenceLinesCenterGapRadius; - vec2.scale( - canvasVectorFromCenterStart, - canvasUnitVectorFromCenter, - // Don't put a gap if the the third view is missing - otherViewportAnnotations.length === 2 ? centerGap : 0 - ); - // Computing Reference start and end (4 lines per viewport in case of 3 view MPR) - const refLinePointTwo = vec2.create(); - const refLinePointFour = vec2.create(); + const refLinePointMinOne = vec2.create(); + const refLinePointMinTwo = vec2.create(); let refLinesCenter = vec2.clone(crosshairCenterCanvas); if (!otherViewportControllable) { refLinesCenter = vec2.clone(otherViewportCenterCanvas); } - vec2.add(refLinePointTwo, refLinesCenter, canvasVectorFromCenterLong); + vec2.add(refLinePointMinOne, refLinesCenter, canvasVectorFromCenterLong); vec2.subtract( - refLinePointFour, + refLinePointMinTwo, refLinesCenter, canvasVectorFromCenterLong ); // Clipping lines to be only included in a box (canvas), we don't want // the lines goes beyond canvas - liangBarksyClip(refLinePointTwo, refLinePointFour, canvasBox); + liangBarksyClip(refLinePointMinOne, refLinePointMinTwo, canvasBox); referenceLines.push([ otherViewport, - refLinePointTwo, - // refLinePointTwo, - refLinePointFour, - // refLinePointFour, + refLinePointMinOne, + refLinePointMinTwo, ]); - //console.debug(refLinePointTwo, refLinePointFour); }); /// create new reference lines here @@ -978,7 +976,7 @@ class VolumeCroppingControlTool extends AnnotationTool { _onSphereMoved = (evt) => { if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { // only update for min spheres - let newCenter = [0, 0, 0]; + let newCenter = [0, 0, 0] as Types.Point3; const eventCenter = evt.detail.toolCenter; if (evt.detail.axis === 'x') { newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; @@ -1004,19 +1002,23 @@ class VolumeCroppingControlTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); + const cropFactor = this.configuration.initialCropFactor ?? 0.2; + this.toolCenter = [ - origin[0] + - this.configuration.initialCropFactor * - (dimensions[0] - 1) * - spacing[0], - origin[1] + - this.configuration.initialCropFactor * - (dimensions[1] - 1) * - spacing[1], - origin[2] + - this.configuration.initialCropFactor * - (dimensions[2] - 1) * - spacing[2], + 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], ]; // Update all annotations' handles.toolCenter const annotations = getAnnotations(this.getToolName()) || []; @@ -1685,7 +1687,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (referenceLines) { for (let i = 0; i < referenceLines.length; ++i) { - // Each line: [otherViewport, refLinePointOne, refLinePointTwo, ...] + // Each line: [otherViewport, refLinePointOne, refLinePointMinOne, ...] const otherViewport = referenceLines[i][0]; // First segment const start1 = referenceLines[i][1]; From f166e2bd53330b57c875350d315837d7bc47c94d Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:53:35 +0200 Subject: [PATCH 027/132] refactor(volumeCropping): Adjust viewport styles and update reference line colors for improved clarity --- .../examples/volumeCroppingTool/index.ts | 14 ++--- .../src/tools/VolumeCroppingControlTool.ts | 51 +++++++++++++++++++ .../tools/src/tools/VolumeCroppingTool.ts | 4 +- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index a85e32bafa..bbf9027afa 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -86,7 +86,7 @@ const element4 = document.createElement('div'); // 3D Volume const rightViewportsContainer = document.createElement('div'); rightViewportsContainer.style.display = 'flex'; rightViewportsContainer.style.flexDirection = 'column'; -rightViewportsContainer.style.width = '50%'; +rightViewportsContainer.style.width = '20%'; rightViewportsContainer.style.height = '100%'; // Set styles for the 2D viewports (stacked vertically on the right) @@ -145,8 +145,8 @@ addToggleButtonToToolbar({ const viewportColors = { [viewportId1]: 'rgb(200, 0, 0)', - [viewportId2]: 'rgb(200, 200, 0)', - [viewportId3]: 'rgb(0, 200, 0)', + [viewportId2]: 'rgb(0, 200, 0)', + [viewportId3]: 'rgb(200, 200, 0)', [viewportId4]: 'rgb(0, 200, 200)', }; @@ -217,7 +217,7 @@ async function run() { type: ViewportType.ORTHOGRAPHIC, element: element2, defaultOptions: { - orientation: Enums.OrientationAxis.SAGITTAL, + orientation: Enums.OrientationAxis.CORONAL, background: [0, 0, 0], }, }, @@ -226,7 +226,7 @@ async function run() { type: ViewportType.ORTHOGRAPHIC, element: element3, defaultOptions: { - orientation: Enums.OrientationAxis.CORONAL, + orientation: Enums.OrientationAxis.SAGITTAL, background: [0, 0, 0], }, }, @@ -325,7 +325,8 @@ async function run() { ], }); toolGroupVRT.addTool(OrientationMarkerTool.toolName, { - overlayMarkerType: OrientationMarkerTool.OVERLAY_MARKER_TYPES.AXES, + overlayMarkerType: + OrientationMarkerTool.OVERLAY_MARKER_TYPES.ANNOTATED_CUBE, }); toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); @@ -352,7 +353,6 @@ async function run() { }, }); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); - // Set zoom to 1.3x viewport.setZoom(1.3); viewport.render(); }); diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 3022c85d93..a2f0aa4e15 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -773,6 +773,9 @@ class VolumeCroppingControlTool extends AnnotationTool { annotations ); + const crosshairCenterCanvasMin = viewport.worldToCanvas(this.toolCenterMin); + const crosshairCenterCanvasMax = viewport.worldToCanvas(this.toolCenterMax); + const referenceLines = []; // get canvas information for points and lines (canvas box, canvas horizontal distances) @@ -867,6 +870,54 @@ class VolumeCroppingControlTool extends AnnotationTool { refLinePointMinOne, refLinePointMinTwo, ]); + + // For min center + const refLinesCenterMin = otherViewportControllable + ? vec2.clone(crosshairCenterCanvasMin) + : 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(crosshairCenterCanvasMax) + : 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', + ]); }); /// create new reference lines here diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 9863e2c1b6..2096ad2b4d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -148,8 +148,8 @@ class VolumeCroppingTool extends AnnotationTool { }, initialCropFactor: 0.2, sphereColors: { - x: [0.0, 1.0, 0.0], // Green for X - y: [1.0, 1.0, 0.0], // Yellow for Y + x: [1.0, 1.0, 0.0], // Yellow for X + y: [0.0, 1.0, 0.0], // Green for Y z: [1.0, 0.0, 0.0], // Red for Z default: [0.0, 0.0, 1.0], // Blue as fallback }, From 1b8045f8abd7d340e1cdfa913533272931821762 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:27:28 +0200 Subject: [PATCH 028/132] feat(volumeCropping): Enhance VolumeCroppingControlTool with toolCenterMin and toolCenterMax properties for improved cropping control --- .../examples/volumeCroppingTool/index.ts | 4 +- .../src/tools/VolumeCroppingControlTool.ts | 72 +++- .../tools/src/tools/VolumeCroppingTool.ts | 332 ++++++++++++------ 3 files changed, 284 insertions(+), 124 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index bbf9027afa..a9cf379bf3 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -103,7 +103,7 @@ element3.style.height = '33.33%'; element3.style.minHeight = '200px'; // Set styles for the 3D viewport (on the left) -element4.style.width = '50%'; +element4.style.width = '100%'; element4.style.height = '100%'; element4.style.minHeight = '600px'; element4.style.position = 'relative'; @@ -328,7 +328,7 @@ async function run() { overlayMarkerType: OrientationMarkerTool.OVERLAY_MARKER_TYPES.ANNOTATED_CUBE, }); - toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); + // toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); const isMobile = window.matchMedia('(any-pointer:coarse)').matches; // Render the image diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index a2f0aa4e15..94181cb9cd 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -403,9 +403,18 @@ class VolumeCroppingControlTool extends AnnotationTool { // this.setToolCenter(toolCenter); }; - setToolCenter(toolCenter: Types.Point3, suppressEvents = false): void { + setToolCenter( + toolCenter: Types.Point3, + suppressEvents = false, + handleType + ): void { // prettier-ignore - this.toolCenter = toolCenter; + + 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 @@ -413,17 +422,22 @@ class VolumeCroppingControlTool extends AnnotationTool { viewportsInfo.map(({ viewportId }) => viewportId) ); if (!suppressEvents) { - console.log('event sent: ', Events.CROSSHAIR_TOOL_CENTER_CHANGED); - triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - }); + // console.log('event sent: ', Events.CROSSHAIR_TOOL_CENTER_CHANGED); + // triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { + // toolGroupId: this.toolGroupId, + // toolCenter: this.toolCenter, + // }); triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { // orientation: viewport.defaultOptions.orientation, toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, - toolMin: this.toolCenter, - // viewportId: data.viewportId, + toolMin: this.toolCenterMin, + toolMax: this.toolCenterMax, + handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here + viewportOrientation: [ + viewportAnnotation.data.referenceLines[0][0].options.orientation, + viewportAnnotation.data.referenceLines[1][0].options.orientation, + ], // viewportId: data.viewportId, }); } } @@ -627,6 +641,9 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here viewportOrientation: [ viewportAnnotation.data.referenceLines[0][0].options.orientation, viewportAnnotation.data.referenceLines[1][0].options.orientation, @@ -847,7 +864,7 @@ class VolumeCroppingControlTool extends AnnotationTool { canvasUnitVectorFromCenter, canvasDiagonalLength * 100 ); - + /* const refLinePointMinOne = vec2.create(); const refLinePointMinTwo = vec2.create(); @@ -870,7 +887,7 @@ class VolumeCroppingControlTool extends AnnotationTool { refLinePointMinOne, refLinePointMinTwo, ]); - +*/ // For min center const refLinesCenterMin = otherViewportControllable ? vec2.clone(crosshairCenterCanvasMin) @@ -1025,6 +1042,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _onSphereMoved = (evt) => { + // console.debug; if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { // only update for min spheres let newCenter = [0, 0, 0] as Types.Point3; @@ -1036,7 +1054,19 @@ class VolumeCroppingControlTool extends AnnotationTool { } else if (evt.detail.axis === 'z') { newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; } - this.setToolCenter(newCenter, true); + this.setToolCenter(newCenter, true, 'min'); + } else { + // only update for max spheres + let newCenter = [0, 0, 0] as Types.Point3; + const eventCenter = evt.detail.toolCenter; + if (evt.detail.axis === 'x') { + newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'y') { + newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; + } else if (evt.detail.axis === 'z') { + newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + } + this.setToolCenter(newCenter, true, 'max'); } }; @@ -1633,9 +1663,19 @@ class VolumeCroppingControlTool extends AnnotationTool { const canvasCoords = currentPoints.canvas; if (handles.activeOperation === OPERATION.DRAG) { - this.toolCenter[0] += delta[0]; - this.toolCenter[1] += delta[1]; - this.toolCenter[2] += delta[2]; + 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) @@ -1743,6 +1783,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // 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], @@ -1752,6 +1793,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (distance1 <= proximity) { viewportIdArray.push(otherViewport.id); data.handles.activeOperation = 1; // DRAG + data.handles.activeType = type; } } } diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 2096ad2b4d..43ffc31924 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -390,121 +390,239 @@ class VolumeCroppingTool extends AnnotationTool { // coronal is x axis in green // sagittal is y axis in yellow // axial is z axis in red + // console.debug('VOLUMECROPPINGCONTROL_TOOL_CHANGED', evt.detail); viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine( viewport3D.renderingEngineId ); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const toolMin = evt.detail.toolCenter; - const planeXmin = vtkPlane.newInstance({ - origin: [toolMin[0], 0, 0], - normal: [1, 0, 0], - }); - const planeYmin = vtkPlane.newInstance({ - origin: [0, toolMin[1], 0], - normal: [0, 1, 0], - }); - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, toolMin[2]], - normal: [0, 0, 1], - }); - viewport.setOriginalClippingPlane( - PLANEINDEX.XMIN, - planeXmin.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.YMIN, - planeYmin.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.ZMIN, - planeZmin.getOrigin() - ); - - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); - clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); - clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); - - this.sphereStates[0].sphereSource.setCenter( - planeXmin.getOrigin()[0], - this.sphereStates[0].point[1], - this.sphereStates[0].point[2] - ); - console.debug( - 'update xmin with : ', - planeXmin.getOrigin()[0], - toolMin[0] - ); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== 0 - ); - const newXCenter = - (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow - ) { - state.point[0] = newXCenter; - state.sphereSource.setCenter(state.point); + if (evt.detail.handleType === 'min') { + const toolMin = evt.detail.toolCenterMin; + const planeXmin = vtkPlane.newInstance({ + origin: [toolMin[0], 0, 0], + normal: [1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, toolMin[1], 0], + normal: [0, 1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, toolMin[2]], + normal: [0, 0, 1], + }); + viewport.setOriginalClippingPlane( + PLANEINDEX.XMIN, + planeXmin.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.YMIN, + planeYmin.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.ZMIN, + planeZmin.getOrigin() + ); + + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; } - }); - - // y - this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point[0], - planeYmin.getOrigin()[1], - this.sphereStates[2].point[2] - ); - console.debug( - 'update ymin with : ', - planeYmin.getOrigin()[1], - toolMin[1] - ); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== 2 - ); - const newYCenter = - (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green - ) { - state.point[1] = newYCenter; - state.sphereSource.setCenter(state.point); - console.debug('updating for y change: ', state); + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); + clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); + clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); + + this.sphereStates[0].sphereSource.setCenter( + planeXmin.getOrigin()[0], + this.sphereStates[0].point[1], + this.sphereStates[0].point[2] + ); + console.debug( + 'update xmin with : ', + planeXmin.getOrigin()[0], + toolMin[0] + ); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== 0 + ); + const newXCenter = + (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow + ) { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + } + }); + + // y + this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; + this.sphereStates[2].sphereSource.setCenter( + this.sphereStates[2].point + ); + /* + this.sphereStates[2].sphereSource.setCenter( + this.sphereStates[2].point[0], + planeYmin.getOrigin()[1], + this.sphereStates[2].point[2] + ); + */ + this.sphereStates[2].sphereSource.modified(); + console.debug( + 'update ymin with : ', + planeYmin.getOrigin()[1], + toolMin[1] + ); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== 2 + ); + const newYCenter = + (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green + ) { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + console.debug('updating for y change: ', state); + } + }); + // z + this.sphereStates[4].sphereSource.setCenter( + this.sphereStates[4].point[0], + this.sphereStates[4].point[1], + planeZmin.getOrigin()[2] + ); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== 4 + ); + const newZCenter = + (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red + ) { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + } + }); + } else if (evt.detail.handleType === 'max') { + const toolMax = evt.detail.toolCenterMax; + const planeXmax = vtkPlane.newInstance({ + origin: [toolMax[0], 0, 0], + normal: [-1, 0, 0], + }); + const planeYmax = vtkPlane.newInstance({ + origin: [0, toolMax[1], 0], + normal: [0, -1, 0], + }); + const planeZmax = vtkPlane.newInstance({ + origin: [0, 0, toolMax[2]], + normal: [0, 0, -1], + }); + viewport.setOriginalClippingPlane( + PLANEINDEX.XMAX, + planeXmax.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.YMAX, + planeYmax.getOrigin() + ); + viewport.setOriginalClippingPlane( + PLANEINDEX.ZMAX, + planeZmax.getOrigin() + ); + + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; } - }); - // z - this.sphereStates[4].sphereSource.setCenter( - this.sphereStates[4].point[0], - this.sphereStates[4].point[1], - planeZmin.getOrigin()[2] - ); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== 4 - ); - const newZCenter = - (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red - ) { - state.point[2] = newZCenter; - state.sphereSource.setCenter(state.point); - } - }); - + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); + clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); + clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); + + // x + this.sphereStates[1].point[0] = planeXmax.getOrigin()[0]; + this.sphereStates[1].sphereSource.setCenter( + this.sphereStates[1].point[0], + this.sphereStates[1].point[1], + this.sphereStates[1].point[2] + ); + this.sphereStates[1].sphereSource.modified(); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== 1 + ); + const newXCenter = + (otherXSphere.point[0] + planeXmax.getOrigin()[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') + ) { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + + // y + this.sphereStates[3].point[1] = planeYmax.getOrigin()[1]; + this.sphereStates[3].sphereSource.setCenter( + this.sphereStates[3].point[0], + this.sphereStates[3].point[1], + this.sphereStates[3].point[2] + ); + this.sphereStates[3].sphereSource.modified(); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== 3 + ); + const newYCenter = + (otherYSphere.point[1] + planeYmax.getOrigin()[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') + ) { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + + // z + this.sphereStates[5].point[2] = planeZmax.getOrigin()[2]; + this.sphereStates[5].sphereSource.setCenter( + this.sphereStates[5].point[0], + this.sphereStates[5].point[1], + this.sphereStates[5].point[2] + ); + this.sphereStates[5].sphereSource.modified(); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== 5 + ); + const newZCenter = + (otherZSphere.point[2] + planeZmax.getOrigin()[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') + ) { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + } viewport.render(); } ); From 996b80821228f04a1d718237f4315e2997f05b8a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 25 Jun 2025 21:25:26 +0200 Subject: [PATCH 029/132] refactor(volumeCropping): Adjust viewport width and clean up unused code in VolumeCroppingTool and VolumeCroppingControlTool --- .../examples/volumeCroppingTool/index.ts | 2 +- .../src/tools/VolumeCroppingControlTool.ts | 39 +++---------------- .../tools/src/tools/VolumeCroppingTool.ts | 20 +++++----- 3 files changed, 18 insertions(+), 43 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index a9cf379bf3..6944c11539 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -103,7 +103,7 @@ element3.style.height = '33.33%'; element3.style.minHeight = '200px'; // Set styles for the 3D viewport (on the left) -element4.style.width = '100%'; +element4.style.width = '75%'; element4.style.height = '100%'; element4.style.minHeight = '600px'; element4.style.position = 'relative'; diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 94181cb9cd..dce19cdf56 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -105,7 +105,6 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. toolCenterMin: Types.Point3 = [0, 0, 0]; toolCenterMax: Types.Point3 = [0, 0, 0]; - // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; constructor( @@ -250,7 +249,6 @@ class VolumeCroppingControlTool extends AnnotationTool { _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; - return viewports; }; @@ -393,13 +391,12 @@ class VolumeCroppingControlTool extends AnnotationTool { const secondPlane = csUtils.planar.planeEquation(normal2, point2); const thirdPlane = csUtils.planar.planeEquation(normal3, point3); - //viewport.render(); const toolCenter = csUtils.planar.threePlaneIntersection( firstPlane, secondPlane, thirdPlane ); - + //viewport.render(); // this.setToolCenter(toolCenter); }; @@ -433,11 +430,11 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenter: this.toolCenter, toolMin: this.toolCenterMin, toolMax: this.toolCenterMax, - handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here + handleType: handleType, // Pass activeType here viewportOrientation: [ viewportAnnotation.data.referenceLines[0][0].options.orientation, viewportAnnotation.data.referenceLines[1][0].options.orientation, - ], // viewportId: data.viewportId, + ], }); } } @@ -864,30 +861,6 @@ class VolumeCroppingControlTool extends AnnotationTool { canvasUnitVectorFromCenter, canvasDiagonalLength * 100 ); - /* - const refLinePointMinOne = vec2.create(); - const refLinePointMinTwo = vec2.create(); - - let refLinesCenter = vec2.clone(crosshairCenterCanvas); - if (!otherViewportControllable) { - refLinesCenter = vec2.clone(otherViewportCenterCanvas); - } - vec2.add(refLinePointMinOne, refLinesCenter, canvasVectorFromCenterLong); - vec2.subtract( - refLinePointMinTwo, - refLinesCenter, - canvasVectorFromCenterLong - ); - - // Clipping lines to be only included in a box (canvas), we don't want - // the lines goes beyond canvas - liangBarksyClip(refLinePointMinOne, refLinePointMinTwo, canvasBox); - referenceLines.push([ - otherViewport, - refLinePointMinOne, - refLinePointMinTwo, - ]); -*/ // For min center const refLinesCenterMin = otherViewportControllable ? vec2.clone(crosshairCenterCanvasMin) @@ -970,10 +943,10 @@ class VolumeCroppingControlTool extends AnnotationTool { lineWidth = 2.5; } - let lineUID = `${lineIndex}`; + const lineUID = `${lineIndex}`; if (viewportControllable) { - lineUID = `${lineIndex}One`; - lineUID = `${lineIndex}Two`; + // lineUID = `${lineIndex}One`; + // lineUID = `${lineIndex}Two`; drawLineSvg( svgDrawingHelper, annotationUID, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 43ffc31924..8f5da82d57 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -465,13 +465,6 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates[2].sphereSource.setCenter( this.sphereStates[2].point ); - /* - this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point[0], - planeYmin.getOrigin()[1], - this.sphereStates[2].point[2] - ); - */ this.sphereStates[2].sphereSource.modified(); console.debug( 'update ymin with : ', @@ -490,6 +483,7 @@ class VolumeCroppingTool extends AnnotationTool { ) { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); console.debug('updating for y change: ', state); } }); @@ -780,8 +774,16 @@ class VolumeCroppingTool extends AnnotationTool { }); } - sphereState.point = newPoint; - sphereState.sphereSource.setCenter(newPoint); + sphereState.point = [ + newPoint[0], + newPoint[1], + newPoint[2], + ] as Types.Point3; + sphereState.sphereSource.setCenter([ + newPoint[0], + newPoint[1], + newPoint[2], + ]); sphereState.sphereSource.modified(); const volumeActor = viewport.getDefaultActor()?.actor; if (!volumeActor) { From 519de23eda263a83aa5dd7e3a065ae7a5cfba685 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 26 Jun 2025 18:58:15 +0200 Subject: [PATCH 030/132] refactor(volumeCropping): Clean up debug statements in VolumeCroppingTool for improved readability --- .../src/tools/VolumeCroppingControlTool.ts | 28 ++++++++----------- .../tools/src/tools/VolumeCroppingTool.ts | 7 +---- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index dce19cdf56..39e3c20ccb 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -408,6 +408,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // prettier-ignore if (handleType==='min') { + this.toolCenterMin = toolCenter; } else if (handleType==='max') { this.toolCenterMax = toolCenter; @@ -453,7 +454,7 @@ class VolumeCroppingControlTool extends AnnotationTool { ): VolumeCroppingAnnotation => { const eventDetail = evt.detail; - console.debug('addNewAnnotation: ', eventDetail); + // console.debug('addNewAnnotation: ', eventDetail); const { element } = eventDetail; const { currentPoints } = eventDetail; @@ -634,7 +635,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // toolCenter: this.toolCenter, // }); } - + console.debug('sending ', this.toolCenterMin); triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, @@ -910,8 +911,6 @@ class VolumeCroppingControlTool extends AnnotationTool { ]); }); - /// create new reference lines here - data.referenceLines = referenceLines; const viewportColor = this._getReferenceLineColor(viewport.id); @@ -1013,36 +1012,33 @@ class VolumeCroppingControlTool extends AnnotationTool { return toolGroupAnnotations; }; - _onSphereMoved = (evt) => { - // console.debug; if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { // only update for min spheres - let newCenter = [0, 0, 0] as Types.Point3; + const newCenter = [...this.toolCenterMin]; const eventCenter = evt.detail.toolCenter; if (evt.detail.axis === 'x') { - newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; + newCenter[0] = eventCenter[0]; } else if (evt.detail.axis === 'y') { - newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; + newCenter[1] = eventCenter[1]; } else if (evt.detail.axis === 'z') { - newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + newCenter[2] = eventCenter[2]; } this.setToolCenter(newCenter, true, 'min'); - } else { + } else if ([1, 3, 5].includes(evt.detail.draggingSphereIndex)) { // only update for max spheres - let newCenter = [0, 0, 0] as Types.Point3; + const newCenter = [...this.toolCenterMax]; const eventCenter = evt.detail.toolCenter; if (evt.detail.axis === 'x') { - newCenter = [eventCenter[0], this.toolCenter[1], this.toolCenter[2]]; + newCenter[0] = eventCenter[0]; } else if (evt.detail.axis === 'y') { - newCenter = [this.toolCenter[0], eventCenter[1], this.toolCenter[2]]; + newCenter[1] = eventCenter[1]; } else if (evt.detail.axis === 'z') { - newCenter = [this.toolCenter[0], this.toolCenter[1], eventCenter[2]]; + newCenter[2] = eventCenter[2]; } this.setToolCenter(newCenter, true, 'max'); } }; - _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); if (viewportsInfo && viewportsInfo.length > 0) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 8f5da82d57..3cecede04e 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -440,11 +440,7 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates[0].point[1], this.sphereStates[0].point[2] ); - console.debug( - 'update xmin with : ', - planeXmin.getOrigin()[0], - toolMin[0] - ); + const otherXSphere = this.sphereStates.find( (s, i) => s.axis === 'x' && i !== 0 ); @@ -484,7 +480,6 @@ class VolumeCroppingTool extends AnnotationTool { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); state.sphereSource.modified(); - console.debug('updating for y change: ', state); } }); // z From 4d20507a5a4b6f5720a02d80bec657aeb3796971 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:17:03 +0200 Subject: [PATCH 031/132] refactor(volumeCropping): Optimize clipping plane management in VolumeCroppingTool for improved performance --- .../tools/src/tools/VolumeCroppingTool.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 3cecede04e..b4e6fd5d31 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -311,25 +311,17 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, zMax], normal: [0, 0, -1], }); - const mapper = viewport.getDefaultActor().actor.getMapper(); planes.push(planeXmin); - mapper.addClippingPlane(planeXmin); planes.push(planeXmax); - mapper.addClippingPlane(planeXmax); planes.push(planeYmin); - mapper.addClippingPlane(planeYmin); planes.push(planeYmax); - mapper.addClippingPlane(planeYmax); planes.push(planeZmin); - mapper.addClippingPlane(planeZmin); planes.push(planeZmax); - mapper.addClippingPlane(planeZmax); const originalPlanes = planes.map((plane) => ({ origin: [...plane.getOrigin()], normal: [...plane.getNormal()], })); - viewport.setOriginalClippingPlanes(originalPlanes); const sphereXminPoint = [xMin, (yMax + yMin) / 2, (zMax + zMin) / 2]; @@ -364,7 +356,6 @@ class VolumeCroppingTool extends AnnotationTool { actorObj.actor.modified(); } }); - viewport.render(); eventTarget.addEventListener( Events.CROSSHAIR_TOOL_CENTER_CHANGED, (evt) => { @@ -457,10 +448,18 @@ class VolumeCroppingTool extends AnnotationTool { }); // y - this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; + // this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; + this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point + this.sphereStates[2].point[0], + planeYmin.getOrigin()[1], + this.sphereStates[2].point[2] ); + + // this.sphereStates[2].sphereSource.setCenter( + // this.sphereStates[2].point + // ); + this.sphereStates[2].sphereSource.modified(); console.debug( 'update ymin with : ', @@ -615,6 +614,13 @@ class VolumeCroppingTool extends AnnotationTool { viewport.render(); } ); + + mapper.addClippingPlane(planeXmin); + mapper.addClippingPlane(planeXmax); + mapper.addClippingPlane(planeYmin); + mapper.addClippingPlane(planeYmax); + mapper.addClippingPlane(planeZmin); + mapper.addClippingPlane(planeZmax); }; /** @@ -910,11 +916,18 @@ class VolumeCroppingTool extends AnnotationTool { const camera = viewport.getCamera(); this._initialize3DViewports(viewportsInfo); - viewport.setCamera({ - focalPoint: camera.focalPoint, - position: [camera.position[0], camera.position[1], camera.position[2]], - }); + // viewport.setCamera({ + // focalPoint: camera.focalPoint, + // position: [camera.position[0], camera.position[1], camera.position[2]], + // }); + viewport.render(); + const originalPlanes = viewport.getOriginalClippingPlanes(); + const mapper = viewport.getDefaultActor().actor.getMapper(); + mapper.removeAllClippingPlanes(); + originalPlanes.forEach((plane) => { + mapper.addClippingPlane(plane); + }); }; _unsubscribeToViewportNewVolumeSet(viewportsInfo) { From 8d22b5896aff3af89272da962efe61796f1b0803 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 28 Jun 2025 17:00:13 +0200 Subject: [PATCH 032/132] refactor(volumeCropping): Update instructions and clean up commented code in VolumeCroppingTool and VolumeCroppingControlTool for improved clarity --- .../examples/volumeCroppingTool/index.ts | 14 +++--- .../src/tools/VolumeCroppingControlTool.ts | 40 +++++++++++++-- .../tools/src/tools/VolumeCroppingTool.ts | 49 ++++++------------- 3 files changed, 59 insertions(+), 44 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 6944c11539..a0751bc326 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -129,8 +129,9 @@ content.appendChild(viewportGrid); const instructions = document.createElement('p'); instructions.innerText = ` Basic controls: - - Click/Drag anywhere in the viewport to move the center of the crosshairs. - - Drag a reference line to move it, scrolling the other views. + - 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); @@ -287,14 +288,15 @@ async function run() { toolGroup.addTool(StackScrollTool.toolName, { viewportIndicators: true, }); + toolGroup.setToolActive(StackScrollTool.toolName, { bindings: [ { mouseButton: MouseBindings.Wheel, }, - { - mouseButton: MouseBindings.Secondary, - }, + // { + // mouseButton: MouseBindings.Secondary, + // }, ], }); @@ -353,7 +355,7 @@ async function run() { }, }); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); - viewport.setZoom(1.3); + viewport.setZoom(1.2); viewport.render(); }); } diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 39e3c20ccb..fcceebe977 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -405,14 +405,23 @@ class VolumeCroppingControlTool extends AnnotationTool { suppressEvents = false, handleType ): void { - // prettier-ignore + /* if (handleType==='min') { - this.toolCenterMin = toolCenter; } else if (handleType==='max') { this.toolCenterMax = toolCenter; } +*/ + if (handleType === 'min') { + this.toolCenterMin[0] = toolCenter[0]; + this.toolCenterMin[1] = toolCenter[1]; + this.toolCenterMin[2] = toolCenter[2]; + } else if (handleType === 'max') { + this.toolCenterMax[0] = toolCenter[0]; + this.toolCenterMax[1] = toolCenter[1]; + this.toolCenterMax[2] = toolCenter[2]; + } const viewportsInfo = this._getViewportsInfo(); // assuming all viewports are in the same rendering engine @@ -630,12 +639,36 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolCenter[0] += deltaCameraPosition[0]; this.toolCenter[1] += deltaCameraPosition[1]; this.toolCenter[2] += deltaCameraPosition[2]; + // Update toolCenterMin as well (keep min cropping plane in sync) + const activeType = this.editData?.annotation?.data?.handles?.activeType; + if (activeType === 'min') { + this.toolCenterMin[0] += deltaCameraPosition[0]; + this.toolCenterMin[1] += deltaCameraPosition[1]; + this.toolCenterMin[2] += deltaCameraPosition[2]; + } else if (activeType === 'max') { + this.toolCenterMax[0] += deltaCameraPosition[0]; + this.toolCenterMax[1] += deltaCameraPosition[1]; + this.toolCenterMax[2] += deltaCameraPosition[2]; + } else { + // No annotation selected: update both min and max + this.toolCenterMin[0] += deltaCameraPosition[0]; + this.toolCenterMin[1] += deltaCameraPosition[1]; + this.toolCenterMin[2] += deltaCameraPosition[2]; + this.toolCenterMax[0] += deltaCameraPosition[0]; + this.toolCenterMax[1] += deltaCameraPosition[1]; + this.toolCenterMax[2] += deltaCameraPosition[2]; + } + // triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { // toolGroupId: this.toolGroupId, // toolCenter: this.toolCenter, // }); } - console.debug('sending ', this.toolCenterMin); + + const viewportAnnotation = filteredToolAnnotations[0]; + if (!viewportAnnotation) { + return; + } triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, toolCenter: this.toolCenter, @@ -671,7 +704,6 @@ class VolumeCroppingControlTool extends AnnotationTool { this.getToolName(), requireSameOrientation ); - triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index b4e6fd5d31..cb03c281b7 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -253,12 +253,6 @@ class VolumeCroppingTool extends AnnotationTool { sphereActor.getProperty().setColor(color); const sphereColors = this.configuration.sphereColors || {}; - console.debug('sphereColors', sphereColors); - /* - const color = sphereColors[this.sphereStates[idx].axis] || - sphereColors.default || [0.0, 0.0, 1.0]; - sphereActor.getProperty().setColor(color); -*/ sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); @@ -356,6 +350,7 @@ class VolumeCroppingTool extends AnnotationTool { actorObj.actor.modified(); } }); + /* eventTarget.addEventListener( Events.CROSSHAIR_TOOL_CENTER_CHANGED, (evt) => { @@ -374,7 +369,7 @@ class VolumeCroppingTool extends AnnotationTool { }); } ); - +*/ eventTarget.addEventListener( Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, (evt) => { @@ -414,18 +409,7 @@ class VolumeCroppingTool extends AnnotationTool { PLANEINDEX.ZMIN, planeZmin.getOrigin() ); - - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); - clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); - clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); - + this.sphereStates[0].point[0] = planeXmin.getOrigin()[0]; this.sphereStates[0].sphereSource.setCenter( planeXmin.getOrigin()[0], this.sphereStates[0].point[1], @@ -448,24 +432,11 @@ class VolumeCroppingTool extends AnnotationTool { }); // y - // this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; - + this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point[0], - planeYmin.getOrigin()[1], - this.sphereStates[2].point[2] + this.sphereStates[2].point ); - - // this.sphereStates[2].sphereSource.setCenter( - // this.sphereStates[2].point - // ); - this.sphereStates[2].sphereSource.modified(); - console.debug( - 'update ymin with : ', - planeYmin.getOrigin()[1], - toolMin[1] - ); const otherYSphere = this.sphereStates.find( (s, i) => s.axis === 'y' && i !== 2 ); @@ -501,6 +472,16 @@ class VolumeCroppingTool extends AnnotationTool { state.sphereSource.setCenter(state.point); } }); + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); + clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); + clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); } else if (evt.detail.handleType === 'max') { const toolMax = evt.detail.toolCenterMax; const planeXmax = vtkPlane.newInstance({ From 8b82cecf465ea04799d800e3f7d7e2cf04b6e0ff Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 29 Jun 2025 14:24:33 +0200 Subject: [PATCH 033/132] refactor(volumeCropping): Improve type definitions and clean up unused code in VolumeViewport3D and VolumeCroppingTool for better maintainability --- .../src/RenderingEngine/VolumeViewport3D.ts | 9 +- .../src/tools/VolumeCroppingControlTool.ts | 139 +---- .../tools/src/tools/VolumeCroppingTool.ts | 522 +++++++++--------- 3 files changed, 277 insertions(+), 393 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index cb0c66b716..6d6833b7cb 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -21,7 +21,7 @@ import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; class VolumeViewport3D extends BaseVolumeViewport { // Store original (axis-aligned) clipping planes for each viewport // _originalClippingPlanes: vtkPlane[] = []; - _originalClippingPlanes: []; + _originalClippingPlanes: vtkPlane[] = []; constructor(props: ViewportInput) { super(props); @@ -47,10 +47,9 @@ class VolumeViewport3D extends BaseVolumeViewport { // Set the original planes for a viewport public setOriginalClippingPlane(index, origin) { - this._originalClippingPlanes[index].origin = origin; - //this._originalClippingPlanes[index].setOrigin(origin); - // console.debug('Updated original clipping plane', this._originalClippingPlanes[index]); - //this.render(); + if (this._originalClippingPlanes[index]) { + this._originalClippingPlanes[index].origin = origin; + } } // Set the original planes for a viewport diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index fcceebe977..435ed6c4ff 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -396,23 +396,9 @@ class VolumeCroppingControlTool extends AnnotationTool { secondPlane, thirdPlane ); - //viewport.render(); - // this.setToolCenter(toolCenter); }; - setToolCenter( - toolCenter: Types.Point3, - suppressEvents = false, - handleType - ): void { - /* - - if (handleType==='min') { - this.toolCenterMin = toolCenter; - } else if (handleType==='max') { - this.toolCenterMax = toolCenter; - } -*/ + setToolCenter(toolCenter: Types.Point3, handleType): void { if (handleType === 'min') { this.toolCenterMin[0] = toolCenter[0]; this.toolCenterMin[1] = toolCenter[1]; @@ -428,25 +414,6 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerAnnotationRenderForViewportIds( viewportsInfo.map(({ viewportId }) => viewportId) ); - if (!suppressEvents) { - // console.log('event sent: ', Events.CROSSHAIR_TOOL_CENTER_CHANGED); - // triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { - // toolGroupId: this.toolGroupId, - // toolCenter: this.toolCenter, - // }); - triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { - // orientation: viewport.defaultOptions.orientation, - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - toolMin: this.toolCenterMin, - toolMax: this.toolCenterMax, - handleType: handleType, // Pass activeType here - viewportOrientation: [ - viewportAnnotation.data.referenceLines[0][0].options.orientation, - viewportAnnotation.data.referenceLines[1][0].options.orientation, - ], - }); - } } /** @@ -640,16 +607,27 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolCenter[1] += deltaCameraPosition[1]; this.toolCenter[2] += deltaCameraPosition[2]; // Update toolCenterMin as well (keep min cropping plane in sync) - const activeType = this.editData?.annotation?.data?.handles?.activeType; - if (activeType === 'min') { - this.toolCenterMin[0] += deltaCameraPosition[0]; - this.toolCenterMin[1] += deltaCameraPosition[1]; - this.toolCenterMin[2] += deltaCameraPosition[2]; - } else if (activeType === 'max') { - this.toolCenterMax[0] += deltaCameraPosition[0]; - this.toolCenterMax[1] += deltaCameraPosition[1]; - this.toolCenterMax[2] += deltaCameraPosition[2]; - } else { + const allAnnotations = this._getAnnotations(enabledElement); + const selectedAnnotations = allAnnotations.filter( + (a) => a.data.handles.activeOperation === 1 // OPERATION.DRAG + ); + + if (selectedAnnotations.length > 1) { + // console.debug('More than one annotation is being dragged/selected'); + } + if (this.editData && this.editData.annotation) { + const activeType = + this.editData?.annotation?.data?.handles?.activeType; + if (activeType === 'min') { + this.toolCenterMin[0] += deltaCameraPosition[0]; + this.toolCenterMin[1] += deltaCameraPosition[1]; + this.toolCenterMin[2] += deltaCameraPosition[2]; + } else if (activeType === 'max') { + this.toolCenterMax[0] += deltaCameraPosition[0]; + this.toolCenterMax[1] += deltaCameraPosition[1]; + this.toolCenterMax[2] += deltaCameraPosition[2]; + } + } else if (selectedAnnotations.length > 1) { // No annotation selected: update both min and max this.toolCenterMin[0] += deltaCameraPosition[0]; this.toolCenterMin[1] += deltaCameraPosition[1]; @@ -1044,6 +1022,7 @@ class VolumeCroppingControlTool extends AnnotationTool { return toolGroupAnnotations; }; + _onSphereMoved = (evt) => { if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { // only update for min spheres @@ -1056,7 +1035,7 @@ class VolumeCroppingControlTool extends AnnotationTool { } else if (evt.detail.axis === 'z') { newCenter[2] = eventCenter[2]; } - this.setToolCenter(newCenter, true, 'min'); + this.setToolCenter(newCenter, 'min'); } else if ([1, 3, 5].includes(evt.detail.draggingSphereIndex)) { // only update for max spheres const newCenter = [...this.toolCenterMax]; @@ -1068,9 +1047,10 @@ class VolumeCroppingControlTool extends AnnotationTool { } else if (evt.detail.axis === 'z') { newCenter[2] = eventCenter[2]; } - this.setToolCenter(newCenter, true, 'max'); + this.setToolCenter(newCenter, 'max'); } }; + _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); if (viewportsInfo && viewportsInfo.length > 0) { @@ -1145,73 +1125,6 @@ class VolumeCroppingControlTool extends AnnotationTool { }); } - _autoPanViewportIfNecessary( - viewportId: string, - renderingEngine: Types.IRenderingEngine - ): void { - // 1. Check if the toolCenter is outside the viewport - // 2. If it is outside, pan the viewport to fit in the toolCenter - - const viewport = renderingEngine.getViewport(viewportId); - const { clientWidth, clientHeight } = viewport.canvas; - - const toolCenterCanvas = viewport.worldToCanvas(this.toolCenter); - - const visiblePointCanvas = [ - toolCenterCanvas[0], - toolCenterCanvas[1], - ]; - - if (toolCenterCanvas[0] < 0) { - visiblePointCanvas[0] = pan; - } else if (toolCenterCanvas[0] > clientWidth) { - visiblePointCanvas[0] = clientWidth - pan; - } - - if (toolCenterCanvas[1] < 0) { - visiblePointCanvas[1] = pan; - } else if (toolCenterCanvas[1] > clientHeight) { - visiblePointCanvas[1] = clientHeight - pan; - } - - if ( - visiblePointCanvas[0] === toolCenterCanvas[0] && - visiblePointCanvas[1] === toolCenterCanvas[1] - ) { - return; - } - - const visiblePointWorld = viewport.canvasToWorld(visiblePointCanvas); - - const deltaPointsWorld = [ - visiblePointWorld[0] - this.toolCenter[0], - visiblePointWorld[1] - this.toolCenter[1], - visiblePointWorld[2] - this.toolCenter[2], - ]; - - const camera = viewport.getCamera(); - const { focalPoint, position } = camera; - - const updatedPosition = [ - position[0] - deltaPointsWorld[0], - position[1] - deltaPointsWorld[1], - position[2] - deltaPointsWorld[2], - ]; - - const updatedFocalPoint = [ - focalPoint[0] - deltaPointsWorld[0], - focalPoint[1] - deltaPointsWorld[1], - focalPoint[2] - deltaPointsWorld[2], - ]; - - viewport.setCamera({ - focalPoint: updatedFocalPoint, - position: updatedPosition, - }); - - viewport.render(); - } - _areViewportIdArraysEqual = (viewportIdArrayOne, viewportIdArrayTwo) => { if (viewportIdArrayOne.length !== viewportIdArrayTwo.length) { return false; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index cb03c281b7..739b273a30 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -87,7 +87,19 @@ const PLANEINDEX = { ZMIN: 4, ZMAX: 5, }; - +const SPHEREINDEX = { + XMIN: 0, + XMAX: 1, + YMIN: 2, + YMAX: 3, + ZMIN: 4, + ZMAX: 5, +}; +const POINTINDEX = { + X: 0, + Y: 1, + Z: 2, +}; /** * VolumeCroppingTool is a tool that provides reference lines between different viewports * of a toolGroup. Using crosshairs, you can jump to a specific location in one @@ -178,10 +190,6 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); - - // Upon new setVolumes on viewports we need to update the crosshairs - // reference points in the new space, so we subscribe to the event - // and update the reference points accordingly. this._unsubscribeToViewportNewVolumeSet(viewportsInfo); this._subscribeToViewportNewVolumeSet(viewportsInfo); this._initialize3DViewports(viewportsInfo); @@ -350,6 +358,13 @@ class VolumeCroppingTool extends AnnotationTool { actorObj.actor.modified(); } }); + mapper.addClippingPlane(planeXmin); + mapper.addClippingPlane(planeXmax); + mapper.addClippingPlane(planeYmin); + mapper.addClippingPlane(planeYmax); + mapper.addClippingPlane(planeZmin); + mapper.addClippingPlane(planeZmax); + /* eventTarget.addEventListener( Events.CROSSHAIR_TOOL_CENTER_CHANGED, @@ -373,244 +388,220 @@ class VolumeCroppingTool extends AnnotationTool { eventTarget.addEventListener( Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, (evt) => { - // coronal is x axis in green - // sagittal is y axis in yellow - // axial is z axis in red - // console.debug('VOLUMECROPPINGCONTROL_TOOL_CHANGED', evt.detail); - viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine( - viewport3D.renderingEngineId - ); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - if (evt.detail.handleType === 'min') { - const toolMin = evt.detail.toolCenterMin; - const planeXmin = vtkPlane.newInstance({ - origin: [toolMin[0], 0, 0], - normal: [1, 0, 0], - }); - const planeYmin = vtkPlane.newInstance({ - origin: [0, toolMin[1], 0], - normal: [0, 1, 0], - }); - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, toolMin[2]], - normal: [0, 0, 1], - }); - viewport.setOriginalClippingPlane( - PLANEINDEX.XMIN, - planeXmin.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.YMIN, - planeYmin.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.ZMIN, - planeZmin.getOrigin() - ); - this.sphereStates[0].point[0] = planeXmin.getOrigin()[0]; - this.sphereStates[0].sphereSource.setCenter( - planeXmin.getOrigin()[0], - this.sphereStates[0].point[1], - this.sphereStates[0].point[2] - ); - - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== 0 - ); - const newXCenter = - (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow - ) { - state.point[0] = newXCenter; - state.sphereSource.setCenter(state.point); - } - }); - - // y - this.sphereStates[2].point[1] = planeYmin.getOrigin()[1]; - this.sphereStates[2].sphereSource.setCenter( - this.sphereStates[2].point - ); - this.sphereStates[2].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== 2 - ); - const newYCenter = - (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green - ) { - state.point[1] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereSource.modified(); - } - }); - // z - this.sphereStates[4].sphereSource.setCenter( - this.sphereStates[4].point[0], - this.sphereStates[4].point[1], - planeZmin.getOrigin()[2] - ); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== 4 - ); - const newZCenter = - (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red - ) { - state.point[2] = newZCenter; - state.sphereSource.setCenter(state.point); - } - }); - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); - clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); - clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); - } else if (evt.detail.handleType === 'max') { - const toolMax = evt.detail.toolCenterMax; - const planeXmax = vtkPlane.newInstance({ - origin: [toolMax[0], 0, 0], - normal: [-1, 0, 0], - }); - const planeYmax = vtkPlane.newInstance({ - origin: [0, toolMax[1], 0], - normal: [0, -1, 0], - }); - const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, toolMax[2]], - normal: [0, 0, -1], - }); - viewport.setOriginalClippingPlane( - PLANEINDEX.XMAX, - planeXmax.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.YMAX, - planeYmax.getOrigin() - ); - viewport.setOriginalClippingPlane( - PLANEINDEX.ZMAX, - planeZmax.getOrigin() - ); - - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); - clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); - clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); - - // x - this.sphereStates[1].point[0] = planeXmax.getOrigin()[0]; - this.sphereStates[1].sphereSource.setCenter( - this.sphereStates[1].point[0], - this.sphereStates[1].point[1], - this.sphereStates[1].point[2] - ); - this.sphereStates[1].sphereSource.modified(); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== 1 - ); - const newXCenter = - (otherXSphere.point[0] + planeXmax.getOrigin()[0]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') - ) { - state.point[0] = newXCenter; - state.sphereSource.setCenter(state.point); - state.sphereSource.modified(); - } - }); - - // y - this.sphereStates[3].point[1] = planeYmax.getOrigin()[1]; - this.sphereStates[3].sphereSource.setCenter( - this.sphereStates[3].point[0], - this.sphereStates[3].point[1], - this.sphereStates[3].point[2] - ); - this.sphereStates[3].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== 3 - ); - const newYCenter = - (otherYSphere.point[1] + planeYmax.getOrigin()[1]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') - ) { - state.point[1] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereSource.modified(); - } - }); - - // z - this.sphereStates[5].point[2] = planeZmax.getOrigin()[2]; - this.sphereStates[5].sphereSource.setCenter( - this.sphereStates[5].point[0], - this.sphereStates[5].point[1], - this.sphereStates[5].point[2] - ); - this.sphereStates[5].sphereSource.modified(); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== 5 - ); - const newZCenter = - (otherZSphere.point[2] + planeZmax.getOrigin()[2]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') - ) { - state.point[2] = newZCenter; - state.sphereSource.setCenter(state.point); - state.sphereSource.modified(); - } - }); - } - viewport.render(); + this._onControlToolChange(evt); } ); + }; - mapper.addClippingPlane(planeXmin); - mapper.addClippingPlane(planeXmax); - mapper.addClippingPlane(planeYmin); - mapper.addClippingPlane(planeYmax); - mapper.addClippingPlane(planeZmin); - mapper.addClippingPlane(planeZmax); + _onControlToolChange = (evt) => { + // coronal is y axis in green + // sagittal is x axis in yellow + // axial is z axis in red + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + if (evt.detail.handleType === 'min') { + const toolMin = evt.detail.toolCenterMin; + const planeXmin = vtkPlane.newInstance({ + origin: [toolMin[0], 0, 0], + normal: [1, 0, 0], + }); + const planeYmin = vtkPlane.newInstance({ + origin: [0, toolMin[1], 0], + normal: [0, 1, 0], + }); + const planeZmin = vtkPlane.newInstance({ + origin: [0, 0, toolMin[2]], + normal: [0, 0, 1], + }); + viewport.setOriginalClippingPlane(PLANEINDEX.XMIN, planeXmin.getOrigin()); + viewport.setOriginalClippingPlane(PLANEINDEX.YMIN, planeYmin.getOrigin()); + viewport.setOriginalClippingPlane(PLANEINDEX.ZMIN, planeZmin.getOrigin()); + this.sphereStates[SPHEREINDEX.XMIN].point[0] = planeXmin.getOrigin()[0]; + this.sphereStates[SPHEREINDEX.XMIN].sphereSource.setCenter( + planeXmin.getOrigin()[0], + this.sphereStates[0].point[1], + this.sphereStates[0].point[2] + ); + + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== 0 + ); + const newXCenter = (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow + ) { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + } + }); + + // y + this.sphereStates[SPHEREINDEX.YMIN].point[1] = planeYmin.getOrigin()[1]; + this.sphereStates[SPHEREINDEX.YMIN].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.YMIN].point + ); + this.sphereStates[SPHEREINDEX.YMIN].sphereSource.modified(); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMIN + ); + const newYCenter = (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green + ) { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + // z + this.sphereStates[SPHEREINDEX.ZMIN].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.ZMIN].point[0], + this.sphereStates[SPHEREINDEX.ZMIN].point[1], + planeZmin.getOrigin()[2] + ); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMIN + ); + const newZCenter = (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red + ) { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + } + }); + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); + clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); + clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); + } else if (evt.detail.handleType === 'max') { + const toolMax = evt.detail.toolCenterMax; + const planeXmax = vtkPlane.newInstance({ + origin: [toolMax[0], 0, 0], + normal: [-1, 0, 0], + }); + const planeYmax = vtkPlane.newInstance({ + origin: [0, toolMax[1], 0], + normal: [0, -1, 0], + }); + const planeZmax = vtkPlane.newInstance({ + origin: [0, 0, toolMax[2]], + normal: [0, 0, -1], + }); + viewport.setOriginalClippingPlane(PLANEINDEX.XMAX, planeXmax.getOrigin()); + viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); + viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); + + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + + // x + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = + planeXmax.getOrigin()[POINTINDEX.X]; + this.sphereStates[SPHEREINDEX.XMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Z] + ); + this.sphereStates[SPHEREINDEX.XMAX].sphereSource.modified(); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== SPHEREINDEX.XMAX + ); + const newXCenter = + (otherXSphere.point[POINTINDEX.X] + + planeXmax.getOrigin()[POINTINDEX.X]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') + ) { + state.point[POINTINDEX.X] = newXCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + + // y + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y] = + planeYmax.getOrigin()[POINTINDEX.Y]; + this.sphereStates[SPHEREINDEX.YMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] + ); + this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX + ); + const newYCenter = + (otherYSphere.point[POINTINDEX.Y] + + planeYmax.getOrigin()[POINTINDEX.Y]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') + ) { + state.point[POINTINDEX.Y] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + + // z + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] = + planeZmax.getOrigin()[POINTINDEX.Z]; + this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] + ); + this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.modified(); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMAX + ); + const newZCenter = + (otherZSphere.point[POINTINDEX.Z] + + planeZmax.getOrigin()[POINTINDEX.Z]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') + ) { + state.point[POINTINDEX.Z] = newZCenter; + state.sphereSource.setCenter(state.point); + state.sphereSource.modified(); + } + }); + + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); + clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); + clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); + } + viewport.render(); }; - /** - * Creates the minimum infrastructure needed to pick a point in the 3D volume - * with VTK.js - * @remarks - * @param viewport - * @returns - */ _prepareImageDataForPicking = (viewport) => { const volumeActor = viewport.getDefaultActor()?.actor; if (!volumeActor) { @@ -667,7 +658,6 @@ class VolumeCroppingTool extends AnnotationTool { // 20 pixels threshold this.draggingSphereIndex = i; element.style.cursor = 'grabbing'; - console.debug('grabbing sphere index: ', i); return; } } @@ -716,40 +706,43 @@ class VolumeCroppingTool extends AnnotationTool { const newPoint = [...sphereState.point]; // Restrict movement to the sphere's axis only if (sphereState.axis === 'x') { - newPoint[0] = pickedPoint[0]; + newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; const otherXSphere = this.sphereStates.find( (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex ); - const newXCenter = (otherXSphere.point[0] + pickedPoint[0]) / 2; + const newXCenter = + (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; this.sphereStates.forEach((state, idx) => { if (state.axis !== 'x') { - state.point[0] = newXCenter; + state.point[POINTINDEX.X] = newXCenter; state.sphereSource.setCenter(state.point); state.sphereSource.modified(); } }); } else if (sphereState.axis === 'y') { - newPoint[1] = pickedPoint[1]; + newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; const otherYSphere = this.sphereStates.find( (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex ); - const newYCenter = (otherYSphere.point[1] + pickedPoint[1]) / 2; + const newYCenter = + (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; this.sphereStates.forEach((state, idx) => { if (state.axis !== 'y') { - state.point[1] = newYCenter; + state.point[POINTINDEX.Y] = newYCenter; state.sphereSource.setCenter(state.point); state.sphereSource.modified(); } }); } else if (sphereState.axis === 'z') { - newPoint[2] = pickedPoint[2]; + newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; const otherZSphere = this.sphereStates.find( (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex ); - const newZCenter = (otherZSphere.point[2] + pickedPoint[2]) / 2; + const newZCenter = + (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; this.sphereStates.forEach((state, idx) => { if (state.axis !== 'z') { - state.point[2] = newZCenter; + state.point[POINTINDEX.Z] = newZCenter; state.sphereSource.setCenter(state.point); state.sphereSource.modified(); } @@ -757,14 +750,14 @@ class VolumeCroppingTool extends AnnotationTool { } sphereState.point = [ - newPoint[0], - newPoint[1], - newPoint[2], + newPoint[POINTINDEX.X], + newPoint[POINTINDEX.Y], + newPoint[POINTINDEX.Z], ] as Types.Point3; sphereState.sphereSource.setCenter([ - newPoint[0], - newPoint[1], - newPoint[2], + newPoint[POINTINDEX.X], + newPoint[POINTINDEX.Y], + newPoint[POINTINDEX.Z], ]); sphereState.sphereSource.modified(); const volumeActor = viewport.getDefaultActor()?.actor; @@ -833,8 +826,6 @@ class VolumeCroppingTool extends AnnotationTool { const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); - - // console.debug('on camera modified', viewport.getActors(), clippingPlanes); enabledElement.viewport.render(); }; @@ -889,26 +880,7 @@ class VolumeCroppingTool extends AnnotationTool { _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); - - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - - const camera = viewport.getCamera(); - this._initialize3DViewports(viewportsInfo); - // viewport.setCamera({ - // focalPoint: camera.focalPoint, - // position: [camera.position[0], camera.position[1], camera.position[2]], - // }); - - viewport.render(); - const originalPlanes = viewport.getOriginalClippingPlanes(); - const mapper = viewport.getDefaultActor().actor.getMapper(); - mapper.removeAllClippingPlanes(); - originalPlanes.forEach((plane) => { - mapper.addClippingPlane(plane); - }); }; _unsubscribeToViewportNewVolumeSet(viewportsInfo) { From 668388c96d76005bd61566079d312cd8f49a9cba Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:19:56 +0200 Subject: [PATCH 034/132] refactor(volumeCropping): Clean up commented code and improve readability in VolumeCroppingTool --- .../src/RenderingEngine/VolumeViewport3D.ts | 2 +- .../examples/volumeCroppingTool/index.ts | 12 +- .../tools/src/tools/VolumeCroppingTool.ts | 335 +++++++++++++----- 3 files changed, 261 insertions(+), 88 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 6d6833b7cb..404a1bde63 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -20,7 +20,7 @@ import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; */ class VolumeViewport3D extends BaseVolumeViewport { // Store original (axis-aligned) clipping planes for each viewport - // _originalClippingPlanes: vtkPlane[] = []; + _originalClippingPlanes: vtkPlane[] = []; constructor(props: ViewportInput) { diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index a0751bc326..36cdb329c7 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -347,12 +347,14 @@ async function run() { toolGroupVRT.addViewport(viewportId4, renderingEngineId); toolGroupVRT.addTool(VolumeCroppingTool.toolName, { sphereRadius: 10, - sphereColor: { - x: [1, 0, 0], // Red for X axis - y: [0, 1, 0], // Green for Y axis - z: [0, 0, 1], // Blue for Z axis - corner: [1, 1, 0], // Yellow for others (optional) + sphereColors: { + x: [1, 1, 0], // yellow for X axis + y: [0, 1, 0], // green for Y axis + z: [1, 0, 0], // red for Z axis + corners: [0, 0, 1], // Blue for corners (optional) }, + showCornerSpheres: true, + initialCropFactor: 0.2, }); toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); viewport.setZoom(1.2); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 739b273a30..1a1b23312f 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -4,6 +4,10 @@ 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 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 vtkPolyDataMapper from '@kitware/vtk.js/Rendering/Core/PolyDataMapper'; import { AnnotationTool } from './base'; @@ -88,12 +92,22 @@ const PLANEINDEX = { 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, }; const POINTINDEX = { X: 0, @@ -102,9 +116,7 @@ const POINTINDEX = { }; /** * VolumeCroppingTool is a tool that provides reference lines between different viewports - * of a toolGroup. Using crosshairs, you can jump to a specific location in one - * viewport and the rest of the viewports in the toolGroup will be aligned to that location. - * + * of a toolGroup. * */ class VolumeCroppingTool extends AnnotationTool { static toolName; @@ -114,6 +126,8 @@ class VolumeCroppingTool extends AnnotationTool { uid: string; sphereSource; sphereActor; + isCorner: boolean; + color: number[]; // [r, g, b] color for the sphere }[] = []; draggingSphereIndex: number | null = null; toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. @@ -122,48 +136,23 @@ class VolumeCroppingTool extends AnnotationTool { _getReferenceLineControllable?: (viewportId: string) => boolean; _getReferenceLineDraggableRotatable?: (viewportId: string) => boolean; picker: vtkCellPicker; + 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, - }, - // Auto pan is a configuration which will update pan - // other viewports in the toolGroup if the center of the crosshairs - // is outside of the viewport. This might be useful for the case - // when the user is scrolling through an image (usually in the zoomed view) - // and the crosshairs will eventually get outside of the viewport for - // the other viewports. - autoPan: { - enabled: false, - panSize: 10, - }, - handleRadius: 3, - // Enable HDPI rendering for handles using devicePixelRatio - enableHDPIHandles: false, - // radius of the area around the intersection of the planes, in which - // the reference lines will not be rendered. This is only used when - // having 3 viewports in the toolGroup. - referenceLinesCenterGapRadius: 20, - + showCornerSpheres: true, mobile: { enabled: false, opacity: 0.8, - handleRadius: 9, }, initialCropFactor: 0.2, sphereColors: { x: [1.0, 1.0, 0.0], // Yellow for X y: [0.0, 1.0, 0.0], // Green for Y z: [1.0, 0.0, 0.0], // Red for Z - default: [0.0, 0.0, 1.0], // Blue as fallback + corners: [0.0, 0.0, 1.0], // Blue for corners }, sphereRadius: 10, }, @@ -210,9 +199,10 @@ class VolumeCroppingTool extends AnnotationTool { this._unsubscribeToViewportNewVolumeSet(viewportsInfo); } - addSphere(viewport, point, axis, position) { + addSphere(viewport, point, axis, position, cornerKey = null) { // Generate a unique UID for each sphere based on its axis and position - const uid = `${axis}_${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; @@ -228,7 +218,18 @@ class VolumeCroppingTool extends AnnotationTool { sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); sphereActor.setMapper(sphereMapper); + let color = [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.z || [1.0, 0.0, 0.0]; + } else if (axis === 'x') { + color = sphereColors.x || [1.0, 1.0, 0.0]; + } else if (axis === 'y') { + color = sphereColors.y || [0.0, 1.0, 0.0]; + } // Store or update the sphere position const idx = this.sphereStates.findIndex((s) => s.uid === uid); if (idx === -1) { @@ -238,6 +239,8 @@ class VolumeCroppingTool extends AnnotationTool { uid, sphereSource, sphereActor, + isCorner: !!cornerKey, + color, }); } else { this.sphereStates[idx].point = point.slice(); @@ -252,19 +255,13 @@ class VolumeCroppingTool extends AnnotationTool { return; } - let color = [0.0, 1.0, 0.0]; - if (axis === 'z') { - color = [1.0, 0.0, 0.0]; - } else if (axis === 'x') { - color = [1.0, 1.0, 0.0]; - } sphereActor.getProperty().setColor(color); - const sphereColors = this.configuration.sphereColors || {}; + // const sphereColors = this.configuration.sphereColors || {}; sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); - viewport.render(); + // viewport.render(); } _initialize3DViewports = (viewportsInfo): void => { @@ -320,6 +317,7 @@ class VolumeCroppingTool extends AnnotationTool { planes.push(planeYmax); planes.push(planeZmin); planes.push(planeZmax); + const originalPlanes = planes.map((plane) => ({ origin: [...plane.getOrigin()], normal: [...plane.getNormal()], @@ -339,6 +337,37 @@ class VolumeCroppingTool extends AnnotationTool { this.addSphere(viewport, sphereYmaxPoint, 'y', 'max'); this.addSphere(viewport, sphereZminPoint, 'z', 'min'); this.addSphere(viewport, sphereZmaxPoint, 'z', 'max'); + if (this.configuration.showCornerSpheres) { + const corners = [ + [xMin, yMin, zMin], // XMIN_YMIN_ZMIN + [xMin, yMin, zMax], // XMIN_YMIN_ZMAX + [xMin, yMax, zMin], // XMIN_YMAX_ZMIN + [xMin, yMax, zMax], // XMIN_YMAX_ZMAX + [xMax, yMin, zMin], // XMAX_YMIN_ZMIN + [xMax, yMin, zMax], // XMAX_YMIN_ZMAX + [xMax, yMax, zMin], // XMAX_YMAX_ZMIN + [xMax, yMax, zMax], // 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]); + } + } + + // draw the lines between corners + // this._updateCornerBoxEdges3D(viewport); + const defaultActor = viewport.getDefaultActor(); if (defaultActor?.actor) { // Cast to any to avoid type errors with different actor types @@ -431,11 +460,13 @@ class VolumeCroppingTool extends AnnotationTool { const newXCenter = (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'x' && !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow ) { state.point[0] = newXCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); } }); @@ -451,15 +482,18 @@ class VolumeCroppingTool extends AnnotationTool { const newYCenter = (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'y' && !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green ) { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); // z + this.sphereStates[SPHEREINDEX.ZMIN].point[2] = planeZmin.getOrigin()[2]; this.sphereStates[SPHEREINDEX.ZMIN].sphereSource.setCenter( this.sphereStates[SPHEREINDEX.ZMIN].point[0], this.sphereStates[SPHEREINDEX.ZMIN].point[1], @@ -471,11 +505,13 @@ class VolumeCroppingTool extends AnnotationTool { const newZCenter = (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'z' && !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red ) { state.point[2] = newZCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); } }); const volumeActor = viewport.getDefaultActor()?.actor; @@ -506,12 +542,6 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - // x this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = planeXmax.getOrigin()[POINTINDEX.X]; @@ -530,11 +560,13 @@ class VolumeCroppingTool extends AnnotationTool { 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'x' && !evt.detail.viewportOrientation.includes('sagittal') ) { state.point[POINTINDEX.X] = newXCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); @@ -547,6 +579,9 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] ); + // this.sphereStates[SPHEREINDEX.YMAX].sphereSource.sphereActor + // .getProperty() + // .setColor(this.sphereStates[SPHEREINDEX.YMAX].color); this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); const otherYSphere = this.sphereStates.find( (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX @@ -557,11 +592,13 @@ class VolumeCroppingTool extends AnnotationTool { 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'y' && !evt.detail.viewportOrientation.includes('coronal') ) { state.point[POINTINDEX.Y] = newYCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); @@ -584,21 +621,24 @@ class VolumeCroppingTool extends AnnotationTool { 2; this.sphereStates.forEach((state, idx) => { if ( + !state.isCorner && state.axis !== 'z' && !evt.detail.viewportOrientation.includes('axial') ) { state.point[POINTINDEX.Z] = newZCenter; state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); - + const volumeActor = viewport.getDefaultActor()?.actor; const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); } + this._updateCornerSpheres(viewport); viewport.render(); }; @@ -704,6 +744,87 @@ class VolumeCroppingTool extends AnnotationTool { const sphereState = this.sphereStates[this.draggingSphereIndex]; const newPoint = [...sphereState.point]; + const volumeActor = viewport.getDefaultActor()?.actor; + if (!volumeActor) { + console.warn('No volume actor found'); + return; + } + const mapper = volumeActor.getMapper(); + + if (sphereState.isCorner) { + // Move the corner sphere + sphereState.point = newPoint; + sphereState.sphereSource.setCenter(newPoint); + sphereState.sphereSource.modified(); + + // --- Update the three adjacent face spheres and their clipping planes --- + // Find which faces this corner belongs to by its index + // (Assumes SPHEREINDEX order: XMIN_YMIN_ZMIN, XMIN_YMIN_ZMAX, ...) + // You may want to generalize this if your SPHEREINDEX changes + + // Map from corner index to which faces it touches + // Each entry: [X face, Y face, Z face] + const cornerToFace = [ + [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMIN_YMIN_ZMIN + [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMIN_YMIN_ZMAX + [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMIN_YMAX_ZMIN + [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMIN_YMAX_ZMAX + [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMAX_YMIN_ZMIN + [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMAX_YMIN_ZMAX + [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMAX_YMAX_ZMIN + [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMAX_YMAX_ZMAX + ]; + const cornerIdx = this.draggingSphereIndex - SPHEREINDEX.XMIN_YMIN_ZMIN; + const faces = cornerToFace[cornerIdx]; + + // Update each face sphere and its clipping plane + faces.forEach((faceIdx) => { + const faceState = this.sphereStates[faceIdx]; + if (!faceState) { + return; + } + // Only update the coordinate relevant to the face + if (faceState.axis === 'x') { + faceState.point[POINTINDEX.X] = newPoint[POINTINDEX.X]; + } else if (faceState.axis === 'y') { + faceState.point[POINTINDEX.Y] = newPoint[POINTINDEX.Y]; + } else if (faceState.axis === 'z') { + faceState.point[POINTINDEX.Z] = newPoint[POINTINDEX.Z]; + } + faceState.sphereSource.setCenter(faceState.point); + faceState.sphereSource.modified(); + + // Update the clipping plane for this face + const clippingPlanes = mapper.getClippingPlanes(); + if (clippingPlanes && clippingPlanes[faceIdx]) { + // Set the origin for the relevant axis, keep other coords unchanged + const origin = [...clippingPlanes[faceIdx].getOrigin()]; + if (faceState.axis === 'x') { + origin[POINTINDEX.X] = newPoint[POINTINDEX.X]; + } else if (faceState.axis === 'y') { + origin[POINTINDEX.Y] = newPoint[POINTINDEX.Y]; + } else if (faceState.axis === 'z') { + origin[POINTINDEX.Z] = newPoint[POINTINDEX.Z]; + } + clippingPlanes[faceIdx].setOrigin(origin); + viewport.setOriginalClippingPlane(faceIdx, origin); + } + }); + + // Update the box edge lines after moving a corner + // this._updateCornerBoxEdges3D(viewport); + + viewport.render(); + + // Optionally: trigger an event if you want to notify others + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + toolCenter: newPoint, + axis: 'corner', + draggingSphereIndex: this.draggingSphereIndex, + }); + return; + } + // Restrict movement to the sphere's axis only if (sphereState.axis === 'x') { newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; @@ -713,9 +834,14 @@ class VolumeCroppingTool extends AnnotationTool { const newXCenter = (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'x') { + if (state.axis !== 'x' && !state.isCorner) { state.point[POINTINDEX.X] = newXCenter; - state.sphereSource.setCenter(state.point); + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); @@ -727,9 +853,14 @@ class VolumeCroppingTool extends AnnotationTool { const newYCenter = (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'y') { + if (state.axis !== 'y' && !state.isCorner) { state.point[POINTINDEX.Y] = newYCenter; - state.sphereSource.setCenter(state.point); + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); @@ -741,31 +872,29 @@ class VolumeCroppingTool extends AnnotationTool { const newZCenter = (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'z') { - state.point[POINTINDEX.Z] = newZCenter; - state.sphereSource.setCenter(state.point); + if (state.axis !== 'z' && !state.isCorner) { + // state.point[POINTINDEX.Z] = newZCenter; + this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; + this.sphereStates[idx].sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + // state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); } - sphereState.point = [ - newPoint[POINTINDEX.X], - newPoint[POINTINDEX.Y], - newPoint[POINTINDEX.Z], - ] as Types.Point3; - sphereState.sphereSource.setCenter([ - newPoint[POINTINDEX.X], - newPoint[POINTINDEX.Y], - newPoint[POINTINDEX.Z], - ]); + // sphereState.point = newPoint as Types.Point3; + this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; + this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; + this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; + + sphereState.sphereSource.setCenter(newPoint[0], newPoint[1], newPoint[2]); sphereState.sphereSource.modified(); - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); + + this._updateCornerSpheres(viewport); const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); @@ -779,6 +908,57 @@ class VolumeCroppingTool extends AnnotationTool { } }; + _updateCornerSpheres(viewport) { + // Get current face sphere positions + const xMin = + this.sphereStates.find((s) => s.axis === 'x' && s.point[0] <= s.point[1]) + ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMIN].point[0]; + const xMax = + this.sphereStates.find((s) => s.axis === 'x' && s.point[0] > s.point[1]) + ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMAX].point[0]; + const yMin = + this.sphereStates.find((s) => s.axis === 'y' && s.point[1] <= s.point[0]) + ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMIN].point[1]; + const yMax = + this.sphereStates.find((s) => s.axis === 'y' && s.point[1] > s.point[0]) + ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMAX].point[1]; + const zMin = + this.sphereStates.find((s) => s.axis === 'z' && s.point[2] <= s.point[0]) + ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMIN].point[2]; + const zMax = + this.sphereStates.find((s) => s.axis === 'z' && s.point[2] > s.point[0]) + ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMAX].point[2]; + + // All 8 corners, with their keys + 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) { + // Update the sphere position and color + state.point[0] = corner.pos[0]; + state.point[1] = corner.pos[1]; + state.point[2] = corner.pos[2]; + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereSource.modified(); + } + } + } _onMouseUpSphere = (evt) => { //evt.stopPropagation(); // evt.preventDefault(); @@ -817,15 +997,6 @@ class VolumeCroppingTool extends AnnotationTool { ? { element: evt.currentTarget } : evt.detail; const enabledElement = getEnabledElement(element); - const { viewport } = enabledElement; - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper(); - - const clippingPlanes = mapper.getClippingPlanes(); enabledElement.viewport.render(); }; @@ -1006,7 +1177,6 @@ class VolumeCroppingTool extends AnnotationTool { triggerAnnotationRenderForViewportIds( viewportsInfo.map(({ viewportId }) => viewportId) ); - viewport.render; } // TRANSLATION @@ -1033,12 +1203,13 @@ class VolumeCroppingTool extends AnnotationTool { ); } ); - + /* this._applyDeltaShiftToSelectedViewportCameras( renderingEngine, viewportsAnnotationsToUpdate, delta ); + */ }; _pointNearTool(element, annotation, canvasCoords, proximity) { From 984d4e74891b3b765bd6162d078e01a86e7a35c9 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:40:33 +0200 Subject: [PATCH 035/132] refactor(volumeCropping): Enhance corner sphere movement logic and update adjacent spheres for improved accuracy in VolumeCroppingTool --- .../tools/src/tools/VolumeCroppingTool.ts | 170 ++++++++++++++---- 1 file changed, 138 insertions(+), 32 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 1a1b23312f..80bfdc5ee3 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -750,20 +750,60 @@ class VolumeCroppingTool extends AnnotationTool { return; } const mapper = volumeActor.getMapper(); - if (sphereState.isCorner) { - // Move the corner sphere - sphereState.point = newPoint; - sphereState.sphereSource.setCenter(newPoint); + // Save the old position + const oldX = sphereState.point[0]; + const oldY = sphereState.point[1]; + const oldZ = sphereState.point[2]; + + // Move the dragged corner sphere to the picked point + sphereState.point[0] = pickedPoint[0]; + sphereState.point[1] = pickedPoint[1]; + sphereState.point[2] = pickedPoint[2]; + sphereState.sphereSource.setCenter( + pickedPoint[0], + pickedPoint[1], + pickedPoint[2] + ); sphereState.sphereSource.modified(); - // --- Update the three adjacent face spheres and their clipping planes --- - // Find which faces this corner belongs to by its index - // (Assumes SPHEREINDEX order: XMIN_YMIN_ZMIN, XMIN_YMIN_ZMAX, ...) - // You may want to generalize this if your SPHEREINDEX changes + // Update all other spheres (face and corner) that shared any min/max coordinate with the old corner position + this.sphereStates.forEach((state, idx) => { + if (idx === this.draggingSphereIndex) { + return; + } // already updated + + let updated = false; + // X + if (Math.abs(state.point[0] - oldX) < 1e-6) { + state.point[0] = pickedPoint[0]; + updated = true; + } + // Y + if (Math.abs(state.point[1] - oldY) < 1e-6) { + state.point[1] = pickedPoint[1]; + updated = true; + } + // Z + if (Math.abs(state.point[2] - oldZ) < 1e-6) { + state.point[2] = pickedPoint[2]; + updated = true; + } + if (updated) { + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereSource.modified(); + if (state.sphereActor && state.color) { + state.sphereActor.getProperty().setColor(state.color); + } + } + }); - // Map from corner index to which faces it touches - // Each entry: [X face, Y face, Z face] + // Update face spheres' positions to always be at the center of their faces + // (between their two corresponding corners) const cornerToFace = [ [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMIN_YMIN_ZMIN [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMIN_YMIN_ZMAX @@ -777,54 +817,120 @@ class VolumeCroppingTool extends AnnotationTool { const cornerIdx = this.draggingSphereIndex - SPHEREINDEX.XMIN_YMIN_ZMIN; const faces = cornerToFace[cornerIdx]; - // Update each face sphere and its clipping plane faces.forEach((faceIdx) => { const faceState = this.sphereStates[faceIdx]; if (!faceState) { return; } - // Only update the coordinate relevant to the face - if (faceState.axis === 'x') { - faceState.point[POINTINDEX.X] = newPoint[POINTINDEX.X]; - } else if (faceState.axis === 'y') { - faceState.point[POINTINDEX.Y] = newPoint[POINTINDEX.Y]; - } else if (faceState.axis === 'z') { - faceState.point[POINTINDEX.Z] = newPoint[POINTINDEX.Z]; - } - faceState.sphereSource.setCenter(faceState.point); + + // Find the two corners that define this face + const faceAxis = faceState.axis; + const isCorner = (s) => s.isCorner; + const cornersOnFace = this.sphereStates.filter((s) => { + if (!isCorner(s)) { + return false; + } + if (faceAxis === 'x') { + return ( + Math.abs(s.point[1] - faceState.point[1]) < 1e-6 && + Math.abs(s.point[2] - faceState.point[2]) < 1e-6 + ); + } + if (faceAxis === 'y') { + return ( + Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && + Math.abs(s.point[2] - faceState.point[2]) < 1e-6 + ); + } + if (faceAxis === 'z') { + return ( + Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && + Math.abs(s.point[1] - faceState.point[1]) < 1e-6 + ); + } + return false; + }); + + if (cornersOnFace.length !== 2) { + return; + } // Defensive + + // Compute center between the two corners + const center = [ + (cornersOnFace[0].point[0] + cornersOnFace[1].point[0]) / 2, + (cornersOnFace[0].point[1] + cornersOnFace[1].point[1]) / 2, + (cornersOnFace[0].point[2] + cornersOnFace[1].point[2]) / 2, + ]; + + faceState.point[0] = center[0]; + faceState.point[1] = center[1]; + faceState.point[2] = center[2]; + faceState.sphereSource.setCenter(center[0], center[1], center[2]); faceState.sphereSource.modified(); // Update the clipping plane for this face const clippingPlanes = mapper.getClippingPlanes(); if (clippingPlanes && clippingPlanes[faceIdx]) { - // Set the origin for the relevant axis, keep other coords unchanged const origin = [...clippingPlanes[faceIdx].getOrigin()]; - if (faceState.axis === 'x') { - origin[POINTINDEX.X] = newPoint[POINTINDEX.X]; - } else if (faceState.axis === 'y') { - origin[POINTINDEX.Y] = newPoint[POINTINDEX.Y]; - } else if (faceState.axis === 'z') { - origin[POINTINDEX.Z] = newPoint[POINTINDEX.Z]; - } + origin[POINTINDEX.X] = center[0]; + origin[POINTINDEX.Y] = center[1]; + origin[POINTINDEX.Z] = center[2]; clippingPlanes[faceIdx].setOrigin(origin); viewport.setOriginalClippingPlane(faceIdx, origin); } }); + // After updating all spheres and before viewport.render() - // Update the box edge lines after moving a corner - // this._updateCornerBoxEdges3D(viewport); + // Determine which planes are connected to this corner + // Use the draggingSphereIndex to get the corner type + const cornerPlaneIndices = []; + const idx = this.draggingSphereIndex; + if ( + idx >= SPHEREINDEX.XMIN_YMIN_ZMIN && + idx <= SPHEREINDEX.XMAX_YMAX_ZMAX + ) { + // Map corner index to plane indices + // [XMIN, XMAX], [YMIN, YMAX], [ZMIN, ZMAX] + const cornerMap = [ + [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMIN_YMIN_ZMIN + [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMIN_YMIN_ZMAX + [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMIN_YMAX_ZMIN + [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMIN_YMAX_ZMAX + [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMAX_YMIN_ZMIN + [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMAX_YMIN_ZMAX + [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMAX_YMAX_ZMIN + [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMAX_YMAX_ZMAX + ]; + const cornerIdx = idx - SPHEREINDEX.XMIN_YMIN_ZMIN; + cornerPlaneIndices.push(...cornerMap[cornerIdx]); + } + const clippingPlanes = mapper.getClippingPlanes(); + cornerPlaneIndices.forEach((planeIdx) => { + if (clippingPlanes && clippingPlanes[planeIdx]) { + // Set the origin of the plane to the new corner position + clippingPlanes[planeIdx].setOrigin( + sphereState.point[0], + sphereState.point[1], + sphereState.point[2] + ); + viewport.setOriginalClippingPlane(planeIdx, [ + sphereState.point[0], + sphereState.point[1], + sphereState.point[2], + ]); + } + }); viewport.render(); // Optionally: trigger an event if you want to notify others triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - toolCenter: newPoint, + toolCenter: pickedPoint, axis: 'corner', draggingSphereIndex: this.draggingSphereIndex, }); return; } - // Restrict movement to the sphere's axis only if (sphereState.axis === 'x') { newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; From a4162d078db4ae52957402ebce3733e946dbee74 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 6 Jul 2025 15:22:50 +0200 Subject: [PATCH 036/132] refactor(volumeCropping): Enhance corner sphere movement logic and update face sphere positions for improved accuracy in VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 36 ++ .../tools/src/tools/VolumeCroppingTool.ts | 319 +++++++++--------- 2 files changed, 198 insertions(+), 157 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 435ed6c4ff..dbe30bcb4f 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1048,6 +1048,42 @@ class VolumeCroppingControlTool extends AnnotationTool { newCenter[2] = eventCenter[2]; } this.setToolCenter(newCenter, 'max'); + } else { + // corner sphere moved, update both min and max + const eventCenter = evt.detail.toolCenter; + // For each axis, check if the moved corner is at min or max for that axis + const minIdx = [0, 2, 4]; // XMIN, YMIN, ZMIN + const maxIdx = [1, 3, 5]; // XMAX, YMAX, ZMAX + + // Copy current min and max + const newMin = [...this.toolCenterMin]; + const newMax = [...this.toolCenterMax]; + + // For each axis, update min or max depending on the corner index + // draggingSphereIndex: 6 = XMIN_YMIN_ZMIN, 7 = XMIN_YMIN_ZMAX, ... up to 13 = XMAX_YMAX_ZMAX + const idx = evt.detail.draggingSphereIndex; + // Determine for each axis if this corner is at min or max + // X axis + if ([6, 7, 8, 9].includes(idx)) { + newMin[0] = eventCenter[0]; + } else { + newMax[0] = eventCenter[0]; + } + // Y axis + if ([6, 7, 10, 11].includes(idx)) { + newMin[1] = eventCenter[1]; + } else { + newMax[1] = eventCenter[1]; + } + // Z axis + if ([6, 8, 10, 12].includes(idx)) { + newMin[2] = eventCenter[2]; + } else { + newMax[2] = eventCenter[2]; + } + + this.setToolCenter(newMin, 'min'); + this.setToolCenter(newMax, 'max'); } }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 80bfdc5ee3..4112aab432 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -134,7 +134,6 @@ class VolumeCroppingTool extends AnnotationTool { // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; - _getReferenceLineDraggableRotatable?: (viewportId: string) => boolean; picker: vtkCellPicker; constructor( @@ -718,7 +717,6 @@ class VolumeCroppingTool extends AnnotationTool { const viewport = renderingEngine.getViewport(viewport3D.viewportId); // Use vtkCellPicker to get world coordinates - const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; @@ -802,83 +800,83 @@ class VolumeCroppingTool extends AnnotationTool { } }); - // Update face spheres' positions to always be at the center of their faces - // (between their two corresponding corners) - const cornerToFace = [ - [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMIN_YMIN_ZMIN - [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMIN_YMIN_ZMAX - [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMIN_YMAX_ZMIN - [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMIN_YMAX_ZMAX - [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMAX_YMIN_ZMIN - [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMAX_YMIN_ZMAX - [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMAX_YMAX_ZMIN - [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMAX_YMAX_ZMAX - ]; - const cornerIdx = this.draggingSphereIndex - SPHEREINDEX.XMIN_YMIN_ZMIN; - const faces = cornerToFace[cornerIdx]; - - faces.forEach((faceIdx) => { - const faceState = this.sphereStates[faceIdx]; - if (!faceState) { - return; - } - - // Find the two corners that define this face - const faceAxis = faceState.axis; - const isCorner = (s) => s.isCorner; - const cornersOnFace = this.sphereStates.filter((s) => { - if (!isCorner(s)) { - return false; - } - if (faceAxis === 'x') { - return ( - Math.abs(s.point[1] - faceState.point[1]) < 1e-6 && - Math.abs(s.point[2] - faceState.point[2]) < 1e-6 - ); - } - if (faceAxis === 'y') { - return ( - Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && - Math.abs(s.point[2] - faceState.point[2]) < 1e-6 - ); - } - if (faceAxis === 'z') { - return ( - Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && - Math.abs(s.point[1] - faceState.point[1]) < 1e-6 - ); - } - return false; - }); - - if (cornersOnFace.length !== 2) { - return; - } // Defensive - - // Compute center between the two corners - const center = [ - (cornersOnFace[0].point[0] + cornersOnFace[1].point[0]) / 2, - (cornersOnFace[0].point[1] + cornersOnFace[1].point[1]) / 2, - (cornersOnFace[0].point[2] + cornersOnFace[1].point[2]) / 2, - ]; - - faceState.point[0] = center[0]; - faceState.point[1] = center[1]; - faceState.point[2] = center[2]; - faceState.sphereSource.setCenter(center[0], center[1], center[2]); - faceState.sphereSource.modified(); - - // Update the clipping plane for this face - const clippingPlanes = mapper.getClippingPlanes(); - if (clippingPlanes && clippingPlanes[faceIdx]) { - const origin = [...clippingPlanes[faceIdx].getOrigin()]; - origin[POINTINDEX.X] = center[0]; - origin[POINTINDEX.Y] = center[1]; - origin[POINTINDEX.Z] = center[2]; - clippingPlanes[faceIdx].setOrigin(origin); - viewport.setOriginalClippingPlane(faceIdx, origin); - } - }); + // // Update face spheres' positions to always be at the center of their faces + // // (between their two corresponding corners) + // const cornerToFace = [ + // [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMIN_YMIN_ZMIN + // [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMIN_YMIN_ZMAX + // [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMIN_YMAX_ZMIN + // [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMIN_YMAX_ZMAX + // [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMAX_YMIN_ZMIN + // [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMAX_YMIN_ZMAX + // [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMAX_YMAX_ZMIN + // [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMAX_YMAX_ZMAX + // ]; + // const cornerIdx = this.draggingSphereIndex - SPHEREINDEX.XMIN_YMIN_ZMIN; + // const faces = cornerToFace[cornerIdx]; + + // faces.forEach((faceIdx) => { + // const faceState = this.sphereStates[faceIdx]; + // if (!faceState) { + // return; + // } + + // // Find the two corners that define this face + // const faceAxis = faceState.axis; + // const isCorner = (s) => s.isCorner; + // const cornersOnFace = this.sphereStates.filter((s) => { + // if (!isCorner(s)) { + // return false; + // } + // if (faceAxis === 'x') { + // return ( + // Math.abs(s.point[1] - faceState.point[1]) < 1e-6 && + // Math.abs(s.point[2] - faceState.point[2]) < 1e-6 + // ); + // } + // if (faceAxis === 'y') { + // return ( + // Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && + // Math.abs(s.point[2] - faceState.point[2]) < 1e-6 + // ); + // } + // if (faceAxis === 'z') { + // return ( + // Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && + // Math.abs(s.point[1] - faceState.point[1]) < 1e-6 + // ); + // } + // return false; + // }); + + // if (cornersOnFace.length !== 2) { + // return; + // } // Defensive + + // // Compute center between the two corners + // const center = [ + // (cornersOnFace[0].point[0] + cornersOnFace[1].point[0]) / 2, + // (cornersOnFace[0].point[1] + cornersOnFace[1].point[1]) / 2, + // (cornersOnFace[0].point[2] + cornersOnFace[1].point[2]) / 2, + // ]; + + // faceState.point[0] = center[0]; + // faceState.point[1] = center[1]; + // faceState.point[2] = center[2]; + // faceState.sphereSource.setCenter(center[0], center[1], center[2]); + // faceState.sphereSource.modified(); + + // // Update the clipping plane for this face + // const clippingPlanes = mapper.getClippingPlanes(); + // if (clippingPlanes && clippingPlanes[faceIdx]) { + // const origin = [...clippingPlanes[faceIdx].getOrigin()]; + // origin[POINTINDEX.X] = center[0]; + // origin[POINTINDEX.Y] = center[1]; + // origin[POINTINDEX.Z] = center[2]; + // clippingPlanes[faceIdx].setOrigin(origin); + // viewport.setOriginalClippingPlane(faceIdx, origin); + // } + // }); // After updating all spheres and before viewport.render() // Determine which planes are connected to this corner @@ -920,6 +918,7 @@ class VolumeCroppingTool extends AnnotationTool { sphereState.point[2], ]); } + // update the face sphere position after the clipping plane change }); viewport.render(); @@ -930,87 +929,93 @@ class VolumeCroppingTool extends AnnotationTool { draggingSphereIndex: this.draggingSphereIndex, }); return; - } - // Restrict movement to the sphere's axis only - if (sphereState.axis === 'x') { - newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex - ); - const newXCenter = - (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'x' && !state.isCorner) { - state.point[POINTINDEX.X] = newXCenter; - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - } else if (sphereState.axis === 'y') { - newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex - ); - const newYCenter = - (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'y' && !state.isCorner) { - state.point[POINTINDEX.Y] = newYCenter; - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - } else if (sphereState.axis === 'z') { - newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex - ); - const newZCenter = - (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'z' && !state.isCorner) { - // state.point[POINTINDEX.Z] = newZCenter; - this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; - this.sphereStates[idx].sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - // state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - } + } else { + // face sphere movement + // Restrict movement to the sphere's axis only + if (sphereState.axis === 'x') { + newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex + ); + const newXCenter = + (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'x' && !state.isCorner) { + state.point[POINTINDEX.X] = newXCenter; + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + } else if (sphereState.axis === 'y') { + newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex + ); + const newYCenter = + (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'y' && !state.isCorner) { + state.point[POINTINDEX.Y] = newYCenter; + state.sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + } else if (sphereState.axis === 'z') { + newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex + ); + const newZCenter = + (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; + this.sphereStates.forEach((state, idx) => { + if (state.axis !== 'z' && !state.isCorner) { + // state.point[POINTINDEX.Z] = newZCenter; + this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; + this.sphereStates[idx].sphereSource.setCenter( + state.point[0], + state.point[1], + state.point[2] + ); + // state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + } - // sphereState.point = newPoint as Types.Point3; - this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; - this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; - this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; + // sphereState.point = newPoint as Types.Point3; + this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; + this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; + this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; - sphereState.sphereSource.setCenter(newPoint[0], newPoint[1], newPoint[2]); - sphereState.sphereSource.modified(); + sphereState.sphereSource.setCenter( + newPoint[0], + newPoint[1], + newPoint[2] + ); + sphereState.sphereSource.modified(); - this._updateCornerSpheres(viewport); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); - viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); - viewport.render(); - /// Send event with the new point - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - toolCenter: newPoint, - axis: sphereState.axis, - draggingSphereIndex: this.draggingSphereIndex, - }); + this._updateCornerSpheres(viewport); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); + viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + viewport.render(); + /// Send event with the new point + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + toolCenter: newPoint, + axis: sphereState.axis, + draggingSphereIndex: this.draggingSphereIndex, + }); + } } }; From 31b05256d88dfe6cae667aac5380bf2d7d4cb92e Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 6 Jul 2025 16:12:52 +0200 Subject: [PATCH 037/132] refactor(volumeCropping): Simplify edge cylinder creation and update logic in VolumeCroppingTool for improved clarity and maintainability --- .../src/tools/VolumeCroppingControlTool.ts | 3 +- .../tools/src/tools/VolumeCroppingTool.ts | 128 +++++++++++++++++- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index dbe30bcb4f..059aab0232 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -872,7 +872,7 @@ class VolumeCroppingControlTool extends AnnotationTool { canvasUnitVectorFromCenter, canvasDiagonalLength * 100 ); - // For min center + // For min const refLinesCenterMin = otherViewportControllable ? vec2.clone(crosshairCenterCanvasMin) : vec2.clone(otherViewportCenterCanvas); @@ -956,6 +956,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (viewportControllable) { // lineUID = `${lineIndex}One`; // lineUID = `${lineIndex}Two`; + console.debug(lineUID, color, line[1], line[2]); drawLineSvg( svgDrawingHelper, annotationUID, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 4112aab432..dd05a66d03 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -4,10 +4,7 @@ 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 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 vtkPolyDataMapper from '@kitware/vtk.js/Rendering/Core/PolyDataMapper'; +import vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; import { AnnotationTool } from './base'; @@ -114,6 +111,48 @@ const POINTINDEX = { Y: 1, Z: 2, }; + +function addCylinderBetweenPoints( + viewport, + point1, + point2, + radius = 2, + color = [0.5, 0.5, 0.5], + uid = '' +) { + const cylinderSource = vtkCylinderSource.newInstance(); + // Compute direction and length + const direction = [ + point2[0] - point1[0], + point2[1] - point1[1], + point2[2] - point1[2], + ]; + const length = Math.sqrt( + direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 + ); + // Midpoint + const center = [ + (point1[0] + point2[0]) / 2, + (point1[1] + point2[1]) / 2, + (point1[2] + point2[2]) / 2, + ]; + // Default cylinder is aligned with Y axis, so compute rotation + cylinderSource.setCenter(center); + cylinderSource.setRadius(radius); + cylinderSource.setHeight(length); + // Set direction (align cylinder axis with direction vector) + cylinderSource.setDirection(direction); + + const cylinderMapper = vtkMapper.newInstance(); + cylinderMapper.setInputConnection(cylinderSource.getOutputPort()); + const cylinderActor = vtkActor.newInstance(); + cylinderActor.setMapper(cylinderMapper); + cylinderActor.getProperty().setColor(color); + + viewport.addActor({ actor: cylinderActor, uid: uid }); + return { actor: cylinderActor, source: cylinderSource }; +} + /** * VolumeCroppingTool is a tool that provides reference lines between different viewports * of a toolGroup. * @@ -135,6 +174,14 @@ class VolumeCroppingTool extends AnnotationTool { _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; picker: vtkCellPicker; + edgeCylinders: { + [uid: string]: { + actor: vtkActor; + source: vtkCylinderSource; + key1: string; + key2: string; + }; + } = {}; constructor( toolProps: PublicToolProps = {}, @@ -362,9 +409,49 @@ class VolumeCroppingTool extends AnnotationTool { for (let i = 0; i < corners.length; i++) { this.addSphere(viewport, corners[i], 'corner', null, cornerKeys[i]); } + + // 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 } = addCylinderBetweenPoints( + viewport, + state1.point, + state2.point, + 2, // radius + [0.7, 0.7, 0.7], // color + uid + ); + this.edgeCylinders[uid] = { actor, source, key1, key2 }; + } + }); } - // draw the lines between corners // this._updateCornerBoxEdges3D(viewport); const defaultActor = viewport.getDefaultActor(); @@ -920,6 +1007,8 @@ class VolumeCroppingTool extends AnnotationTool { } // update the face sphere position after the clipping plane change }); + this._updateCornerSpheres(viewport); + viewport.render(); // Optionally: trigger an event if you want to notify others @@ -1069,6 +1158,35 @@ class VolumeCroppingTool extends AnnotationTool { state.sphereSource.modified(); } } + // ...existing code for updating corners... + + // Update edge cylinders + Object.values(this.edgeCylinders).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 point1 = state1.point; + const point2 = state2.point; + // Compute new direction and length + const direction = [ + point2[0] - point1[0], + point2[1] - point1[1], + point2[2] - point1[2], + ]; + const length = Math.sqrt( + direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 + ); + const center = [ + (point1[0] + point2[0]) / 2, + (point1[1] + point2[1]) / 2, + (point1[2] + point2[2]) / 2, + ]; + source.setCenter(center); + source.setHeight(length); + source.setDirection(direction); + source.modified(); + } + }); } _onMouseUpSphere = (evt) => { //evt.stopPropagation(); From a9a2c931588c65e77e1f2ac8f8e3e9247526e10b Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:22:22 +0200 Subject: [PATCH 038/132] refactor(volumeCropping): Remove commented-out code and enhance face sphere position updates for improved clarity in VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 2 +- .../tools/src/tools/VolumeCroppingTool.ts | 148 +++++++++--------- 2 files changed, 71 insertions(+), 79 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 059aab0232..daa190d938 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -956,7 +956,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (viewportControllable) { // lineUID = `${lineIndex}One`; // lineUID = `${lineIndex}Two`; - console.debug(lineUID, color, line[1], line[2]); + // console.debug(lineUID, color, line[1], line[2]); drawLineSvg( svgDrawingHelper, annotationUID, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index dd05a66d03..9f3dd41852 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -887,85 +887,76 @@ class VolumeCroppingTool extends AnnotationTool { } }); - // // Update face spheres' positions to always be at the center of their faces - // // (between their two corresponding corners) - // const cornerToFace = [ - // [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMIN_YMIN_ZMIN - // [SPHEREINDEX.XMIN, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMIN_YMIN_ZMAX - // [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMIN_YMAX_ZMIN - // [SPHEREINDEX.XMIN, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMIN_YMAX_ZMAX - // [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMIN], // XMAX_YMIN_ZMIN - // [SPHEREINDEX.XMAX, SPHEREINDEX.YMIN, SPHEREINDEX.ZMAX], // XMAX_YMIN_ZMAX - // [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMIN], // XMAX_YMAX_ZMIN - // [SPHEREINDEX.XMAX, SPHEREINDEX.YMAX, SPHEREINDEX.ZMAX], // XMAX_YMAX_ZMAX - // ]; - // const cornerIdx = this.draggingSphereIndex - SPHEREINDEX.XMIN_YMIN_ZMIN; - // const faces = cornerToFace[cornerIdx]; - - // faces.forEach((faceIdx) => { - // const faceState = this.sphereStates[faceIdx]; - // if (!faceState) { - // return; - // } - - // // Find the two corners that define this face - // const faceAxis = faceState.axis; - // const isCorner = (s) => s.isCorner; - // const cornersOnFace = this.sphereStates.filter((s) => { - // if (!isCorner(s)) { - // return false; - // } - // if (faceAxis === 'x') { - // return ( - // Math.abs(s.point[1] - faceState.point[1]) < 1e-6 && - // Math.abs(s.point[2] - faceState.point[2]) < 1e-6 - // ); - // } - // if (faceAxis === 'y') { - // return ( - // Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && - // Math.abs(s.point[2] - faceState.point[2]) < 1e-6 - // ); - // } - // if (faceAxis === 'z') { - // return ( - // Math.abs(s.point[0] - faceState.point[0]) < 1e-6 && - // Math.abs(s.point[1] - faceState.point[1]) < 1e-6 - // ); - // } - // return false; - // }); - - // if (cornersOnFace.length !== 2) { - // return; - // } // Defensive - - // // Compute center between the two corners - // const center = [ - // (cornersOnFace[0].point[0] + cornersOnFace[1].point[0]) / 2, - // (cornersOnFace[0].point[1] + cornersOnFace[1].point[1]) / 2, - // (cornersOnFace[0].point[2] + cornersOnFace[1].point[2]) / 2, - // ]; - - // faceState.point[0] = center[0]; - // faceState.point[1] = center[1]; - // faceState.point[2] = center[2]; - // faceState.sphereSource.setCenter(center[0], center[1], center[2]); - // faceState.sphereSource.modified(); - - // // Update the clipping plane for this face - // const clippingPlanes = mapper.getClippingPlanes(); - // if (clippingPlanes && clippingPlanes[faceIdx]) { - // const origin = [...clippingPlanes[faceIdx].getOrigin()]; - // origin[POINTINDEX.X] = center[0]; - // origin[POINTINDEX.Y] = center[1]; - // origin[POINTINDEX.Z] = center[2]; - // clippingPlanes[faceIdx].setOrigin(origin); - // viewport.setOriginalClippingPlane(faceIdx, origin); - // } - // }); - // After updating all spheres and before viewport.render() + // After moving the corner sphere, update all face spheres to the center between their corners + + // 1. Get all corner points + const cornerStates = [ + this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMIN], + this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMAX], + this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMIN], + this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMAX], + this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMIN], + this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMAX], + this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMIN], + this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMAX], + ]; + + const xs = cornerStates.map((s) => s.point[0]); + const ys = cornerStates.map((s) => s.point[1]); + const zs = cornerStates.map((s) => s.point[2]); + + const xMin = Math.min(...xs); + const xMax = Math.max(...xs); + const yMin = Math.min(...ys); + const yMax = Math.max(...ys); + const zMin = Math.min(...zs); + const zMax = Math.max(...zs); + + // 2. Set face spheres to the center between their two corners + 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, + ]; + // 3. Update sphere sources + [ + SPHEREINDEX.XMIN, + SPHEREINDEX.XMAX, + SPHEREINDEX.YMIN, + SPHEREINDEX.YMAX, + SPHEREINDEX.ZMIN, + SPHEREINDEX.ZMAX, + ].forEach((idx) => { + const s = this.sphereStates[idx]; + s.sphereSource.setCenter(s.point[0], s.point[1], s.point[2]); + s.sphereSource.modified(); + }); // Determine which planes are connected to this corner // Use the draggingSphereIndex to get the corner type const cornerPlaneIndices = []; @@ -1188,6 +1179,7 @@ class VolumeCroppingTool extends AnnotationTool { } }); } + _onMouseUpSphere = (evt) => { //evt.stopPropagation(); // evt.preventDefault(); From 36b4cbb2e5fc9da09b85d69060772c1d2c54cd11 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 6 Jul 2025 20:35:06 +0200 Subject: [PATCH 039/132] refactor(volumeCropping): Add line intersection calculation for improved reference line handling in VolumeCroppingControlTool --- .../src/tools/VolumeCroppingControlTool.ts | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index daa190d938..79790e2f67 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -758,6 +758,23 @@ class VolumeCroppingControlTool extends AnnotationTool { 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; + } + let renderStatus = false; const { viewport, renderingEngine } = enabledElement; const { element } = viewport; @@ -928,6 +945,31 @@ class VolumeCroppingControlTool extends AnnotationTool { 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, + }); + } + } + console.log( + `Reference line ${lineIndex} (${line[3]}) intersects at:`, + intersections + ); + // get color for the reference line const otherViewport = line[0]; const viewportColor = this._getReferenceLineColor(otherViewport.id); @@ -956,16 +998,20 @@ class VolumeCroppingControlTool extends AnnotationTool { if (viewportControllable) { // lineUID = `${lineIndex}One`; // lineUID = `${lineIndex}Two`; - // console.debug(lineUID, color, line[1], line[2]); + console.debug(lineUID, color, line[1], line[2]); + drawLineSvg( svgDrawingHelper, annotationUID, lineUID, - line[1], - line[2], + intersections[0].point, + intersections[1].point, + // line[1], + // line[2], { color, lineWidth, + // lineDash: [4, 4], } ); } From 4cd6a03e12c5f20e1ba8dea8c6c6a839a3bdbe83 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:00:09 +0200 Subject: [PATCH 040/132] refactor(volumeCropping): Adjust line width and cylinder radius for improved visual clarity in VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 42 ++++++++++--------- .../tools/src/tools/VolumeCroppingTool.ts | 6 ++- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 79790e2f67..9699f40f0f 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -983,7 +983,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let lineWidth = 1; + let lineWidth = 2.5; const lineActive = data.handles.activeOperation !== null && @@ -991,29 +991,31 @@ class VolumeCroppingControlTool extends AnnotationTool { selectedViewportId; if (lineActive) { - lineWidth = 2.5; + lineWidth = 4.5; } const lineUID = `${lineIndex}`; if (viewportControllable) { - // lineUID = `${lineIndex}One`; - // lineUID = `${lineIndex}Two`; - console.debug(lineUID, color, line[1], line[2]); - - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID, - intersections[0].point, - intersections[1].point, - // line[1], - // line[2], - { - color, - lineWidth, - // lineDash: [4, 4], - } - ); + if (intersections.length === 2) { + // lineUID = `${lineIndex}One`; + // lineUID = `${lineIndex}Two`; + // console.debug(lineUID, color, line[1], line[2]); + + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID, + intersections[0].point, + intersections[1].point, + // line[1], + // line[2], + { + color, + lineWidth, + // lineDash: [4, 4], + } + ); + } } if (viewportControllable) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 9f3dd41852..32422ccf82 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -116,7 +116,7 @@ function addCylinderBetweenPoints( viewport, point1, point2, - radius = 2, + radius = 0.5, color = [0.5, 0.5, 0.5], uid = '' ) { @@ -148,6 +148,10 @@ function addCylinderBetweenPoints( const cylinderActor = vtkActor.newInstance(); cylinderActor.setMapper(cylinderMapper); cylinderActor.getProperty().setColor(color); + cylinderActor.getProperty().setInterpolationToFlat(); + cylinderActor.getProperty().setAmbient(1.0); // Full ambient + cylinderActor.getProperty().setDiffuse(0.0); // No diffuse + cylinderActor.getProperty().setSpecular(0.0); viewport.addActor({ actor: cylinderActor, uid: uid }); return { actor: cylinderActor, source: cylinderSource }; From a4f734dabf503719732290f139c2f48ce1ca0f5d Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 7 Jul 2025 23:39:36 +0200 Subject: [PATCH 041/132] Added Toggle 3D handles visible --- .../examples/volumeCroppingTool/index.ts | 10 +- .../src/tools/VolumeCroppingControlTool.ts | 4 - .../tools/src/tools/VolumeCroppingTool.ts | 383 ++++++++++-------- 3 files changed, 215 insertions(+), 182 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 36cdb329c7..bf5448f106 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -140,7 +140,15 @@ addToggleButtonToToolbar({ title: 'Toggle 3D handles', defaultToggle: false, onClick: (toggle) => { - // resetViewports(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('VolumeCroppingTool'); + // Call setHandlesVisible on the tool instance + if (croppingTool && typeof croppingTool.setHandlesVisible === 'function') { + croppingTool.setHandlesVisible(!croppingTool.getHandlesVisible()); + } }, }); diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 9699f40f0f..64c6612187 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -965,10 +965,6 @@ class VolumeCroppingControlTool extends AnnotationTool { }); } } - console.log( - `Reference line ${lineIndex} (${line[3]}) intersects at:`, - intersections - ); // get color for the reference line const otherViewport = line[0]; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 32422ccf82..61c3bcf296 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -193,6 +193,7 @@ class VolumeCroppingTool extends AnnotationTool { supportedInteractionTypes: ['Mouse'], configuration: { showCornerSpheres: true, + showHandles: true, mobile: { enabled: false, opacity: 0.8, @@ -222,6 +223,37 @@ class VolumeCroppingTool extends AnnotationTool { this.picker.initializePickList(); } + setHandlesVisible(visible: boolean) { + this.configuration.showHandles = visible; + // Remove or show actors accordingly + this._updateHandlesVisibility(); + } + getHandlesVisible() { + return this.configuration.showHandles; + } + _updateHandlesVisibility() { + const viewportsInfo = this._getViewportsInfo(); + viewportsInfo.forEach(({ renderingEngineId, viewportId }) => { + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId); + + // Spheres + this.sphereStates.forEach((state) => { + if (state.sphereActor) { + state.sphereActor.setVisibility(this.configuration.showHandles); + } + }); + + // Edge cylinders + Object.values(this.edgeCylinders).forEach(({ actor }) => { + if (actor) { + actor.setVisibility(this.configuration.showHandles); + } + }); + + viewport.render(); + }); + } _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; return viewports; @@ -250,6 +282,9 @@ class VolumeCroppingTool extends AnnotationTool { } addSphere(viewport, point, axis, position, cornerKey = null) { + if (!this.configuration.showHandles) { + return; + } // 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}`; @@ -387,7 +422,10 @@ class VolumeCroppingTool extends AnnotationTool { this.addSphere(viewport, sphereYmaxPoint, 'y', 'max'); this.addSphere(viewport, sphereZminPoint, 'z', 'min'); this.addSphere(viewport, sphereZmaxPoint, 'z', 'max'); - if (this.configuration.showCornerSpheres) { + if ( + this.configuration.showCornerSpheres && + this.configuration.showHandles + ) { const corners = [ [xMin, yMin, zMin], // XMIN_YMIN_ZMIN [xMin, yMin, zMax], // XMIN_YMIN_ZMAX @@ -456,8 +494,6 @@ class VolumeCroppingTool extends AnnotationTool { }); } - // this._updateCornerBoxEdges3D(viewport); - const defaultActor = viewport.getDefaultActor(); if (defaultActor?.actor) { // Cast to any to avoid type errors with different actor types @@ -484,26 +520,6 @@ class VolumeCroppingTool extends AnnotationTool { mapper.addClippingPlane(planeZmin); mapper.addClippingPlane(planeZmax); - /* - eventTarget.addEventListener( - Events.CROSSHAIR_TOOL_CENTER_CHANGED, - (evt) => { - console.debug('CROSSHAIR_TOOL_CENTER_CHANGED', evt); - viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - - const renderingEngine = getRenderingEngine( - viewport3D.renderingEngineId - ); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - - const { toolCenter } = evt.detail; - viewport.setCamera({ - focalPoint: toolCenter, - }); - } - ); -*/ eventTarget.addEventListener( Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, (evt) => { @@ -537,73 +553,79 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlane(PLANEINDEX.XMIN, planeXmin.getOrigin()); viewport.setOriginalClippingPlane(PLANEINDEX.YMIN, planeYmin.getOrigin()); viewport.setOriginalClippingPlane(PLANEINDEX.ZMIN, planeZmin.getOrigin()); - this.sphereStates[SPHEREINDEX.XMIN].point[0] = planeXmin.getOrigin()[0]; - this.sphereStates[SPHEREINDEX.XMIN].sphereSource.setCenter( - planeXmin.getOrigin()[0], - this.sphereStates[0].point[1], - this.sphereStates[0].point[2] - ); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== 0 - ); - const newXCenter = (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow - ) { - state.point[0] = newXCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - } - }); + if (this.configuration.showHandles) { + this.sphereStates[SPHEREINDEX.XMIN].point[0] = planeXmin.getOrigin()[0]; + this.sphereStates[SPHEREINDEX.XMIN].sphereSource.setCenter( + planeXmin.getOrigin()[0], + this.sphereStates[0].point[1], + this.sphereStates[0].point[2] + ); - // y - this.sphereStates[SPHEREINDEX.YMIN].point[1] = planeYmin.getOrigin()[1]; - this.sphereStates[SPHEREINDEX.YMIN].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.YMIN].point - ); - this.sphereStates[SPHEREINDEX.YMIN].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMIN - ); - const newYCenter = (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green - ) { - state.point[1] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - // z - this.sphereStates[SPHEREINDEX.ZMIN].point[2] = planeZmin.getOrigin()[2]; - this.sphereStates[SPHEREINDEX.ZMIN].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.ZMIN].point[0], - this.sphereStates[SPHEREINDEX.ZMIN].point[1], - planeZmin.getOrigin()[2] - ); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMIN - ); - const newZCenter = (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red - ) { - state.point[2] = newZCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - } - }); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== 0 + ); + const newXCenter = + (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow + ) { + state.point[0] = newXCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + } + }); + + // y + this.sphereStates[SPHEREINDEX.YMIN].point[1] = planeYmin.getOrigin()[1]; + this.sphereStates[SPHEREINDEX.YMIN].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.YMIN].point + ); + this.sphereStates[SPHEREINDEX.YMIN].sphereSource.modified(); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMIN + ); + const newYCenter = + (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green + ) { + state.point[1] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + // z + this.sphereStates[SPHEREINDEX.ZMIN].point[2] = planeZmin.getOrigin()[2]; + this.sphereStates[SPHEREINDEX.ZMIN].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.ZMIN].point[0], + this.sphereStates[SPHEREINDEX.ZMIN].point[1], + planeZmin.getOrigin()[2] + ); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMIN + ); + const newZCenter = + (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red + ) { + state.point[2] = newZCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + } + }); + } const volumeActor = viewport.getDefaultActor()?.actor; if (!volumeActor) { console.warn('No volume actor found'); @@ -631,96 +653,97 @@ class VolumeCroppingTool extends AnnotationTool { viewport.setOriginalClippingPlane(PLANEINDEX.XMAX, planeXmax.getOrigin()); viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); + if (this.configuration.showHandles) { + // x + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = + planeXmax.getOrigin()[POINTINDEX.X]; + this.sphereStates[SPHEREINDEX.XMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Z] + ); + this.sphereStates[SPHEREINDEX.XMAX].sphereSource.modified(); + const otherXSphere = this.sphereStates.find( + (s, i) => s.axis === 'x' && i !== SPHEREINDEX.XMAX + ); + const newXCenter = + (otherXSphere.point[POINTINDEX.X] + + planeXmax.getOrigin()[POINTINDEX.X]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'x' && + !evt.detail.viewportOrientation.includes('sagittal') + ) { + state.point[POINTINDEX.X] = newXCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); - // x - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = - planeXmax.getOrigin()[POINTINDEX.X]; - this.sphereStates[SPHEREINDEX.XMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Z] - ); - this.sphereStates[SPHEREINDEX.XMAX].sphereSource.modified(); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== SPHEREINDEX.XMAX - ); - const newXCenter = - (otherXSphere.point[POINTINDEX.X] + - planeXmax.getOrigin()[POINTINDEX.X]) / - 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') - ) { - state.point[POINTINDEX.X] = newXCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - - // y - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y] = - planeYmax.getOrigin()[POINTINDEX.Y]; - this.sphereStates[SPHEREINDEX.YMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] - ); - // this.sphereStates[SPHEREINDEX.YMAX].sphereSource.sphereActor - // .getProperty() - // .setColor(this.sphereStates[SPHEREINDEX.YMAX].color); - this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX - ); - const newYCenter = - (otherYSphere.point[POINTINDEX.Y] + - planeYmax.getOrigin()[POINTINDEX.Y]) / - 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') - ) { - state.point[POINTINDEX.Y] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); + // y + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y] = + planeYmax.getOrigin()[POINTINDEX.Y]; + this.sphereStates[SPHEREINDEX.YMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] + ); + // this.sphereStates[SPHEREINDEX.YMAX].sphereSource.sphereActor + // .getProperty() + // .setColor(this.sphereStates[SPHEREINDEX.YMAX].color); + this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); + const otherYSphere = this.sphereStates.find( + (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX + ); + const newYCenter = + (otherYSphere.point[POINTINDEX.Y] + + planeYmax.getOrigin()[POINTINDEX.Y]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'y' && + !evt.detail.viewportOrientation.includes('coronal') + ) { + state.point[POINTINDEX.Y] = newYCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); - // z - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] = - planeZmax.getOrigin()[POINTINDEX.Z]; - this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] - ); - this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.modified(); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMAX - ); - const newZCenter = - (otherZSphere.point[POINTINDEX.Z] + - planeZmax.getOrigin()[POINTINDEX.Z]) / - 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') - ) { - state.point[POINTINDEX.Z] = newZCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); + // z + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] = + planeZmax.getOrigin()[POINTINDEX.Z]; + this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.setCenter( + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.X], + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Y], + this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] + ); + this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.modified(); + const otherZSphere = this.sphereStates.find( + (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMAX + ); + const newZCenter = + (otherZSphere.point[POINTINDEX.Z] + + planeZmax.getOrigin()[POINTINDEX.Z]) / + 2; + this.sphereStates.forEach((state, idx) => { + if ( + !state.isCorner && + state.axis !== 'z' && + !evt.detail.viewportOrientation.includes('axial') + ) { + state.point[POINTINDEX.Z] = newZCenter; + state.sphereSource.setCenter(state.point); + state.sphereActor.getProperty().setColor(state.color); + state.sphereSource.modified(); + } + }); + } const volumeActor = viewport.getDefaultActor()?.actor; const mapper = volumeActor.getMapper(); const clippingPlanes = mapper.getClippingPlanes(); @@ -728,7 +751,13 @@ class VolumeCroppingTool extends AnnotationTool { clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); } - this._updateCornerSpheres(viewport); + if ( + this.configuration.showHandles && + this.configuration.showCornerSpheres + ) { + this._updateCornerSpheres(viewport); + } + // this._updateCornerSpheres(viewport); viewport.render(); }; From 76be85261a99f428e70d391f74503e8905589306 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:37:58 +0200 Subject: [PATCH 042/132] refactor(volumeCropping): Update documentation and clean up comments for clarity in VolumeCroppingControlTool and VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 55 ++++++------------- .../tools/src/tools/VolumeCroppingTool.ts | 3 +- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 64c6612187..aa431617e8 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -87,9 +87,9 @@ const OPERATION = { }; /** - * VolumeCroppingControlTool is a tool that provides reference lines between different viewports - * of a toolGroup. Using crosshairs, you can jump to a specific location in one - * viewport and the rest of the viewports in the toolGroup will be aligned to that location. + * VolumeCroppingControlTool is a tool that provides reference lines to modify the cropping planes + * of the VolumeCroppingTool. It has no use on it's own, and is used in conjunction with + * the VolumeCroppingTool to allow for more precise adjustments to the cropping planes. * */ class VolumeCroppingControlTool extends AnnotationTool { @@ -102,7 +102,7 @@ class VolumeCroppingControlTool extends AnnotationTool { sphereActor; }[] = []; draggingSphereIndex: number | null = null; - toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. + toolCenter: Types.Point3 = [0, 0, 0]; toolCenterMin: Types.Point3 = [0, 0, 0]; toolCenterMax: Types.Point3 = [0, 0, 0]; _getReferenceLineColor?: (viewportId: string) => string; @@ -186,7 +186,7 @@ class VolumeCroppingControlTool extends AnnotationTool { * 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 to add the crosshairs + * @param viewportInfo - The viewportInfo for the viewport * @returns viewPlaneNormal and center of viewport canvas in world space */ initializeViewport = ({ @@ -255,7 +255,6 @@ class VolumeCroppingControlTool extends AnnotationTool { onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); - // Upon new setVolumes on viewports we need to update the crosshairs // reference points in the new space, so we subscribe to the event // and update the reference points accordingly. this._unsubscribeToViewportNewVolumeSet(viewportsInfo); @@ -348,15 +347,6 @@ class VolumeCroppingControlTool extends AnnotationTool { this._computeToolCenter(viewportsInfo); }; - /** - * When activated, it initializes the crosshairs. It begins by computing - * the intersection of viewports associated with the crosshairs instance. - * When all three views are accessible, the intersection (e.g., crosshairs tool centre) - * will be an exact point in space; however, with two viewports, because the - * intersection of two planes is a line, it assumes the last view is between the centre - * of the two rendering viewports. - * @param viewportsInfo Array of viewportInputs which each item containing `{viewportId, renderingEngineId}` - */ _computeToolCenter = (viewportsInfo): void => { // Todo: handle two same view viewport, or more than 3 viewports const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; @@ -377,7 +367,7 @@ class VolumeCroppingControlTool extends AnnotationTool { ({ normal: normal3, point: point3 } = this.initializeViewport(thirdViewport)); } else { - // If there are only two views (viewport) associated with the crosshairs: + // If there are only two views (viewport) associated with the volumecropping // In this situation, we don't have a third information to find the // exact intersection, and we "assume" the third view is looking at // a location in between the first and second view centers @@ -483,7 +473,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }; /** - * It returns if the canvas point is near the provided crosshairs annotation in the + * 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. * @@ -542,10 +532,8 @@ class VolumeCroppingControlTool extends AnnotationTool { // -- Update the camera of other linked viewports containing the same volumeId that // have the same camera in case of translation - // -- Update the crosshair center in world coordinates in annotation. // This is necessary because other tools can modify the position of the slices, // e.g. stackScroll tool at wheel scroll. So we update the coordinates of the center always here. - // NOTE: rotation and slab thickness handles are created/updated in renderTool. const currentCamera = viewport.getCamera(); const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; const deltaCameraPosition: Types.Point3 = [0, 0, 0]; @@ -582,7 +570,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // This is guaranteed to be the same diff for both position and focal point // if the camera is modified by pan, zoom, or scroll BUT for rotation of - // crosshairs handles it will be different. + // volume cropping handles it will be different. const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( deltaCameraPosition, deltaCameraFocalPoint, @@ -600,8 +588,7 @@ class VolumeCroppingControlTool extends AnnotationTool { ) < 1e-2; // TRANSLATION - // NOTE1: if the camera modified is a result of a pan or zoom don't update the crosshair center - // NOTE2: rotation handles are updates in renderTool + // NOTE1: if the camera modified is a result of a pan or zoom don't update the volume cropping center if (!isRotation && !cameraModifiedInPlane) { this.toolCenter[0] += deltaCameraPosition[0]; this.toolCenter[1] += deltaCameraPosition[1]; @@ -802,21 +789,20 @@ class VolumeCroppingControlTool extends AnnotationTool { const canvasDiagonalLength = Math.sqrt( clientWidth * clientWidth + clientHeight * clientHeight ); - const canvasMinDimensionLength = Math.min(clientWidth, clientHeight); const data = viewportAnnotation.data; - // console.debug('annotation data: ', data.viewportId); - - const crosshairCenterCanvas = viewport.worldToCanvas(this.toolCenter); - const otherViewportAnnotations = this._filterAnnotationsByUniqueViewportOrientations( enabledElement, annotations ); - const crosshairCenterCanvasMin = viewport.worldToCanvas(this.toolCenterMin); - const crosshairCenterCanvasMax = viewport.worldToCanvas(this.toolCenterMax); + const volumeCroppingCenterCanvasMin = viewport.worldToCanvas( + this.toolCenterMin + ); + const volumeCroppingCenterCanvasMax = viewport.worldToCanvas( + this.toolCenterMax + ); const referenceLines = []; @@ -891,7 +877,7 @@ class VolumeCroppingControlTool extends AnnotationTool { ); // For min const refLinesCenterMin = otherViewportControllable - ? vec2.clone(crosshairCenterCanvasMin) + ? vec2.clone(volumeCroppingCenterCanvasMin) : vec2.clone(otherViewportCenterCanvas); const refLinePointMinOne = vec2.create(); const refLinePointMinTwo = vec2.create(); @@ -915,7 +901,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // For max center const refLinesCenterMax = otherViewportControllable - ? vec2.clone(crosshairCenterCanvasMax) + ? vec2.clone(volumeCroppingCenterCanvasMax) : vec2.clone(otherViewportCenterCanvas); const refLinePointMaxOne = vec2.create(); const refLinePointMaxTwo = vec2.create(); @@ -993,10 +979,6 @@ class VolumeCroppingControlTool extends AnnotationTool { const lineUID = `${lineIndex}`; if (viewportControllable) { if (intersections.length === 2) { - // lineUID = `${lineIndex}One`; - // lineUID = `${lineIndex}Two`; - // console.debug(lineUID, color, line[1], line[2]); - drawLineSvg( svgDrawingHelper, annotationUID, @@ -1008,7 +990,6 @@ class VolumeCroppingControlTool extends AnnotationTool { { color, lineWidth, - // lineDash: [4, 4], } ); } @@ -1227,8 +1208,6 @@ class VolumeCroppingControlTool extends AnnotationTool { return true; }; - // It filters the viewports with crosshairs and only return viewports - // that have different camera. _getAnnotationsForViewportsWithDifferentCameras = ( enabledElement, annotations diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 61c3bcf296..89810d637d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -173,8 +173,7 @@ class VolumeCroppingTool extends AnnotationTool { color: number[]; // [r, g, b] color for the sphere }[] = []; draggingSphereIndex: number | null = null; - toolCenter: Types.Point3 = [0, 0, 0]; // NOTE: it is assumed that all the active/linked viewports share the same crosshair center. - // This because the rotation operation rotates also all the other active/intersecting reference lines of the same angle + toolCenter: Types.Point3 = [0, 0, 0]; _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; picker: vtkCellPicker; From cd4a3adcc3cce0c06b6d6e1939c22f689090f424 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:07:14 +0200 Subject: [PATCH 043/132] refactor(tools): Enhance type definitions and improve clarity in PanTool, TrackballRotateTool, VolumeCroppingControlTool, VolumeCroppingTool, and ZoomTool --- packages/tools/src/tools/PanTool.ts | 21 +++++++++---- .../tools/src/tools/TrackballRotateTool.ts | 22 ++++++++----- .../src/tools/VolumeCroppingControlTool.ts | 14 +++------ .../tools/src/tools/VolumeCroppingTool.ts | 31 ++++++++----------- packages/tools/src/tools/ZoomTool.ts | 20 ++++++++---- 5 files changed, 62 insertions(+), 46 deletions(-) diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index 00c09e7c5b..e9a5d125b5 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -26,7 +26,7 @@ class PanTool extends BaseTool { mouseDragCallback(evt: EventTypes.InteractionEventType) { this._dragCallback(evt); } - _transformNormal(normal, mat) { + _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { return [ mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], @@ -60,9 +60,18 @@ class PanTool extends BaseTool { } mapper.removeAllClippingPlanes(); - originalPlanes.forEach(({ origin, normal }) => { + originalPlanes.forEach((plane) => { + const origin = + typeof plane.getOrigin === 'function' + ? plane.getOrigin() + : plane.origin; + const normal = + typeof plane.getNormal === 'function' + ? plane.getNormal() + : plane.normal; + // Transform origin (full 4x4) - const o = [ + const o: Types.Point3 = [ matrix[0] * origin[0] + matrix[4] * origin[1] + matrix[8] * origin[2] + @@ -77,9 +86,9 @@ class PanTool extends BaseTool { matrix[14], ]; // Transform normal (rotation only) - const n = this._transformNormal(normal, rot); - const plane = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(plane); + const n: Types.Point3 = this._transformNormal(normal, rot); + const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(planeInstance); }); } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index c7109cab53..d8ceb49de1 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -155,7 +155,7 @@ class TrackballRotateTool extends BaseTool { }; // Helper to transform a normal by a 3x3 matrix - _transformNormal(normal, mat) { + _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { return [ mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], @@ -188,9 +188,18 @@ class TrackballRotateTool extends BaseTool { } mapper.removeAllClippingPlanes(); - originalPlanes.forEach(({ origin, normal }) => { + originalPlanes.forEach((plane) => { + const origin = + typeof plane.getOrigin === 'function' + ? plane.getOrigin() + : plane.origin; + const normal = + typeof plane.getNormal === 'function' + ? plane.getNormal() + : plane.normal; + // Transform origin (full 4x4) - const o = [ + const o: Types.Point3 = [ matrix[0] * origin[0] + matrix[4] * origin[1] + matrix[8] * origin[2] + @@ -205,10 +214,9 @@ class TrackballRotateTool extends BaseTool { matrix[14], ]; // Transform normal (rotation only) - const n = this._transformNormal(normal, rot); - // const plane = vtkPlane.newInstance({ origin: o, normal: n }); - const plane = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(plane); + const n: Types.Point3 = this._transformNormal(normal, rot); + const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(planeInstance); }); } diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index aa431617e8..2e451100c8 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -657,10 +657,6 @@ class VolumeCroppingControlTool extends AnnotationTool { const otherViewportIds = toolGroup .getViewportIds() .filter((id) => id !== viewport.id); - - otherViewportIds.forEach((viewportId) => { - this._autoPanViewportIfNecessary(viewportId, renderingEngine); - }); } const requireSameOrientation = false; @@ -962,7 +958,7 @@ class VolumeCroppingControlTool extends AnnotationTool { (id) => id === otherViewport.id ); - const color = + let color = viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; let lineWidth = 2.5; @@ -1052,7 +1048,7 @@ class VolumeCroppingControlTool extends AnnotationTool { _onSphereMoved = (evt) => { if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { // only update for min spheres - const newCenter = [...this.toolCenterMin]; + const newCenter: Types.Point3 = [...this.toolCenterMin]; const eventCenter = evt.detail.toolCenter; if (evt.detail.axis === 'x') { newCenter[0] = eventCenter[0]; @@ -1064,7 +1060,7 @@ class VolumeCroppingControlTool extends AnnotationTool { this.setToolCenter(newCenter, 'min'); } else if ([1, 3, 5].includes(evt.detail.draggingSphereIndex)) { // only update for max spheres - const newCenter = [...this.toolCenterMax]; + const newCenter: Types.Point3 = [...this.toolCenterMax]; const eventCenter = evt.detail.toolCenter; if (evt.detail.axis === 'x') { newCenter[0] = eventCenter[0]; @@ -1082,8 +1078,8 @@ class VolumeCroppingControlTool extends AnnotationTool { const maxIdx = [1, 3, 5]; // XMAX, YMAX, ZMAX // Copy current min and max - const newMin = [...this.toolCenterMin]; - const newMax = [...this.toolCenterMax]; + const newMin: Types.Point3 = [...this.toolCenterMin]; + const newMax: Types.Point3 = [...this.toolCenterMax]; // For each axis, update min or max depending on the corner index // draggingSphereIndex: 6 = XMIN_YMIN_ZMIN, 7 = XMIN_YMIN_ZMAX, ... up to 13 = XMAX_YMAX_ZMAX diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 89810d637d..de22702827 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,6 +1,8 @@ import { vec2, vec3 } from 'gl-matrix'; import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; + 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'; @@ -117,7 +119,7 @@ function addCylinderBetweenPoints( point1, point2, radius = 0.5, - color = [0.5, 0.5, 0.5], + color: [number, number, number] = [0.5, 0.5, 0.5], uid = '' ) { const cylinderSource = vtkCylinderSource.newInstance(); @@ -131,17 +133,17 @@ function addCylinderBetweenPoints( direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 ); // Midpoint - const center = [ + const center: Types.Point3 = [ (point1[0] + point2[0]) / 2, (point1[1] + point2[1]) / 2, (point1[2] + point2[2]) / 2, ]; // Default cylinder is aligned with Y axis, so compute rotation - cylinderSource.setCenter(center); + cylinderSource.setCenter(center[0], center[1], center[2]); cylinderSource.setRadius(radius); cylinderSource.setHeight(length); // Set direction (align cylinder axis with direction vector) - cylinderSource.setDirection(direction); + cylinderSource.setDirection(direction[0], direction[1], direction[2]); const cylinderMapper = vtkMapper.newInstance(); cylinderMapper.setInputConnection(cylinderSource.getOutputPort()); @@ -217,7 +219,7 @@ class VolumeCroppingTool extends AnnotationTool { toolProps.configuration?.getReferenceLineControllable || defaultReferenceLineControllable; this.picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); - this.picker.setPickFromList(1); + this.picker.setPickFromList(true); this.picker.setTolerance(0); this.picker.initializePickList(); } @@ -302,7 +304,7 @@ class VolumeCroppingTool extends AnnotationTool { sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); sphereActor.setMapper(sphereMapper); - let color = [0.0, 1.0, 0.0]; // Default green + let color: [number, number, number] = [0.0, 1.0, 0.0]; // Default green const sphereColors = this.configuration.sphereColors || {}; if (cornerKey) { @@ -494,9 +496,9 @@ class VolumeCroppingTool extends AnnotationTool { } const defaultActor = viewport.getDefaultActor(); - if (defaultActor?.actor) { - // Cast to any to avoid type errors with different actor types - this.picker.addPickList(defaultActor.actor); + const actor = defaultActor.actor; + if (actor && (actor.isA?.('vtkActor') || actor.isA?.('vtkVolume'))) { + this.picker.addPickList(actor); this._prepareImageDataForPicking(viewport); } @@ -505,13 +507,6 @@ class VolumeCroppingTool extends AnnotationTool { element.addEventListener('mousemove', this._onMouseMoveSphere); element.addEventListener('mouseup', this._onMouseUpSphere); - mapper.modified(); - viewport.getDefaultActor().actor.modified(); - volumeActors.forEach((actorObj) => { - if (actorObj.actor && typeof actorObj.actor.modified === 'function') { - actorObj.actor.modified(); - } - }); mapper.addClippingPlane(planeXmin); mapper.addClippingPlane(planeXmax); mapper.addClippingPlane(planeYmin); @@ -1204,9 +1199,9 @@ class VolumeCroppingTool extends AnnotationTool { (point1[1] + point2[1]) / 2, (point1[2] + point2[2]) / 2, ]; - source.setCenter(center); + source.setCenter(center[0], center[1], center[2]); source.setHeight(length); - source.setDirection(direction); + source.setDirection(direction[0], direction[1], direction[2]); source.modified(); } }); diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 93d3dac8ec..21c4f70099 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -48,7 +48,7 @@ class ZoomTool extends BaseTool { this.mouseDragCallback = this._dragCallback.bind(this); } // Helper to transform a normal by a 3x3 matrix - _transformNormal(normal, mat) { + _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { return [ mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], @@ -83,9 +83,17 @@ class ZoomTool extends BaseTool { } mapper.removeAllClippingPlanes(); - originalPlanes.forEach(({ origin, normal }) => { + originalPlanes.forEach((plane) => { + const origin = + typeof plane.getOrigin === 'function' + ? plane.getOrigin() + : plane.origin; + const normal = + typeof plane.getNormal === 'function' + ? plane.getNormal() + : plane.normal; // Transform origin (full 4x4) - const o = [ + const o: Types.Point3 = [ matrix[0] * origin[0] + matrix[4] * origin[1] + matrix[8] * origin[2] + @@ -100,9 +108,9 @@ class ZoomTool extends BaseTool { matrix[14], ]; // Transform normal (rotation only) - const n = this._transformNormal(normal, rot); - const plane = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(plane); + const n: Types.Point3 = this._transformNormal(normal, rot); + const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(planeInstance); }); } From abdb5a6dc7e75988466be6660cfddc32023a0457 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:37:18 +0200 Subject: [PATCH 044/132] refactor(volumeCropping): Simplify addNewAnnotation and cancel methods, and add handleSelectedCallback in VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 20 ++++-- .../tools/src/tools/VolumeCroppingTool.ts | 61 +++++++++++-------- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 2e451100c8..01d730fa80 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -415,9 +415,9 @@ class VolumeCroppingControlTool extends AnnotationTool { * @returns annotation */ - addNewAnnotation = ( + addNewAnnotation( evt: EventTypes.InteractionEventType - ): VolumeCroppingAnnotation => { + ): VolumeCroppingAnnotation { const eventDetail = evt.detail; // console.debug('addNewAnnotation: ', eventDetail); @@ -466,7 +466,7 @@ class VolumeCroppingControlTool extends AnnotationTool { this._activateModify(element); return filteredAnnotations[0]; - }; + } cancel = () => { console.log('Not implemented yet'); @@ -511,6 +511,17 @@ class VolumeCroppingControlTool extends AnnotationTool { 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); + } + onCameraModified = (evt) => { const eventDetail = evt.detail; const { element } = eventDetail; @@ -1141,7 +1152,8 @@ class VolumeCroppingControlTool extends AnnotationTool { origin[2] + maxCropFactor * (dimensions[2] - 1) * spacing[2], ]; // Update all annotations' handles.toolCenter - const annotations = getAnnotations(this.getToolName()) || []; + const annotations = + getAnnotations(this.getToolName(), viewportId) || []; annotations.forEach((annotation) => { if (annotation.data && annotation.data.handles) { annotation.data.handles.toolCenter = [...this.toolCenter]; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index de22702827..a2e2cb498d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -45,6 +45,7 @@ import type { PublicToolProps, ToolProps, InteractionTypes, + SVGDrawingHelper, } from '../types'; import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; @@ -224,6 +225,42 @@ class VolumeCroppingTool extends AnnotationTool { this.picker.initializePickList(); } + addNewAnnotation( + evt: EventTypes.InteractionEventType + ): Annotation | undefined { + // Implement your logic here if needed + return undefined; + } + + cancel(): void { + // Implement your logic here if needed + } + + handleSelectedCallback( + evt: EventTypes.InteractionEventType, + annotation: Annotation, + handle: ToolHandle, + interactionType: InteractionTypes + ): void { + // Implement your logic here if needed + } + + toolSelectedCallback( + evt: EventTypes.InteractionEventType, + annotation: Annotation, + interactionType: InteractionTypes + ): void { + // Implement your logic here if needed + } + + renderAnnotation( + enabledElement: Types.IEnabledElement, + svgDrawingHelper: SVGDrawingHelper + ): boolean { + // Implement your logic here if needed + return false; + } + setHandlesVisible(visible: boolean) { this.configuration.showHandles = visible; // Remove or show actors accordingly @@ -1434,30 +1471,6 @@ class VolumeCroppingTool extends AnnotationTool { enabledElement, annotations ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - return ( - otherViewportControllable === true && - viewportAnnotation.data.activeViewportIds.find( - (id) => id === otherViewport.id - ) - ); - } - ); - /* - this._applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta - ); - */ }; _pointNearTool(element, annotation, canvasCoords, proximity) { From 804bb2b89ae45547e96ffb7b9fd271c4dee8a1a9 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:34:29 +0200 Subject: [PATCH 045/132] refactor(tools): Integrate VolumeCroppingTool's originalClippingPlanes into PanTool, TrackballRotateTool, and ZoomTool --- .../src/RenderingEngine/VolumeViewport3D.ts | 22 ---- packages/tools/src/tools/PanTool.ts | 9 +- .../tools/src/tools/TrackballRotateTool.ts | 6 +- .../tools/src/tools/VolumeCroppingTool.ts | 101 +++++++++++------- packages/tools/src/tools/ZoomTool.ts | 7 +- 5 files changed, 77 insertions(+), 68 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 404a1bde63..0aad75b55d 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -19,10 +19,6 @@ import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; * which will add volumes to the specified viewports. */ class VolumeViewport3D extends BaseVolumeViewport { - // Store original (axis-aligned) clipping planes for each viewport - - _originalClippingPlanes: vtkPlane[] = []; - constructor(props: ViewportInput) { super(props); const _originalClippingPlanes = []; @@ -39,24 +35,6 @@ class VolumeViewport3D extends BaseVolumeViewport { } } - // Set the original planes for a viewport - public setOriginalClippingPlanes(planes) { - this._originalClippingPlanes = planes; - // this.render(); - } - - // Set the original planes for a viewport - public setOriginalClippingPlane(index, origin) { - if (this._originalClippingPlanes[index]) { - this._originalClippingPlanes[index].origin = origin; - } - } - - // Set the original planes for a viewport - public getOriginalClippingPlanes() { - return this._originalClippingPlanes; - } - public getNumberOfSlices = (): number => { return 1; }; diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index e9a5d125b5..a1c21368ec 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -4,7 +4,7 @@ import type { Types } from '@cornerstonejs/core'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; - +import { getToolGroup } from '../store/ToolGroupManager'; /** * Tool that pans the camera in the plane defined by the viewPlaneNormal and the viewUp. */ @@ -53,8 +53,11 @@ class PanTool extends BaseTool { matrix[10], ]; - // Get original planes from the viewport (VolumeViewport3D) - const originalPlanes = viewport.getOriginalClippingPlanes?.(); + // --- Get the VolumeCroppingTool instance for this viewport --- + const toolGroup = getToolGroup(this.toolGroupId); + const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); + // Use the tool's originalClippingPlanes property + const originalPlanes = croppingTool?.originalClippingPlanes; if (!originalPlanes || !originalPlanes.length) { return; } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index d8ceb49de1..deb6b0a25a 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -182,7 +182,11 @@ class TrackballRotateTool extends BaseTool { matrix[10], ]; - const originalPlanes = viewport.getOriginalClippingPlanes(); + // --- Get the VolumeCroppingTool instance for this viewport --- + const toolGroup = getToolGroup(this.toolGroupId); + const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); + // Use the tool's originalClippingPlanes property + const originalPlanes = croppingTool?.originalClippingPlanes; if (!originalPlanes || originalPlanes.length === 0) { return; } diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index a2e2cb498d..298308e2f4 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,7 +1,5 @@ -import { vec2, vec3 } from 'gl-matrix'; import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; -import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; @@ -52,7 +50,6 @@ import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotatio const { RENDERING_DEFAULTS } = CONSTANTS; -//interface VolumeCroppingAnnotation extends Annotation { interface VolumeCroppingAnnotation extends Annotation { data: { handles: { @@ -166,6 +163,8 @@ function addCylinderBetweenPoints( */ class VolumeCroppingTool extends AnnotationTool { static toolName; + originalClippingPlanes: { origin: number[]; normal: number[] }[] = []; + sphereStates: { point: Types.Point3; axis: string; @@ -306,7 +305,6 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolPassive() { const viewportsInfo = this._getViewportsInfo(); - // this._initialize3DViewports(viewportsInfo); } onSetToolEnabled() { @@ -377,14 +375,9 @@ class VolumeCroppingTool extends AnnotationTool { //viewport.removeActor(uid); return; } - sphereActor.getProperty().setColor(color); - - // const sphereColors = this.configuration.sphereColors || {}; - sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); - // viewport.render(); } _initialize3DViewports = (viewportsInfo): void => { @@ -445,8 +438,9 @@ class VolumeCroppingTool extends AnnotationTool { origin: [...plane.getOrigin()], normal: [...plane.getNormal()], })); - viewport.setOriginalClippingPlanes(originalPlanes); - + // viewport.setOriginalClippingPlanes(originalPlanes); + // viewport.originalClippingPlanes = originalPlanes; + 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]; @@ -544,12 +538,17 @@ class VolumeCroppingTool extends AnnotationTool { element.addEventListener('mousemove', this._onMouseMoveSphere); element.addEventListener('mouseup', this._onMouseUpSphere); - mapper.addClippingPlane(planeXmin); - mapper.addClippingPlane(planeXmax); - mapper.addClippingPlane(planeYmin); - mapper.addClippingPlane(planeYmax); - mapper.addClippingPlane(planeZmin); - mapper.addClippingPlane(planeZmax); + if ( + typeof mapper.addClippingPlane === 'function' && + typeof mapper.removeAllClippingPlanes === 'function' + ) { + mapper.addClippingPlane(planeXmin); + mapper.addClippingPlane(planeXmax); + mapper.addClippingPlane(planeYmin); + mapper.addClippingPlane(planeYmax); + mapper.addClippingPlane(planeZmin); + mapper.addClippingPlane(planeZmax); + } eventTarget.addEventListener( Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, @@ -581,10 +580,12 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, toolMin[2]], normal: [0, 0, 1], }); - viewport.setOriginalClippingPlane(PLANEINDEX.XMIN, planeXmin.getOrigin()); - viewport.setOriginalClippingPlane(PLANEINDEX.YMIN, planeYmin.getOrigin()); - viewport.setOriginalClippingPlane(PLANEINDEX.ZMIN, planeZmin.getOrigin()); - + this.originalClippingPlanes[PLANEINDEX.XMIN].origin = + planeXmin.getOrigin(); + this.originalClippingPlanes[PLANEINDEX.YMIN].origin = + planeYmin.getOrigin(); + this.originalClippingPlanes[PLANEINDEX.ZMIN].origin = + planeZmin.getOrigin(); if (this.configuration.showHandles) { this.sphereStates[SPHEREINDEX.XMIN].point[0] = planeXmin.getOrigin()[0]; this.sphereStates[SPHEREINDEX.XMIN].sphereSource.setCenter( @@ -681,9 +682,15 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, toolMax[2]], normal: [0, 0, -1], }); - viewport.setOriginalClippingPlane(PLANEINDEX.XMAX, planeXmax.getOrigin()); - viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); - viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); + // viewport.setOriginalClippingPlane(PLANEINDEX.XMAX, planeXmax.getOrigin()); + // viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); + // viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); + this.originalClippingPlanes[PLANEINDEX.XMAX].origin = + planeXmax.getOrigin(); + this.originalClippingPlanes[PLANEINDEX.YMAX].origin = + planeYmax.getOrigin(); + this.originalClippingPlanes[PLANEINDEX.ZMAX].origin = + planeZmax.getOrigin(); if (this.configuration.showHandles) { // x this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = @@ -722,9 +729,7 @@ class VolumeCroppingTool extends AnnotationTool { this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] ); - // this.sphereStates[SPHEREINDEX.YMAX].sphereSource.sphereActor - // .getProperty() - // .setColor(this.sphereStates[SPHEREINDEX.YMAX].color); + this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); const otherYSphere = this.sphereStates.find( (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX @@ -877,7 +882,9 @@ class VolumeCroppingTool extends AnnotationTool { // --- Remove clipping planes before picking otherwise we cannot back out of the volume const mapper = viewport.getDefaultActor().actor.getMapper(); - const originalClippingPlanes = mapper.getClippingPlanes().slice(); + const originalClippingPlanes = (mapper as vtkMapper) + .getClippingPlanes() + .slice(); mapper.removeAllClippingPlanes(); this.picker.pick( [displayCoords[0], displayCoords[1], 0], @@ -1054,11 +1061,11 @@ class VolumeCroppingTool extends AnnotationTool { sphereState.point[1], sphereState.point[2] ); - viewport.setOriginalClippingPlane(planeIdx, [ + this.originalClippingPlanes[planeIdx].origin = [ sphereState.point[0], sphereState.point[1], sphereState.point[2], - ]); + ]; } // update the face sphere position after the clipping plane change }); @@ -1149,9 +1156,14 @@ class VolumeCroppingTool extends AnnotationTool { sphereState.sphereSource.modified(); this._updateCornerSpheres(viewport); - const clippingPlanes = mapper.getClippingPlanes(); + const clippingPlanes = (mapper as vtkMapper).getClippingPlanes(); clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); - viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + // viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + this.originalClippingPlanes[this.draggingSphereIndex].origin = [ + newPoint[0], + newPoint[1], + newPoint[2], + ]; viewport.render(); /// Send event with the new point triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { @@ -1289,6 +1301,23 @@ class VolumeCroppingTool extends AnnotationTool { console.debug('on reset camera'); }; + _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; + }; + mouseMoveCallback = ( evt: EventTypes.MouseMoveEventType, filteredToolAnnotations: Annotations @@ -1463,14 +1492,6 @@ class VolumeCroppingTool extends AnnotationTool { viewportsInfo.map(({ viewportId }) => viewportId) ); } - - // TRANSLATION - // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); }; _pointNearTool(element, annotation, canvasCoords, proximity) { diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 21c4f70099..56f5bd94f8 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -6,6 +6,7 @@ import { Enums, getEnabledElement } from '@cornerstonejs/core'; import { BaseTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { Events } from '../enums'; +import { getToolGroup } from '../store/ToolGroupManager'; /** * ZoomTool tool manipulates the camera zoom applied to a viewport. It @@ -76,8 +77,10 @@ class ZoomTool extends BaseTool { matrix[10], ]; - // Get original planes from the viewport (VolumeViewport3D) - const originalPlanes = viewport.getOriginalClippingPlanes?.(); + const toolGroup = getToolGroup(this.toolGroupId); + const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); + // Use the tool's originalClippingPlanes property + const originalPlanes = croppingTool?.originalClippingPlanes; if (!originalPlanes || !originalPlanes.length) { return; } From b439cfd43d23b4c88c25925c7902a49226b79252 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:36:00 +0200 Subject: [PATCH 046/132] refactor(volumeCropping): Enhance type definitions and add getVtkDisplayCoords method in VolumeCroppingTool --- .../tools/src/tools/VolumeCroppingTool.ts | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 298308e2f4..38c2fc6937 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,10 +1,11 @@ import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; 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 vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; +import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; +import type vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import { AnnotationTool } from './base'; @@ -19,7 +20,6 @@ import { triggerEvent, eventTarget, } from '@cornerstonejs/core'; -import { Enums as toolsEnums } from '@cornerstonejs/tools'; import { getToolGroup, @@ -33,6 +33,7 @@ import { resetElementCursor, hideElementCursor, } from '../cursors/elementCursor'; +import { getAnnotations } from '../stateManagement/annotation/annotationState'; // <-- Add this import import * as lineSegment from '../utilities/math/line'; import type { @@ -48,13 +49,21 @@ import type { import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; -const { RENDERING_DEFAULTS } = CONSTANTS; - +//const { RENDERING_DEFAULTS } = CONSTANTS; +declare module '@cornerstonejs/core' { + interface IViewport { + /** + * Converts canvas coordinates [x, y] to VTK display coordinates [x, y]. + * VTK display coordinates have their origin at the bottom-left of the renderer. + * @param {number[]} canvasCoords - The [x, y] canvas coordinates. + * @returns {[number, number]} The VTK display coordinates. + */ + getVtkDisplayCoords(canvasCoords: [number, number]): [number, number]; + } +} interface VolumeCroppingAnnotation extends Annotation { data: { handles: { - // rotationPoints: Types.Point3[]; // rotation handles, used for rotation interactions - // slabThicknessPoints: Types.Point3[]; // slab thickness handles, used for setting the slab thickness activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles toolCenter: Types.Point3; }; @@ -144,6 +153,7 @@ function addCylinderBetweenPoints( cylinderSource.setDirection(direction[0], direction[1], direction[2]); const cylinderMapper = vtkMapper.newInstance(); + //const cylinderMapper = vtkVolumeMapper.newInstance(); cylinderMapper.setInputConnection(cylinderSource.getOutputPort()); const cylinderActor = vtkActor.newInstance(); cylinderActor.setMapper(cylinderMapper); @@ -158,8 +168,7 @@ function addCylinderBetweenPoints( } /** - * VolumeCroppingTool is a tool that provides reference lines between different viewports - * of a toolGroup. * + * VolumeCroppingTool is a tool that provides clipping planes to crop a volume */ class VolumeCroppingTool extends AnnotationTool { static toolName; @@ -426,7 +435,9 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, zMax], normal: [0, 0, -1], }); - const mapper = viewport.getDefaultActor().actor.getMapper(); + const mapper = viewport + .getDefaultActor() + .actor.getMapper() as vtkVolumeMapper; planes.push(planeXmin); planes.push(planeXmax); planes.push(planeYmin); @@ -438,8 +449,7 @@ class VolumeCroppingTool extends AnnotationTool { origin: [...plane.getOrigin()], normal: [...plane.getNormal()], })); - // viewport.setOriginalClippingPlanes(originalPlanes); - // viewport.originalClippingPlanes = originalPlanes; + this.originalClippingPlanes = originalPlanes; const sphereXminPoint = [xMin, (yMax + yMin) / 2, (zMax + zMin) / 2]; const sphereXmaxPoint = [xMax, (yMax + yMin) / 2, (zMax + zMin) / 2]; @@ -527,7 +537,7 @@ class VolumeCroppingTool extends AnnotationTool { } const defaultActor = viewport.getDefaultActor(); - const actor = defaultActor.actor; + const actor = defaultActor.actor as vtkActor | vtkVolume; if (actor && (actor.isA?.('vtkActor') || actor.isA?.('vtkVolume'))) { this.picker.addPickList(actor); this._prepareImageDataForPicking(viewport); @@ -663,7 +673,7 @@ class VolumeCroppingTool extends AnnotationTool { console.warn('No volume actor found'); return; } - const mapper = volumeActor.getMapper(); + const mapper = volumeActor.getMapper() as vtkVolumeMapper; const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); @@ -781,7 +791,7 @@ class VolumeCroppingTool extends AnnotationTool { }); } const volumeActor = viewport.getDefaultActor()?.actor; - const mapper = volumeActor.getMapper(); + const mapper = volumeActor.getMapper() as vtkVolumeMapper; const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); @@ -876,15 +886,13 @@ class VolumeCroppingTool extends AnnotationTool { const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; - const displayCoords = viewport.getVtkDisplayCoords([x, y]); - // Use the picker to get the 3D coordinates // --- Remove clipping planes before picking otherwise we cannot back out of the volume - const mapper = viewport.getDefaultActor().actor.getMapper(); - const originalClippingPlanes = (mapper as vtkMapper) - .getClippingPlanes() - .slice(); + const mapper = viewport + .getDefaultActor() + .actor.getMapper() as vtkVolumeMapper; + const originalClippingPlanes = mapper.getClippingPlanes().slice(); mapper.removeAllClippingPlanes(); this.picker.pick( [displayCoords[0], displayCoords[1], 0], @@ -905,7 +913,7 @@ class VolumeCroppingTool extends AnnotationTool { console.warn('No volume actor found'); return; } - const mapper = volumeActor.getMapper(); + const mapper = volumeActor.getMapper() as vtkVolumeMapper; if (sphereState.isCorner) { // Save the old position const oldX = sphereState.point[0]; @@ -1156,9 +1164,12 @@ class VolumeCroppingTool extends AnnotationTool { sphereState.sphereSource.modified(); this._updateCornerSpheres(viewport); - const clippingPlanes = (mapper as vtkMapper).getClippingPlanes(); - clippingPlanes[this.draggingSphereIndex].setOrigin(newPoint); - // viewport.setOriginalClippingPlane(this.draggingSphereIndex, newPoint); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[this.draggingSphereIndex].setOrigin( + newPoint[0], + newPoint[1], + newPoint[2] + ); this.originalClippingPlanes[this.draggingSphereIndex].origin = [ newPoint[0], newPoint[1], From 1bf889b0f5db1a12e385be79bfabd856ec0527a7 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 23:23:50 +0200 Subject: [PATCH 047/132] refactor(volumeCropping): Clean up imports and adjust getVtkDisplayCoords usage in VolumeCroppingTool --- .../src/RenderingEngine/VolumeViewport3D.ts | 2 -- .../tools/src/tools/VolumeCroppingTool.ts | 31 ++++++------------- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 0aad75b55d..03fb73f16d 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -10,7 +10,6 @@ import type vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import type { ViewportInput } from '../types/IViewport'; import type { ImageActor } from '../types/IActor'; import BaseVolumeViewport from './BaseVolumeViewport'; -import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; /** * An object representing a 3-dimensional volume viewport. VolumeViewport3Ds are used to render * 3D volumes in their entirety, and not just load a single slice at a time. @@ -21,7 +20,6 @@ import type vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; class VolumeViewport3D extends BaseVolumeViewport { constructor(props: ViewportInput) { super(props); - const _originalClippingPlanes = []; const { parallelProjection, orientation } = this.options; const activeCamera = this.getVtkActiveCamera(); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 38c2fc6937..6234657145 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -16,23 +16,16 @@ import { getEnabledElement, utilities as csUtils, Enums, - CONSTANTS, triggerEvent, eventTarget, } from '@cornerstonejs/core'; -import { - getToolGroup, - getToolGroupForViewport, -} from '../store/ToolGroupManager'; +import { getToolGroup } from '../store/ToolGroupManager'; import { state } from '../store/state'; import { Events } from '../enums'; import { getViewportIdsWithToolToRender } from '../utilities/viewportFilters'; -import { - resetElementCursor, - hideElementCursor, -} from '../cursors/elementCursor'; +import { resetElementCursor } from '../cursors/elementCursor'; import { getAnnotations } from '../stateManagement/annotation/annotationState'; // <-- Add this import import * as lineSegment from '../utilities/math/line'; @@ -49,18 +42,6 @@ import type { import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; -//const { RENDERING_DEFAULTS } = CONSTANTS; -declare module '@cornerstonejs/core' { - interface IViewport { - /** - * Converts canvas coordinates [x, y] to VTK display coordinates [x, y]. - * VTK display coordinates have their origin at the bottom-left of the renderer. - * @param {number[]} canvasCoords - The [x, y] canvas coordinates. - * @returns {[number, number]} The VTK display coordinates. - */ - getVtkDisplayCoords(canvasCoords: [number, number]): [number, number]; - } -} interface VolumeCroppingAnnotation extends Annotation { data: { handles: { @@ -886,7 +867,13 @@ class VolumeCroppingTool extends AnnotationTool { const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; - const displayCoords = viewport.getVtkDisplayCoords([x, y]); + // const displayCoords = viewport.getVtkDisplayCoords([x, y]); + //const displayCoords = (viewport as any).getVtkDisplayCoords([x, y]); + const displayCoords = ( + viewport as unknown as { + getVtkDisplayCoords: (coords: [number, number]) => [number, number]; + } + ).getVtkDisplayCoords([x, y]); // --- Remove clipping planes before picking otherwise we cannot back out of the volume const mapper = viewport From 99f2c766104e03293887fa1a619b4cb57de308a8 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 23:29:02 +0200 Subject: [PATCH 048/132] refactor(volumeCropping): Remove commented-out code and improve clarity in VolumeCroppingTool and VolumeCroppingControlTool --- .../src/tools/VolumeCroppingControlTool.ts | 22 ------------------- .../tools/src/tools/VolumeCroppingTool.ts | 12 ++-------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 01d730fa80..1066114b2d 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -280,7 +280,6 @@ class VolumeCroppingControlTool extends AnnotationTool { this._unsubscribeToViewportNewVolumeSet(viewportsInfo); - // Crosshairs annotations in the state // has no value when the tool is disabled // since viewports can change (zoom, pan, scroll) // between disabled and enabled/active states. @@ -375,17 +374,6 @@ class VolumeCroppingControlTool extends AnnotationTool { vec3.scale(point3, point3, 0.5); vec3.cross(normal3, normal1, normal2); } - - // Planes of each viewport - const firstPlane = csUtils.planar.planeEquation(normal1, point1); - const secondPlane = csUtils.planar.planeEquation(normal2, point2); - const thirdPlane = csUtils.planar.planeEquation(normal3, point3); - - const toolCenter = csUtils.planar.threePlaneIntersection( - firstPlane, - secondPlane, - thirdPlane - ); }; setToolCenter(toolCenter: Types.Point3, handleType): void { @@ -419,18 +407,9 @@ class VolumeCroppingControlTool extends AnnotationTool { evt: EventTypes.InteractionEventType ): VolumeCroppingAnnotation { const eventDetail = evt.detail; - - // console.debug('addNewAnnotation: ', eventDetail); const { element } = eventDetail; - - const { currentPoints } = eventDetail; - const jumpWorld = currentPoints.world; - const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - - //this._jump(enabledElement, jumpWorld); - const annotations = this._getAnnotations(enabledElement); const filteredAnnotations = this.filterInteractableAnnotationsForElement( viewport.element, @@ -1703,7 +1682,6 @@ class VolumeCroppingControlTool extends AnnotationTool { ) { // update camera for the other viewports. // NOTE1: The lines then are rendered by the onCameraModified - // NOTE2: crosshair center are automatically updated in the onCameraModified event viewportsAnnotationsToUpdate.forEach((annotation) => { this._applyDeltaShiftToViewportCamera(renderingEngine, annotation, delta); }); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 6234657145..10636ea241 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -867,8 +867,6 @@ class VolumeCroppingTool extends AnnotationTool { const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; - // const displayCoords = viewport.getVtkDisplayCoords([x, y]); - //const displayCoords = (viewport as any).getVtkDisplayCoords([x, y]); const displayCoords = ( viewport as unknown as { getVtkDisplayCoords: (coords: [number, number]) => [number, number]; @@ -1132,13 +1130,11 @@ class VolumeCroppingTool extends AnnotationTool { state.point[1], state.point[2] ); - // state.sphereActor.getProperty().setColor(state.color); state.sphereSource.modified(); } }); } - // sphereState.point = newPoint as Types.Point3; this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; @@ -1255,16 +1251,12 @@ class VolumeCroppingTool extends AnnotationTool { } _onMouseUpSphere = (evt) => { - //evt.stopPropagation(); - // evt.preventDefault(); - // if (this.draggingSphereIndex !== null) { evt.currentTarget.style.cursor = ''; - // } this.draggingSphereIndex = null; }; /** - * It returns if the canvas point is near the provided crosshairs annotation in the + * It returns if the canvas point is near the provided reference line 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. * @@ -1461,7 +1453,7 @@ class VolumeCroppingTool extends AnnotationTool { const { element } = eventDetail; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; if (viewport.type === Enums.ViewportType.VOLUME_3D) { return; } From 0a5058fa54343babc9e73482098d0785125e01ad Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 9 Jul 2025 23:33:16 +0200 Subject: [PATCH 049/132] refactor(volumeViewport3D): Remove unnecessary blank lines in VolumeViewport3D constructor --- packages/core/src/RenderingEngine/VolumeViewport3D.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/RenderingEngine/VolumeViewport3D.ts b/packages/core/src/RenderingEngine/VolumeViewport3D.ts index 03fb73f16d..da477d981b 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport3D.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport3D.ts @@ -10,6 +10,7 @@ import type vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import type { ViewportInput } from '../types/IViewport'; import type { ImageActor } from '../types/IActor'; import BaseVolumeViewport from './BaseVolumeViewport'; + /** * An object representing a 3-dimensional volume viewport. VolumeViewport3Ds are used to render * 3D volumes in their entirety, and not just load a single slice at a time. @@ -20,6 +21,7 @@ import BaseVolumeViewport from './BaseVolumeViewport'; class VolumeViewport3D extends BaseVolumeViewport { constructor(props: ViewportInput) { super(props); + const { parallelProjection, orientation } = this.options; const activeCamera = this.getVtkActiveCamera(); From 622e4e9782f588fce5f45b4ee3beaa9a124e9030 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:42:23 +0200 Subject: [PATCH 050/132] refactor(volumeCropping): Make initial crop factor configurable in VolumeCroppingTool. Remove 0.8 --- packages/tools/src/tools/VolumeCroppingTool.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 10636ea241..2a025cdd3d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -379,7 +379,7 @@ class VolumeCroppingTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const cropFactor = 0.2; + const cropFactor = this.configuration.initialCropFactor || 0.2; const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; const xMax = origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; @@ -387,7 +387,8 @@ class VolumeCroppingTool extends AnnotationTool { const yMax = origin[1] + (1 - cropFactor) * (dimensions[1] - 1) * spacing[1]; const zMin = origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2]; - const zMax = origin[2] + 0.8 * (dimensions[2] - 1) * spacing[2]; + const zMax = + origin[2] + (1 - cropFactor) * (dimensions[2] - 1) * spacing[2]; const planes: vtkPlane[] = []; From b809a48559b1ea522a1c524b182be95b4dc0bd3b Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:46:08 +0200 Subject: [PATCH 051/132] fix(volumeCropping): Add checks for volume actors and image data in VolumeCroppingTool --- packages/tools/src/tools/VolumeCroppingTool.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 2a025cdd3d..406becac4d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -375,7 +375,17 @@ class VolumeCroppingTool extends AnnotationTool { const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); 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; + } const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] From d3f2efc39535aef5d8c36c752f14908f58895b78 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:49:31 +0200 Subject: [PATCH 052/132] refactor(volumeCropping): Simplify actor checks and clipping plane additions in VolumeCroppingTool --- .../tools/src/tools/VolumeCroppingTool.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 406becac4d..e79263ba96 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -530,27 +530,20 @@ class VolumeCroppingTool extends AnnotationTool { const defaultActor = viewport.getDefaultActor(); const actor = defaultActor.actor as vtkActor | vtkVolume; - if (actor && (actor.isA?.('vtkActor') || actor.isA?.('vtkVolume'))) { - this.picker.addPickList(actor); - this._prepareImageDataForPicking(viewport); - } + this.picker.addPickList(actor); + this._prepareImageDataForPicking(viewport); const element = viewport.canvas || viewport.element; element.addEventListener('mousedown', this._onMouseDownSphere); element.addEventListener('mousemove', this._onMouseMoveSphere); element.addEventListener('mouseup', this._onMouseUpSphere); - if ( - typeof mapper.addClippingPlane === 'function' && - typeof mapper.removeAllClippingPlanes === 'function' - ) { - mapper.addClippingPlane(planeXmin); - mapper.addClippingPlane(planeXmax); - mapper.addClippingPlane(planeYmin); - mapper.addClippingPlane(planeYmax); - mapper.addClippingPlane(planeZmin); - mapper.addClippingPlane(planeZmax); - } + mapper.addClippingPlane(planeXmin); + mapper.addClippingPlane(planeXmax); + mapper.addClippingPlane(planeYmin); + mapper.addClippingPlane(planeYmax); + mapper.addClippingPlane(planeZmin); + mapper.addClippingPlane(planeZmax); eventTarget.addEventListener( Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, From 807b90c7b1365b478f1d96d6b925dd8faa201e88 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 13:59:34 +0200 Subject: [PATCH 053/132] fix(volumeCropping): Add checks for volume actors and use enums for axial.sagittal,coronal --- .../src/tools/VolumeCroppingControlTool.ts | 8 ++++-- .../tools/src/tools/VolumeCroppingTool.ts | 28 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 1066114b2d..adc000f811 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -154,9 +154,13 @@ class VolumeCroppingControlTool extends AnnotationTool { 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(); - - // const imageData = enabledElement?.viewport?.getImageData?.(); if (imageData) { const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index e79263ba96..d4cc0cffea 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -598,7 +598,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') // sagittal is y axis in yellow + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.SAGITTAL + ) // sagittal is y axis in yellow ) { state.point[0] = newXCenter; state.sphereSource.setCenter(state.point); @@ -621,7 +623,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') // coronal is x axis in green + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.CORONAL + ) // coronal is x axis in green ) { state.point[1] = newYCenter; state.sphereSource.setCenter(state.point); @@ -645,7 +649,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') // axial is z axis in red + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.AXIAL + ) // axial is z axis in red ) { state.point[2] = newZCenter; state.sphereSource.setCenter(state.point); @@ -677,9 +683,6 @@ class VolumeCroppingTool extends AnnotationTool { origin: [0, 0, toolMax[2]], normal: [0, 0, -1], }); - // viewport.setOriginalClippingPlane(PLANEINDEX.XMAX, planeXmax.getOrigin()); - // viewport.setOriginalClippingPlane(PLANEINDEX.YMAX, planeYmax.getOrigin()); - // viewport.setOriginalClippingPlane(PLANEINDEX.ZMAX, planeZmax.getOrigin()); this.originalClippingPlanes[PLANEINDEX.XMAX].origin = planeXmax.getOrigin(); this.originalClippingPlanes[PLANEINDEX.YMAX].origin = @@ -707,7 +710,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'x' && - !evt.detail.viewportOrientation.includes('sagittal') + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.SAGITTAL + ) ) { state.point[POINTINDEX.X] = newXCenter; state.sphereSource.setCenter(state.point); @@ -737,7 +742,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'y' && - !evt.detail.viewportOrientation.includes('coronal') + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.CORONAL + ) ) { state.point[POINTINDEX.Y] = newYCenter; state.sphereSource.setCenter(state.point); @@ -766,7 +773,9 @@ class VolumeCroppingTool extends AnnotationTool { if ( !state.isCorner && state.axis !== 'z' && - !evt.detail.viewportOrientation.includes('axial') + !evt.detail.viewportOrientation.includes( + Enums.OrientationAxis.AXIAL + ) // axial is z axis in red ) { state.point[POINTINDEX.Z] = newZCenter; state.sphereSource.setCenter(state.point); @@ -788,7 +797,6 @@ class VolumeCroppingTool extends AnnotationTool { ) { this._updateCornerSpheres(viewport); } - // this._updateCornerSpheres(viewport); viewport.render(); }; From eb85f2579078511012bf3791f51c0726bf62d4ce Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:44:50 +0200 Subject: [PATCH 054/132] Replace clipping plane handling in pan,zoom and rotate by events. --- packages/tools/src/enums/Events.ts | 6 ++ packages/tools/src/tools/PanTool.ts | 82 +++--------------- .../tools/src/tools/TrackballRotateTool.ts | 76 +---------------- .../tools/src/tools/VolumeCroppingTool.ts | 74 ++++++++++++++++ packages/tools/src/tools/ZoomTool.ts | 85 +++---------------- 5 files changed, 106 insertions(+), 217 deletions(-) diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index f32a532921..d5a6488b87 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -39,6 +39,12 @@ enum Events { VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', + ZOOM_TOOL_CHANGED = 'CORNERSTONE_TOOLS_ZOOM_TOOL_CHANGED', + + PAN_TOOL_CHANGED = 'CORNERSTONE_TOOLS_PAN_TOOL_CHANGED', + + TRACKBALLROTATE_TOOL_CHANGED = 'CORNERSTONE_TOOLS_TRACKBALLROTATE_TOOL_CHANGED', + /////////////////////////////////////// // Annotations /////////////////////////////////////// diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index a1c21368ec..aaf1455348 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -1,9 +1,14 @@ import { BaseTool } from './base'; -import { getEnabledElement } from '@cornerstonejs/core'; +import { + getEnabledElement, + triggerEvent, + eventTarget, +} from '@cornerstonejs/core'; +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; +import { Events } from '../enums'; import type { Types } from '@cornerstonejs/core'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; -import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { getToolGroup } from '../store/ToolGroupManager'; /** * Tool that pans the camera in the plane defined by the viewPlaneNormal and the viewUp. @@ -26,74 +31,6 @@ class PanTool extends BaseTool { mouseDragCallback(evt: EventTypes.InteractionEventType) { this._dragCallback(evt); } - _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { - return [ - mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], - mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], - mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], - ]; - } - - _updateClippingPlanes(viewport) { - const actorEntry = viewport.getDefaultActor(); - const actor = actorEntry.actor; - const mapper = actor.getMapper(); - const matrix = actor.getMatrix(); - - // Extract rotation part for normals - const rot = [ - matrix[0], - matrix[1], - matrix[2], - matrix[4], - matrix[5], - matrix[6], - matrix[8], - matrix[9], - matrix[10], - ]; - - // --- Get the VolumeCroppingTool instance for this viewport --- - const toolGroup = getToolGroup(this.toolGroupId); - const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); - // Use the tool's originalClippingPlanes property - const originalPlanes = croppingTool?.originalClippingPlanes; - if (!originalPlanes || !originalPlanes.length) { - return; - } - - mapper.removeAllClippingPlanes(); - originalPlanes.forEach((plane) => { - const origin = - typeof plane.getOrigin === 'function' - ? plane.getOrigin() - : plane.origin; - const normal = - typeof plane.getNormal === 'function' - ? plane.getNormal() - : plane.normal; - - // Transform origin (full 4x4) - const o: Types.Point3 = [ - matrix[0] * origin[0] + - matrix[4] * origin[1] + - matrix[8] * origin[2] + - matrix[12], - matrix[1] * origin[0] + - matrix[5] * origin[1] + - matrix[9] * origin[2] + - matrix[13], - matrix[2] * origin[0] + - matrix[6] * origin[1] + - matrix[10] * origin[2] + - matrix[14], - ]; - // Transform normal (rotation only) - const n: Types.Point3 = this._transformNormal(normal, rot); - const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(planeInstance); - }); - } _dragCallback(evt: EventTypes.InteractionEventType) { const { element, deltaPoints } = evt.detail; @@ -127,8 +64,9 @@ class PanTool extends BaseTool { focalPoint: updatedFocalPoint, position: updatedPosition, }); - // Update clipping planes after pan - this._updateClippingPlanes(enabledElement.viewport); + triggerEvent(eventTarget, Events.PAN_TOOL_CHANGED, { + viewport: enabledElement.viewport, + }); enabledElement.viewport.render(); } } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index deb6b0a25a..f8a470b0e2 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -3,6 +3,7 @@ import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import { Events } from '../enums'; import { eventTarget, + triggerEvent, getEnabledElement, getEnabledElementByIds, Enums, @@ -154,76 +155,6 @@ class TrackballRotateTool extends BaseTool { } }; - // Helper to transform a normal by a 3x3 matrix - _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { - return [ - mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], - mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], - mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], - ]; - } - - // Update all clipping planes after rotation - _updateClippingPlanes(viewport) { - const actorEntry = viewport.getDefaultActor(); - const actor = actorEntry.actor as Types.VolumeActor; - const mapper = actor.getMapper(); - const matrix = actor.getMatrix(); - // Extract rotation part for normals - const rot = [ - matrix[0], - matrix[1], - matrix[2], - matrix[4], - matrix[5], - matrix[6], - matrix[8], - matrix[9], - matrix[10], - ]; - - // --- Get the VolumeCroppingTool instance for this viewport --- - const toolGroup = getToolGroup(this.toolGroupId); - const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); - // Use the tool's originalClippingPlanes property - const originalPlanes = croppingTool?.originalClippingPlanes; - if (!originalPlanes || originalPlanes.length === 0) { - return; - } - - mapper.removeAllClippingPlanes(); - originalPlanes.forEach((plane) => { - const origin = - typeof plane.getOrigin === 'function' - ? plane.getOrigin() - : plane.origin; - const normal = - typeof plane.getNormal === 'function' - ? plane.getNormal() - : plane.normal; - - // Transform origin (full 4x4) - const o: Types.Point3 = [ - matrix[0] * origin[0] + - matrix[4] * origin[1] + - matrix[8] * origin[2] + - matrix[12], - matrix[1] * origin[0] + - matrix[5] * origin[1] + - matrix[9] * origin[2] + - matrix[13], - matrix[2] * origin[0] + - matrix[6] * origin[1] + - matrix[10] * origin[2] + - matrix[14], - ]; - // Transform normal (rotation only) - const n: Types.Point3 = this._transformNormal(normal, rot); - const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(planeInstance); - }); - } - rotateCamera = (viewport, centerWorld, axis, angle) => { const vtkCamera = viewport.getVtkActiveCamera(); const viewUp = vtkCamera.getViewUp(); @@ -255,8 +186,9 @@ class TrackballRotateTool extends BaseTool { focalPoint: newFocalPoint, }); - // Update clipping planes after rotation - this._updateClippingPlanes(viewport); + triggerEvent(eventTarget, Events.PAN_TOOL_CHANGED, { + viewport: viewport, + }); }; _dragCallback(evt: EventTypes.InteractionEventType): void { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index d4cc0cffea..9e5eced4f8 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -551,8 +551,82 @@ class VolumeCroppingTool extends AnnotationTool { this._onControlToolChange(evt); } ); + eventTarget.addEventListener(Events.ZOOM_TOOL_CHANGED, (evt) => { + this._updateClippingPlanes(evt.detail.viewport); + }); + eventTarget.addEventListener(Events.PAN_TOOL_CHANGED, (evt) => { + this._updateClippingPlanes(evt.detail.viewport); + }); + eventTarget.addEventListener(Events.TRACKBALLROTATE_TOOL_CHANGED, (evt) => { + this._updateClippingPlanes(evt.detail.viewport); + }); }; + _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { + return [ + mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], + mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], + mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], + ]; + } + + _updateClippingPlanes(viewport) { + // Get the actor and transformation matrix + const actorEntry = viewport.getDefaultActor(); + const actor = actorEntry.actor; + const mapper = actor.getMapper(); + const matrix = actor.getMatrix(); + + // Extract rotation part for normals + const rot = [ + matrix[0], + matrix[1], + matrix[2], + matrix[4], + matrix[5], + matrix[6], + matrix[8], + matrix[9], + matrix[10], + ]; + + const originalPlanes = this.originalClippingPlanes; + if (!originalPlanes || !originalPlanes.length) { + return; + } + + mapper.removeAllClippingPlanes(); + originalPlanes.forEach((plane) => { + const origin = + typeof plane.getOrigin === 'function' + ? plane.getOrigin() + : plane.origin; + const normal = + typeof plane.getNormal === 'function' + ? plane.getNormal() + : plane.normal; + // Transform origin (full 4x4) + const o: Types.Point3 = [ + matrix[0] * origin[0] + + matrix[4] * origin[1] + + matrix[8] * origin[2] + + matrix[12], + matrix[1] * origin[0] + + matrix[5] * origin[1] + + matrix[9] * origin[2] + + matrix[13], + matrix[2] * origin[0] + + matrix[6] * origin[1] + + matrix[10] * origin[2] + + matrix[14], + ]; + // Transform normal (rotation only) + const n: Types.Point3 = this._transformNormal(normal, rot); + const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); + mapper.addClippingPlane(planeInstance); + }); + } + _onControlToolChange = (evt) => { // coronal is y axis in green // sagittal is x axis in yellow diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 56f5bd94f8..fba68b3041 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -2,7 +2,12 @@ import { vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import type { Types } from '@cornerstonejs/core'; -import { Enums, getEnabledElement } from '@cornerstonejs/core'; +import { + Enums, + getEnabledElement, + triggerEvent, + eventTarget, +} from '@cornerstonejs/core'; import { BaseTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { Events } from '../enums'; @@ -48,74 +53,6 @@ class ZoomTool extends BaseTool { } this.mouseDragCallback = this._dragCallback.bind(this); } - // Helper to transform a normal by a 3x3 matrix - _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { - return [ - mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], - mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], - mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], - ]; - } - - _updateClippingPlanes(viewport) { - // Get the actor and transformation matrix - const actorEntry = viewport.getDefaultActor(); - const actor = actorEntry.actor; - const mapper = actor.getMapper(); - const matrix = actor.getMatrix(); - - // Extract rotation part for normals - const rot = [ - matrix[0], - matrix[1], - matrix[2], - matrix[4], - matrix[5], - matrix[6], - matrix[8], - matrix[9], - matrix[10], - ]; - - const toolGroup = getToolGroup(this.toolGroupId); - const croppingTool = toolGroup?.getToolInstance?.('VolumeCroppingTool'); - // Use the tool's originalClippingPlanes property - const originalPlanes = croppingTool?.originalClippingPlanes; - if (!originalPlanes || !originalPlanes.length) { - return; - } - - mapper.removeAllClippingPlanes(); - originalPlanes.forEach((plane) => { - const origin = - typeof plane.getOrigin === 'function' - ? plane.getOrigin() - : plane.origin; - const normal = - typeof plane.getNormal === 'function' - ? plane.getNormal() - : plane.normal; - // Transform origin (full 4x4) - const o: Types.Point3 = [ - matrix[0] * origin[0] + - matrix[4] * origin[1] + - matrix[8] * origin[2] + - matrix[12], - matrix[1] * origin[0] + - matrix[5] * origin[1] + - matrix[9] * origin[2] + - matrix[13], - matrix[2] * origin[0] + - matrix[6] * origin[1] + - matrix[10] * origin[2] + - matrix[14], - ]; - // Transform normal (rotation only) - const n: Types.Point3 = this._transformNormal(normal, rot); - const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); - mapper.addClippingPlane(planeInstance); - }); - } mouseWheelCallback(evt: EventTypes.MouseWheelEventType) { this._zoom(evt); @@ -185,9 +122,10 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera, true); } - // Update clipping planes after zoom - this._updateClippingPlanes(viewport); + triggerEvent(eventTarget, Events.ZOOM_TOOL_CHANGED, { + viewport: viewport, + }); viewport.render(); } @@ -209,9 +147,10 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera); } - // Update clipping planes after zoom - this._updateClippingPlanes(viewport); + triggerEvent(eventTarget, Events.ZOOM_TOOL_CHANGED, { + viewport: viewport, + }); viewport.render(); } From 4f1597f206e3fca1d3ddf09deb74baaac30061cf Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:54:56 +0200 Subject: [PATCH 055/132] refactor(volumeCropping): Simplify origin and normal extraction in VolumeCroppingTool --- .../src/tools/VolumeCroppingControlTool.ts | 5 ----- .../tools/src/tools/VolumeCroppingTool.ts | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index adc000f811..02ca5d5054 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -617,11 +617,6 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolCenterMax[1] += deltaCameraPosition[1]; this.toolCenterMax[2] += deltaCameraPosition[2]; } - - // triggerEvent(eventTarget, Events.CROSSHAIR_TOOL_CENTER_CHANGED, { - // toolGroupId: this.toolGroupId, - // toolCenter: this.toolCenter, - // }); } const viewportAnnotation = filteredToolAnnotations[0]; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 9e5eced4f8..c33b97674d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -597,14 +597,16 @@ class VolumeCroppingTool extends AnnotationTool { mapper.removeAllClippingPlanes(); originalPlanes.forEach((plane) => { - const origin = - typeof plane.getOrigin === 'function' - ? plane.getOrigin() - : plane.origin; - const normal = - typeof plane.getNormal === 'function' - ? plane.getNormal() - : plane.normal; + const origin: Types.Point3 = [ + plane.origin[0], + plane.origin[1], + plane.origin[2], + ]; + const normal: Types.Point3 = [ + plane.normal[0], + plane.normal[1], + plane.normal[2], + ]; // Transform origin (full 4x4) const o: Types.Point3 = [ matrix[0] * origin[0] + @@ -621,7 +623,7 @@ class VolumeCroppingTool extends AnnotationTool { matrix[14], ]; // Transform normal (rotation only) - const n: Types.Point3 = this._transformNormal(normal, rot); + const n = this._transformNormal(normal, rot); const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); mapper.addClippingPlane(planeInstance); }); From 970b24947a05fef779106f1358bc62e4f393101a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:03:48 +0200 Subject: [PATCH 056/132] refactor(tools): Remove unused vtkPlane imports from PanTool, TrackballRotateTool, and ZoomTool --- packages/tools/src/tools/PanTool.ts | 2 -- packages/tools/src/tools/TrackballRotateTool.ts | 1 - packages/tools/src/tools/ZoomTool.ts | 2 -- 3 files changed, 5 deletions(-) diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index aaf1455348..fb30698352 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -7,9 +7,7 @@ import { import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { Events } from '../enums'; import type { Types } from '@cornerstonejs/core'; -import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; -import { getToolGroup } from '../store/ToolGroupManager'; /** * Tool that pans the camera in the plane defined by the viewPlaneNormal and the viewUp. */ diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index f8a470b0e2..23a6c26720 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -1,5 +1,4 @@ import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import { Events } from '../enums'; import { eventTarget, diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index fba68b3041..311529dce0 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -1,6 +1,5 @@ import { vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; -import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import type { Types } from '@cornerstonejs/core'; import { Enums, @@ -11,7 +10,6 @@ import { import { BaseTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import { Events } from '../enums'; -import { getToolGroup } from '../store/ToolGroupManager'; /** * ZoomTool tool manipulates the camera zoom applied to a viewport. It From a8f5eb96f6250100863411304b558c7ad0a8246f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:50:15 +0200 Subject: [PATCH 057/132] Use CAMERA_MODIFIED event name. --- packages/tools/src/enums/Events.ts | 6 ------ packages/tools/src/tools/PanTool.ts | 5 +++-- .../tools/src/tools/TrackballRotateTool.ts | 2 +- .../tools/src/tools/VolumeCroppingTool.ts | 19 +++++++------------ packages/tools/src/tools/ZoomTool.ts | 6 +++--- 5 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/enums/Events.ts b/packages/tools/src/enums/Events.ts index d5a6488b87..f32a532921 100644 --- a/packages/tools/src/enums/Events.ts +++ b/packages/tools/src/enums/Events.ts @@ -39,12 +39,6 @@ enum Events { VOLUMECROPPING_TOOL_CHANGED = 'CORNERSTONE_TOOLS_VOLUMECROPPING_TOOL_CHANGED', - ZOOM_TOOL_CHANGED = 'CORNERSTONE_TOOLS_ZOOM_TOOL_CHANGED', - - PAN_TOOL_CHANGED = 'CORNERSTONE_TOOLS_PAN_TOOL_CHANGED', - - TRACKBALLROTATE_TOOL_CHANGED = 'CORNERSTONE_TOOLS_TRACKBALLROTATE_TOOL_CHANGED', - /////////////////////////////////////// // Annotations /////////////////////////////////////// diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index fb30698352..346dbad359 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -3,9 +3,10 @@ import { getEnabledElement, triggerEvent, eventTarget, + Enums, } from '@cornerstonejs/core'; +const { Events } = Enums; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; -import { Events } from '../enums'; import type { Types } from '@cornerstonejs/core'; /** @@ -62,7 +63,7 @@ class PanTool extends BaseTool { focalPoint: updatedFocalPoint, position: updatedPosition, }); - triggerEvent(eventTarget, Events.PAN_TOOL_CHANGED, { + triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { viewport: enabledElement.viewport, }); enabledElement.viewport.render(); diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 23a6c26720..3631d85fa1 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -185,7 +185,7 @@ class TrackballRotateTool extends BaseTool { focalPoint: newFocalPoint, }); - triggerEvent(eventTarget, Events.PAN_TOOL_CHANGED, { + triggerEvent(eventTarget, 'CORNERSTONE_CAMERA_MODIFIED', { viewport: viewport, }); }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index c33b97674d..64f02dcd4c 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -533,11 +533,6 @@ class VolumeCroppingTool extends AnnotationTool { this.picker.addPickList(actor); this._prepareImageDataForPicking(viewport); - const element = viewport.canvas || viewport.element; - element.addEventListener('mousedown', this._onMouseDownSphere); - element.addEventListener('mousemove', this._onMouseMoveSphere); - element.addEventListener('mouseup', this._onMouseUpSphere); - mapper.addClippingPlane(planeXmin); mapper.addClippingPlane(planeXmax); mapper.addClippingPlane(planeYmin); @@ -551,15 +546,15 @@ class VolumeCroppingTool extends AnnotationTool { this._onControlToolChange(evt); } ); - eventTarget.addEventListener(Events.ZOOM_TOOL_CHANGED, (evt) => { - this._updateClippingPlanes(evt.detail.viewport); - }); - eventTarget.addEventListener(Events.PAN_TOOL_CHANGED, (evt) => { - this._updateClippingPlanes(evt.detail.viewport); - }); - eventTarget.addEventListener(Events.TRACKBALLROTATE_TOOL_CHANGED, (evt) => { + + eventTarget.addEventListener('CORNERSTONE_CAMERA_MODIFIED', (evt) => { this._updateClippingPlanes(evt.detail.viewport); }); + + const element = viewport.canvas || viewport.element; + element.addEventListener('mousedown', this._onMouseDownSphere); + element.addEventListener('mousemove', this._onMouseMoveSphere); + element.addEventListener('mouseup', this._onMouseUpSphere); }; _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index 311529dce0..eb74d29e56 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -7,9 +7,9 @@ import { triggerEvent, eventTarget, } from '@cornerstonejs/core'; +const { Events } = Enums; import { BaseTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; -import { Events } from '../enums'; /** * ZoomTool tool manipulates the camera zoom applied to a viewport. It @@ -121,7 +121,7 @@ class ZoomTool extends BaseTool { this._dragPerspectiveProjection(evt, viewport, camera, true); } - triggerEvent(eventTarget, Events.ZOOM_TOOL_CHANGED, { + triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { viewport: viewport, }); viewport.render(); @@ -146,7 +146,7 @@ class ZoomTool extends BaseTool { this._dragPerspectiveProjection(evt, viewport, camera); } - triggerEvent(eventTarget, Events.ZOOM_TOOL_CHANGED, { + triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { viewport: viewport, }); viewport.render(); From 4d75442301a60cb96a7b6ed8cb9addaeb9248826 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:53:37 +0200 Subject: [PATCH 058/132] refactor(TrackballRotateTool): remove viewport type check for VOLUME_3D in drag callback --- packages/tools/src/tools/TrackballRotateTool.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 3631d85fa1..6775319e44 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -5,7 +5,6 @@ import { triggerEvent, getEnabledElement, getEnabledElementByIds, - Enums, } from '@cornerstonejs/core'; import type { Types } from '@cornerstonejs/core'; import { mat4, vec3 } from 'gl-matrix'; @@ -197,10 +196,6 @@ class TrackballRotateTool extends BaseTool { const { rotateIncrementDegrees } = this.configuration; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; - if (viewport.type !== Enums.ViewportType.VOLUME_3D) { - // Only allow rotation for VOLUME_3D viewports - return; - } const camera = viewport.getCamera(); const width = element.clientWidth; const height = element.clientHeight; From b27907264b156ccb7edf9604f5a1a9b3d17d3fbb Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:25:42 +0200 Subject: [PATCH 059/132] Remove unused function _areViewportIdArraysEqual and other unused functions --- .../src/tools/VolumeCroppingControlTool.ts | 80 ------------------- utils/ExampleRunner/example-info.json | 2 +- 2 files changed, 1 insertion(+), 81 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 02ca5d5054..9c8d21b4be 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1173,27 +1173,6 @@ class VolumeCroppingControlTool extends AnnotationTool { }); } - _areViewportIdArraysEqual = (viewportIdArrayOne, viewportIdArrayTwo) => { - if (viewportIdArrayOne.length !== viewportIdArrayTwo.length) { - return false; - } - - viewportIdArrayOne.forEach((id) => { - let itemFound = false; - for (let i = 0; i < viewportIdArrayTwo.length; ++i) { - if (id === viewportIdArrayTwo[i]) { - itemFound = true; - break; - } - } - if (itemFound === false) { - return false; - } - }); - - return true; - }; - _getAnnotationsForViewportsWithDifferentCameras = ( enabledElement, annotations @@ -1478,65 +1457,6 @@ class VolumeCroppingControlTool extends AnnotationTool { return otherViewportsAnnotationsWithUniqueCameras; }; - _checkIfViewportsRenderingSameScene = (viewport, otherViewport) => { - const volumeIds = viewport.getAllVolumeIds(); - const otherVolumeIds = otherViewport.getAllVolumeIds(); - - return ( - volumeIds.length === otherVolumeIds.length && - volumeIds.every((id) => otherVolumeIds.includes(id)) - ); - }; - - _jump = (enabledElement, jumpWorld) => { - state.isInteractingWithTool = true; - const { viewport, renderingEngine } = enabledElement; - - const annotations = this._getAnnotations(enabledElement); - - const delta: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(jumpWorld, this.toolCenter, delta); - - // TRANSLATION - // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - - const sameScene = this._checkIfViewportsRenderingSameScene( - viewport, - otherViewport - ); - - return ( - this._getReferenceLineControllable(otherViewport.id) && sameScene - ); - } - ); - - if (viewportsAnnotationsToUpdate.length === 0) { - state.isInteractingWithTool = false; - return false; - } - - this._applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta - ); - - state.isInteractingWithTool = false; - - return true; - }; - _activateModify = (element) => { // mobile sometimes has lingering interaction even when touchEnd triggers // this check allows for multiple handles to be active which doesn't affect diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 42058f9e8e..cc28841d9b 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -165,7 +165,7 @@ }, "volumeCroppingTool": { "name": "3D Volume Cropping", - "description": "Demonstrates how to use the VTK.js vtkImageCroppingWidget." + "description": "Demonstrates how to use the VolumeCropping and VolumeCroppingControl tools." }, "multipleToolGroups": { "name": "Multiple Tool Groups", From b2cb1eafc5ad5b9e1d6f2bb8d58d9f4d07f7adb7 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:36:00 +0200 Subject: [PATCH 060/132] use vec3 transform --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 7 +------ packages/tools/src/tools/VolumeCroppingTool.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 9c8d21b4be..119540c0da 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1274,11 +1274,7 @@ class VolumeCroppingControlTool extends AnnotationTool { return false; } - return ( - viewport !== otherViewport && - // scene === otherScene && - otherViewportControllable === true - ); + return viewport !== otherViewport && otherViewportControllable === true; } ); @@ -1486,7 +1482,6 @@ class VolumeCroppingControlTool extends AnnotationTool { _endCallback = (evt: EventTypes.InteractionEventType) => { const eventDetail = evt.detail; - // console.debug(eventDetail); const { element } = eventDetail; this.editData.annotation.data.handles.activeOperation = null; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 64f02dcd4c..bb2c8fe0e5 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,3 +1,5 @@ +import { vec3 } from 'gl-matrix'; + import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; @@ -618,7 +620,12 @@ class VolumeCroppingTool extends AnnotationTool { matrix[14], ]; // Transform normal (rotation only) - const n = this._transformNormal(normal, rot); + // const n = this._transformNormal(normal, rot); + const n: Types.Point3 = vec3.transformMat3( + [0, 0, 0], + normal, + rot as unknown as number[] + ); const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); mapper.addClippingPlane(planeInstance); }); From 825e0779d93f74d33e3e1899d5d480fcbe06207f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:01:30 +0200 Subject: [PATCH 061/132] refactor(VolumeCroppingTool): replace custom normal transformation with gl-matrix functions --- .../tools/src/tools/VolumeCroppingTool.ts | 53 +++++-------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index bb2c8fe0e5..8f3e8b3647 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,4 +1,4 @@ -import { vec3 } from 'gl-matrix'; +import { mat3, vec3 } from 'gl-matrix'; import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; @@ -559,14 +559,6 @@ class VolumeCroppingTool extends AnnotationTool { element.addEventListener('mouseup', this._onMouseUpSphere); }; - _transformNormal(normal: Types.Point3, mat: number[]): Types.Point3 { - return [ - mat[0] * normal[0] + mat[3] * normal[1] + mat[6] * normal[2], - mat[1] * normal[0] + mat[4] * normal[1] + mat[7] * normal[2], - mat[2] * normal[0] + mat[5] * normal[1] + mat[8] * normal[2], - ]; - } - _updateClippingPlanes(viewport) { // Get the actor and transformation matrix const actorEntry = viewport.getDefaultActor(); @@ -575,17 +567,8 @@ class VolumeCroppingTool extends AnnotationTool { const matrix = actor.getMatrix(); // Extract rotation part for normals - const rot = [ - matrix[0], - matrix[1], - matrix[2], - matrix[4], - matrix[5], - matrix[6], - matrix[8], - matrix[9], - matrix[10], - ]; + const rot: mat3 = mat3.create(); + mat3.fromMat4(rot, matrix); const originalPlanes = this.originalClippingPlanes; if (!originalPlanes || !originalPlanes.length) { @@ -604,29 +587,17 @@ class VolumeCroppingTool extends AnnotationTool { plane.normal[1], plane.normal[2], ]; + // Transform origin (full 4x4) - const o: Types.Point3 = [ - matrix[0] * origin[0] + - matrix[4] * origin[1] + - matrix[8] * origin[2] + - matrix[12], - matrix[1] * origin[0] + - matrix[5] * origin[1] + - matrix[9] * origin[2] + - matrix[13], - matrix[2] * origin[0] + - matrix[6] * origin[1] + - matrix[10] * origin[2] + - matrix[14], - ]; + const o: Types.Point3 = [0, 0, 0]; + vec3.transformMat4(o, origin, matrix); + // Transform normal (rotation only) - // const n = this._transformNormal(normal, rot); - const n: Types.Point3 = vec3.transformMat3( - [0, 0, 0], - normal, - rot as unknown as number[] - ); - const planeInstance = vtkPlane.newInstance({ origin: o, normal: n }); + const n = vec3.transformMat3([0, 0, 0], normal, rot); + const planeInstance = vtkPlane.newInstance({ + origin: o, + normal: [n[0], n[1], n[2]], + }); mapper.addClippingPlane(planeInstance); }); } From 9638ee103969d45e7a3b8dc5e8fcc28cead4a980 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:10:13 +0200 Subject: [PATCH 062/132] Use robust transform --- packages/tools/src/tools/VolumeCroppingTool.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 8f3e8b3647..cc542f9aa1 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -569,12 +569,15 @@ class VolumeCroppingTool extends AnnotationTool { // Extract rotation part for normals const rot: mat3 = mat3.create(); mat3.fromMat4(rot, matrix); - + // Compute inverse transpose for normal transformation + const normalMatrix: mat3 = mat3.create(); + mat3.invert(normalMatrix, rot); + mat3.transpose(normalMatrix, normalMatrix); + // Remove existing clipping planes const originalPlanes = this.originalClippingPlanes; if (!originalPlanes || !originalPlanes.length) { return; } - mapper.removeAllClippingPlanes(); originalPlanes.forEach((plane) => { const origin: Types.Point3 = [ @@ -592,8 +595,7 @@ class VolumeCroppingTool extends AnnotationTool { const o: Types.Point3 = [0, 0, 0]; vec3.transformMat4(o, origin, matrix); - // Transform normal (rotation only) - const n = vec3.transformMat3([0, 0, 0], normal, rot); + const n = vec3.transformMat3([0, 0, 0], normal, normalMatrix); const planeInstance = vtkPlane.newInstance({ origin: o, normal: [n[0], n[1], n[2]], From 69379640cd0aa0c89ce4f14bbf50be24bc41e616 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:35:58 +0200 Subject: [PATCH 063/132] Normalize direction vectors --- .../tools/src/tools/VolumeCroppingTool.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index cc542f9aa1..e664108a31 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -114,14 +114,18 @@ function addCylinderBetweenPoints( ) { const cylinderSource = vtkCylinderSource.newInstance(); // Compute direction and length - const direction = [ + const direction = new Float32Array([ point2[0] - point1[0], point2[1] - point1[1], point2[2] - point1[2], - ]; + ]); const length = Math.sqrt( direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 ); + // Normalize direction vector + const normDirection = new Float32Array([0, 0, 0]); + vec3.normalize(normDirection, direction); + // Midpoint const center: Types.Point3 = [ (point1[0] + point2[0]) / 2, @@ -133,10 +137,13 @@ function addCylinderBetweenPoints( cylinderSource.setRadius(radius); cylinderSource.setHeight(length); // Set direction (align cylinder axis with direction vector) - cylinderSource.setDirection(direction[0], direction[1], direction[2]); + cylinderSource.setDirection( + normDirection[0], + normDirection[1], + normDirection[2] + ); const cylinderMapper = vtkMapper.newInstance(); - //const cylinderMapper = vtkVolumeMapper.newInstance(); cylinderMapper.setInputConnection(cylinderSource.getOutputPort()); const cylinderActor = vtkActor.newInstance(); cylinderActor.setMapper(cylinderMapper); @@ -1300,6 +1307,9 @@ class VolumeCroppingTool extends AnnotationTool { const length = Math.sqrt( direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 ); + // Normalize direction vector + const normDirection: [number, number, number] = [0, 0, 0]; + vec3.normalize(normDirection, direction); const center = [ (point1[0] + point2[0]) / 2, (point1[1] + point2[1]) / 2, @@ -1307,7 +1317,12 @@ class VolumeCroppingTool extends AnnotationTool { ]; source.setCenter(center[0], center[1], center[2]); source.setHeight(length); - source.setDirection(direction[0], direction[1], direction[2]); + + source.setDirection( + normDirection[0], + normDirection[1], + normDirection[2] + ); source.modified(); } }); From db2b380e562346a7fa5dfbfac4a3505bfe683d40 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 11 Jul 2025 23:41:45 +0200 Subject: [PATCH 064/132] Normalize the transform in updateClippingPlanes --- packages/tools/src/tools/VolumeCroppingTool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index e664108a31..ff44f96a6d 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -603,6 +603,7 @@ class VolumeCroppingTool extends AnnotationTool { vec3.transformMat4(o, origin, matrix); const n = vec3.transformMat3([0, 0, 0], normal, normalMatrix); + vec3.normalize(n, n); const planeInstance = vtkPlane.newInstance({ origin: o, normal: [n[0], n[1], n[2]], From 26ba56c744fa7cfcd0b36c8db00300868c31c92f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:40:43 +0200 Subject: [PATCH 065/132] fix(addCylinderBetweenPoints): prevent cylinder creation for identical points --- packages/tools/src/tools/VolumeCroppingTool.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index ff44f96a6d..411fbe721c 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -112,6 +112,14 @@ function addCylinderBetweenPoints( color: [number, number, number] = [0.5, 0.5, 0.5], uid = '' ) { + // Avoid creating a cylinder 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 cylinderSource = vtkCylinderSource.newInstance(); // Compute direction and length const direction = new Float32Array([ From 6cd257a08d52bd442c44313f3a6a9bf8744792ba Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 12 Jul 2025 16:28:44 +0200 Subject: [PATCH 066/132] refactor(VolumeCroppingTool): simplify corner sphere position updates and streamline edge cylinder calculations --- .../tools/src/tools/VolumeCroppingTool.ts | 134 +++++++++++++----- 1 file changed, 101 insertions(+), 33 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 411fbe721c..63466a9ff2 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1248,28 +1248,104 @@ class VolumeCroppingTool extends AnnotationTool { } }; + // _updateCornerSpheres(viewport) { + // // Get current face sphere positions + // const xMin = + // this.sphereStates.find((s) => s.axis === 'x' && s.point[0] <= s.point[1]) + // ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMIN].point[0]; + // const xMax = + // this.sphereStates.find((s) => s.axis === 'x' && s.point[0] > s.point[1]) + // ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMAX].point[0]; + // const yMin = + // this.sphereStates.find((s) => s.axis === 'y' && s.point[1] <= s.point[0]) + // ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMIN].point[1]; + // const yMax = + // this.sphereStates.find((s) => s.axis === 'y' && s.point[1] > s.point[0]) + // ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMAX].point[1]; + // const zMin = + // this.sphereStates.find((s) => s.axis === 'z' && s.point[2] <= s.point[0]) + // ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMIN].point[2]; + // const zMax = + // this.sphereStates.find((s) => s.axis === 'z' && s.point[2] > s.point[0]) + // ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMAX].point[2]; + + // // All 8 corners, with their keys + // 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) { + // // Update the sphere position and color + // state.point[0] = corner.pos[0]; + // state.point[1] = corner.pos[1]; + // state.point[2] = corner.pos[2]; + // state.sphereSource.setCenter( + // state.point[0], + // state.point[1], + // state.point[2] + // ); + // state.sphereSource.modified(); + // } + // } + // // ...existing code for updating corners... + + // // Update edge cylinders + // Object.values(this.edgeCylinders).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 point1 = state1.point; + // const point2 = state2.point; + // // Compute new direction and length + // const direction = [ + // point2[0] - point1[0], + // point2[1] - point1[1], + // point2[2] - point1[2], + // ]; + // const length = Math.sqrt( + // direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 + // ); + // // Normalize direction vector + // const normDirection: [number, number, number] = [0, 0, 0]; + // vec3.normalize(normDirection, direction); + // const center = [ + // (point1[0] + point2[0]) / 2, + // (point1[1] + point2[1]) / 2, + // (point1[2] + point2[2]) / 2, + // ]; + // source.setCenter(center[0], center[1], center[2]); + // source.setHeight(length); + + // source.setDirection( + // normDirection[0], + // normDirection[1], + // normDirection[2] + // ); + // source.modified(); + // } + // }); + // } _updateCornerSpheres(viewport) { - // Get current face sphere positions - const xMin = - this.sphereStates.find((s) => s.axis === 'x' && s.point[0] <= s.point[1]) - ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMIN].point[0]; - const xMax = - this.sphereStates.find((s) => s.axis === 'x' && s.point[0] > s.point[1]) - ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMAX].point[0]; - const yMin = - this.sphereStates.find((s) => s.axis === 'y' && s.point[1] <= s.point[0]) - ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMIN].point[1]; - const yMax = - this.sphereStates.find((s) => s.axis === 'y' && s.point[1] > s.point[0]) - ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMAX].point[1]; - const zMin = - this.sphereStates.find((s) => s.axis === 'z' && s.point[2] <= s.point[0]) - ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMIN].point[2]; - const zMax = - this.sphereStates.find((s) => s.axis === 'z' && s.point[2] > s.point[0]) - ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMAX].point[2]; - - // All 8 corners, with their keys + // 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] }, @@ -1281,33 +1357,27 @@ class VolumeCroppingTool extends AnnotationTool { { 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) { - // Update the sphere position and color state.point[0] = corner.pos[0]; state.point[1] = corner.pos[1]; state.point[2] = corner.pos[2]; - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); + state.sphereSource.setCenter(...state.point); state.sphereSource.modified(); } } - // ...existing code for updating corners... - // Update edge cylinders + // Update edge cylinders as before Object.values(this.edgeCylinders).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 point1 = state1.point; const point2 = state2.point; - // Compute new direction and length const direction = [ point2[0] - point1[0], point2[1] - point1[1], @@ -1316,8 +1386,7 @@ class VolumeCroppingTool extends AnnotationTool { const length = Math.sqrt( direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 ); - // Normalize direction vector - const normDirection: [number, number, number] = [0, 0, 0]; + const normDirection = [0, 0, 0]; vec3.normalize(normDirection, direction); const center = [ (point1[0] + point2[0]) / 2, @@ -1326,7 +1395,6 @@ class VolumeCroppingTool extends AnnotationTool { ]; source.setCenter(center[0], center[1], center[2]); source.setHeight(length); - source.setDirection( normDirection[0], normDirection[1], From 8abce5fc45a246804465919cc04389722f60dbd1 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 12 Jul 2025 23:15:32 +0200 Subject: [PATCH 067/132] refactor(VolumeCroppingTool): enhance mouse movement handling for sphere manipulation and improve clipping plane updates --- .../tools/src/tools/VolumeCroppingTool.ts | 918 ++++++++++++------ 1 file changed, 602 insertions(+), 316 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 63466a9ff2..935c4ff93f 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -929,324 +929,324 @@ class VolumeCroppingTool extends AnnotationTool { this.draggingSphereIndex = null; }; - _onMouseMoveSphere = (evt) => { - if (this.draggingSphereIndex === null) { - return; - } - evt.stopPropagation(); - evt.preventDefault(); - - const element = evt.currentTarget; - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - - // Use vtkCellPicker to get world coordinates - const rect = element.getBoundingClientRect(); - const x = evt.clientX - rect.left; - const y = evt.clientY - rect.top; - const displayCoords = ( - viewport as unknown as { - getVtkDisplayCoords: (coords: [number, number]) => [number, number]; - } - ).getVtkDisplayCoords([x, y]); - - // --- Remove clipping planes before picking otherwise we cannot back out of the volume - const mapper = viewport - .getDefaultActor() - .actor.getMapper() as vtkVolumeMapper; - const originalClippingPlanes = mapper.getClippingPlanes().slice(); - mapper.removeAllClippingPlanes(); - this.picker.pick( - [displayCoords[0], displayCoords[1], 0], - viewport.getRenderer() - ); - // --- Restore clipping planes after picking --- - originalClippingPlanes.forEach((plane) => { - mapper.addClippingPlane(plane); - }); - const pickedPositions = this.picker.getPickedPositions(); - if (pickedPositions.length > 0) { - const pickedPoint = pickedPositions[0]; - - const sphereState = this.sphereStates[this.draggingSphereIndex]; - const newPoint = [...sphereState.point]; - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper() as vtkVolumeMapper; - if (sphereState.isCorner) { - // Save the old position - const oldX = sphereState.point[0]; - const oldY = sphereState.point[1]; - const oldZ = sphereState.point[2]; - - // Move the dragged corner sphere to the picked point - sphereState.point[0] = pickedPoint[0]; - sphereState.point[1] = pickedPoint[1]; - sphereState.point[2] = pickedPoint[2]; - sphereState.sphereSource.setCenter( - pickedPoint[0], - pickedPoint[1], - pickedPoint[2] - ); - sphereState.sphereSource.modified(); - - // Update all other spheres (face and corner) that shared any min/max coordinate with the old corner position - this.sphereStates.forEach((state, idx) => { - if (idx === this.draggingSphereIndex) { - return; - } // already updated - - let updated = false; - // X - if (Math.abs(state.point[0] - oldX) < 1e-6) { - state.point[0] = pickedPoint[0]; - updated = true; - } - // Y - if (Math.abs(state.point[1] - oldY) < 1e-6) { - state.point[1] = pickedPoint[1]; - updated = true; - } - // Z - if (Math.abs(state.point[2] - oldZ) < 1e-6) { - state.point[2] = pickedPoint[2]; - updated = true; - } - if (updated) { - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereSource.modified(); - if (state.sphereActor && state.color) { - state.sphereActor.getProperty().setColor(state.color); - } - } - }); - - // After moving the corner sphere, update all face spheres to the center between their corners - - // 1. Get all corner points - const cornerStates = [ - this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMIN], - this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMAX], - this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMIN], - this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMAX], - this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMIN], - this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMAX], - this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMIN], - this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMAX], - ]; - - const xs = cornerStates.map((s) => s.point[0]); - const ys = cornerStates.map((s) => s.point[1]); - const zs = cornerStates.map((s) => s.point[2]); - - const xMin = Math.min(...xs); - const xMax = Math.max(...xs); - const yMin = Math.min(...ys); - const yMax = Math.max(...ys); - const zMin = Math.min(...zs); - const zMax = Math.max(...zs); - - // 2. Set face spheres to the center between their two corners - 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, - ]; - - // 3. Update sphere sources - [ - SPHEREINDEX.XMIN, - SPHEREINDEX.XMAX, - SPHEREINDEX.YMIN, - SPHEREINDEX.YMAX, - SPHEREINDEX.ZMIN, - SPHEREINDEX.ZMAX, - ].forEach((idx) => { - const s = this.sphereStates[idx]; - s.sphereSource.setCenter(s.point[0], s.point[1], s.point[2]); - s.sphereSource.modified(); - }); - // Determine which planes are connected to this corner - // Use the draggingSphereIndex to get the corner type - const cornerPlaneIndices = []; - const idx = this.draggingSphereIndex; - if ( - idx >= SPHEREINDEX.XMIN_YMIN_ZMIN && - idx <= SPHEREINDEX.XMAX_YMAX_ZMAX - ) { - // Map corner index to plane indices - // [XMIN, XMAX], [YMIN, YMAX], [ZMIN, ZMAX] - const cornerMap = [ - [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMIN_YMIN_ZMIN - [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMIN_YMIN_ZMAX - [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMIN_YMAX_ZMIN - [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMIN_YMAX_ZMAX - [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMAX_YMIN_ZMIN - [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMAX_YMIN_ZMAX - [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMAX_YMAX_ZMIN - [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMAX_YMAX_ZMAX - ]; - const cornerIdx = idx - SPHEREINDEX.XMIN_YMIN_ZMIN; - cornerPlaneIndices.push(...cornerMap[cornerIdx]); - } - - const clippingPlanes = mapper.getClippingPlanes(); - cornerPlaneIndices.forEach((planeIdx) => { - if (clippingPlanes && clippingPlanes[planeIdx]) { - // Set the origin of the plane to the new corner position - clippingPlanes[planeIdx].setOrigin( - sphereState.point[0], - sphereState.point[1], - sphereState.point[2] - ); - this.originalClippingPlanes[planeIdx].origin = [ - sphereState.point[0], - sphereState.point[1], - sphereState.point[2], - ]; - } - // update the face sphere position after the clipping plane change - }); - this._updateCornerSpheres(viewport); - - viewport.render(); - - // Optionally: trigger an event if you want to notify others - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - toolCenter: pickedPoint, - axis: 'corner', - draggingSphereIndex: this.draggingSphereIndex, - }); - return; - } else { - // face sphere movement - // Restrict movement to the sphere's axis only - if (sphereState.axis === 'x') { - newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex - ); - const newXCenter = - (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'x' && !state.isCorner) { - state.point[POINTINDEX.X] = newXCenter; - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - } else if (sphereState.axis === 'y') { - newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex - ); - const newYCenter = - (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'y' && !state.isCorner) { - state.point[POINTINDEX.Y] = newYCenter; - state.sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - } else if (sphereState.axis === 'z') { - newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex - ); - const newZCenter = - (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; - this.sphereStates.forEach((state, idx) => { - if (state.axis !== 'z' && !state.isCorner) { - // state.point[POINTINDEX.Z] = newZCenter; - this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; - this.sphereStates[idx].sphereSource.setCenter( - state.point[0], - state.point[1], - state.point[2] - ); - state.sphereSource.modified(); - } - }); - } - - this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; - this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; - this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; + // _onMouseMoveSphere = (evt) => { + // if (this.draggingSphereIndex === null) { + // return; + // } + // evt.stopPropagation(); + // evt.preventDefault(); + + // const element = evt.currentTarget; + // const viewportsInfo = this._getViewportsInfo(); + // const [viewport3D] = viewportsInfo; + // const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + // const viewport = renderingEngine.getViewport(viewport3D.viewportId); + + // // Use vtkCellPicker to get world coordinates + // const rect = element.getBoundingClientRect(); + // const x = evt.clientX - rect.left; + // const y = evt.clientY - rect.top; + // const displayCoords = ( + // viewport as unknown as { + // getVtkDisplayCoords: (coords: [number, number]) => [number, number]; + // } + // ).getVtkDisplayCoords([x, y]); + + // // --- Remove clipping planes before picking otherwise we cannot back out of the volume + // const mapper = viewport + // .getDefaultActor() + // .actor.getMapper() as vtkVolumeMapper; + // const originalClippingPlanes = mapper.getClippingPlanes().slice(); + // mapper.removeAllClippingPlanes(); + // this.picker.pick( + // [displayCoords[0], displayCoords[1], 0], + // viewport.getRenderer() + // ); + // // --- Restore clipping planes after picking --- + // originalClippingPlanes.forEach((plane) => { + // mapper.addClippingPlane(plane); + // }); + // const pickedPositions = this.picker.getPickedPositions(); + // if (pickedPositions.length > 0) { + // const pickedPoint = pickedPositions[0]; + + // const sphereState = this.sphereStates[this.draggingSphereIndex]; + // const newPoint = [...sphereState.point]; + // const volumeActor = viewport.getDefaultActor()?.actor; + // if (!volumeActor) { + // console.warn('No volume actor found'); + // return; + // } + // const mapper = volumeActor.getMapper() as vtkVolumeMapper; + // if (sphereState.isCorner) { + // // Save the old position + // const oldX = sphereState.point[0]; + // const oldY = sphereState.point[1]; + // const oldZ = sphereState.point[2]; + + // // Move the dragged corner sphere to the picked point + // sphereState.point[0] = pickedPoint[0]; + // sphereState.point[1] = pickedPoint[1]; + // sphereState.point[2] = pickedPoint[2]; + // sphereState.sphereSource.setCenter( + // pickedPoint[0], + // pickedPoint[1], + // pickedPoint[2] + // ); + // sphereState.sphereSource.modified(); + + // // Update all other spheres (face and corner) that shared any min/max coordinate with the old corner position + // this.sphereStates.forEach((state, idx) => { + // if (idx === this.draggingSphereIndex) { + // return; + // } // already updated + + // let updated = false; + // // X + // if (Math.abs(state.point[0] - oldX) < 1e-6) { + // state.point[0] = pickedPoint[0]; + // updated = true; + // } + // // Y + // if (Math.abs(state.point[1] - oldY) < 1e-6) { + // state.point[1] = pickedPoint[1]; + // updated = true; + // } + // // Z + // if (Math.abs(state.point[2] - oldZ) < 1e-6) { + // state.point[2] = pickedPoint[2]; + // updated = true; + // } + // if (updated) { + // state.sphereSource.setCenter( + // state.point[0], + // state.point[1], + // state.point[2] + // ); + // state.sphereSource.modified(); + // if (state.sphereActor && state.color) { + // state.sphereActor.getProperty().setColor(state.color); + // } + // } + // }); + + // // After moving the corner sphere, update all face spheres to the center between their corners + + // // 1. Get all corner points + // const cornerStates = [ + // this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMIN], + // this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMAX], + // this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMIN], + // this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMAX], + // this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMIN], + // this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMAX], + // this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMIN], + // this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMAX], + // ]; - sphereState.sphereSource.setCenter( - newPoint[0], - newPoint[1], - newPoint[2] - ); - sphereState.sphereSource.modified(); + // const xs = cornerStates.map((s) => s.point[0]); + // const ys = cornerStates.map((s) => s.point[1]); + // const zs = cornerStates.map((s) => s.point[2]); + + // const xMin = Math.min(...xs); + // const xMax = Math.max(...xs); + // const yMin = Math.min(...ys); + // const yMax = Math.max(...ys); + // const zMin = Math.min(...zs); + // const zMax = Math.max(...zs); + + // // 2. Set face spheres to the center between their two corners + // 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, + // ]; - this._updateCornerSpheres(viewport); - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[this.draggingSphereIndex].setOrigin( - newPoint[0], - newPoint[1], - newPoint[2] - ); - this.originalClippingPlanes[this.draggingSphereIndex].origin = [ - newPoint[0], - newPoint[1], - newPoint[2], - ]; - viewport.render(); - /// Send event with the new point - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - toolCenter: newPoint, - axis: sphereState.axis, - draggingSphereIndex: this.draggingSphereIndex, - }); - } - } - }; + // // 3. Update sphere sources + // [ + // SPHEREINDEX.XMIN, + // SPHEREINDEX.XMAX, + // SPHEREINDEX.YMIN, + // SPHEREINDEX.YMAX, + // SPHEREINDEX.ZMIN, + // SPHEREINDEX.ZMAX, + // ].forEach((idx) => { + // const s = this.sphereStates[idx]; + // s.sphereSource.setCenter(s.point[0], s.point[1], s.point[2]); + // s.sphereSource.modified(); + // }); + // // Determine which planes are connected to this corner + // // Use the draggingSphereIndex to get the corner type + // const cornerPlaneIndices = []; + // const idx = this.draggingSphereIndex; + // if ( + // idx >= SPHEREINDEX.XMIN_YMIN_ZMIN && + // idx <= SPHEREINDEX.XMAX_YMAX_ZMAX + // ) { + // // Map corner index to plane indices + // // [XMIN, XMAX], [YMIN, YMAX], [ZMIN, ZMAX] + // const cornerMap = [ + // [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMIN_YMIN_ZMIN + // [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMIN_YMIN_ZMAX + // [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMIN_YMAX_ZMIN + // [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMIN_YMAX_ZMAX + // [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMAX_YMIN_ZMIN + // [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMAX_YMIN_ZMAX + // [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMAX_YMAX_ZMIN + // [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMAX_YMAX_ZMAX + // ]; + // const cornerIdx = idx - SPHEREINDEX.XMIN_YMIN_ZMIN; + // cornerPlaneIndices.push(...cornerMap[cornerIdx]); + // } + + // const clippingPlanes = mapper.getClippingPlanes(); + // cornerPlaneIndices.forEach((planeIdx) => { + // if (clippingPlanes && clippingPlanes[planeIdx]) { + // // Set the origin of the plane to the new corner position + // clippingPlanes[planeIdx].setOrigin( + // sphereState.point[0], + // sphereState.point[1], + // sphereState.point[2] + // ); + // this.originalClippingPlanes[planeIdx].origin = [ + // sphereState.point[0], + // sphereState.point[1], + // sphereState.point[2], + // ]; + // } + // // update the face sphere position after the clipping plane change + // }); + // this._updateCornerSpheres(viewport); + + // viewport.render(); + + // // Optionally: trigger an event if you want to notify others + // triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + // toolCenter: pickedPoint, + // axis: 'corner', + // draggingSphereIndex: this.draggingSphereIndex, + // }); + // return; + // } else { + // // face sphere movement + // // Restrict movement to the sphere's axis only + // if (sphereState.axis === 'x') { + // newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; + // const otherXSphere = this.sphereStates.find( + // (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex + // ); + // const newXCenter = + // (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; + // this.sphereStates.forEach((state, idx) => { + // if (state.axis !== 'x' && !state.isCorner) { + // state.point[POINTINDEX.X] = newXCenter; + // state.sphereSource.setCenter( + // state.point[0], + // state.point[1], + // state.point[2] + // ); + // state.sphereActor.getProperty().setColor(state.color); + // state.sphereSource.modified(); + // } + // }); + // } else if (sphereState.axis === 'y') { + // newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; + // const otherYSphere = this.sphereStates.find( + // (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex + // ); + // const newYCenter = + // (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; + // this.sphereStates.forEach((state, idx) => { + // if (state.axis !== 'y' && !state.isCorner) { + // state.point[POINTINDEX.Y] = newYCenter; + // state.sphereSource.setCenter( + // state.point[0], + // state.point[1], + // state.point[2] + // ); + // state.sphereActor.getProperty().setColor(state.color); + // state.sphereSource.modified(); + // } + // }); + // } else if (sphereState.axis === 'z') { + // newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; + // const otherZSphere = this.sphereStates.find( + // (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex + // ); + // const newZCenter = + // (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; + // this.sphereStates.forEach((state, idx) => { + // if (state.axis !== 'z' && !state.isCorner) { + // // state.point[POINTINDEX.Z] = newZCenter; + // this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; + // this.sphereStates[idx].sphereSource.setCenter( + // state.point[0], + // state.point[1], + // state.point[2] + // ); + // state.sphereSource.modified(); + // } + // }); + // } + + // this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; + // this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; + // this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; + + // sphereState.sphereSource.setCenter( + // newPoint[0], + // newPoint[1], + // newPoint[2] + // ); + // sphereState.sphereSource.modified(); + + // this._updateCornerSpheres(viewport); + // const clippingPlanes = mapper.getClippingPlanes(); + // clippingPlanes[this.draggingSphereIndex].setOrigin( + // newPoint[0], + // newPoint[1], + // newPoint[2] + // ); + // this.originalClippingPlanes[this.draggingSphereIndex].origin = [ + // newPoint[0], + // newPoint[1], + // newPoint[2], + // ]; + // viewport.render(); + // /// Send event with the new point + // triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + // toolCenter: newPoint, + // axis: sphereState.axis, + // draggingSphereIndex: this.draggingSphereIndex, + // }); + // } + // } + // }; // _updateCornerSpheres(viewport) { // // Get current face sphere positions @@ -1336,6 +1336,280 @@ class VolumeCroppingTool extends AnnotationTool { // } // }); // } + + _onMouseMoveSphere = (evt) => { + if (this.draggingSphereIndex === null) { + return; + } + evt.stopPropagation(); + evt.preventDefault(); + + const element = evt.currentTarget; + const [viewport3D] = this._getViewportsInfo(); + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + + // Get 2D mouse position in canvas coordinates + const rect = element.getBoundingClientRect(); + const x = evt.clientX - rect.left; + const y = evt.clientY - rect.top; + + // Convert canvas to world coordinates + const world = viewport.canvasToWorld([x, y]); + + const sphereState = this.sphereStates[this.draggingSphereIndex]; + if (!sphereState) { + return; + } + + if (sphereState.isCorner) { + // Move the dragged corner sphere + const newCorner = [...world]; + sphereState.point = newCorner; + sphereState.sphereSource.setCenter(...newCorner); + sphereState.sphereSource.modified(); + + // Determine which axes are min/max for this corner + // Example: XMIN_YMAX_ZMIN => x=min, y=max, z=min + const cornerKey = sphereState.uid.replace('corner_', ''); + const isXMin = cornerKey.includes('XMIN'); + const isXMax = cornerKey.includes('XMAX'); + const isYMin = cornerKey.includes('YMIN'); + const isYMax = cornerKey.includes('YMAX'); + const isZMin = cornerKey.includes('ZMIN'); + const isZMax = cornerKey.includes('ZMAX'); + + // Update all corners that share any min/max coordinate with this corner + this.sphereStates.forEach((state) => { + if (!state.isCorner || state === sphereState) { + return; + } + const key = state.uid.replace('corner_', ''); + if ( + (isXMin && key.includes('XMIN')) || + (isXMax && key.includes('XMAX')) || + (isYMin && key.includes('YMIN')) || + (isYMax && key.includes('YMAX')) || + (isZMin && key.includes('ZMIN')) || + (isZMax && key.includes('ZMAX')) + ) { + // For each axis that matches, update that coordinate + if (isXMin && key.includes('XMIN')) { + state.point[0] = newCorner[0]; + } + if (isXMax && key.includes('XMAX')) { + state.point[0] = newCorner[0]; + } + if (isYMin && key.includes('YMIN')) { + state.point[1] = newCorner[1]; + } + if (isYMax && key.includes('YMAX')) { + state.point[1] = newCorner[1]; + } + if (isZMin && key.includes('ZMIN')) { + state.point[2] = newCorner[2]; + } + if (isZMax && key.includes('ZMAX')) { + state.point[2] = newCorner[2]; + } + state.sphereSource.setCenter(...state.point); + state.sphereSource.modified(); + } + }); + + // After updating corners, update face spheres and edge cylinders + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(viewport); + this._updateClippingPlanesFromFaceSpheres(viewport); + } else { + // For face spheres: only update the coordinate along the face's axis + const axis = sphereState.axis; + const axisIdx = { x: 0, y: 1, z: 2 }[axis]; + + // Get min/max for each axis from all face spheres + 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]; + + // Project mouse world position onto the axis + let newValue; + if (axis === 'x') { + newValue = world[0]; + if (this.draggingSphereIndex === SPHEREINDEX.XMIN) { + this.sphereStates[SPHEREINDEX.XMIN].point[0] = newValue; + } else { + this.sphereStates[SPHEREINDEX.XMAX].point[0] = newValue; + } + } else if (axis === 'y') { + newValue = world[1]; + if (this.draggingSphereIndex === SPHEREINDEX.YMIN) { + this.sphereStates[SPHEREINDEX.YMIN].point[1] = newValue; + } else { + this.sphereStates[SPHEREINDEX.YMAX].point[1] = newValue; + } + } else if (axis === 'z') { + newValue = world[2]; + if (this.draggingSphereIndex === SPHEREINDEX.ZMIN) { + this.sphereStates[SPHEREINDEX.ZMIN].point[2] = newValue; + } else { + this.sphereStates[SPHEREINDEX.ZMAX].point[2] = newValue; + } + } + + // After updating the face sphere, update all corners from faces + this._updateCornerSpheresFromFaces(); + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(viewport); + this._updateClippingPlanesFromFaceSpheres(viewport); + } + + viewport.render(); + + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + toolCenter: sphereState.point, + axis: sphereState.isCorner ? 'corner' : sphereState.axis, + draggingSphereIndex: this.draggingSphereIndex, + }); + }; + + _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 plane = vtkPlane.newInstance({ + origin: this.originalClippingPlanes[i].origin, + normal: this.originalClippingPlanes[i].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(viewport) { // Get face sphere positions const xMin = this.sphereStates[SPHEREINDEX.XMIN].point[0]; @@ -1407,6 +1681,18 @@ class VolumeCroppingTool extends AnnotationTool { _onMouseUpSphere = (evt) => { evt.currentTarget.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._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(viewport); + this._updateClippingPlanesFromFaceSpheres(viewport); + } + } this.draggingSphereIndex = null; }; From 65943c3b4cc36e642c738a47fb94377136a3f723 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:01:49 +0200 Subject: [PATCH 068/132] Remove 3D picker --- .../tools/src/tools/VolumeCroppingTool.ts | 747 ++---------------- 1 file changed, 59 insertions(+), 688 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 935c4ff93f..ee53bca423 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,6 +1,5 @@ import { mat3, vec3 } from 'gl-matrix'; -import vtkCellPicker from '@kitware/vtk.js/Rendering/Core/CellPicker'; 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'; @@ -98,11 +97,6 @@ const SPHEREINDEX = { XMAX_YMAX_ZMIN: 12, XMAX_YMAX_ZMAX: 13, }; -const POINTINDEX = { - X: 0, - Y: 1, - Z: 2, -}; function addCylinderBetweenPoints( viewport, @@ -185,7 +179,6 @@ class VolumeCroppingTool extends AnnotationTool { toolCenter: Types.Point3 = [0, 0, 0]; _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; - picker: vtkCellPicker; edgeCylinders: { [uid: string]: { actor: vtkActor; @@ -214,6 +207,7 @@ class VolumeCroppingTool extends AnnotationTool { corners: [0.0, 0.0, 1.0], // Blue for corners }, sphereRadius: 10, + grabSpherePixelDistance: 20, //pixels threshold for closeness to the sphere being grabbed }, } ) { @@ -225,10 +219,6 @@ class VolumeCroppingTool extends AnnotationTool { this._getReferenceLineControllable = toolProps.configuration?.getReferenceLineControllable || defaultReferenceLineControllable; - this.picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); - this.picker.setPickFromList(true); - this.picker.setTolerance(0); - this.picker.initializePickList(); } addNewAnnotation( @@ -544,12 +534,6 @@ class VolumeCroppingTool extends AnnotationTool { } }); } - - const defaultActor = viewport.getDefaultActor(); - const actor = defaultActor.actor as vtkActor | vtkVolume; - this.picker.addPickList(actor); - this._prepareImageDataForPicking(viewport); - mapper.addClippingPlane(planeXmin); mapper.addClippingPlane(planeXmax); mapper.addClippingPlane(planeYmin); @@ -621,243 +605,85 @@ class VolumeCroppingTool extends AnnotationTool { } _onControlToolChange = (evt) => { - // coronal is y axis in green - // sagittal is x axis in yellow - // axial is z axis in red const viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - if (evt.detail.handleType === 'min') { - const toolMin = evt.detail.toolCenterMin; - const planeXmin = vtkPlane.newInstance({ - origin: [toolMin[0], 0, 0], - normal: [1, 0, 0], - }); - const planeYmin = vtkPlane.newInstance({ - origin: [0, toolMin[1], 0], - normal: [0, 1, 0], - }); - const planeZmin = vtkPlane.newInstance({ - origin: [0, 0, toolMin[2]], - normal: [0, 0, 1], - }); - this.originalClippingPlanes[PLANEINDEX.XMIN].origin = - planeXmin.getOrigin(); - this.originalClippingPlanes[PLANEINDEX.YMIN].origin = - planeYmin.getOrigin(); - this.originalClippingPlanes[PLANEINDEX.ZMIN].origin = - planeZmin.getOrigin(); - if (this.configuration.showHandles) { - this.sphereStates[SPHEREINDEX.XMIN].point[0] = planeXmin.getOrigin()[0]; - this.sphereStates[SPHEREINDEX.XMIN].sphereSource.setCenter( - planeXmin.getOrigin()[0], - this.sphereStates[0].point[1], - this.sphereStates[0].point[2] - ); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== 0 - ); - const newXCenter = - (otherXSphere.point[0] + planeXmin.getOrigin()[0]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.SAGITTAL - ) // sagittal is y axis in yellow - ) { - state.point[0] = newXCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - } - }); + 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, + ]; - // y - this.sphereStates[SPHEREINDEX.YMIN].point[1] = planeYmin.getOrigin()[1]; - this.sphereStates[SPHEREINDEX.YMIN].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.YMIN].point - ); - this.sphereStates[SPHEREINDEX.YMIN].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMIN - ); - const newYCenter = - (otherYSphere.point[1] + planeYmin.getOrigin()[1]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.CORONAL - ) // coronal is x axis in green - ) { - state.point[1] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - // z - this.sphereStates[SPHEREINDEX.ZMIN].point[2] = planeZmin.getOrigin()[2]; - this.sphereStates[SPHEREINDEX.ZMIN].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.ZMIN].point[0], - this.sphereStates[SPHEREINDEX.ZMIN].point[1], - planeZmin.getOrigin()[2] - ); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMIN - ); - const newZCenter = - (otherZSphere.point[2] + planeZmin.getOrigin()[2]) / 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.AXIAL - ) // axial is z axis in red - ) { - state.point[2] = newZCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - } - }); - } - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - console.warn('No volume actor found'); - return; - } - const mapper = volumeActor.getMapper() as vtkVolumeMapper; - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMIN].setOrigin(planeXmin.getOrigin()); - clippingPlanes[PLANEINDEX.YMIN].setOrigin(planeYmin.getOrigin()); - clippingPlanes[PLANEINDEX.ZMIN].setOrigin(planeZmin.getOrigin()); - } else if (evt.detail.handleType === 'max') { - const toolMax = evt.detail.toolCenterMax; - const planeXmax = vtkPlane.newInstance({ - origin: [toolMax[0], 0, 0], - normal: [-1, 0, 0], - }); - const planeYmax = vtkPlane.newInstance({ - origin: [0, toolMax[1], 0], - normal: [0, -1, 0], - }); - const planeZmax = vtkPlane.newInstance({ - origin: [0, 0, toolMax[2]], - normal: [0, 0, -1], + // Update planes and spheres for each axis + for (let i = 0; i < 3; ++i) { + const origin = [0, 0, 0]; + origin[i] = toolCenter[i]; + const plane = vtkPlane.newInstance({ + origin, + normal: normals[i], }); - this.originalClippingPlanes[PLANEINDEX.XMAX].origin = - planeXmax.getOrigin(); - this.originalClippingPlanes[PLANEINDEX.YMAX].origin = - planeYmax.getOrigin(); - this.originalClippingPlanes[PLANEINDEX.ZMAX].origin = - planeZmax.getOrigin(); - if (this.configuration.showHandles) { - // x - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X] = - planeXmax.getOrigin()[POINTINDEX.X]; - this.sphereStates[SPHEREINDEX.XMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.XMAX].point[POINTINDEX.Z] - ); - this.sphereStates[SPHEREINDEX.XMAX].sphereSource.modified(); - const otherXSphere = this.sphereStates.find( - (s, i) => s.axis === 'x' && i !== SPHEREINDEX.XMAX - ); - const newXCenter = - (otherXSphere.point[POINTINDEX.X] + - planeXmax.getOrigin()[POINTINDEX.X]) / - 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'x' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.SAGITTAL - ) - ) { - state.point[POINTINDEX.X] = newXCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); + this.originalClippingPlanes[planeIndices[i]].origin = plane.getOrigin(); - // y - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y] = - planeYmax.getOrigin()[POINTINDEX.Y]; - this.sphereStates[SPHEREINDEX.YMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.YMAX].point[POINTINDEX.Z] + if (this.configuration.showHandles) { + // 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(); - this.sphereStates[SPHEREINDEX.YMAX].sphereSource.modified(); - const otherYSphere = this.sphereStates.find( - (s, i) => s.axis === 'y' && i !== SPHEREINDEX.YMAX + // 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 newYCenter = - (otherYSphere.point[POINTINDEX.Y] + - planeYmax.getOrigin()[POINTINDEX.Y]) / - 2; - this.sphereStates.forEach((state, idx) => { + const newCenter = (otherSphere.point[i] + plane.getOrigin()[i]) / 2; + this.sphereStates.forEach((state) => { if ( !state.isCorner && - state.axis !== 'y' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.CORONAL - ) + state.axis !== axes[i] && + !evt.detail.viewportOrientation.includes(orientationAxes[i]) ) { - state.point[POINTINDEX.Y] = newYCenter; - state.sphereSource.setCenter(state.point); - state.sphereActor.getProperty().setColor(state.color); - state.sphereSource.modified(); - } - }); - - // z - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] = - planeZmax.getOrigin()[POINTINDEX.Z]; - this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.setCenter( - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.X], - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Y], - this.sphereStates[SPHEREINDEX.ZMAX].point[POINTINDEX.Z] - ); - this.sphereStates[SPHEREINDEX.ZMAX].sphereSource.modified(); - const otherZSphere = this.sphereStates.find( - (s, i) => s.axis === 'z' && i !== SPHEREINDEX.ZMAX - ); - const newZCenter = - (otherZSphere.point[POINTINDEX.Z] + - planeZmax.getOrigin()[POINTINDEX.Z]) / - 2; - this.sphereStates.forEach((state, idx) => { - if ( - !state.isCorner && - state.axis !== 'z' && - !evt.detail.viewportOrientation.includes( - Enums.OrientationAxis.AXIAL - ) // axial is z axis in red - ) { - state.point[POINTINDEX.Z] = newZCenter; + 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; - const mapper = volumeActor.getMapper() as vtkVolumeMapper; - const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[PLANEINDEX.XMAX].setOrigin(planeXmax.getOrigin()); - clippingPlanes[PLANEINDEX.YMAX].setOrigin(planeYmax.getOrigin()); - clippingPlanes[PLANEINDEX.ZMAX].setOrigin(planeZmax.getOrigin()); + if (volumeActor) { + const mapper = volumeActor.getMapper(); + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + } } + if ( this.configuration.showHandles && this.configuration.showCornerSpheres @@ -867,43 +693,6 @@ class VolumeCroppingTool extends AnnotationTool { viewport.render(); }; - _prepareImageDataForPicking = (viewport) => { - const volumeActor = viewport.getDefaultActor()?.actor; - if (!volumeActor) { - return; - } - // Get the imageData from the volumeActor - const imageData = volumeActor.getMapper().getInputData(); - - if (!imageData) { - console.error('No imageData found in the volumeActor'); - return null; - } - - // Get the voxelManager from the imageData - const { voxelManager } = imageData.get('voxelManager'); - - if (!voxelManager) { - console.error('No voxelManager found in the imageData'); - return imageData; - } - - // Create a fake scalar object to expose the scalar data to VTK.js - const fakeScalars = { - getData: () => { - return voxelManager.getCompleteScalarDataArray(); - }, - getNumberOfComponents: () => voxelManager.numberOfComponents, - getDataType: () => - voxelManager.getCompleteScalarDataArray().constructor.name, - }; - - // Set the point data to return the fakeScalars - imageData.setPointData({ - getScalars: () => fakeScalars, - }); - }; - _onMouseDownSphere = (evt) => { const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); @@ -919,8 +708,7 @@ class VolumeCroppingTool extends AnnotationTool { Math.pow(mouseCanvas[0] - sphereCanvas[0], 2) + Math.pow(mouseCanvas[1] - sphereCanvas[1], 2) ); - if (dist < 20) { - // 20 pixels threshold + if (dist < this.configuration.grabSpherePixelDistance) { this.draggingSphereIndex = i; element.style.cursor = 'grabbing'; return; @@ -929,414 +717,6 @@ class VolumeCroppingTool extends AnnotationTool { this.draggingSphereIndex = null; }; - // _onMouseMoveSphere = (evt) => { - // if (this.draggingSphereIndex === null) { - // return; - // } - // evt.stopPropagation(); - // evt.preventDefault(); - - // const element = evt.currentTarget; - // const viewportsInfo = this._getViewportsInfo(); - // const [viewport3D] = viewportsInfo; - // const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - // const viewport = renderingEngine.getViewport(viewport3D.viewportId); - - // // Use vtkCellPicker to get world coordinates - // const rect = element.getBoundingClientRect(); - // const x = evt.clientX - rect.left; - // const y = evt.clientY - rect.top; - // const displayCoords = ( - // viewport as unknown as { - // getVtkDisplayCoords: (coords: [number, number]) => [number, number]; - // } - // ).getVtkDisplayCoords([x, y]); - - // // --- Remove clipping planes before picking otherwise we cannot back out of the volume - // const mapper = viewport - // .getDefaultActor() - // .actor.getMapper() as vtkVolumeMapper; - // const originalClippingPlanes = mapper.getClippingPlanes().slice(); - // mapper.removeAllClippingPlanes(); - // this.picker.pick( - // [displayCoords[0], displayCoords[1], 0], - // viewport.getRenderer() - // ); - // // --- Restore clipping planes after picking --- - // originalClippingPlanes.forEach((plane) => { - // mapper.addClippingPlane(plane); - // }); - // const pickedPositions = this.picker.getPickedPositions(); - // if (pickedPositions.length > 0) { - // const pickedPoint = pickedPositions[0]; - - // const sphereState = this.sphereStates[this.draggingSphereIndex]; - // const newPoint = [...sphereState.point]; - // const volumeActor = viewport.getDefaultActor()?.actor; - // if (!volumeActor) { - // console.warn('No volume actor found'); - // return; - // } - // const mapper = volumeActor.getMapper() as vtkVolumeMapper; - // if (sphereState.isCorner) { - // // Save the old position - // const oldX = sphereState.point[0]; - // const oldY = sphereState.point[1]; - // const oldZ = sphereState.point[2]; - - // // Move the dragged corner sphere to the picked point - // sphereState.point[0] = pickedPoint[0]; - // sphereState.point[1] = pickedPoint[1]; - // sphereState.point[2] = pickedPoint[2]; - // sphereState.sphereSource.setCenter( - // pickedPoint[0], - // pickedPoint[1], - // pickedPoint[2] - // ); - // sphereState.sphereSource.modified(); - - // // Update all other spheres (face and corner) that shared any min/max coordinate with the old corner position - // this.sphereStates.forEach((state, idx) => { - // if (idx === this.draggingSphereIndex) { - // return; - // } // already updated - - // let updated = false; - // // X - // if (Math.abs(state.point[0] - oldX) < 1e-6) { - // state.point[0] = pickedPoint[0]; - // updated = true; - // } - // // Y - // if (Math.abs(state.point[1] - oldY) < 1e-6) { - // state.point[1] = pickedPoint[1]; - // updated = true; - // } - // // Z - // if (Math.abs(state.point[2] - oldZ) < 1e-6) { - // state.point[2] = pickedPoint[2]; - // updated = true; - // } - // if (updated) { - // state.sphereSource.setCenter( - // state.point[0], - // state.point[1], - // state.point[2] - // ); - // state.sphereSource.modified(); - // if (state.sphereActor && state.color) { - // state.sphereActor.getProperty().setColor(state.color); - // } - // } - // }); - - // // After moving the corner sphere, update all face spheres to the center between their corners - - // // 1. Get all corner points - // const cornerStates = [ - // this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMIN], - // this.sphereStates[SPHEREINDEX.XMIN_YMIN_ZMAX], - // this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMIN], - // this.sphereStates[SPHEREINDEX.XMIN_YMAX_ZMAX], - // this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMIN], - // this.sphereStates[SPHEREINDEX.XMAX_YMIN_ZMAX], - // this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMIN], - // this.sphereStates[SPHEREINDEX.XMAX_YMAX_ZMAX], - // ]; - - // const xs = cornerStates.map((s) => s.point[0]); - // const ys = cornerStates.map((s) => s.point[1]); - // const zs = cornerStates.map((s) => s.point[2]); - - // const xMin = Math.min(...xs); - // const xMax = Math.max(...xs); - // const yMin = Math.min(...ys); - // const yMax = Math.max(...ys); - // const zMin = Math.min(...zs); - // const zMax = Math.max(...zs); - - // // 2. Set face spheres to the center between their two corners - // 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, - // ]; - - // // 3. Update sphere sources - // [ - // SPHEREINDEX.XMIN, - // SPHEREINDEX.XMAX, - // SPHEREINDEX.YMIN, - // SPHEREINDEX.YMAX, - // SPHEREINDEX.ZMIN, - // SPHEREINDEX.ZMAX, - // ].forEach((idx) => { - // const s = this.sphereStates[idx]; - // s.sphereSource.setCenter(s.point[0], s.point[1], s.point[2]); - // s.sphereSource.modified(); - // }); - // // Determine which planes are connected to this corner - // // Use the draggingSphereIndex to get the corner type - // const cornerPlaneIndices = []; - // const idx = this.draggingSphereIndex; - // if ( - // idx >= SPHEREINDEX.XMIN_YMIN_ZMIN && - // idx <= SPHEREINDEX.XMAX_YMAX_ZMAX - // ) { - // // Map corner index to plane indices - // // [XMIN, XMAX], [YMIN, YMAX], [ZMIN, ZMAX] - // const cornerMap = [ - // [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMIN_YMIN_ZMIN - // [PLANEINDEX.XMIN, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMIN_YMIN_ZMAX - // [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMIN_YMAX_ZMIN - // [PLANEINDEX.XMIN, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMIN_YMAX_ZMAX - // [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMIN], // XMAX_YMIN_ZMIN - // [PLANEINDEX.XMAX, PLANEINDEX.YMIN, PLANEINDEX.ZMAX], // XMAX_YMIN_ZMAX - // [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMIN], // XMAX_YMAX_ZMIN - // [PLANEINDEX.XMAX, PLANEINDEX.YMAX, PLANEINDEX.ZMAX], // XMAX_YMAX_ZMAX - // ]; - // const cornerIdx = idx - SPHEREINDEX.XMIN_YMIN_ZMIN; - // cornerPlaneIndices.push(...cornerMap[cornerIdx]); - // } - - // const clippingPlanes = mapper.getClippingPlanes(); - // cornerPlaneIndices.forEach((planeIdx) => { - // if (clippingPlanes && clippingPlanes[planeIdx]) { - // // Set the origin of the plane to the new corner position - // clippingPlanes[planeIdx].setOrigin( - // sphereState.point[0], - // sphereState.point[1], - // sphereState.point[2] - // ); - // this.originalClippingPlanes[planeIdx].origin = [ - // sphereState.point[0], - // sphereState.point[1], - // sphereState.point[2], - // ]; - // } - // // update the face sphere position after the clipping plane change - // }); - // this._updateCornerSpheres(viewport); - - // viewport.render(); - - // // Optionally: trigger an event if you want to notify others - // triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - // toolCenter: pickedPoint, - // axis: 'corner', - // draggingSphereIndex: this.draggingSphereIndex, - // }); - // return; - // } else { - // // face sphere movement - // // Restrict movement to the sphere's axis only - // if (sphereState.axis === 'x') { - // newPoint[POINTINDEX.X] = pickedPoint[POINTINDEX.X]; - // const otherXSphere = this.sphereStates.find( - // (s, i) => s.axis === 'x' && i !== this.draggingSphereIndex - // ); - // const newXCenter = - // (otherXSphere.point[POINTINDEX.X] + pickedPoint[POINTINDEX.X]) / 2; - // this.sphereStates.forEach((state, idx) => { - // if (state.axis !== 'x' && !state.isCorner) { - // state.point[POINTINDEX.X] = newXCenter; - // state.sphereSource.setCenter( - // state.point[0], - // state.point[1], - // state.point[2] - // ); - // state.sphereActor.getProperty().setColor(state.color); - // state.sphereSource.modified(); - // } - // }); - // } else if (sphereState.axis === 'y') { - // newPoint[POINTINDEX.Y] = pickedPoint[POINTINDEX.Y]; - // const otherYSphere = this.sphereStates.find( - // (s, i) => s.axis === 'y' && i !== this.draggingSphereIndex - // ); - // const newYCenter = - // (otherYSphere.point[POINTINDEX.Y] + pickedPoint[POINTINDEX.Y]) / 2; - // this.sphereStates.forEach((state, idx) => { - // if (state.axis !== 'y' && !state.isCorner) { - // state.point[POINTINDEX.Y] = newYCenter; - // state.sphereSource.setCenter( - // state.point[0], - // state.point[1], - // state.point[2] - // ); - // state.sphereActor.getProperty().setColor(state.color); - // state.sphereSource.modified(); - // } - // }); - // } else if (sphereState.axis === 'z') { - // newPoint[POINTINDEX.Z] = pickedPoint[POINTINDEX.Z]; - // const otherZSphere = this.sphereStates.find( - // (s, i) => s.axis === 'z' && i !== this.draggingSphereIndex - // ); - // const newZCenter = - // (otherZSphere.point[POINTINDEX.Z] + pickedPoint[POINTINDEX.Z]) / 2; - // this.sphereStates.forEach((state, idx) => { - // if (state.axis !== 'z' && !state.isCorner) { - // // state.point[POINTINDEX.Z] = newZCenter; - // this.sphereStates[idx].point[POINTINDEX.Z] = newZCenter; - // this.sphereStates[idx].sphereSource.setCenter( - // state.point[0], - // state.point[1], - // state.point[2] - // ); - // state.sphereSource.modified(); - // } - // }); - // } - - // this.sphereStates[this.draggingSphereIndex].point[0] = newPoint[0]; - // this.sphereStates[this.draggingSphereIndex].point[1] = newPoint[1]; - // this.sphereStates[this.draggingSphereIndex].point[2] = newPoint[2]; - - // sphereState.sphereSource.setCenter( - // newPoint[0], - // newPoint[1], - // newPoint[2] - // ); - // sphereState.sphereSource.modified(); - - // this._updateCornerSpheres(viewport); - // const clippingPlanes = mapper.getClippingPlanes(); - // clippingPlanes[this.draggingSphereIndex].setOrigin( - // newPoint[0], - // newPoint[1], - // newPoint[2] - // ); - // this.originalClippingPlanes[this.draggingSphereIndex].origin = [ - // newPoint[0], - // newPoint[1], - // newPoint[2], - // ]; - // viewport.render(); - // /// Send event with the new point - // triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { - // toolCenter: newPoint, - // axis: sphereState.axis, - // draggingSphereIndex: this.draggingSphereIndex, - // }); - // } - // } - // }; - - // _updateCornerSpheres(viewport) { - // // Get current face sphere positions - // const xMin = - // this.sphereStates.find((s) => s.axis === 'x' && s.point[0] <= s.point[1]) - // ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMIN].point[0]; - // const xMax = - // this.sphereStates.find((s) => s.axis === 'x' && s.point[0] > s.point[1]) - // ?.point[0] ?? this.sphereStates[SPHEREINDEX.XMAX].point[0]; - // const yMin = - // this.sphereStates.find((s) => s.axis === 'y' && s.point[1] <= s.point[0]) - // ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMIN].point[1]; - // const yMax = - // this.sphereStates.find((s) => s.axis === 'y' && s.point[1] > s.point[0]) - // ?.point[1] ?? this.sphereStates[SPHEREINDEX.YMAX].point[1]; - // const zMin = - // this.sphereStates.find((s) => s.axis === 'z' && s.point[2] <= s.point[0]) - // ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMIN].point[2]; - // const zMax = - // this.sphereStates.find((s) => s.axis === 'z' && s.point[2] > s.point[0]) - // ?.point[2] ?? this.sphereStates[SPHEREINDEX.ZMAX].point[2]; - - // // All 8 corners, with their keys - // 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) { - // // Update the sphere position and color - // state.point[0] = corner.pos[0]; - // state.point[1] = corner.pos[1]; - // state.point[2] = corner.pos[2]; - // state.sphereSource.setCenter( - // state.point[0], - // state.point[1], - // state.point[2] - // ); - // state.sphereSource.modified(); - // } - // } - // // ...existing code for updating corners... - - // // Update edge cylinders - // Object.values(this.edgeCylinders).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 point1 = state1.point; - // const point2 = state2.point; - // // Compute new direction and length - // const direction = [ - // point2[0] - point1[0], - // point2[1] - point1[1], - // point2[2] - point1[2], - // ]; - // const length = Math.sqrt( - // direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 - // ); - // // Normalize direction vector - // const normDirection: [number, number, number] = [0, 0, 0]; - // vec3.normalize(normDirection, direction); - // const center = [ - // (point1[0] + point2[0]) / 2, - // (point1[1] + point2[1]) / 2, - // (point1[2] + point2[2]) / 2, - // ]; - // source.setCenter(center[0], center[1], center[2]); - // source.setHeight(length); - - // source.setDirection( - // normDirection[0], - // normDirection[1], - // normDirection[2] - // ); - // source.modified(); - // } - // }); - // } - _onMouseMoveSphere = (evt) => { if (this.draggingSphereIndex === null) { return; @@ -1424,15 +804,6 @@ class VolumeCroppingTool extends AnnotationTool { } else { // For face spheres: only update the coordinate along the face's axis const axis = sphereState.axis; - const axisIdx = { x: 0, y: 1, z: 2 }[axis]; - - // Get min/max for each axis from all face spheres - 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]; // Project mouse world position onto the axis let newValue; From 4a06d67e0e7986b74900ef870c88765cfac3fbb1 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 13 Jul 2025 20:14:26 +0200 Subject: [PATCH 069/132] refactor(VolumeCroppingTool): update handle visibility logic and implement corner drag offset for improved sphere manipulation --- .../tools/src/tools/VolumeCroppingTool.ts | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index ee53bca423..ae30e35518 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -6,7 +6,6 @@ import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; -import type vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; import { AnnotationTool } from './base'; @@ -187,13 +186,14 @@ class VolumeCroppingTool extends AnnotationTool { key2: string; }; } = {}; + cornerDragOffset: [number, number, number] | null = null; constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse'], configuration: { - showCornerSpheres: true, + showCornerSpheres: false, showHandles: true, mobile: { enabled: false, @@ -261,33 +261,39 @@ class VolumeCroppingTool extends AnnotationTool { this.configuration.showHandles = visible; // Remove or show actors accordingly this._updateHandlesVisibility(); + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + if (visible) { + this._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(viewport); + this._updateClippingPlanesFromFaceSpheres(viewport); + // this._updateClippingPlanes(viewport); + } + viewport.render(); } + getHandlesVisible() { return this.configuration.showHandles; } - _updateHandlesVisibility() { - const viewportsInfo = this._getViewportsInfo(); - viewportsInfo.forEach(({ renderingEngineId, viewportId }) => { - const renderingEngine = getRenderingEngine(renderingEngineId); - const viewport = renderingEngine.getViewport(viewportId); - - // Spheres - this.sphereStates.forEach((state) => { - if (state.sphereActor) { - state.sphereActor.setVisibility(this.configuration.showHandles); - } - }); - // Edge cylinders - Object.values(this.edgeCylinders).forEach(({ actor }) => { - if (actor) { - actor.setVisibility(this.configuration.showHandles); - } - }); + _updateHandlesVisibility() { + // Spheres + this.sphereStates.forEach((state) => { + if (state.sphereActor) { + state.sphereActor.setVisibility(this.configuration.showHandles); + } + }); - viewport.render(); + // Edge cylinders + Object.values(this.edgeCylinders).forEach(({ actor }) => { + if (actor) { + actor.setVisibility(this.configuration.showHandles); + } }); } + _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; return viewports; @@ -697,7 +703,6 @@ class VolumeCroppingTool extends AnnotationTool { const element = evt.currentTarget; const viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); const mouseCanvas = [evt.offsetX, evt.offsetY]; @@ -711,10 +716,24 @@ class VolumeCroppingTool extends AnnotationTool { if (dist < this.configuration.grabSpherePixelDistance) { this.draggingSphereIndex = i; element.style.cursor = 'grabbing'; + + // --- Store offset for corners --- + const sphereState = this.sphereStates[i]; + if (sphereState.isCorner) { + const mouseWorld = viewport.canvasToWorld(mouseCanvas); + this.cornerDragOffset = [ + sphereState.point[0] - mouseWorld[0], + sphereState.point[1] - mouseWorld[1], + sphereState.point[2] - mouseWorld[2], + ]; + } else { + this.cornerDragOffset = null; + } return; } } this.draggingSphereIndex = null; + this.cornerDragOffset = null; }; _onMouseMoveSphere = (evt) => { @@ -733,7 +752,7 @@ class VolumeCroppingTool extends AnnotationTool { const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; - + console.debug(`Dragging sphere at (${x}, ${y})`); // Convert canvas to world coordinates const world = viewport.canvasToWorld([x, y]); @@ -744,7 +763,14 @@ class VolumeCroppingTool extends AnnotationTool { if (sphereState.isCorner) { // Move the dragged corner sphere - const newCorner = [...world]; + let newCorner = [...world]; + if (this.cornerDragOffset) { + newCorner = [ + world[0] + this.cornerDragOffset[0], + world[1] + this.cornerDragOffset[1], + world[2] + this.cornerDragOffset[2], + ]; + } sphereState.point = newCorner; sphereState.sphereSource.setCenter(...newCorner); sphereState.sphereSource.modified(); @@ -1065,6 +1091,7 @@ class VolumeCroppingTool extends AnnotationTool { } } this.draggingSphereIndex = null; + this.cornerDragOffset = null; }; /** From ea019efbbd265bb95ebd7430c25c8ac6bd3a6f79 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:08:58 +0200 Subject: [PATCH 070/132] refactor(VolumeCroppingTool): add face drag offset handling for improved sphere manipulation --- .../tools/src/tools/VolumeCroppingTool.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index ae30e35518..7df0e3d2fd 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -187,6 +187,7 @@ class VolumeCroppingTool extends AnnotationTool { }; } = {}; cornerDragOffset: [number, number, number] | null = null; + faceDragOffset: number | null = null; constructor( toolProps: PublicToolProps = {}, @@ -719,14 +720,19 @@ class VolumeCroppingTool extends AnnotationTool { // --- Store offset for corners --- const sphereState = this.sphereStates[i]; + const mouseWorld = viewport.canvasToWorld(mouseCanvas); if (sphereState.isCorner) { - const mouseWorld = viewport.canvasToWorld(mouseCanvas); 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; @@ -734,6 +740,7 @@ class VolumeCroppingTool extends AnnotationTool { } this.draggingSphereIndex = null; this.cornerDragOffset = null; + this.faceDragOffset = null; }; _onMouseMoveSphere = (evt) => { @@ -752,7 +759,7 @@ class VolumeCroppingTool extends AnnotationTool { const rect = element.getBoundingClientRect(); const x = evt.clientX - rect.left; const y = evt.clientY - rect.top; - console.debug(`Dragging sphere at (${x}, ${y})`); + // Convert canvas to world coordinates const world = viewport.canvasToWorld([x, y]); @@ -830,9 +837,11 @@ class VolumeCroppingTool extends AnnotationTool { } else { // For face spheres: only update the coordinate along the face's axis const axis = sphereState.axis; - - // Project mouse world position onto the axis - let newValue; + const axisIdx = { x: 0, y: 1, z: 2 }[axis]; + let newValue = world[axisIdx]; + if (this.faceDragOffset !== null) { + newValue += this.faceDragOffset; + } if (axis === 'x') { newValue = world[0]; if (this.draggingSphereIndex === SPHEREINDEX.XMIN) { @@ -1092,6 +1101,7 @@ class VolumeCroppingTool extends AnnotationTool { } this.draggingSphereIndex = null; this.cornerDragOffset = null; + this.faceDragOffset = null; }; /** From eb908d1576cedcb6fd1ea1a26110a99a0319eb6a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:15:46 +0200 Subject: [PATCH 071/132] Add dashed extended control line option --- .../src/tools/VolumeCroppingControlTool.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 119540c0da..3092500294 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -120,6 +120,7 @@ class VolumeCroppingControlTool extends AnnotationTool { x: null, y: null, }, + extendReferenceLines: true, referenceLinesCenterGapRadius: 20, initialCropFactor: 0.2, mobile: { @@ -970,13 +971,26 @@ class VolumeCroppingControlTool extends AnnotationTool { lineUID, intersections[0].point, intersections[1].point, - // line[1], - // line[2], { color, lineWidth, } ); + if (this.configuration.extendReferenceLines) { + const dashLineUID = lineUID + '_dashed'; + drawLineSvg( + svgDrawingHelper, + annotationUID, + dashLineUID, + line[1], + line[2], + { + color, + lineWidth, + lineDash: [4, 4], + } + ); + } } } From c720ec578532fee77c8f025178fc9b2c8a8b8f4f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:53:16 +0200 Subject: [PATCH 072/132] use CAMERA_MODIFIED event --- packages/tools/src/tools/PanTool.ts | 11 +- .../tools/src/tools/TrackballRotateTool.ts | 6 +- .../src/tools/VolumeCroppingControlTool.ts | 102 +++++++++--------- .../tools/src/tools/VolumeCroppingTool.ts | 34 ++---- packages/tools/src/tools/ZoomTool.ts | 17 +-- 5 files changed, 64 insertions(+), 106 deletions(-) diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index 346dbad359..415295116c 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -1,11 +1,5 @@ import { BaseTool } from './base'; -import { - getEnabledElement, - triggerEvent, - eventTarget, - Enums, -} from '@cornerstonejs/core'; -const { Events } = Enums; +import { getEnabledElement } from '@cornerstonejs/core'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import type { Types } from '@cornerstonejs/core'; @@ -63,9 +57,6 @@ class PanTool extends BaseTool { focalPoint: updatedFocalPoint, position: updatedPosition, }); - triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { - viewport: enabledElement.viewport, - }); enabledElement.viewport.render(); } } diff --git a/packages/tools/src/tools/TrackballRotateTool.ts b/packages/tools/src/tools/TrackballRotateTool.ts index 6775319e44..ea1124ff61 100644 --- a/packages/tools/src/tools/TrackballRotateTool.ts +++ b/packages/tools/src/tools/TrackballRotateTool.ts @@ -2,7 +2,6 @@ import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import { Events } from '../enums'; import { eventTarget, - triggerEvent, getEnabledElement, getEnabledElementByIds, } from '@cornerstonejs/core'; @@ -183,10 +182,6 @@ class TrackballRotateTool extends BaseTool { viewUp: newViewUp, focalPoint: newFocalPoint, }); - - triggerEvent(eventTarget, 'CORNERSTONE_CAMERA_MODIFIED', { - viewport: viewport, - }); }; _dragCallback(evt: EventTypes.InteractionEventType): void { @@ -196,6 +191,7 @@ class TrackballRotateTool extends BaseTool { const { rotateIncrementDegrees } = this.configuration; const enabledElement = getEnabledElement(element); const { viewport } = enabledElement; + const camera = viewport.getCamera(); const width = element.clientWidth; const height = element.clientHeight; diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 3092500294..e25cc4f5a8 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1049,69 +1049,71 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _onSphereMoved = (evt) => { - if ([0, 2, 4].includes(evt.detail.draggingSphereIndex)) { - // only update for min spheres - const newCenter: Types.Point3 = [...this.toolCenterMin]; - const eventCenter = evt.detail.toolCenter; - if (evt.detail.axis === 'x') { - newCenter[0] = eventCenter[0]; - } else if (evt.detail.axis === 'y') { - newCenter[1] = eventCenter[1]; - } else if (evt.detail.axis === 'z') { - newCenter[2] = eventCenter[2]; - } - this.setToolCenter(newCenter, 'min'); - } else if ([1, 3, 5].includes(evt.detail.draggingSphereIndex)) { - // only update for max spheres - const newCenter: Types.Point3 = [...this.toolCenterMax]; - const eventCenter = evt.detail.toolCenter; - if (evt.detail.axis === 'x') { - newCenter[0] = eventCenter[0]; - } else if (evt.detail.axis === 'y') { - newCenter[1] = eventCenter[1]; - } else if (evt.detail.axis === 'z') { - newCenter[2] = eventCenter[2]; + const { draggingSphereIndex, toolCenter } = evt.detail; + // Use enums for clarity + const SPHEREINDEX = { + XMIN: 0, + XMAX: 1, + YMIN: 2, + YMAX: 3, + ZMIN: 4, + ZMAX: 5, + // Corner indices: 6-13 (if needed) + }; + + // Helper to update min/max + const updateAxis = (arr, axis, value) => { + arr[axis] = value; + }; + + // Copy current min/max + const newMin = [...this.toolCenterMin]; + const newMax = [...this.toolCenterMax]; + + // Face spheres + if (draggingSphereIndex >= 0 && draggingSphereIndex <= 5) { + const axis = Math.floor(draggingSphereIndex / 2); // 0:x, 1:y, 2:z + const isMin = draggingSphereIndex % 2 === 0; + if (isMin) { + updateAxis(newMin, axis, toolCenter[axis]); + } else { + updateAxis(newMax, axis, toolCenter[axis]); } - this.setToolCenter(newCenter, 'max'); - } else { - // corner sphere moved, update both min and max - const eventCenter = evt.detail.toolCenter; - // For each axis, check if the moved corner is at min or max for that axis - const minIdx = [0, 2, 4]; // XMIN, YMIN, ZMIN - const maxIdx = [1, 3, 5]; // XMAX, YMAX, ZMAX - - // Copy current min and max - const newMin: Types.Point3 = [...this.toolCenterMin]; - const newMax: Types.Point3 = [...this.toolCenterMax]; - - // For each axis, update min or max depending on the corner index - // draggingSphereIndex: 6 = XMIN_YMIN_ZMIN, 7 = XMIN_YMIN_ZMAX, ... up to 13 = XMAX_YMAX_ZMAX - const idx = evt.detail.draggingSphereIndex; - // Determine for each axis if this corner is at min or max - // X axis - if ([6, 7, 8, 9].includes(idx)) { - newMin[0] = eventCenter[0]; + this.setToolCenter(newMin, 'min'); + this.setToolCenter(newMax, 'max'); + return; + } + + // Corner spheres (indices 6-13) + if (draggingSphereIndex >= 6 && draggingSphereIndex <= 13) { + // For each axis, update min or max depending on the corner index bits + // X: 6-9 = min, 10-13 = max + // Y: 6,7,10,11 = min, 8,9,12,13 = max + // Z: even = min, odd = max + const idx = draggingSphereIndex; + // X + if (idx < 10) { + newMin[0] = toolCenter[0]; } else { - newMax[0] = eventCenter[0]; + newMax[0] = toolCenter[0]; } - // Y axis + // Y if ([6, 7, 10, 11].includes(idx)) { - newMin[1] = eventCenter[1]; + newMin[1] = toolCenter[1]; } else { - newMax[1] = eventCenter[1]; + newMax[1] = toolCenter[1]; } - // Z axis - if ([6, 8, 10, 12].includes(idx)) { - newMin[2] = eventCenter[2]; + // Z + if (idx % 2 === 0) { + newMin[2] = toolCenter[2]; } else { - newMax[2] = eventCenter[2]; + newMax[2] = toolCenter[2]; } this.setToolCenter(newMin, 'min'); this.setToolCenter(newMax, 'max'); } }; - _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); if (viewportsInfo && viewportsInfo.length > 0) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 7df0e3d2fd..0cd2047e3e 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -555,10 +555,6 @@ class VolumeCroppingTool extends AnnotationTool { } ); - eventTarget.addEventListener('CORNERSTONE_CAMERA_MODIFIED', (evt) => { - this._updateClippingPlanes(evt.detail.viewport); - }); - const element = viewport.canvas || viewport.element; element.addEventListener('mousedown', this._onMouseDownSphere); element.addEventListener('mousemove', this._onMouseMoveSphere); @@ -842,28 +838,12 @@ class VolumeCroppingTool extends AnnotationTool { if (this.faceDragOffset !== null) { newValue += this.faceDragOffset; } - if (axis === 'x') { - newValue = world[0]; - if (this.draggingSphereIndex === SPHEREINDEX.XMIN) { - this.sphereStates[SPHEREINDEX.XMIN].point[0] = newValue; - } else { - this.sphereStates[SPHEREINDEX.XMAX].point[0] = newValue; - } - } else if (axis === 'y') { - newValue = world[1]; - if (this.draggingSphereIndex === SPHEREINDEX.YMIN) { - this.sphereStates[SPHEREINDEX.YMIN].point[1] = newValue; - } else { - this.sphereStates[SPHEREINDEX.YMAX].point[1] = newValue; - } - } else if (axis === 'z') { - newValue = world[2]; - if (this.draggingSphereIndex === SPHEREINDEX.ZMIN) { - this.sphereStates[SPHEREINDEX.ZMIN].point[2] = newValue; - } else { - this.sphereStates[SPHEREINDEX.ZMAX].point[2] = newValue; - } - } + // Only update the correct axis for the correct sphere + this.sphereStates[this.draggingSphereIndex].point[axisIdx] = newValue; + this.sphereStates[this.draggingSphereIndex].sphereSource.setCenter( + ...this.sphereStates[this.draggingSphereIndex].point + ); + this.sphereStates[this.draggingSphereIndex].sphereSource.modified(); // After updating the face sphere, update all corners from faces this._updateCornerSpheresFromFaces(); @@ -1133,7 +1113,9 @@ class VolumeCroppingTool extends AnnotationTool { ? { element: evt.currentTarget } : evt.detail; const enabledElement = getEnabledElement(element); + this._updateClippingPlanes(enabledElement.viewport); enabledElement.viewport.render(); + console.debug('VolumeCroppingTool: Camera modified', evt); }; onResetCamera = (evt) => { diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index eb74d29e56..ffe482d544 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -1,15 +1,10 @@ import { vec3 } from 'gl-matrix'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import type { Types } from '@cornerstonejs/core'; -import { - Enums, - getEnabledElement, - triggerEvent, - eventTarget, -} from '@cornerstonejs/core'; -const { Events } = Enums; +import { Enums, getEnabledElement } from '@cornerstonejs/core'; import { BaseTool } from './base'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; +import { Events } from '../enums'; /** * ZoomTool tool manipulates the camera zoom applied to a viewport. It @@ -120,10 +115,6 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera, true); } - - triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { - viewport: viewport, - }); viewport.render(); } @@ -145,10 +136,6 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera); } - - triggerEvent(eventTarget, Events.CAMERA_MODIFIED, { - viewport: viewport, - }); viewport.render(); } From ef15cca0c4d2aa80ea9dfc816457f8746c6331ba Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:55:29 +0200 Subject: [PATCH 073/132] fix(PanTool, ZoomTool): clean up imports and ensure viewport renders after zoom adjustments --- packages/tools/src/tools/PanTool.ts | 3 ++- packages/tools/src/tools/ZoomTool.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/tools/PanTool.ts b/packages/tools/src/tools/PanTool.ts index 415295116c..ff96b39c4f 100644 --- a/packages/tools/src/tools/PanTool.ts +++ b/packages/tools/src/tools/PanTool.ts @@ -1,8 +1,9 @@ import { BaseTool } from './base'; import { getEnabledElement } from '@cornerstonejs/core'; -import type { EventTypes, PublicToolProps, ToolProps } from '../types'; import type { Types } from '@cornerstonejs/core'; +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; + /** * Tool that pans the camera in the plane defined by the viewPlaneNormal and the viewUp. */ diff --git a/packages/tools/src/tools/ZoomTool.ts b/packages/tools/src/tools/ZoomTool.ts index ffe482d544..e14dde0963 100644 --- a/packages/tools/src/tools/ZoomTool.ts +++ b/packages/tools/src/tools/ZoomTool.ts @@ -136,6 +136,7 @@ class ZoomTool extends BaseTool { } else { this._dragPerspectiveProjection(evt, viewport, camera); } + viewport.render(); } From f4922972eedad6f225324bf092212323e26c0a35 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:20:06 +0200 Subject: [PATCH 074/132] refactor(VolumeCroppingTool): update handle visibility logic to synchronize sphere positions with clipping planes --- .../tools/src/tools/VolumeCroppingTool.ts | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 0cd2047e3e..0e347164ba 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -257,24 +257,57 @@ class VolumeCroppingTool extends AnnotationTool { // Implement your logic here if needed return false; } - setHandlesVisible(visible: boolean) { this.configuration.showHandles = visible; - // Remove or show actors accordingly + // Before showing, update sphere positions to match clipping planes + if (visible) { + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + + // 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(viewport); + } + + // Show/hide actors this._updateHandlesVisibility(); + + // Render const viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - if (visible) { - this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(viewport); - this._updateClippingPlanesFromFaceSpheres(viewport); - // this._updateClippingPlanes(viewport); - } viewport.render(); } - getHandlesVisible() { return this.configuration.showHandles; } @@ -1115,7 +1148,6 @@ class VolumeCroppingTool extends AnnotationTool { const enabledElement = getEnabledElement(element); this._updateClippingPlanes(enabledElement.viewport); enabledElement.viewport.render(); - console.debug('VolumeCroppingTool: Camera modified', evt); }; onResetCamera = (evt) => { From 0dbbd8e59d5d410dd9bc0baa66b91049ccd24757 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:49:24 +0200 Subject: [PATCH 075/132] refactor(VolumeCroppingTool): add type annotations for improved type safety in clipping plane calculations --- .../src/tools/VolumeCroppingControlTool.ts | 4 +-- .../tools/src/tools/VolumeCroppingTool.ts | 34 ++++++++++++------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index e25cc4f5a8..601ad1a1d7 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1067,8 +1067,8 @@ class VolumeCroppingControlTool extends AnnotationTool { }; // Copy current min/max - const newMin = [...this.toolCenterMin]; - const newMax = [...this.toolCenterMax]; + const newMin: [number, number, number] = [...this.toolCenterMin]; + const newMax: [number, number, number] = [...this.toolCenterMax]; // Face spheres if (draggingSphereIndex >= 0 && draggingSphereIndex <= 5) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 0e347164ba..7664dd2cef 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -676,11 +676,11 @@ class VolumeCroppingTool extends AnnotationTool { // Update planes and spheres for each axis for (let i = 0; i < 3; ++i) { - const origin = [0, 0, 0]; + const origin: [number, number, number] = [0, 0, 0]; origin[i] = toolCenter[i]; const plane = vtkPlane.newInstance({ origin, - normal: normals[i], + normal: normals[i] as [number, number, number], }); this.originalClippingPlanes[planeIndices[i]].origin = plane.getOrigin(); @@ -714,7 +714,7 @@ class VolumeCroppingTool extends AnnotationTool { // Update vtk clipping plane origin const volumeActor = viewport.getDefaultActor()?.actor; if (volumeActor) { - const mapper = volumeActor.getMapper(); + const mapper = volumeActor.getMapper() as vtkVolumeMapper; const clippingPlanes = mapper.getClippingPlanes(); clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); } @@ -735,7 +735,7 @@ class VolumeCroppingTool extends AnnotationTool { const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const mouseCanvas = [evt.offsetX, evt.offsetY]; + const mouseCanvas: [number, number] = [evt.offsetX, evt.offsetY]; // Find the sphere under the mouse for (let i = 0; i < this.sphereStates.length; ++i) { const sphereCanvas = viewport.worldToCanvas(this.sphereStates[i].point); @@ -799,7 +799,7 @@ class VolumeCroppingTool extends AnnotationTool { if (sphereState.isCorner) { // Move the dragged corner sphere - let newCorner = [...world]; + let newCorner: [number, number, number] = [world[0], world[1], world[2]]; if (this.cornerDragOffset) { newCorner = [ world[0] + this.cornerDragOffset[0], @@ -918,9 +918,19 @@ class VolumeCroppingTool extends AnnotationTool { 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: this.originalClippingPlanes[i].origin, - normal: this.originalClippingPlanes[i].normal, + origin, + normal, }); mapper.addClippingPlane(plane); } @@ -1071,21 +1081,21 @@ class VolumeCroppingTool extends AnnotationTool { if (state1 && state2) { const point1 = state1.point; const point2 = state2.point; - const direction = [ + const direction = new Float32Array([ point2[0] - point1[0], point2[1] - point1[1], point2[2] - point1[2], - ]; + ]); const length = Math.sqrt( direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 ); - const normDirection = [0, 0, 0]; + const normDirection = new Float32Array([0, 0, 0]); vec3.normalize(normDirection, direction); - const center = [ + const center = new Float32Array([ (point1[0] + point2[0]) / 2, (point1[1] + point2[1]) / 2, (point1[2] + point2[2]) / 2, - ]; + ]); source.setCenter(center[0], center[1], center[2]); source.setHeight(length); source.setDirection( From d1aa6bcdd20b67db14a191df949a75e727a1b5d8 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:53:46 +0200 Subject: [PATCH 076/132] refactor(VolumeCroppingTool): remove viewport parameter from _updateCornerSpheres calls for consistency --- .../src/tools/VolumeCroppingControlTool.ts | 148 +++++++++++------- .../tools/src/tools/VolumeCroppingTool.ts | 6 +- 2 files changed, 92 insertions(+), 62 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 601ad1a1d7..d5c9907169 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -383,13 +383,9 @@ class VolumeCroppingControlTool extends AnnotationTool { setToolCenter(toolCenter: Types.Point3, handleType): void { if (handleType === 'min') { - this.toolCenterMin[0] = toolCenter[0]; - this.toolCenterMin[1] = toolCenter[1]; - this.toolCenterMin[2] = toolCenter[2]; + this.toolCenterMin = [...toolCenter]; } else if (handleType === 'max') { - this.toolCenterMax[0] = toolCenter[0]; - this.toolCenterMax[1] = toolCenter[1]; - this.toolCenterMax[2] = toolCenter[2]; + this.toolCenterMax = [...toolCenter]; } const viewportsInfo = this._getViewportsInfo(); @@ -594,9 +590,6 @@ class VolumeCroppingControlTool extends AnnotationTool { (a) => a.data.handles.activeOperation === 1 // OPERATION.DRAG ); - if (selectedAnnotations.length > 1) { - // console.debug('More than one annotation is being dragged/selected'); - } if (this.editData && this.editData.annotation) { const activeType = this.editData?.annotation?.data?.handles?.activeType; @@ -976,21 +969,21 @@ class VolumeCroppingControlTool extends AnnotationTool { lineWidth, } ); - if (this.configuration.extendReferenceLines) { - const dashLineUID = lineUID + '_dashed'; - drawLineSvg( - svgDrawingHelper, - annotationUID, - dashLineUID, - line[1], - line[2], - { - color, - lineWidth, - lineDash: [4, 4], - } - ); - } + } + if (this.configuration.extendReferenceLines) { + const dashLineUID = lineUID + '_dashed'; + drawLineSvg( + svgDrawingHelper, + annotationUID, + dashLineUID, + line[1], + line[2], + { + color, + lineWidth, + lineDash: [4, 4], + } + ); } } @@ -1048,72 +1041,109 @@ class VolumeCroppingControlTool extends AnnotationTool { return toolGroupAnnotations; }; + // _onSphereMoved = (evt) => { + // const { draggingSphereIndex, toolCenter } = evt.detail; + // // Use enums for clarity + // const SPHEREINDEX = { + // XMIN: 0, + // XMAX: 1, + // YMIN: 2, + // YMAX: 3, + // ZMIN: 4, + // ZMAX: 5, + // // Corner indices: 6-13 (if needed) + // }; + + // // Helper to update min/max + // const updateAxis = (arr, axis, value) => { + // arr[axis] = value; + // }; + + // // Copy current min/max + // 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); // 0:x, 1:y, 2:z + // const isMin = draggingSphereIndex % 2 === 0; + // if (isMin) { + // updateAxis(newMin, axis, toolCenter[axis]); + // } else { + // updateAxis(newMax, axis, toolCenter[axis]); + // } + // this.setToolCenter(newMin, 'min'); + // this.setToolCenter(newMax, 'max'); + // return; + // } + + // // Corner spheres (indices 6-13) + // if (draggingSphereIndex >= 6 && draggingSphereIndex <= 13) { + // // For each axis, update min or max depending on the corner index bits + // // X: 6-9 = min, 10-13 = max + // // Y: 6,7,10,11 = min, 8,9,12,13 = max + // // Z: even = min, odd = max + // const idx = draggingSphereIndex; + // // X + // if (idx < 10) { + // newMin[0] = toolCenter[0]; + // } else { + // newMax[0] = toolCenter[0]; + // } + // // Y + // if ([6, 7, 10, 11].includes(idx)) { + // newMin[1] = toolCenter[1]; + // } else { + // newMax[1] = toolCenter[1]; + // } + // // Z + // if (idx % 2 === 0) { + // newMin[2] = toolCenter[2]; + // } else { + // newMax[2] = toolCenter[2]; + // } + + // this.setToolCenter(newMin, 'min'); + // this.setToolCenter(newMax, 'max'); + // } + // }; + _onSphereMoved = (evt) => { const { draggingSphereIndex, toolCenter } = evt.detail; - // Use enums for clarity - const SPHEREINDEX = { - XMIN: 0, - XMAX: 1, - YMIN: 2, - YMAX: 3, - ZMIN: 4, - ZMAX: 5, - // Corner indices: 6-13 (if needed) - }; - - // Helper to update min/max - const updateAxis = (arr, axis, value) => { - arr[axis] = value; - }; - - // Copy current min/max const newMin: [number, number, number] = [...this.toolCenterMin]; const newMax: [number, number, number] = [...this.toolCenterMax]; - - // Face spheres + // face spheres if (draggingSphereIndex >= 0 && draggingSphereIndex <= 5) { - const axis = Math.floor(draggingSphereIndex / 2); // 0:x, 1:y, 2:z + const axis = Math.floor(draggingSphereIndex / 2); const isMin = draggingSphereIndex % 2 === 0; - if (isMin) { - updateAxis(newMin, axis, toolCenter[axis]); - } else { - updateAxis(newMax, axis, toolCenter[axis]); - } + (isMin ? newMin : newMax)[axis] = toolCenter[axis]; this.setToolCenter(newMin, 'min'); this.setToolCenter(newMax, 'max'); return; } - - // Corner spheres (indices 6-13) + // corner spheres if (draggingSphereIndex >= 6 && draggingSphereIndex <= 13) { - // For each axis, update min or max depending on the corner index bits - // X: 6-9 = min, 10-13 = max - // Y: 6,7,10,11 = min, 8,9,12,13 = max - // Z: even = min, odd = max const idx = draggingSphereIndex; - // X if (idx < 10) { newMin[0] = toolCenter[0]; } else { newMax[0] = toolCenter[0]; } - // Y if ([6, 7, 10, 11].includes(idx)) { newMin[1] = toolCenter[1]; } else { newMax[1] = toolCenter[1]; } - // Z 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) { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 7664dd2cef..8d7c614750 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -724,7 +724,7 @@ class VolumeCroppingTool extends AnnotationTool { this.configuration.showHandles && this.configuration.showCornerSpheres ) { - this._updateCornerSpheres(viewport); + this._updateCornerSpheres(); } viewport.render(); }; @@ -861,7 +861,7 @@ class VolumeCroppingTool extends AnnotationTool { // After updating corners, update face spheres and edge cylinders this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(viewport); + this._updateCornerSpheres(); this._updateClippingPlanesFromFaceSpheres(viewport); } else { // For face spheres: only update the coordinate along the face's axis @@ -881,7 +881,7 @@ class VolumeCroppingTool extends AnnotationTool { // After updating the face sphere, update all corners from faces this._updateCornerSpheresFromFaces(); this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(viewport); + this._updateCornerSpheres(); this._updateClippingPlanesFromFaceSpheres(viewport); } From e2ccc5a0fb4beae622d4274619f3d79c7cf4bc84 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 14 Jul 2025 16:26:01 +0200 Subject: [PATCH 077/132] Stack scroll should not change clipping planes --- .../src/tools/VolumeCroppingControlTool.ts | 59 ++++++------------- 1 file changed, 17 insertions(+), 42 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index d5c9907169..dc9f47d5ed 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -521,10 +521,6 @@ class VolumeCroppingControlTool extends AnnotationTool { return; } - // -- Update the camera of other linked viewports containing the same volumeId that - // have the same camera in case of translation - // This is necessary because other tools can modify the position of the slices, - // e.g. stackScroll tool at wheel scroll. So we update the coordinates of the center always here. const currentCamera = viewport.getCamera(); const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; const deltaCameraPosition: Types.Point3 = [0, 0, 0]; @@ -568,19 +564,21 @@ class VolumeCroppingControlTool extends AnnotationTool { 1e-3 ); - // NOTE: it is a translation if the the focal point and camera position shifts are the same if (!cameraModifiedSameForPosAndFocalPoint) { isRotation = true; } - const cameraModifiedInPlane = - Math.abs( - vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) - ) < 1e-2; + // Only update cropping reference lines if the camera movement is NOT a stack scroll. + // Stack scroll: camera moves along viewPlaneNormal (dot product large). + // Pan/zoom: camera moves perpendicular to viewPlaneNormal (dot product small). + const dot = Math.abs( + vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) + ); + const isStackScroll = dot > 1e-2; // TRANSLATION - // NOTE1: if the camera modified is a result of a pan or zoom don't update the volume cropping center - if (!isRotation && !cameraModifiedInPlane) { + // Only update cropping reference lines for pan/zoom, not stack scroll + if (!isRotation && !isStackScroll) { this.toolCenter[0] += deltaCameraPosition[0]; this.toolCenter[1] += deltaCameraPosition[1]; this.toolCenter[2] += deltaCameraPosition[2]; @@ -1601,38 +1599,15 @@ class VolumeCroppingControlTool extends AnnotationTool { 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: [], // You can fill this if needed + }); } - - // TRANSLATION - // get the annotation of the other viewport which are parallel to the delta shift and are of the same scene - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - const viewportsAnnotationsToUpdate = otherViewportAnnotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - return ( - otherViewportControllable === true && - viewportAnnotation.data.activeViewportIds.find( - (id) => id === otherViewport.id - ) - ); - } - ); - - this._applyDeltaShiftToSelectedViewportCameras( - renderingEngine, - viewportsAnnotationsToUpdate, - delta - ); }; _applyDeltaShiftToSelectedViewportCameras( From 140b8108f8c4a9fcee6290edc80331b4e1f19976 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:57:54 +0200 Subject: [PATCH 078/132] refactor(VolumeCroppingControlTool): remove commented-out _onSphereMoved logic for clarity --- .../src/tools/VolumeCroppingControlTool.ts | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index dc9f47d5ed..3965b7d8cb 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1039,73 +1039,6 @@ class VolumeCroppingControlTool extends AnnotationTool { return toolGroupAnnotations; }; - // _onSphereMoved = (evt) => { - // const { draggingSphereIndex, toolCenter } = evt.detail; - // // Use enums for clarity - // const SPHEREINDEX = { - // XMIN: 0, - // XMAX: 1, - // YMIN: 2, - // YMAX: 3, - // ZMIN: 4, - // ZMAX: 5, - // // Corner indices: 6-13 (if needed) - // }; - - // // Helper to update min/max - // const updateAxis = (arr, axis, value) => { - // arr[axis] = value; - // }; - - // // Copy current min/max - // 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); // 0:x, 1:y, 2:z - // const isMin = draggingSphereIndex % 2 === 0; - // if (isMin) { - // updateAxis(newMin, axis, toolCenter[axis]); - // } else { - // updateAxis(newMax, axis, toolCenter[axis]); - // } - // this.setToolCenter(newMin, 'min'); - // this.setToolCenter(newMax, 'max'); - // return; - // } - - // // Corner spheres (indices 6-13) - // if (draggingSphereIndex >= 6 && draggingSphereIndex <= 13) { - // // For each axis, update min or max depending on the corner index bits - // // X: 6-9 = min, 10-13 = max - // // Y: 6,7,10,11 = min, 8,9,12,13 = max - // // Z: even = min, odd = max - // const idx = draggingSphereIndex; - // // X - // if (idx < 10) { - // newMin[0] = toolCenter[0]; - // } else { - // newMax[0] = toolCenter[0]; - // } - // // Y - // if ([6, 7, 10, 11].includes(idx)) { - // newMin[1] = toolCenter[1]; - // } else { - // newMax[1] = toolCenter[1]; - // } - // // Z - // if (idx % 2 === 0) { - // newMin[2] = toolCenter[2]; - // } else { - // newMax[2] = toolCenter[2]; - // } - - // this.setToolCenter(newMin, 'min'); - // this.setToolCenter(newMax, 'max'); - // } - // }; - _onSphereMoved = (evt) => { const { draggingSphereIndex, toolCenter } = evt.detail; const newMin: [number, number, number] = [...this.toolCenterMin]; From fb249087e329e91584458c18e28c0ac20da35605 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:59:40 +0200 Subject: [PATCH 079/132] refactor(VolumeCroppingTool): remove viewport parameter from _updateCornerSpheres for consistency --- packages/tools/src/tools/VolumeCroppingTool.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 8d7c614750..90ce6651d3 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -295,7 +295,7 @@ class VolumeCroppingTool extends AnnotationTool { }); // Update corners and edges as well - this._updateCornerSpheres(viewport); + this._updateCornerSpheres(); } // Show/hide actors @@ -1039,7 +1039,7 @@ class VolumeCroppingTool extends AnnotationTool { }); } - _updateCornerSpheres(viewport) { + _updateCornerSpheres() { // Get face sphere positions const xMin = this.sphereStates[SPHEREINDEX.XMIN].point[0]; const xMax = this.sphereStates[SPHEREINDEX.XMAX].point[0]; @@ -1118,7 +1118,7 @@ class VolumeCroppingTool extends AnnotationTool { if (sphereState.isCorner) { this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(viewport); + this._updateCornerSpheres(); this._updateClippingPlanesFromFaceSpheres(viewport); } } From 32663a819515383a0b23547b11a0f5af6fa1d316 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:26:27 +0200 Subject: [PATCH 080/132] refactor(VolumeCroppingTool): update initial crop factor, enable corner spheres, and improve debug logging --- .../src/tools/VolumeCroppingControlTool.ts | 14 ++++++++++++- .../tools/src/tools/VolumeCroppingTool.ts | 20 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 3965b7d8cb..f568c83f50 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -201,6 +201,12 @@ class VolumeCroppingControlTool extends AnnotationTool { normal: Types.Point3; point: Types.Point3; } => { + if (!renderingEngineId || !viewportId) { + console.warn( + 'VolumeCroppingControlTool: Missing renderingEngineId or viewportId' + ); + return; + } const enabledElement = getEnabledElementByIds( viewportId, renderingEngineId @@ -352,6 +358,12 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _computeToolCenter = (viewportsInfo): void => { + if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { + console.warn( + 'VolumeCroppingControlTool: No valid viewportsInfo for computeToolCenter.' + ); + return; + } // Todo: handle two same view viewport, or more than 3 viewports const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; @@ -1635,5 +1647,5 @@ class VolumeCroppingControlTool extends AnnotationTool { } } -VolumeCroppingControlTool.toolName = 'VolumeCroppingControlTool'; +VolumeCroppingControlTool.toolName = 'VolumeCroppingControl'; export default VolumeCroppingControlTool; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 90ce6651d3..0bd7da2842 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -194,7 +194,7 @@ class VolumeCroppingTool extends AnnotationTool { defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse'], configuration: { - showCornerSpheres: false, + showCornerSpheres: true, showHandles: true, mobile: { enabled: false, @@ -207,7 +207,7 @@ class VolumeCroppingTool extends AnnotationTool { z: [1.0, 0.0, 0.0], // Red for Z corners: [0.0, 0.0, 1.0], // Blue for corners }, - sphereRadius: 10, + sphereRadius: 8, grabSpherePixelDistance: 20, //pixels threshold for closeness to the sphere being grabbed }, } @@ -334,6 +334,7 @@ class VolumeCroppingTool extends AnnotationTool { }; onSetToolActive() { + console.debug('VolumeCroppingTool: onSetToolActive'); const viewportsInfo = this._getViewportsInfo(); this._unsubscribeToViewportNewVolumeSet(viewportsInfo); this._subscribeToViewportNewVolumeSet(viewportsInfo); @@ -342,14 +343,17 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolPassive() { const viewportsInfo = this._getViewportsInfo(); + console.debug('VolumeCroppingTool: onSetToolPassive'); } onSetToolEnabled() { + console.debug('VolumeCroppingTool: onSetToolEnabled'); const viewportsInfo = this._getViewportsInfo(); this._initialize3DViewports(viewportsInfo); } onSetToolDisabled() { + console.debug('VolumeCroppingTool: onSetToolDisabled'); const viewportsInfo = this._getViewportsInfo(); this._unsubscribeToViewportNewVolumeSet(viewportsInfo); } @@ -418,6 +422,13 @@ class VolumeCroppingTool extends AnnotationTool { } _initialize3DViewports = (viewportsInfo): void => { + console.debug('VolumeCroppingTool: starting.'); + if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { + console.warn( + 'VolumeCroppingTool: No viewportsInfo available for initialization of volumecroppingtool.' + ); + return; + } const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); @@ -1265,6 +1276,7 @@ class VolumeCroppingTool extends AnnotationTool { // mobile sometimes has lingering interaction even when touchEnd triggers // this check allows for multiple handles to be active which doesn't affect // tool usage. + console.debug('Activating VolumeCroppingTool'); state.isInteractingWithTool = !this.configuration.mobile?.enabled; element.addEventListener(Events.MOUSE_UP, this._endCallback); @@ -1278,7 +1290,7 @@ class VolumeCroppingTool extends AnnotationTool { _deactivateModify = (element) => { state.isInteractingWithTool = false; - + console.debug('Deactivating VolumeCroppingTool'); element.removeEventListener(Events.MOUSE_UP, this._endCallback); element.removeEventListener(Events.MOUSE_DRAG, this._dragCallback); element.removeEventListener(Events.MOUSE_CLICK, this._endCallback); @@ -1403,5 +1415,5 @@ class VolumeCroppingTool extends AnnotationTool { } } -VolumeCroppingTool.toolName = 'VolumeCroppingTool'; +VolumeCroppingTool.toolName = 'VolumeCropping'; export default VolumeCroppingTool; From 8c3365f29f71d6e1270366fd40b5fa73594f0143 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:14:29 +0200 Subject: [PATCH 081/132] Add rotate to tool --- .../tools/src/tools/VolumeCroppingTool.ts | 615 +++++++++--------- 1 file changed, 317 insertions(+), 298 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 0bd7da2842..96f8f5e8e5 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,5 +1,5 @@ -import { mat3, vec3 } from 'gl-matrix'; - +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'; @@ -7,7 +7,7 @@ import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; import vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; -import { AnnotationTool } from './base'; +import { BaseTool } from './base'; import type { Types } from '@cornerstonejs/core'; import { @@ -24,8 +24,6 @@ import { getToolGroup } from '../store/ToolGroupManager'; import { state } from '../store/state'; import { Events } from '../enums'; -import { getViewportIdsWithToolToRender } from '../utilities/viewportFilters'; -import { resetElementCursor } from '../cursors/elementCursor'; import { getAnnotations } from '../stateManagement/annotation/annotationState'; // <-- Add this import import * as lineSegment from '../utilities/math/line'; @@ -39,22 +37,6 @@ import type { InteractionTypes, SVGDrawingHelper, } from '../types'; -import { isAnnotationLocked } from '../stateManagement/annotation/annotationLocking'; -import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotationRenderForViewportIds'; - -interface VolumeCroppingAnnotation extends Annotation { - data: { - handles: { - activeOperation: number | null; // 0 translation, 1 rotation handles, 2 slab thickness handles - toolCenter: Types.Point3; - }; - activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated - viewportId: string; - referenceLines: []; // set in renderAnnotation - clippingPlanes?: vtkPlane[]; // clipping planes for the viewport - clippingPlaneReferenceLines?: []; - }; -} function defaultReferenceLineColor() { return 'rgb(0, 200, 0)'; @@ -64,39 +46,6 @@ function defaultReferenceLineControllable() { return true; } -const OPERATION = { - DRAG: 1, - ROTATE: 2, - SLAB: 3, -}; - -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, -}; - function addCylinderBetweenPoints( viewport, point1, @@ -157,14 +106,52 @@ function addCylinderBetweenPoints( viewport.addActor({ actor: cylinderActor, uid: uid }); return { actor: cylinderActor, source: cylinderSource }; } +const OPERATION = { + DRAG: 1, + ROTATE: 2, + SLAB: 3, +}; + +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 is a tool that provides clipping planes to crop a volume */ -class VolumeCroppingTool extends AnnotationTool { +class VolumeCroppingTool extends BaseTool { static toolName; + touchDragCallback: (evt: EventTypes.InteractionEventType) => void; + mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; + cleanUp: () => void; + _resizeObservers = new Map(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _viewportAddedListener: (evt: any) => void; + _hasResolutionChanged = false; originalClippingPlanes: { origin: number[]; normal: number[] }[] = []; - sphereStates: { point: Types.Point3; axis: string; @@ -192,7 +179,7 @@ class VolumeCroppingTool extends AnnotationTool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse'], + supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { showCornerSpheres: true, showHandles: true, @@ -209,11 +196,14 @@ class VolumeCroppingTool extends AnnotationTool { }, 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); this._getReferenceLineColor = toolProps.configuration?.getReferenceLineColor || defaultReferenceLineColor; @@ -222,41 +212,6 @@ class VolumeCroppingTool extends AnnotationTool { defaultReferenceLineControllable; } - addNewAnnotation( - evt: EventTypes.InteractionEventType - ): Annotation | undefined { - // Implement your logic here if needed - return undefined; - } - - cancel(): void { - // Implement your logic here if needed - } - - handleSelectedCallback( - evt: EventTypes.InteractionEventType, - annotation: Annotation, - handle: ToolHandle, - interactionType: InteractionTypes - ): void { - // Implement your logic here if needed - } - - toolSelectedCallback( - evt: EventTypes.InteractionEventType, - annotation: Annotation, - interactionType: InteractionTypes - ): void { - // Implement your logic here if needed - } - - renderAnnotation( - enabledElement: Types.IEnabledElement, - svgDrawingHelper: SVGDrawingHelper - ): boolean { - // Implement your logic here if needed - return false; - } setHandlesVisible(visible: boolean) { this.configuration.showHandles = visible; // Before showing, update sphere positions to match clipping planes @@ -308,6 +263,7 @@ class VolumeCroppingTool extends AnnotationTool { const viewport = renderingEngine.getViewport(viewport3D.viewportId); viewport.render(); } + getHandlesVisible() { return this.configuration.showHandles; } @@ -336,6 +292,57 @@ class VolumeCroppingTool extends AnnotationTool { onSetToolActive() { console.debug('VolumeCroppingTool: onSetToolActive'); 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); @@ -600,9 +607,6 @@ class VolumeCroppingTool extends AnnotationTool { ); const element = viewport.canvas || viewport.element; - element.addEventListener('mousedown', this._onMouseDownSphere); - element.addEventListener('mousemove', this._onMouseMoveSphere); - element.addEventListener('mouseup', this._onMouseUpSphere); }; _updateClippingPlanes(viewport) { @@ -742,6 +746,8 @@ class VolumeCroppingTool extends AnnotationTool { _onMouseDownSphere = (evt) => { const element = evt.currentTarget; + console.debug('VolumeCroppingTool: _onMouseDownSphere', evt); + // Prevent default behavior const viewportsInfo = this._getViewportsInfo(); const [viewport3D] = viewportsInfo; const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); @@ -787,18 +793,21 @@ class VolumeCroppingTool extends AnnotationTool { if (this.draggingSphereIndex === null) { return; } - evt.stopPropagation(); - evt.preventDefault(); - const element = evt.currentTarget; + //evt.stopPropagation(); + //evt.preventDefault(); + + const element = evt.detail.element || evt.currentTarget; const [viewport3D] = this._getViewportsInfo(); const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); // Get 2D mouse position in canvas coordinates const rect = element.getBoundingClientRect(); - const x = evt.clientX - rect.left; - const y = evt.clientY - rect.top; + // const x = evt.clientX - rect.left; + // const y = evt.clientY - rect.top; + const x = evt.detail.currentPoints.canvas[0]; + const y = evt.detail.currentPoints.canvas[1]; // Convert canvas to world coordinates const world = viewport.canvasToWorld([x, y]); @@ -903,6 +912,8 @@ class VolumeCroppingTool extends AnnotationTool { axis: sphereState.isCorner ? 'corner' : sphereState.axis, draggingSphereIndex: this.draggingSphereIndex, }); + + return true; }; _updateClippingPlanesFromFaceSpheres(viewport) { @@ -1120,6 +1131,7 @@ class VolumeCroppingTool extends AnnotationTool { } _onMouseUpSphere = (evt) => { + console.debug('VolumeCroppingTool: _onMouseUpSphere', evt); evt.currentTarget.style.cursor = ''; if (this.draggingSphereIndex !== null) { const sphereState = this.sphereStates[this.draggingSphereIndex]; @@ -1138,30 +1150,6 @@ class VolumeCroppingTool extends AnnotationTool { this.faceDragOffset = null; }; - /** - * It returns if the canvas point is near the provided reference line 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; - }; - onCameraModified = (evt) => { const { element } = evt.currentTarget ? { element: evt.currentTarget } @@ -1175,68 +1163,6 @@ class VolumeCroppingTool extends AnnotationTool { console.debug('on reset camera'); }; - _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; - }; - - 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; - }; - _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); this._initialize3DViewports(viewportsInfo); @@ -1272,146 +1198,239 @@ class VolumeCroppingTool extends AnnotationTool { }); } + preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + 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; + } + console.debug('preMouseDownCallback: VolumeCroppingTool ', { + draggingSphereIndex: this.draggingSphereIndex, + cornerDragOffset: this.cornerDragOffset, + faceDragOffset: this.faceDragOffset, + }); + 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); + console.debug('VolumeCroppingTool: Clean up after mouse up'); + // 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._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(); + 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; + }; + _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. console.debug('Activating VolumeCroppingTool'); 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; console.debug('Deactivating VolumeCroppingTool'); - 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; - console.debug(eventDetail); - const { element } = eventDetail; + this._onMouseUpSphere(evt); + }; - this.editData.annotation.data.handles.activeOperation = null; - this.editData.annotation.data.activeViewportIds = []; + 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, + }); + }; - this._deactivateModify(element); + _dragCallback(evt: EventTypes.InteractionEventType): void { + const { element, currentPoints, lastPoints } = evt.detail; - resetElementCursor(element); + if (this.draggingSphereIndex !== null) { + this._onMouseMoveSphere(evt); + } else { + 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, + ]; - this.editData = null; + const normalizedPreviousPosition = [ + lastPointsCanvas[0] / width, + lastPointsCanvas[1] / height, + ]; - const requireSameOrientation = false; - const viewportIdsToRender = getViewportIdsWithToolToRender( - element, - this.getToolName(), - requireSameOrientation - ); + 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]; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - }; + const radsq = (1.0 + Math.abs(normalizedCenter[0])) ** 2.0; + const op = [normalizedPreviousPosition[0], 0, 0]; + const oe = [normalizedPosition[0], 0, 0]; - _dragCallback = (evt: EventTypes.InteractionEventType) => { - const eventDetail = evt.detail; - const delta = eventDetail.deltaPoints.world; + const opsq = op[0] ** 2; + const oesq = oe[0] ** 2; - if ( - Math.abs(delta[0]) < 1e-3 && - Math.abs(delta[1]) < 1e-3 && - Math.abs(delta[2]) < 1e-3 - ) { - return; - } + const lop = opsq > radsq ? 0 : Math.sqrt(radsq - opsq); + const loe = oesq > radsq ? 0 : Math.sqrt(radsq - oesq); - 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 nop: Types.Point3 = [op[0], 0, lop]; + vtkMath.normalize(nop); + const noe: Types.Point3 = [oe[0], 0, loe]; + vtkMath.normalize(noe); - const { handles } = viewportAnnotation.data; - const { currentPoints } = evt.detail; - const canvasCoords = currentPoints.canvas; + 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; - if (handles.activeOperation === OPERATION.DRAG) { - this.toolCenter[0] += delta[0]; - this.toolCenter[1] += delta[1]; - this.toolCenter[2] += delta[2]; - const viewportsInfo = this._getViewportsInfo(); - triggerAnnotationRenderForViewportIds( - viewportsInfo.map(({ viewportId }) => viewportId) - ); - } - }; + const upVec = camera.viewUp; + const atV = camera.viewPlaneNormal; + const rightV: Types.Point3 = [0, 0, 0]; + const forwardV: Types.Point3 = [0, 0, 0]; - _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, refLinePointTwo, refLinePointThree, refLinePointFour, ...] - const otherViewport = referenceLines[i][0]; - // First segment - const start1 = referenceLines[i][1]; - const end1 = referenceLines[i][2]; - // Second segment - const start2 = referenceLines[i][3]; - const end2 = referenceLines[i][4]; - - const distance1 = lineSegment.distanceToPoint(start1, end1, [ - canvasCoords[0], - canvasCoords[1], - ]); - const distance2 = lineSegment.distanceToPoint(start2, end2, [ - canvasCoords[0], - canvasCoords[1], - ]); + vtkMath.cross(upVec, atV, rightV); + vtkMath.normalize(rightV); - if (distance1 <= proximity || distance2 <= proximity) { - viewportIdArray.push(otherViewport.id); - data.handles.activeOperation = 1; // DRAG - } - } - } + vtkMath.cross(atV, rightV, forwardV); + vtkMath.normalize(forwardV); + vtkMath.normalize(upVec); - data.activeViewportIds = [...viewportIdArray]; + this.rotateCamera(viewport, centerWorld, forwardV, angleX); - this.editData = { - annotation, - }; - return data.handles.activeOperation === 1 ? true : false; + const angleY = + (normalizedPreviousPosition[1] - normalizedPosition[1]) * + rotateIncrementDegrees; + + this.rotateCamera(viewport, centerWorld, rightV, angleY); + } + + viewport.render(); + } } } From ad3196308928fa40ccfa3f4fcdacc10c24e18d43 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:30:00 +0200 Subject: [PATCH 082/132] refactor(VolumeCroppingTool): remove debug logs and unused mouse event handlers for clarity --- .../tools/src/tools/VolumeCroppingTool.ts | 122 +++--------------- 1 file changed, 17 insertions(+), 105 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 96f8f5e8e5..957acc1f51 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -290,7 +290,6 @@ class VolumeCroppingTool extends BaseTool { }; onSetToolActive() { - console.debug('VolumeCroppingTool: onSetToolActive'); const viewportsInfo = this._getViewportsInfo(); const subscribeToElementResize = () => { viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { @@ -348,19 +347,21 @@ class VolumeCroppingTool extends BaseTool { this._initialize3DViewports(viewportsInfo); } - onSetToolPassive() { - const viewportsInfo = this._getViewportsInfo(); - console.debug('VolumeCroppingTool: onSetToolPassive'); - } + onSetToolDisabled() { + // Disconnect all resize observers + this._resizeObservers.forEach((resizeObserver, viewportId) => { + resizeObserver.disconnect(); + this._resizeObservers.delete(viewportId); + }); - onSetToolEnabled() { - console.debug('VolumeCroppingTool: onSetToolEnabled'); - const viewportsInfo = this._getViewportsInfo(); - this._initialize3DViewports(viewportsInfo); - } + if (this._viewportAddedListener) { + eventTarget.removeEventListener( + Events.TOOLGROUP_VIEWPORT_ADDED, + this._viewportAddedListener + ); + this._viewportAddedListener = null; // Clear the reference to the listener + } - onSetToolDisabled() { - console.debug('VolumeCroppingTool: onSetToolDisabled'); const viewportsInfo = this._getViewportsInfo(); this._unsubscribeToViewportNewVolumeSet(viewportsInfo); } @@ -429,7 +430,6 @@ class VolumeCroppingTool extends BaseTool { } _initialize3DViewports = (viewportsInfo): void => { - console.debug('VolumeCroppingTool: starting.'); if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { console.warn( 'VolumeCroppingTool: No viewportsInfo available for initialization of volumecroppingtool.' @@ -744,51 +744,6 @@ class VolumeCroppingTool extends BaseTool { viewport.render(); }; - _onMouseDownSphere = (evt) => { - const element = evt.currentTarget; - console.debug('VolumeCroppingTool: _onMouseDownSphere', evt); - // Prevent default behavior - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const mouseCanvas: [number, number] = [evt.offsetX, evt.offsetY]; - // Find the sphere under the mouse - 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; - } - } - this.draggingSphereIndex = null; - this.cornerDragOffset = null; - this.faceDragOffset = null; - }; - _onMouseMoveSphere = (evt) => { if (this.draggingSphereIndex === null) { return; @@ -1130,26 +1085,6 @@ class VolumeCroppingTool extends BaseTool { }); } - _onMouseUpSphere = (evt) => { - console.debug('VolumeCroppingTool: _onMouseUpSphere', evt); - evt.currentTarget.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._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(); - this._updateClippingPlanesFromFaceSpheres(viewport); - } - } - this.draggingSphereIndex = null; - this.cornerDragOffset = null; - this.faceDragOffset = null; - }; - onCameraModified = (evt) => { const { element } = evt.currentTarget ? { element: evt.currentTarget } @@ -1159,10 +1094,6 @@ class VolumeCroppingTool extends BaseTool { enabledElement.viewport.render(); }; - onResetCamera = (evt) => { - console.debug('on reset camera'); - }; - _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); this._initialize3DViewports(viewportsInfo); @@ -1242,11 +1173,7 @@ class VolumeCroppingTool extends BaseTool { sphereState.point[axisIdx] - mouseWorld[axisIdx]; this.cornerDragOffset = null; } - console.debug('preMouseDownCallback: VolumeCroppingTool ', { - draggingSphereIndex: this.draggingSphereIndex, - cornerDragOffset: this.cornerDragOffset, - faceDragOffset: this.faceDragOffset, - }); + return true; } } @@ -1274,7 +1201,7 @@ class VolumeCroppingTool extends BaseTool { this.cleanUp = () => { mapper.setSampleDistance(originalSampleDistance); - console.debug('VolumeCroppingTool: Clean up after mouse up'); + // Reset cursor style (evt.target as HTMLElement).style.cursor = ''; if (this.draggingSphereIndex !== null) { @@ -1305,23 +1232,6 @@ class VolumeCroppingTool extends BaseTool { return true; }; - _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. - console.debug('Activating VolumeCroppingTool'); - state.isInteractingWithTool = !this.configuration.mobile?.enabled; - }; - - _deactivateModify = (element) => { - state.isInteractingWithTool = false; - console.debug('Deactivating VolumeCroppingTool'); - }; - - _endCallback = (evt: EventTypes.InteractionEventType) => { - this._onMouseUpSphere(evt); - }; - rotateCamera = (viewport, centerWorld, axis, angle) => { const vtkCamera = viewport.getVtkActiveCamera(); const viewUp = vtkCamera.getViewUp(); @@ -1358,8 +1268,10 @@ class VolumeCroppingTool extends BaseTool { 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; From 5e1febcb67f791cdd3cbe9c42edef52cf47deb9a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:36:37 +0200 Subject: [PATCH 083/132] feat(VolumeCroppingTool): add clipping planes visibility toggle and related methods --- .../examples/volumeCroppingTool/index.ts | 51 ++++++++++++++----- .../tools/src/tools/VolumeCroppingTool.ts | 23 ++++++++- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index bf5448f106..eb7bc37a14 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -144,7 +144,7 @@ addToggleButtonToToolbar({ const toolGroupVRT = cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupIdVRT); // Get the VolumeCroppingTool instance from the tool group - const croppingTool = toolGroupVRT.getToolInstance('VolumeCroppingTool'); + const croppingTool = toolGroupVRT.getToolInstance('VolumeCropping'); // Call setHandlesVisible on the tool instance if (croppingTool && typeof croppingTool.setHandlesVisible === 'function') { croppingTool.setHandlesVisible(!croppingTool.getHandlesVisible()); @@ -152,6 +152,27 @@ addToggleButtonToToolbar({ }, }); +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)', @@ -205,7 +226,6 @@ async function run() { const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds, }); - volume.load(); // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); @@ -272,6 +292,7 @@ async function run() { toolGroup.addViewport(viewportId1, renderingEngineId); toolGroup.addViewport(viewportId2, renderingEngineId); toolGroup.addViewport(viewportId3, renderingEngineId); + /* toolGroup.addTool(CrosshairsTool.toolName); toolGroup.setToolActive(CrosshairsTool.toolName, { @@ -310,14 +331,14 @@ async function run() { const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); - toolGroupVRT.addTool(TrackballRotateTool.toolName); - toolGroupVRT.setToolActive(TrackballRotateTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Primary, - }, - ], - }); + // toolGroupVRT.addTool(TrackballRotateTool.toolName); + // toolGroupVRT.setToolActive(TrackballRotateTool.toolName, { + // bindings: [ + // { + // mouseButton: MouseBindings.Secondary, + // }, + // ], + // }); toolGroupVRT.addTool(ZoomTool.toolName); toolGroupVRT.setToolActive(ZoomTool.toolName, { bindings: [ @@ -359,12 +380,18 @@ async function run() { x: [1, 1, 0], // yellow for X axis y: [0, 1, 0], // green for Y axis z: [1, 0, 0], // red for Z axis - corners: [0, 0, 1], // Blue for corners (optional) + corners: [0, 0, 1], // Blue for corners (optional) [0.7, 0.7, 0.7], // }, showCornerSpheres: true, initialCropFactor: 0.2, }); - toolGroupVRT.setToolActive(VolumeCroppingTool.toolName); + toolGroupVRT.setToolActive(VolumeCroppingTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, + }, + ], + }); viewport.setZoom(1.2); viewport.render(); }); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 957acc1f51..3726033a30 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -183,6 +183,7 @@ class VolumeCroppingTool extends BaseTool { configuration: { showCornerSpheres: true, showHandles: true, + showClippingPlanes: true, mobile: { enabled: false, opacity: 0.8, @@ -268,6 +269,24 @@ class VolumeCroppingTool extends BaseTool { return this.configuration.showHandles; } + getClippingPlanesVisible() { + return this.configuration.showClippingPlanes; + } + + setClippingPlanesVisible(visible: boolean) { + this.configuration.showClippingPlanes = visible; + // Show/hide actors + // this._updateHandlesVisibility(); + + // Render + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + this._updateClippingPlanes(viewport); + viewport.render(); + } + _updateHandlesVisibility() { // Spheres this.sphereStates.forEach((state) => { @@ -651,7 +670,9 @@ class VolumeCroppingTool extends BaseTool { origin: o, normal: [n[0], n[1], n[2]], }); - mapper.addClippingPlane(planeInstance); + if (this.configuration.showClippingPlanes) { + mapper.addClippingPlane(planeInstance); + } }); } From 82d2cb1f949befd511ee0725b5389df9f308d257 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:29:21 +0200 Subject: [PATCH 084/132] feat(VolumeCroppingTool): implement edge lines for corner spheres and refactor related methods --- .../src/tools/VolumeCroppingControlTool.ts | 19 +- .../tools/src/tools/VolumeCroppingTool.ts | 183 +++++++----------- 2 files changed, 84 insertions(+), 118 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f568c83f50..44a48f19e5 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -258,6 +258,17 @@ class VolumeCroppingControlTool extends AnnotationTool { }; }; + initializeReferenceLines(enabledElement) { + const annotations = this._getAnnotations(enabledElement); + if (!annotations.length) { + // Optionally, create a default annotation here + return; + } + // Force a render to compute reference lines + triggerAnnotationRenderForViewportIds( + this._getViewportsInfo().map(({ viewportId }) => viewportId) + ); + } _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; return viewports; @@ -360,7 +371,7 @@ class VolumeCroppingControlTool extends AnnotationTool { _computeToolCenter = (viewportsInfo): void => { if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { console.warn( - 'VolumeCroppingControlTool: No valid viewportsInfo for computeToolCenter.' + ' _computeToolCenter : No valid viewportsInfo for computeToolCenter.' ); return; } @@ -391,6 +402,12 @@ class VolumeCroppingControlTool extends AnnotationTool { vec3.scale(point3, point3, 0.5); vec3.cross(normal3, normal1, normal2); } + + if (viewportsInfo && viewportsInfo.length) { + triggerAnnotationRenderForViewportIds( + viewportsInfo.map(({ viewportId }) => viewportId) + ); + } }; setToolCenter(toolCenter: Types.Point3, handleType): void { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 3726033a30..156dab4487 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1,10 +1,13 @@ +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 vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; +import type vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; import { BaseTool } from './base'; @@ -24,14 +27,9 @@ import { getToolGroup } from '../store/ToolGroupManager'; import { state } from '../store/state'; import { Events } from '../enums'; -import { getAnnotations } from '../stateManagement/annotation/annotationState'; // <-- Add this import -import * as lineSegment from '../utilities/math/line'; import type { - Annotation, - Annotations, EventTypes, - ToolHandle, PublicToolProps, ToolProps, InteractionTypes, @@ -46,72 +44,6 @@ function defaultReferenceLineControllable() { return true; } -function addCylinderBetweenPoints( - viewport, - point1, - point2, - radius = 0.5, - color: [number, number, number] = [0.5, 0.5, 0.5], - uid = '' -) { - // Avoid creating a cylinder 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 cylinderSource = vtkCylinderSource.newInstance(); - // Compute direction and length - const direction = new Float32Array([ - point2[0] - point1[0], - point2[1] - point1[1], - point2[2] - point1[2], - ]); - const length = Math.sqrt( - direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 - ); - // Normalize direction vector - const normDirection = new Float32Array([0, 0, 0]); - vec3.normalize(normDirection, direction); - - // Midpoint - const center: Types.Point3 = [ - (point1[0] + point2[0]) / 2, - (point1[1] + point2[1]) / 2, - (point1[2] + point2[2]) / 2, - ]; - // Default cylinder is aligned with Y axis, so compute rotation - cylinderSource.setCenter(center[0], center[1], center[2]); - cylinderSource.setRadius(radius); - cylinderSource.setHeight(length); - // Set direction (align cylinder axis with direction vector) - cylinderSource.setDirection( - normDirection[0], - normDirection[1], - normDirection[2] - ); - - const cylinderMapper = vtkMapper.newInstance(); - cylinderMapper.setInputConnection(cylinderSource.getOutputPort()); - const cylinderActor = vtkActor.newInstance(); - cylinderActor.setMapper(cylinderMapper); - cylinderActor.getProperty().setColor(color); - cylinderActor.getProperty().setInterpolationToFlat(); - cylinderActor.getProperty().setAmbient(1.0); // Full ambient - cylinderActor.getProperty().setDiffuse(0.0); // No diffuse - cylinderActor.getProperty().setSpecular(0.0); - - viewport.addActor({ actor: cylinderActor, uid: uid }); - return { actor: cylinderActor, source: cylinderSource }; -} -const OPERATION = { - DRAG: 1, - ROTATE: 2, - SLAB: 3, -}; - const PLANEINDEX = { XMIN: 0, XMAX: 1, @@ -143,6 +75,57 @@ const SPHEREINDEX = { * VolumeCroppingTool is a tool that provides clipping planes to crop a volume */ class VolumeCroppingTool extends BaseTool { + // Store 2D edge lines between corner spheres + edgeLines: { + [uid: string]: { + actor: vtkActor; + source: vtkPolyData; + key1: string; + key2: string; + }; + } = {}; + + // Helper to add a 3D line between two points using vtkActor + 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(2); // 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(true); + viewport.addActor({ actor, uid }); + return { actor, source: polyData }; + } static toolName; touchDragCallback: (evt: EventTypes.InteractionEventType) => void; mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; @@ -165,14 +148,6 @@ class VolumeCroppingTool extends BaseTool { toolCenter: Types.Point3 = [0, 0, 0]; _getReferenceLineColor?: (viewportId: string) => string; _getReferenceLineControllable?: (viewportId: string) => boolean; - edgeCylinders: { - [uid: string]: { - actor: vtkActor; - source: vtkCylinderSource; - key1: string; - key2: string; - }; - } = {}; cornerDragOffset: [number, number, number] | null = null; faceDragOffset: number | null = null; @@ -295,8 +270,8 @@ class VolumeCroppingTool extends BaseTool { } }); - // Edge cylinders - Object.values(this.edgeCylinders).forEach(({ actor }) => { + // Edge lines (box edges) + Object.values(this.edgeLines).forEach(({ actor }) => { if (actor) { actor.setVisibility(this.configuration.showHandles); } @@ -599,15 +574,14 @@ class VolumeCroppingTool extends BaseTool { ); if (state1 && state2) { const uid = `edge_${key1}_${key2}`; - const { actor, source } = addCylinderBetweenPoints( + const { actor, source } = this.addLine3DBetweenPoints( viewport, state1.point, state2.point, - 2, // radius - [0.7, 0.7, 0.7], // color + [0.7, 0.7, 0.7], uid ); - this.edgeCylinders[uid] = { actor, source, key1, key2 }; + this.edgeLines[uid] = { actor, source, key1, key2 }; } }); } @@ -770,9 +744,6 @@ class VolumeCroppingTool extends BaseTool { return; } - //evt.stopPropagation(); - //evt.preventDefault(); - const element = evt.detail.element || evt.currentTarget; const [viewport3D] = this._getViewportsInfo(); const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); @@ -780,8 +751,6 @@ class VolumeCroppingTool extends BaseTool { // Get 2D mouse position in canvas coordinates const rect = element.getBoundingClientRect(); - // const x = evt.clientX - rect.left; - // const y = evt.clientY - rect.top; const x = evt.detail.currentPoints.canvas[0]; const y = evt.detail.currentPoints.canvas[1]; @@ -1072,35 +1041,15 @@ class VolumeCroppingTool extends BaseTool { } } - // Update edge cylinders as before - Object.values(this.edgeCylinders).forEach(({ source, key1, key2 }) => { + // 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 point1 = state1.point; - const point2 = state2.point; - const direction = new Float32Array([ - point2[0] - point1[0], - point2[1] - point1[1], - point2[2] - point1[2], - ]); - const length = Math.sqrt( - direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2 - ); - const normDirection = new Float32Array([0, 0, 0]); - vec3.normalize(normDirection, direction); - const center = new Float32Array([ - (point1[0] + point2[0]) / 2, - (point1[1] + point2[1]) / 2, - (point1[2] + point2[2]) / 2, - ]); - source.setCenter(center[0], center[1], center[2]); - source.setHeight(length); - source.setDirection( - normDirection[0], - normDirection[1], - normDirection[2] - ); + 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(); } }); From ed4af8f08d807983ee98959f217e6c0ce3ee53a2 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 17 Jul 2025 07:48:26 +0200 Subject: [PATCH 085/132] refactor(VolumeCroppingTool): adjust line width and initial crop factor for improved visibility and usability --- .../src/tools/VolumeCroppingControlTool.ts | 292 +++++++++--------- .../tools/src/tools/VolumeCroppingTool.ts | 7 +- 2 files changed, 150 insertions(+), 149 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 44a48f19e5..0e27bb49f8 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -531,152 +531,152 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolSelectedCallback(evt, annotation, interactionType); } - onCameraModified = (evt) => { - const eventDetail = evt.detail; - const { element } = eventDetail; - const enabledElement = getEnabledElement(element); - const { renderingEngine } = enabledElement; - const viewport = enabledElement.viewport as Types.IVolumeViewport; - - const annotations = this._getAnnotations(enabledElement); - const filteredToolAnnotations = - this.filterInteractableAnnotationsForElement(element, annotations); - - // viewport that the camera modified is originating from - const viewportAnnotation = - filteredToolAnnotations[0] as VolumeCroppingAnnotation; - - if (!viewportAnnotation) { - return; - } - - const currentCamera = viewport.getCamera(); - const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; - const deltaCameraPosition: Types.Point3 = [0, 0, 0]; - vtkMath.subtract( - currentCamera.position, - oldCameraPosition, - deltaCameraPosition - ); - - const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; - const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; - vtkMath.subtract( - currentCamera.focalPoint, - oldCameraFocalPoint, - deltaCameraFocalPoint - ); - - // updated cached "previous" camera position and focal point - viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; - viewportAnnotation.metadata.cameraFocalPoint = [ - ...currentCamera.focalPoint, - ]; - - const viewportControllable = this._getReferenceLineControllable( - viewport.id - ); - - if ( - !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && - viewportControllable - ) { - // Is camera Modified a TRANSLATION or ROTATION? - let isRotation = false; - - // This is guaranteed to be the same diff for both position and focal point - // if the camera is modified by pan, zoom, or scroll BUT for rotation of - // volume cropping handles it will be different. - const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( - deltaCameraPosition, - deltaCameraFocalPoint, - 1e-3 - ); - - if (!cameraModifiedSameForPosAndFocalPoint) { - isRotation = true; - } - - // Only update cropping reference lines if the camera movement is NOT a stack scroll. - // Stack scroll: camera moves along viewPlaneNormal (dot product large). - // Pan/zoom: camera moves perpendicular to viewPlaneNormal (dot product small). - const dot = Math.abs( - vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) - ); - const isStackScroll = dot > 1e-2; - - // TRANSLATION - // Only update cropping reference lines for pan/zoom, not stack scroll - if (!isRotation && !isStackScroll) { - this.toolCenter[0] += deltaCameraPosition[0]; - this.toolCenter[1] += deltaCameraPosition[1]; - this.toolCenter[2] += deltaCameraPosition[2]; - // Update toolCenterMin as well (keep min cropping plane in sync) - const allAnnotations = this._getAnnotations(enabledElement); - const selectedAnnotations = allAnnotations.filter( - (a) => a.data.handles.activeOperation === 1 // OPERATION.DRAG - ); - - if (this.editData && this.editData.annotation) { - const activeType = - this.editData?.annotation?.data?.handles?.activeType; - if (activeType === 'min') { - this.toolCenterMin[0] += deltaCameraPosition[0]; - this.toolCenterMin[1] += deltaCameraPosition[1]; - this.toolCenterMin[2] += deltaCameraPosition[2]; - } else if (activeType === 'max') { - this.toolCenterMax[0] += deltaCameraPosition[0]; - this.toolCenterMax[1] += deltaCameraPosition[1]; - this.toolCenterMax[2] += deltaCameraPosition[2]; - } - } else if (selectedAnnotations.length > 1) { - // No annotation selected: update both min and max - this.toolCenterMin[0] += deltaCameraPosition[0]; - this.toolCenterMin[1] += deltaCameraPosition[1]; - this.toolCenterMin[2] += deltaCameraPosition[2]; - this.toolCenterMax[0] += deltaCameraPosition[0]; - this.toolCenterMax[1] += deltaCameraPosition[1]; - this.toolCenterMax[2] += deltaCameraPosition[2]; - } - } - - const viewportAnnotation = filteredToolAnnotations[0]; - if (!viewportAnnotation) { - return; - } - triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { - toolGroupId: this.toolGroupId, - toolCenter: this.toolCenter, - toolCenterMin: this.toolCenterMin, - toolCenterMax: this.toolCenterMax, - handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here - viewportOrientation: [ - viewportAnnotation.data.referenceLines[0][0].options.orientation, - viewportAnnotation.data.referenceLines[1][0].options.orientation, - ], - }); - } - - // AutoPan modification - if (this.configuration.autoPan?.enabled) { - const toolGroup = getToolGroupForViewport( - viewport.id, - renderingEngine.id - ); - - const otherViewportIds = toolGroup - .getViewportIds() - .filter((id) => id !== viewport.id); - } - - const requireSameOrientation = false; - const viewportIdsToRender = getViewportIdsWithToolToRender( - element, - this.getToolName(), - requireSameOrientation - ); - triggerAnnotationRenderForViewportIds(viewportIdsToRender); - }; + // onCameraModified = (evt) => { + // const eventDetail = evt.detail; + // const { element } = eventDetail; + // const enabledElement = getEnabledElement(element); + // const { renderingEngine } = enabledElement; + // const viewport = enabledElement.viewport as Types.IVolumeViewport; + + // const annotations = this._getAnnotations(enabledElement); + // const filteredToolAnnotations = + // this.filterInteractableAnnotationsForElement(element, annotations); + + // // viewport that the camera modified is originating from + // const viewportAnnotation = + // filteredToolAnnotations[0] as VolumeCroppingAnnotation; + + // if (!viewportAnnotation) { + // return; + // } + + // const currentCamera = viewport.getCamera(); + // const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; + // const deltaCameraPosition: Types.Point3 = [0, 0, 0]; + // vtkMath.subtract( + // currentCamera.position, + // oldCameraPosition, + // deltaCameraPosition + // ); + + // const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; + // const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; + // vtkMath.subtract( + // currentCamera.focalPoint, + // oldCameraFocalPoint, + // deltaCameraFocalPoint + // ); + + // // updated cached "previous" camera position and focal point + // viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; + // viewportAnnotation.metadata.cameraFocalPoint = [ + // ...currentCamera.focalPoint, + // ]; + + // const viewportControllable = this._getReferenceLineControllable( + // viewport.id + // ); + + // if ( + // !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && + // viewportControllable + // ) { + // // Is camera Modified a TRANSLATION or ROTATION? + // let isRotation = false; + + // // This is guaranteed to be the same diff for both position and focal point + // // if the camera is modified by pan, zoom, or scroll BUT for rotation of + // // volume cropping handles it will be different. + // const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( + // deltaCameraPosition, + // deltaCameraFocalPoint, + // 1e-3 + // ); + + // if (!cameraModifiedSameForPosAndFocalPoint) { + // isRotation = true; + // } + + // // Only update cropping reference lines if the camera movement is NOT a stack scroll. + // // Stack scroll: camera moves along viewPlaneNormal (dot product large). + // // Pan/zoom: camera moves perpendicular to viewPlaneNormal (dot product small). + // const dot = Math.abs( + // vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) + // ); + // const isStackScroll = dot > 1e-2; + + // // TRANSLATION + // // Only update cropping reference lines for pan/zoom, not stack scroll + // if (!isRotation && !isStackScroll) { + // this.toolCenter[0] += deltaCameraPosition[0]; + // this.toolCenter[1] += deltaCameraPosition[1]; + // this.toolCenter[2] += deltaCameraPosition[2]; + // // Update toolCenterMin as well (keep min cropping plane in sync) + // const allAnnotations = this._getAnnotations(enabledElement); + // const selectedAnnotations = allAnnotations.filter( + // (a) => a.data.handles.activeOperation === 1 // OPERATION.DRAG + // ); + + // if (this.editData && this.editData.annotation) { + // const activeType = + // this.editData?.annotation?.data?.handles?.activeType; + // if (activeType === 'min') { + // this.toolCenterMin[0] += deltaCameraPosition[0]; + // this.toolCenterMin[1] += deltaCameraPosition[1]; + // this.toolCenterMin[2] += deltaCameraPosition[2]; + // } else if (activeType === 'max') { + // this.toolCenterMax[0] += deltaCameraPosition[0]; + // this.toolCenterMax[1] += deltaCameraPosition[1]; + // this.toolCenterMax[2] += deltaCameraPosition[2]; + // } + // } else if (selectedAnnotations.length > 1) { + // // No annotation selected: update both min and max + // this.toolCenterMin[0] += deltaCameraPosition[0]; + // this.toolCenterMin[1] += deltaCameraPosition[1]; + // this.toolCenterMin[2] += deltaCameraPosition[2]; + // this.toolCenterMax[0] += deltaCameraPosition[0]; + // this.toolCenterMax[1] += deltaCameraPosition[1]; + // this.toolCenterMax[2] += deltaCameraPosition[2]; + // } + // } + + // const viewportAnnotation = filteredToolAnnotations[0]; + // if (!viewportAnnotation) { + // return; + // } + // triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + // toolGroupId: this.toolGroupId, + // toolCenter: this.toolCenter, + // toolCenterMin: this.toolCenterMin, + // toolCenterMax: this.toolCenterMax, + // handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here + // viewportOrientation: [ + // viewportAnnotation.data.referenceLines[0][0].options.orientation, + // viewportAnnotation.data.referenceLines[1][0].options.orientation, + // ], + // }); + // } + + // // AutoPan modification + // if (this.configuration.autoPan?.enabled) { + // const toolGroup = getToolGroupForViewport( + // viewport.id, + // renderingEngine.id + // ); + + // const otherViewportIds = toolGroup + // .getViewportIds() + // .filter((id) => id !== viewport.id); + // } + + // const requireSameOrientation = false; + // const viewportIdsToRender = getViewportIdsWithToolToRender( + // element, + // this.getToolName(), + // requireSameOrientation + // ); + // triggerAnnotationRenderForViewportIds(viewportIdsToRender); + // }; onResetCamera = (evt) => { this.resetCrosshairs(); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 156dab4487..a772260885 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -116,7 +116,7 @@ class VolumeCroppingTool extends BaseTool { const actor = vtkActor.newInstance(); actor.setMapper(mapper); actor.getProperty().setColor(...color); - actor.getProperty().setLineWidth(2); // Thinner line + actor.getProperty().setLineWidth(0.5); // Thinner line actor.getProperty().setOpacity(1.0); actor.getProperty().setInterpolationToFlat(); // No shading actor.getProperty().setAmbient(1.0); // Full ambient @@ -163,7 +163,7 @@ class VolumeCroppingTool extends BaseTool { enabled: false, opacity: 0.8, }, - initialCropFactor: 0.2, + initialCropFactor: 0.08, sphereColors: { x: [1.0, 1.0, 0.0], // Yellow for X y: [0.0, 1.0, 0.0], // Green for Y @@ -285,6 +285,7 @@ class VolumeCroppingTool extends BaseTool { onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); + console.debug('VolumeCroppingTool: onSetToolActive', viewportsInfo); const subscribeToElementResize = () => { viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { if (!this._resizeObservers.has(viewportId)) { @@ -448,7 +449,7 @@ class VolumeCroppingTool extends BaseTool { const dimensions = imageData.getDimensions(); const origin = imageData.getOrigin(); const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const cropFactor = this.configuration.initialCropFactor || 0.2; + const cropFactor = this.configuration.initialCropFactor || 0.1; const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; const xMax = origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; From 95ade4843d58f48a3fc8d5ec93d1b92c38a70815 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 17 Jul 2025 09:01:30 +0200 Subject: [PATCH 086/132] feat(VolumeCroppingControlTool): add viewport-based tool center updates and initialize annotations for improved cropping functionality --- .../src/tools/VolumeCroppingControlTool.ts | 228 +++++------------- 1 file changed, 56 insertions(+), 172 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 0e27bb49f8..c2b8b8f004 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -187,6 +187,40 @@ class VolumeCroppingControlTool extends AnnotationTool { } } + _updateToolCentersFromViewport(viewport) { + const volumeActors = viewport.getActors(); + if (!volumeActors || !volumeActors.length) { + return; + } + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (!imageData) { + return; + } + 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 @@ -214,7 +248,9 @@ class VolumeCroppingControlTool extends AnnotationTool { if (!enabledElement) { return; } + const { FrameOfReferenceUID, viewport } = enabledElement; + this._updateToolCentersFromViewport(viewport); const { element } = viewport; const { position, focalPoint, viewPlaneNormal } = viewport.getCamera(); @@ -274,8 +310,19 @@ class VolumeCroppingControlTool extends AnnotationTool { return viewports; }; + _initializeAnnotationsForAllViewports() { + const viewportsInfo = this._getViewportsInfo(); + if (!viewportsInfo || viewportsInfo.length < 3) { + return; + } + viewportsInfo.forEach((viewportInfo) => { + this.initializeViewport(viewportInfo); + }); + } + onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); + this._initializeAnnotationsForAllViewports(); // reference points in the new space, so we subscribe to the event // and update the reference points accordingly. @@ -287,13 +334,13 @@ class VolumeCroppingControlTool extends AnnotationTool { onSetToolPassive() { const viewportsInfo = this._getViewportsInfo(); - + this._initializeAnnotationsForAllViewports(); this._computeToolCenter(viewportsInfo); } onSetToolEnabled() { const viewportsInfo = this._getViewportsInfo(); - + this._initializeAnnotationsForAllViewports(); this._computeToolCenter(viewportsInfo); } @@ -369,7 +416,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _computeToolCenter = (viewportsInfo): void => { - if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { + if (!viewportsInfo || viewportsInfo.length < 3 || !viewportsInfo[0]) { console.warn( ' _computeToolCenter : No valid viewportsInfo for computeToolCenter.' ); @@ -531,153 +578,6 @@ class VolumeCroppingControlTool extends AnnotationTool { this.toolSelectedCallback(evt, annotation, interactionType); } - // onCameraModified = (evt) => { - // const eventDetail = evt.detail; - // const { element } = eventDetail; - // const enabledElement = getEnabledElement(element); - // const { renderingEngine } = enabledElement; - // const viewport = enabledElement.viewport as Types.IVolumeViewport; - - // const annotations = this._getAnnotations(enabledElement); - // const filteredToolAnnotations = - // this.filterInteractableAnnotationsForElement(element, annotations); - - // // viewport that the camera modified is originating from - // const viewportAnnotation = - // filteredToolAnnotations[0] as VolumeCroppingAnnotation; - - // if (!viewportAnnotation) { - // return; - // } - - // const currentCamera = viewport.getCamera(); - // const oldCameraPosition = viewportAnnotation.metadata.cameraPosition; - // const deltaCameraPosition: Types.Point3 = [0, 0, 0]; - // vtkMath.subtract( - // currentCamera.position, - // oldCameraPosition, - // deltaCameraPosition - // ); - - // const oldCameraFocalPoint = viewportAnnotation.metadata.cameraFocalPoint; - // const deltaCameraFocalPoint: Types.Point3 = [0, 0, 0]; - // vtkMath.subtract( - // currentCamera.focalPoint, - // oldCameraFocalPoint, - // deltaCameraFocalPoint - // ); - - // // updated cached "previous" camera position and focal point - // viewportAnnotation.metadata.cameraPosition = [...currentCamera.position]; - // viewportAnnotation.metadata.cameraFocalPoint = [ - // ...currentCamera.focalPoint, - // ]; - - // const viewportControllable = this._getReferenceLineControllable( - // viewport.id - // ); - - // if ( - // !csUtils.isEqual(currentCamera.position, oldCameraPosition, 1e-3) && - // viewportControllable - // ) { - // // Is camera Modified a TRANSLATION or ROTATION? - // let isRotation = false; - - // // This is guaranteed to be the same diff for both position and focal point - // // if the camera is modified by pan, zoom, or scroll BUT for rotation of - // // volume cropping handles it will be different. - // const cameraModifiedSameForPosAndFocalPoint = csUtils.isEqual( - // deltaCameraPosition, - // deltaCameraFocalPoint, - // 1e-3 - // ); - - // if (!cameraModifiedSameForPosAndFocalPoint) { - // isRotation = true; - // } - - // // Only update cropping reference lines if the camera movement is NOT a stack scroll. - // // Stack scroll: camera moves along viewPlaneNormal (dot product large). - // // Pan/zoom: camera moves perpendicular to viewPlaneNormal (dot product small). - // const dot = Math.abs( - // vtkMath.dot(deltaCameraPosition, currentCamera.viewPlaneNormal) - // ); - // const isStackScroll = dot > 1e-2; - - // // TRANSLATION - // // Only update cropping reference lines for pan/zoom, not stack scroll - // if (!isRotation && !isStackScroll) { - // this.toolCenter[0] += deltaCameraPosition[0]; - // this.toolCenter[1] += deltaCameraPosition[1]; - // this.toolCenter[2] += deltaCameraPosition[2]; - // // Update toolCenterMin as well (keep min cropping plane in sync) - // const allAnnotations = this._getAnnotations(enabledElement); - // const selectedAnnotations = allAnnotations.filter( - // (a) => a.data.handles.activeOperation === 1 // OPERATION.DRAG - // ); - - // if (this.editData && this.editData.annotation) { - // const activeType = - // this.editData?.annotation?.data?.handles?.activeType; - // if (activeType === 'min') { - // this.toolCenterMin[0] += deltaCameraPosition[0]; - // this.toolCenterMin[1] += deltaCameraPosition[1]; - // this.toolCenterMin[2] += deltaCameraPosition[2]; - // } else if (activeType === 'max') { - // this.toolCenterMax[0] += deltaCameraPosition[0]; - // this.toolCenterMax[1] += deltaCameraPosition[1]; - // this.toolCenterMax[2] += deltaCameraPosition[2]; - // } - // } else if (selectedAnnotations.length > 1) { - // // No annotation selected: update both min and max - // this.toolCenterMin[0] += deltaCameraPosition[0]; - // this.toolCenterMin[1] += deltaCameraPosition[1]; - // this.toolCenterMin[2] += deltaCameraPosition[2]; - // this.toolCenterMax[0] += deltaCameraPosition[0]; - // this.toolCenterMax[1] += deltaCameraPosition[1]; - // this.toolCenterMax[2] += deltaCameraPosition[2]; - // } - // } - - // const viewportAnnotation = filteredToolAnnotations[0]; - // if (!viewportAnnotation) { - // return; - // } - // triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { - // toolGroupId: this.toolGroupId, - // toolCenter: this.toolCenter, - // toolCenterMin: this.toolCenterMin, - // toolCenterMax: this.toolCenterMax, - // handleType: this.editData?.annotation?.data?.handles?.activeType, // Pass activeType here - // viewportOrientation: [ - // viewportAnnotation.data.referenceLines[0][0].options.orientation, - // viewportAnnotation.data.referenceLines[1][0].options.orientation, - // ], - // }); - // } - - // // AutoPan modification - // if (this.configuration.autoPan?.enabled) { - // const toolGroup = getToolGroupForViewport( - // viewport.id, - // renderingEngine.id - // ); - - // const otherViewportIds = toolGroup - // .getViewportIds() - // .filter((id) => id !== viewport.id); - // } - - // const requireSameOrientation = false; - // const viewportIdsToRender = getViewportIdsWithToolToRender( - // element, - // this.getToolName(), - // requireSameOrientation - // ); - // triggerAnnotationRenderForViewportIds(viewportIdsToRender); - // }; - onResetCamera = (evt) => { this.resetCrosshairs(); }; @@ -767,7 +667,11 @@ class VolumeCroppingControlTool extends AnnotationTool { } return null; } - + const viewportsInfo = this._getViewportsInfo(); + if (!viewportsInfo || viewportsInfo.length < 3) { + // Not enough viewports for reference lines + return false; + } let renderStatus = false; const { viewport, renderingEngine } = enabledElement; const { element } = viewport; @@ -1114,27 +1018,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (volumeActors.length > 0) { const imageData = volumeActors[0].actor.getMapper().getInputData(); if (imageData) { - const dimensions = imageData.getDimensions(); - const spacing = imageData.getSpacing(); - const origin = imageData.getOrigin(); - 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], - ]; + this._updateToolCentersFromViewport(viewport); // Update all annotations' handles.toolCenter const annotations = getAnnotations(this.getToolName(), viewportId) || []; From 21d7b49cab66b0afc899699cf22b94af327f9a50 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 17 Jul 2025 09:13:15 +0200 Subject: [PATCH 087/132] refactor(VolumeCroppingTool): update mouse bindings and adjust sphere radius for improved interaction --- .../tools/examples/volumeCroppingTool/index.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index eb7bc37a14..d28ae68660 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -323,22 +323,13 @@ async function run() { { mouseButton: MouseBindings.Wheel, }, - // { - // mouseButton: MouseBindings.Secondary, - // }, + { + mouseButton: MouseBindings.Secondary, + }, ], }); const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); - - // toolGroupVRT.addTool(TrackballRotateTool.toolName); - // toolGroupVRT.setToolActive(TrackballRotateTool.toolName, { - // bindings: [ - // { - // mouseButton: MouseBindings.Secondary, - // }, - // ], - // }); toolGroupVRT.addTool(ZoomTool.toolName); toolGroupVRT.setToolActive(ZoomTool.toolName, { bindings: [ @@ -375,7 +366,7 @@ async function run() { }); toolGroupVRT.addViewport(viewportId4, renderingEngineId); toolGroupVRT.addTool(VolumeCroppingTool.toolName, { - sphereRadius: 10, + sphereRadius: 7, sphereColors: { x: [1, 1, 0], // yellow for X axis y: [0, 1, 0], // green for Y axis From bb896c60f145ccf6dc022d2473683d8b98ab1159 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 17 Jul 2025 11:57:50 +0200 Subject: [PATCH 088/132] fix(VolumeCroppingTool): add check for default actor in viewport before updating clipping planes --- packages/tools/src/tools/VolumeCroppingTool.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index a772260885..f3e564c4a3 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -606,6 +606,12 @@ class VolumeCroppingTool extends BaseTool { _updateClippingPlanes(viewport) { // Get the actor and transformation matrix const actorEntry = viewport.getDefaultActor(); + if (!actorEntry || !actorEntry.actor) { + console.warn( + 'VolumeCroppingTool._updateClippingPlanes: No default actor found in viewport.' + ); + return; + } const actor = actorEntry.actor; const mapper = actor.getMapper(); const matrix = actor.getMatrix(); From ac27de7c205279a26db72eaa700c5bbb2fed01b1 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:54:29 +0200 Subject: [PATCH 089/132] refactor(VolumeCroppingControlTool): remove redundant viewport initialization calls for improved performance --- .../src/tools/VolumeCroppingControlTool.ts | 21 ++++--------------- .../tools/src/tools/VolumeCroppingTool.ts | 1 - 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index c2b8b8f004..32485a31f0 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -310,19 +310,8 @@ class VolumeCroppingControlTool extends AnnotationTool { return viewports; }; - _initializeAnnotationsForAllViewports() { - const viewportsInfo = this._getViewportsInfo(); - if (!viewportsInfo || viewportsInfo.length < 3) { - return; - } - viewportsInfo.forEach((viewportInfo) => { - this.initializeViewport(viewportInfo); - }); - } - onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); - this._initializeAnnotationsForAllViewports(); // reference points in the new space, so we subscribe to the event // and update the reference points accordingly. @@ -333,14 +322,12 @@ class VolumeCroppingControlTool extends AnnotationTool { } onSetToolPassive() { - const viewportsInfo = this._getViewportsInfo(); - this._initializeAnnotationsForAllViewports(); this._computeToolCenter(viewportsInfo); } onSetToolEnabled() { const viewportsInfo = this._getViewportsInfo(); - this._initializeAnnotationsForAllViewports(); + this._computeToolCenter(viewportsInfo); } @@ -416,7 +403,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _computeToolCenter = (viewportsInfo): void => { - if (!viewportsInfo || viewportsInfo.length < 3 || !viewportsInfo[0]) { + if (!viewportsInfo || !viewportsInfo[0]) { console.warn( ' _computeToolCenter : No valid viewportsInfo for computeToolCenter.' ); @@ -668,8 +655,8 @@ class VolumeCroppingControlTool extends AnnotationTool { return null; } const viewportsInfo = this._getViewportsInfo(); - if (!viewportsInfo || viewportsInfo.length < 3) { - // Not enough viewports for reference lines + if (!viewportsInfo || viewportsInfo.length === 0) { + // No viewports available return false; } let renderStatus = false; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index f3e564c4a3..d10d6563b8 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -7,7 +7,6 @@ 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 vtkCylinderSource from '@kitware/vtk.js/Filters/Sources/CylinderSource'; import type vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; import { BaseTool } from './base'; From 7baba832db4c3e981eec23294d24060ad02f7b71 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 18 Jul 2025 16:04:58 +0200 Subject: [PATCH 090/132] feat(VolumeCroppingControlTool): synthesize virtual annotations for missing orientations and improve viewport handling --- .../src/tools/VolumeCroppingControlTool.ts | 462 +++++++----------- 1 file changed, 176 insertions(+), 286 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 32485a31f0..ac7cea4374 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -93,6 +93,8 @@ const OPERATION = { * */ class VolumeCroppingControlTool extends AnnotationTool { + // Store virtual annotations (e.g., for missing orientations like CT_CORONAL) + _virtualAnnotations: Annotation[] = []; static toolName; sphereStates: { point: Types.Point3; @@ -322,6 +324,7 @@ class VolumeCroppingControlTool extends AnnotationTool { } onSetToolPassive() { + const viewportsInfo = this._getViewportsInfo(); this._computeToolCenter(viewportsInfo); } @@ -409,32 +412,64 @@ class VolumeCroppingControlTool extends AnnotationTool { ); return; } - // Todo: handle two same view viewport, or more than 3 viewports - const [firstViewport, secondViewport, thirdViewport] = viewportsInfo; - - // Initialize first viewport - const { normal: normal1, point: point1 } = - this.initializeViewport(firstViewport); - - // Initialize second viewport - const { normal: normal2, point: point2 } = - this.initializeViewport(secondViewport); - - let normal3 = [0, 0, 0]; - let point3 = vec3.create(); - - // If there are three viewports - if (thirdViewport) { - ({ normal: normal3, point: point3 } = - this.initializeViewport(thirdViewport)); - } else { - // If there are only two views (viewport) associated with the volumecropping - // In this situation, we don't have a third information to find the - // exact intersection, and we "assume" the third view is looking at - // a location in between the first and second view centers - vec3.add(point3, point1, point2); - vec3.scale(point3, point3, 0.5); - vec3.cross(normal3, normal1, normal2); + // Support any missing orientation (CT_AXIAL, CT_CORONAL, CT_SAGITTAL) + const orientationIds = ['CT_AXIAL', 'CT_CORONAL', 'CT_SAGITTAL']; + const presentViewports = viewportsInfo.map((vp) => vp.viewportId); + + const missingOrientation = orientationIds.find( + (id) => !presentViewports.includes(id) + ); + + // Initialize present viewports + + const presentNormals: Types.Point3[] = []; + const presentCenters: Types.Point3[] = []; + const presentViewportInfos = viewportsInfo.filter((vp) => + orientationIds.includes(vp.viewportId) + ); + 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 === 3) { + // ...existing code... + } else if (presentViewportInfos.length === 2 && missingOrientation) { + // Synthesize virtual annotation for the missing orientation + // Use cross product of the two present normals + const virtualNormal: Types.Point3 = [0, 0, 0]; + vec3.cross(virtualNormal, presentNormals[0], presentNormals[1]); + vec3.normalize(virtualNormal, virtualNormal); + // Use average of the two present centers + 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 virtualAnnotation = { + highlighted: false, + metadata: { + cameraPosition: [...virtualCenter], + cameraFocalPoint: [...virtualCenter], + toolName: this.getToolName(), + }, + data: { + handles: { + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeOperation: null, + activeViewportIds: [], + viewportId: missingOrientation, + referenceLines: [], + }, + // Mark as virtual so renderAnnotation can handle it + isVirtual: true, + }; + this._virtualAnnotations = [virtualAnnotation]; } if (viewportsInfo && viewportsInfo.length) { @@ -662,15 +697,25 @@ class VolumeCroppingControlTool extends AnnotationTool { let renderStatus = false; const { viewport, renderingEngine } = enabledElement; const { element } = viewport; - const annotations = this._getAnnotations(enabledElement); + let annotations = this._getAnnotations(enabledElement); + // If we have virtual annotations (like CT_CORONAL), always include them + if (this._virtualAnnotations && this._virtualAnnotations.length) { + // Only add if not already present + const hasCoronal = annotations.some( + (a) => a.data.viewportId === 'CT_CORONAL' + ); + if (!hasCoronal) { + annotations = annotations.concat(this._virtualAnnotations); + } + } const camera = viewport.getCamera(); const filteredToolAnnotations = this.filterInteractableAnnotationsForElement(element, annotations); - // viewport Annotation + // viewport Annotation: use the first annotation for the current viewport const viewportAnnotation = filteredToolAnnotations[0]; - if (!annotations?.length || !viewportAnnotation?.data) { - // No annotations yet, and didn't just create it as we likely don't have a FrameOfReference/any data loaded yet. + if (!viewportAnnotation || !viewportAnnotation.data) { + // No annotation for this viewport return renderStatus; } //console.debug(viewportAnnotation); @@ -688,11 +733,8 @@ class VolumeCroppingControlTool extends AnnotationTool { ); const data = viewportAnnotation.data; - const otherViewportAnnotations = - this._filterAnnotationsByUniqueViewportOrientations( - enabledElement, - annotations - ); + // Get all other annotations except the current viewport's + const otherViewportAnnotations = annotations; const volumeCroppingCenterCanvasMin = viewport.worldToCanvas( this.toolCenterMin @@ -707,56 +749,105 @@ class VolumeCroppingControlTool extends AnnotationTool { const canvasBox = [0, 0, clientWidth, clientHeight]; otherViewportAnnotations.forEach((annotation) => { - const { data } = 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; - - const otherViewport = renderingEngine.getViewport( - data.viewportId - ) as Types.IVolumeViewport; - - const otherCamera = otherViewport.getCamera(); + let otherViewport, + otherCamera, + clientWidth, + clientHeight, + otherCanvasDiagonalLength, + otherCanvasCenter, + otherViewportCenterWorld; + if (isVirtual && data.viewportId === 'CT_CORONAL') { + // Synthesize a virtual viewport/camera for CT_CORONAL + // Use the cross product of the two real viewports' normals + const realViewports = viewportsInfo.filter( + (vp) => vp.viewportId !== 'CT_CORONAL' + ); + 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 coronalNormal = vec3.create(); + vec3.cross(coronalNormal, normal1, normal2); + vec3.normalize(coronalNormal, coronalNormal); + otherCamera = { + viewPlaneNormal: coronalNormal, + position: data.handles.toolCenter, + focalPoint: data.handles.toolCenter, + viewUp: [0, 1, 0], + }; + // Synthesize canvas size and center + 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: 'CT_CORONAL', + canvas: viewport.canvas, + canvasToWorld: () => data.handles.toolCenter, + }; + } else { + // Fallback: skip rendering this virtual annotation + return; + } + } 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 ); - // get coordinates for the reference line - const { clientWidth, clientHeight } = otherViewport.canvas; - const otherCanvasDiagonalLength = Math.sqrt( - clientWidth * clientWidth + clientHeight * clientHeight - ); - const otherCanvasCenter: Types.Point2 = [ - clientWidth * 0.5, - clientHeight * 0.5, - ]; - const otherViewportCenterWorld = - otherViewport.canvasToWorld(otherCanvasCenter); - - const direction: Types.Point3 = [0, 0, 0]; + const direction = [0, 0, 0]; vtkMath.cross( - camera.viewPlaneNormal, - otherCamera.viewPlaneNormal, - direction + camera.viewPlaneNormal as [number, number, number], + otherCamera.viewPlaneNormal as [number, number, number], + direction as [number, number, number] ); - vtkMath.normalize(direction); + vtkMath.normalize(direction as [number, number, number]); vtkMath.multiplyScalar( - direction, + direction as [number, number, number], otherCanvasDiagonalLength ); - const pointWorld0: Types.Point3 = [0, 0, 0]; - vtkMath.add(otherViewportCenterWorld, direction, pointWorld0); - - const pointWorld1: Types.Point3 = [0, 0, 0]; - vtkMath.subtract(otherViewportCenterWorld, direction, pointWorld1); - - const pointCanvas0 = viewport.worldToCanvas(pointWorld0); - - const otherViewportCenterCanvas = viewport.worldToCanvas( - otherViewportCenterWorld + 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, @@ -766,12 +857,12 @@ class VolumeCroppingControlTool extends AnnotationTool { vec2.normalize(canvasUnitVectorFromCenter, canvasUnitVectorFromCenter); const canvasVectorFromCenterLong = vec2.create(); - vec2.scale( canvasVectorFromCenterLong, canvasUnitVectorFromCenter, canvasDiagonalLength * 100 ); + // For min const refLinesCenterMin = otherViewportControllable ? vec2.clone(volumeCroppingCenterCanvasMin) @@ -851,7 +942,19 @@ class VolumeCroppingControlTool extends AnnotationTool { // get color for the reference line const otherViewport = line[0]; - const viewportColor = this._getReferenceLineColor(otherViewport.id); + let color; + // If the reference line is for the virtual CT_CORONAL annotation, use yellow + if ( + annotationUID && + data.viewportId !== 'CT_CORONAL' && + otherViewport.id === 'CT_CORONAL' + ) { + color = 'yellow'; + } else { + const viewportColor = this._getReferenceLineColor(otherViewport.id); + color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + } const viewportControllable = this._getReferenceLineControllable( otherViewport.id ); @@ -859,16 +962,11 @@ class VolumeCroppingControlTool extends AnnotationTool { (id) => id === otherViewport.id ); - let color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - let lineWidth = 2.5; - const lineActive = data.handles.activeOperation !== null && data.handles.activeOperation === OPERATION.DRAG && selectedViewportId; - if (lineActive) { lineWidth = 4.5; } @@ -904,14 +1002,6 @@ class VolumeCroppingControlTool extends AnnotationTool { ); } } - - if (viewportControllable) { - color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - if (lineActive) { - const handleUID = `${lineIndex}`; - } - } }); renderStatus = true; @@ -1130,206 +1220,6 @@ class VolumeCroppingControlTool extends AnnotationTool { return otherViewportsAnnotationsWithSameCameraDirection; }; - _filterAnnotationsByUniqueViewportOrientations = ( - enabledElement, - annotations - ) => { - const { renderingEngine, viewport } = enabledElement; - const camera = viewport.getCamera(); - const viewPlaneNormal = camera.viewPlaneNormal; - vtkMath.normalize(viewPlaneNormal); - - const otherLinkedViewportAnnotationsFromSameScene = annotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - // Filter out 3D viewports - if (otherViewport.type === Enums.ViewportType.VOLUME_3D) { - return false; - } - - return viewport !== otherViewport && otherViewportControllable === true; - } - ); - - const otherViewportsAnnotationsWithUniqueCameras = []; - // Iterate first on other viewport from the same scene linked - for ( - let i = 0; - i < otherLinkedViewportAnnotationsFromSameScene.length; - ++i - ) { - const annotation = otherLinkedViewportAnnotationsFromSameScene[i]; - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - const otherNonLinkedViewportAnnotationsFromSameScene = annotations.filter( - (annotation) => { - const { data } = annotation; - const otherViewport = renderingEngine.getViewport(data.viewportId); - const otherViewportControllable = this._getReferenceLineControllable( - otherViewport.id - ); - - return ( - viewport !== otherViewport && - // scene === otherScene && - otherViewportControllable !== true - ); - } - ); - - // Iterate second on other viewport from the same scene non linked - for ( - let i = 0; - i < otherNonLinkedViewportAnnotationsFromSameScene.length; - ++i - ) { - const annotation = otherNonLinkedViewportAnnotationsFromSameScene[i]; - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - // Iterate on all the viewport - const otherViewportAnnotations = - this._getAnnotationsForViewportsWithDifferentCameras( - enabledElement, - annotations - ); - - for (let i = 0; i < otherViewportAnnotations.length; ++i) { - const annotation = otherViewportAnnotations[i]; - if ( - otherViewportsAnnotationsWithUniqueCameras.some( - (element) => element === annotation - ) - ) { - continue; - } - - const { viewportId } = annotation.data; - const otherViewport = renderingEngine.getViewport(viewportId); - const otherCamera = otherViewport.getCamera(); - const otherViewPlaneNormal = otherCamera.viewPlaneNormal; - vtkMath.normalize(otherViewPlaneNormal); - - if ( - csUtils.isEqual(viewPlaneNormal, otherViewPlaneNormal, 1e-2) || - csUtils.isOpposite(viewPlaneNormal, otherViewPlaneNormal, 1e-2) - ) { - continue; - } - - let cameraFound = false; - for ( - let jj = 0; - jj < otherViewportsAnnotationsWithUniqueCameras.length; - ++jj - ) { - const annotation = otherViewportsAnnotationsWithUniqueCameras[jj]; - const { viewportId } = annotation.data; - const stockedViewport = renderingEngine.getViewport(viewportId); - const cameraOfStocked = stockedViewport.getCamera(); - - if ( - csUtils.isEqual( - cameraOfStocked.viewPlaneNormal, - otherCamera.viewPlaneNormal, - 1e-2 - ) && - csUtils.isEqual(cameraOfStocked.position, otherCamera.position, 1) - ) { - cameraFound = true; - } - } - - if (!cameraFound) { - otherViewportsAnnotationsWithUniqueCameras.push(annotation); - } - } - - return otherViewportsAnnotationsWithUniqueCameras; - }; - _activateModify = (element) => { // mobile sometimes has lingering interaction even when touchEnd triggers // this check allows for multiple handles to be active which doesn't affect From 38d12f83b3425977087a867c9471c86612d8fa17 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 18 Jul 2025 18:16:49 +0200 Subject: [PATCH 091/132] refactor(VolumeCroppingControlTool): streamline virtual annotation handling and improve viewport synthesis logic --- .../src/tools/VolumeCroppingControlTool.ts | 45 +++++++------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index ac7cea4374..6b9af352d9 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -698,15 +698,9 @@ class VolumeCroppingControlTool extends AnnotationTool { const { viewport, renderingEngine } = enabledElement; const { element } = viewport; let annotations = this._getAnnotations(enabledElement); - // If we have virtual annotations (like CT_CORONAL), always include them + // If we have virtual annotations , always include them if (this._virtualAnnotations && this._virtualAnnotations.length) { - // Only add if not already present - const hasCoronal = annotations.some( - (a) => a.data.viewportId === 'CT_CORONAL' - ); - if (!hasCoronal) { - annotations = annotations.concat(this._virtualAnnotations); - } + annotations = annotations.concat(this._virtualAnnotations); } const camera = viewport.getCamera(); const filteredToolAnnotations = @@ -762,22 +756,21 @@ class VolumeCroppingControlTool extends AnnotationTool { otherCanvasDiagonalLength, otherCanvasCenter, otherViewportCenterWorld; - if (isVirtual && data.viewportId === 'CT_CORONAL') { - // Synthesize a virtual viewport/camera for CT_CORONAL - // Use the cross product of the two real viewports' normals + if (isVirtual) { + // Synthesize a virtual viewport/camera for any missing orientation const realViewports = viewportsInfo.filter( - (vp) => vp.viewportId !== 'CT_CORONAL' + (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 coronalNormal = vec3.create(); - vec3.cross(coronalNormal, normal1, normal2); - vec3.normalize(coronalNormal, coronalNormal); + const virtualNormal = vec3.create(); + vec3.cross(virtualNormal, normal1, normal2); + vec3.normalize(virtualNormal, virtualNormal); otherCamera = { - viewPlaneNormal: coronalNormal, + viewPlaneNormal: virtualNormal, position: data.handles.toolCenter, focalPoint: data.handles.toolCenter, viewUp: [0, 1, 0], @@ -791,7 +784,7 @@ class VolumeCroppingControlTool extends AnnotationTool { otherCanvasCenter = [clientWidth * 0.5, clientHeight * 0.5]; otherViewportCenterWorld = data.handles.toolCenter; otherViewport = { - id: 'CT_CORONAL', + id: data.viewportId, canvas: viewport.canvas, canvasToWorld: () => data.handles.toolCenter, }; @@ -942,19 +935,11 @@ class VolumeCroppingControlTool extends AnnotationTool { // get color for the reference line const otherViewport = line[0]; - let color; - // If the reference line is for the virtual CT_CORONAL annotation, use yellow - if ( - annotationUID && - data.viewportId !== 'CT_CORONAL' && - otherViewport.id === 'CT_CORONAL' - ) { - color = 'yellow'; - } else { - const viewportColor = this._getReferenceLineColor(otherViewport.id); - color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; - } + + const viewportColor = this._getReferenceLineColor(otherViewport.id); + const color = + viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + const viewportControllable = this._getReferenceLineControllable( otherViewport.id ); From c7f97c33a1f484cd50609b05f897f321ee0495b7 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 02:12:59 +0200 Subject: [PATCH 092/132] feat(VolumeCroppingControlTool): synthesize virtual annotations for missing orientations and enhance viewport handling --- .../src/tools/VolumeCroppingControlTool.ts | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 6b9af352d9..c72d02507a 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -438,11 +438,9 @@ class VolumeCroppingControlTool extends AnnotationTool { // ...existing code... } else if (presentViewportInfos.length === 2 && missingOrientation) { // Synthesize virtual annotation for the missing orientation - // Use cross product of the two present normals const virtualNormal: Types.Point3 = [0, 0, 0]; vec3.cross(virtualNormal, presentNormals[0], presentNormals[1]); vec3.normalize(virtualNormal, virtualNormal); - // Use average of the two present centers const virtualCenter: Types.Point3 = [ (presentCenters[0][0] + presentCenters[1][0]) / 2, (presentCenters[0][1] + presentCenters[1][1]) / 2, @@ -466,10 +464,44 @@ class VolumeCroppingControlTool extends AnnotationTool { viewportId: missingOrientation, referenceLines: [], }, - // Mark as virtual so renderAnnotation can handle it isVirtual: true, + virtualNormal, }; this._virtualAnnotations = [virtualAnnotation]; + } else if (presentViewportInfos.length === 1) { + // Synthesize two virtual annotations for the two missing orientations + const presentId = presentViewportInfos[0].viewportId; + const presentCenter = presentCenters[0]; + const canonicalNormals = { + CT_AXIAL: [0, 0, 1], + CT_CORONAL: [0, 1, 0], + CT_SAGITTAL: [1, 0, 0], + }; + const missingIds = orientationIds.filter((id) => id !== presentId); + const virtualAnnotations = missingIds.map((missingId) => { + return { + highlighted: false, + metadata: { + cameraPosition: [...presentCenter], + cameraFocalPoint: [...presentCenter], + toolName: this.getToolName(), + }, + data: { + handles: { + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeOperation: null, + activeViewportIds: [], + viewportId: missingId, + referenceLines: [], + }, + isVirtual: true, + virtualNormal: canonicalNormals[missingId], + }; + }); + this._virtualAnnotations = virtualAnnotations; } if (viewportsInfo && viewportsInfo.length) { @@ -775,7 +807,6 @@ class VolumeCroppingControlTool extends AnnotationTool { focalPoint: data.handles.toolCenter, viewUp: [0, 1, 0], }; - // Synthesize canvas size and center clientWidth = viewport.canvas.clientWidth; clientHeight = viewport.canvas.clientHeight; otherCanvasDiagonalLength = Math.sqrt( @@ -789,8 +820,26 @@ class VolumeCroppingControlTool extends AnnotationTool { canvasToWorld: () => data.handles.toolCenter, }; } else { - // Fallback: skip rendering this virtual annotation - return; + // Only one real viewport: use canonical normal from virtual annotation + const virtualNormal = annotation.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); From 2271cc5f815f49d8626eb5cca88cc198f36b0639 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 07:26:17 +0200 Subject: [PATCH 093/132] fix(VolumeCroppingControlTool): cast annotation to any for virtualNormal access --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index c72d02507a..dc394a5e00 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -70,6 +70,8 @@ interface VolumeCroppingAnnotation extends Annotation { clippingPlanes?: vtkPlane[]; // clipping planes for the viewport clippingPlaneReferenceLines?: []; }; + isVirtual?: boolean; + virtualNormal?: Types.Point3; } function defaultReferenceLineColor() { @@ -94,7 +96,7 @@ const OPERATION = { */ class VolumeCroppingControlTool extends AnnotationTool { // Store virtual annotations (e.g., for missing orientations like CT_CORONAL) - _virtualAnnotations: Annotation[] = []; + _virtualAnnotations: VolumeCroppingAnnotation[] = []; static toolName; sphereStates: { point: Types.Point3; @@ -821,7 +823,8 @@ class VolumeCroppingControlTool extends AnnotationTool { }; } else { // Only one real viewport: use canonical normal from virtual annotation - const virtualNormal = annotation.virtualNormal ?? [0, 0, 1]; + const virtualNormal = (annotation as VolumeCroppingAnnotation) + .virtualNormal ?? [0, 0, 1]; otherCamera = { viewPlaneNormal: virtualNormal, position: data.handles.toolCenter, From 45297045ea48c646199949a5c0c2155238faff8e Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 07:28:48 +0200 Subject: [PATCH 094/132] fix(VolumeCroppingControlTool): specify type for virtualAnnotation and streamline virtual annotation creation --- .../src/tools/VolumeCroppingControlTool.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index dc394a5e00..0d776a9e50 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -448,7 +448,7 @@ class VolumeCroppingControlTool extends AnnotationTool { (presentCenters[0][1] + presentCenters[1][1]) / 2, (presentCenters[0][2] + presentCenters[1][2]) / 2, ]; - const virtualAnnotation = { + const virtualAnnotation: VolumeCroppingAnnotation = { highlighted: false, metadata: { cameraPosition: [...virtualCenter], @@ -457,11 +457,11 @@ class VolumeCroppingControlTool extends AnnotationTool { }, data: { handles: { + activeOperation: null, toolCenter: this.toolCenter, toolCenterMin: this.toolCenterMin, toolCenterMax: this.toolCenterMax, }, - activeOperation: null, activeViewportIds: [], viewportId: missingOrientation, referenceLines: [], @@ -480,29 +480,31 @@ class VolumeCroppingControlTool extends AnnotationTool { CT_SAGITTAL: [1, 0, 0], }; const missingIds = orientationIds.filter((id) => id !== presentId); - const virtualAnnotations = missingIds.map((missingId) => { - return { - highlighted: false, - metadata: { - cameraPosition: [...presentCenter], - cameraFocalPoint: [...presentCenter], - toolName: this.getToolName(), - }, - data: { - handles: { - toolCenter: this.toolCenter, - toolCenterMin: this.toolCenterMin, - toolCenterMax: this.toolCenterMax, + const virtualAnnotations: VolumeCroppingAnnotation[] = missingIds.map( + (missingId) => { + return { + highlighted: false, + metadata: { + cameraPosition: [...presentCenter], + cameraFocalPoint: [...presentCenter], + toolName: this.getToolName(), }, - activeOperation: null, - activeViewportIds: [], - viewportId: missingId, - referenceLines: [], - }, - isVirtual: true, - virtualNormal: canonicalNormals[missingId], - }; - }); + data: { + handles: { + activeOperation: null, + toolCenter: this.toolCenter, + toolCenterMin: this.toolCenterMin, + toolCenterMax: this.toolCenterMax, + }, + activeViewportIds: [], + viewportId: missingId, + referenceLines: [], + }, + isVirtual: true, + virtualNormal: canonicalNormals[missingId], + }; + } + ); this._virtualAnnotations = virtualAnnotations; } From 2ccb251c9e69666e2432ca9c635f070843656580 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 08:30:58 +0200 Subject: [PATCH 095/132] refactor(VolumeCroppingTool): optimize clipping plane updates and improve logging --- .../src/tools/VolumeCroppingControlTool.ts | 63 ++++++++++++---- .../tools/src/tools/VolumeCroppingTool.ts | 72 +++++++++++-------- 2 files changed, 95 insertions(+), 40 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 0d776a9e50..a79b93c036 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -422,6 +422,7 @@ class VolumeCroppingControlTool extends AnnotationTool { (id) => !presentViewports.includes(id) ); + console.debug(' _computeToolCenter : presentViewports:', presentViewports); // Initialize present viewports const presentNormals: Types.Point3[] = []; @@ -436,9 +437,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }); // If all three orientations are present, nothing to synthesize - if (presentViewportInfos.length === 3) { - // ...existing code... - } else if (presentViewportInfos.length === 2 && missingOrientation) { + 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]); @@ -551,6 +550,15 @@ class VolumeCroppingControlTool extends AnnotationTool { annotations ); + // Guard clause: if no interactable annotation, return null + if ( + !filteredAnnotations || + filteredAnnotations.length === 0 || + !filteredAnnotations[0] + ) { + return null; + } + const { data } = filteredAnnotations[0]; const viewportIdArray = []; @@ -692,11 +700,41 @@ class VolumeCroppingControlTool extends AnnotationTool { const enabledElement = getEnabledElement(element); const { viewportId } = enabledElement; - const viewportUIDSpecificCrosshairs = annotations.filter( - (annotation) => annotation.data.viewportId === viewportId - ); - return viewportUIDSpecificCrosshairs; + // Normalize viewportId for OHIF (strip numeric suffixes, handle orientation) + let normalizedViewportId = viewportId; + if (typeof viewportId === 'string') { + const orientationMatch = viewportId.match(/CT_(AXIAL|CORONAL|SAGITTAL)/); + if (orientationMatch) { + normalizedViewportId = orientationMatch[0]; + } + } + + // Filter annotations for this viewportId, including virtual annotations + const filtered = annotations.filter((annotation) => { + // Always include virtual annotations for reference line rendering + if (annotation.isVirtual) { + return true; + } + // Normalize annotation viewportId + const annotationViewportId = annotation.data.viewportId; + let normalizedAnnotationViewportId = annotationViewportId; + if (typeof annotationViewportId === 'string') { + const orientationMatch = annotationViewportId.match( + /CT_(AXIAL|CORONAL|SAGITTAL)/ + ); + if (orientationMatch) { + normalizedAnnotationViewportId = orientationMatch[0]; + } + } + // Match normalized viewportId + if (normalizedAnnotationViewportId === normalizedViewportId) { + return true; + } + return false; + }); + + return filtered; }; /** @@ -730,6 +768,11 @@ class VolumeCroppingControlTool extends AnnotationTool { // No viewports available return false; } + console.debug( + `VolumeCroppingControlTool.renderAnnotation: Rendering for viewports: ${viewportsInfo + .map((vp) => vp.viewportId) + .join(', ')}` + ); let renderStatus = false; const { viewport, renderingEngine } = enabledElement; const { element } = viewport; @@ -748,7 +791,6 @@ class VolumeCroppingControlTool extends AnnotationTool { // No annotation for this viewport return renderStatus; } - //console.debug(viewportAnnotation); const annotationUID = viewportAnnotation.annotationUID; @@ -1367,7 +1409,7 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenterMin: this.toolCenterMin, toolCenterMax: this.toolCenterMax, handleType: handles.activeType, - viewportOrientation: [], // You can fill this if needed + viewportOrientation: [], }); } }; @@ -1389,9 +1431,6 @@ class VolumeCroppingControlTool extends AnnotationTool { annotation, delta ) { - // update camera for the other viewports. - // NOTE1: The lines then are rendered by the onCameraModified - // NOTE2: crosshair center are automatically updated in the onCameraModified event const { data } = annotation; const viewport = renderingEngine.getViewport(data.viewportId); diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index d10d6563b8..538a19a7c0 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -284,7 +284,6 @@ class VolumeCroppingTool extends BaseTool { onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); - console.debug('VolumeCroppingTool: onSetToolActive', viewportsInfo); const subscribeToElementResize = () => { viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { if (!this._resizeObservers.has(viewportId)) { @@ -606,54 +605,71 @@ class VolumeCroppingTool extends BaseTool { // Get the actor and transformation matrix const actorEntry = viewport.getDefaultActor(); if (!actorEntry || !actorEntry.actor) { - console.warn( - 'VolumeCroppingTool._updateClippingPlanes: No default actor found in viewport.' - ); + // 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 = mat3.create(); + const rot = mat3.create(); mat3.fromMat4(rot, matrix); // Compute inverse transpose for normal transformation - const normalMatrix: mat3 = mat3.create(); + const normalMatrix = mat3.create(); mat3.invert(normalMatrix, rot); mat3.transpose(normalMatrix, normalMatrix); - // Remove existing clipping planes + + // 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(); - originalPlanes.forEach((plane) => { - const origin: Types.Point3 = [ - plane.origin[0], - plane.origin[1], - plane.origin[2], - ]; - const normal: Types.Point3 = [ - plane.normal[0], - plane.normal[1], - plane.normal[2], - ]; + // 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 o: Types.Point3 = [0, 0, 0]; - vec3.transformMat4(o, origin, matrix); + 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); + } - const n = vec3.transformMat3([0, 0, 0], normal, normalMatrix); - vec3.normalize(n, 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: o, - normal: [n[0], n[1], n[2]], + origin: transformedOrigins[i], + normal: transformedNormals[i], }); - if (this.configuration.showClippingPlanes) { - mapper.addClippingPlane(planeInstance); - } - }); + mapper.addClippingPlane(planeInstance); + } } _onControlToolChange = (evt) => { From f9046312e0b5b63ece34490f90959ee5775609cc Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 09:00:50 +0200 Subject: [PATCH 096/132] feat(VolumeCroppingControlTool): add orientation mapping from camera normal and viewport ID for virtual annotations --- .../src/tools/VolumeCroppingControlTool.ts | 83 ++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index a79b93c036..5ad40d6922 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1,3 +1,38 @@ +/** + * Utility function to map a camera normal to an orientation string. + * Returns 'AXIAL', 'CORONAL', 'SAGITTAL', or null if not matched. + */ +function 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; +} 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'; @@ -69,6 +104,7 @@ interface VolumeCroppingAnnotation extends Annotation { referenceLines: []; // set in renderAnnotation clippingPlanes?: vtkPlane[]; // clipping planes for the viewport clippingPlaneReferenceLines?: []; + orientation?: string; // AXIAL, CORONAL, SAGITTAL }; isVirtual?: boolean; virtualNormal?: Types.Point3; @@ -270,6 +306,17 @@ class VolumeCroppingControlTool extends AnnotationTool { removeAnnotation(annotations[0].annotationUID); } + // Determine orientation from camera normal, fallback to viewportId string + let orientation = getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + if (!orientation && typeof viewportId === 'string') { + const orientationMatch = viewportId.match(/CT_(AXIAL|CORONAL|SAGITTAL)/); + if (orientationMatch) { + orientation = orientationMatch[1]; + } + } + console.debug(' _computeToolCenter :', orientation); const annotation = { highlighted: false, metadata: { @@ -288,6 +335,7 @@ class VolumeCroppingControlTool extends AnnotationTool { activeViewportIds: [], // a list of the viewport ids connected to the reference lines being translated viewportId, referenceLines: [], // set in renderAnnotation + orientation, }, }; @@ -447,6 +495,16 @@ class VolumeCroppingControlTool extends AnnotationTool { (presentCenters[0][1] + presentCenters[1][1]) / 2, (presentCenters[0][2] + presentCenters[1][2]) / 2, ]; + // Extract orientation string from missingOrientation (AXIAL, CORONAL, SAGITTAL) + let orientation = null; + if (typeof missingOrientation === 'string') { + const orientationMatch = missingOrientation.match( + /CT_(AXIAL|CORONAL|SAGITTAL)/ + ); + if (orientationMatch) { + orientation = orientationMatch[1]; + } + } const virtualAnnotation: VolumeCroppingAnnotation = { highlighted: false, metadata: { @@ -464,6 +522,7 @@ class VolumeCroppingControlTool extends AnnotationTool { activeViewportIds: [], viewportId: missingOrientation, referenceLines: [], + orientation, }, isVirtual: true, virtualNormal, @@ -473,14 +532,29 @@ class VolumeCroppingControlTool extends AnnotationTool { // Synthesize two virtual annotations for the two missing orientations const presentId = presentViewportInfos[0].viewportId; const presentCenter = presentCenters[0]; + // Map canonical normals to orientation strings const canonicalNormals = { - CT_AXIAL: [0, 0, 1], - CT_CORONAL: [0, 1, 0], - CT_SAGITTAL: [1, 0, 0], + AXIAL: [0, 0, 1], + CORONAL: [0, 1, 0], + SAGITTAL: [1, 0, 0], }; + // missingIds: CT_AXIAL, CT_CORONAL, CT_SAGITTAL const missingIds = orientationIds.filter((id) => id !== presentId); const virtualAnnotations: VolumeCroppingAnnotation[] = missingIds.map( (missingId) => { + let orientation = null; + if (typeof missingId === 'string') { + const orientationMatch = missingId.match( + /CT_(AXIAL|CORONAL|SAGITTAL)/ + ); + if (orientationMatch) { + orientation = orientationMatch[1]; + } + } + // Use orientation string to get canonical normal + const normal = orientation + ? canonicalNormals[orientation] + : [0, 0, 1]; return { highlighted: false, metadata: { @@ -498,9 +572,10 @@ class VolumeCroppingControlTool extends AnnotationTool { activeViewportIds: [], viewportId: missingId, referenceLines: [], + orientation, }, isVirtual: true, - virtualNormal: canonicalNormals[missingId], + virtualNormal: normal, }; } ); From 9f88b072b77131fa995c77901bee637b93deb528 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 09:52:01 +0200 Subject: [PATCH 097/132] fix(VolumeCroppingControlTool): correct viewport orientation handling and improve debug logging --- .../examples/volumeCroppingTool/index.ts | 4 +- .../src/tools/VolumeCroppingControlTool.ts | 136 +++++++++++++----- 2 files changed, 105 insertions(+), 35 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index d28ae68660..c75a6180a3 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -54,8 +54,8 @@ const toolGroupId = 'MY_TOOLGROUP_ID'; const toolGroupIdVRT = 'MY_TOOLGROUP_VRT_ID'; const viewportId1 = 'CT_AXIAL'; -const viewportId2 = 'CT_SAGITTAL'; -const viewportId3 = 'CT_CORONAL'; +const viewportId2 = 'CT_CORONAL'; +const viewportId3 = 'CT_SAGITTAL'; const viewportId4 = 'CT_3D_VOLUME'; // New 3D volume viewport const viewportIds = [viewportId1, viewportId2, viewportId3, viewportId4]; const renderingEngineId = 'myRenderingEngine'; diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 5ad40d6922..37ad47001d 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -316,7 +316,11 @@ class VolumeCroppingControlTool extends AnnotationTool { orientation = orientationMatch[1]; } } - console.debug(' _computeToolCenter :', orientation); + console.debug('[VolumeCroppingControlTool] initializeViewport:', { + viewportId, + orientation, + cameraNormal: viewport.getCamera().viewPlaneNormal, + }); const annotation = { highlighted: false, metadata: { @@ -404,7 +408,10 @@ class VolumeCroppingControlTool extends AnnotationTool { } const annotations = this._getAnnotations(enabledElement); - + console.debug( + 'VolumeCroppingControlTool: Annotations found:', + annotations + ); if (annotations?.length) { annotations.forEach((annotation) => { removeAnnotation(annotation.annotationUID); @@ -463,21 +470,48 @@ class VolumeCroppingControlTool extends AnnotationTool { return; } // Support any missing orientation (CT_AXIAL, CT_CORONAL, CT_SAGITTAL) - const orientationIds = ['CT_AXIAL', 'CT_CORONAL', 'CT_SAGITTAL']; - const presentViewports = viewportsInfo.map((vp) => vp.viewportId); + 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 = getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + if (orientation) { + return orientation; + } + } + } + return null; + }) + .filter(Boolean); const missingOrientation = orientationIds.find( - (id) => !presentViewports.includes(id) + (id) => !presentOrientations.includes(id) ); - console.debug(' _computeToolCenter : presentViewports:', presentViewports); // Initialize present viewports const presentNormals: Types.Point3[] = []; const presentCenters: Types.Point3[] = []; - const presentViewportInfos = viewportsInfo.filter((vp) => - orientationIds.includes(vp.viewportId) - ); + // 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 = getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + } + } + return orientation && orientationIds.includes(orientation); + }); presentViewportInfos.forEach((vpInfo) => { const { normal, point } = this.initializeViewport(vpInfo); presentNormals.push(normal); @@ -527,6 +561,15 @@ class VolumeCroppingControlTool extends AnnotationTool { isVirtual: true, virtualNormal, }; + console.debug( + '[VolumeCroppingControlTool] _computeToolCenter synthesized virtualAnnotation:', + { + viewportId: missingOrientation, + orientation, + virtualNormal, + virtualCenter, + } + ); this._virtualAnnotations = [virtualAnnotation]; } else if (presentViewportInfos.length === 1) { // Synthesize two virtual annotations for the two missing orientations @@ -555,7 +598,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const normal = orientation ? canonicalNormals[orientation] : [0, 0, 1]; - return { + const virtualAnnotation = { highlighted: false, metadata: { cameraPosition: [...presentCenter], @@ -577,6 +620,16 @@ class VolumeCroppingControlTool extends AnnotationTool { isVirtual: true, virtualNormal: normal, }; + console.debug( + '[VolumeCroppingControlTool] _computeToolCenter synthesized virtualAnnotation:', + { + viewportId: missingId, + orientation, + virtualNormal: normal, + presentCenter, + } + ); + return virtualAnnotation; } ); this._virtualAnnotations = virtualAnnotations; @@ -774,41 +827,58 @@ class VolumeCroppingControlTool extends AnnotationTool { } const enabledElement = getEnabledElement(element); - const { viewportId } = enabledElement; - - // Normalize viewportId for OHIF (strip numeric suffixes, handle orientation) - let normalizedViewportId = viewportId; - if (typeof viewportId === 'string') { - const orientationMatch = viewportId.match(/CT_(AXIAL|CORONAL|SAGITTAL)/); + // Use orientation property for matching + let orientation = null; + if (enabledElement.viewport && enabledElement.viewport.getCamera) { + orientation = getOrientationFromNormal( + enabledElement.viewport.getCamera().viewPlaneNormal + ); + } + // Fallback: try to extract from viewportId string + if (!orientation && typeof enabledElement.viewportId === 'string') { + const orientationMatch = enabledElement.viewportId.match( + /CT_(AXIAL|CORONAL|SAGITTAL)/ + ); if (orientationMatch) { - normalizedViewportId = orientationMatch[0]; + orientation = orientationMatch[1]; } } - - // Filter annotations for this viewportId, including virtual annotations + // Debug log: orientation being matched and annotation orientations + console.debug( + '[VolumeCroppingControlTool] filterInteractableAnnotationsForElement:', + { + elementViewportId: enabledElement.viewportId, + orientationToMatch: orientation, + annotationOrientations: annotations.map((a) => ({ + annotationUID: a.annotationUID, + isVirtual: a.isVirtual, + orientation: a.data.orientation, + viewportId: a.data.viewportId, + })), + } + ); + // 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; } - // Normalize annotation viewportId - const annotationViewportId = annotation.data.viewportId; - let normalizedAnnotationViewportId = annotationViewportId; - if (typeof annotationViewportId === 'string') { - const orientationMatch = annotationViewportId.match( - /CT_(AXIAL|CORONAL|SAGITTAL)/ - ); - if (orientationMatch) { - normalizedAnnotationViewportId = orientationMatch[0]; - } - } - // Match normalized viewportId - if (normalizedAnnotationViewportId === normalizedViewportId) { + // Match by orientation property + if ( + annotation.data.orientation && + orientation && + annotation.data.orientation === orientation + ) { return true; } return false; }); - + console.debug( + '[VolumeCroppingControlTool] filterInteractableAnnotationsForElement result:', + { + filteredAnnotationUIDs: filtered.map((a) => a.annotationUID), + } + ); return filtered; }; From d91b0284066d91db5f62a24dc0a756307b4ae4bd Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 10:02:40 +0200 Subject: [PATCH 098/132] fix(VolumeCroppingControlTool): improve virtual annotation synthesis by using camera normal for orientation detection --- .../src/tools/VolumeCroppingControlTool.ts | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 37ad47001d..d31b63833b 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -573,7 +573,18 @@ class VolumeCroppingControlTool extends AnnotationTool { this._virtualAnnotations = [virtualAnnotation]; } else if (presentViewportInfos.length === 1) { // Synthesize two virtual annotations for the two missing orientations - const presentId = presentViewportInfos[0].viewportId; + // 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 = getOrientationFromNormal( + viewport.getCamera().viewPlaneNormal + ); + } + } const presentCenter = presentCenters[0]; // Map canonical normals to orientation strings const canonicalNormals = { @@ -581,23 +592,14 @@ class VolumeCroppingControlTool extends AnnotationTool { CORONAL: [0, 1, 0], SAGITTAL: [1, 0, 0], }; - // missingIds: CT_AXIAL, CT_CORONAL, CT_SAGITTAL - const missingIds = orientationIds.filter((id) => id !== presentId); + // missingIds: AXIAL, CORONAL, SAGITTAL + const missingIds = orientationIds.filter( + (id) => id !== presentOrientation + ); const virtualAnnotations: VolumeCroppingAnnotation[] = missingIds.map( - (missingId) => { - let orientation = null; - if (typeof missingId === 'string') { - const orientationMatch = missingId.match( - /CT_(AXIAL|CORONAL|SAGITTAL)/ - ); - if (orientationMatch) { - orientation = orientationMatch[1]; - } - } + (orientation) => { // Use orientation string to get canonical normal - const normal = orientation - ? canonicalNormals[orientation] - : [0, 0, 1]; + const normal = canonicalNormals[orientation]; const virtualAnnotation = { highlighted: false, metadata: { @@ -613,7 +615,7 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenterMax: this.toolCenterMax, }, activeViewportIds: [], - viewportId: missingId, + viewportId: orientation, // Use orientation string for virtual annotation referenceLines: [], orientation, }, @@ -623,7 +625,7 @@ class VolumeCroppingControlTool extends AnnotationTool { console.debug( '[VolumeCroppingControlTool] _computeToolCenter synthesized virtualAnnotation:', { - viewportId: missingId, + viewportId: orientation, orientation, virtualNormal: normal, presentCenter, @@ -843,20 +845,7 @@ class VolumeCroppingControlTool extends AnnotationTool { orientation = orientationMatch[1]; } } - // Debug log: orientation being matched and annotation orientations - console.debug( - '[VolumeCroppingControlTool] filterInteractableAnnotationsForElement:', - { - elementViewportId: enabledElement.viewportId, - orientationToMatch: orientation, - annotationOrientations: annotations.map((a) => ({ - annotationUID: a.annotationUID, - isVirtual: a.isVirtual, - orientation: a.data.orientation, - viewportId: a.data.viewportId, - })), - } - ); + // Filter annotations for this orientation, including virtual annotations const filtered = annotations.filter((annotation) => { // Always include virtual annotations for reference line rendering @@ -873,12 +862,7 @@ class VolumeCroppingControlTool extends AnnotationTool { } return false; }); - console.debug( - '[VolumeCroppingControlTool] filterInteractableAnnotationsForElement result:', - { - filteredAnnotationUIDs: filtered.map((a) => a.annotationUID), - } - ); + return filtered; }; From 6bb7bed8b9354d017c33e06b2d398a29599b3d89 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 10:14:53 +0200 Subject: [PATCH 099/132] fix(VolumeCroppingControlTool): update type annotations for referenceLines and clippingPlaneReferenceLines in VolumeCroppingAnnotation interface --- .../src/tools/VolumeCroppingControlTool.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index d31b63833b..b314fdf439 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -91,6 +91,17 @@ import triggerAnnotationRenderForViewportIds from '../utilities/triggerAnnotatio 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: { @@ -101,9 +112,9 @@ interface VolumeCroppingAnnotation extends Annotation { }; activeViewportIds: string[]; // a list of the viewport ids connected to the reference lines being translated viewportId: string; - referenceLines: []; // set in renderAnnotation + referenceLines: ReferenceLine[]; // set in renderAnnotation clippingPlanes?: vtkPlane[]; // clipping planes for the viewport - clippingPlaneReferenceLines?: []; + clippingPlaneReferenceLines?: ReferenceLine[]; orientation?: string; // AXIAL, CORONAL, SAGITTAL }; isVirtual?: boolean; @@ -897,11 +908,6 @@ class VolumeCroppingControlTool extends AnnotationTool { // No viewports available return false; } - console.debug( - `VolumeCroppingControlTool.renderAnnotation: Rendering for viewports: ${viewportsInfo - .map((vp) => vp.viewportId) - .join(', ')}` - ); let renderStatus = false; const { viewport, renderingEngine } = enabledElement; const { element } = viewport; From 99f56d65b516a0a94206dd66c629fe38bb590067 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 10:19:14 +0200 Subject: [PATCH 100/132] refactor(VolumeCroppingControlTool): remove debug logging for viewport initialization and virtual annotation synthesis --- .../src/tools/VolumeCroppingControlTool.ts | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index b314fdf439..f8c2dfb0e2 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -327,11 +327,7 @@ class VolumeCroppingControlTool extends AnnotationTool { orientation = orientationMatch[1]; } } - console.debug('[VolumeCroppingControlTool] initializeViewport:', { - viewportId, - orientation, - cameraNormal: viewport.getCamera().viewPlaneNormal, - }); + const annotation = { highlighted: false, metadata: { @@ -419,10 +415,6 @@ class VolumeCroppingControlTool extends AnnotationTool { } const annotations = this._getAnnotations(enabledElement); - console.debug( - 'VolumeCroppingControlTool: Annotations found:', - annotations - ); if (annotations?.length) { annotations.forEach((annotation) => { removeAnnotation(annotation.annotationUID); @@ -572,15 +564,6 @@ class VolumeCroppingControlTool extends AnnotationTool { isVirtual: true, virtualNormal, }; - console.debug( - '[VolumeCroppingControlTool] _computeToolCenter synthesized virtualAnnotation:', - { - viewportId: missingOrientation, - orientation, - virtualNormal, - virtualCenter, - } - ); this._virtualAnnotations = [virtualAnnotation]; } else if (presentViewportInfos.length === 1) { // Synthesize two virtual annotations for the two missing orientations @@ -633,15 +616,7 @@ class VolumeCroppingControlTool extends AnnotationTool { isVirtual: true, virtualNormal: normal, }; - console.debug( - '[VolumeCroppingControlTool] _computeToolCenter synthesized virtualAnnotation:', - { - viewportId: orientation, - orientation, - virtualNormal: normal, - presentCenter, - } - ); + return virtualAnnotation; } ); From 37f27c2772c28ba522cd5c1bdd584988b8499461 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 10:57:08 +0200 Subject: [PATCH 101/132] refactor(VolumeCroppingControlTool): simplify orientation determination logic by removing fallback to viewportId --- .../src/tools/VolumeCroppingControlTool.ts | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f8c2dfb0e2..b327004097 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -318,15 +318,9 @@ class VolumeCroppingControlTool extends AnnotationTool { } // Determine orientation from camera normal, fallback to viewportId string - let orientation = getOrientationFromNormal( + const orientation = getOrientationFromNormal( viewport.getCamera().viewPlaneNormal ); - if (!orientation && typeof viewportId === 'string') { - const orientationMatch = viewportId.match(/CT_(AXIAL|CORONAL|SAGITTAL)/); - if (orientationMatch) { - orientation = orientationMatch[1]; - } - } const annotation = { highlighted: false, @@ -472,7 +466,7 @@ class VolumeCroppingControlTool extends AnnotationTool { ); return; } - // Support any missing orientation (CT_AXIAL, CT_CORONAL, CT_SAGITTAL) + // Support any missing orientation const orientationIds = ['AXIAL', 'CORONAL', 'SAGITTAL']; // Get present orientations from viewportsInfo const presentOrientations = viewportsInfo @@ -532,16 +526,7 @@ class VolumeCroppingControlTool extends AnnotationTool { (presentCenters[0][1] + presentCenters[1][1]) / 2, (presentCenters[0][2] + presentCenters[1][2]) / 2, ]; - // Extract orientation string from missingOrientation (AXIAL, CORONAL, SAGITTAL) - let orientation = null; - if (typeof missingOrientation === 'string') { - const orientationMatch = missingOrientation.match( - /CT_(AXIAL|CORONAL|SAGITTAL)/ - ); - if (orientationMatch) { - orientation = orientationMatch[1]; - } - } + const orientation = null; const virtualAnnotation: VolumeCroppingAnnotation = { highlighted: false, metadata: { @@ -822,15 +807,6 @@ class VolumeCroppingControlTool extends AnnotationTool { enabledElement.viewport.getCamera().viewPlaneNormal ); } - // Fallback: try to extract from viewportId string - if (!orientation && typeof enabledElement.viewportId === 'string') { - const orientationMatch = enabledElement.viewportId.match( - /CT_(AXIAL|CORONAL|SAGITTAL)/ - ); - if (orientationMatch) { - orientation = orientationMatch[1]; - } - } // Filter annotations for this orientation, including virtual annotations const filtered = annotations.filter((annotation) => { From e1398fd0fe51af4d8eaa46935e6733327132c3f8 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:58:46 +0200 Subject: [PATCH 102/132] feat(VolumeCroppingControlTool): add configurable dropdown for number of orthographic viewports and update viewport handling --- .../examples/volumeCroppingTool/index.ts | 94 ++++++++++++------- 1 file changed, 61 insertions(+), 33 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index c75a6180a3..1046a304d1 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -58,6 +58,20 @@ 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'; ///////////////////////////////////////// @@ -196,13 +210,24 @@ function getReferenceLineControllable(viewportId) { } /** - * Runs the demo + * Get the number of orthographic viewports from the URL (?numViewports=1|2|3) */ -async function run() { - // Init Cornerstone and related libraries +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(); - // Add tools to Cornerstone3D cornerstoneTools.addTool(VolumeCroppingTool); cornerstoneTools.addTool(VolumeCroppingControlTool); cornerstoneTools.addTool(TrackballRotateTool); @@ -212,7 +237,6 @@ async function run() { cornerstoneTools.addTool(StackScrollTool); cornerstoneTools.addTool(CrosshairsTool); - // Get Cornerstone imageIds for the source data and fetch metadata into RAM const imageIds = await createImageIdsAndCacheMetaData({ StudyInstanceUID: '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', @@ -222,16 +246,14 @@ async function run() { getLocalUrl() || 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', }); - // Define a volume in memory const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds, }); - // Instantiate a rendering engine const renderingEngine = new RenderingEngine(renderingEngineId); - // Create the viewports - const viewportInputArray = [ + // Only include the requested number of orthographic viewports + const orthographicViewports = [ { viewportId: viewportId1, type: ViewportType.ORTHOGRAPHIC, @@ -259,6 +281,20 @@ async function run() { 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'; + } + }); + + const viewportInputArray = [ + ...orthographicViewports, { viewportId: viewportId4, type: ViewportType.VOLUME_3D, @@ -272,10 +308,13 @@ async function run() { renderingEngine.setViewports(viewportInputArray); - // Set the volume to load volume.load(); - // Set volumes on the viewports + // Only set volumes for the active viewport IDs + const activeViewportIds = [ + ...orthographicViewports.map((vp) => vp.viewportId), + viewportId4, + ]; await setVolumesForViewports( renderingEngine, [ @@ -284,25 +323,15 @@ async function run() { callback: setCtTransferFunctionForVolumeActor, }, ], - viewportIds + activeViewportIds ); - // Define tool groups to add the segmentation display tool to + // Tool group for orthographic viewports const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); - toolGroup.addViewport(viewportId1, renderingEngineId); - toolGroup.addViewport(viewportId2, renderingEngineId); - toolGroup.addViewport(viewportId3, renderingEngineId); - - /* - toolGroup.addTool(CrosshairsTool.toolName); - toolGroup.setToolActive(CrosshairsTool.toolName, { - bindings: [ - { - mouseButton: MouseBindings.Secondary, - }, - ], + orthographicViewports.forEach((vp) => { + toolGroup.addViewport(vp.viewportId, renderingEngineId); }); - */ + toolGroup.addTool(VolumeCroppingControlTool.toolName, { getReferenceLineColor, viewportIndicators: true, @@ -317,7 +346,6 @@ async function run() { toolGroup.addTool(StackScrollTool.toolName, { viewportIndicators: true, }); - toolGroup.setToolActive(StackScrollTool.toolName, { bindings: [ { @@ -329,6 +357,7 @@ async function run() { ], }); + // Tool group for 3D viewport const toolGroupVRT = ToolGroupManager.createToolGroup(toolGroupIdVRT); toolGroupVRT.addTool(ZoomTool.toolName); toolGroupVRT.setToolActive(ZoomTool.toolName, { @@ -353,9 +382,8 @@ async function run() { // toolGroupVRT.setToolActive(OrientationMarkerTool.toolName); const isMobile = window.matchMedia('(any-pointer:coarse)').matches; - // Render the image const viewport = renderingEngine.getViewport(viewportId4) as VolumeViewport3D; - renderingEngine.renderViewports(viewportIds); + renderingEngine.renderViewports(activeViewportIds); await setVolumesForViewports( renderingEngine, [{ volumeId }], @@ -368,10 +396,10 @@ async function run() { toolGroupVRT.addTool(VolumeCroppingTool.toolName, { sphereRadius: 7, sphereColors: { - x: [1, 1, 0], // yellow for X axis - y: [0, 1, 0], // green for Y axis - z: [1, 0, 0], // red for Z axis - corners: [0, 0, 1], // Blue for corners (optional) [0.7, 0.7, 0.7], // + x: [1, 1, 0], + y: [0, 1, 0], + z: [1, 0, 0], + corners: [0, 0, 1], }, showCornerSpheres: true, initialCropFactor: 0.2, From cfaf1cd46e871e45ab3a883cf7bdc1a183fe3425 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:49:29 +0200 Subject: [PATCH 103/132] feat(VolumeCroppingControlTool): add configurable line colors for reference lines based on orientation --- .../examples/volumeCroppingTool/index.ts | 1 + .../src/tools/VolumeCroppingControlTool.ts | 44 ++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 1046a304d1..401fc11b13 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -293,6 +293,7 @@ async function run(numViewports = getNumViewportsFromUrl()) { } }); + // Always set viewport4 (3D viewport) orientation to CORONAL const viewportInputArray = [ ...orthographicViewports, { diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index b327004097..7b3b654bb0 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -172,12 +172,17 @@ class VolumeCroppingControlTool extends AnnotationTool { y: null, }, extendReferenceLines: true, - referenceLinesCenterGapRadius: 20, 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 + }, }, } ) { @@ -1115,12 +1120,39 @@ class VolumeCroppingControlTool extends AnnotationTool { } } - // get color for the reference line + // get color for the reference line using orientation const otherViewport = line[0]; - - const viewportColor = this._getReferenceLineColor(otherViewport.id); - const color = - viewportColor !== undefined ? viewportColor : 'rgb(200, 200, 200)'; + 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 From 21479428ddec937936f2d044c9c0d474d4230a46 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:36:00 +0200 Subject: [PATCH 104/132] feat(VolumeCroppingTool): add configurable line widths for reference lines --- .../src/tools/VolumeCroppingControlTool.ts | 6 +- .../tools/src/tools/VolumeCroppingTool.ts | 119 ++++++++++-------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 7b3b654bb0..f3dbb054cd 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -183,6 +183,8 @@ class VolumeCroppingControlTool extends AnnotationTool { 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, }, } ) { @@ -1161,13 +1163,13 @@ class VolumeCroppingControlTool extends AnnotationTool { (id) => id === otherViewport.id ); - let lineWidth = 2.5; + let lineWidth = this.configuration.lineWidth ?? 1.5; const lineActive = data.handles.activeOperation !== null && data.handles.activeOperation === OPERATION.DRAG && selectedViewportId; if (lineActive) { - lineWidth = 4.5; + lineWidth = this.configuration.activeLineWidth ?? 2.5; } const lineUID = `${lineIndex}`; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 538a19a7c0..4b6bb19a15 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -283,64 +283,81 @@ class VolumeCroppingTool extends BaseTool { }; onSetToolActive() { - 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; - } + console.debug('Setting tool active: volumeCropping'); - const { element } = viewport; - - const resizeObserver = new ResizeObserver(() => { - const element = getEnabledElementByIds( + if (this.sphereStates && this.sphereStates.length > 0) { + this.setHandlesVisible(!this.configuration.showHandles); + this.setClippingPlanesVisible(!this.configuration.showClippingPlanes); + } else { + const viewportsInfo = this._getViewportsInfo(); + const subscribeToElementResize = () => { + viewportsInfo.forEach(({ viewportId, renderingEngineId }) => { + if (!this._resizeObservers.has(viewportId)) { + const { viewport } = getEnabledElementByIds( viewportId, renderingEngineId - ); - if (!element) { + ) || { viewport: null }; + + if (!viewport) { return; } - const { viewport } = element; - const viewPresentation = viewport.getViewPresentation(); + const { element } = viewport; - viewport.resetCamera(); + const resizeObserver = new ResizeObserver(() => { + const element = getEnabledElementByIds( + viewportId, + renderingEngineId + ); + if (!element) { + return; + } + const { viewport } = element; - viewport.setViewPresentation(viewPresentation); - viewport.render(); - }); + const viewPresentation = viewport.getViewPresentation(); - resizeObserver.observe(element); - this._resizeObservers.set(viewportId, resizeObserver); - } - }); - }; + viewport.resetCamera(); - subscribeToElementResize(); + viewport.setViewPresentation(viewPresentation); + viewport.render(); + }); - this._viewportAddedListener = (evt) => { - if (evt.detail.toolGroupId === this.toolGroupId) { - subscribeToElementResize(); - } - }; + resizeObserver.observe(element); + this._resizeObservers.set(viewportId, resizeObserver); + } + }); + }; - eventTarget.addEventListener( - Events.TOOLGROUP_VIEWPORT_ADDED, - this._viewportAddedListener - ); + subscribeToElementResize(); - this._unsubscribeToViewportNewVolumeSet(viewportsInfo); - this._subscribeToViewportNewVolumeSet(viewportsInfo); - this._initialize3DViewports(viewportsInfo); + 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); + } } + onSetToolConfiguration = (): void => { + console.debug('Setting tool settoolconfiguration init: 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(); @@ -519,14 +536,14 @@ class VolumeCroppingTool extends BaseTool { this.configuration.showHandles ) { const corners = [ - [xMin, yMin, zMin], // XMIN_YMIN_ZMIN - [xMin, yMin, zMax], // XMIN_YMIN_ZMAX - [xMin, yMax, zMin], // XMIN_YMAX_ZMIN - [xMin, yMax, zMax], // XMIN_YMAX_ZMAX - [xMax, yMin, zMin], // XMAX_YMIN_ZMIN - [xMax, yMin, zMax], // XMAX_YMIN_ZMAX - [xMax, yMax, zMin], // XMAX_YMAX_ZMIN - [xMax, yMax, zMax], // XMAX_YMAX_ZMAX + [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 = [ @@ -873,7 +890,7 @@ class VolumeCroppingTool extends BaseTool { } viewport.render(); - + //setTimeout(() => viewport.render(), 0); triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { toolCenter: sphereState.point, axis: sphereState.isCorner ? 'corner' : sphereState.axis, From 536b8b68a21a2622253dd6eac2b7f5a87ebfc4e6 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 20 Jul 2025 13:02:47 +0200 Subject: [PATCH 105/132] refactor(VolumeCroppingTool): remove debug logging for tool activation and configuration --- .../src/tools/VolumeCroppingControlTool.ts | 92 +++++++++++-------- .../tools/src/tools/VolumeCroppingTool.ts | 6 +- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f3dbb054cd..00b3895464 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1,38 +1,3 @@ -/** - * Utility function to map a camera normal to an orientation string. - * Returns 'AXIAL', 'CORONAL', 'SAGITTAL', or null if not matched. - */ -function 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; -} 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'; @@ -129,6 +94,42 @@ function defaultReferenceLineControllable() { return true; } +/** + * Utility function to map a camera normal to an orientation string. + * Returns 'AXIAL', 'CORONAL', 'SAGITTAL', or null if not matched. + */ +function 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; +} + const OPERATION = { DRAG: 1, ROTATE: 2, @@ -1187,13 +1188,28 @@ class VolumeCroppingControlTool extends AnnotationTool { } ); } - if (this.configuration.extendReferenceLines) { - const dashLineUID = lineUID + '_dashed'; + if ( + this.configuration.extendReferenceLines && + intersections.length === 2 + ) { drawLineSvg( svgDrawingHelper, annotationUID, - dashLineUID, + lineUID + '_dashed_before', line[1], + intersections[0].point, + { + color, + lineWidth, + lineDash: [4, 4], + } + ); + // Dashed line from second intersection to end + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID + '_dashed_after', + intersections[1].point, line[2], { color, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 4b6bb19a15..5c389c2865 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -283,7 +283,7 @@ class VolumeCroppingTool extends BaseTool { }; onSetToolActive() { - console.debug('Setting tool active: volumeCropping'); + //console.debug('Setting tool active: volumeCropping'); if (this.sphereStates && this.sphereStates.length > 0) { this.setHandlesVisible(!this.configuration.showHandles); @@ -348,7 +348,7 @@ class VolumeCroppingTool extends BaseTool { } onSetToolConfiguration = (): void => { - console.debug('Setting tool settoolconfiguration init: volumeCropping'); + //console.debug('Setting tool settoolconfiguration init: volumeCropping'); //this._init(); }; @@ -357,7 +357,7 @@ class VolumeCroppingTool extends BaseTool { }; onSetToolDisabled() { - console.debug('Setting tool disabled: volumeCropping'); + // console.debug('Setting tool disabled: volumeCropping'); // Disconnect all resize observers this._resizeObservers.forEach((resizeObserver, viewportId) => { resizeObserver.disconnect(); From 7122963b73219bb39c52edd20cdbfb63b5a9ca35 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 20 Jul 2025 13:16:38 +0200 Subject: [PATCH 106/132] feat(VolumeCroppingControlTool): add annotation existence check before subscribing to viewport events --- .../src/tools/VolumeCroppingControlTool.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 00b3895464..6a33cac538 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -378,23 +378,31 @@ class VolumeCroppingControlTool extends AnnotationTool { onSetToolActive() { const viewportsInfo = this._getViewportsInfo(); - // reference points in the new space, so we subscribe to the event - // and update the reference points accordingly. - this._unsubscribeToViewportNewVolumeSet(viewportsInfo); - this._subscribeToViewportNewVolumeSet(viewportsInfo); - - this._computeToolCenter(viewportsInfo); - } + // 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); - onSetToolPassive() { - const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); + this._computeToolCenter(viewportsInfo); + } } onSetToolEnabled() { const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); + //this._computeToolCenter(viewportsInfo); } onSetToolDisabled() { From 2daa7d7effeaf9eb322d1d4ac1fd7e9f2241e7ea Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 08:01:49 +0200 Subject: [PATCH 107/132] fix(VolumeCroppingControlTool): correct start and end points for dashed lines based on intersection order --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 6a33cac538..8b9d53e212 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1200,11 +1200,12 @@ class VolumeCroppingControlTool extends AnnotationTool { this.configuration.extendReferenceLines && intersections.length === 2 ) { + const startPoint = line[1][0] > line[2][0] ? line[2] : line[1]; drawLineSvg( svgDrawingHelper, annotationUID, lineUID + '_dashed_before', - line[1], + startPoint, intersections[0].point, { color, @@ -1213,12 +1214,13 @@ class VolumeCroppingControlTool extends AnnotationTool { } ); // Dashed line from second intersection to end + const endPoint = line[1][0] > line[2][0] ? line[1] : line[2]; drawLineSvg( svgDrawingHelper, annotationUID, lineUID + '_dashed_after', intersections[1].point, - line[2], + endPoint, { color, lineWidth, From 21e9d907ed992acb7a6b05aa239ef22e846739c3 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:01:49 +0200 Subject: [PATCH 108/132] fix(VolumeCroppingControlTool): disable extending reference lines by default --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 8b9d53e212..246da7553f 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -172,7 +172,7 @@ class VolumeCroppingControlTool extends AnnotationTool { x: null, y: null, }, - extendReferenceLines: true, + extendReferenceLines: false, initialCropFactor: 0.2, mobile: { enabled: false, From d0810481bf1094cb2092ec6a526c9260df52b5e0 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:45:41 +0200 Subject: [PATCH 109/132] fix(VolumeCroppingControlTool): enable extending reference lines by default and improve intersection handling for dashed lines --- .../src/tools/VolumeCroppingControlTool.ts | 37 +++++++++++++++---- .../tools/src/tools/VolumeCroppingTool.ts | 2 - 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 246da7553f..c23347909c 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -172,7 +172,7 @@ class VolumeCroppingControlTool extends AnnotationTool { x: null, y: null, }, - extendReferenceLines: false, + extendReferenceLines: true, initialCropFactor: 0.2, mobile: { enabled: false, @@ -1200,13 +1200,24 @@ class VolumeCroppingControlTool extends AnnotationTool { this.configuration.extendReferenceLines && intersections.length === 2 ) { - const startPoint = line[1][0] > line[2][0] ? line[2] : line[1]; + // Calculate distances from line[1] to both intersection points + const dist1 = Math.sqrt( + Math.pow(line[1][0] - intersections[0].point[0], 2) + + Math.pow(line[1][1] - intersections[0].point[1], 2) + ); + const dist2 = Math.sqrt( + Math.pow(line[1][0] - intersections[1].point[0], 2) + + Math.pow(line[1][1] - intersections[1].point[1], 2) + ); + const closestIntersection = + dist1 <= dist2 ? intersections[0].point : intersections[1].point; + drawLineSvg( svgDrawingHelper, annotationUID, lineUID + '_dashed_before', - startPoint, - intersections[0].point, + line[1], + closestIntersection, { color, lineWidth, @@ -1214,13 +1225,25 @@ class VolumeCroppingControlTool extends AnnotationTool { } ); // Dashed line from second intersection to end - const endPoint = line[1][0] > line[2][0] ? line[1] : line[2]; + const SecondDist1 = Math.sqrt( + Math.pow(line[2][0] - intersections[0].point[0], 2) + + Math.pow(line[2][1] - intersections[0].point[1], 2) + ); + const SecondDist2 = Math.sqrt( + Math.pow(line[2][0] - intersections[1].point[0], 2) + + Math.pow(line[2][1] - intersections[1].point[1], 2) + ); + const SecondClosestIntersection = + SecondDist1 <= SecondDist2 + ? intersections[0].point + : intersections[1].point; + drawLineSvg( svgDrawingHelper, annotationUID, lineUID + '_dashed_after', - intersections[1].point, - endPoint, + line[2], + SecondClosestIntersection, { color, lineWidth, diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 5c389c2865..da674bcd5e 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -614,8 +614,6 @@ class VolumeCroppingTool extends BaseTool { this._onControlToolChange(evt); } ); - - const element = viewport.canvas || viewport.element; }; _updateClippingPlanes(viewport) { From 7487051e9f3dd2637250feac97b164f98149df48 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:28:25 +0200 Subject: [PATCH 110/132] fix(VolumeCroppingControlTool): improve dashed line rendering by sorting intersections based on distance --- .../src/tools/VolumeCroppingControlTool.ts | 79 +++++++------------ 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index c23347909c..8a9ee0a15f 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1200,56 +1200,37 @@ class VolumeCroppingControlTool extends AnnotationTool { this.configuration.extendReferenceLines && intersections.length === 2 ) { - // Calculate distances from line[1] to both intersection points - const dist1 = Math.sqrt( - Math.pow(line[1][0] - intersections[0].point[0], 2) + - Math.pow(line[1][1] - intersections[0].point[1], 2) - ); - const dist2 = Math.sqrt( - Math.pow(line[1][0] - intersections[1].point[0], 2) + - Math.pow(line[1][1] - intersections[1].point[1], 2) - ); - const closestIntersection = - dist1 <= dist2 ? intersections[0].point : intersections[1].point; - - drawLineSvg( - svgDrawingHelper, - annotationUID, - lineUID + '_dashed_before', - line[1], - closestIntersection, - { - color, - lineWidth, - lineDash: [4, 4], - } - ); - // Dashed line from second intersection to end - const SecondDist1 = Math.sqrt( - Math.pow(line[2][0] - intersections[0].point[0], 2) + - Math.pow(line[2][1] - intersections[0].point[1], 2) - ); - const SecondDist2 = Math.sqrt( - Math.pow(line[2][0] - intersections[1].point[0], 2) + - Math.pow(line[2][1] - intersections[1].point[1], 2) - ); - const SecondClosestIntersection = - SecondDist1 <= SecondDist2 - ? intersections[0].point - : intersections[1].point; + 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', - line[2], - SecondClosestIntersection, - { - color, - lineWidth, - lineDash: [4, 4], - } - ); + drawLineSvg( + svgDrawingHelper, + annotationUID, + lineUID + '_dashed_after', + sortedIntersections[1].point, + line[2], + { color, lineWidth, lineDash: [4, 4] } + ); + } } } }); From 2877fcc2b1e616d811451999f6bc220a8e546ad5 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:41:23 +0200 Subject: [PATCH 111/132] fix(VolumeCroppingControlTool): enhance sphere movement handling and streamline coordinate updates --- .../tools/src/tools/VolumeCroppingTool.ts | 265 +++++++++++------- 1 file changed, 170 insertions(+), 95 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index da674bcd5e..41505d0084 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -778,124 +778,199 @@ class VolumeCroppingTool extends BaseTool { _onMouseMoveSphere = (evt) => { if (this.draggingSphereIndex === null) { - return; + 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 + if (sphereState.isCorner) { + this._handleCornerSphereMovement(sphereState, world, viewport); + } else { + this._handleFaceSphereMovement(sphereState, world, viewport); } - const element = evt.detail.element || evt.currentTarget; + // Single render and event trigger + viewport.render(); + this._triggerToolChangedEvent(sphereState); + + return true; + }; + + // Helper method to get viewport and world coordinates + _getViewportAndWorldCoords = (evt) => { const [viewport3D] = this._getViewportsInfo(); const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); const viewport = renderingEngine.getViewport(viewport3D.viewportId); - // Get 2D mouse position in canvas coordinates - const rect = element.getBoundingClientRect(); const x = evt.detail.currentPoints.canvas[0]; const y = evt.detail.currentPoints.canvas[1]; - - // Convert canvas to world coordinates const world = viewport.canvasToWorld([x, y]); - const sphereState = this.sphereStates[this.draggingSphereIndex]; - if (!sphereState) { - return; + return { viewport, world }; + }; + + // 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; } - if (sphereState.isCorner) { - // Move the dragged corner sphere - let newCorner: [number, number, number] = [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], - ]; + // 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; } - sphereState.point = newCorner; - sphereState.sphereSource.setCenter(...newCorner); - sphereState.sphereSource.modified(); - - // Determine which axes are min/max for this corner - // Example: XMIN_YMAX_ZMIN => x=min, y=max, z=min - const cornerKey = sphereState.uid.replace('corner_', ''); - const isXMin = cornerKey.includes('XMIN'); - const isXMax = cornerKey.includes('XMAX'); - const isYMin = cornerKey.includes('YMIN'); - const isYMax = cornerKey.includes('YMAX'); - const isZMin = cornerKey.includes('ZMIN'); - const isZMax = cornerKey.includes('ZMAX'); - - // Update all corners that share any min/max coordinate with this corner - this.sphereStates.forEach((state) => { - if (!state.isCorner || state === sphereState) { - return; - } - const key = state.uid.replace('corner_', ''); - if ( - (isXMin && key.includes('XMIN')) || - (isXMax && key.includes('XMAX')) || - (isYMin && key.includes('YMIN')) || - (isYMax && key.includes('YMAX')) || - (isZMin && key.includes('ZMIN')) || - (isZMax && key.includes('ZMAX')) - ) { - // For each axis that matches, update that coordinate - if (isXMin && key.includes('XMIN')) { - state.point[0] = newCorner[0]; - } - if (isXMax && key.includes('XMAX')) { - state.point[0] = newCorner[0]; - } - if (isYMin && key.includes('YMIN')) { - state.point[1] = newCorner[1]; - } - if (isYMax && key.includes('YMAX')) { - state.point[1] = newCorner[1]; - } - if (isZMin && key.includes('ZMIN')) { - state.point[2] = newCorner[2]; - } - if (isZMax && key.includes('ZMAX')) { - state.point[2] = newCorner[2]; - } - state.sphereSource.setCenter(...state.point); - state.sphereSource.modified(); - } - }); - // After updating corners, update face spheres and edge cylinders - this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(); - this._updateClippingPlanesFromFaceSpheres(viewport); - } else { - // For face spheres: only update the coordinate along the face's axis - const axis = sphereState.axis; - const axisIdx = { x: 0, y: 1, z: 2 }[axis]; - let newValue = world[axisIdx]; - if (this.faceDragOffset !== null) { - newValue += this.faceDragOffset; + const key = state.uid.replace('corner_', ''); + const shouldUpdate = this._shouldUpdateCorner(key, axisFlags); + + if (shouldUpdate) { + this._updateCornerCoordinates(state, newCorner, key, axisFlags); } - // Only update the correct axis for the correct sphere - this.sphereStates[this.draggingSphereIndex].point[axisIdx] = newValue; - this.sphereStates[this.draggingSphereIndex].sphereSource.setCenter( - ...this.sphereStates[this.draggingSphereIndex].point - ); - this.sphereStates[this.draggingSphereIndex].sphereSource.modified(); + }); + }; - // After updating the face sphere, update all corners from faces - this._updateCornerSpheresFromFaces(); - this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(); - this._updateClippingPlanesFromFaceSpheres(viewport); + // 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]; } - viewport.render(); - //setTimeout(() => viewport.render(), 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, }); - - return true; }; _updateClippingPlanesFromFaceSpheres(viewport) { From 44ced2244442190aeddd8b0234587a4aa277dcae Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:53:40 +0200 Subject: [PATCH 112/132] Implement review comments regarding naming and position --- .../tools/src/tools/VolumeCroppingTool.ts | 1300 +++++++++-------- 1 file changed, 656 insertions(+), 644 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 41505d0084..fa816b6296 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -35,14 +35,6 @@ import type { SVGDrawingHelper, } from '../types'; -function defaultReferenceLineColor() { - return 'rgb(0, 200, 0)'; -} - -function defaultReferenceLineControllable() { - return true; -} - const PLANEINDEX = { XMIN: 0, XMAX: 1, @@ -84,47 +76,6 @@ class VolumeCroppingTool extends BaseTool { }; } = {}; - // Helper to add a 3D line between two points using vtkActor - 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(true); - viewport.addActor({ actor, uid }); - return { actor, source: polyData }; - } static toolName; touchDragCallback: (evt: EventTypes.InteractionEventType) => void; mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; @@ -179,23 +130,14 @@ class VolumeCroppingTool extends BaseTool { super(toolProps, defaultToolProps); this.touchDragCallback = this._dragCallback.bind(this); this.mouseDragCallback = this._dragCallback.bind(this); - this._getReferenceLineColor = - toolProps.configuration?.getReferenceLineColor || - defaultReferenceLineColor; - this._getReferenceLineControllable = - toolProps.configuration?.getReferenceLineControllable || - defaultReferenceLineControllable; } + // Helper to add a 3D line between two points using vtkActor + setHandlesVisible(visible: boolean) { this.configuration.showHandles = visible; // Before showing, update sphere positions to match clipping planes if (visible) { - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - // Update face spheres from the current clipping planes this.sphereStates[SPHEREINDEX.XMIN].point[0] = this.originalClippingPlanes[PLANEINDEX.XMIN].origin[0]; @@ -261,27 +203,6 @@ class VolumeCroppingTool extends BaseTool { viewport.render(); } - _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); - } - }); - } - - _getViewportsInfo = () => { - const viewports = getToolGroup(this.toolGroupId).viewportsInfo; - return viewports; - }; - onSetToolActive() { //console.debug('Setting tool active: volumeCropping'); @@ -376,131 +297,605 @@ class VolumeCroppingTool extends BaseTool { this._unsubscribeToViewportNewVolumeSet(viewportsInfo); } - addSphere(viewport, point, axis, position, cornerKey = null) { - if (!this.configuration.showHandles) { - return; - } - // 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); - const sphereRadius = - this.configuration.sphereRadius !== undefined - ? this.configuration.sphereRadius - : 15; - sphereSource.setRadius(sphereRadius); - 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 || {}; + onCameraModified = (evt) => { + const { element } = evt.currentTarget + ? { element: evt.currentTarget } + : evt.detail; + const enabledElement = getEnabledElement(element); + this._updateClippingPlanes(enabledElement.viewport); + enabledElement.viewport.render(); + }; - 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.z || [1.0, 0.0, 0.0]; - } else if (axis === 'x') { - color = sphereColors.x || [1.0, 1.0, 0.0]; - } else if (axis === 'y') { - color = sphereColors.y || [0.0, 1.0, 0.0]; - } - // 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; - } + preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + 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(); - // 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; + 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; + } } - sphereActor.getProperty().setColor(color); - sphereActor.setPickable(true); - viewport.addActor({ actor: sphereActor, uid: uid }); - } - _initialize3DViewports = (viewportsInfo): void => { - if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { - console.warn( - 'VolumeCroppingTool: No viewportsInfo available for initialization of volumecroppingtool.' - ); - return; + const hasSampleDistance = + 'getSampleDistance' in mapper || 'getCurrentSampleDistance' in mapper; + + if (!hasSampleDistance) { + return true; } - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - const volumeActors = viewport.getActors(); - if (!volumeActors || volumeActors.length === 0) { - console.warn( - 'VolumeCroppingTool: No volume actors found in the viewport.' + + const originalSampleDistance = mapper.getSampleDistance(); + + if (!this._hasResolutionChanged) { + const { rotateSampleDistanceFactor } = this.configuration; + mapper.setSampleDistance( + originalSampleDistance * rotateSampleDistanceFactor ); - return; - } - const imageData = volumeActors[0].actor.getMapper().getInputData(); - if (!imageData) { - console.warn('VolumeCroppingTool: No image data found for volume actor.'); - return; - } - const dimensions = imageData.getDimensions(); - const origin = imageData.getOrigin(); - const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] - const cropFactor = this.configuration.initialCropFactor || 0.1; - const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; - const xMax = - origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; - const yMin = origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1]; - const yMax = - origin[1] + (1 - cropFactor) * (dimensions[1] - 1) * spacing[1]; - const zMin = origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2]; - const zMax = - origin[2] + (1 - cropFactor) * (dimensions[2] - 1) * spacing[2]; + this._hasResolutionChanged = true; - const planes: vtkPlane[] = []; + if (this.cleanUp !== null) { + // Clean up previous event listener + document.removeEventListener('mouseup', this.cleanUp); + } - // 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], + 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._updateFaceSpheresFromCorners(); + this._updateCornerSpheres(); + 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; + }; + + _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(); + } + + // Render to show sphere updates FIRST + viewport.render(); + + // THEN update clipping planes + this._updateClippingPlanesFromFaceSpheres(viewport); + + // Final render and event trigger + viewport.render(); + this._triggerToolChangedEvent(sphereState); + + return true; + }; + + _onControlToolChange = (evt) => { + const viewportsInfo = this._getViewportsInfo(); + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + + 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(); + + if (this.configuration.showHandles) { + // 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(); + clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + } + } + + if ( + this.configuration.showHandles && + this.configuration.showCornerSpheres + ) { + this._updateCornerSpheres(); + } + 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(true); + viewport.addActor({ actor, uid }); + return { actor, source: polyData }; + } + + _getViewportsInfo = () => { + const viewports = getToolGroup(this.toolGroupId).viewportsInfo; + return viewports; + }; + + _addSphere(viewport, point, axis, position, cornerKey = null) { + if (!this.configuration.showHandles) { + return; + } + // 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); + const sphereRadius = + this.configuration.sphereRadius !== undefined + ? this.configuration.sphereRadius + : 15; + sphereSource.setRadius(sphereRadius); + 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.z || [1.0, 0.0, 0.0]; + } else if (axis === 'x') { + color = sphereColors.x || [1.0, 1.0, 0.0]; + } else if (axis === 'y') { + color = sphereColors.y || [0.0, 1.0, 0.0]; + } + // 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.setPickable(true); + viewport.addActor({ actor: sphereActor, uid: uid }); + } + + _initialize3DViewports = (viewportsInfo): void => { + if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { + console.warn( + 'VolumeCroppingTool: No viewportsInfo available for initialization of volumecroppingtool.' + ); + return; + } + const [viewport3D] = viewportsInfo; + const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); + const viewport = renderingEngine.getViewport(viewport3D.viewportId); + 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; + } + const dimensions = imageData.getDimensions(); + const origin = imageData.getOrigin(); + const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] + const cropFactor = this.configuration.initialCropFactor || 0.1; + const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; + const xMax = + origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; + const yMin = origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1]; + const yMax = + origin[1] + (1 - cropFactor) * (dimensions[1] - 1) * spacing[1]; + const zMin = origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2]; + const zMax = + origin[2] + (1 - cropFactor) * (dimensions[2] - 1) * spacing[2]; + + 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() @@ -525,12 +920,12 @@ class VolumeCroppingTool extends BaseTool { const sphereZminPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMin]; const sphereZmaxPoint = [(xMax + xMin) / 2, (yMax + yMin) / 2, zMax]; - this.addSphere(viewport, sphereXminPoint, 'x', 'min'); - this.addSphere(viewport, sphereXmaxPoint, 'x', 'max'); - this.addSphere(viewport, sphereYminPoint, 'y', 'min'); - this.addSphere(viewport, sphereYmaxPoint, 'y', 'max'); - this.addSphere(viewport, sphereZminPoint, 'z', 'min'); - this.addSphere(viewport, sphereZmaxPoint, 'z', 'max'); + this._addSphere(viewport, sphereXminPoint, 'x', 'min'); + this._addSphere(viewport, sphereXmaxPoint, 'x', 'max'); + this._addSphere(viewport, sphereYminPoint, 'y', 'min'); + this._addSphere(viewport, sphereYmaxPoint, 'y', 'max'); + this._addSphere(viewport, sphereZminPoint, 'z', 'min'); + this._addSphere(viewport, sphereZmaxPoint, 'z', 'max'); if ( this.configuration.showCornerSpheres && this.configuration.showHandles @@ -554,256 +949,66 @@ class VolumeCroppingTool extends BaseTool { '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]); - } - - // 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); - } - ); - }; - - _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); - } - } - - _onControlToolChange = (evt) => { - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - - 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(); - - if (this.configuration.showHandles) { - // 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(); - clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); - } - } - - if ( - this.configuration.showHandles && - this.configuration.showCornerSpheres - ) { - this._updateCornerSpheres(); - } - viewport.render(); - }; - - _onMouseMoveSphere = (evt) => { - if (this.draggingSphereIndex === null) { - return false; - } - - const sphereState = this.sphereStates[this.draggingSphereIndex]; - if (!sphereState) { - return false; - } + 'XMAX_YMAX_ZMAX', + ]; - // Get viewport and world coordinates - const { viewport, world } = this._getViewportAndWorldCoords(evt); - if (!viewport || !world) { - return false; - } + for (let i = 0; i < corners.length; i++) { + this._addSphere(viewport, corners[i], 'corner', null, cornerKeys[i]); + } - // Handle sphere movement based on type - if (sphereState.isCorner) { - this._handleCornerSphereMovement(sphereState, world, viewport); - } else { - this._handleFaceSphereMovement(sphereState, world, viewport); - } + // 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'], + ]; - // Single render and event trigger - viewport.render(); - this._triggerToolChangedEvent(sphereState); + 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); - return true; + eventTarget.addEventListener( + Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, + (evt) => { + this._onControlToolChange(evt); + } + ); }; // Helper method to get viewport and world coordinates @@ -1167,15 +1372,6 @@ class VolumeCroppingTool extends BaseTool { }); } - onCameraModified = (evt) => { - const { element } = evt.currentTarget - ? { element: evt.currentTarget } - : evt.detail; - const enabledElement = getEnabledElement(element); - this._updateClippingPlanes(enabledElement.viewport); - enabledElement.viewport.render(); - }; - _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); this._initialize3DViewports(viewportsInfo); @@ -1211,110 +1407,7 @@ class VolumeCroppingTool extends BaseTool { }); } - preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { - 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._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(); - 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; - }; - - rotateCamera = (viewport, centerWorld, axis, angle) => { + _rotateCamera = (viewport, centerWorld, axis, angle) => { const vtkCamera = viewport.getVtkActiveCamera(); const viewUp = vtkCamera.getViewUp(); const focalPoint = vtkCamera.getFocalPoint(); @@ -1345,87 +1438,6 @@ class VolumeCroppingTool extends BaseTool { focalPoint: newFocalPoint, }); }; - - _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(); - } - } } VolumeCroppingTool.toolName = 'VolumeCropping'; From fed4eba01d2de472da97da028893316ca0e3f0a0 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:05:44 +0200 Subject: [PATCH 113/132] refactor(VolumeCroppingTool): simplify viewport retrieval by introducing a dedicated method --- .../tools/src/tools/VolumeCroppingTool.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index fa816b6296..aaed5aa8e9 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -191,14 +191,7 @@ class VolumeCroppingTool extends BaseTool { setClippingPlanesVisible(visible: boolean) { this.configuration.showClippingPlanes = visible; - // Show/hide actors - // this._updateHandlesVisibility(); - - // Render - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const viewport = this._getViewport(); this._updateClippingPlanes(viewport); viewport.render(); } @@ -550,11 +543,7 @@ class VolumeCroppingTool extends BaseTool { }; _onControlToolChange = (evt) => { - const viewportsInfo = this._getViewportsInfo(); - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - + const viewport = this._getViewport(); const isMin = evt.detail.handleType === 'min'; const toolCenter = isMin ? evt.detail.toolCenterMin @@ -841,9 +830,7 @@ class VolumeCroppingTool extends BaseTool { ); return; } - const [viewport3D] = viewportsInfo; - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); + const viewport = this._getViewport(); const volumeActors = viewport.getActors(); if (!volumeActors || volumeActors.length === 0) { console.warn( @@ -1013,10 +1000,7 @@ class VolumeCroppingTool extends BaseTool { // Helper method to get viewport and world coordinates _getViewportAndWorldCoords = (evt) => { - const [viewport3D] = this._getViewportsInfo(); - const renderingEngine = getRenderingEngine(viewport3D.renderingEngineId); - const viewport = renderingEngine.getViewport(viewport3D.viewportId); - + const viewport = this._getViewport(); const x = evt.detail.currentPoints.canvas[0]; const y = evt.detail.currentPoints.canvas[1]; const world = viewport.canvasToWorld([x, y]); @@ -1024,6 +1008,12 @@ class VolumeCroppingTool extends BaseTool { 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 From f40e3e50980f95a001d1fdf492fae76b3b95b20a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:19:14 +0200 Subject: [PATCH 114/132] refactor(VolumeCroppingTool): clean up imports and reorganize handle visibility methods --- .../examples/volumeCroppingTool/index.ts | 15 +- .../tools/src/tools/VolumeCroppingTool.ts | 139 ++++++++---------- 2 files changed, 64 insertions(+), 90 deletions(-) diff --git a/packages/tools/examples/volumeCroppingTool/index.ts b/packages/tools/examples/volumeCroppingTool/index.ts index 401fc11b13..70de70e81f 100644 --- a/packages/tools/examples/volumeCroppingTool/index.ts +++ b/packages/tools/examples/volumeCroppingTool/index.ts @@ -1,17 +1,10 @@ -import type { - BaseVolumeViewport, - Types, - VolumeViewport3D, -} from '@cornerstonejs/core'; +import type { Types, VolumeViewport3D } from '@cornerstonejs/core'; import { RenderingEngine, Enums, setVolumesForViewports, volumeLoader, - getRenderingEngine, - eventTarget, } from '@cornerstonejs/core'; -import { Enums as toolsEnums } from '@cornerstonejs/tools'; import { initDemo, createImageIdsAndCacheMetaData, @@ -20,7 +13,6 @@ import { addDropdownToToolbar, getLocalUrl, addToggleButtonToToolbar, - addButtonToToolbar, } from '../../../../utils/demo/helpers'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -204,11 +196,6 @@ const viewportReferenceLineControllable = [ viewportId3, ]; -function getReferenceLineControllable(viewportId) { - const index = viewportReferenceLineControllable.indexOf(viewportId); - return index !== -1; -} - /** * Get the number of orthographic viewports from the URL (?numViewports=1|2|3) */ diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index aaed5aa8e9..6fa2f85b27 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -16,24 +16,15 @@ import { getRenderingEngine, getEnabledElementByIds, getEnabledElement, - utilities as csUtils, Enums, triggerEvent, eventTarget, } from '@cornerstonejs/core'; import { getToolGroup } from '../store/ToolGroupManager'; - -import { state } from '../store/state'; import { Events } from '../enums'; -import type { - EventTypes, - PublicToolProps, - ToolProps, - InteractionTypes, - SVGDrawingHelper, -} from '../types'; +import type { EventTypes, PublicToolProps, ToolProps } from '../types'; const PLANEINDEX = { XMIN: 0, @@ -104,7 +95,6 @@ class VolumeCroppingTool extends BaseTool { constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { - supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { showCornerSpheres: true, showHandles: true, @@ -132,73 +122,8 @@ class VolumeCroppingTool extends BaseTool { this.mouseDragCallback = this._dragCallback.bind(this); } - // Helper to add a 3D line between two points using vtkActor - - 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(); - } - - getHandlesVisible() { - return this.configuration.showHandles; - } - - getClippingPlanesVisible() { - return this.configuration.showClippingPlanes; - } - - setClippingPlanesVisible(visible: boolean) { - this.configuration.showClippingPlanes = visible; - const viewport = this._getViewport(); - this._updateClippingPlanes(viewport); - viewport.render(); - } - onSetToolActive() { //console.debug('Setting tool active: volumeCropping'); - if (this.sphereStates && this.sphereStates.length > 0) { this.setHandlesVisible(!this.configuration.showHandles); this.setClippingPlanesVisible(!this.configuration.showClippingPlanes); @@ -402,6 +327,68 @@ class VolumeCroppingTool extends BaseTool { return 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(); + } + + getHandlesVisible() { + return this.configuration.showHandles; + } + + getClippingPlanesVisible() { + return this.configuration.showClippingPlanes; + } + + 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; From e69b7cbd440a0399124fa7ddbc75efdc10acc07d Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:28:42 +0200 Subject: [PATCH 115/132] fix(VolumeCroppingTool): add debug log for cleanup process after mouseup --- packages/tools/src/tools/VolumeCroppingTool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 6fa2f85b27..552eb40b61 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -295,6 +295,7 @@ class VolumeCroppingTool extends BaseTool { } this.cleanUp = () => { + console.debug('Cleaning up after mouseup'); mapper.setSampleDistance(originalSampleDistance); // Reset cursor style From fdb097b8769f64e2781886cfac946dc30bfa7592 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 09:54:14 +0200 Subject: [PATCH 116/132] Fix rendering problem in OHIF when moving the first sphere quickly. --- .../tools/src/tools/VolumeCroppingTool.ts | 72 ++++++++++++------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 552eb40b61..cd71d916ac 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -25,6 +25,7 @@ import { getToolGroup } from '../store/ToolGroupManager'; import { Events } from '../enums'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; +import { viewportFilters } from '../utilities'; const PLANEINDEX = { XMIN: 0, @@ -57,25 +58,20 @@ const SPHEREINDEX = { * VolumeCroppingTool is a tool that provides clipping planes to crop a volume */ class VolumeCroppingTool extends BaseTool { - // Store 2D edge lines between corner spheres - edgeLines: { - [uid: string]: { - actor: vtkActor; - source: vtkPolyData; - key1: string; - key2: string; - }; - } = {}; - static toolName; + touchDragCallback: (evt: EventTypes.InteractionEventType) => void; mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; cleanUp: () => void; _resizeObservers = new Map(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _viewportAddedListener: (evt: any) => void; + _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; @@ -85,12 +81,15 @@ class VolumeCroppingTool extends BaseTool { isCorner: boolean; color: number[]; // [r, g, b] color for the sphere }[] = []; - draggingSphereIndex: number | null = null; - toolCenter: Types.Point3 = [0, 0, 0]; - _getReferenceLineColor?: (viewportId: string) => string; - _getReferenceLineControllable?: (viewportId: string) => boolean; - cornerDragOffset: [number, number, number] | null = null; - faceDragOffset: number | null = null; + // 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 = {}, @@ -187,7 +186,7 @@ class VolumeCroppingTool extends BaseTool { } onSetToolConfiguration = (): void => { - //console.debug('Setting tool settoolconfiguration init: volumeCropping'); + console.debug('Setting tool settoolconfiguration : volumeCropping'); //this._init(); }; @@ -225,6 +224,7 @@ class VolumeCroppingTool extends BaseTool { }; preMouseDownCallback = (evt: EventTypes.InteractionEventType) => { + //console.debug('VolumeCroppingTool.preMouseDownCallback called'); const eventDetail = evt.detail; const { element } = eventDetail; const enabledElement = getEnabledElement(element); @@ -295,7 +295,6 @@ class VolumeCroppingTool extends BaseTool { } this.cleanUp = () => { - console.debug('Cleaning up after mouseup'); mapper.setSampleDistance(originalSampleDistance); // Reset cursor style @@ -309,8 +308,8 @@ class VolumeCroppingTool extends BaseTool { const viewport = renderingEngine.getViewport(viewport3D.viewportId); if (sphereState.isCorner) { - this._updateFaceSpheresFromCorners(); this._updateCornerSpheres(); + this._updateFaceSpheresFromCorners(); this._updateClippingPlanesFromFaceSpheres(viewport); } } @@ -517,19 +516,42 @@ class VolumeCroppingTool extends BaseTool { this._updateCornerSpheres(); } - // Render to show sphere updates FIRST - viewport.render(); + // For OHIF: Force immediate VTK pipeline updates + this.sphereStates.forEach((state) => { + if (state.sphereSource) { + // Force the mapper to update + if (state.sphereActor && state.sphereActor.getMapper()) { + const mapper = state.sphereActor.getMapper(); + mapper.update(); + mapper.modified(); + } + } + }); // THEN update clipping planes this._updateClippingPlanesFromFaceSpheres(viewport); // Final render and event trigger viewport.render(); + this._triggerToolChangedEvent(sphereState); return true; }; + _forceImmediateVTKUpdates(viewport) { + // Force all sphere sources to update their geometry immediately + this.sphereStates.forEach((state) => { + if (state.sphereSource) { + // Force the mapper to update + if (state.sphereActor && state.sphereActor.getMapper()) { + const mapper = state.sphereActor.getMapper(); + mapper.update(); + mapper.modified(); + } + } + }); + } _onControlToolChange = (evt) => { const viewport = this._getViewport(); const isMin = evt.detail.handleType === 'min'; @@ -1142,8 +1164,8 @@ class VolumeCroppingTool extends BaseTool { // Update after face movement _updateAfterFaceMovement = (viewport) => { this._updateCornerSpheresFromFaces(); - this._updateFaceSpheresFromCorners(); - this._updateCornerSpheres(); + // this._updateFaceSpheresFromCorners(); + // this._updateCornerSpheres(); this._updateClippingPlanesFromFaceSpheres(viewport); }; From c91fa6254a77bea1d30ecb051f9cb94d50d79c39 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 12:29:07 +0200 Subject: [PATCH 117/132] feat(VolumeCroppingTool): sync tool centers with clipping planes --- .../src/tools/VolumeCroppingControlTool.ts | 274 +++++++++++++++--- .../tools/src/tools/VolumeCroppingTool.ts | 189 +++++++----- 2 files changed, 354 insertions(+), 109 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 8a9ee0a15f..f2a1d103d7 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -375,7 +375,16 @@ class VolumeCroppingControlTool extends AnnotationTool { 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 @@ -394,18 +403,50 @@ class VolumeCroppingControlTool extends AnnotationTool { 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, + }); + } 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); @@ -472,7 +513,6 @@ class VolumeCroppingControlTool extends AnnotationTool { computeToolCenter = () => { const viewportsInfo = this._getViewportsInfo(); - this._computeToolCenter(viewportsInfo); }; _computeToolCenter = (viewportsInfo): void => { @@ -631,6 +671,167 @@ class VolumeCroppingControlTool extends AnnotationTool { } }; + _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.toUpperCase(); + + // 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]; @@ -1281,38 +1482,43 @@ class VolumeCroppingControlTool extends AnnotationTool { }; _onSphereMoved = (evt) => { - 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 (evt.detail.originalClippingPlanes) { + this._syncWithVolumeCroppingTool(evt.detail.originalClippingPlanes); + } else { + // 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; } - if (idx % 2 === 0) { - newMin[2] = toolCenter[2]; - } else { - newMax[2] = toolCenter[2]; + // 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'); } - this.setToolCenter(newMin, 'min'); - this.setToolCenter(newMax, 'max'); } }; @@ -1515,7 +1721,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const { element } = eventDetail; const enabledElement = getEnabledElement(element); - const { renderingEngine, viewport } = enabledElement; + const { viewport } = enabledElement; if (viewport.type === Enums.ViewportType.VOLUME_3D) { return; } @@ -1532,8 +1738,6 @@ class VolumeCroppingControlTool extends AnnotationTool { } const { handles } = viewportAnnotation.data; - const { currentPoints } = evt.detail; - const canvasCoords = currentPoints.canvas; if (handles.activeOperation === OPERATION.DRAG) { if (handles.activeType === 'min') { diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index cd71d916ac..58a5e9c5a3 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -528,6 +528,8 @@ class VolumeCroppingTool extends BaseTool { } }); + this._forceImmediateVTKUpdates(viewport); + // THEN update clipping planes this._updateClippingPlanesFromFaceSpheres(viewport); @@ -543,100 +545,139 @@ class VolumeCroppingTool extends BaseTool { // Force all sphere sources to update their geometry immediately this.sphereStates.forEach((state) => { if (state.sphereSource) { + // Force the source to update + state.sphereSource.modified(); + state.sphereSource.update(); + // Force the mapper to update if (state.sphereActor && state.sphereActor.getMapper()) { const mapper = state.sphereActor.getMapper(); mapper.update(); mapper.modified(); } + + // Force the actor to update + if (state.sphereActor) { + state.sphereActor.modified(); + } + } + }); + + // Force edge line updates + Object.values(this.edgeLines).forEach(({ source, actor }) => { + if (source) { + const points = source.getPoints(); + if (points) { + points.modified(); + } + source.modified(); + //source.update(); + } + if (actor && actor.getMapper()) { + const mapper = actor.getMapper(); + mapper.update(); + mapper.modified(); + actor.modified(); } }); } + _onControlToolChange = (evt) => { const viewport = this._getViewport(); - 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, - ]; + if (!evt.detail.toolCenter) { + console.debug( + 'VolumeCroppingTool._onControlToolChange: sending orriginal planes for init' + ); - // 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], + triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { + originalClippingPlanes: this.originalClippingPlanes, + viewportId: viewport.id, + renderingEngineId: viewport.renderingEngineId, }); - this.originalClippingPlanes[planeIndices[i]].origin = plane.getOrigin(); - - if (this.configuration.showHandles) { - // 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(); + } else { + 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 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 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(); + + if (this.configuration.showHandles) { + // 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(); - clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + // Update vtk clipping plane origin + const volumeActor = viewport.getDefaultActor()?.actor; + if (volumeActor) { + const mapper = volumeActor.getMapper() as vtkVolumeMapper; + const clippingPlanes = mapper.getClippingPlanes(); + clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + } } - } - if ( - this.configuration.showHandles && - this.configuration.showCornerSpheres - ) { - this._updateCornerSpheres(); + if ( + this.configuration.showHandles && + this.configuration.showCornerSpheres + ) { + this._updateCornerSpheres(); + } + viewport.render(); } - viewport.render(); }; - _updateClippingPlanes(viewport) { // Get the actor and transformation matrix const actorEntry = viewport.getDefaultActor(); From 7f35faaf3ce903f46ff77bfbf5d29ec7d21ef9da Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:31:35 +0200 Subject: [PATCH 118/132] refactor(VolumeCroppingTool): enhance documentation and restructure orientation utility function --- .../src/tools/VolumeCroppingControlTool.ts | 164 +++++++++++++----- .../tools/src/tools/VolumeCroppingTool.ts | 122 ++++++++++++- 2 files changed, 232 insertions(+), 54 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f2a1d103d7..f56f66c2f7 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -94,42 +94,6 @@ function defaultReferenceLineControllable() { return true; } -/** - * Utility function to map a camera normal to an orientation string. - * Returns 'AXIAL', 'CORONAL', 'SAGITTAL', or null if not matched. - */ -function 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; -} - const OPERATION = { DRAG: 1, ROTATE: 2, @@ -137,10 +101,84 @@ const OPERATION = { }; /** - * VolumeCroppingControlTool is a tool that provides reference lines to modify the cropping planes - * of the VolumeCroppingTool. It has no use on it's own, and is used in conjunction with - * the VolumeCroppingTool to allow for more precise adjustments to the cropping planes. + * VolumeCroppingControlTool is a tool that provides interactive reference lines to modify the cropping planes + * of the VolumeCroppingTool. It renders crosshair-style reference lines across multiple 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 the VolumeCroppingTool + * to provide precise, visual adjustments to volume cropping planes. It automatically synchronizes with + * the main cropping tool and updates clipping planes based on user interactions. + * + * @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 {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) @@ -326,7 +364,7 @@ class VolumeCroppingControlTool extends AnnotationTool { } // Determine orientation from camera normal, fallback to viewportId string - const orientation = getOrientationFromNormal( + const orientation = this._getOrientationFromNormal( viewport.getCamera().viewPlaneNormal ); @@ -531,7 +569,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const renderingEngine = getRenderingEngine(vp.renderingEngineId); const viewport = renderingEngine.getViewport(vp.viewportId); if (viewport && viewport.getCamera) { - const orientation = getOrientationFromNormal( + const orientation = this._getOrientationFromNormal( viewport.getCamera().viewPlaneNormal ); if (orientation) { @@ -558,7 +596,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const renderingEngine = getRenderingEngine(vp.renderingEngineId); const viewport = renderingEngine.getViewport(vp.viewportId); if (viewport && viewport.getCamera) { - orientation = getOrientationFromNormal( + orientation = this._getOrientationFromNormal( viewport.getCamera().viewPlaneNormal ); } @@ -615,7 +653,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const renderingEngine = getRenderingEngine(vpInfo.renderingEngineId); const viewport = renderingEngine.getViewport(vpInfo.viewportId); if (viewport && viewport.getCamera) { - presentOrientation = getOrientationFromNormal( + presentOrientation = this._getOrientationFromNormal( viewport.getCamera().viewPlaneNormal ); } @@ -670,7 +708,41 @@ class VolumeCroppingControlTool extends AnnotationTool { ); } }; - + /** + * 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; @@ -706,7 +778,7 @@ class VolumeCroppingControlTool extends AnnotationTool { annotation.data.handles && annotation.data.orientation ) { - const orientation = annotation.data.orientation.toUpperCase(); + const orientation = annotation.data.orientation; // Update tool centers based on the specific orientation if (orientation === 'AXIAL') { @@ -1020,7 +1092,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // Use orientation property for matching let orientation = null; if (enabledElement.viewport && enabledElement.viewport.getCamera) { - orientation = getOrientationFromNormal( + orientation = this._getOrientationFromNormal( enabledElement.viewport.getCamera().viewPlaneNormal ); } diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 58a5e9c5a3..208692293f 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -55,7 +55,120 @@ const SPHEREINDEX = { }; /** - * VolumeCroppingTool is a tool that provides clipping planes to crop a volume + * VolumeCroppingTool is a comprehensive 3D volume cropping tool that provides interactive clipping planes, + * 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 for cross-viewport interaction + * + * The tool automatically handles volume actor detection, clipping plane management, and provides + * smooth interaction through optimized rendering and event handling. + * + * @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: { + * x: [1.0, 1.0, 0.0], // Yellow for X-axis spheres + * y: [0.0, 1.0, 0.0], // Green for Y-axis spheres + * z: [1.0, 0.0, 0.0], // Red for 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); + * ``` + * + * @public + * @class VolumeCroppingTool + * @extends BaseTool + * + * @property {string} toolName - Static tool identifier: 'VolumeCropping' + * @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.x - RGB color for X-axis face spheres [r, g, b] (default: [1.0, 1.0, 0.0]) + * @property {number[]} sphereColors.y - RGB color for Y-axis face spheres [r, g, b] (default: [0.0, 1.0, 0.0]) + * @property {number[]} sphereColors.z - RGB color for 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; @@ -135,13 +248,10 @@ class VolumeCroppingTool extends BaseTool { viewportId, renderingEngineId ) || { viewport: null }; - if (!viewport) { return; } - const { element } = viewport; - const resizeObserver = new ResizeObserver(() => { const element = getEnabledElementByIds( viewportId, @@ -151,15 +261,11 @@ class VolumeCroppingTool extends BaseTool { return; } const { viewport } = element; - const viewPresentation = viewport.getViewPresentation(); - viewport.resetCamera(); - viewport.setViewPresentation(viewPresentation); viewport.render(); }); - resizeObserver.observe(element); this._resizeObservers.set(viewportId, resizeObserver); } From 072c6d738edb83b833c2a65a698717345ff4f73c Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:41:52 +0200 Subject: [PATCH 119/132] feat(VolumeCroppingTool): implement adaptive sphere radius calculation for improved cropping --- .../tools/src/tools/VolumeCroppingTool.ts | 81 +++++++++++++++---- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 208692293f..16387f6d45 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -930,11 +930,13 @@ class VolumeCroppingTool extends BaseTool { } const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); - const sphereRadius = - this.configuration.sphereRadius !== undefined - ? this.configuration.sphereRadius - : 15; - sphereSource.setRadius(sphereRadius); + // const sphereRadius = + // this.configuration.sphereRadius !== undefined + // ? this.configuration.sphereRadius + // : 15; + // sphereSource.setRadius(sphereRadius); + const adaptiveRadius = this._calculateAdaptiveSphereRadius(); + sphereSource.setRadius(adaptiveRadius); const sphereMapper = vtkMapper.newInstance(); sphereMapper.setInputConnection(sphereSource.getOutputPort()); const sphereActor = vtkActor.newInstance(); @@ -979,7 +981,47 @@ class VolumeCroppingTool extends BaseTool { sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); } + _calculateAdaptiveSphereRadius(): number { + // Get base radius from configuration (acts as a scaling factor) + const baseRadius = + this.configuration.sphereRadius !== undefined + ? this.configuration.sphereRadius + : 8; + + // Get volume bounds to calculate scale + const viewport = this._getViewport(); + const volumeActors = viewport.getActors(); + + if (!volumeActors || volumeActors.length === 0) { + return baseRadius; // Fallback to base radius + } + + const imageData = volumeActors[0].actor.getMapper().getInputData(); + if (!imageData) { + return baseRadius; // Fallback to base radius + } + + // Get world bounds + const bounds = imageData.getBounds(); // [xmin, xmax, ymin, ymax, zmin, zmax] + + // Calculate volume diagonal length + const xRange = bounds[1] - bounds[0]; + const yRange = bounds[3] - bounds[2]; + const zRange = bounds[5] - bounds[4]; + const diagonal = Math.sqrt( + xRange * xRange + yRange * yRange + zRange * zRange + ); + + // 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( @@ -1000,19 +1042,24 @@ class VolumeCroppingTool extends BaseTool { console.warn('VolumeCroppingTool: No image data found for volume actor.'); return; } - const dimensions = imageData.getDimensions(); - const origin = imageData.getOrigin(); - const spacing = imageData.getSpacing(); // [xSpacing, ySpacing, zSpacing] + + const worldBounds = imageData.getBounds(); // Already in world coordinates const cropFactor = this.configuration.initialCropFactor || 0.1; - const xMin = origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0]; - const xMax = - origin[0] + (1 - cropFactor) * (dimensions[0] - 1) * spacing[0]; - const yMin = origin[1] + cropFactor * (dimensions[1] - 1) * spacing[1]; - const yMax = - origin[1] + (1 - cropFactor) * (dimensions[1] - 1) * spacing[1]; - const zMin = origin[2] + cropFactor * (dimensions[2] - 1) * spacing[2]; - const zMax = - origin[2] + (1 - cropFactor) * (dimensions[2] - 1) * spacing[2]; + + // 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 adaptiveRadius = + Math.sqrt(xRange * xRange + yRange * yRange + zRange * zRange) * 0.001; + + 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[] = []; From 56c11a0fa7529146219d196255f47a9fe8301771 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Tue, 22 Jul 2025 17:06:24 +0200 Subject: [PATCH 120/132] feat(VolumeCroppingTool): implement adaptive sphere radius calculation for spheres based on volume diagonal --- .../tools/src/tools/VolumeCroppingTool.ts | 119 +++++++++++------- 1 file changed, 75 insertions(+), 44 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 16387f6d45..82cf434308 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -25,7 +25,6 @@ import { getToolGroup } from '../store/ToolGroupManager'; import { Events } from '../enums'; import type { EventTypes, PublicToolProps, ToolProps } from '../types'; -import { viewportFilters } from '../utilities'; const PLANEINDEX = { XMIN: 0, @@ -917,7 +916,14 @@ class VolumeCroppingTool extends BaseTool { return viewports; }; - _addSphere(viewport, point, axis, position, cornerKey = null) { + _addSphere( + viewport, + point, + axis, + position, + cornerKey = null, + adaptiveRadius + ) { if (!this.configuration.showHandles) { return; } @@ -930,12 +936,6 @@ class VolumeCroppingTool extends BaseTool { } const sphereSource = vtkSphereSource.newInstance(); sphereSource.setCenter(point); - // const sphereRadius = - // this.configuration.sphereRadius !== undefined - // ? this.configuration.sphereRadius - // : 15; - // sphereSource.setRadius(sphereRadius); - const adaptiveRadius = this._calculateAdaptiveSphereRadius(); sphereSource.setRadius(adaptiveRadius); const sphereMapper = vtkMapper.newInstance(); sphereMapper.setInputConnection(sphereSource.getOutputPort()); @@ -981,37 +981,19 @@ class VolumeCroppingTool extends BaseTool { sphereActor.setPickable(true); viewport.addActor({ actor: sphereActor, uid: uid }); } - _calculateAdaptiveSphereRadius(): number { + /** + * 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; - // Get volume bounds to calculate scale - const viewport = this._getViewport(); - const volumeActors = viewport.getActors(); - - if (!volumeActors || volumeActors.length === 0) { - return baseRadius; // Fallback to base radius - } - - const imageData = volumeActors[0].actor.getMapper().getInputData(); - if (!imageData) { - return baseRadius; // Fallback to base radius - } - - // Get world bounds - const bounds = imageData.getBounds(); // [xmin, xmax, ymin, ymax, zmin, zmax] - - // Calculate volume diagonal length - const xRange = bounds[1] - bounds[0]; - const yRange = bounds[3] - bounds[2]; - const zRange = bounds[5] - bounds[4]; - const diagonal = Math.sqrt( - xRange * xRange + yRange * yRange + zRange * zRange - ); - // 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; @@ -1022,6 +1004,7 @@ class VolumeCroppingTool extends BaseTool { return Math.max(minRadius, Math.min(maxRadius, adaptiveRadius)); } + _initialize3DViewports = (viewportsInfo): void => { if (!viewportsInfo || !viewportsInfo.length || !viewportsInfo[0]) { console.warn( @@ -1051,9 +1034,6 @@ class VolumeCroppingTool extends BaseTool { const yRange = worldBounds[3] - worldBounds[2]; const zRange = worldBounds[5] - worldBounds[4]; - const adaptiveRadius = - Math.sqrt(xRange * xRange + yRange * yRange + zRange * zRange) * 0.001; - const xMin = worldBounds[0] + cropFactor * xRange; const xMax = worldBounds[1] - cropFactor * xRange; const yMin = worldBounds[2] + cropFactor * yRange; @@ -1110,13 +1090,57 @@ class VolumeCroppingTool extends BaseTool { 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]; - - this._addSphere(viewport, sphereXminPoint, 'x', 'min'); - this._addSphere(viewport, sphereXmaxPoint, 'x', 'max'); - this._addSphere(viewport, sphereYminPoint, 'y', 'min'); - this._addSphere(viewport, sphereYmaxPoint, 'y', 'max'); - this._addSphere(viewport, sphereZminPoint, 'z', 'min'); - this._addSphere(viewport, sphereZmaxPoint, 'z', 'max'); + 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 + ); if ( this.configuration.showCornerSpheres && this.configuration.showHandles @@ -1144,7 +1168,14 @@ class VolumeCroppingTool extends BaseTool { ]; for (let i = 0; i < corners.length; i++) { - this._addSphere(viewport, corners[i], 'corner', null, cornerKeys[i]); + this._addSphere( + viewport, + corners[i], + 'corner', + null, + cornerKeys[i], + adaptiveRadius + ); } // draw the lines between corners From bb01e746449666836c75d5b99d71c0845cfc6bee Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:30:33 +0200 Subject: [PATCH 121/132] feat(VolumeCroppingTool): initialize state variables for new volume processing --- packages/tools/src/tools/VolumeCroppingTool.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 82cf434308..8da3a00132 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -1599,6 +1599,9 @@ class VolumeCroppingTool extends BaseTool { _onNewVolume = () => { const viewportsInfo = this._getViewportsInfo(); + this.originalClippingPlanes = []; + this.sphereStates = []; + this.edgeLines = {}; this._initialize3DViewports(viewportsInfo); }; From dbeb78eff6a534fd56d02487e3e6de632c473cfc Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:06:43 +0200 Subject: [PATCH 122/132] feat(VolumeCroppingControlTool): trigger event on tool state change --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index f56f66c2f7..964af4cc92 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -1617,6 +1617,10 @@ class VolumeCroppingControlTool extends AnnotationTool { } } this._computeToolCenter(viewportsInfo); + triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { + toolGroupId: this.toolGroupId, + viewportsInfo: viewportsInfo, + }); }; _unsubscribeToViewportNewVolumeSet(viewportsInfo) { From 0d47eb34d52aac051129421c236f0c2ce1a77596 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Thu, 24 Jul 2025 12:46:09 +0200 Subject: [PATCH 123/132] Add frameOfReference to tool events so that many tool instances can run on different series. --- .../tools/src/tools/VolumeCroppingControlTool.ts | 13 ++++++++++++- packages/tools/src/tools/VolumeCroppingTool.ts | 15 +++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 964af4cc92..ab36eb2637 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -137,6 +137,7 @@ const OPERATION = { * @class VolumeCroppingControlTool * @extends AnnotationTool * + * @property {string} frameOfReference - 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 @@ -169,7 +170,7 @@ const OPERATION = { * @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 @@ -184,6 +185,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // Store virtual annotations (e.g., for missing orientations like CT_CORONAL) _virtualAnnotations: VolumeCroppingAnnotation[] = []; static toolName; + frameOfReference?: string; sphereStates: { point: Types.Point3; axis: string; @@ -263,6 +265,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); + this.frameOfReference = imageData.frameOfReference || 'unknown'; const cropFactor = this.configuration.initialCropFactor ?? 0.2; this.toolCenter = [ origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0], @@ -293,6 +296,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (!imageData) { return; } + this.frameOfReference = imageData.frameOfReference || 'unknown'; const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); @@ -446,6 +450,7 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, viewportsInfo: viewportsInfo, + frameOfReference: this.frameOfReference, }); } else { // Turn off visibility of existing annotations @@ -1557,6 +1562,9 @@ class VolumeCroppingControlTool extends AnnotationTool { if (evt.detail.originalClippingPlanes) { this._syncWithVolumeCroppingTool(evt.detail.originalClippingPlanes); } else { + if (evt.detail.frameOfReference !== this.frameOfReference) { + return; + } // This is called when a sphere is moved const { draggingSphereIndex, toolCenter } = evt.detail; const newMin: [number, number, number] = [...this.toolCenterMin]; @@ -1604,6 +1612,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (volumeActors.length > 0) { const imageData = volumeActors[0].actor.getMapper().getInputData(); if (imageData) { + this.frameOfReference = imageData.frameOfReference; this._updateToolCentersFromViewport(viewport); // Update all annotations' handles.toolCenter const annotations = @@ -1620,6 +1629,7 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, viewportsInfo: viewportsInfo, + frameOfReference: this.frameOfReference, }); }; @@ -1840,6 +1850,7 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenterMax: this.toolCenterMax, handleType: handles.activeType, viewportOrientation: [], + frameOfReference: this.frameOfReference, }); } }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 8da3a00132..1e8d11ec64 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -105,6 +105,7 @@ const SPHEREINDEX = { * @extends BaseTool * * @property {string} toolName - Static tool identifier: 'VolumeCropping' + * @property {string} frameOfReference - 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 @@ -171,7 +172,7 @@ const SPHEREINDEX = { */ class VolumeCroppingTool extends BaseTool { static toolName; - + frameOfReference?: string; touchDragCallback: (evt: EventTypes.InteractionEventType) => void; mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; cleanUp: () => void; @@ -690,16 +691,16 @@ class VolumeCroppingTool extends BaseTool { _onControlToolChange = (evt) => { const viewport = this._getViewport(); if (!evt.detail.toolCenter) { - console.debug( - 'VolumeCroppingTool._onControlToolChange: sending orriginal planes for init' - ); - triggerEvent(eventTarget, Events.VOLUMECROPPING_TOOL_CHANGED, { originalClippingPlanes: this.originalClippingPlanes, viewportId: viewport.id, renderingEngineId: viewport.renderingEngineId, + frameOfReference: this.frameOfReference, }); } else { + if (evt.detail.frameOfReference !== this.frameOfReference) { + return; + } const isMin = evt.detail.handleType === 'min'; const toolCenter = isMin ? evt.detail.toolCenterMin @@ -1025,7 +1026,7 @@ class VolumeCroppingTool extends BaseTool { console.warn('VolumeCroppingTool: No image data found for volume actor.'); return; } - + this.frameOfReference = imageData.frameOfReference || 'unknown'; const worldBounds = imageData.getBounds(); // Already in world coordinates const cropFactor = this.configuration.initialCropFactor || 0.1; @@ -1231,6 +1232,7 @@ class VolumeCroppingTool extends BaseTool { this._onControlToolChange(evt); } ); + viewport.render(); }; // Helper method to get viewport and world coordinates @@ -1400,6 +1402,7 @@ class VolumeCroppingTool extends BaseTool { toolCenter: sphereState.point, axis: sphereState.isCorner ? 'corner' : sphereState.axis, draggingSphereIndex: this.draggingSphereIndex, + frameOfReference: this.frameOfReference, }); }; From a5f5aaefd9326ba1313a6438b8f826a84c659304 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 07:56:45 +0200 Subject: [PATCH 124/132] feat(VolumeCroppingTool): use seriesInstanceUID instead of frameOfReferenceUID to identify events. --- .../src/tools/VolumeCroppingControlTool.ts | 22 +++++++++---------- .../tools/src/tools/VolumeCroppingTool.ts | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index ab36eb2637..83b64c5987 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -137,7 +137,7 @@ const OPERATION = { * @class VolumeCroppingControlTool * @extends AnnotationTool * - * @property {string} frameOfReference - Frame of reference for the tool + * @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 @@ -185,7 +185,7 @@ class VolumeCroppingControlTool extends AnnotationTool { // Store virtual annotations (e.g., for missing orientations like CT_CORONAL) _virtualAnnotations: VolumeCroppingAnnotation[] = []; static toolName; - frameOfReference?: string; + seriesInstanceUID?: string; sphereStates: { point: Types.Point3; axis: string; @@ -265,7 +265,7 @@ class VolumeCroppingControlTool extends AnnotationTool { const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); - this.frameOfReference = imageData.frameOfReference || 'unknown'; + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; const cropFactor = this.configuration.initialCropFactor ?? 0.2; this.toolCenter = [ origin[0] + cropFactor * (dimensions[0] - 1) * spacing[0], @@ -296,7 +296,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (!imageData) { return; } - this.frameOfReference = imageData.frameOfReference || 'unknown'; + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; const dimensions = imageData.getDimensions(); const spacing = imageData.getSpacing(); const origin = imageData.getOrigin(); @@ -350,7 +350,7 @@ class VolumeCroppingControlTool extends AnnotationTool { return; } - const { FrameOfReferenceUID, viewport } = enabledElement; + const { seriesInstanceUID, viewport } = enabledElement; this._updateToolCentersFromViewport(viewport); const { element } = viewport; const { position, focalPoint, viewPlaneNormal } = viewport.getCamera(); @@ -377,7 +377,7 @@ class VolumeCroppingControlTool extends AnnotationTool { metadata: { cameraPosition: [...position], cameraFocalPoint: [...focalPoint], - FrameOfReferenceUID, + seriesInstanceUID, toolName: this.getToolName(), }, data: { @@ -450,7 +450,7 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, viewportsInfo: viewportsInfo, - frameOfReference: this.frameOfReference, + seriesInstanceUID: this.seriesInstanceUID, }); } else { // Turn off visibility of existing annotations @@ -1562,7 +1562,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (evt.detail.originalClippingPlanes) { this._syncWithVolumeCroppingTool(evt.detail.originalClippingPlanes); } else { - if (evt.detail.frameOfReference !== this.frameOfReference) { + if (evt.detail.seriesInstanceUID !== this.seriesInstanceUID) { return; } // This is called when a sphere is moved @@ -1612,7 +1612,7 @@ class VolumeCroppingControlTool extends AnnotationTool { if (volumeActors.length > 0) { const imageData = volumeActors[0].actor.getMapper().getInputData(); if (imageData) { - this.frameOfReference = imageData.frameOfReference; + this.seriesInstanceUID = imageData.seriesInstanceUID; this._updateToolCentersFromViewport(viewport); // Update all annotations' handles.toolCenter const annotations = @@ -1629,7 +1629,7 @@ class VolumeCroppingControlTool extends AnnotationTool { triggerEvent(eventTarget, Events.VOLUMECROPPINGCONTROL_TOOL_CHANGED, { toolGroupId: this.toolGroupId, viewportsInfo: viewportsInfo, - frameOfReference: this.frameOfReference, + seriesInstanceUID: this.seriesInstanceUID, }); }; @@ -1850,7 +1850,7 @@ class VolumeCroppingControlTool extends AnnotationTool { toolCenterMax: this.toolCenterMax, handleType: handles.activeType, viewportOrientation: [], - frameOfReference: this.frameOfReference, + seriesInstanceUID: this.seriesInstanceUID, }); } }; diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 1e8d11ec64..0852ccff47 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -105,7 +105,7 @@ const SPHEREINDEX = { * @extends BaseTool * * @property {string} toolName - Static tool identifier: 'VolumeCropping' - * @property {string} frameOfReference - Frame of reference for the tool + * @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 @@ -172,7 +172,7 @@ const SPHEREINDEX = { */ class VolumeCroppingTool extends BaseTool { static toolName; - frameOfReference?: string; + seriesInstanceUID?: string; touchDragCallback: (evt: EventTypes.InteractionEventType) => void; mouseDragCallback: (evt: EventTypes.InteractionEventType) => void; cleanUp: () => void; @@ -695,10 +695,10 @@ class VolumeCroppingTool extends BaseTool { originalClippingPlanes: this.originalClippingPlanes, viewportId: viewport.id, renderingEngineId: viewport.renderingEngineId, - frameOfReference: this.frameOfReference, + seriesInstanceUID: this.seriesInstanceUID, }); } else { - if (evt.detail.frameOfReference !== this.frameOfReference) { + if (evt.detail.seriesInstanceUID !== this.seriesInstanceUID) { return; } const isMin = evt.detail.handleType === 'min'; @@ -1026,7 +1026,7 @@ class VolumeCroppingTool extends BaseTool { console.warn('VolumeCroppingTool: No image data found for volume actor.'); return; } - this.frameOfReference = imageData.frameOfReference || 'unknown'; + this.seriesInstanceUID = imageData.seriesInstanceUID || 'unknown'; const worldBounds = imageData.getBounds(); // Already in world coordinates const cropFactor = this.configuration.initialCropFactor || 0.1; @@ -1402,7 +1402,7 @@ class VolumeCroppingTool extends BaseTool { toolCenter: sphereState.point, axis: sphereState.isCorner ? 'corner' : sphereState.axis, draggingSphereIndex: this.draggingSphereIndex, - frameOfReference: this.frameOfReference, + seriesInstanceUID: this.seriesInstanceUID, }); }; From 7ca49ce3fd6c081b1eb8b773d3739062cac6cc0a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 08:40:17 +0200 Subject: [PATCH 125/132] feat(VolumeCroppingTool): enhance documentation for VolumeCroppingTool and VolumeCroppingControlTool interactions --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 11 ++++++----- packages/tools/src/tools/VolumeCroppingTool.ts | 10 +++------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 83b64c5987..a06330aa60 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -101,14 +101,15 @@ const OPERATION = { }; /** - * VolumeCroppingControlTool is a tool that provides interactive reference lines to modify the cropping planes - * of the VolumeCroppingTool. It renders crosshair-style reference lines across multiple viewports and allows + * 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 the VolumeCroppingTool - * to provide precise, visual adjustments to volume cropping planes. It automatically synchronizes with - * the main cropping tool and updates clipping planes based on user interactions. + * 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 diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 0852ccff47..d607c83502 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -54,10 +54,8 @@ const SPHEREINDEX = { }; /** - * VolumeCroppingTool is a comprehensive 3D volume cropping tool that provides interactive clipping planes, - * 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. + * 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: @@ -65,10 +63,8 @@ const SPHEREINDEX = { * - 8 corner spheres for multi-axis cropping * - 12 edge lines connecting corner spheres * - Real-time clipping plane updates - * - Synchronization with VolumeCroppingControlTool for cross-viewport interaction + * - Synchronization with VolumeCroppingControlTool working on the same series instance UID for cross-viewport interaction * - * The tool automatically handles volume actor detection, clipping plane management, and provides - * smooth interaction through optimized rendering and event handling. * * @example * ```typescript From bb61f8ca48c51a4ad99e2fc0b3e96789f08f010d Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:19:27 +0200 Subject: [PATCH 126/132] feat(VolumeCroppingTool): update sphere color properties and add visibility control methods for cropping handles and planes --- .../src/tools/VolumeCroppingControlTool.ts | 21 +-- .../tools/src/tools/VolumeCroppingTool.ts | 144 ++++++++++++++++-- 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index a06330aa60..349a9c2ca5 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -402,17 +402,6 @@ class VolumeCroppingControlTool extends AnnotationTool { }; }; - initializeReferenceLines(enabledElement) { - const annotations = this._getAnnotations(enabledElement); - if (!annotations.length) { - // Optionally, create a default annotation here - return; - } - // Force a render to compute reference lines - triggerAnnotationRenderForViewportIds( - this._getViewportsInfo().map(({ viewportId }) => viewportId) - ); - } _getViewportsInfo = () => { const viewports = getToolGroup(this.toolGroupId).viewportsInfo; return viewports; @@ -425,9 +414,9 @@ class VolumeCroppingControlTool extends AnnotationTool { } onSetToolActive() { - console.debug( - `VolumeCroppingControlTool: onSetToolActive called for tool ${this.getToolName()}` - ); + // console.debug( + // `VolumeCroppingControlTool: onSetToolActive called for tool ${this.getToolName()}` + // ); const viewportsInfo = this._getViewportsInfo(); // Check if any annotation exists before proceeding @@ -518,7 +507,7 @@ class VolumeCroppingControlTool extends AnnotationTool { }); } - resetCrosshairs = () => { + resetCroppingSpheres = () => { const viewportsInfo = this._getViewportsInfo(); for (const viewportInfo of viewportsInfo) { const { viewportId, renderingEngineId } = viewportInfo; @@ -1041,7 +1030,7 @@ class VolumeCroppingControlTool extends AnnotationTool { } onResetCamera = (evt) => { - this.resetCrosshairs(); + this.resetCroppingSpheres(); }; mouseMoveCallback = ( diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index d607c83502..26b26de93c 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -78,10 +78,10 @@ const SPHEREINDEX = { * showHandles: true, * initialCropFactor: 0.1, * sphereColors: { - * x: [1.0, 1.0, 0.0], // Yellow for X-axis spheres - * y: [0.0, 1.0, 0.0], // Green for Y-axis spheres - * z: [1.0, 0.0, 0.0], // Red for Z-axis spheres - * corners: [0.0, 0.0, 1.0] // Blue for corner spheres + * 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 @@ -94,6 +94,30 @@ const SPHEREINDEX = { * 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 @@ -140,10 +164,10 @@ const SPHEREINDEX = { * @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.x - RGB color for X-axis face spheres [r, g, b] (default: [1.0, 1.0, 0.0]) - * @property {number[]} sphereColors.y - RGB color for Y-axis face spheres [r, g, b] (default: [0.0, 1.0, 0.0]) - * @property {number[]} sphereColors.z - RGB color for 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[]} 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) @@ -213,10 +237,10 @@ class VolumeCroppingTool extends BaseTool { }, initialCropFactor: 0.08, sphereColors: { - x: [1.0, 1.0, 0.0], // Yellow for X - y: [0.0, 1.0, 0.0], // Green for Y - z: [1.0, 0.0, 0.0], // Red for Z - corners: [0.0, 0.0, 1.0], // Blue for corners + 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 @@ -429,6 +453,25 @@ class VolumeCroppingTool extends BaseTool { 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 @@ -476,14 +519,83 @@ class VolumeCroppingTool extends BaseTool { 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(); @@ -942,13 +1054,13 @@ class VolumeCroppingTool extends BaseTool { const sphereColors = this.configuration.sphereColors || {}; if (cornerKey) { - color = sphereColors.corners || [0.0, 0.0, 1.0]; // Use corners color from config, fallback to blue + color = sphereColors.CORNERS || [0.0, 0.0, 1.0]; // Use corners color from config, fallback to blue } else if (axis === 'z') { - color = sphereColors.z || [1.0, 0.0, 0.0]; + color = sphereColors.AXIAL || [1.0, 0.0, 0.0]; // Z-axis = AXIAL planes } else if (axis === 'x') { - color = sphereColors.x || [1.0, 1.0, 0.0]; + color = sphereColors.SAGITTAL || [1.0, 1.0, 0.0]; // X-axis = SAGITTAL planes } else if (axis === 'y') { - color = sphereColors.y || [0.0, 1.0, 0.0]; + 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); From e51df99e1cd39f459ab01b449fab9911ed8fc37f Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:25:18 +0200 Subject: [PATCH 127/132] fix(VolumeCroppingControlTool): remove unused seriesInstanceUID from metadata --- packages/tools/src/tools/VolumeCroppingControlTool.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingControlTool.ts b/packages/tools/src/tools/VolumeCroppingControlTool.ts index 349a9c2ca5..379c8818b0 100644 --- a/packages/tools/src/tools/VolumeCroppingControlTool.ts +++ b/packages/tools/src/tools/VolumeCroppingControlTool.ts @@ -351,7 +351,7 @@ class VolumeCroppingControlTool extends AnnotationTool { return; } - const { seriesInstanceUID, viewport } = enabledElement; + const { viewport } = enabledElement; this._updateToolCentersFromViewport(viewport); const { element } = viewport; const { position, focalPoint, viewPlaneNormal } = viewport.getCamera(); @@ -378,7 +378,6 @@ class VolumeCroppingControlTool extends AnnotationTool { metadata: { cameraPosition: [...position], cameraFocalPoint: [...focalPoint], - seriesInstanceUID, toolName: this.getToolName(), }, data: { From 481737051c46ae4784329696d5b433d0223f8b97 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:57:57 +0200 Subject: [PATCH 128/132] feat(VolumeCroppingTool): improve handle visibility logic when changing volume --- .../tools/src/tools/VolumeCroppingTool.ts | 218 +++++++++--------- 1 file changed, 107 insertions(+), 111 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 26b26de93c..4bdc5bf65f 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -255,10 +255,17 @@ class VolumeCroppingTool extends BaseTool { } onSetToolActive() { - //console.debug('Setting tool active: volumeCropping'); + // console.debug('Setting tool active: volumeCropping', this.sphereStates); if (this.sphereStates && this.sphereStates.length > 0) { - this.setHandlesVisible(!this.configuration.showHandles); - this.setClippingPlanesVisible(!this.configuration.showClippingPlanes); + 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 = () => { @@ -308,6 +315,14 @@ class VolumeCroppingTool extends BaseTool { 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); + } } } @@ -847,32 +862,30 @@ class VolumeCroppingTool extends BaseTool { }); this.originalClippingPlanes[planeIndices[i]].origin = plane.getOrigin(); - if (this.configuration.showHandles) { - // 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 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 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; @@ -882,13 +895,6 @@ class VolumeCroppingTool extends BaseTool { clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); } } - - if ( - this.configuration.showHandles && - this.configuration.showCornerSpheres - ) { - this._updateCornerSpheres(); - } viewport.render(); } }; @@ -1015,7 +1021,7 @@ class VolumeCroppingTool extends BaseTool { actor.getProperty().setAmbient(1.0); // Full ambient actor.getProperty().setDiffuse(0.0); // No diffuse actor.getProperty().setSpecular(0.0); // No specular - actor.setVisibility(true); + actor.setVisibility(this.configuration.showHandles); viewport.addActor({ actor, uid }); return { actor, source: polyData }; } @@ -1033,9 +1039,6 @@ class VolumeCroppingTool extends BaseTool { cornerKey = null, adaptiveRadius ) { - if (!this.configuration.showHandles) { - return; - } // 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}`; @@ -1087,7 +1090,7 @@ class VolumeCroppingTool extends BaseTool { return; } sphereActor.getProperty().setColor(color); - sphereActor.setPickable(true); + sphereActor.setVisibility(this.configuration.showHandles); viewport.addActor({ actor: sphereActor, uid: uid }); } /** @@ -1250,83 +1253,76 @@ class VolumeCroppingTool extends BaseTool { null, adaptiveRadius ); - if ( - this.configuration.showCornerSpheres && - this.configuration.showHandles - ) { - 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', - ]; + 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'], + ]; - for (let i = 0; i < corners.length; i++) { - this._addSphere( + 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, - corners[i], - 'corner', - null, - cornerKeys[i], - adaptiveRadius + state1.point, + state2.point, + [0.7, 0.7, 0.7], + uid ); + this.edgeLines[uid] = { actor, source, key1, key2 }; } + }); - // 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); From b36dedd21752f219336fd8e7c38931b2fc32f742 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:18:59 +0200 Subject: [PATCH 129/132] refactor(VolumeCroppingTool): streamline VTK pipeline update logic and improve code clarity --- packages/tools/src/tools/VolumeCroppingTool.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 4bdc5bf65f..46a2f1deea 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -745,23 +745,12 @@ class VolumeCroppingTool extends BaseTool { this._updateCornerSpheres(); } - // For OHIF: Force immediate VTK pipeline updates - this.sphereStates.forEach((state) => { - if (state.sphereSource) { - // Force the mapper to update - if (state.sphereActor && state.sphereActor.getMapper()) { - const mapper = state.sphereActor.getMapper(); - mapper.update(); - mapper.modified(); - } - } - }); + // // THEN update clipping planes + this._updateClippingPlanesFromFaceSpheres(viewport); + // // For OHIF: Force immediate VTK pipeline updates this._forceImmediateVTKUpdates(viewport); - // THEN update clipping planes - this._updateClippingPlanesFromFaceSpheres(viewport); - // Final render and event trigger viewport.render(); From dc09fd0ed2c1e5ad617b353caa3ef799865ac578 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:29:20 +0200 Subject: [PATCH 130/132] fix(VolumeCroppingTool): conditionally set clipping plane origin based on visibility --- packages/tools/src/tools/VolumeCroppingTool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 46a2f1deea..603b2a41d4 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -881,7 +881,9 @@ class VolumeCroppingTool extends BaseTool { if (volumeActor) { const mapper = volumeActor.getMapper() as vtkVolumeMapper; const clippingPlanes = mapper.getClippingPlanes(); - clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + if (this.getClippingPlanesVisible) { + clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); + } } } viewport.render(); From 517af4cbed829cc3eb0b3ff83f3dca4136a0966a Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:35:59 +0200 Subject: [PATCH 131/132] fix(VolumeCroppingTool): update clipping plane origin condition to check for existing clipping planes --- packages/tools/src/tools/VolumeCroppingTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 603b2a41d4..41290c800f 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -881,7 +881,7 @@ class VolumeCroppingTool extends BaseTool { if (volumeActor) { const mapper = volumeActor.getMapper() as vtkVolumeMapper; const clippingPlanes = mapper.getClippingPlanes(); - if (this.getClippingPlanesVisible) { + if (clippingPlanes) { clippingPlanes[planeIndices[i]].setOrigin(plane.getOrigin()); } } From d51f3eff37df449ff9533353cc2a78be1c275f45 Mon Sep 17 00:00:00 2001 From: "Martin Bellehumeur, M. Eng." <23396581+mbellehumeur@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:15:47 +0200 Subject: [PATCH 132/132] refactor(VolumeCroppingTool): remove VTK update logic (real problem is bun run dev:fast) --- .../tools/src/tools/VolumeCroppingTool.ts | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/packages/tools/src/tools/VolumeCroppingTool.ts b/packages/tools/src/tools/VolumeCroppingTool.ts index 41290c800f..a6bac4d188 100644 --- a/packages/tools/src/tools/VolumeCroppingTool.ts +++ b/packages/tools/src/tools/VolumeCroppingTool.ts @@ -748,9 +748,6 @@ class VolumeCroppingTool extends BaseTool { // // THEN update clipping planes this._updateClippingPlanesFromFaceSpheres(viewport); - // // For OHIF: Force immediate VTK pipeline updates - this._forceImmediateVTKUpdates(viewport); - // Final render and event trigger viewport.render(); @@ -759,47 +756,6 @@ class VolumeCroppingTool extends BaseTool { return true; }; - _forceImmediateVTKUpdates(viewport) { - // Force all sphere sources to update their geometry immediately - this.sphereStates.forEach((state) => { - if (state.sphereSource) { - // Force the source to update - state.sphereSource.modified(); - state.sphereSource.update(); - - // Force the mapper to update - if (state.sphereActor && state.sphereActor.getMapper()) { - const mapper = state.sphereActor.getMapper(); - mapper.update(); - mapper.modified(); - } - - // Force the actor to update - if (state.sphereActor) { - state.sphereActor.modified(); - } - } - }); - - // Force edge line updates - Object.values(this.edgeLines).forEach(({ source, actor }) => { - if (source) { - const points = source.getPoints(); - if (points) { - points.modified(); - } - source.modified(); - //source.update(); - } - if (actor && actor.getMapper()) { - const mapper = actor.getMapper(); - mapper.update(); - mapper.modified(); - actor.modified(); - } - }); - } - _onControlToolChange = (evt) => { const viewport = this._getViewport(); if (!evt.detail.toolCenter) {