Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"deep-equal": "^2.2.3",
"dicom-parser": "^1.8.21",
"dicomweb-client-typed": "^0.8.6",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
Expand Down
205 changes: 205 additions & 0 deletions src/components/CineViewer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<template>
<div
class="vtk-container-wrapper"
tabindex="0"
@pointerenter="hover = true"
@pointerleave="hover = false"
@focusin="hover = true"
@focusout="hover = false"
>
<div class="vtk-gutter mt-1">
<v-btn dark icon size="medium" variant="text" @click="resetCamera">
<v-icon size="medium" class="py-1">mdi-camera-flip-outline</v-icon>
<v-tooltip
location="right"
activator="parent"
transition="slide-x-transition"
>
Reset Camera
</v-tooltip>
</v-btn>
<slice-slider
v-model="currentFrame"
class="slice-slider"
:min="frameRange[0]"
:max="frameRange[1]"
:step="1"
:handle-height="20"
/>
</div>
<div class="vtk-container" data-testid="two-view-container">
<v-progress-linear
v-if="isImageLoading"
indeterminate
class="loading-indicator"
height="2"
color="grey"
/>
<div class="vtk-sub-container">
<vtk-slice-view
class="vtk-view"
ref="vtkView"
data-testid="vtk-view vtk-cine-view"
:view-id="viewId"
:image-id="currentImageID"
:view-direction="VIEW_DIRECTION"
:view-up="VIEW_UP"
>
<vtk-mouse-interaction-manipulator
v-if="currentTool === Tools.Pan"
:manipulator-constructor="vtkMouseCameraTrackballPanManipulator"
:manipulator-props="{ button: 1 }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
:manipulator-constructor="vtkMouseCameraTrackballPanManipulator"
:manipulator-props="{ button: 1, shift: true }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
:manipulator-constructor="vtkMouseCameraTrackballPanManipulator"
:manipulator-props="{ button: 2 }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
v-if="currentTool === Tools.Zoom"
:manipulator-constructor="
vtkMouseCameraTrackballZoomToMouseManipulator
"
:manipulator-props="{ button: 1 }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
:manipulator-constructor="
vtkMouseCameraTrackballZoomToMouseManipulator
"
:manipulator-props="{ button: 3 }"
></vtk-mouse-interaction-manipulator>
<vtk-cine-scrub-manipulator
:view-id="viewId"
:image-id="currentImageID"
></vtk-cine-scrub-manipulator>
<vtk-cine-scrub-key-manipulator
:view-id="viewId"
:image-id="currentImageID"
></vtk-cine-scrub-key-manipulator>
<cine-viewer-overlay
:view-id="viewId"
:image-id="currentImageID"
></cine-viewer-overlay>
<vtk-base-slice-representation
ref="baseSliceRep"
:view-id="viewId"
:image-id="currentImageID"
:axis="VIEW_AXIS"
:frame="currentFrame"
></vtk-base-slice-representation>
<polygon-tool
:view-id="viewId"
:image-id="currentImageID"
:view-direction="VIEW_DIRECTION"
/>
<ruler-tool
:view-id="viewId"
:image-id="currentImageID"
:view-direction="VIEW_DIRECTION"
/>
<rectangle-tool
:view-id="viewId"
:image-id="currentImageID"
:view-direction="VIEW_DIRECTION"
/>
<select-tool />
<svg class="overlay-no-events">
<bounding-rectangle :points="selectionPoints" />
</svg>
<slot></slot>
</vtk-slice-view>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, toRefs, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import VtkSliceView from '@/src/components/vtk/VtkSliceView.vue';
import { VtkViewApi } from '@/src/types/vtk-types';
import { Tools } from '@/src/store/tools/types';
import VtkBaseSliceRepresentation from '@/src/components/vtk/VtkBaseSliceRepresentation.vue';
import { useViewAnimationListener } from '@/src/composables/useViewAnimationListener';
import PolygonTool from '@/src/components/tools/polygon/PolygonTool.vue';
import RulerTool from '@/src/components/tools/ruler/RulerTool.vue';
import RectangleTool from '@/src/components/tools/rectangle/RectangleTool.vue';
import SelectTool from '@/src/components/tools/SelectTool.vue';
import BoundingRectangle from '@/src/components/tools/BoundingRectangle.vue';
import SliceSlider from '@/src/components/SliceSlider.vue';
import CineViewerOverlay from '@/src/components/CineViewerOverlay.vue';
import { useToolSelectionStore } from '@/src/store/tools/toolSelection';
import { useAnnotationToolStore, useToolStore } from '@/src/store/tools';
import { useWebGLWatchdog } from '@/src/composables/useWebGLWatchdog';
import { useCineFrame } from '@/src/composables/useCineFrame';
import VtkCineScrubManipulator from '@/src/components/vtk/VtkCineScrubManipulator.vue';
import VtkCineScrubKeyManipulator from '@/src/components/vtk/VtkCineScrubKeyManipulator.vue';
import VtkMouseInteractionManipulator from '@/src/components/vtk/VtkMouseInteractionManipulator.vue';
import vtkMouseCameraTrackballPanManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballPanManipulator';
import vtkMouseCameraTrackballZoomToMouseManipulator from '@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballZoomToMouseManipulator';
import { useResetViewsEvents } from '@/src/components/tools/ResetViews.vue';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { get2DViewingVectors } from '@/src/utils/getViewingVectors';
import type { LPSAxis } from '@/src/types/lps';

type Props = {
viewId: string;
};

const VIEW_AXIS: LPSAxis = 'Axial';
const { viewDirection: VIEW_DIRECTION, viewUp: VIEW_UP } =
get2DViewingVectors(VIEW_AXIS);

const vtkView = ref<VtkViewApi>();
const baseSliceRep = ref();

const props = defineProps<Props>();
const { viewId } = toRefs(props);

const { currentImageID, currentImageData, isImageLoading } = useCurrentImage();

const hover = ref(false);

function resetCamera() {
vtkView.value?.resetCamera();
}

useResetViewsEvents().onClick(resetCamera);

useWebGLWatchdog(vtkView);
useViewAnimationListener(vtkView, viewId, '2D');

const { currentTool } = storeToRefs(useToolStore());

const { frame: currentFrame, frameRange } = useCineFrame(
viewId,
currentImageID
);

onVTKEvent(currentImageData, 'onModified', () => {
vtkView.value?.requestRender();
});

const selectionStore = useToolSelectionStore();
const selectionPoints = computed(() => {
return selectionStore.selection
.map((sel) => {
const store = useAnnotationToolStore(sel.type);
return { store, tool: store.toolByID[sel.id] };
})
.filter(
({ tool }) =>
tool.imageID === currentImageID.value &&
tool.frame === currentFrame.value &&
!tool.hidden
)
.flatMap(({ store, tool }) => store.getPoints(tool.id));
});
</script>

<style scoped src="@/src/components/styles/vtk-view.css"></style>
<style scoped src="@/src/components/styles/utils.css"></style>
52 changes: 52 additions & 0 deletions src/components/CineViewerOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { toRefs, computed } from 'vue';
import ViewOverlayGrid from '@/src/components/ViewOverlayGrid.vue';
import { Maybe } from '@/src/types';
import { useCineFrame } from '@/src/composables/useCineFrame';
import DicomQuickInfoButton from '@/src/components/DicomQuickInfoButton.vue';
import { useImage } from '@/src/composables/useCurrentImage';
import PlayControls from '@/src/components/PlayControls.vue';

type Props = {
viewId: string;
imageId: Maybe<string>;
};

const props = defineProps<Props>();
const { viewId, imageId } = toRefs(props);

const { metadata } = useImage(imageId);
const { frame, frameRange } = useCineFrame(viewId, imageId);
const frameCount = computed(() => frameRange.value[1] + 1);
</script>

<template>
<view-overlay-grid class="overlay-no-events view-annotations">
<template v-slot:top-left>
<div class="annotation-cell">
<span>{{ metadata.name }}</span>
</div>
</template>
<template v-slot:bottom-left>
<div class="annotation-cell">
<div>
<span class="frame-label">
Frame: {{ frame + 1 }} / {{ frameCount }}
</span>
</div>
</div>
</template>
<template v-slot:top-right>
<div class="annotation-cell">
<dicom-quick-info-button :image-id="imageId"></dicom-quick-info-button>
</div>
</template>
<template #bottom-right>
<div class="annotation-cell" @click.stop>
<play-controls :view-id="viewId" :image-id="imageId" />
</div>
</template>
</view-overlay-grid>
</template>

<style scoped src="@/src/components/styles/vtk-view.css"></style>
38 changes: 29 additions & 9 deletions src/components/ControlsStripTools.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
icon="mdi-crosshairs"
:name="`Crosshairs [${nameToShortcut['Crosshairs']}]`"
:buttonClass="['tool-btn', active ? 'tool-btn-selected' : '']"
:disabled="noCurrentImage || isObliqueLayout"
:disabled="
noCurrentImage ||
isObliqueLayout ||
isDisallowedOnCine(Tools.Crosshairs)
"
@click="toggle"
/>
</groupable-item>
Expand All @@ -64,7 +68,9 @@
icon="mdi-brush"
:name="`Paint [${nameToShortcut['Paint']}]`"
:buttonClass="['tool-btn', active ? 'tool-btn-selected' : '']"
:disabled="noCurrentImage || isObliqueLayout"
:disabled="
noCurrentImage || isObliqueLayout || isDisallowedOnCine(Tools.Paint)
"
@click="toggle"
></control-button>
</groupable-item>
Expand Down Expand Up @@ -114,7 +120,9 @@
icon="mdi-crop"
:name="`Crop [${nameToShortcut['Crop']}]`"
:active="active"
:disabled="noCurrentImage || isObliqueLayout"
:disabled="
noCurrentImage || isObliqueLayout || isDisallowedOnCine(Tools.Crop)
"
@click="toggle"
>
<crop-controls />
Expand All @@ -132,7 +140,9 @@ import { Tools } from '@/src/store/tools/types';
import ControlButton from '@/src/components/ControlButton.vue';
import ItemGroup from '@/src/components/ItemGroup.vue';
import GroupableItem from '@/src/components/GroupableItem.vue';
import { useToolStore } from '@/src/store/tools';
import { useToolStore, isToolAllowedFor } from '@/src/store/tools';
import { useEffectiveView } from '@/src/composables/useEffectiveView';
import { toRef } from 'vue';
import MenuControlButton from '@/src/components/MenuControlButton.vue';
import CropControls from '@/src/components/tools/crop/CropControls.vue';
import ResetViews from '@/src/components/tools/ResetViews.vue';
Expand Down Expand Up @@ -164,11 +174,20 @@ export default defineComponent({
const { currentImageID } = useCurrentImage();
const noCurrentImage = computed(() => !currentImageID.value);
const currentTool = computed(() => toolStore.currentTool);
const isObliqueLayout = computed(() => {
if (!viewStore.activeView) return false;
const view = viewStore.viewByID[viewStore.activeView];
return view.type === 'Oblique';
});

const activeViewRef = toRef(viewStore, 'activeView');
const activeEffective = useEffectiveView(
computed(() => activeViewRef.value ?? '')
);
// The rendered viewer is decided by effective kind, not stored slot type:
// a cine clip dropped into an Oblique slot still renders as cine, so the
// toolbar should treat it as cine, not Oblique.
const isObliqueLayout = computed(
() => activeEffective.value?.kind === 'oblique'
);
const isCineActive = computed(() => activeEffective.value?.kind === 'cine');
const isDisallowedOnCine = (tool: Tools) =>
isCineActive.value && !isToolAllowedFor(tool, activeEffective.value);

const paintMenu = ref(false);
const cropMenu = ref(false);
Expand Down Expand Up @@ -211,6 +230,7 @@ export default defineComponent({
setCurrentTool: toolStore.setCurrentTool,
noCurrentImage,
isObliqueLayout,
isDisallowedOnCine,
Tools,
paintMenu,
cropMenu,
Expand Down
Loading
Loading