From 761ad26136eb98dad9ec3f23b9a21ead897a603c Mon Sep 17 00:00:00 2001 From: Tom Tonroe Date: Sat, 5 Jul 2025 19:54:55 +1000 Subject: [PATCH 1/6] feat(PaintTool): add cross-plane synchronization toggle and functionality --- src/components/Settings.vue | 11 +++++ src/components/tools/paint/PaintWidget2D.vue | 8 ++- src/io/state-file/index.ts | 1 + src/io/state-file/schema.ts | 1 + src/store/tools/paint.ts | 51 ++++++++++++++++++-- 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 48c9cd686..0444a902a 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -35,6 +35,14 @@ hide-details > + + @@ -52,6 +60,7 @@ import { useLocalStorage } from '@vueuse/core'; import { useKeyboardShortcutsStore } from '@/src/store/keyboard-shortcuts'; import { useViewCameraStore } from '@/src/store/view-configs/camera'; +import { usePaintToolStore } from '@/src/store/tools/paint'; import DicomWebSettings from './dicom-web/DicomWebSettings.vue'; import ServerSettings from './ServerSettings.vue'; import { DarkTheme, LightTheme, ThemeStorageKey } from '../constants'; @@ -78,6 +87,7 @@ export default defineComponent({ }); const { disableCameraAutoReset } = storeToRefs(useViewCameraStore()); + const { crossPlaneSync } = storeToRefs(usePaintToolStore()); const keyboardStore = useKeyboardShortcutsStore(); const openKeyboardShortcuts = () => { @@ -90,6 +100,7 @@ export default defineComponent({ errorReportingConfigured, openKeyboardShortcuts, disableCameraAutoReset, + crossPlaneSync, }; }, components: { diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index bf26884b8..eb0c7a5c8 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -93,13 +93,17 @@ export default defineComponent({ onVTKEvent(widget, 'onStartInteractionEvent', () => { // StartInteraction cannot occur if origin is null. - const indexPoint = worldPointToIndex(widgetState.getBrush().getOrigin()!); + const origin = widgetState.getBrush().getOrigin()!; + const indexPoint = worldPointToIndex(origin); paintStore.startStroke(indexPoint, viewAxisIndex.value); + paintStore.updateCrossPlaneSlicing(origin); }); onVTKEvent(widget, 'onInteractionEvent', () => { - const indexPoint = worldPointToIndex(widgetState.getBrush().getOrigin()!); + const origin = widgetState.getBrush().getOrigin()!; + const indexPoint = worldPointToIndex(origin); paintStore.placeStrokePoint(indexPoint, viewAxisIndex.value); + paintStore.updateCrossPlaneSlicing(origin); }); onVTKEvent(widget, 'onEndInteractionEvent', () => { diff --git a/src/io/state-file/index.ts b/src/io/state-file/index.ts index 839951019..c448cff43 100644 --- a/src/io/state-file/index.ts +++ b/src/io/state-file/index.ts @@ -36,6 +36,7 @@ export async function serialize() { activeSegmentGroupID: null, activeSegment: null, brushSize: 8, + crossPlaneSync: false, }, crop: {}, current: Tools.WindowLevel, diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 7c81999d2..55f0fb45d 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -371,6 +371,7 @@ const Paint = z.object({ activeSegmentGroupID: z.string().nullable(), activeSegment: z.number().nullish(), brushSize: z.number(), + crossPlaneSync: z.boolean().default(false), labelmapOpacity: z.number().optional(), // labelmapOpacity now ignored. Opacity per segment group via layerColoring store. }); diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 55e7057ba..37a4e942f 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -1,15 +1,18 @@ -import type { Vector2 } from '@kitware/vtk.js/types'; +import type { Vector2, Vector3 } from '@kitware/vtk.js/types'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import type { Manifest, StateFile } from '@/src/io/state-file/schema'; import type { Maybe } from '@/src/types'; import { useImageStatsStore } from '@/src/store/image-stats'; -import { computed, ref } from 'vue'; +import { computed, ref, unref } from 'vue'; import { watchImmediate } from '@vueuse/core'; import { vec3 } from 'gl-matrix'; import { defineStore } from 'pinia'; import { PaintMode } from '@/src/core/tools/paint'; +import { getLPSAxisFromDir } from '@/src/utils/lps'; import { Tools } from './types'; import { useSegmentGroupStore } from '../segmentGroups'; +import useViewSliceStore from '../view-configs/slicing'; +import { useViewStore } from '../views'; const DEFAULT_BRUSH_SIZE = 4; const DEFAULT_THRESHOLD_RANGE: Vector2 = [ @@ -27,9 +30,12 @@ export const usePaintToolStore = defineStore('paint', () => { const strokePoints = ref([]); const isActive = ref(false); const thresholdRange = ref([...DEFAULT_THRESHOLD_RANGE]); + const crossPlaneSync = ref(false); - const { currentImageID, currentImageData } = useCurrentImage(); + const { currentImageID, currentImageData, currentImageMetadata } = useCurrentImage(); const imageStatsStore = useImageStatsStore(); + const viewSliceStore = useViewSliceStore(); + const viewStore = useViewStore(); function getWidgetFactory(this: _This) { return this.$paint.factory; @@ -42,6 +48,16 @@ export const usePaintToolStore = defineStore('paint', () => { return segmentGroupStore.dataIndex[activeSegmentGroupID.value] ?? null; }); + const currentViewIDs = computed(() => { + const imageID = unref(currentImageID); + if (imageID) { + return viewStore.viewIDs.filter( + (viewID) => !!viewSliceStore.getConfig(viewID, imageID) + ); + } + return []; + }); + // --- actions --- // /** @@ -284,12 +300,37 @@ export const usePaintToolStore = defineStore('paint', () => { thresholdRange.value = range; } + function setCrossPlaneSync(enabled: boolean) { + crossPlaneSync.value = enabled; + } + + function updateCrossPlaneSlicing(worldPosition: Vector3) { + if (!crossPlaneSync.value) return; + const imageID = unref(currentImageID); + const metadata = unref(currentImageMetadata); + if (!imageID || !metadata?.lpsOrientation || !metadata?.worldToIndex) return; + const { lpsOrientation, worldToIndex } = metadata; + const indexPos = vec3.create(); + vec3.transformMat4(indexPos, worldPosition, worldToIndex); + currentViewIDs.value.forEach((viewID) => { + const sliceConfig = viewSliceStore.getConfig(viewID, imageID); + if (!sliceConfig) return; + const axis = getLPSAxisFromDir(sliceConfig.axisDirection); + const index = lpsOrientation[axis]; + const slice = Math.round(indexPos[index]); + if (slice !== sliceConfig.slice) { + viewSliceStore.updateConfig(viewID, imageID, { slice }); + } + }); + } + function serialize(state: StateFile) { const { paint } = state.manifest.tools; paint.activeSegmentGroupID = activeSegmentGroupID.value ?? null; paint.brushSize = brushSize.value; paint.activeSegment = activeSegment.value; + paint.crossPlaneSync = crossPlaneSync.value; } function deserialize( @@ -307,6 +348,7 @@ export const usePaintToolStore = defineStore('paint', () => { setActiveSegmentGroup(activeSegmentGroupID.value); setActiveSegment.call(this, paint.activeSegment); } + setCrossPlaneSync(paint.crossPlaneSync ?? false); } return { @@ -317,6 +359,7 @@ export const usePaintToolStore = defineStore('paint', () => { strokePoints, isActive, thresholdRange, + crossPlaneSync, getWidgetFactory, @@ -329,6 +372,8 @@ export const usePaintToolStore = defineStore('paint', () => { setBrushSize, setSliceAxis, setThresholdRange, + setCrossPlaneSync, + updateCrossPlaneSlicing, startStroke, placeStrokePoint, endStroke, From c8313a1caf7b5130eb053fb7f28dc54140c1188b Mon Sep 17 00:00:00 2001 From: Tom Tonroe Date: Sun, 6 Jul 2025 21:42:07 +1000 Subject: [PATCH 2/6] feat(PaintTool): add camera centering for non-active views --- src/components/tools/paint/PaintWidget2D.vue | 4 ++-- src/store/tools/paint.ts | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index eb0c7a5c8..65a9b0350 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -96,14 +96,14 @@ export default defineComponent({ const origin = widgetState.getBrush().getOrigin()!; const indexPoint = worldPointToIndex(origin); paintStore.startStroke(indexPoint, viewAxisIndex.value); - paintStore.updateCrossPlaneSlicing(origin); + paintStore.updateCrossPlaneSlicing(origin, viewId.value); }); onVTKEvent(widget, 'onInteractionEvent', () => { const origin = widgetState.getBrush().getOrigin()!; const indexPoint = worldPointToIndex(origin); paintStore.placeStrokePoint(indexPoint, viewAxisIndex.value); - paintStore.updateCrossPlaneSlicing(origin); + paintStore.updateCrossPlaneSlicing(origin, viewId.value); }); onVTKEvent(widget, 'onEndInteractionEvent', () => { diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 37a4e942f..34af88648 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -13,6 +13,7 @@ import { Tools } from './types'; import { useSegmentGroupStore } from '../segmentGroups'; import useViewSliceStore from '../view-configs/slicing'; import { useViewStore } from '../views'; +import { useViewCameraStore } from '../view-configs/camera'; const DEFAULT_BRUSH_SIZE = 4; const DEFAULT_THRESHOLD_RANGE: Vector2 = [ @@ -36,6 +37,7 @@ export const usePaintToolStore = defineStore('paint', () => { const imageStatsStore = useImageStatsStore(); const viewSliceStore = useViewSliceStore(); const viewStore = useViewStore(); + const viewCameraStore = useViewCameraStore(); function getWidgetFactory(this: _This) { return this.$paint.factory; @@ -304,23 +306,35 @@ export const usePaintToolStore = defineStore('paint', () => { crossPlaneSync.value = enabled; } - function updateCrossPlaneSlicing(worldPosition: Vector3) { + function updateCrossPlaneSlicing(worldPosition: Vector3, activeViewID?: string) { if (!crossPlaneSync.value) return; const imageID = unref(currentImageID); const metadata = unref(currentImageMetadata); if (!imageID || !metadata?.lpsOrientation || !metadata?.worldToIndex) return; + const { lpsOrientation, worldToIndex } = metadata; const indexPos = vec3.create(); vec3.transformMat4(indexPos, worldPosition, worldToIndex); + currentViewIDs.value.forEach((viewID) => { const sliceConfig = viewSliceStore.getConfig(viewID, imageID); if (!sliceConfig) return; + + // Update slice position const axis = getLPSAxisFromDir(sliceConfig.axisDirection); const index = lpsOrientation[axis]; const slice = Math.round(indexPos[index]); if (slice !== sliceConfig.slice) { viewSliceStore.updateConfig(viewID, imageID, { slice }); } + + // Center camera on paint position (skip active view) + if (activeViewID && viewID === activeViewID) { + return; + } + viewCameraStore.updateConfig(viewID, imageID, { + focalPoint: worldPosition, + }); }); } From 91ffc7ffe80ea04147ed140c73488e98f151da3f Mon Sep 17 00:00:00 2001 From: TomTonroe Date: Sun, 27 Jul 2025 20:45:11 +1000 Subject: [PATCH 3/6] refactor(PaintTool): use reactive pattern for cross-plane sync --- src/components/tools/paint/PaintWidget2D.vue | 4 +-- src/store/tools/paint.ts | 32 +++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index 65a9b0350..c2e233835 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -96,14 +96,14 @@ export default defineComponent({ const origin = widgetState.getBrush().getOrigin()!; const indexPoint = worldPointToIndex(origin); paintStore.startStroke(indexPoint, viewAxisIndex.value); - paintStore.updateCrossPlaneSlicing(origin, viewId.value); + paintStore.updatePaintPosition(origin, viewId.value); }); onVTKEvent(widget, 'onInteractionEvent', () => { const origin = widgetState.getBrush().getOrigin()!; const indexPoint = worldPointToIndex(origin); paintStore.placeStrokePoint(indexPoint, viewAxisIndex.value); - paintStore.updateCrossPlaneSlicing(origin, viewId.value); + paintStore.updatePaintPosition(origin, viewId.value); }); onVTKEvent(widget, 'onEndInteractionEvent', () => { diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 34af88648..2cd87b828 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -3,7 +3,7 @@ import { useCurrentImage } from '@/src/composables/useCurrentImage'; import type { Manifest, StateFile } from '@/src/io/state-file/schema'; import type { Maybe } from '@/src/types'; import { useImageStatsStore } from '@/src/store/image-stats'; -import { computed, ref, unref } from 'vue'; +import { computed, ref, unref, watch } from 'vue'; import { watchImmediate } from '@vueuse/core'; import { vec3 } from 'gl-matrix'; import { defineStore } from 'pinia'; @@ -32,8 +32,11 @@ export const usePaintToolStore = defineStore('paint', () => { const isActive = ref(false); const thresholdRange = ref([...DEFAULT_THRESHOLD_RANGE]); const crossPlaneSync = ref(false); + const paintPosition = ref([0, 0, 0]); + const activePaintViewID = ref>(null); - const { currentImageID, currentImageData, currentImageMetadata } = useCurrentImage(); + const { currentImageID, currentImageData, currentImageMetadata } = + useCurrentImage(); const imageStatsStore = useImageStatsStore(); const viewSliceStore = useViewSliceStore(); const viewStore = useViewStore(); @@ -306,20 +309,22 @@ export const usePaintToolStore = defineStore('paint', () => { crossPlaneSync.value = enabled; } - function updateCrossPlaneSlicing(worldPosition: Vector3, activeViewID?: string) { - if (!crossPlaneSync.value) return; + watch([paintPosition, crossPlaneSync], ([worldPosition]) => { + if (!crossPlaneSync.value || !isActive.value) return; + const imageID = unref(currentImageID); const metadata = unref(currentImageMetadata); - if (!imageID || !metadata?.lpsOrientation || !metadata?.worldToIndex) return; - + if (!imageID || !metadata?.lpsOrientation || !metadata?.worldToIndex) + return; + const { lpsOrientation, worldToIndex } = metadata; const indexPos = vec3.create(); vec3.transformMat4(indexPos, worldPosition, worldToIndex); - + currentViewIDs.value.forEach((viewID) => { const sliceConfig = viewSliceStore.getConfig(viewID, imageID); if (!sliceConfig) return; - + // Update slice position const axis = getLPSAxisFromDir(sliceConfig.axisDirection); const index = lpsOrientation[axis]; @@ -327,15 +332,20 @@ export const usePaintToolStore = defineStore('paint', () => { if (slice !== sliceConfig.slice) { viewSliceStore.updateConfig(viewID, imageID, { slice }); } - + // Center camera on paint position (skip active view) - if (activeViewID && viewID === activeViewID) { + if (activePaintViewID.value && viewID === activePaintViewID.value) { return; } viewCameraStore.updateConfig(viewID, imageID, { focalPoint: worldPosition, }); }); + }); + + function updatePaintPosition(worldPosition: Vector3, activeViewID?: string) { + paintPosition.value = worldPosition; + activePaintViewID.value = activeViewID; } function serialize(state: StateFile) { @@ -387,7 +397,7 @@ export const usePaintToolStore = defineStore('paint', () => { setSliceAxis, setThresholdRange, setCrossPlaneSync, - updateCrossPlaneSlicing, + updatePaintPosition, startStroke, placeStrokePoint, endStroke, From 50470447b6383e7ad3efaad13a33d1e4e1217a3a Mon Sep 17 00:00:00 2001 From: Tom Tonroe Date: Fri, 1 Aug 2025 18:27:33 +1000 Subject: [PATCH 4/6] refactor(PaintTool): move crossPlaneSync toggle to paintControls --- src/components/PaintControls.vue | 12 +++++++++++- src/components/Settings.vue | 11 ----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/PaintControls.vue b/src/components/PaintControls.vue index 5a1ec713c..6588fdca6 100644 --- a/src/components/PaintControls.vue +++ b/src/components/PaintControls.vue @@ -49,6 +49,16 @@