From a211f35f5333ba6b6a54e185e79e17a395e5be0c Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 20 Feb 2024 17:22:17 -0500 Subject: [PATCH 01/50] feat: replace proxies --- package-lock.json | 153 +++++++----- package.json | 4 +- src/components/LayoutGrid.vue | 2 + src/components/SliceVolumeViewer.vue | 109 +++++++++ src/components/VolumeRendering.vue | 13 +- src/components/VolumeViewer.vue | 69 ++++++ src/components/VtkTwoView.vue | 13 +- .../vtk/VtkBaseSliceRepresentation.vue | 60 +++++ .../vtk/VtkBaseVolumeRepresentation.vue | 148 ++++++++++++ .../vtk/VtkLayerSliceRepresentation.vue | 103 ++++++++ .../VtkSegmentationSliceRepresentation.vue | 119 +++++++++ src/components/vtk/VtkSliceView.vue | 161 +++++++++++++ src/components/vtk/VtkVolumeView.vue | 112 +++++++++ src/components/vtk/context.ts | 4 + src/composables/isViewAnimating.ts | 13 + src/composables/onPausableVTKEvent.ts | 50 ++++ src/composables/onVTKEvent.ts | 20 +- src/composables/useAutoFitState.ts | 13 + src/composables/useCameraOrientation.ts | 13 +- src/composables/useColoringEffectNew.ts | 103 ++++++++ src/composables/useLayerConfigInitializer.ts | 22 ++ src/composables/usePersistCameraConfigNew.ts | 62 +++++ src/composables/useSliceConfigInitializer.ts | 60 +++-- src/composables/useViewAnimationListener.ts | 30 +++ .../useVolumeColoringInitializer.ts | 27 +++ src/composables/useWindowingConfig.ts | 2 +- .../useWindowingConfigInitializer.ts | 67 +++--- src/core/vtk/types.ts | 3 + .../vtk/useMouseRangeManipulatorListener.ts | 54 +++++ src/core/vtk/useSliceRepresentation.ts | 21 ++ src/core/vtk/useVolumeRepresentation.ts | 21 ++ src/core/vtk/useVtkInteractionManipulator.ts | 57 +++++ src/core/vtk/useVtkInteractorStyle.ts | 25 ++ src/core/vtk/useVtkRepresentation.ts | 67 ++++++ src/core/vtk/useVtkView.ts | 130 ++++++++++ src/core/vtk/vtkFieldRef.ts | 71 ++++++ src/shims-vtk.d.ts | 20 ++ src/store/view-animation.ts | 75 ++++++ src/store/view-configs/volume-coloring.ts | 2 + src/types/index.ts | 4 + src/types/vtk-types.ts | 7 + src/utils/camera.ts | 112 +++++++++ src/utils/guardedWritableRef.ts | 16 ++ src/utils/volumeProperties.ts | 225 ++++++++++++++++++ src/utils/vtk-helpers.ts | 60 ++++- src/utils/watchCompare.ts | 75 ++++++ 46 files changed, 2433 insertions(+), 164 deletions(-) create mode 100644 src/components/SliceVolumeViewer.vue create mode 100644 src/components/VolumeViewer.vue create mode 100644 src/components/vtk/VtkBaseSliceRepresentation.vue create mode 100644 src/components/vtk/VtkBaseVolumeRepresentation.vue create mode 100644 src/components/vtk/VtkLayerSliceRepresentation.vue create mode 100644 src/components/vtk/VtkSegmentationSliceRepresentation.vue create mode 100644 src/components/vtk/VtkSliceView.vue create mode 100644 src/components/vtk/VtkVolumeView.vue create mode 100644 src/components/vtk/context.ts create mode 100644 src/composables/onPausableVTKEvent.ts create mode 100644 src/composables/useAutoFitState.ts create mode 100644 src/composables/useColoringEffectNew.ts create mode 100644 src/composables/useLayerConfigInitializer.ts create mode 100644 src/composables/usePersistCameraConfigNew.ts create mode 100644 src/composables/useViewAnimationListener.ts create mode 100644 src/composables/useVolumeColoringInitializer.ts create mode 100644 src/core/vtk/types.ts create mode 100644 src/core/vtk/useMouseRangeManipulatorListener.ts create mode 100644 src/core/vtk/useSliceRepresentation.ts create mode 100644 src/core/vtk/useVolumeRepresentation.ts create mode 100644 src/core/vtk/useVtkInteractionManipulator.ts create mode 100644 src/core/vtk/useVtkInteractorStyle.ts create mode 100644 src/core/vtk/useVtkRepresentation.ts create mode 100644 src/core/vtk/useVtkView.ts create mode 100644 src/core/vtk/vtkFieldRef.ts create mode 100644 src/store/view-animation.ts create mode 100644 src/utils/camera.ts create mode 100644 src/utils/guardedWritableRef.ts create mode 100644 src/utils/volumeProperties.ts create mode 100644 src/utils/watchCompare.ts diff --git a/package-lock.json b/package-lock.json index c90332843..a71959e89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,11 @@ "@kitware/vtk.js": "^28.13.0", "@netlify/edge-functions": "^2.0.0", "@sentry/vue": "^7.54.0", - "@vueuse/core": "^8.5.0", - "@vueuse/shared": "^9.2.0", + "@vueuse/core": "^10.7.0", "core-js": "3.22.5", "deep-equal": "^2.0.5", "dicomweb-client-typed": "^0.8.6", + "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "itk-wasm": "1.0.0-b.156", @@ -4805,9 +4805,9 @@ "dev": true }, "node_modules/@types/web-bluetooth": { - "version": "0.0.14", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz", - "integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==" + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" }, "node_modules/@types/which": { "version": "2.0.2", @@ -5427,73 +5427,88 @@ } }, "node_modules/@vueuse/core": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.9.4.tgz", - "integrity": "sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.7.2.tgz", + "integrity": "sha512-AOyAL2rK0By62Hm+iqQn6Rbu8bfmbgaIMXcE3TSr7BdQ42wnSFlwIdPjInO62onYsEMK/yDMU8C6oGfDAtZ2qQ==", "dependencies": { - "@types/web-bluetooth": "^0.0.14", - "@vueuse/metadata": "8.9.4", - "@vueuse/shared": "8.9.4", - "vue-demi": "*" + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.7.2", + "@vueuse/shared": "10.7.2", + "vue-demi": ">=0.14.6" }, "funding": { "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vue/composition-api": "^1.1.0", - "vue": "^2.6.0 || ^3.2.0" - }, - "peerDependenciesMeta": { - "@vue/composition-api": { - "optional": true - }, - "vue": { - "optional": true - } } }, - "node_modules/@vueuse/core/node_modules/@vueuse/shared": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.9.4.tgz", - "integrity": "sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==", - "dependencies": { - "vue-demi": "*" + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "@vue/composition-api": "^1.1.0", - "vue": "^2.6.0 || ^3.2.0" + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" }, "peerDependenciesMeta": { "@vue/composition-api": { "optional": true - }, - "vue": { - "optional": true } } }, "node_modules/@vueuse/metadata": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.9.4.tgz", - "integrity": "sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.7.2.tgz", + "integrity": "sha512-kCWPb4J2KGrwLtn1eJwaJD742u1k5h6v/St5wFe8Quih90+k2a0JP8BS4Zp34XUuJqS2AxFYMb1wjUL8HfhWsQ==", "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/@vueuse/shared": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.5.0.tgz", - "integrity": "sha512-HnnCWU1Vg9CVWRCcI8ohDKDRB2Sc4bTgT1XAIaoLSfVHHn+TKbrox6pd3klCSw4UDxkhDfOk8cAdcK+Z5KleCA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.7.2.tgz", + "integrity": "sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==", "dependencies": { - "vue-demi": "*" + "vue-demi": ">=0.14.6" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@wdio/cli": { "version": "8.32.3", "resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-8.32.3.tgz", @@ -25880,9 +25895,9 @@ "dev": true }, "@types/web-bluetooth": { - "version": "0.0.14", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz", - "integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A==" + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" }, "@types/which": { "version": "2.0.2", @@ -26346,37 +26361,43 @@ } }, "@vueuse/core": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.9.4.tgz", - "integrity": "sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.7.2.tgz", + "integrity": "sha512-AOyAL2rK0By62Hm+iqQn6Rbu8bfmbgaIMXcE3TSr7BdQ42wnSFlwIdPjInO62onYsEMK/yDMU8C6oGfDAtZ2qQ==", "requires": { - "@types/web-bluetooth": "^0.0.14", - "@vueuse/metadata": "8.9.4", - "@vueuse/shared": "8.9.4", - "vue-demi": "*" + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.7.2", + "@vueuse/shared": "10.7.2", + "vue-demi": ">=0.14.6" }, "dependencies": { - "@vueuse/shared": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.9.4.tgz", - "integrity": "sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==", - "requires": { - "vue-demi": "*" - } + "vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "requires": {} } } }, "@vueuse/metadata": { - "version": "8.9.4", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.9.4.tgz", - "integrity": "sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==" + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.7.2.tgz", + "integrity": "sha512-kCWPb4J2KGrwLtn1eJwaJD742u1k5h6v/St5wFe8Quih90+k2a0JP8BS4Zp34XUuJqS2AxFYMb1wjUL8HfhWsQ==" }, "@vueuse/shared": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.5.0.tgz", - "integrity": "sha512-HnnCWU1Vg9CVWRCcI8ohDKDRB2Sc4bTgT1XAIaoLSfVHHn+TKbrox6pd3klCSw4UDxkhDfOk8cAdcK+Z5KleCA==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.7.2.tgz", + "integrity": "sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==", "requires": { - "vue-demi": "*" + "vue-demi": ">=0.14.6" + }, + "dependencies": { + "vue-demi": { + "version": "0.14.7", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz", + "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==", + "requires": {} + } } }, "@wdio/cli": { diff --git a/package.json b/package.json index 319416aec..50fea5e98 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "@kitware/vtk.js": "^28.13.0", "@netlify/edge-functions": "^2.0.0", "@sentry/vue": "^7.54.0", - "@vueuse/core": "^8.5.0", - "@vueuse/shared": "^9.2.0", + "@vueuse/core": "^10.7.0", "core-js": "3.22.5", "deep-equal": "^2.0.5", "dicomweb-client-typed": "^0.8.6", + "fast-deep-equal": "^3.1.3", "file-saver": "^2.0.5", "gl-matrix": "3.4.3", "itk-wasm": "1.0.0-b.156", diff --git a/src/components/LayoutGrid.vue b/src/components/LayoutGrid.vue index 659f694df..ea161001f 100644 --- a/src/components/LayoutGrid.vue +++ b/src/components/LayoutGrid.vue @@ -11,6 +11,7 @@ :is="item.component" :key="item.id" :id="item.id" + :type="item.viewType" v-bind="item.props" /> @@ -52,6 +53,7 @@ export default defineComponent({ return { type: 'view', id: item, + viewType: spec.viewType, component: ViewTypeToComponent[spec.viewType], props: spec.props, }; diff --git a/src/components/SliceVolumeViewer.vue b/src/components/SliceVolumeViewer.vue new file mode 100644 index 000000000..6660569df --- /dev/null +++ b/src/components/SliceVolumeViewer.vue @@ -0,0 +1,109 @@ + + + + + + diff --git a/src/components/VolumeRendering.vue b/src/components/VolumeRendering.vue index 0e71385f2..44d84465a 100644 --- a/src/components/VolumeRendering.vue +++ b/src/components/VolumeRendering.vue @@ -15,6 +15,7 @@ import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/C import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; +import useViewAnimationStore from '@/src/store/view-animation'; import { useResizeObserver } from '../composables/useResizeObserver'; import { useCurrentImage } from '../composables/useCurrentImage'; import useVolumeColoringStore from '../store/view-configs/volume-coloring'; @@ -23,8 +24,6 @@ import { getShiftedOpacityFromPreset, } from '../utils/vtk-helpers'; import { useVolumeThumbnailing } from '../composables/useVolumeThumbnailing'; -import { useViewProxy } from '../composables/useViewProxy'; -import { ViewProxyType } from '../core/proxies'; import { InitViewIDs } from '../config'; const WIDGET_WIDTH = 250; @@ -133,21 +132,21 @@ export default defineComponent({ onVTKEvent(pwfWidget, 'onOpacityChange', updateOpacityFunc); // trigger 3D view animations when updating the opacity widget - const { viewProxy } = useViewProxy(TARGET_VIEW_ID, ViewProxyType.Volume); + const viewAnimationStore = useViewAnimationStore(); let animationRequested = false; const request3DAnimation = () => { if (!animationRequested) { animationRequested = true; - viewProxy.value.getInteractor().requestAnimation(pwfWidget); + viewAnimationStore.requestAnimation(pwfWidget, { + byViewType: ['3D'], + }); } }; const cancel3DAnimation = () => { animationRequested = false; - viewProxy.value - .getInteractor() - .cancelAnimation(pwfWidget, true /* skipWarning */); + viewAnimationStore.cancelAnimation(pwfWidget); }; onVTKEvent(pwfWidget, 'onAnimation', (animating: boolean) => { diff --git a/src/components/VolumeViewer.vue b/src/components/VolumeViewer.vue new file mode 100644 index 000000000..3f8688447 --- /dev/null +++ b/src/components/VolumeViewer.vue @@ -0,0 +1,69 @@ + + + + + + diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index 32a1a42ef..fb21677cd 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -258,18 +258,7 @@ export default defineComponent({ // --- initializers --- // - const sliceDomain = computed(() => { - const { lpsOrientation, dimensions } = curImageMetadata.value; - const ijkIndex = lpsOrientation[viewAxis.value]; - const dimMax = dimensions[ijkIndex]; - - return { - min: 0, - max: dimMax - 1, - }; - }); - - useSliceConfigInitializer(viewID, curImageID, viewDirection, sliceDomain); + useSliceConfigInitializer(viewID, curImageID, viewDirection); useWindowingConfigInitializer(viewID, curImageID); // --- view proxy setup --- // diff --git a/src/components/vtk/VtkBaseSliceRepresentation.vue b/src/components/vtk/VtkBaseSliceRepresentation.vue new file mode 100644 index 000000000..9d89667a1 --- /dev/null +++ b/src/components/vtk/VtkBaseSliceRepresentation.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/components/vtk/VtkBaseVolumeRepresentation.vue b/src/components/vtk/VtkBaseVolumeRepresentation.vue new file mode 100644 index 000000000..1f73306d1 --- /dev/null +++ b/src/components/vtk/VtkBaseVolumeRepresentation.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/components/vtk/VtkLayerSliceRepresentation.vue b/src/components/vtk/VtkLayerSliceRepresentation.vue new file mode 100644 index 000000000..882981dcf --- /dev/null +++ b/src/components/vtk/VtkLayerSliceRepresentation.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/vtk/VtkSegmentationSliceRepresentation.vue b/src/components/vtk/VtkSegmentationSliceRepresentation.vue new file mode 100644 index 000000000..ad5251142 --- /dev/null +++ b/src/components/vtk/VtkSegmentationSliceRepresentation.vue @@ -0,0 +1,119 @@ + + + diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue new file mode 100644 index 000000000..b321ea3c4 --- /dev/null +++ b/src/components/vtk/VtkSliceView.vue @@ -0,0 +1,161 @@ + + + diff --git a/src/components/vtk/VtkVolumeView.vue b/src/components/vtk/VtkVolumeView.vue new file mode 100644 index 000000000..f4990a3bb --- /dev/null +++ b/src/components/vtk/VtkVolumeView.vue @@ -0,0 +1,112 @@ + + + diff --git a/src/components/vtk/context.ts b/src/components/vtk/context.ts new file mode 100644 index 000000000..952cc96c6 --- /dev/null +++ b/src/components/vtk/context.ts @@ -0,0 +1,4 @@ +import { VtkViewApi } from '@/src/types/vtk-types'; +import { InjectionKey } from 'vue'; + +export const VtkViewContext: InjectionKey = Symbol('VtkView'); diff --git a/src/composables/isViewAnimating.ts b/src/composables/isViewAnimating.ts index fc93dfd86..dd29c938a 100644 --- a/src/composables/isViewAnimating.ts +++ b/src/composables/isViewAnimating.ts @@ -2,6 +2,7 @@ import vtkViewProxy from '@kitware/vtk.js/Proxy/Core/ViewProxy'; import { computed, ref, unref } from 'vue'; import { MaybeRef } from '@vueuse/core'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; +import { View } from '@/src/core/vtk/useVtkView'; export function isViewAnimating(viewProxy: MaybeRef) { const isAnimating = ref(false); @@ -16,3 +17,15 @@ export function isViewAnimating(viewProxy: MaybeRef) { return isAnimating; } +export function isViewAnimatingNew(view: View) { + const isAnimating = ref(false); + + onVTKEvent(view.interactor, 'onStartAnimation', () => { + isAnimating.value = true; + }); + onVTKEvent(view.interactor, 'onEndAnimation', () => { + isAnimating.value = false; + }); + + return isAnimating; +} diff --git a/src/composables/onPausableVTKEvent.ts b/src/composables/onPausableVTKEvent.ts new file mode 100644 index 000000000..f600db172 --- /dev/null +++ b/src/composables/onPausableVTKEvent.ts @@ -0,0 +1,50 @@ +import { vtkObject } from '@kitware/vtk.js/interfaces'; +import { MaybeRef } from 'vue'; +import { + OnVTKEventOptions, + VTKEventHandler, + VTKEventListener, + onVTKEvent, +} from './onVTKEvent'; + +export function onPausableVTKEvent( + vtkObj: MaybeRef, + eventHookName: T[K] extends VTKEventListener ? K : never, + callback: VTKEventHandler, + options?: OnVTKEventOptions +) { + let paused = false; + + const pause = () => { + paused = true; + }; + + const resume = () => { + paused = false; + }; + + const withPaused = (fn: () => void) => { + pause(); + try { + fn(); + } finally { + resume(); + } + }; + + const { stop } = onVTKEvent( + vtkObj, + eventHookName, + (obj) => { + if (!paused) callback(obj); + }, + options + ); + + return { + stop, + pause, + resume, + withPaused, + }; +} diff --git a/src/composables/onVTKEvent.ts b/src/composables/onVTKEvent.ts index 1d08a0a7a..32cad9c1c 100644 --- a/src/composables/onVTKEvent.ts +++ b/src/composables/onVTKEvent.ts @@ -1,6 +1,6 @@ import { Maybe } from '@/src/types'; import { vtkObject, vtkSubscription } from '@kitware/vtk.js/interfaces'; -import { MaybeRef, computed, onBeforeUnmount, unref, watch } from 'vue'; +import { MaybeRef, computed, onScopeDispose, unref, watch } from 'vue'; export type VTKEventHandler = (ev?: any) => any; export type VTKEventListener = ( @@ -23,15 +23,16 @@ export function onVTKEvent( }); let subscription: Maybe = null; - const stop = () => { + + const cleanup = () => { subscription?.unsubscribe(); subscription = null; }; - watch( + const stop = watch( listenerRef, (listener) => { - stop(); + cleanup(); if (listener) { subscription = listener(callback, options?.priority ?? 0); } @@ -39,7 +40,14 @@ export function onVTKEvent( { immediate: true } ); - onBeforeUnmount(() => { - stop(); + onScopeDispose(() => { + cleanup(); }); + + return { + stop: () => { + cleanup(); + stop(); + }, + }; } diff --git a/src/composables/useAutoFitState.ts b/src/composables/useAutoFitState.ts new file mode 100644 index 000000000..5a11ead06 --- /dev/null +++ b/src/composables/useAutoFitState.ts @@ -0,0 +1,13 @@ +import { onPausableVTKEvent } from '@/src/composables/onPausableVTKEvent'; +import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import { MaybeRef, ref } from 'vue'; + +export function useAutoFitState(camera: MaybeRef) { + const autoFit = ref(true); + + const { withPaused } = onPausableVTKEvent(camera, 'onModified', () => { + autoFit.value = false; + }); + + return { autoFit, withoutAutoFitEffect: withPaused }; +} diff --git a/src/composables/useCameraOrientation.ts b/src/composables/useCameraOrientation.ts index ff5c2e407..ab9b66341 100644 --- a/src/composables/useCameraOrientation.ts +++ b/src/composables/useCameraOrientation.ts @@ -1,9 +1,10 @@ +import { Vector3 } from '@kitware/vtk.js/types'; import { computed, Ref, unref } from 'vue'; import { MaybeRef } from '@vueuse/core'; import { mat3 } from 'gl-matrix'; -import { ImageMetadata } from '../types/image'; -import { LPSAxisDir } from '../types/lps'; -import { getLPSDirections } from '../utils/lps'; +import { ImageMetadata } from '@/src/types/image'; +import { LPSAxisDir } from '@/src/types/lps'; +import { getLPSDirections } from '@/src/utils/lps'; /** * @@ -23,9 +24,11 @@ export function useCameraOrientation( getLPSDirections(orientationMatrix.value) ); const cameraDirVec = computed( - () => lpsDirections.value[unref(viewDirection)] + () => lpsDirections.value[unref(viewDirection)] as Vector3 + ); + const cameraUpVec = computed( + () => lpsDirections.value[unref(viewUp)] as Vector3 ); - const cameraUpVec = computed(() => lpsDirections.value[unref(viewUp)]); return { cameraDirVec, diff --git a/src/composables/useColoringEffectNew.ts b/src/composables/useColoringEffectNew.ts new file mode 100644 index 000000000..544e70212 --- /dev/null +++ b/src/composables/useColoringEffectNew.ts @@ -0,0 +1,103 @@ +import { Maybe } from '@/src/types'; +import { + ColorTransferFunction, + ColoringConfig, + OpacityFunction, +} from '@/src/types/views'; +import { MaybeRef, computed, unref, watchEffect } from 'vue'; +import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy'; +import { + applyNodesToPiecewiseFunction, + applyPointsToPiecewiseFunction, + getShiftedOpacityFromPreset, +} from '@/src/utils/vtk-helpers'; +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import vtkPiecewiseGaussianWidget from '@kitware/vtk.js/Interaction/Widgets/PiecewiseGaussianWidget'; + +export interface ApplyColoringParams { + props: { + colorFunction: ColorTransferFunction; + opacityFunction: OpacityFunction; + }; + cfun: vtkColorTransferFunction; + ofun: vtkPiecewiseFunction; + componentIndex?: number; +} + +export function applyColoring({ + props: { colorFunction, opacityFunction }, + cfun, + ofun, + componentIndex = -1, +}: ApplyColoringParams) { + if (componentIndex === -1) { + cfun.setVectorModeToMagnitude(); + } else { + cfun.setVectorModeToComponent(); + cfun.setVectorComponent(componentIndex); + } + + cfun.setMappingRange(...colorFunction.mappingRange); + const preset = vtkColorMaps.getPresetByName(colorFunction.preset); + if (preset) { + cfun.applyColorMap(preset); + } + + const { mappingRange } = opacityFunction; + ofun.setRange(...opacityFunction.mappingRange); + + switch (opacityFunction.mode) { + case vtkPiecewiseFunctionProxy.Mode.Gaussians: + vtkPiecewiseGaussianWidget.applyGaussianToPiecewiseFunction( + opacityFunction.gaussians, + 256, + opacityFunction.mappingRange, + ofun + ); + break; + case vtkPiecewiseFunctionProxy.Mode.Points: { + const opacityPoints = getShiftedOpacityFromPreset( + opacityFunction.preset, + opacityFunction.mappingRange, + opacityFunction.shift, + opacityFunction.shiftAlpha + ); + if (opacityPoints) { + applyPointsToPiecewiseFunction(ofun, opacityPoints, mappingRange); + } + break; + } + case vtkPiecewiseFunctionProxy.Mode.Nodes: { + applyNodesToPiecewiseFunction(ofun, opacityFunction.nodes, mappingRange); + break; + } + default: + throw new Error('Invalid opacity function mode encountered'); + } +} + +export function useColoringEffect( + config: MaybeRef>, + cfun: MaybeRef, + ofun: MaybeRef +) { + const colorTransferFunction = computed(() => unref(config)?.transferFunction); + const opacityFunction = computed(() => unref(config)?.opacityFunction); + + watchEffect(() => { + const colorFunc = colorTransferFunction.value; + const opacityFunc = opacityFunction.value; + if (!colorFunc || !opacityFunc) return; + + applyColoring({ + props: { + colorFunction: colorFunc, + opacityFunction: opacityFunc, + }, + cfun: unref(cfun), + ofun: unref(ofun), + }); + }); +} diff --git a/src/composables/useLayerConfigInitializer.ts b/src/composables/useLayerConfigInitializer.ts new file mode 100644 index 000000000..08bb84b38 --- /dev/null +++ b/src/composables/useLayerConfigInitializer.ts @@ -0,0 +1,22 @@ +import { LayerID } from '@/src/store/datasets-layers'; +import useLayerColoringStore from '@/src/store/view-configs/layers'; +import { watchImmediate } from '@vueuse/core'; +import { MaybeRef, computed, unref } from 'vue'; + +export function useLayerConfigInitializer( + viewId: MaybeRef, + layerId: MaybeRef +) { + const coloringStore = useLayerColoringStore(); + const colorConfig = computed(() => + coloringStore.getConfig(unref(viewId), unref(layerId)) + ); + + watchImmediate(colorConfig, (config) => { + if (config) return; + + const viewIdVal = unref(viewId); + const layerIdVal = unref(layerId); + coloringStore.resetColorPreset(viewIdVal, layerIdVal); + }); +} diff --git a/src/composables/usePersistCameraConfigNew.ts b/src/composables/usePersistCameraConfigNew.ts new file mode 100644 index 000000000..424eeb219 --- /dev/null +++ b/src/composables/usePersistCameraConfigNew.ts @@ -0,0 +1,62 @@ +import { MaybeRef, Ref, computed, unref } from 'vue'; +import { Maybe } from '@/src/types'; +import { CameraConfig } from '@/src/store/view-configs/types'; +import useViewCameraStore from '@/src/store/view-configs/camera'; +import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; +import { syncRef } from '@vueuse/core'; +import { guardedWritableRef } from '@/src/utils/guardedWritableRef'; + +export function usePersistCameraConfig( + viewID: MaybeRef, + dataID: MaybeRef>, + camera: MaybeRef +) { + const viewCameraStore = useViewCameraStore(); + + type KeyType = keyof CameraConfig; + const keys: KeyType[] = [ + 'position', + 'focalPoint', + 'viewUp', + 'parallelScale', + 'directionOfProjection', + ]; + + const cameraRefs = keys.reduce( + (refs, key) => ({ + ...refs, + [key]: guardedWritableRef( + vtkFieldRef(camera, key), + (incoming) => !!incoming + ), + }), + {} as Record> + ); + + const config = computed(() => + viewCameraStore.getConfig(unref(viewID), unref(dataID)) + ); + + const configRefs = keys.reduce( + (refs, key) => ({ + ...refs, + [key]: computed({ + get: () => config.value?.[key], + set: (v) => { + const viewIDVal = unref(viewID); + const dataIDVal = unref(dataID); + if (!viewIDVal || !dataIDVal) return; + viewCameraStore.updateConfig(viewIDVal, dataIDVal, { + [key]: v, + }); + }, + }), + }), + {} as Record> + ); + + keys.forEach((key) => { + syncRef(configRefs[key], cameraRefs[key]); + }); +} diff --git a/src/composables/useSliceConfigInitializer.ts b/src/composables/useSliceConfigInitializer.ts index e3fbcbaa4..283e2a3c9 100644 --- a/src/composables/useSliceConfigInitializer.ts +++ b/src/composables/useSliceConfigInitializer.ts @@ -1,40 +1,50 @@ +import { useImage } from '@/src/composables/useCurrentImage'; import { useSliceConfig } from '@/src/composables/useSliceConfig'; import useViewSliceStore from '@/src/store/view-configs/slicing'; import { Maybe } from '@/src/types'; import { LPSAxisDir } from '@/src/types/lps'; -import { MaybeRef, toRef, unref, watch } from 'vue'; +import { getLPSAxisFromDir } from '@/src/utils/lps'; +import { watchImmediate } from '@vueuse/core'; +import { MaybeRef, computed, toRef, unref } from 'vue'; export function useSliceConfigInitializer( viewID: MaybeRef, imageID: MaybeRef>, viewDirection: MaybeRef, - sliceDomain: MaybeRef<{ min: number; max: number }> + slicingDomain?: MaybeRef<{ min: number; max: number }> ) { const store = useViewSliceStore(); const { config: sliceConfig } = useSliceConfig(viewID, imageID); + const { metadata } = useImage(imageID); - watch( - toRef(sliceDomain), - (domain) => { - const imageIdVal = unref(imageID); - if (!imageIdVal) return; - store.updateConfig(unref(viewID), imageIdVal, domain); - }, - { immediate: true } - ); + const viewAxis = computed(() => getLPSAxisFromDir(unref(viewDirection))); + const sliceDomain = computed(() => { + const domainArg = unref(slicingDomain); + if (domainArg) return domainArg; + const { lpsOrientation, dimensions } = metadata.value; + const ijkIndex = lpsOrientation[viewAxis.value]; + const dimMax = dimensions[ijkIndex]; - watch( - sliceConfig, - (config) => { - const imageIdVal = unref(imageID); - const viewIdVal = unref(viewID); - if (config || !imageIdVal) return; - store.updateConfig(viewIdVal, imageIdVal, { - ...unref(sliceDomain), - axisDirection: unref(viewDirection), - }); - store.resetSlice(viewIdVal, imageIdVal); - }, - { immediate: true } - ); + return { + min: 0, + max: dimMax - 1, + }; + }); + + watchImmediate(toRef(sliceDomain), (domain) => { + const imageIdVal = unref(imageID); + if (!imageIdVal) return; + store.updateConfig(unref(viewID), imageIdVal, domain); + }); + + watchImmediate(sliceConfig, (config) => { + const imageIdVal = unref(imageID); + const viewIdVal = unref(viewID); + if (config || !imageIdVal) return; + store.updateConfig(viewIdVal, imageIdVal, { + ...unref(sliceDomain), + axisDirection: unref(viewDirection), + }); + store.resetSlice(viewIdVal, imageIdVal); + }); } diff --git a/src/composables/useViewAnimationListener.ts b/src/composables/useViewAnimationListener.ts new file mode 100644 index 000000000..27e40fbc3 --- /dev/null +++ b/src/composables/useViewAnimationListener.ts @@ -0,0 +1,30 @@ +import { View } from '@/src/core/vtk/useVtkView'; +import useViewAnimationStore, { + matchesViewFilter, +} from '@/src/store/view-animation'; +import { storeToRefs } from 'pinia'; +import { MaybeRef, computed, unref, watchEffect } from 'vue'; + +export function useViewAnimationListener( + view: View, + viewId: MaybeRef, + viewType: MaybeRef +) { + const store = useViewAnimationStore(); + const { animating, viewFilter } = storeToRefs(store); + const canAnimate = computed(() => + matchesViewFilter(unref(viewId), unref(viewType), viewFilter.value) + ); + + let requested = false; + + watchEffect(() => { + if (!animating.value) { + view.interactor.cancelAnimation(store); + requested = false; + } else if (!requested && canAnimate.value) { + view.interactor.requestAnimation(store); + requested = true; + } + }); +} diff --git a/src/composables/useVolumeColoringInitializer.ts b/src/composables/useVolumeColoringInitializer.ts new file mode 100644 index 000000000..4c176893f --- /dev/null +++ b/src/composables/useVolumeColoringInitializer.ts @@ -0,0 +1,27 @@ +import { useImage } from '@/src/composables/useCurrentImage'; +import useVolumeColoringStore from '@/src/store/view-configs/volume-coloring'; +import { Maybe } from '@/src/types'; +import { watchImmediate } from '@vueuse/core'; +import { MaybeRef, computed, unref } from 'vue'; + +export function useVolumeColoringInitializer( + viewId: MaybeRef, + imageId: MaybeRef> +) { + const store = useVolumeColoringStore(); + const coloringConfig = computed(() => + store.getConfig(unref(viewId), unref(imageId)) + ); + + const { imageData } = useImage(imageId); + + watchImmediate(coloringConfig, (config) => { + if (config) return; + + const viewIdVal = unref(viewId); + const imageIdVal = unref(imageId); + if (!imageIdVal || !imageData.value) return; + + store.resetToDefaultColoring(viewIdVal, imageIdVal, imageData.value); + }); +} diff --git a/src/composables/useWindowingConfig.ts b/src/composables/useWindowingConfig.ts index c3586bb0b..f7654dc8d 100644 --- a/src/composables/useWindowingConfig.ts +++ b/src/composables/useWindowingConfig.ts @@ -12,7 +12,7 @@ export function useWindowingConfig( const generateComputed = (prop: 'width' | 'level') => { return computed({ - get: () => config.value?.[prop], + get: () => config.value?.[prop] ?? 0, set: (val) => { const imageIdVal = unref(imageID); if (!imageIdVal || val == null) return; diff --git a/src/composables/useWindowingConfigInitializer.ts b/src/composables/useWindowingConfigInitializer.ts index 79cfee1aa..b4f8d8f29 100644 --- a/src/composables/useWindowingConfigInitializer.ts +++ b/src/composables/useWindowingConfigInitializer.ts @@ -5,6 +5,7 @@ import { useDICOMStore } from '@/src/store/datasets-dicom'; import useWindowingStore from '@/src/store/view-configs/windowing'; import { Maybe } from '@/src/types'; import { TypedArray } from '@kitware/vtk.js/types'; +import { watchImmediate } from '@vueuse/core'; import { MaybeRef, computed, unref, watch } from 'vue'; function useAutoRangeValues(imageID: MaybeRef>) { @@ -83,50 +84,40 @@ export function useWindowingConfigInitializer( return {}; }); - watch( - windowConfig, - (config) => { - const image = imageData.value; - const imageIdVal = unref(imageID); - const viewIdVal = unref(viewID); - if (config || !image || !imageIdVal) return; + watchImmediate(windowConfig, (config) => { + const image = imageData.value; + const imageIdVal = unref(imageID); + const viewIdVal = unref(viewID); + if (config || !image || !imageIdVal) return; - const [min, max] = image.getPointData().getScalars().getRange(); - store.updateConfig(viewIdVal, imageIdVal, { min, max }); - store.resetWindowLevel(viewIdVal, imageIdVal); - }, - { immediate: true } - ); + const [min, max] = image.getPointData().getScalars().getRange(); + store.updateConfig(viewIdVal, imageIdVal, { min, max }); + store.resetWindowLevel(viewIdVal, imageIdVal); + }); - watch( - imageData, - (image) => { - const imageIdVal = unref(imageID); - const config = unref(windowConfig); - const viewIdVal = unref(viewID); - if (imageIdVal == null || config != null || !image) { - return; - } + watchImmediate(imageData, (image) => { + const imageIdVal = unref(imageID); + const config = unref(windowConfig); + const viewIdVal = unref(viewID); + if (imageIdVal == null || config != null || !image) { + return; + } - const range = autoRangeValues.value[autoRange.value]; + const range = autoRangeValues.value[autoRange.value]; + store.updateConfig(viewIdVal, imageIdVal, { + min: range[0], + max: range[1], + }); + if (firstTag.value?.width) { store.updateConfig(viewIdVal, imageIdVal, { - min: range[0], - max: range[1], + preset: { + width: parseFloat(firstTag.value.width), + level: parseFloat(firstTag.value.level), + }, }); - if (firstTag.value?.width) { - store.updateConfig(viewIdVal, imageIdVal, { - preset: { - width: parseFloat(firstTag.value.width), - level: parseFloat(firstTag.value.level), - }, - }); - } - store.resetWindowLevel(viewIdVal, imageIdVal); - }, - { - immediate: true, } - ); + store.resetWindowLevel(viewIdVal, imageIdVal); + }); watch(autoRange, (percentile) => { const image = imageData.value; diff --git a/src/core/vtk/types.ts b/src/core/vtk/types.ts new file mode 100644 index 000000000..73082dcff --- /dev/null +++ b/src/core/vtk/types.ts @@ -0,0 +1,3 @@ +export type VtkObjectConstructor = { + newInstance(props?: any): T; +}; diff --git a/src/core/vtk/useMouseRangeManipulatorListener.ts b/src/core/vtk/useMouseRangeManipulatorListener.ts new file mode 100644 index 000000000..99a7444c8 --- /dev/null +++ b/src/core/vtk/useMouseRangeManipulatorListener.ts @@ -0,0 +1,54 @@ +import { Maybe } from '@/src/types'; +import { watchCompare } from '@/src/utils/watchCompare'; +import vtkMouseRangeManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseRangeManipulator'; +import { capitalize } from '@kitware/vtk.js/macros'; +import { MaybeRef, ref, toRef, unref } from 'vue'; +import deepEqual from 'fast-deep-equal'; + +type ListenerType = 'vertical' | 'horizontal' | 'scroll'; + +const DEFAULT_STEP = 1; + +export function useMouseRangeManipulatorListener( + manipulator: MaybeRef, + type: ListenerType, + range: MaybeRef>, + step: MaybeRef>, + initialValue?: number +) { + const internalValue = ref(initialValue ?? 0); + + watchCompare( + [toRef(range), toRef(step), toRef(manipulator)], + ([newRange, , manip], _, onCleanup) => { + if (!newRange) return; + + const setterName = `set${ + capitalize(type) as Capitalize + }Listener` as const; + const removerName = `remove${ + capitalize(type) as Capitalize + }Listener` as const; + + manip[setterName]( + newRange[0], + newRange[1], + unref(step) ?? DEFAULT_STEP, + () => internalValue.value, + (val) => { + internalValue.value = val; + } + ); + + onCleanup(() => { + manip[removerName](); + }); + }, + { + immediate: true, + compare: deepEqual, + } + ); + + return internalValue; +} diff --git a/src/core/vtk/useSliceRepresentation.ts b/src/core/vtk/useSliceRepresentation.ts new file mode 100644 index 000000000..859284928 --- /dev/null +++ b/src/core/vtk/useSliceRepresentation.ts @@ -0,0 +1,21 @@ +import { MaybeRef } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; +import { Maybe } from '@/src/types'; +import { View } from '@/src/core/vtk/useVtkView'; + +export function useSliceRepresentation( + view: View, + imageData: MaybeRef> +) { + const sliceRep = useVtkRepresentation({ + view, + data: imageData, + vtkActorClass: vtkImageSlice, + vtkMapperClass: vtkImageMapper, + }); + + return sliceRep; +} diff --git a/src/core/vtk/useVolumeRepresentation.ts b/src/core/vtk/useVolumeRepresentation.ts new file mode 100644 index 000000000..085736603 --- /dev/null +++ b/src/core/vtk/useVolumeRepresentation.ts @@ -0,0 +1,21 @@ +import { MaybeRef } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; +import { Maybe } from '@/src/types'; +import { View } from '@/src/core/vtk/useVtkView'; +import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; +import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; + +export function useVolumeRepresentation( + view: View, + imageData: MaybeRef> +) { + const volRep = useVtkRepresentation({ + view, + data: imageData, + vtkActorClass: vtkVolume, + vtkMapperClass: vtkVolumeMapper, + }); + + return volRep; +} diff --git a/src/core/vtk/useVtkInteractionManipulator.ts b/src/core/vtk/useVtkInteractionManipulator.ts new file mode 100644 index 000000000..d8d26f295 --- /dev/null +++ b/src/core/vtk/useVtkInteractionManipulator.ts @@ -0,0 +1,57 @@ +import { MaybeRef, computed, ref, unref, watch, watchEffect } from 'vue'; +import vtkInteractorStyleManipulator from '@kitware/vtk.js/Interaction/Style/InteractorStyleManipulator'; +import { VtkObjectConstructor } from '@/src/core/vtk/types'; +import { FirstParam } from '@/src/types'; + +function addManipulator(style: vtkInteractorStyleManipulator, manip: any) { + if (manip.isA('vtkCompositeMouseManipulator')) { + style.addMouseManipulator(manip); + } else if (manip.isA('vtkCompositeGestureManipulator')) { + style.addGestureManipulator(manip); + } else if (manip.isA('vtkCompositeKeyboardManipulator')) { + style.addKeyboardManipulator(manip); + } +} + +function removeManipulator(style: vtkInteractorStyleManipulator, manip: any) { + if (manip.isA('vtkCompositeMouseManipulator')) { + style.removeMouseManipulator(manip); + } else if (manip.isA('vtkCompositeGestureManipulator')) { + style.removeGestureManipulator(manip); + } else if (manip.isA('vtkCompositeKeyboardManipulator')) { + style.removeKeyboardManipulator(manip); + } +} + +export function useVtkInteractionManipulator< + T extends VtkObjectConstructor +>( + style: vtkInteractorStyleManipulator, + vtkCtor: T, + props: MaybeRef> +) { + const manipulator = computed(() => { + return vtkCtor.newInstance(unref(props)); + }); + + const enabled = ref(true); + + watch(manipulator, (_, oldManipulator) => { + oldManipulator?.delete(); + }); + + watchEffect((onCleanup) => { + if (!enabled.value) return; + + const manip = manipulator.value; + addManipulator(style, manip); + onCleanup(() => { + removeManipulator(style, manip); + }); + }); + + return { + instance: manipulator, + enabled, + }; +} diff --git a/src/core/vtk/useVtkInteractorStyle.ts b/src/core/vtk/useVtkInteractorStyle.ts new file mode 100644 index 000000000..57c982a25 --- /dev/null +++ b/src/core/vtk/useVtkInteractorStyle.ts @@ -0,0 +1,25 @@ +import { VtkObjectConstructor } from '@/src/core/vtk/types'; +import vtkInteractorStyle from '@kitware/vtk.js/Rendering/Core/InteractorStyle'; +import { vtkWarningMacro } from '@kitware/vtk.js/macros'; +import { onScopeDispose } from 'vue'; +import type { View } from '@/src/core/vtk/useVtkView'; + +export function useVtkInteractorStyle( + vtkCtor: VtkObjectConstructor, + view: View +) { + const style = vtkCtor.newInstance(); + + if (view.interactor.getInteractorStyle()) { + vtkWarningMacro('Overwriting an already set interactor style'); + } + view.interactor.setInteractorStyle(style); + + onScopeDispose(() => { + if (view.interactor.getInteractorStyle() === style) + view.interactor.setInteractorStyle(null); + style.delete(); + }); + + return { interactorStyle: style }; +} diff --git a/src/core/vtk/useVtkRepresentation.ts b/src/core/vtk/useVtkRepresentation.ts new file mode 100644 index 000000000..ba9ce65e9 --- /dev/null +++ b/src/core/vtk/useVtkRepresentation.ts @@ -0,0 +1,67 @@ +import { MaybeRef, onScopeDispose, unref, watchEffect } from 'vue'; +import { vtkObject } from '@kitware/vtk.js/interfaces'; +import vtkAbstractMapper from '@kitware/vtk.js/Rendering/Core/AbstractMapper'; +import vtkProp from '@kitware/vtk.js/Rendering/Core/Prop'; +import { VtkObjectConstructor } from '@/src/core/vtk/types'; +import { View } from '@/src/core/vtk/useVtkView'; +import { Maybe } from '@/src/types'; +import { onVTKEvent } from '@/src/composables/onVTKEvent'; + +type vtkPropWithMapperProperty< + M extends vtkAbstractMapper = vtkAbstractMapper, + P extends vtkObject = vtkObject +> = vtkProp & { + setMapper(m: M): void; + getProperty(): P; +}; + +export interface UseVtkRepresentationParameters { + view: View; + data: MaybeRef>; + vtkActorClass: VtkObjectConstructor; + vtkMapperClass: VtkObjectConstructor; +} + +export function useVtkRepresentation< + Actor extends vtkPropWithMapperProperty, + Mapper extends vtkAbstractMapper, + Property extends ReturnType +>({ + view, + data: dataObject, + vtkActorClass, + vtkMapperClass, +}: UseVtkRepresentationParameters) { + const actor = vtkActorClass.newInstance(); + const mapper = vtkMapperClass.newInstance(); + const property = actor.getProperty() as Property; + + actor.setMapper(mapper); + + watchEffect((onCleanup) => { + const data = unref(dataObject); + if (!data) return; + + const { renderer } = view; + mapper.setInputData(data, 0); + renderer.addActor(actor); + view.requestRender(); + + onCleanup(() => { + renderer.removeActor(actor); + }); + }); + + [actor, mapper, property].forEach((obj: vtkObject) => { + onVTKEvent(obj, 'onModified', () => { + view.requestRender(); + }); + }); + + onScopeDispose(() => { + actor.delete(); + mapper.delete(); + }); + + return { actor, mapper, property }; +} diff --git a/src/core/vtk/useVtkView.ts b/src/core/vtk/useVtkView.ts new file mode 100644 index 000000000..f3219e7eb --- /dev/null +++ b/src/core/vtk/useVtkView.ts @@ -0,0 +1,130 @@ +import { onVTKEvent } from '@/src/composables/onVTKEvent'; +import { Maybe } from '@/src/types'; +import { batchForNextTask } from '@/src/utils/batchForNextTask'; +import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow'; +import vtkRenderWindowInteractor from '@kitware/vtk.js/Rendering/Core/RenderWindowInteractor'; +import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; +import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; +import { useElementSize } from '@vueuse/core'; +import { + MaybeRef, + onScopeDispose, + unref, + watchEffect, + watchPostEffect, +} from 'vue'; + +export interface RequestRenderOptions { + immediate?: boolean; +} + +export interface View { + renderWindow: vtkRenderWindow; + renderer: vtkRenderer; + interactor: vtkRenderWindowInteractor; + renderWindowView: vtkOpenGLRenderWindow; + requestRender(opts?: RequestRenderOptions): void; +} + +export function useWebGLRenderWindow(container: MaybeRef>) { + const renderWindowView = vtkOpenGLRenderWindow.newInstance(); + + watchPostEffect((onCleanup) => { + const el = unref(container); + if (!el) return; + + renderWindowView.setContainer(el); + onCleanup(() => { + renderWindowView.setContainer(null as unknown as HTMLElement); + }); + }); + + onScopeDispose(() => { + renderWindowView.delete(); + }); + + return renderWindowView; +} + +export function useVtkView(container: MaybeRef>): View { + const renderer = vtkRenderer.newInstance(); + const renderWindow = vtkRenderWindow.newInstance(); + renderWindow.addRenderer(renderer); + + // the render window view + const renderWindowView = useWebGLRenderWindow(container); + renderWindow.addView(renderWindowView); + + onScopeDispose(() => { + renderWindow.removeView(renderWindowView); + }); + + // interactor + const interactor = vtkRenderWindowInteractor.newInstance(); + renderWindow.setInteractor(interactor); + + watchPostEffect((onCleanup) => { + const el = unref(container); + if (!el) return; + + interactor.initialize(); + interactor.setView(renderWindowView); + interactor.bindEvents(el); + onCleanup(() => { + if (interactor.getContainer()) interactor.unbindEvents(); + }); + }); + + // render API + const deferredRender = batchForNextTask(() => { + // don't need to re-render during animation + if (interactor.isAnimating()) return; + renderWindow.render(); + }); + + const immediateRender = () => { + if (interactor.isAnimating()) return; + renderWindow.render(); + }; + + const requestRender = ({ immediate } = { immediate: true }) => { + if (immediate) { + immediateRender(); + } + deferredRender(); + }; + + onVTKEvent(renderer, 'onModified', () => { + requestRender(); + }); + + // set size + const setSize = (width: number, height: number) => { + const scaledWidth = width * globalThis.devicePixelRatio; + const scaledHeight = height * globalThis.devicePixelRatio; + renderWindowView.setSize(scaledWidth, scaledHeight); + requestRender({ immediate: true }); + }; + + const { width, height } = useElementSize(container); + watchEffect(() => { + setSize(width.value, height.value); + }); + + // cleanup + onScopeDispose(() => { + renderWindow.removeRenderer(renderer); + + renderer.delete(); + renderWindow.delete(); + interactor.delete(); + }); + + return { + renderer, + renderWindow, + interactor, + renderWindowView, + requestRender, + }; +} diff --git a/src/core/vtk/vtkFieldRef.ts b/src/core/vtk/vtkFieldRef.ts new file mode 100644 index 000000000..d32a3d87b --- /dev/null +++ b/src/core/vtk/vtkFieldRef.ts @@ -0,0 +1,71 @@ +import { MaybeRef, Ref, computed, customRef, triggerRef, unref } from 'vue'; +import { vtkObject } from '@kitware/vtk.js/interfaces'; +import { capitalize } from '@kitware/vtk.js/macros'; +import { onVTKEvent } from '@/src/composables/onVTKEvent'; + +type NonEmptyString = T extends '' ? never : T; + +type FilterGetters = T extends `get${infer R}` + ? NonEmptyString> + : never; + +type GettableFields = FilterGetters; + +type NameToGetter = `get${Capitalize}`; + +type GetterReturnType = NameToGetter extends keyof T + ? T[NameToGetter] extends (...args: any[]) => infer R + ? R + : never + : never; + +type ArraySetter = (...args: any[]) => boolean; + +export function vtkFieldRef>( + obj: MaybeRef, + fieldName: F +): Ref> { + const getterName = `get${capitalize(fieldName)}` as keyof T; + const setterName = `set${capitalize(fieldName)}` as keyof T; + + const getter = computed( + () => unref(obj)[getterName] as () => GetterReturnType + ); + const setter = computed( + () => unref(obj)[setterName] as ((v: any) => boolean) | undefined + ); + + const ref = customRef>((track, trigger) => { + return { + get: () => { + track(); + return getter.value(); + }, + set: (v) => { + const set = setter.value; + if (!set) throw new Error(`No setter for field '${fieldName}'`); + + let changed = false; + // handle certain array setters not accepting an array as input + if (Array.isArray(v) && set.length === v.length) { + changed = (set as ArraySetter)(...v); + } else { + changed = set(v); + } + + // in the event a setter returns undefined, assume something changed. + if (changed === true || changed === undefined) { + trigger(); + } + }, + }; + }); + + const onModified = () => { + triggerRef(ref); + }; + + onVTKEvent(obj as vtkObject, 'onModified', onModified); + + return ref; +} diff --git a/src/shims-vtk.d.ts b/src/shims-vtk.d.ts index f1ee4b343..c37cb30c3 100644 --- a/src/shims-vtk.d.ts +++ b/src/shims-vtk.d.ts @@ -276,3 +276,23 @@ declare module '@kitware/vtk.js/Widgets/Widgets3D/ResliceCursorWidget' { // Just forwarding vtk-js's definition as default export: export default vtkResliceCursorWidget; } + +declare module '@kitware/vtk.js/Interaction/Widgets/PiecewiseGaussianWidget' { + import type { Vector2 } from '@kitware/vtk.js/types'; + import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; + + export interface vtkPiecewiseGaussianWidget {} + + function applyGaussianToPiecewiseFunction( + gaussians: any, + sampling: number, + rangeToUse: Vector2, + piecewiseFunction: vtkPiecewiseFunction + ): void; + + export declare const vtkPiecewiseGaussianWidget: { + applyGaussianToPiecewiseFunction: typeof applyGaussianToPiecewiseFunction; + }; + + export default vtkPiecewiseWidget; +} diff --git a/src/store/view-animation.ts b/src/store/view-animation.ts new file mode 100644 index 000000000..4680b0779 --- /dev/null +++ b/src/store/view-animation.ts @@ -0,0 +1,75 @@ +import { defineStore } from 'pinia'; +import { ref, shallowRef, triggerRef, computed } from 'vue'; + +export interface ViewFilterSpec { + byViewType?: string[]; + byViewIds?: string[]; +} + +export function matchesViewFilter( + viewID: string, + viewType: string, + filterSpec?: ViewFilterSpec +) { + if (!filterSpec) return true; + const { byViewType, byViewIds } = filterSpec; + if (byViewType && !byViewType.includes(viewType)) return false; + if (byViewIds && !byViewIds.includes(viewID)) return false; + return true; +} + +function mergeArrayFilters(arr1?: string[], arr2?: string[]) { + if (!arr1 || !arr2) return undefined; + return Array.from(new Set(arr1.concat(arr2))); +} + +/** + * Merges view filter specs. + * + * An undefined filter spec means no filter. + * @param filterSpecs + * @returns + */ +export function mergeViewFilters( + filterSpecs: Array +) { + if (filterSpecs.length === 0) return undefined; + return filterSpecs.reduce((result, spec) => { + if (result === undefined) return undefined; + const byViewIds = mergeArrayFilters(result?.byViewIds, spec?.byViewIds); + const byViewType = mergeArrayFilters(result?.byViewType, spec?.byViewType); + return { byViewIds, byViewType }; + }, filterSpecs[0] as ViewFilterSpec | undefined); +} + +const useViewAnimationStore = defineStore('viewAnimation', () => { + const animating = ref(false); + const requestors = shallowRef(new Map()); + const viewFilter = computed(() => { + return mergeViewFilters(Array.from(requestors.value.values())); + }); + + const requestAnimation = (requestor: any, filter?: ViewFilterSpec) => { + if (requestors.value.has(requestor)) return; + requestors.value.set(requestor, filter); + animating.value = true; + triggerRef(requestors); + }; + + const cancelAnimation = (requestor: any) => { + requestors.value.delete(requestor); + if (requestors.value.size === 0) { + animating.value = false; + } + triggerRef(requestors); + }; + + return { + animating, + viewFilter, + requestAnimation, + cancelAnimation, + }; +}); + +export default useViewAnimationStore; diff --git a/src/store/view-configs/volume-coloring.ts b/src/store/view-configs/volume-coloring.ts index e020e7236..24a8e749b 100644 --- a/src/store/view-configs/volume-coloring.ts +++ b/src/store/view-configs/volume-coloring.ts @@ -25,6 +25,8 @@ import { useImageStore } from '../datasets-images'; export const DEFAULT_AMBIENT = 0.2; export const DEFAULT_DIFFUSE = 0.7; export const DEFAULT_SPECULAR = 0.3; +export const DEFAULT_EDGE_GRADIENT = 0.2; +export const DEFAULT_SAMPLING_DISTANCE = 0.2; function getPresetFromImageModality(imageID: string) { const dicomStore = useDICOMStore(); diff --git a/src/types/index.ts b/src/types/index.ts index 500d1b7d6..ff53e745e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -57,3 +57,7 @@ export type DeepPartial = T extends object [P in keyof T]?: DeepPartial; } : T; + +export type FirstParam = T extends (first: infer R, ...args: any[]) => any + ? R + : never; diff --git a/src/types/vtk-types.ts b/src/types/vtk-types.ts index 2d91bca40..197540959 100644 --- a/src/types/vtk-types.ts +++ b/src/types/vtk-types.ts @@ -1,5 +1,7 @@ import { vtkAlgorithm, vtkObject } from '@kitware/vtk.js/interfaces'; import vtkDataSet from '@kitware/vtk.js/Common/DataModel/DataSet'; +import { View } from '@/src/core/vtk/useVtkView'; +import vtkInteractorStyle from '@kitware/vtk.js/Rendering/Core/InteractorStyle'; import vtkLPSView2DProxy from '../vtk/LPSView2DProxy'; import vtkLPSView3DProxy from '../vtk/LPSView3DProxy'; @@ -18,3 +20,8 @@ export interface vtkWriter extends vtkObject { } export type vtkLPSViewProxy = vtkLPSView2DProxy | vtkLPSView3DProxy; + +export interface VtkViewApi extends View { + interactorStyle: vtkInteractorStyle; + resetCamera(): void; +} diff --git a/src/utils/camera.ts b/src/utils/camera.ts new file mode 100644 index 000000000..fda93f7db --- /dev/null +++ b/src/utils/camera.ts @@ -0,0 +1,112 @@ +import { View } from '@/src/core/vtk/useVtkView'; +import { ImageMetadata } from '@/src/types/image'; +import { LPSAxisDir } from '@/src/types/lps'; +import { getLPSAxisFromDir, getLPSDirections } from '@/src/utils/lps'; +import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; +import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import { Vector2, Vector3 } from '@kitware/vtk.js/types'; +import { vec3 } from 'gl-matrix'; + +/** + * Given an eye frame, return the dimension indices corresponding to the horizontal and vertical dimensions. + * @param lookAxis + * @param eyeUpAxis + * @returns + */ +function eyeFrameDimIndices(lookAxis: 0 | 1 | 2, eyeUpAxis: 0 | 1 | 2) { + if (lookAxis === 0 && eyeUpAxis === 1) return [2, 1] as Vector2; + if (lookAxis === 0 && eyeUpAxis === 2) return [1, 2] as Vector2; + if (lookAxis === 1 && eyeUpAxis === 0) return [2, 0] as Vector2; + if (lookAxis === 1 && eyeUpAxis === 2) return [0, 2] as Vector2; + if (lookAxis === 2 && eyeUpAxis === 0) return [1, 0] as Vector2; + if (lookAxis === 2 && eyeUpAxis === 1) return [0, 1] as Vector2; + throw new Error(`Invalid lookAxis and eyeUpAxis: ${lookAxis}, ${eyeUpAxis}`); +} + +function computeParallelScale( + lookAxis: 0 | 1 | 2, + viewUpAxis: 0 | 1 | 2, + dimensions: Vector3 | vec3, + viewSize: Vector2 +) { + const [widthIndex, heightIndex] = eyeFrameDimIndices(lookAxis, viewUpAxis); + const width = dimensions[widthIndex]; + const height = dimensions[heightIndex]; + const dimAspect = width / height; + + const [viewWidth, viewHeight] = viewSize; + const viewAspect = viewWidth / viewHeight; + + let scale = height / 2; + if (viewAspect < dimAspect) { + scale = width / 2 / viewAspect; + } + + return scale; +} + +export function resizeToFit( + view: View, + lookAxis: 0 | 1 | 2, + upAxis: 0 | 1 | 2, + dimensions: Vector3 | vec3 +) { + const camera = view.renderer.getActiveCamera(); + camera.setParallelScale( + computeParallelScale( + lookAxis, + upAxis, + dimensions, + view.renderWindowView.getSize() + ) + ); +} + +export function positionCamera( + camera: vtkCamera, + directionOfProjection: Vector3, + viewUp: Vector3, + focalPoint: Vector3 +) { + const position = vec3.clone(focalPoint) as Vector3; + vec3.sub(position, position, directionOfProjection); + camera.setFocalPoint(...focalPoint); + camera.setPosition(...position); + camera.setDirectionOfProjection(...directionOfProjection); + camera.setViewUp(...viewUp); +} + +export function resetCameraToImage( + view: View, + metadata: ImageMetadata, + viewDirection: LPSAxisDir, + viewUp: LPSAxisDir +) { + const { worldBounds, orientation } = metadata; + const lpsDirections = getLPSDirections(orientation); + + const center = vtkBoundingBox.getCenter(worldBounds); + const camera = view.renderer.getActiveCamera(); + + const directionOfProjection = lpsDirections[viewDirection] as Vector3; + const camerViewUp = lpsDirections[viewUp] as Vector3; + positionCamera(camera, directionOfProjection, camerViewUp, center); + + view.renderer.resetCamera(worldBounds); + view.requestRender(); +} +export function resizeToFitImage( + view: View, + metadata: ImageMetadata, + viewDirection: LPSAxisDir, + viewUp: LPSAxisDir +) { + const { lpsOrientation, dimensions } = metadata; + const viewDirAxis = getLPSAxisFromDir(viewDirection); + const viewUpAxis = getLPSAxisFromDir(viewUp); + const lookAxis = lpsOrientation[viewDirAxis]; + const upAxis = lpsOrientation[viewUpAxis]; + + resizeToFit(view, lookAxis, upAxis, dimensions); + view.requestRender(); +} diff --git a/src/utils/guardedWritableRef.ts b/src/utils/guardedWritableRef.ts new file mode 100644 index 000000000..5384e67fd --- /dev/null +++ b/src/utils/guardedWritableRef.ts @@ -0,0 +1,16 @@ +import { Ref, computed } from 'vue'; + +export function guardedWritableRef( + obj: Ref, + accept: (incoming: T, current: T) => boolean +) { + return computed({ + get: () => obj.value, + set: (v) => { + if (accept(v, obj.value)) { + // eslint-disable-next-line no-param-reassign + obj.value = v; + } + }, + }); +} diff --git a/src/utils/volumeProperties.ts b/src/utils/volumeProperties.ts new file mode 100644 index 000000000..105cf4345 --- /dev/null +++ b/src/utils/volumeProperties.ts @@ -0,0 +1,225 @@ +import { + DEFAULT_AMBIENT, + DEFAULT_DIFFUSE, + DEFAULT_SPECULAR, +} from '@/src/store/view-configs/volume-coloring'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { getDiagonalLength } from '@kitware/vtk.js/Common/DataModel/BoundingBox'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; +import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; +import vtkVolumeProperty from '@kitware/vtk.js/Rendering/Core/VolumeProperty'; +import { Vector3 } from '@kitware/vtk.js/types'; +import { vec3 } from 'gl-matrix'; + +/** + * Sets the volume sampling distance. + * @param mapper + * @param distance A value betweeen 0 and 1. + * @param imageData + */ +export function setSamplingDistance( + mapper: vtkVolumeMapper, + distance: number, + imageData: vtkImageData +) { + const sampleDistance = + 0.7 * + Math.sqrt( + imageData + .getSpacing() + .map((v) => v * v) + .reduce((a, b) => a + b, 0) + ); + mapper.setSampleDistance(sampleDistance * 2 ** (distance * 3.0 - 1.5)); +} + +/** + * Sets the edge gradient. + * @param property + * @param edgeGradient A value between 0 and 1. + * @param dataArray + */ +export function setEdgeGradient( + property: vtkVolumeProperty, + edgeGradient: number, + dataArray: vtkDataArray +) { + const numberOfComponents = dataArray.getNumberOfComponents(); + for (let component = 0; component < numberOfComponents; component++) { + if (edgeGradient === 0) { + property.setUseGradientOpacity(component, false); + // eslint-disable-next-line no-continue + continue; + } + + property.setUseGradientOpacity(component, true); + + const range = dataArray.getRange(component); + const width = range[1] - range[0]; + const minV = Math.max(0.0, edgeGradient - 0.3) / 0.7; + const minGradOpacity = + minV > 0 ? Math.exp(Math.log(width * 0.2) * minV ** 2) : 0; + const maxGradOpacity = Math.exp(Math.log(width * edgeGradient ** 2)); + + property.setGradientOpacityMinimumValue(component, minGradOpacity); + property.setGradientOpacityMaximumValue(component, maxGradOpacity); + } +} + +export interface SetCinematicLightingParameters { + renderer: vtkRenderer; + enabled: boolean; + center: Vector3; + lightFollowsCamera: boolean; +} + +export function setCinematicLighting({ + renderer, + enabled, + center, + lightFollowsCamera, +}: SetCinematicLightingParameters) { + if (renderer.getLights().length === 0) { + renderer.createLight(); + } + const light = renderer.getLights()[0]; + if (enabled) { + light.setFocalPoint(...center); + light.setColor(1, 1, 1); + light.setIntensity(1); + light.setConeAngle(90); + light.setPositional(true); + renderer.setTwoSidedLighting(false); + if (lightFollowsCamera) { + light.setLightTypeToHeadLight(); + renderer.updateLightsGeometryToFollowCamera(); + } else { + light.setLightTypeToSceneLight(); + } + } else { + light.setPositional(false); + } +} + +export interface SetCinematicVolumeSamplingParameters { + enabled: boolean; + mapper: vtkVolumeMapper; + quality: number; + isAnimating: boolean; + image: vtkImageData; +} + +export function setCinematicVolumeSampling({ + enabled, + mapper, + quality, + isAnimating, + image, +}: SetCinematicVolumeSamplingParameters) { + if (isAnimating) { + mapper.setSampleDistance(0.75); + mapper.setMaximumSamplesPerRay(1000); + mapper.setGlobalIlluminationReach(0); + mapper.setComputeNormalFromOpacity(false); + } else { + const dims = image.getDimensions(); + const spacing = image.getSpacing(); + const spatialDiagonal = vec3.length( + vec3.fromValues( + dims[0] * spacing[0], + dims[1] * spacing[1], + dims[2] * spacing[2] + ) + ); + + // Use the average spacing for sampling by default + let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0; + // Adjust the volume sampling by the quality slider value + sampleDistance /= quality > 1 ? 0.5 * quality ** 2 : 1.0; + const samplesPerRay = spatialDiagonal / sampleDistance + 1; + mapper.setMaximumSamplesPerRay(samplesPerRay); + mapper.setSampleDistance(sampleDistance); + // Adjust the global illumination reach by volume quality slider + mapper.setGlobalIlluminationReach(enabled ? 0.25 * quality : 0); + mapper.setComputeNormalFromOpacity(!enabled && quality > 2); + } +} + +export interface SetCinematicVolumeShadingParameters { + enabled: boolean; + image: vtkImageData; + property: vtkVolumeProperty; + ambient: number; + diffuse: number; + specular: number; +} + +export function setCinematicVolumeShading({ + enabled, + image, + property, + ambient, + diffuse, + specular, +}: SetCinematicVolumeShadingParameters) { + property.setScalarOpacityUnitDistance( + 0, + (0.5 * getDiagonalLength(image.getBounds())) / + Math.max(...image.getDimensions()) + ); + + property.setShade(true); + property.setUseGradientOpacity(0, !enabled); + property.setGradientOpacityMinimumValue(0, 0.0); + const dataRange = image.getPointData().getScalars().getRange(); + property.setGradientOpacityMaximumValue( + 0, + (dataRange[1] - dataRange[0]) * 0.01 + ); + property.setGradientOpacityMinimumOpacity(0, 0.0); + property.setGradientOpacityMaximumOpacity(0, 1.0); + + // do not toggle these parameters when animating + property.setAmbient(enabled ? ambient : DEFAULT_AMBIENT); + property.setDiffuse(enabled ? diffuse : DEFAULT_DIFFUSE); + property.setSpecular(enabled ? specular : DEFAULT_SPECULAR); +} + +export interface SetCinematicVolumeScatterParameters { + enabled: boolean; + mapper: vtkVolumeMapper; + blending: number; +} + +export function setCinematicVolumeScatter({ + enabled, + mapper, + blending, +}: SetCinematicVolumeScatterParameters) { + mapper.setVolumetricScatteringBlending(enabled ? blending : 0); +} + +export interface SetCinematicLocalAmbientOcclusionParameters { + enabled: boolean; + mapper: vtkVolumeMapper; + kernelSize: number; + kernelRadius: number; +} + +export function setCinematicLocalAmbientOcclusion({ + enabled, + mapper, + kernelSize, + kernelRadius, +}: SetCinematicLocalAmbientOcclusionParameters) { + if (enabled) { + mapper.setLocalAmbientOcclusion(true); + mapper.setLAOKernelSize(kernelSize); + mapper.setLAOKernelRadius(kernelRadius); + } else { + mapper.setLocalAmbientOcclusion(false); + mapper.setLAOKernelSize(0); + mapper.setLAOKernelRadius(0); + } +} diff --git a/src/utils/vtk-helpers.ts b/src/utils/vtk-helpers.ts index bb4d86b9c..7d9b9c17b 100644 --- a/src/utils/vtk-helpers.ts +++ b/src/utils/vtk-helpers.ts @@ -1,10 +1,14 @@ import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; +import type { vtkObject } from '@kitware/vtk.js/interfaces'; import { intersectDisplayWithPlane } from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; -import { OpacityFunction } from '../types/views'; +import { OpacityFunction } from '@/src/types/views'; +import vtkFieldData from '@kitware/vtk.js/Common/DataModel/DataSetAttributes/FieldData'; +import { Maybe } from '@/src/types'; export function computeWorldToDisplay( xyz: Vector3, @@ -134,7 +138,7 @@ export function getShiftedOpacityFromPreset( // but preset values of zero should not const shifted = y && y - shiftAlpha; const yVal = Math.max(Math.min(shifted, 1), 0); - return [(x - xmin) / width + shift, yVal]; + return [(x - xmin) / width + shift, yVal] as Vector2; }); } return null; @@ -220,3 +224,55 @@ export function getOpacityRangeFromPreset(presetName: string) { } return null; } + +/** + * Applies a set of points to a piecewise function. + * @param pwf + * @param points + * @param range + */ +export function applyPointsToPiecewiseFunction( + pwf: vtkPiecewiseFunction, + points: Vector2[], + range: Vector2 +) { + const width = range[1] - range[0]; + const rescaled = points.map(([x, y]) => [x * width + range[0], y]); + + pwf.removeAllPoints(); + rescaled.forEach(([x, y]) => pwf.addPoint(x, y)); +} + +/** + * Applies a set of nodes to a piecewise function. + * @param nodes + * @param range + * @param pwf + */ +export function applyNodesToPiecewiseFunction( + pwf: vtkPiecewiseFunction, + nodes: any[], + range: Vector2 +) { + const width = range[1] - range[0]; + const rescaled = nodes.map((n) => ({ ...n, x: n.x * width + range[0] })); + + pwf.setNodes(rescaled); +} + +/** + * Gets the data array given by the arrayName and arrayLocation. + * @param obj + * @param arrayName + * @param arrayLocation + * @returns + */ +export function getDataArray( + obj: vtkObject, + arrayName: string, + arrayLocation: 'pointData' | 'cellData' +) { + const field: Maybe = obj.getReferenceByName(arrayLocation); + const array = field?.getArrayByName(arrayName); + return array; +} diff --git a/src/utils/watchCompare.ts b/src/utils/watchCompare.ts new file mode 100644 index 000000000..5b0c22a16 --- /dev/null +++ b/src/utils/watchCompare.ts @@ -0,0 +1,75 @@ +import { + WatchCallback, + WatchOptions, + WatchSource, + WatchStopHandle, + watch, +} from "vue"; + +export interface WatchCompareOptions + extends WatchOptions { + compare: (a: any, b: any) => boolean; +} + +// Internal Types from vue +export type MultiWatchSources = (WatchSource | object)[]; + +export type MapSources = { + [K in keyof T]: T[K] extends WatchSource ? V : never; +}; +export type MapOldSources = { + [K in keyof T]: T[K] extends WatchSource + ? Immediate extends true + ? V | undefined + : V + : never; +}; + +// overloads +export function watchCompare< + T extends Readonly[]>, + Immediate extends Readonly = false +>( + sources: [...T], + cb: WatchCallback, MapOldSources>, + options?: WatchCompareOptions +): WatchStopHandle; +export function watchCompare = false>( + source: WatchSource, + cb: WatchCallback, + options?: WatchCompareOptions +): WatchStopHandle; +export function watchCompare< + T extends object, + Immediate extends Readonly = false +>( + source: T, + cb: WatchCallback, + options?: WatchCompareOptions +): WatchStopHandle; + +export function watchCompare = false>( + sources: any, + callback: any, + options?: WatchCompareOptions +): WatchStopHandle { + return watch( + sources, + (newData, oldData, onCleanup) => { + let cleanupFn: (() => void) | null = null; + const innerOnCleanup = (fn: () => void) => { + cleanupFn = fn; + }; + const changed = options?.compare(newData, oldData); + + onCleanup(() => { + if (!changed) return; + cleanupFn?.(); + }); + + if (changed) return; + callback(newData, oldData, innerOnCleanup); + }, + options + ); +} From 02e8e520a2af0b824bd54a0abae9560af4d5e31f Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 20 Feb 2024 17:42:54 -0500 Subject: [PATCH 02/50] feat: set shading on all components --- .../vtk/VtkBaseVolumeRepresentation.vue | 40 +++++++++---------- src/utils/volumeProperties.ts | 13 +++--- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/components/vtk/VtkBaseVolumeRepresentation.vue b/src/components/vtk/VtkBaseVolumeRepresentation.vue index 1f73306d1..afdb0b79b 100644 --- a/src/components/vtk/VtkBaseVolumeRepresentation.vue +++ b/src/components/vtk/VtkBaseVolumeRepresentation.vue @@ -9,8 +9,6 @@ import useVolumeColoringStore, { DEFAULT_AMBIENT, DEFAULT_DIFFUSE, DEFAULT_SPECULAR, - DEFAULT_EDGE_GRADIENT, - DEFAULT_SAMPLING_DISTANCE, } from '@/src/store/view-configs/volume-coloring'; import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; @@ -18,8 +16,6 @@ import { useVolumeColoringInitializer } from '@/src/composables/useVolumeColorin import { isViewAnimatingNew as isViewAnimating } from '@/src/composables/isViewAnimating'; import { InterpolationType } from '@kitware/vtk.js/Rendering/Core/VolumeProperty/Constants'; import { - setEdgeGradient, - setSamplingDistance, setCinematicLighting, setCinematicLocalAmbientOcclusion, setCinematicVolumeSampling, @@ -67,14 +63,6 @@ rep.property.setSpecular(DEFAULT_SPECULAR); onVTKEvent(obj, 'onModified', view.requestRender); }); -// set initial edge gradient + sampling distance -watchEffect(() => { - if (!imageData.value) return; - const dataArray = imageData.value.getPointData().getScalars(); - setEdgeGradient(rep.property, DEFAULT_EDGE_GRADIENT, dataArray); - setSamplingDistance(rep.mapper, DEFAULT_SAMPLING_DISTANCE, imageData.value); -}); - // cinematic volume rendering const cvrParams = computed(() => coloringConfig.value?.cvr); const center = computed(() => @@ -87,7 +75,7 @@ watchEffect(() => { if (!cvrParams.value || !image) return; const { - enabled, + enabled: cvrEnabled, lightFollowsCamera, ambient, diffuse, @@ -101,25 +89,34 @@ watchEffect(() => { } = cvrParams.value; const { property, mapper } = rep; + const enabled = cvrEnabled && !isAnimating.value; + const dataArray = image.getPointData().getScalars(); + setCinematicLighting({ enabled, renderer: view.renderer, lightFollowsCamera, center: center.value, }); - setCinematicVolumeShading({ - enabled, - image, - ambient, - diffuse, - specular, - property, - }); + + for (let comp = 0; comp < dataArray.getNumberOfComponents(); comp++) { + setCinematicVolumeShading({ + enabled, + image, + ambient, + diffuse, + specular, + property, + component: comp, + }); + } + setCinematicVolumeScatter({ enabled: enabled && useVolumetricScatteringBlending, mapper, blending: volumetricScatteringBlending, }); + setCinematicVolumeSampling({ enabled, image, @@ -127,6 +124,7 @@ watchEffect(() => { quality: volumeQuality, isAnimating: isAnimating.value, }); + setCinematicLocalAmbientOcclusion({ enabled: enabled && useLocalAmbientOcclusion, kernelRadius: laoKernelRadius, diff --git a/src/utils/volumeProperties.ts b/src/utils/volumeProperties.ts index 105cf4345..34aa92e48 100644 --- a/src/utils/volumeProperties.ts +++ b/src/utils/volumeProperties.ts @@ -153,6 +153,7 @@ export interface SetCinematicVolumeShadingParameters { ambient: number; diffuse: number; specular: number; + component?: number; } export function setCinematicVolumeShading({ @@ -162,6 +163,7 @@ export function setCinematicVolumeShading({ ambient, diffuse, specular, + component = 0, }: SetCinematicVolumeShadingParameters) { property.setScalarOpacityUnitDistance( 0, @@ -170,15 +172,15 @@ export function setCinematicVolumeShading({ ); property.setShade(true); - property.setUseGradientOpacity(0, !enabled); - property.setGradientOpacityMinimumValue(0, 0.0); + property.setUseGradientOpacity(component, !enabled); + property.setGradientOpacityMinimumValue(component, 0.0); const dataRange = image.getPointData().getScalars().getRange(); property.setGradientOpacityMaximumValue( - 0, + component, (dataRange[1] - dataRange[0]) * 0.01 ); - property.setGradientOpacityMinimumOpacity(0, 0.0); - property.setGradientOpacityMaximumOpacity(0, 1.0); + property.setGradientOpacityMinimumOpacity(component, 0.0); + property.setGradientOpacityMaximumOpacity(component, 1.0); // do not toggle these parameters when animating property.setAmbient(enabled ? ambient : DEFAULT_AMBIENT); @@ -197,6 +199,7 @@ export function setCinematicVolumeScatter({ mapper, blending, }: SetCinematicVolumeScatterParameters) { + (window as any).am = mapper; mapper.setVolumetricScatteringBlending(enabled ? blending : 0); } From b1d2726d4758cafedc501ed58d9d4e64fd75152d Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 20 Feb 2024 17:52:28 -0500 Subject: [PATCH 03/50] feat(VtkSliceView): zoom to mouse --- src/components/vtk/VtkSliceView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index b321ea3c4..1ca2dd022 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -2,7 +2,6 @@ import { ref, toRefs, computed, provide, toRaw } from 'vue'; import vtkInteractorStyleManipulator from '@kitware/vtk.js/Interaction/Style/InteractorStyleManipulator'; import vtkMouseCameraTrackballPanManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballPanManipulator'; -import vtkMouseCameraTrackballZoomManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballZoomManipulator'; import vtkMouseRangeManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseRangeManipulator'; import { useVtkView } from '@/src/core/vtk/useVtkView'; import { useImage } from '@/src/composables/useCurrentImage'; @@ -22,6 +21,7 @@ import { useAutoFitState } from '@/src/composables/useAutoFitState'; import { Maybe } from '@/src/types'; import { VtkViewApi } from '@/src/types/vtk-types'; import { VtkViewContext } from '@/src/components/vtk/context'; +import vtkMouseCameraTrackballZoomToMouseManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballZoomToMouseManipulator'; interface Props { viewId: string; @@ -59,7 +59,7 @@ useVtkInteractionManipulator( ); useVtkInteractionManipulator( interactorStyle, - vtkMouseCameraTrackballZoomManipulator, + vtkMouseCameraTrackballZoomToMouseManipulator, { button: 3 } ); const { instance: rangeManipulator } = useVtkInteractionManipulator( From 0b63b71c2549a0dadac3408339e7951a1fdd2fe6 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 20 Feb 2024 22:11:11 -0500 Subject: [PATCH 04/50] fix(useVtkView): ensure non-zero size --- src/core/vtk/useVtkView.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/vtk/useVtkView.ts b/src/core/vtk/useVtkView.ts index f3219e7eb..a4ad63ba4 100644 --- a/src/core/vtk/useVtkView.ts +++ b/src/core/vtk/useVtkView.ts @@ -100,8 +100,10 @@ export function useVtkView(container: MaybeRef>): View { // set size const setSize = (width: number, height: number) => { - const scaledWidth = width * globalThis.devicePixelRatio; - const scaledHeight = height * globalThis.devicePixelRatio; + // ensure we have a non-zero size, otherwise + // the framebuffers might not be populated correctly + const scaledWidth = Math.max(1, width * globalThis.devicePixelRatio); + const scaledHeight = Math.max(1, height * globalThis.devicePixelRatio); renderWindowView.setSize(scaledWidth, scaledHeight); requestRender({ immediate: true }); }; From de5f8112423c4e9f5e40ef47a17fd3180b72764d Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 21 Feb 2024 16:41:58 -0500 Subject: [PATCH 05/50] fix: animation listener should be at viewer level Also unifies the viewer props via LayoutViewProps. --- src/components/SliceVolumeViewer.vue | 10 ++++++---- src/components/VolumeViewer.vue | 11 ++++++----- src/components/vtk/VtkBaseVolumeRepresentation.vue | 8 ++------ src/composables/useViewAnimationListener.ts | 10 +++++++--- src/types/index.ts | 8 ++++++++ 5 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/components/SliceVolumeViewer.vue b/src/components/SliceVolumeViewer.vue index 6660569df..88bc73ffd 100644 --- a/src/components/SliceVolumeViewer.vue +++ b/src/components/SliceVolumeViewer.vue @@ -66,14 +66,14 @@ import { LPSAxisDir } from '@/src/types/lps'; import { getLPSAxisFromDir } from '@/src/utils/lps'; import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue'; import { VtkViewApi } from '@/src/types/vtk-types'; +import { LayoutViewProps } from '@/src/types'; import VtkBaseSliceRepresentation from '@/src/components/vtk/VtkBaseSliceRepresentation.vue'; import VtkSegmentationSliceRepresentation from '@/src/components/vtk/VtkSegmentationSliceRepresentation.vue'; import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; +import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; -interface Props { - id: string; - viewType: string; +interface Props extends LayoutViewProps { viewDirection: LPSAxisDir; viewUp: LPSAxisDir; } @@ -82,7 +82,7 @@ const vtkView = ref(); const props = defineProps(); -const { viewDirection, viewUp } = toRefs(props); +const { id: viewId, type: viewType, viewDirection, viewUp } = toRefs(props); const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value)); const hover = ref(false); @@ -92,6 +92,8 @@ function resetCamera() { vtkView.value.resetCamera(); } +useViewAnimationListener(vtkView, viewId, viewType); + // base image const { currentImageID, currentLayers } = useCurrentImage(); diff --git a/src/components/VolumeViewer.vue b/src/components/VolumeViewer.vue index 3f8688447..ef9b50db1 100644 --- a/src/components/VolumeViewer.vue +++ b/src/components/VolumeViewer.vue @@ -25,7 +25,6 @@ > @@ -41,11 +40,11 @@ import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { LPSAxisDir } from '@/src/types/lps'; import VtkVolumeView from '@/src/components/vtk/VtkVolumeView.vue'; import { VtkViewApi } from '@/src/types/vtk-types'; +import { LayoutViewProps } from '@/src/types'; import VtkBaseVolumeRepresentation from '@/src/components/vtk/VtkBaseVolumeRepresentation.vue'; +import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; -interface Props { - id: string; - type: string; +interface Props extends LayoutViewProps { viewDirection: LPSAxisDir; viewUp: LPSAxisDir; } @@ -54,13 +53,15 @@ const vtkView = ref(); const props = defineProps(); -const { viewDirection, viewUp } = toRefs(props); +const { id: viewId, type: viewType, viewDirection, viewUp } = toRefs(props); function resetCamera() { if (!vtkView.value) return; vtkView.value.resetCamera(); } +useViewAnimationListener(vtkView, viewId, viewType); + // base image const { currentImageID } = useCurrentImage(); diff --git a/src/components/vtk/VtkBaseVolumeRepresentation.vue b/src/components/vtk/VtkBaseVolumeRepresentation.vue index afdb0b79b..ae18b27fc 100644 --- a/src/components/vtk/VtkBaseVolumeRepresentation.vue +++ b/src/components/vtk/VtkBaseVolumeRepresentation.vue @@ -23,24 +23,20 @@ import { setCinematicVolumeShading, } from '@/src/utils/volumeProperties'; import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; -import { vtkObject } from '@kitware/vtk.js/interfaces'; +import type { vtkObject } from '@kitware/vtk.js/interfaces'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; -import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; interface Props { viewId: string; - viewType: string; imageId: Maybe; } const props = defineProps(); -const { viewId: viewID, imageId: imageID, viewType } = toRefs(props); +const { viewId: viewID, imageId: imageID } = toRefs(props); const view = inject(VtkViewContext); if (!view) throw new Error('No VtkView'); -useViewAnimationListener(view, viewID, viewType); - const { imageData, metadata: imageMetadata } = useImage(imageID); const coloringConfig = computed(() => useVolumeColoringStore().getConfig(viewID.value, imageID.value) diff --git a/src/composables/useViewAnimationListener.ts b/src/composables/useViewAnimationListener.ts index 27e40fbc3..680bf9472 100644 --- a/src/composables/useViewAnimationListener.ts +++ b/src/composables/useViewAnimationListener.ts @@ -2,11 +2,12 @@ import { View } from '@/src/core/vtk/useVtkView'; import useViewAnimationStore, { matchesViewFilter, } from '@/src/store/view-animation'; +import { Maybe } from '@/src/types'; import { storeToRefs } from 'pinia'; import { MaybeRef, computed, unref, watchEffect } from 'vue'; export function useViewAnimationListener( - view: View, + view: MaybeRef>, viewId: MaybeRef, viewType: MaybeRef ) { @@ -19,11 +20,14 @@ export function useViewAnimationListener( let requested = false; watchEffect(() => { + const viewVal = unref(view); + if (!viewVal) return; + if (!animating.value) { - view.interactor.cancelAnimation(store); + viewVal.interactor.cancelAnimation(store); requested = false; } else if (!requested && canAnimate.value) { - view.interactor.requestAnimation(store); + viewVal.interactor.requestAnimation(store); requested = true; } }); diff --git a/src/types/index.ts b/src/types/index.ts index ff53e745e..9cf7311eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -61,3 +61,11 @@ export type DeepPartial = T extends object export type FirstParam = T extends (first: infer R, ...args: any[]) => any ? R : never; + +/** + * The props passed to components when used as part of the LayoutGrid. + */ +export interface LayoutViewProps { + id: string; + type: string; +} From cd6670c2bcd81d39082a99dbc854952bd964cbe3 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 21 Feb 2024 16:44:42 -0500 Subject: [PATCH 06/50] fix(VtkViewApi): interactorStyle is optional --- src/types/vtk-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/vtk-types.ts b/src/types/vtk-types.ts index 197540959..2cc05a9d4 100644 --- a/src/types/vtk-types.ts +++ b/src/types/vtk-types.ts @@ -22,6 +22,6 @@ export interface vtkWriter extends vtkObject { export type vtkLPSViewProxy = vtkLPSView2DProxy | vtkLPSView3DProxy; export interface VtkViewApi extends View { - interactorStyle: vtkInteractorStyle; + interactorStyle?: vtkInteractorStyle; resetCamera(): void; } From 47183fddd217aaa2a3badd103ec5df39978002b6 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 21 Feb 2024 16:48:14 -0500 Subject: [PATCH 07/50] feat: support disabling auto camera reset --- src/components/vtk/VtkSliceView.vue | 16 +++++++++++++--- src/components/vtk/VtkVolumeView.vue | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index 1ca2dd022..0d0f1715f 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -28,14 +28,18 @@ interface Props { imageId: Maybe; viewDirection: LPSAxisDir; viewUp: LPSAxisDir; + disableAutoResetCamera?: boolean; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + disableAutoResetCamera: false, +}); const { viewId: viewID, imageId: imageID, viewDirection, viewUp, + disableAutoResetCamera, } = toRefs(props); const vtkContainerRef = ref(); @@ -125,7 +129,10 @@ function autoFitImage() { }); } -useResizeObserver(vtkContainerRef, autoFitImage); +useResizeObserver(vtkContainerRef, () => { + if (disableAutoResetCamera.value) return; + autoFitImage(); +}); function resetCamera() { autoFit.value = true; @@ -140,7 +147,10 @@ function resetCamera() { }); } -watchImmediate([viewID, imageID], resetCamera); +watchImmediate([disableAutoResetCamera, viewID, imageID], (noAutoReset) => { + if (noAutoReset) return; + resetCamera(); +}); // persistent camera config usePersistCameraConfig(viewID, imageID, view.renderer.getActiveCamera()); diff --git a/src/components/vtk/VtkVolumeView.vue b/src/components/vtk/VtkVolumeView.vue index f4990a3bb..6ed2d8127 100644 --- a/src/components/vtk/VtkVolumeView.vue +++ b/src/components/vtk/VtkVolumeView.vue @@ -30,14 +30,18 @@ interface Props { imageId: Maybe; viewDirection: LPSAxisDir; viewUp: LPSAxisDir; + disableAutoResetCamera?: boolean; } -const props = defineProps(); +const props = withDefaults(defineProps(), { + disableAutoResetCamera: false, +}); const { viewId: viewID, imageId: imageID, viewDirection, viewUp, + disableAutoResetCamera, } = toRefs(props); const vtkContainerRef = ref(); @@ -91,7 +95,10 @@ function resetCamera() { ); } -watchImmediate([viewID, imageID], resetCamera); +watchImmediate([disableAutoResetCamera, viewID, imageID], (noAutoReset) => { + if (noAutoReset) return; + resetCamera(); +}); // persistent camera config usePersistCameraConfig(viewID, imageID, view.renderer.getActiveCamera()); From 39db5648a9ff3f7dcc34d92a14907535ccbbf646 Mon Sep 17 00:00:00 2001 From: Forrest Date: Fri, 23 Feb 2024 10:07:27 -0500 Subject: [PATCH 08/50] refactor: rename to VolumeSliceViewer --- src/components/{SliceVolumeViewer.vue => VolumeSliceViewer.vue} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/components/{SliceVolumeViewer.vue => VolumeSliceViewer.vue} (100%) diff --git a/src/components/SliceVolumeViewer.vue b/src/components/VolumeSliceViewer.vue similarity index 100% rename from src/components/SliceVolumeViewer.vue rename to src/components/VolumeSliceViewer.vue From 062e66a35eb9c30aa16e4a7f5eed64b845a76d3f Mon Sep 17 00:00:00 2001 From: Forrest Date: Fri, 23 Feb 2024 10:08:02 -0500 Subject: [PATCH 09/50] fix: markRaw instead of toRaw --- src/components/vtk/VtkSliceView.vue | 4 ++-- src/components/vtk/VtkVolumeView.vue | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index 0d0f1715f..06fbc10b4 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -1,5 +1,5 @@ + + + diff --git a/src/components/tools/ResliceCursorToolNew.vue b/src/components/tools/ResliceCursorToolNew.vue new file mode 100644 index 000000000..eeb4258c4 --- /dev/null +++ b/src/components/tools/ResliceCursorToolNew.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue b/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue new file mode 100644 index 000000000..659b97100 --- /dev/null +++ b/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/core/vtk/onViewMounted.ts b/src/core/vtk/onViewMounted.ts new file mode 100644 index 000000000..e36431fb4 --- /dev/null +++ b/src/core/vtk/onViewMounted.ts @@ -0,0 +1,36 @@ +import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; +import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; +import { whenever } from '@vueuse/core'; +import { computed, onUnmounted } from 'vue'; + +function isViewMounted(renderWindowView: vtkOpenGLRenderWindow) { + const container = vtkFieldRef(renderWindowView, 'container'); + return computed(() => !!container.value); +} + +export function onViewMounted( + renderWindowView: vtkOpenGLRenderWindow, + callback: () => void +) { + const isMounted = isViewMounted(renderWindowView); + whenever(isMounted, () => { + callback(); + }); +} + +export function onViewUnmounted( + renderWindowView: vtkOpenGLRenderWindow, + callback: () => void +) { + const isMounted = isViewMounted(renderWindowView); + whenever( + computed(() => !isMounted.value), + () => { + callback(); + } + ); + + onUnmounted(() => { + callback(); + }); +} diff --git a/src/core/vtk/useResliceRepresentation.ts b/src/core/vtk/useResliceRepresentation.ts new file mode 100644 index 000000000..21413d409 --- /dev/null +++ b/src/core/vtk/useResliceRepresentation.ts @@ -0,0 +1,21 @@ +import { MaybeRef } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkImageSlice from '@kitware/vtk.js/Rendering/Core/ImageSlice'; +import { useVtkRepresentation } from '@/src/core/vtk/useVtkRepresentation'; +import { Maybe } from '@/src/types'; +import { View } from '@/src/core/vtk/types'; +import vtkImageResliceMapper from '@kitware/vtk.js/Rendering/Core/ImageResliceMapper'; + +export function useResliceRepresentation( + view: View, + imageData: MaybeRef> +) { + const sliceRep = useVtkRepresentation({ + view, + data: imageData, + vtkActorClass: vtkImageSlice, + vtkMapperClass: vtkImageResliceMapper, + }); + + return sliceRep; +} diff --git a/src/shims-vtk.d.ts b/src/shims-vtk.d.ts index c37cb30c3..08fe4347a 100644 --- a/src/shims-vtk.d.ts +++ b/src/shims-vtk.d.ts @@ -271,6 +271,8 @@ declare module '@kitware/vtk.js/Widgets/Widgets3D/ResliceCursorWidget' { getCenter(): Vector3; setScrollingMethod(mode: number): boolean; setOpacity(opacity: number): boolean; + setImage(image: vtkImageData): boolean; + getImage(): vtkImageData; } // Just forwarding vtk-js's definition as default export: diff --git a/src/store/reslice-cursor.ts b/src/store/reslice-cursor.ts new file mode 100644 index 000000000..5ce098757 --- /dev/null +++ b/src/store/reslice-cursor.ts @@ -0,0 +1,72 @@ +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { ImageMetadata } from '@/src/types/image'; +import { ViewTypes } from '@kitware/vtk.js/Widgets/Core/WidgetManager/Constants'; +import vtkResliceCursorWidget, { + ResliceCursorWidgetState, +} from '@kitware/vtk.js/Widgets/Widgets3D/ResliceCursorWidget'; +import type { Vector3 } from '@kitware/vtk.js/types'; +import { defineStore } from 'pinia'; +import { toRaw, watchEffect } from 'vue'; + +function resetReslicePlanes( + resliceCursorState: ResliceCursorWidgetState, + imageMetadata: ImageMetadata +) { + const { Inferior, Anterior, Superior, Left } = toRaw( + imageMetadata.lpsOrientation + ); + const planes = { + [ViewTypes.YZ_PLANE]: { + normal: Left as Vector3, + viewUp: Superior as Vector3, + }, + [ViewTypes.XZ_PLANE]: { + normal: Anterior as Vector3, + viewUp: Superior as Vector3, + }, + [ViewTypes.XY_PLANE]: { + normal: Inferior as Vector3, + viewUp: Anterior as Vector3, + }, + }; + + resliceCursorState.setPlanes(planes); +} + +function useResliceInit( + resliceCursor: vtkResliceCursorWidget, + resliceCursorState: ResliceCursorWidgetState +) { + const { currentImageData, currentImageMetadata } = useCurrentImage(); + + watchEffect(() => { + const image = currentImageData.value; + if (!image) return; + // TODO just do syncRefs on vtkFieldRefs + resliceCursor.setImage(image); + // Reset to default plane values before transforming based on current image-data. + resetReslicePlanes(resliceCursorState, currentImageMetadata.value); + }); +} + +const useResliceCursorStore = defineStore('resliceCursor', () => { + const resliceCursor = vtkResliceCursorWidget.newInstance({ + scaleInPixels: true, + rotationHandlePosition: 0.75, + }) as vtkResliceCursorWidget; + + const widgetState = + resliceCursor.getWidgetState() as ResliceCursorWidgetState; + + useResliceInit(resliceCursor, widgetState); + + return { + resliceCursor, + resliceCursorState: widgetState, + resetReslicePlanes: (imageMetadata: ImageMetadata) => { + resetReslicePlanes(widgetState, imageMetadata); + }, + }; +}); + +export default useResliceCursorStore; From 301932a1a86938d8ead14a7f0be33f6b37801acd Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 29 Feb 2024 15:45:15 -0500 Subject: [PATCH 19/50] feat(ObliqueSliceViewer): slice outlines --- src/components/ObliqueSliceViewer.vue | 49 +++++++++---- .../vtk/VtkBaseObliqueSliceRepresentation.vue | 5 +- .../vtk/VtkImageOutlineRepresentation.vue | 72 +++++++++++++++++++ src/core/vtk/useVtkFilter.ts | 41 +++++++++++ src/shims-vtk.d.ts | 6 ++ 5 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 src/components/vtk/VtkImageOutlineRepresentation.vue create mode 100644 src/core/vtk/useVtkFilter.ts diff --git a/src/components/ObliqueSliceViewer.vue b/src/components/ObliqueSliceViewer.vue index 3ad97a863..ff21b5f13 100644 --- a/src/components/ObliqueSliceViewer.vue +++ b/src/components/ObliqueSliceViewer.vue @@ -26,6 +26,14 @@ :plane-normal="planeNormal" :plane-origin="planeOrigin" > + diff --git a/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue b/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue index 659b97100..d59641c82 100644 --- a/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue +++ b/src/components/vtk/VtkBaseObliqueSliceRepresentation.vue @@ -46,10 +46,7 @@ sliceRep.mapper.setResolveCoincidentTopologyPolygonOffsetParameters(1, 1); const slicePlane = vtkPlane.newInstance(); sliceRep.mapper.setSlicePlane(slicePlane); -// TODO initialize visual properties -// outlineRep.actor.setVisibility(true); -// outlineRep.actor.setLineWidth(4.0); -// outlineRep.actor.setColor(outlineColor); +// initialize visual properties sliceRep.mapper.setSlabType(SlabTypes.MAX); sliceRep.mapper.setSlabThickness(1); diff --git a/src/components/vtk/VtkImageOutlineRepresentation.vue b/src/components/vtk/VtkImageOutlineRepresentation.vue new file mode 100644 index 000000000..ca700ef2f --- /dev/null +++ b/src/components/vtk/VtkImageOutlineRepresentation.vue @@ -0,0 +1,72 @@ + + + diff --git a/src/core/vtk/useVtkFilter.ts b/src/core/vtk/useVtkFilter.ts new file mode 100644 index 000000000..0c50fbe45 --- /dev/null +++ b/src/core/vtk/useVtkFilter.ts @@ -0,0 +1,41 @@ +import { VtkObjectConstructor } from '@/src/core/vtk/types'; +import { vtkFieldRef } from '@/src/core/vtk/vtkFieldRef'; +import { Maybe } from '@/src/types'; +import { vtkAlgorithm, vtkObject } from '@kitware/vtk.js/interfaces'; +import { computedWithControl } from '@vueuse/core'; +import { ComputedRef, MaybeRef, onScopeDispose, unref, watchEffect } from 'vue'; + +export function useVtkFilter( + filterClass: VtkObjectConstructor, + ...inputData: MaybeRef>[] +) { + const filter = filterClass.newInstance(); + const mtime = vtkFieldRef(filter as vtkObject, 'mTime'); + + watchEffect(() => { + inputData + .map((input) => unref(input)) + .forEach((input, port) => { + if (input) filter.setInputData(unref(input), port); + }); + }); + + let cache: Record> = {}; + + const getOutputData = (port = 0) => { + if (!(port in cache)) { + cache[port] = computedWithControl(mtime, () => { + if (!filter.getInputData(port)) return null; + return filter.getOutputData(port) as D; + }); + } + return cache[port] as ComputedRef; + }; + + onScopeDispose(() => { + cache = {}; + filter.delete(); + }); + + return { filter, getOutputData }; +} diff --git a/src/shims-vtk.d.ts b/src/shims-vtk.d.ts index 08fe4347a..a618b3d60 100644 --- a/src/shims-vtk.d.ts +++ b/src/shims-vtk.d.ts @@ -298,3 +298,9 @@ declare module '@kitware/vtk.js/Interaction/Widgets/PiecewiseGaussianWidget' { export default vtkPiecewiseWidget; } + +declare module '@kitware/vtk.js/Filters/Core/Cutter' { + export type vtkCutter = any; + export declare const vtkCutter: any; + export default vtkCutter; +} From 9e6b5d143115c41cfea08f667ff7f084270eef80 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 29 Feb 2024 15:57:01 -0500 Subject: [PATCH 20/50] fix(vtkFieldRef): guard against deleted objects --- src/core/vtk/vtkFieldRef.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/vtk/vtkFieldRef.ts b/src/core/vtk/vtkFieldRef.ts index ac5cbf95d..fb9d40e73 100644 --- a/src/core/vtk/vtkFieldRef.ts +++ b/src/core/vtk/vtkFieldRef.ts @@ -107,6 +107,7 @@ export function vtkFieldRef( }); const onModified = batchForNextTask(() => { + if (unref(obj).isDeleted()) return; triggerRef(ref); }); From 2c4166431e10f70ec871298f6400af1ce5da94da Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 29 Feb 2024 16:13:48 -0500 Subject: [PATCH 21/50] style: remove old TODO --- src/store/reslice-cursor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/store/reslice-cursor.ts b/src/store/reslice-cursor.ts index 5ce098757..f6d01b331 100644 --- a/src/store/reslice-cursor.ts +++ b/src/store/reslice-cursor.ts @@ -42,7 +42,6 @@ function useResliceInit( watchEffect(() => { const image = currentImageData.value; if (!image) return; - // TODO just do syncRefs on vtkFieldRefs resliceCursor.setImage(image); // Reset to default plane values before transforming based on current image-data. resetReslicePlanes(resliceCursorState, currentImageMetadata.value); From 3e9ab503eaa0a3d42703cce49c58112e8ab9bf52 Mon Sep 17 00:00:00 2001 From: Forrest Date: Sun, 17 Mar 2024 00:12:50 -0400 Subject: [PATCH 22/50] feat(vtkFieldRef): support Maybe inputs If the input is null|undefined, then the getter returns undefined and the setter is a no-op. --- src/core/vtk/vtkFieldRef.ts | 41 ++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/core/vtk/vtkFieldRef.ts b/src/core/vtk/vtkFieldRef.ts index fb9d40e73..0597e02fe 100644 --- a/src/core/vtk/vtkFieldRef.ts +++ b/src/core/vtk/vtkFieldRef.ts @@ -3,6 +3,7 @@ import { vtkObject } from '@kitware/vtk.js/interfaces'; import { capitalize } from '@kitware/vtk.js/macros'; import { onPausableVTKEvent } from '@/src/composables/onPausableVTKEvent'; import { batchForNextTask } from '@/src/utils/batchForNextTask'; +import { Maybe } from '@/src/types'; type NonEmptyString = T extends '' ? never : T; @@ -14,7 +15,11 @@ type GettableFields = FilterGetters; type NameToGetter = `get${Capitalize}`; -type GetterReturnType = NameToGetter extends keyof T +type Just = Exclude; + +type GetterReturnType = T extends null | undefined + ? undefined + : NameToGetter extends keyof T ? T[NameToGetter] extends (...args: any[]) => infer R ? R : never @@ -32,22 +37,22 @@ export type GetterSetterFactory = { * @param obj * @param fieldName */ -export function vtkFieldRef>( - obj: MaybeRef, - fieldName: F -): Ref>; +export function vtkFieldRef< + T extends Maybe, + F extends GettableFields> +>(obj: MaybeRef, fieldName: F): Ref>; /** * A customRef wrapper that triggers the ref based on a vtk object modification event. * @param obj * @param factory */ -export function vtkFieldRef( +export function vtkFieldRef, R>( obj: MaybeRef, factory: GetterSetterFactory ): Ref; -export function vtkFieldRef( +export function vtkFieldRef>( obj: MaybeRef, fieldNameOrFactory: string | GetterSetterFactory ): any { @@ -58,15 +63,27 @@ export function vtkFieldRef( const getterName = `get${capitalize(fieldNameOrFactory)}` as keyof T; const setterName = `set${capitalize(fieldNameOrFactory)}` as keyof T; - const _getter = computed(() => unref(obj)[getterName] as () => any); + const _getter = computed( + () => unref(obj)?.[getterName] as (() => any) | undefined + ); const _setter = computed( - () => unref(obj)[setterName] as ((...args: any[]) => boolean) | undefined + () => + unref(obj)?.[setterName] as ((...args: any[]) => boolean) | undefined ); - getter = () => _getter.value(); + const notNull = computed(() => !!unref(obj)); + const hasSetter = computed(() => { + const val = unref(obj); + return val ? setterName in val : false; + }); + + getter = () => _getter.value?.(); setter = (v: any) => { const set = _setter.value; - if (!set) throw new Error(`No setter for field '${fieldNameOrFactory}'`); + if (!notNull.value) return false; + if (!hasSetter.value || !set) + throw new Error(`No setter for field '${fieldNameOrFactory}'`); + // handle certain array setters not accepting an array as input if (Array.isArray(v) && set.length === v.length) { return (set as ArraySetter)(...v); @@ -107,7 +124,7 @@ export function vtkFieldRef( }); const onModified = batchForNextTask(() => { - if (unref(obj).isDeleted()) return; + if (unref(obj)?.isDeleted()) return; triggerRef(ref); }); From 8259f6bd76b4807273ada0eafb9ad2494a3059d7 Mon Sep 17 00:00:00 2001 From: Forrest Date: Sun, 17 Mar 2024 00:26:16 -0400 Subject: [PATCH 23/50] refactor(VolumeSliceViewer): rename to SliceViewer --- src/components/{VolumeSliceViewer.vue => SliceViewer.vue} | 3 +++ 1 file changed, 3 insertions(+) rename src/components/{VolumeSliceViewer.vue => SliceViewer.vue} (96%) diff --git a/src/components/VolumeSliceViewer.vue b/src/components/SliceViewer.vue similarity index 96% rename from src/components/VolumeSliceViewer.vue rename to src/components/SliceViewer.vue index 88bc73ffd..94f613b41 100644 --- a/src/components/VolumeSliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -30,6 +30,9 @@ :view-direction="viewDirection" :view-up="viewUp" > +
+ +
Date: Mon, 18 Mar 2024 19:28:42 -0400 Subject: [PATCH 24/50] feat: migrate crop tool --- src/components/SliceViewer.vue | 5 +- src/components/VolumeViewer.vue | 2 + src/components/tools/crop/Crop2D.vue | 113 ++++++++++------- src/components/tools/crop/Crop3D.vue | 117 +++++++----------- src/components/tools/crop/CropTool.vue | 35 +++--- .../vtk/VtkBaseVolumeRepresentation.vue | 7 ++ src/composables/useCroppingEffect.ts | 20 +++ .../{useCurrentSlice.ts => useSliceInfo.ts} | 23 ++-- 8 files changed, 171 insertions(+), 151 deletions(-) create mode 100644 src/composables/useCroppingEffect.ts rename src/composables/{useCurrentSlice.ts => useSliceInfo.ts} (61%) diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue index 94f613b41..a4306308e 100644 --- a/src/components/SliceViewer.vue +++ b/src/components/SliceViewer.vue @@ -30,9 +30,6 @@ :view-direction="viewDirection" :view-up="viewUp" > -
- -
+ @@ -75,6 +73,7 @@ import VtkSegmentationSliceRepresentation from '@/src/components/vtk/VtkSegmenta import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; +import CropTool from '@/src/components/tools/crop/CropTool.vue'; interface Props extends LayoutViewProps { viewDirection: LPSAxisDir; diff --git a/src/components/VolumeViewer.vue b/src/components/VolumeViewer.vue index ef9b50db1..454df0fcd 100644 --- a/src/components/VolumeViewer.vue +++ b/src/components/VolumeViewer.vue @@ -27,6 +27,7 @@ :view-id="id" :image-id="currentImageID" > + @@ -43,6 +44,7 @@ import { VtkViewApi } from '@/src/types/vtk-types'; import { LayoutViewProps } from '@/src/types'; import VtkBaseVolumeRepresentation from '@/src/components/vtk/VtkBaseVolumeRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; +import CropTool from '@/src/components/tools/crop/CropTool.vue'; interface Props extends LayoutViewProps { viewDirection: LPSAxisDir; diff --git a/src/components/tools/crop/Crop2D.vue b/src/components/tools/crop/Crop2D.vue index aa90aed88..294ca2fdf 100644 --- a/src/components/tools/crop/Crop2D.vue +++ b/src/components/tools/crop/Crop2D.vue @@ -1,14 +1,12 @@ + @@ -74,6 +79,7 @@ import { useSegmentGroupStore } from '@/src/store/segmentGroups'; import VtkLayerSliceRepresentation from '@/src/components/vtk/VtkLayerSliceRepresentation.vue'; import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener'; import CropTool from '@/src/components/tools/crop/CropTool.vue'; +import CrosshairsTool from '@/src/components/tools/crosshairs/CrosshairsTool.vue'; interface Props extends LayoutViewProps { viewDirection: LPSAxisDir; diff --git a/src/components/tools/crosshairs/CrosshairSVG2D.vue b/src/components/tools/crosshairs/CrosshairSVG2D.vue index 04b99f557..0ca172b4d 100644 --- a/src/components/tools/crosshairs/CrosshairSVG2D.vue +++ b/src/components/tools/crosshairs/CrosshairSVG2D.vue @@ -43,9 +43,7 @@ import { useResizeObserver } from '@/src/composables/useResizeObserver'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { ToolContainer } from '@/src/constants'; -import { useViewStore } from '@/src/store/views'; import { worldToSVG } from '@/src/utils/vtk-helpers'; -import vtkLPSView2DProxy from '@/src/vtk/LPSView2DProxy'; import type { Vector3 } from '@kitware/vtk.js/types'; import { PropType, @@ -57,6 +55,7 @@ import { computed, inject, } from 'vue'; +import { VtkViewContext } from '@/src/components/vtk/context'; type SVGPoint = { x: number; @@ -66,23 +65,16 @@ type SVGPoint = { export default defineComponent({ props: { position: Array as PropType>, - viewId: { - type: String, - required: true, - }, }, setup(props) { - const { viewId: viewID, position } = toRefs(props); + const { position } = toRefs(props); const position2D = ref(); - const viewStore = useViewStore(); - - const viewProxy = computed( - () => viewStore.getViewProxy(viewID.value)! - ); + const view = inject(VtkViewContext); + if (!view) throw new Error('No VtkView'); const updatePoints = () => { - const viewRenderer = viewProxy.value.getRenderer(); + const viewRenderer = view.renderer; const pt = unref(position) as Vector3 | undefined; if (pt) { const point2D = worldToSVG(pt, viewRenderer); @@ -95,8 +87,7 @@ export default defineComponent({ } }; - const camera = computed(() => viewProxy.value.getCamera()); - onVTKEvent(camera, 'onModified', updatePoints); + onVTKEvent(view.renderer.getActiveCamera(), 'onModified', updatePoints); watchEffect(updatePoints); diff --git a/src/components/tools/crosshairs/CrosshairsTool.vue b/src/components/tools/crosshairs/CrosshairsTool.vue index 69eb3d9e4..4f1b73778 100644 --- a/src/components/tools/crosshairs/CrosshairsTool.vue +++ b/src/components/tools/crosshairs/CrosshairsTool.vue @@ -1,14 +1,10 @@ @@ -153,7 +161,7 @@ useWebGLWatchdog(vtkView); useViewAnimationListener(vtkView, viewId, viewType); // base image -const { currentImageID, currentLayers, currentImageMetadata } = +const { currentImageID, currentLayers, currentImageMetadata, isImageLoading } = useCurrentImage(); const { slice: currentSlice, range: sliceRange } = useSliceConfig( viewId, diff --git a/src/components/VolumeViewer.vue b/src/components/VolumeViewer.vue index f13ad4e27..d2d5e3b24 100644 --- a/src/components/VolumeViewer.vue +++ b/src/components/VolumeViewer.vue @@ -31,6 +31,14 @@ + +
+
Loading the image
+
+ +
+
+
@@ -67,7 +75,7 @@ useWebGLWatchdog(vtkView); useViewAnimationListener(vtkView, viewId, viewType); // base image -const { currentImageID } = useCurrentImage(); +const { currentImageID, isImageLoading } = useCurrentImage(); From 556f0e29747e3d4059e302a9e9d9f8ec7e0cca00 Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 21 Mar 2024 16:29:57 -0400 Subject: [PATCH 36/50] refactor: separate out manipulators Fix showing incorrect slicing information in the oblique views. --- src/components/ObliqueSliceViewer.vue | 10 +++ src/components/SliceViewer.vue | 15 ++++ src/components/SliceViewerOverlay.vue | 21 ++++-- src/components/vtk/VtkSliceView.vue | 68 +------------------ .../vtk/VtkSliceViewSlicingManipulator.vue | 52 ++++++++++++++ .../vtk/VtkSliceViewWindowManipulator.vue | 65 ++++++++++++++++++ src/composables/useSliceConfigInitializer.ts | 31 ++++----- .../vtk/useMouseRangeManipulatorListener.ts | 4 +- src/core/vtk/useVtkInteractionManipulator.ts | 2 +- 9 files changed, 177 insertions(+), 91 deletions(-) create mode 100644 src/components/vtk/VtkSliceViewSlicingManipulator.vue create mode 100644 src/components/vtk/VtkSliceViewWindowManipulator.vue diff --git a/src/components/ObliqueSliceViewer.vue b/src/components/ObliqueSliceViewer.vue index b6aa53048..275ec508b 100644 --- a/src/components/ObliqueSliceViewer.vue +++ b/src/components/ObliqueSliceViewer.vue @@ -13,6 +13,14 @@ :view-up="viewUp" :slice-range="sliceDomain" > + + + + { if (!currentImageID.value) return []; diff --git a/src/components/SliceViewerOverlay.vue b/src/components/SliceViewerOverlay.vue index a9fd211ed..822d54e95 100644 --- a/src/components/SliceViewerOverlay.vue +++ b/src/components/SliceViewerOverlay.vue @@ -21,11 +21,16 @@ if (!view) throw new Error('No VtkView'); const { top: topLabel, left: leftLabel } = useOrientationLabels(view); -const { slice, range: sliceRange } = useSliceConfig(viewId, imageId); -const { width: windowWidth, level: windowLevel } = useWindowingConfig( - viewId, - imageId -); +const { + config: sliceConfig, + slice, + range: sliceRange, +} = useSliceConfig(viewId, imageId); +const { + config: wlConfig, + width: windowWidth, + level: windowLevel, +} = useWindowingConfig(viewId, imageId);