diff --git a/src/components/VtkThreeView.vue b/src/components/VtkThreeView.vue index 8e702c4c9..a261c6b6e 100644 --- a/src/components/VtkThreeView.vue +++ b/src/components/VtkThreeView.vue @@ -24,7 +24,7 @@ import { CommonViewProps, useVtkView, useVtkViewCameraOrientation, - giveViewAnnotations, + applyViewAnnotations, } from '@/src/composables/view/common'; import { useResizeObserver } from '@/src/composables/resizeObserver'; import { watchScene, watchColorBy } from '@/src/composables/scene'; @@ -42,9 +42,9 @@ export default { const { sceneSources, - worldOrientation, + imageParams, colorBy, - boundsWithSpacing, + extentWithSpacing, baseImageColorPreset, baseImage, slices, @@ -56,11 +56,11 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - worldOrientation: (state) => state.visualization.worldOrientation, + imageParams: (state) => state.visualization.imageParams, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), - boundsWithSpacing: (_, getters) => - getters['visualization/boundsWithSpacing'], + extentWithSpacing: (_, getters) => + getters['visualization/extentWithSpacing'], baseImageColorPreset: (_, getters) => getters['visualization/baseImageColorPreset'], baseImage(state) { @@ -75,7 +75,7 @@ export default { windowing: (state) => state.visualization.windowing, }); - const spacing = computed(() => worldOrientation.value.spacing); + const spacing = computed(() => imageParams.value.spacing); const viewRef = useVtkView({ containerRef: vtkContainer, @@ -94,7 +94,7 @@ export default { }); // update scene sources and their colors - watchScene(sceneSources, worldOrientation, viewRef); + watchScene(sceneSources, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // prepare view @@ -121,14 +121,14 @@ export default { }); // reset camera whenever bounds changes - watch(boundsWithSpacing, () => { + watch(extentWithSpacing, () => { const view = unref(viewRef); if (view) { view.resetCamera(); } }); - giveViewAnnotations( + applyViewAnnotations( viewRef, reactive({ nw: baseImageColorPreset, diff --git a/src/components/VtkTwoView.vue b/src/components/VtkTwoView.vue index da26da56f..0648fc7c2 100644 --- a/src/components/VtkTwoView.vue +++ b/src/components/VtkTwoView.vue @@ -33,13 +33,14 @@ import { import { CommonViewProps, useVtkView, - useVtkViewCameraOrientation, - giveViewAnnotations, + applyViewAnnotations, } from '@/src/composables/view/common'; import { useOrientationLabels, use2DMouseControls, usePixelProbe, + apply2DCameraPlacement, + useIJKAxisCamera, } from '@/src/composables/view/view2D'; import { useResizeObserver } from '@/src/composables/resizeObserver'; import { watchScene, watchColorBy } from '@/src/composables/scene'; @@ -48,24 +49,84 @@ import { useSubscription } from '@/src/composables/vtk'; import { useWidgetProvider } from '@/src/composables/widgetProvider'; import { useProxyManager } from '@/src/composables/proxyManager'; -import { resize2DCameraToFit } from '@/src/vtk/proxyUtils'; import { DataTypes } from '@/src/constants'; import SliceSlider from '@/src/components/SliceSlider.vue'; +/** + * Sets parallel scale of 2D view camera to fit a given bounds. + * + * Assumes the camera is reset, i.e. focused correctly. + * + * Bounds is specified as width/height of orthographic view. + * Renders must be triggered manually. + */ +function resize2DCameraToFit(view, lookAxis, viewUpAxis, bounds) { + const camera = view.getCamera(); + const lengths = [ + bounds[1] - bounds[0], + bounds[3] - bounds[2], + bounds[5] - bounds[4], + ]; + const [w, h] = view.getOpenglRenderWindow().getSize(); + let bw; + let bh; + /* eslint-disable prefer-destructuring */ + if (lookAxis === 0 && viewUpAxis === 1) { + bw = lengths[2]; + bh = lengths[1]; + } else if (lookAxis === 0 && viewUpAxis === 2) { + bw = lengths[1]; + bh = lengths[2]; + } else if (lookAxis === 1 && viewUpAxis === 0) { + bw = lengths[2]; + bh = lengths[0]; + } else if (lookAxis === 1 && viewUpAxis === 2) { + bw = lengths[0]; + bh = lengths[2]; + } else if (lookAxis === 2 && viewUpAxis === 0) { + bw = lengths[1]; + bh = lengths[0]; + } else if (lookAxis === 2 && viewUpAxis === 1) { + bw = lengths[0]; + bh = lengths[1]; + } + /* eslint-enable prefer-destructuring */ + const viewAspect = w / h; + const boundsAspect = bw / bh; + + let scale = 0; + if (viewAspect >= boundsAspect) { + scale = bh / 2; + } else { + scale = bw / 2 / viewAspect; + } + + camera.setParallelScale(scale); +} + /** * This differs from view.resetCamera() in that we reset the view * to the specified bounds. */ -function resetCamera(viewRef, boundsWithSpacing, resizeToFit) { +function resetCamera(viewRef, lookAxis, viewUpAxis, imageParams, resizeToFit) { const view = unref(viewRef); if (view) { const renderer = view.getRenderer(); renderer.computeVisiblePropBounds(); - renderer.resetCamera(unref(boundsWithSpacing)); + renderer.resetCamera(imageParams.value.bounds); if (unref(resizeToFit)) { - resize2DCameraToFit(view, unref(boundsWithSpacing)); + const { extent, spacing } = imageParams.value; + const extentWithSpacing = extent.map( + (e, i) => e * spacing[Math.floor(i / 2)] + ); + resize2DCameraToFit( + view, + unref(lookAxis), + unref(viewUpAxis), + unref(extentWithSpacing) + ); } } } @@ -80,20 +141,26 @@ export default { }, setup(props) { - const { viewName, viewType, viewUp, axis, orientation } = toRefs(props); + const { viewName, viewType } = toRefs(props); const vtkContainer = ref(null); const resizeToFit = ref(true); + + const { axis, orientation, viewUp, viewUpAxis } = useIJKAxisCamera( + viewType + ); const axisLabel = computed(() => 'xyz'[axis.value]); const store = useStore(); const widgetProvider = useWidgetProvider(); const pxm = useProxyManager(); + // currentSlice: VtkTwoView concerns itself only with IJK coords, so + // currentSlice is expected to be in image coords. const { sceneSources, - worldOrientation, + imageParams, colorBy, - boundsWithSpacing, + extentWithSpacing, baseImage, currentSlice, windowing, @@ -105,11 +172,11 @@ export default { .filter((id) => id in pipelines) .map((id) => pipelines[id].last); }, - worldOrientation: (state) => state.visualization.worldOrientation, + imageParams: (state) => state.visualization.imageParams, colorBy: (state, getters) => getters.sceneObjectIDs.map((id) => state.visualization.colorBy[id]), - boundsWithSpacing: (_, getters) => - getters['visualization/boundsWithSpacing'], + extentWithSpacing: (_, getters) => + getters['visualization/extentWithSpacing'], baseImage(state) { const { pipelines } = state.visualization; const { selectedBaseImage } = state; @@ -137,10 +204,6 @@ export default { }, }); - const currentSliceSpacing = computed( - () => worldOrientation.value.spacing[axis.value] - ); - const viewRef = useVtkView({ containerRef: vtkContainer, viewName, @@ -148,7 +211,14 @@ export default { }); // configure camera orientation - useVtkViewCameraOrientation(viewRef, viewUp, axis, orientation); + apply2DCameraPlacement( + viewRef, + imageParams, + viewUp, + orientation, + axis, + 'image' + ); useResizeObserver(vtkContainer, () => { const view = unref(viewRef); @@ -157,17 +227,19 @@ export default { } }); - watchScene(sceneSources, worldOrientation, viewRef); + watchScene(sceneSources, viewRef); watchColorBy(colorBy, sceneSources, viewRef); // reset camera conditions watch( - [baseImage, boundsWithSpacing], - () => resetCamera(viewRef, boundsWithSpacing, resizeToFit), + [baseImage, extentWithSpacing], + () => resetCamera(viewRef, axis, viewUpAxis, imageParams, resizeToFit), { immediate: true } ); useSubscription(viewRef, (view) => - view.onResize(() => resetCamera(viewRef, boundsWithSpacing, resizeToFit)) + view.onResize(() => + resetCamera(viewRef, axis, viewUpAxis, imageParams, resizeToFit) + ) ); // setup view @@ -193,10 +265,10 @@ export default { default: windowing.value.level, })); const sliceRange = computed(() => { - const { bounds } = unref(worldOrientation); + const { extent } = unref(imageParams); return { - min: bounds[axis.value * 2], - max: bounds[axis.value * 2 + 1], + min: extent[axis.value * 2], + max: extent[axis.value * 2 + 1], step: 1, default: currentSlice.value, }; @@ -231,8 +303,7 @@ export default { if (viewRef.value && baseImage.value) { const rep = pxm.getRepresentation(baseImage.value, viewRef.value); if (rep) { - if (rep.setSlice) - rep.setSlice(currentSlice.value * currentSliceSpacing.value); + if (rep.setSlice) rep.setSlice(currentSlice.value); if (rep.setWindowWidth) rep.setWindowWidth(windowing.value.width); if (rep.setWindowLevel) rep.setWindowLevel(windowing.value.level); } @@ -243,10 +314,7 @@ export default { const { pixelProbe } = usePixelProbe(viewRef, baseImage); // orientation labels - const { leftLabel, upLabel } = useOrientationLabels( - viewRef, - worldOrientation - ); + const { left: leftLabel, top: upLabel } = useOrientationLabels(viewRef); // pixel probe annotation const pixelAnnotation = computed(() => { @@ -285,7 +353,7 @@ export default { : '' ); - giveViewAnnotations( + applyViewAnnotations( viewRef, reactive({ n: upLabel, diff --git a/src/composables/scene.js b/src/composables/scene.js index 54fce9331..b54cf04ba 100644 --- a/src/composables/scene.js +++ b/src/composables/scene.js @@ -5,16 +5,14 @@ import { useProxyManager } from '@/src/composables/proxyManager'; /** * Updates the scene. * @param {Ref} sourcesRef - * @param {Ref} worldOrientationRef * @param {Ref} viewRef */ -export function watchScene(sourcesRef, worldOrientationRef, viewRef) { +export function watchScene(sourcesRef, viewRef) { const pxm = useProxyManager(); function repopulateScene() { const view = unref(viewRef); const sources = unref(sourcesRef); - const worldOrientation = unref(worldOrientationRef); if (view) { view .getRepresentations() @@ -23,16 +21,13 @@ export function watchScene(sourcesRef, worldOrientationRef, viewRef) { sources.forEach((source) => { const rep = pxm.getRepresentation(source, view); if (rep) { - if (rep.setTransform) { - rep.setTransform(worldOrientation.worldToIndex); - } view.addRepresentation(rep); } }); } } - watch([sourcesRef, viewRef, worldOrientationRef], repopulateScene); + watch([sourcesRef, viewRef], repopulateScene); // trigger this after repopulateScene watch(sourcesRef, () => { diff --git a/src/composables/view/common.js b/src/composables/view/common.js index 2fe8c2324..ded879896 100644 --- a/src/composables/view/common.js +++ b/src/composables/view/common.js @@ -132,7 +132,7 @@ export function useVtkViewCameraOrientation( * @param {Reactive<{ [label: string]: Ref }} labels * @param {Reactive<{ [label: string]: string }} defaults */ -export function giveViewAnnotations(viewRef, labels, defaults = {}) { +export function applyViewAnnotations(viewRef, labels, defaults = {}) { const places = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; watchEffect(() => { diff --git a/src/composables/view/view2D.js b/src/composables/view/view2D.js index 503a3967c..9b60b948f 100644 --- a/src/composables/view/view2D.js +++ b/src/composables/view/view2D.js @@ -1,4 +1,4 @@ -import { mat3, vec3 } from 'gl-matrix'; +import { vec3 } from 'gl-matrix'; import { computed, ref, unref, watch, watchEffect } from '@vue/composition-api'; import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; @@ -10,82 +10,81 @@ import { useSubscription } from '@/src/composables/vtk'; import { useProxyManager } from '@/src/composables/proxyManager'; import { useElementListener } from '@/src/composables/domEvents'; import { useViewContainer } from '@/src/composables/view/common'; -import { zip, worldToIndexRotation } from '@/src/utils/common'; +import { useComputedState } from '@/src/composables/store'; +import { indexToWorldRotation, multiComputed } from '@/src/utils/common'; const EPS = 10e-6; // priority of the pixel mouse probe export const PROBE_PRIORITY = WIDGET_PRIORITY + 10; -function lpsDirToLabels(dir) { - const [x, y, z] = dir; - let label = ''; - if (x > EPS) label += 'L'; - else if (x < -EPS) label += 'R'; - if (y > EPS) label += 'P'; - else if (y < -EPS) label += 'A'; - if (z > EPS) label += 'S'; - else if (z < -EPS) label += 'I'; - return label; +// a signed axis has domain [+-1, +-2, +-3]. +// Adding 3, we shift the domain to [0, 1, 2, 4, 5, 6], +// so the the 3rd index is not used. +const LABELS = 'IAR_LPS'; + +function signedAxesToLabels(axes) { + return axes.map((sa) => LABELS[sa + 3]).join(''); +} + +function sortAndOrientAxes(vec) { + return ( + vec + // track each component's signed axis, + // and shift axes to be 1-indexed so we can differentiate between 0 and -0 + .map((component, axis) => [component, Math.sign(component) * (axis + 1)]) + // remove components close to zero + .filter(([component]) => Math.abs(component) > EPS) + // sort components in decreasing order + .sort(([c1], [c2]) => c2 - c1) + // pick out the axes, now sorted by decreasing magnitude + .map(([, signedAxis]) => signedAxis) + ); } /** - * Writes out left and up orientation labels. + * Computes orientation labels for a given view. + * + * Labels are with respect to the current camera orientation. + * + * vtk.js world axis is implied to be LPS, so orientation labels + * are determined solely from the camera' direction and view-up. + * * @param {Ref} viewRef - * @param {Ref} worldOrientation */ -export function useOrientationLabels(viewRef, worldOrientation) { - const leftLabel = ref(''); - const upLabel = ref(''); +export function useOrientationLabels(viewRef) { + const rightAxes = ref([]); + const upAxes = ref([]); + + const top = computed(() => signedAxesToLabels(upAxes.value)); + const right = computed(() => signedAxesToLabels(rightAxes.value)); + const bottom = computed(() => + signedAxesToLabels(upAxes.value.map((a) => -a)) + ); + const left = computed(() => + signedAxesToLabels(rightAxes.value.map((a) => -a)) + ); - function updateLabels() { + function updateAxes() { const view = unref(viewRef); if (view) { const camera = view.getCamera(); - // TODO make modifications only if vup and vdir differ const vup = camera.getViewUp(); const vdir = camera.getDirectionOfProjection(); const vright = [0, 0, 0]; + // assumption: vright and vdir are not equal + // (which should be the case for the camera) vec3.cross(vright, vdir, vup); - // assume direction is orthonormal - const { direction } = unref(worldOrientation); - - // since camera is in "image space", transform into - // image's world space. - const cameraMat = mat3.fromValues(...vright, ...vup, ...vdir); - const imageMat = mat3.fromValues(...direction); - // `direction` is row-major, and gl-matrix is col-major - mat3.transpose(imageMat, imageMat); - const cameraInImWorld = mat3.create(); - mat3.mul(cameraInImWorld, imageMat, cameraMat); - - // gl-matrix is col-major - const left = cameraInImWorld.slice(0, 3).map((c) => -c); - const up = cameraInImWorld.slice(3, 6); - - const leftLabels = lpsDirToLabels(left); - const upLabels = lpsDirToLabels(up); - - // sort by magnitude - leftLabel.value = zip(left.map(Math.abs), leftLabels) - .sort(([a], [b]) => b - a) - .map(([, label]) => label) - .join(''); - upLabel.value = zip(up.map(Math.abs), upLabels) - .sort(([a], [b]) => b - a) - .map(([, label]) => label) - .join(''); + rightAxes.value = sortAndOrientAxes(vright); + upAxes.value = sortAndOrientAxes(vup); } } - useSubscription(viewRef, (view) => view.getCamera().onModified(updateLabels)); - updateLabels(); + useSubscription(viewRef, (view) => view.getCamera().onModified(updateAxes)); + updateAxes(); - return { - leftLabel, - upLabel, - }; + return { top, right, bottom, left }; } export function use2DMouseControls( @@ -176,7 +175,7 @@ export function computePixelAt(plane, probeVec, imageData) { // this is a hack to work around the first slice sometimes being // very close to zero, but not quite, resulting in being unable to // see pixel values for 0th slice. - Math.abs(c) < 1e-8 ? Math.round(c) : c + Math.abs(c) < 1e-4 ? Math.round(c) : c ); const extent = imageData.getExtent(); if ( @@ -261,8 +260,8 @@ export function usePixelProbe(viewRef, baseImage) { // transform from index to world if required if (axis >= 3) { - origin = image.value.indexToWorld(normal); - normal = worldToIndexRotation(image.value, normal); + origin = image.value.indexToWorld(origin); + normal = indexToWorldRotation(image.value, normal); } if ( @@ -305,3 +304,138 @@ export function usePixelProbe(viewRef, baseImage) { pixelProbe, }; } + +// mat3x3 is taken to be column-major +function findClosestFrameVec(mat3x3, axis) { + let closestIndex = 0; + let closestSign = 1; + let closest = -Infinity; + let vector = []; + for (let idx = 0; idx < 3; idx += 1) { + const indexDir = vec3.fromValues( + mat3x3[idx * 3 + 0], + mat3x3[idx * 3 + 1], + mat3x3[idx * 3 + 2] + ); + const cosine = vec3.dot(indexDir, axis); + const sign = Math.sign(cosine); + const howClose = Math.abs(cosine); + if (howClose > closest) { + closest = howClose; + closestIndex = idx; + closestSign = sign; + vector = indexDir; + } + } + + return { + howClose: closest, + vectorIndex: closestIndex, + sign: closestSign, + vector, + }; +} + +const ViewTypeAxis = { + ViewX: [1, 0, 0], + ViewY: [0, -1, 0], + ViewZ: [0, 0, -1], +}; + +export function useIJKAxisCamera(viewType) { + const { direction } = useComputedState({ + direction: (state) => state.visualization.imageParams.direction, + }); + + return multiComputed(() => { + const viewDir = ViewTypeAxis[viewType.value]; + const { vectorIndex: axis, sign: orientation } = findClosestFrameVec( + direction.value, + viewDir + ); + + let lpsViewUp = []; + switch (viewType.value) { + case 'ViewX': + case 'ViewY': { + lpsViewUp = [0, 0, 1]; // superior + break; + } + case 'ViewZ': { + lpsViewUp = [0, -1, 0]; // anterior + break; + } + default: + // noop; + } + + const { vectorIndex: vupIndex, sign: vupSign } = findClosestFrameVec( + direction.value, + lpsViewUp + ); + const viewUp = [0, 0, 0]; + viewUp[vupIndex] = vupSign; + + return { + axis, // 1=I, 2=J, 3=K + orientation, + viewUp, + viewUpAxis: vupIndex, + }; + }); +} + +/** + * Sets the camera based on camera configuration parameters. + * @param {Ref} view + * @param {Ref} imageParams + * @param {Ref} viewUp + * @param {Ref<-1|1>} orientation + * @param {Ref<0|1|2>} axis + * @param {'image'|'world'} frame + */ +export function apply2DCameraPlacement( + view, + imageParams, + viewUp, + orientation, + axis, + frame +) { + function updateCamera() { + // get world bounds center + const { bounds } = imageParams.value; + const center = [ + (bounds[0] + bounds[1]) / 2, + (bounds[2] + bounds[3]) / 2, + (bounds[4] + bounds[5]) / 2, + ]; + + const position = [...center]; + position[axis.value] += orientation.value; + + const dop = [0, 0, 0]; + dop[axis.value] = -orientation.value; + + const vup = [...viewUp.value]; + + if (unref(frame) === 'image') { + const { direction } = imageParams.value; + vec3.transformMat3(dop, dop, direction); + vec3.transformMat3(vup, vup, direction); + } + + const camera = view.value.getCamera(); + camera.setFocalPoint(...center); + camera.setPosition(...position); + camera.setDirectionOfProjection(...dop); + camera.setViewUp(...vup); + + view.value.getRenderer().resetCamera(); + view.value.set({ axis: axis.value }, true); // set the corresponding axis + } + + watch([imageParams, viewUp, orientation, axis], updateCamera, { + immediate: true, + }); +} diff --git a/src/io/dicom.js b/src/io/dicom.js index 13bfedc8b..303d7a949 100644 --- a/src/io/dicom.js +++ b/src/io/dicom.js @@ -1,3 +1,4 @@ +import { mat3 } from 'gl-matrix'; import runPipelineBrowser from 'itk/runPipelineBrowser'; import { readFileAsArrayBuffer } from '@/src/io/io'; import IOTypes from 'itk/IOTypes'; @@ -194,7 +195,11 @@ export default class DicomIO { 10 // building volumes is high priority ); - return result.outputs[0].data; + // TEMPORARY tranpose until itk.js consistently outputs col-major + // and ITKHelper is updated. + const image = result.outputs[0].data; + mat3.transpose(image.direction.data, image.direction.data); + return image; } /** diff --git a/src/store/visualization.js b/src/store/visualization.js index bc994225f..3ebea1271 100644 --- a/src/store/visualization.js +++ b/src/store/visualization.js @@ -7,6 +7,11 @@ import { DEFAULT_PRESET } from '../vtk/ColorMaps'; const { Mode } = LUTConstants; +export const CoordinateSystem = { + Image: 'image', + World: 'world', +}; + export function asInteger(value, defaultValue) { const rv = Math.round(value); if (Number.isInteger(rv)) { @@ -15,15 +20,15 @@ export function asInteger(value, defaultValue) { return defaultValue; } -export const defaultWorldOrientation = () => ({ - // ok for images this is actually just extent, since - // that's how we process images in this application. +export const defaultImageParams = () => ({ bounds: [0, 1, 0, 1, 0, 1], - // world spacing + extent: [0, 1, 0, 1, 0, 1], + dimensions: [1, 1, 1], spacing: [1, 1, 1], + // all matrices are column-major direction: [1, 0, 0, 0, 1, 0, 0, 0, 1], - // identity worldToIndex: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + indexToWorld: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], }); export const defaultWindowing = () => ({ @@ -33,8 +38,10 @@ export const defaultWindowing = () => ({ max: 255, }); -// slicing is done in index space +// For image, XYZ corresponds to image IJK +// For world, XYZ corresponds to LPS in vtk.js export const defaultSlicing = () => ({ + system: CoordinateSystem.Image, x: 0, y: 0, z: 0, @@ -79,7 +86,7 @@ export default (dependencies) => ({ pipelines: {}, slices: defaultSlicing(), resizeToFit: true, - worldOrientation: defaultWorldOrientation(), + imageParams: defaultImageParams(), windowing: defaultWindowing(), colorBy: {}, // id -> { array, location } arrayLutPresets: {}, // arrayName -> LUT preset @@ -94,18 +101,29 @@ export default (dependencies) => ({ }; }, - setWorldOrientation(state, { bounds, spacing, direction, worldToIndex }) { - state.worldOrientation = { + setImageParams( + state, + { bounds, extent, spacing, direction, worldToIndex, indexToWorld } + ) { + state.imageParams = { bounds: [...bounds], + extent: [...extent], + dimensions: [ + extent[1] - extent[0] + 1, + extent[3] - extent[2] + 1, + extent[5] - extent[4] + 1, + ], spacing: [...spacing], direction: [...direction], worldToIndex: [...worldToIndex], + indexToWorld: [...indexToWorld], }; }, setSlices(state, { x, y, z }) { const { slices: s } = state; state.slices = { + system: s.system, x: asInteger(x, s.x), y: asInteger(y, s.y), z: asInteger(z, s.z), @@ -164,9 +182,9 @@ export default (dependencies) => ({ }, getters: { - boundsWithSpacing(state) { - const { spacing, bounds } = state.worldOrientation; - return bounds.map((b, i) => b * spacing[Math.floor(i / 2)]); + extentWithSpacing(state) { + const { spacing, extent } = state.imageParams; + return extent.map((b, i) => b * spacing[Math.floor(i / 2)]); }, baseImagePipeline(state, getters, rootState) { const { selectedBaseImage } = rootState; @@ -185,7 +203,7 @@ export default (dependencies) => ({ actions: { async updateScene({ dispatch }, { reset = false }) { if (reset) { - await dispatch('updateWorldOrientation'); + await dispatch('updateImageParams'); await dispatch('resetWindowing'); await dispatch('resetSlicing'); await dispatch('updateColorBy'); @@ -201,7 +219,7 @@ export default (dependencies) => ({ }, /** - * Should run after updateWorldOrientation + * Should run after updateImageParams */ createPipelinesForScene({ commit, state, rootGetters, rootState }) { const { proxyManager } = dependencies; @@ -219,16 +237,17 @@ export default (dependencies) => ({ } }, - async updateWorldOrientation({ commit, rootState }) { + async updateImageParams({ commit, rootState }) { const { selectedBaseImage, data } = rootState; if (selectedBaseImage !== NO_SELECTION) { const image = data.vtkCache[selectedBaseImage]; - const spacing = image.getSpacing(); - commit('setWorldOrientation', { - bounds: image.getExtent(), - spacing, + commit('setImageParams', { + bounds: image.getBounds(), + extent: image.getExtent(), + spacing: image.getSpacing(), direction: image.getDirection(), worldToIndex: [...image.getWorldToIndex()], + indexToWorld: [...image.getIndexToWorld()], }); } else { // set dimensions to be the max bounds of all layers @@ -240,9 +259,10 @@ export default (dependencies) => ({ } bbox.inflate(5); // some extra padding // Without a base image, we assume a spacing of 1. - commit('setWorldOrientation', { - ...defaultWorldOrientation(), + commit('setImageParams', { + ...defaultImageParams(), bounds: bbox.getBounds(), + extent: bbox.getBounds(), }); } }, @@ -265,23 +285,23 @@ export default (dependencies) => ({ }, /** - * updateWorldOrientation should be invoked prior to this action. + * updateImageParams should be invoked prior to this action. */ async resetSlicing({ commit, state, rootState }) { if (rootState.selectedBaseImage !== NO_SELECTION) { - const { bounds } = state.worldOrientation; + const { extent } = state.imageParams; await commit('setSlices', { - x: bounds[0], - y: bounds[2], - z: bounds[4], + x: extent[0], + y: extent[2], + z: extent[4], }); } else { - // pick middle of bounds - const { bounds } = state.worldOrientation; + // pick middle of extent + const { extent } = state.imageParams; const center = [ - (bounds[0] + bounds[1]) / 2, - (bounds[2] + bounds[3]) / 2, - (bounds[4] + bounds[5]) / 2, + (extent[0] + extent[1]) / 2, + (extent[2] + extent[3]) / 2, + (extent[4] + extent[5]) / 2, ]; await commit('setSlices', { x: center[0], @@ -388,9 +408,6 @@ export default (dependencies) => ({ proxyManager.getViews().forEach((view) => { const rep = proxyManager.getRepresentation(source, view); if (rep) { - if (rep.setTransform) { - rep.setTransform(...state.worldOrientation.worldToIndex); - } rep.getMapper().modified(); } }); diff --git a/src/utils/common.js b/src/utils/common.js index 2a493af00..79fb8b252 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -1,3 +1,4 @@ +import { computed } from '@vue/composition-api'; import { mat4, quat, vec3 } from 'gl-matrix'; export function defer() { @@ -69,16 +70,32 @@ export function unsubscribeVtkList(subs) { } /** - * Rotates vector from image world to index. + * Rotates vector from image index space to world dir. * @param {vtkImageData} imageData * @param {vec3} vec */ -export function worldToIndexRotation(imageData, vec) { - const w2iMat = imageData.getWorldToIndex(); +export function indexToWorldRotation(imageData, vec) { + const i2wMat = imageData.getIndexToWorld(); const rotation = quat.create(); - mat4.getRotation(rotation, w2iMat); + mat4.getRotation(rotation, i2wMat); const out = vec3.create(); vec3.transformQuat(out, vec, rotation); return out; } + +/** + * Unpacks an object returned by vue's computed, and makes each + * individual key a separate ref. + */ +export function multiComputed(fn) { + const obj = computed(fn); + const a = Object.keys(obj.value).reduce( + (acc, key) => ({ + ...acc, + [key]: computed(() => obj.value[key]), + }), + {} + ); + return a; +} diff --git a/src/vtk/TransformedSliceRepresentationProxy/index.js b/src/vtk/TransformedSliceRepresentationProxy/index.js index 54d51e0cb..56c9cfdf5 100644 --- a/src/vtk/TransformedSliceRepresentationProxy/index.js +++ b/src/vtk/TransformedSliceRepresentationProxy/index.js @@ -2,9 +2,6 @@ import macro from 'vtk.js/Sources/macro'; import vtkSliceRepresentationProxy from 'vtk.js/Sources/Proxy/Representations/SliceRepresentationProxy'; -import vtkImageTransformFilter from '../ImageTransformFilter'; -import vtkRepresentationProxyTransformMixin from '../transformMixin'; - function vtkTransformedSliceRepresentationProxy(publicAPI, model) { model.classHierarchy.push('vtkTransformedSliceRepresentationProxy'); @@ -20,6 +17,10 @@ function vtkTransformedSliceRepresentationProxy(publicAPI, model) { publicAPI.setSlicingMode(mode); }; + // restrict to IJK slicing + publicAPI.setSlicingMode = (mode) => + superClass.setSlicingMode('IJK'['XYZIJK'.indexOf(mode) % 3]); + // don't set colors on slices publicAPI.setColorBy = () => {}; } @@ -38,11 +39,6 @@ export function extend(publicAPI, model, initialValues = {}) { // Object methods vtkSliceRepresentationProxy.extend(publicAPI, model); - vtkRepresentationProxyTransformMixin(vtkImageTransformFilter)( - publicAPI, - model - ); - // Object specific methods vtkTransformedSliceRepresentationProxy(publicAPI, model); } diff --git a/src/vtk/View2DProxy/index.js b/src/vtk/View2DProxy/index.js index 51560d21a..d5ee88802 100644 --- a/src/vtk/View2DProxy/index.js +++ b/src/vtk/View2DProxy/index.js @@ -9,6 +9,18 @@ function vtkView2DProxy(publicAPI, model) { // we will set the manipulator ourselves publicAPI.bindRepresentationToManipulator = () => {}; + + // allow setting the axis + publicAPI.setAxis = (axis) => { + if (axis !== model.axis) { + model.axis = axis; + model.representations + .filter((rep) => !!rep.setSlicingMode) + .forEach((rep) => rep.setSlicingMode('XYZ'[axis])); + return true; + } + return false; + }; } export function extend(publicAPI, model, initialValues = {}) { diff --git a/src/vtk/proxyUtils.js b/src/vtk/proxyUtils.js index 4f40c033e..f85213115 100644 --- a/src/vtk/proxyUtils.js +++ b/src/vtk/proxyUtils.js @@ -14,42 +14,3 @@ export function createFourUpViews(proxyManager) { createOrGetView(proxyManager, 'ViewZ', 'Z:1'); createOrGetView(proxyManager, 'View3D', '3D:1'); } - -/** - * Sets parallel scale of 2D view camera to fit a given bounds. - * - * Assumes the camera is reset, i.e. focused correctly. - * - * Bounds is specified as width/height of orthographic view. - * Renders must be triggered manually. - */ -export function resize2DCameraToFit(view, bounds) { - const camera = view.getCamera(); - const axis = view.getAxis(); - const lengths = [ - bounds[1] - bounds[0], - bounds[3] - bounds[2], - bounds[5] - bounds[4], - ]; - const [w, h] = view.getOpenglRenderWindow().getSize(); - let bw; - let bh; - if (axis === 0 || axis === 2) { - bw = lengths[(axis + 1) % 3]; - bh = lengths[(axis + 2) % 3]; - } else { - bw = lengths[(axis + 2) % 3]; - bh = lengths[(axis + 1) % 3]; - } - const viewAspect = w / h; - const boundsAspect = bw / bh; - - let scale = 0; - if (viewAspect >= boundsAspect) { - scale = bh / 2; - } else { - scale = bw / 2 / viewAspect; - } - - camera.setParallelScale(scale); -} diff --git a/src/widgets/paint.js b/src/widgets/paint.js index ccebdaff4..40f882edd 100644 --- a/src/widgets/paint.js +++ b/src/widgets/paint.js @@ -45,8 +45,8 @@ export default class PaintWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; @@ -85,13 +85,13 @@ export default class PaintWidget extends Widget { if (id !== NO_SELECTION) { const { vtkCache } = this.store.state.data; - const { worldOrientation } = this.store.state.visualization; + const { imageParams } = this.store.state.visualization; const { radius, currentLabelFor } = this.store.state.annotations; this.filter = vtkPaintFilter.newInstance(); this.filter.setBackgroundImage(vtkCache[selectedBaseImage]); this.filter.setLabelMap(vtkCache[id]); - this.filter.setMaskWorldToIndex(worldOrientation.worldToIndex); + this.filter.setMaskWorldToIndex(imageParams.worldToIndex); this.filter.setLabel(currentLabelFor[id]); this.onRadiusChange(radius); } else { @@ -116,7 +116,7 @@ export default class PaintWidget extends Widget { const { spacing, worldToIndex, - } = this.store.state.visualization.worldOrientation; + } = this.store.state.visualization.imageParams; const inv = mat4.create(); const pt = vec3.create(); mat4.invert(inv, worldToIndex); diff --git a/src/widgets/ruler.js b/src/widgets/ruler.js index fd1ea8c51..9664b5ab2 100644 --- a/src/widgets/ruler.js +++ b/src/widgets/ruler.js @@ -77,8 +77,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view && this.lockedSlice === null) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0]; diff --git a/src/widgets/slicingCrosshairs.js b/src/widgets/slicingCrosshairs.js index 0a1b84042..4682fd9ee 100644 --- a/src/widgets/slicingCrosshairs.js +++ b/src/widgets/slicingCrosshairs.js @@ -14,10 +14,10 @@ export default class RulerWidget extends Widget { this.factory = vtkCrosshairsWidget.newInstance(); this.state = this.factory.getWidgetState(); - const { bounds, spacing } = this.store.state.visualization.worldOrientation; + const { extent, spacing } = this.store.state.visualization.imageParams; this.state .getHandle() - .setBounds(...bounds.map((b, i) => b * spacing[Math.floor(i / 2)])); + .setBounds(...extent.map((b, i) => b * spacing[Math.floor(i / 2)])); // register after setting handle bounds, so our slices don't get // reset to 0,0,0 @@ -31,7 +31,7 @@ export default class RulerWidget extends Widget { } const origin = this.state.getHandle().getOrigin(); - const { spacing } = this.store.state.visualization.worldOrientation; + const { spacing } = this.store.state.visualization.imageParams; this.store.dispatch('visualization/setSlices', { x: origin[0] / spacing[0], y: origin[1] / spacing[1], @@ -54,8 +54,8 @@ export default class RulerWidget extends Widget { updateManipulator(view) { if (view) { const axis = view.getAxis(); - const { slices, worldOrientation } = this.store.state.visualization; - const { spacing } = worldOrientation; + const { slices, imageParams } = this.store.state.visualization; + const { spacing } = imageParams; const normal = [0, 0, 0]; normal[axis] = 1; const origin = [0, 0, 0];